Interface one to many (#533)

* Extract edit modal to standalone component

* Fix creating new item from m2o edit modal

* Rename item-modal to modal-detail

* Extract selection modal in standalone component

* Add required primary-key prop to v-form

* Add inline prop to table

* Fetch items in o2m

* Accept numbers for primary key in v-form

* Use correct collection in render template in m2o

* Render modal detail

* Fix edit existing

* Add add-new

* Do things

* Finish o2m
This commit is contained in:
Rijk van Zanten
2020-05-07 10:53:51 -04:00
committed by GitHub
parent b4fdf96900
commit 0c17735e0e
25 changed files with 1003 additions and 212 deletions

View File

@@ -92,6 +92,7 @@
:type="field.type"
:collection="field.collection"
:field="field.field"
:primary-key="primaryKey"
@input="setValue(field, $event)"
/>
</div>
@@ -146,6 +147,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
primaryKey: {
type: [String, Number],
required: true,
},
},
setup(props, { emit }) {
const el = ref<Element>(null);

View File

@@ -8,6 +8,7 @@ export type HeaderRaw = {
align?: Alignment;
sortable?: boolean;
width?: number | null;
[key: string]: any;
};
export type Header = Required<HeaderRaw>;

View File

@@ -1,5 +1,5 @@
<template>
<div class="v-table" :class="{ loading }">
<div class="v-table" :class="{ loading, inline }">
<table
:summary="_headers.map((header) => header.text).join(', ')"
:style="{
@@ -163,6 +163,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
inline: {
type: Boolean,
default: false,
},
},
setup(props, { emit, listeners, slots }) {
const _headers = computed({
@@ -382,6 +386,10 @@ body {
grid-template-columns: var(--grid-columns);
}
.loading-indicator {
position: relative;
}
td,
th {
color: var(--foreground-normal);
@@ -448,4 +456,9 @@ body {
}
}
}
.inline {
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
}
</style>

View File

@@ -14,6 +14,7 @@ import InterfaceDateTime from './datetime';
import InterfaceImage from './image';
import InterfaceIcon from './icon';
import InterfaceManyToOne from './many-to-one';
import InterfaceOneToMany from './one-to-many';
export const interfaces = [
InterfaceTextInput,
@@ -32,6 +33,7 @@ export const interfaces = [
InterfaceImage,
InterfaceIcon,
InterfaceManyToOne,
InterfaceOneToMany,
];
export default interfaces;

View File

@@ -18,7 +18,7 @@
<template #input v-if="currentItem">
<div class="preview">
<render-template
:collection="collection"
:collection="relatedCollection.collection"
:item="currentItem"
:template="displayTemplate"
/>
@@ -31,7 +31,7 @@
name="open_in_new"
class="edit"
v-tooltip="$t('edit')"
@click.stop="startEditing"
@click.stop="editModalActive = true"
/>
<v-icon
name="close"
@@ -45,7 +45,7 @@
class="add"
name="add"
v-tooltip="$t('add_new_item')"
@click.stop="startEditing"
@click.stop="editModalActive = true"
/>
<v-icon class="expand" :class="{ active }" name="expand_more" />
</template>
@@ -65,8 +65,8 @@
<template v-else>
<v-list-item
v-for="item in items"
:key="item[primaryKeyField.field]"
:active="value === item[primaryKeyField.field]"
:key="item[relatedPrimaryKeyField.field]"
:active="value === item[relatedPrimaryKeyField.field]"
@click="setCurrent(item)"
>
<v-list-item-content>
@@ -81,49 +81,33 @@
</v-list>
</v-menu>
<v-modal
v-model="editModalActive"
:title="$t('editing_in', { collection: relatedCollection.name })"
persistent
>
<v-form
:loading="editLoading"
:initial-values="existingItem"
:collection="relatedCollection.collection"
v-model="edits"
/>
<modal-detail
:active.sync="editModalActive"
:collection="relatedCollection.collection"
:primary-key="currentPrimaryKey"
:edits="edits"
@input="stageEdits"
/>
<template #footer>
<v-button @click="cancelEditing" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="stopEditing">{{ $t('save') }}</v-button>
</template>
</v-modal>
<v-modal v-model="selectModalActive" :title="$t('select_item')" no-padding>
<layout-tabular
class="layout"
:collection="relatedCollection.collection"
:selection="selection"
@update:selection="onSelect"
select-mode
/>
<template #footer>
<v-button @click="cancelSelecting" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="stopSelecting">{{ $t('save') }}</v-button>
</template>
</v-modal>
<modal-browse
:active.sync="selectModalActive"
:collection="relatedCollection.collection"
:selection="selection"
@input="stageSelection"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref, toRefs, watch, PropType } from '@vue/composition-api';
import { defineComponent, computed, ref, toRefs, watch, PropType, Ref } from '@vue/composition-api';
import { useRelationsStore } from '@/stores/relations';
import useCollection from '@/composables/use-collection';
import getFieldsFromTemplate from '@/utils/get-fields-from-template';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import useCollectionsStore from '@/stores/collections';
import ModalDetail from '@/views/private/components/modal-detail';
import ModalBrowse from '@/views/private/components/modal-browse';
/**
* @NOTE
@@ -134,6 +118,7 @@ import useCollectionsStore from '@/stores/collections';
*/
export default defineComponent({
components: { ModalDetail, ModalBrowse },
props: {
value: {
type: [Number, String, Object],
@@ -163,61 +148,45 @@ export default defineComponent({
const relationsStore = useRelationsStore();
const collectionsStore = useCollectionsStore();
const { relation, relatedCollection } = useRelation();
const { relation, relatedCollection, relatedPrimaryKeyField } = useRelation();
const { usesMenu, menuActive } = useMenu();
const { info: collectionInfo, primaryKeyField } = useCollection(collection);
const { info: collectionInfo } = useCollection(collection);
const { selection, stageSelection, selectModalActive } = useSelection();
const { displayTemplate, onPreviewClick, requiredFields } = usePreview();
const { totalCount, loading: itemsLoading, fetchItems, items } = useItems();
const { setCurrent, currentItem, loading: loadingCurrent } = useCurrent();
const {
edits,
editModalActive,
startEditing,
stopEditing,
loading: editLoading,
error: editError,
existingItem,
cancelEditing,
} = useEdit();
const {
startSelecting,
stopSelecting,
cancelSelecting,
active: selectModalActive,
selection,
onSelect,
} = useSelectionModal();
setCurrent,
currentItem,
loading: loadingCurrent,
currentPrimaryKey,
} = useCurrent();
const { edits, stageEdits } = useEdits();
const editModalActive = ref(false);
return {
cancelEditing,
cancelSelecting,
collectionInfo,
currentItem,
displayTemplate,
editError,
editLoading,
editModalActive,
edits,
existingItem,
items,
itemsLoading,
loadingCurrent,
menuActive,
onPreviewClick,
primaryKeyField,
relatedCollection,
relation,
selection,
selectModalActive,
setCurrent,
startEditing,
startSelecting,
stopEditing,
stopSelecting,
totalCount,
onSelect,
stageSelection,
useMenu,
currentPrimaryKey,
edits,
stageEdits,
editModalActive,
};
function useCurrent() {
@@ -233,7 +202,7 @@ export default defineComponent({
if (
newValue !== null &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
newValue !== currentItem.value?.[primaryKeyField.value!.field] &&
newValue !== currentItem.value?.[relatedPrimaryKeyField.value!.field] &&
(typeof newValue === 'string' || typeof newValue === 'number')
) {
fetchCurrent();
@@ -248,11 +217,29 @@ export default defineComponent({
}
);
return { setCurrent, currentItem, loading };
const currentPrimaryKey = computed<string | number>(() => {
if (!currentItem.value) return '+';
if (!props.value) return '+';
if (typeof props.value === 'number' || typeof props.value === 'string') {
return props.value;
}
if (
typeof props.value === 'object' &&
props.value.hasOwnProperty(relatedPrimaryKeyField.value.field)
) {
return props.value[relatedPrimaryKeyField.value.field];
}
return '+';
});
return { setCurrent, currentItem, loading, currentPrimaryKey };
function setCurrent(item: Record<string, any>) {
currentItem.value = item;
emit('input', item[primaryKeyField.value.field]);
emit('input', item[relatedPrimaryKeyField.value.field]);
}
async function fetchCurrent() {
@@ -261,8 +248,8 @@ export default defineComponent({
const fields = requiredFields.value || [];
if (fields.includes(primaryKeyField.value.field) === false) {
fields.push(primaryKeyField.value.field);
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
fields.push(relatedPrimaryKeyField.value.field);
}
try {
@@ -306,8 +293,8 @@ export default defineComponent({
const fields = requiredFields.value || [];
if (fields.includes(primaryKeyField.value.field) === false) {
fields.push(primaryKeyField.value.field);
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
fields.push(relatedPrimaryKeyField.value.field);
}
try {
@@ -345,97 +332,6 @@ export default defineComponent({
}
}
function useEdit() {
const loading = ref(false);
const error = ref(null);
const existingItem = ref<any>(null);
const edits = ref<any>(null);
const editModalActive = ref(false);
return {
edits,
editModalActive,
startEditing,
stopEditing,
loading,
error,
existingItem,
cancelEditing,
};
async function startEditing() {
editModalActive.value = true;
loading.value = true;
// If the current value is an object, it's the previously created changes to the item
if (props.value && typeof props.value === 'object') {
edits.value = props.value;
}
// if not, it's the primary key of the existing item (fresh load). It's important for
// us to stage the existing ID back up in the object of edits, otherwise the API will
// treat the edits as a creation of a new item instead of editing the values of an
// existing one
else if (
props.value &&
(typeof props.value === 'number' || typeof props.value === 'string')
) {
edits.value = {
[primaryKeyField.value.field]: props.value,
};
}
// If the current item has a primary key, it means that it's an existing item we're
// about to edit. In that case, we want to fetch the whole existing item, so we can
// render the full form inline
if (currentItem.value?.hasOwnProperty(primaryKeyField.value.field)) {
loading.value = true;
const { currentProjectKey } = projectsStore.state;
try {
const response = await api.get(
`/${currentProjectKey}/items/${relatedCollection.value.collection}/${
currentItem.value[primaryKeyField.value.field]
}`
);
existingItem.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
// When the current item doesn't have a primary key, it means it's new. In that case
// we don't have to bother fetching anything, as all the edits are already stored
// in current item
else {
loading.value = false;
}
}
function cancelEditing() {
editModalActive.value = false;
error.value = null;
loading.value = false;
existingItem.value = null;
edits.value = null;
}
function stopEditing() {
emit('input', edits.value);
// Merging the previously fetched existing current item makes sure we don't remove
// any fields in the preview that wasn't edited, but still used in the preview
currentItem.value = {
...currentItem.value,
...edits.value,
};
cancelEditing();
}
}
function useRelation() {
const relation = computed(() => {
return relationsStore.getRelationsForField(props.collection, props.field)?.[0];
@@ -446,7 +342,12 @@ export default defineComponent({
return collectionsStore.getCollection(relation.value.collection_one);
});
return { relation, relatedCollection };
const { collection } = toRefs(relatedCollection.value);
const { primaryKeyField: relatedPrimaryKeyField } = useCollection(
collection as Ref<string>
);
return { relation, relatedCollection, relatedPrimaryKeyField };
}
function useMenu() {
@@ -482,52 +383,78 @@ export default defineComponent({
menuActive.value = newActive;
if (newActive === true) fetchItems();
} else {
startSelecting();
selectModalActive.value = true;
}
}
}
function useSelectionModal() {
const active = ref(false);
const selection = ref<any[]>([]);
function useSelection() {
const selectModalActive = ref(false);
return { active, selection, onSelect, startSelecting, stopSelecting, cancelSelecting };
const selection = computed<(number | string)[]>(() => {
if (!props.value) return [];
function onSelect(newSelection: any[]) {
if (newSelection.length > 0) {
selection.value = [newSelection[newSelection.length - 1]];
} else {
selection.value = [];
if (
typeof props.value === 'object' &&
props.value.hasOwnProperty(relatedPrimaryKeyField.value.field)
) {
return [props.value[relatedPrimaryKeyField.value.field]];
}
}
function startSelecting() {
active.value = true;
if (props.value) {
if (
typeof props.value === 'object' &&
props.value.hasOwnProperty(primaryKeyField.value.field)
) {
selection.value = [props.value[primaryKeyField.value.field]];
} else if (typeof props.value === 'string' || typeof props.value === 'number') {
selection.value = [props.value];
}
if (typeof props.value === 'string' || typeof props.value === 'number') {
return [props.value];
}
}
function stopSelecting() {
if (!selection.value[0]) {
return [];
});
return { selection, stageSelection, selectModalActive };
function stageSelection(newSelection: (number | string)[]) {
if (newSelection.length === 0) {
emit('input', null);
} else {
emit('input', selection.value[0]);
emit('input', newSelection[0]);
}
cancelSelecting();
}
}
function cancelSelecting() {
active.value = false;
selection.value = [];
function useEdits() {
const edits = computed(() => {
// If the current value isn't a primitive, it means we've already staged some changes
// This ensures we continue on those changes instead of starting over
if (props.value && typeof props.value === 'object') {
return props.value;
}
return {};
});
return { edits, stageEdits };
function stageEdits(newEdits: Record<string, any>) {
// Make sure we stage the primary key if it exists. This is needed to have the API
// update the existing item instead of create a new one
if (currentPrimaryKey.value && currentPrimaryKey.value !== '+') {
emit('input', {
[relatedPrimaryKeyField.value.field]: currentPrimaryKey.value,
...newEdits,
});
} else {
if (
newEdits.hasOwnProperty(relatedPrimaryKeyField.value.field) &&
newEdits[relatedPrimaryKeyField.value.field] === '+'
) {
delete newEdits[relatedPrimaryKeyField.value.field];
}
emit('input', newEdits);
}
currentItem.value = {
...currentItem.value,
...newEdits,
};
}
}
},
@@ -572,8 +499,4 @@ export default defineComponent({
.deselect:hover {
--v-icon-color: var(--danger);
}
.layout {
--layout-offset-top: 0px;
}
</style>

View File

@@ -0,0 +1,10 @@
import { defineInterface } from '../define';
import InterfaceOneToMany from './one-to-many.vue';
export default defineInterface(({ i18n }) => ({
id: 'one-to-many',
name: i18n.t('one-to-many'),
icon: 'arrow_right_alt',
component: InterfaceOneToMany,
options: [],
}));

View File

@@ -0,0 +1,538 @@
<template>
<v-notice warning v-if="!relation">
{{ $t('relationship_not_setup') }}
</v-notice>
<div class="one-to-many" v-else>
<v-table
:loading="currentLoading"
:items="currentItems"
:headers.sync="tableHeaders"
show-resize
inline
@click:row="editItem"
>
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
<render-display
:key="header.value"
:value="item[header.value]"
:display="header.field.display"
:options="header.field.displayOptions"
:interface="header.field.interface"
:interface-options="header.field.interfaceOptions"
/>
</template>
<template #item-append="{ item }">
<v-icon
name="close"
v-tooltip="$t('deselect')"
class="deselect"
@click.stop="deselect(item)"
/>
</template>
</v-table>
<div class="actions">
<v-button class="new" @click="currentlyEditing = '+'">{{ $t('add_new') }}</v-button>
<v-button class="existing" @click="selectModalActive = true">
{{ $t('add_existing') }}
</v-button>
</div>
<modal-detail
:active="currentlyEditing !== null"
:collection="relatedCollection.collection"
:primary-key="currentlyEditing || '+'"
:edits="editsAtStart"
@input="stageEdits"
@update:active="cancelEdit"
/>
<modal-browse
:active.sync="selectModalActive"
:collection="relatedCollection.collection"
:selection="[]"
:filters="selectionFilters"
@input="stageSelection"
multiple
/>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, watch, toRefs, Ref } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import useRelationsStore from '@/stores/relations';
import useCollection from '@/composables/use-collection';
import useCollectionsStore from '@/stores/collections';
import useFieldsStore from '@/stores/fields';
import ModalDetail from '@/views/private/components/modal-detail';
import ModalBrowse from '@/views/private/components/modal-browse';
import { Filter } from '@/stores/collection-presets/types';
import { Header } from '@/components/v-table/types';
export default defineComponent({
components: { ModalDetail, ModalBrowse },
props: {
value: {
type: Array,
default: undefined,
},
primaryKey: {
type: [Number, String],
required: true,
},
collection: {
type: String,
required: true,
},
field: {
type: String,
required: true,
},
fields: {
type: String,
required: true,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
const relationsStore = useRelationsStore();
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const { relation, relatedCollection, relatedPrimaryKeyField } = useRelation();
const { loading: currentLoading, items: currentItems } = useCurrent();
const { tableHeaders } = useTable();
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdits();
const { stageSelection, selectModalActive, selectionFilters } = useSelection();
return {
currentLoading,
currentItems,
relation,
tableHeaders,
currentlyEditing,
editItem,
relatedCollection,
editsAtStart,
stageEdits,
cancelEdit,
stageSelection,
selectModalActive,
selectionFilters,
deselect,
};
/**
* Holds info about the current relationship, like related collection, primary key field
* of the other collection etc
*/
function useRelation() {
const relation = computed(() => {
return relationsStore.getRelationsForField(props.collection, props.field)?.[0];
});
const relatedCollection = computed(() => {
if (!relation.value) return null;
return collectionsStore.getCollection(relation.value.collection_many);
});
const { collection } = toRefs(relatedCollection.value);
const { primaryKeyField: relatedPrimaryKeyField } = useCollection(
collection as Ref<string>
);
return { relation, relatedCollection, relatedPrimaryKeyField };
}
/**
* Manages the current display value (the rows in the table)
* This listens to changes in props.value to make sure we always display the correct info
* in the table itself
*/
function useCurrent() {
const loading = ref(false);
const items = ref<any[]>([]);
const error = ref(null);
// This is the primary key of the parent form, not the related items
// By watching the primary key prop for this, it'll load the items fresh on load, but
// also when we navigate from edit form to another edit form.
watch(
() => props.primaryKey,
(newKey) => {
if (newKey !== null && newKey !== '+' && Array.isArray(props.value) !== true) {
fetchCurrent();
}
}
);
// The value can either be null (no changes), or an array of primary key / object with changes
watch(
() => props.value,
(newValue) => {
// When the value is null, there aren't any changes. It does not mean that all
// related items are deselected
if (newValue === null) {
fetchCurrent();
}
if (Array.isArray(newValue)) {
mergeWithItems(newValue);
}
}
);
return { loading, items, error, fetchCurrent };
/**
* Fetch all related items based on the primary key of the current field. This is only
* run on first load (or when the parent form primary key changes)
*/
async function fetchCurrent() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
let fields = props.fields.split(',');
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
fields.push(relatedPrimaryKeyField.value.field);
}
// We're fetching these fields nested on the current item, so nest them in the current
// field-key
fields = fields.map((fieldKey) => `${props.field}.${fieldKey}`);
try {
const response = await api.get(
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`,
{
params: {
fields: fields,
},
}
);
items.value = response.data.data[props.field];
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
/**
* Merges all changes / newly selected items with the current value array, so we can
* display the most up to date information in the table. This will merge edits with the
* existing items, and fetch the full item info when the item is newly selected (as it
* will only have a pk in the array of changes)
*/
async function mergeWithItems(changes: any[]) {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
const pkField = relatedPrimaryKeyField.value.field;
const updatedItems = items.value
.map((item: any) => {
const changeForThisItem = changes.find(
(change) => change[pkField] === item[pkField]
);
if (changeForThisItem) {
return {
...item,
...changeForThisItem,
};
}
return item;
})
.filter((item) => item.hasOwnProperty(pkField))
.filter((item) => item[relation.value.field_many] !== null);
const newlyAddedItems = changes.filter(
(change) =>
typeof change !== 'string' &&
typeof change !== 'number' &&
change.hasOwnProperty(pkField) === false
);
const selectedPrimaryKeys = changes
.filter((change) => typeof change === 'string' || typeof change === 'number')
.filter((primaryKey) => {
const isAlsoUpdate = updatedItems.some(
(update) => update[pkField] === primaryKey
);
return isAlsoUpdate === false;
});
let selectedItems: any[] = [];
if (selectedPrimaryKeys.length > 0) {
const fields = props.fields.split(',');
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
fields.push(relatedPrimaryKeyField.value.field);
}
const response = await api.get(
`/${currentProjectKey}/items/${
relatedCollection.value.collection
}/${selectedPrimaryKeys.join(',')}`,
{
params: {
fields: fields,
},
}
);
if (Array.isArray(response.data.data)) {
selectedItems = response.data.data;
} else {
selectedItems = [response.data.data];
}
}
items.value = [...updatedItems, ...newlyAddedItems, ...selectedItems];
loading.value = false;
}
}
function useTable() {
// Using a ref for the table headers here means that the table itself can update the
// values if it needs to. This allows the user to manually resize the columns for example
const tableHeaders = ref<Header[]>([]);
// Seeing we don't care about saving those tableHeaders, we can reset it whenever the
// fields prop changes (most likely when we're navigating to a different o2m context)
watch(
() => props.fields,
() => {
tableHeaders.value = props.fields
.split(',')
.map((fieldKey) => {
const field = fieldsStore.getField(
relatedCollection.value.collection,
fieldKey
);
if (!field) return null;
const header: Header = {
text: field.name,
value: fieldKey,
align: 'left',
sortable: true,
width: null,
field: {
display: field.display,
displayOptions: field.display_options,
interface: field.interface,
interfaceOptions: field.options,
},
};
return header;
})
.filter((h) => h) as Header[];
}
);
return { tableHeaders };
}
function useEdits() {
// Primary key of the item we're currently editing. If null, the edit modal should be
// closed
const currentlyEditing = ref<string | number>(null);
// This keeps track of the starting values so we can match with it
const editsAtStart = ref({});
return { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit };
function editItem(item: any) {
const primaryKey = item[relatedPrimaryKeyField.value.field];
// When the currently staged value is an array, we know we made changes / added / removed
// certain items. In that case, we have to extract the previously made edits so we can
// keep moving forwards with those
if (props.value && Array.isArray(props.value)) {
const existingEdits = props.value.find((existingChange) => {
const existingPK = existingChange[relatedPrimaryKeyField.value.field];
if (!existingPK) return item === existingChange;
return existingPK === primaryKey;
});
if (existingEdits) {
editsAtStart.value = existingEdits;
}
}
// Make sure the edits have the primary key included, otherwise the api will create
// the item as a new one instead of update the existing
if (
primaryKey &&
editsAtStart.value.hasOwnProperty(relatedPrimaryKeyField.value.field) === false
) {
editsAtStart.value = {
...editsAtStart.value,
[relatedPrimaryKeyField.value.field]: primaryKey,
};
}
currentlyEditing.value = primaryKey;
}
function stageEdits(edits: any) {
const pkField = relatedPrimaryKeyField.value.field;
const hasPrimaryKey = edits.hasOwnProperty(pkField);
if (props.value && Array.isArray(props.value)) {
const newValue = props.value.map((existingChange) => {
if (
existingChange[pkField] &&
edits[pkField] &&
existingChange[pkField] === edits[pkField]
) {
return edits;
}
if (existingChange === edits[pkField]) {
return edits;
}
if (editsAtStart.value === existingChange) {
return edits;
}
return existingChange;
});
if (hasPrimaryKey === false && newValue.includes(edits) === false) {
newValue.push(edits);
}
emit('input', newValue);
} else {
emit('input', [edits]);
}
}
function cancelEdit() {
editsAtStart.value = {};
currentlyEditing.value = null;
}
}
function useSelection() {
const selectModalActive = ref(false);
const selectedPrimaryKeys = computed<(number | string)[]>(() => {
if (!currentItems.value) return [];
const pkField = relatedPrimaryKeyField.value.field;
return currentItems.value
.filter((currentItem) => currentItem.hasOwnProperty(pkField))
.map((currentItem) => currentItem[pkField]);
});
const selectionFilters = computed<Filter[]>(() => {
const filter: Filter = {
key: 'selection',
field: relatedPrimaryKeyField.value.field,
operator: 'nin',
value: selectedPrimaryKeys.value.join(','),
};
return [filter];
});
return { stageSelection, selectModalActive, selectionFilters };
function stageSelection(newSelection: (number | string)[]) {
if (props.value && Array.isArray(props.value)) {
emit('input', [...props.value, ...newSelection]);
} else {
emit('input', newSelection);
}
}
}
function deselect(item: any) {
const pkField = relatedPrimaryKeyField.value.field;
const itemPrimaryKey = item[pkField];
if (itemPrimaryKey) {
if (props.value && Array.isArray(props.value)) {
const itemHasEdits =
props.value.find((stagedItem) => stagedItem[pkField] === itemPrimaryKey) !==
undefined;
if (itemHasEdits) {
emit(
'input',
props.value.map((stagedValue) => {
if (stagedValue[pkField] === itemPrimaryKey) {
return {
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
};
}
return stagedValue;
})
);
} else {
emit('input', [
...props.value,
{
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
},
]);
}
} else {
emit('input', [
{
[pkField]: itemPrimaryKey,
[relation.value.field_many]: null,
},
]);
}
} else {
// If the edited item doesn't have a primary key, it's new. In that case, filtering
// it out of props.value should be enough to remove it
emit(
'input',
props.value.filter((stagedValue) => stagedValue !== item)
);
}
}
},
});
</script>
<style lang="scss" scoped>
.actions {
margin-top: 12px;
}
.existing {
margin-left: 12px;
}
.deselect {
--v-icon-color: var(--foreground-subdued);
&:hover {
--v-icon-color: var(--danger);
}
}
</style>

View File

@@ -138,6 +138,8 @@
"save_and_stay": "Save and Stay",
"save_as_copy": "Save as Copy",
"add_existing": "Add Existing",
"comments": "Comments",
"select_item": "Select Item",

View File

@@ -14,7 +14,12 @@
<activity-navigation />
</template>
<v-form collection="directus_activity" :loading="loading" :initial-values="item" />
<v-form
collection="directus_activity"
:loading="loading"
:initial-values="item"
:primary-key="primaryKey"
/>
</private-view>
</template>

View File

@@ -138,6 +138,7 @@
:initial-values="item"
:collection="collection"
:batch-mode="isBatch"
:primary-key="primaryKey"
v-model="edits"
/>

View File

@@ -97,6 +97,7 @@
:initial-values="item"
collection="directus_files"
:batch-mode="isBatch"
:primary-key="primaryKey"
v-model="edits"
/>
</div>

View File

@@ -2,7 +2,7 @@
<v-tab-item value="advanced">
<h2 class="title" v-if="isNew">{{ $t('advanced_options_title') }}</h2>
<v-form :initial-values="existingField" v-model="_edits" :fields="fields" />
<v-form :initial-values="existingField" v-model="_edits" :fields="fields" primary-key="+" />
</v-tab-item>
</template>

View File

@@ -10,6 +10,7 @@
Array.isArray(selectedDisplay.options)
"
:fields="selectedDisplay.options"
primary-key="+"
v-model="_options"
/>
</transition-expand>

View File

@@ -10,6 +10,7 @@
Array.isArray(selectedInterface.options)
"
:fields="selectedInterface.options"
primary-key="+"
v-model="_options"
/>
</transition-expand>

View File

@@ -67,6 +67,7 @@
:loading="loading"
:initial-values="item"
:batch-mode="isBatch"
:primary-key="collection"
v-model="edits"
/>
</div>

View File

@@ -17,7 +17,12 @@
</template>
<div class="settings">
<v-form :initial-values="initialValues" v-model="edits" :fields="fields" />
<v-form
:initial-values="initialValues"
v-model="edits"
:fields="fields"
:primary-key="1"
/>
</div>
</private-view>
</template>

View File

@@ -54,6 +54,7 @@
:fields="fields"
:loading="loading"
:initial-values="initialValues"
:primary-key="id"
v-model="edits"
/>

View File

@@ -67,6 +67,7 @@
:initial-values="item"
collection="directus_webhooks"
:batch-mode="isBatch"
:primary-key="primaryKey"
v-model="edits"
/>

View File

@@ -67,6 +67,7 @@
:initial-values="item"
collection="directus_users"
:batch-mode="isBatch"
:primary-key="primaryKey"
v-model="edits"
/>

View File

@@ -65,7 +65,11 @@ export const useFieldsStore = createStore({
* between settings and regular collections.
*/
const settingsResponse = await api.get(`/${currentProjectKey}/settings/fields`);
const settingsResponse = await api.get(`/${currentProjectKey}/settings/fields`, {
params: {
limit: -1,
},
});
fields.push(...settingsResponse.data.data);
/**

View File

@@ -0,0 +1,4 @@
import ModalBrowse from './modal-browse.vue';
export { ModalBrowse };
export default ModalBrowse;

View File

@@ -0,0 +1,121 @@
<template>
<v-modal v-model="_active" :title="$t('select_item')" no-padding>
<layout-tabular
class="layout"
:collection="collection"
:selection="_selection"
:filters="filters"
@update:selection="onSelect"
select-mode
/>
<template #footer>
<v-button @click="cancel" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="save">{{ $t('save') }}</v-button>
</template>
</v-modal>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, computed } from '@vue/composition-api';
import { Filter } from '@/stores/collection-presets/types';
export default defineComponent({
props: {
active: {
type: Boolean,
default: false,
},
selection: {
type: Array as PropType<(number | string)[]>,
default: () => [],
},
collection: {
type: String,
required: true,
},
multiple: {
type: Boolean,
default: false,
},
filters: {
type: Array as PropType<Filter[]>,
default: () => [],
},
},
setup(props, { emit }) {
const { save, cancel } = useActions();
const { _active } = useActiveState();
const { _selection, onSelect } = useSelection();
return { save, cancel, _active, _selection, onSelect };
function useActiveState() {
const localActive = ref(false);
const _active = computed({
get() {
return props.active === undefined ? localActive.value : props.active;
},
set(newActive: boolean) {
localActive.value = newActive;
emit('update:active', newActive);
},
});
return { _active };
}
function useSelection() {
const localSelection = ref<(string | number)[]>(null);
const _selection = computed({
get() {
if (localSelection.value === null) {
return props.selection;
}
return localSelection.value;
},
set(newSelection: (string | number)[]) {
localSelection.value = newSelection;
},
});
return { _selection, onSelect };
function onSelect(newSelection: (string | number)[]) {
if (newSelection.length === 0) {
localSelection.value = [];
return;
}
if (props.multiple === true) {
localSelection.value = newSelection;
} else {
localSelection.value = [newSelection[newSelection.length - 1]];
}
}
}
function useActions() {
return { save, cancel };
function save() {
emit('input', _selection.value);
_active.value = false;
}
function cancel() {
_active.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.layout {
--layout-offset-top: 0px;
}
</style>

View File

@@ -0,0 +1,4 @@
import ModalDetail from './modal-detail.vue';
export { ModalDetail };
export default ModalDetail;

View File

@@ -0,0 +1,144 @@
<template>
<v-modal v-model="_active" :title="$t('editing_in', { collection })" persistent>
<v-form
:loading="loading"
:initial-values="item"
:collection="collection"
:primary-key="primaryKey"
v-model="_edits"
/>
<template #footer>
<v-button @click="cancel" secondary>{{ $t('cancel') }}</v-button>
<v-button @click="save">{{ $t('save') }}</v-button>
</template>
</v-modal>
</template>
<script lang="ts">
import { defineComponent, ref, computed, PropType, watch } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
export default defineComponent({
model: {
prop: 'edits',
},
props: {
active: {
type: Boolean,
default: false,
},
collection: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
required: true,
},
edits: {
type: Object as PropType<Record<string, any>>,
default: undefined,
},
},
setup(props, { emit }) {
const projectsStore = useProjectsStore();
const { _active } = useActiveState();
const { _edits, loading, error, item } = useItem();
const { save, cancel } = useActions();
return { _active, _edits, loading, error, item, save, cancel };
function useActiveState() {
const localActive = ref(false);
const _active = computed({
get() {
return props.active === undefined ? localActive.value : props.active;
},
set(newActive: boolean) {
localActive.value = newActive;
emit('update:active', newActive);
},
});
return { _active };
}
function useItem() {
const localEdits = ref<Record<string, any>>({});
const _edits = computed<Record<string, any>>({
get() {
if (props.edits !== undefined) {
return {
...props.edits,
...localEdits.value,
};
}
return localEdits.value;
},
set(newEdits) {
localEdits.value = newEdits;
},
});
const loading = ref(false);
const error = ref(null);
const item = ref<Record<string, any>>(null);
watch(
() => props.active,
(isActive) => {
if (isActive === true) {
if (props.primaryKey !== '+') fetchItem();
} else {
loading.value = false;
error.value = null;
item.value = null;
localEdits.value = {};
}
}
);
return { _edits, loading, error, item, fetchItem };
async function fetchItem() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
try {
const response = await api.get(
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`
);
item.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
function useActions() {
return { save, cancel };
function save() {
emit('input', _edits.value);
_active.value = false;
_edits.value = {};
}
function cancel() {
_active.value = false;
_edits.value = {};
}
}
},
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<value-null v-if="value === null || value === undefined" />
<span v-else-if="!displayInfo">{{ value }}</span>
<span v-else-if="displayInfo === null">{{ value }}</span>
<span v-else-if="typeof displayInfo.handler === 'function'">
{{ display.handler(value, options) }}
</span>
@@ -15,7 +15,7 @@
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, computed } from '@vue/composition-api';
import displays from '@/displays';
import ValueNull from '@/views/private/components/value-null';
@@ -44,7 +44,9 @@ export default defineComponent({
},
},
setup(props) {
const displayInfo = displays.find((display) => display.id === props.display) || null;
const displayInfo = computed(
() => displays.find((display) => display.id === props.display) || null
);
return { displayInfo };
},
});