Files
directus/app/src/interfaces/m2a-builder/m2a-builder.vue
Pascal Jufer acd41eb0be Syntax fixes (#5367)
* Declare return types on functions

And a very few other type related minor fixes

* Minor syntax fixes

* Remove unnecessary escape chars in regexes
* Remove unnecessary awaits
* Replace deprecated req.connection with req.socket
* Replace deprecated upload with uploadOne
* Remove unnecessary eslint-disable-next-line comments
* Comment empty functions / catch or finally clauses
* Fix irregular whitespaces
* Add missing returns (null)
* Remove unreachable code
* A few logical fixes
* Remove / Handle non-null assertions which are certainly unnecessary (e.g. in
tests)
2021-04-29 12:11:43 -04:00

711 lines
21 KiB
Vue

<template>
<div class="m2a-builder">
<div v-if="previewLoading && !previewValues" class="loader">
<v-skeleton-loader v-for="n in (value || []).length" :key="n" />
</div>
<v-list v-else>
<v-notice v-if="previewValues.length === 0">
{{ $t('no_items') }}
</v-notice>
<draggable
:force-fallback="true"
:value="previewValues"
@input="onSort"
:set-data="hideDragImage"
:disabled="!o2mRelation.sort_field"
>
<template v-for="item of previewValues">
<v-list-item
:key="item.$index"
v-if="allowedCollections.includes(item[anyRelation.one_collection_field])"
block
:dense="previewValues.length > 4"
@click="editExisting((value || [])[item.$index])"
>
<v-icon class="drag-handle" left name="drag_handle" @click.stop v-if="o2mRelation.sort_field" />
<span class="collection">{{ collections[item[anyRelation.one_collection_field]].name }}:</span>
<span
v-if="
typeof item[anyRelation.many_field] === 'number' || typeof item[anyRelation.many_field] === 'string'
"
>
{{ item[anyRelation.many_field] }}
</span>
<render-template
v-else
:collection="item[anyRelation.one_collection_field]"
:template="templates[item[anyRelation.one_collection_field]]"
:item="item[anyRelation.many_field]"
/>
<div class="spacer" />
<v-icon
v-if="!disabled"
class="clear-icon"
name="clear"
@click.stop="deselect((value || [])[item.$index])"
/>
</v-list-item>
<v-list-item v-else :key="item.$index" block>
<v-icon class="invalid-icon" name="warning" left />
<span>{{ $t('invalid_item') }}</span>
<div class="spacer" />
<v-icon
v-if="!disabled"
class="clear-icon"
name="clear"
@click.stop="deselect((value || [])[item.$index])"
/>
</v-list-item>
</template>
</draggable>
</v-list>
<div class="buttons">
<v-menu show-arrow>
<template #activator="{ toggle }">
<v-button @click="toggle">
{{ $t('create_new') }}
<v-icon name="arrow_drop_down" right />
</v-button>
</template>
<v-list>
<v-list-item
@click="createNew(collection.collection)"
v-for="collection of collections"
:key="collection.collection"
>
<v-list-item-icon><v-icon :name="collection.icon" /></v-list-item-icon>
<v-text-overflow :text="collection.name" />
</v-list-item>
</v-list>
</v-menu>
<v-menu show-arrow>
<template #activator="{ toggle }">
<v-button @click="toggle" class="existing">
{{ $t('add_existing') }}
<v-icon name="arrow_drop_down" right />
</v-button>
</template>
<v-list>
<v-list-item
@click="selectingFrom = collection.collection"
v-for="collection of collections"
:key="collection.collection"
>
<v-list-item-icon><v-icon :name="collection.icon" /></v-list-item-icon>
<v-text-overflow :text="collection.name" />
</v-list-item>
</v-list>
</v-menu>
</div>
<drawer-collection
multiple
v-if="!disabled && !!selectingFrom"
:active="!!selectingFrom"
:collection="selectingFrom"
:selection="[]"
@input="stageSelection"
@update:active="selectingFrom = null"
/>
<drawer-item
v-if="!disabled"
:active="!!currentlyEditing"
:collection="o2mRelation.many_collection"
:primary-key="currentlyEditing || '+'"
:related-primary-key="relatedPrimaryKey || '+'"
:junction-field="o2mRelation.junction_field"
:edits="editsAtStart"
:circular-field="o2mRelation.many_field"
@input="stageEdits"
@update:active="cancelEdit"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType, ref, watch } from '@vue/composition-api';
import { useRelationsStore, useCollectionsStore, useFieldsStore } from '@/stores';
import { Relation, Collection } from '@/types/';
import DrawerCollection from '@/views/private/components/drawer-collection/';
import DrawerItem from '@/views/private/components/drawer-item/';
import api from '@/api';
import { unexpectedError } from '@/utils/unexpected-error';
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
import { isPlainObject, cloneDeep } from 'lodash';
import { getEndpoint } from '@/utils/get-endpoint';
import { hideDragImage } from '@/utils/hide-drag-image';
import Draggable from 'vuedraggable';
export default defineComponent({
components: { DrawerCollection, DrawerItem, Draggable },
props: {
collection: {
type: String,
required: true,
},
field: {
type: String,
required: true,
},
value: {
type: Array as PropType<any[]>,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
primaryKey: {
type: [String, Number] as PropType<string | number>,
required: true,
},
},
setup(props, { emit }) {
const relationsStore = useRelationsStore();
const fieldsStore = useFieldsStore();
const collectionsStore = useCollectionsStore();
const { o2mRelation, anyRelation, allowedCollections } = useRelations();
const { fetchValues, previewValues, loading: previewLoading, junctionRowMap, relatedItemValues } = useValues();
const { collections, templates, primaryKeys } = useCollections();
const { selectingFrom, stageSelection, deselect } = useSelection();
const {
currentlyEditing,
relatedPrimaryKey,
editsAtStart,
stageEdits,
cancelEdit,
editExisting,
createNew,
} = useEdits();
const { onSort } = useManualSort();
watch(props, fetchValues, { immediate: true, deep: true });
return {
previewValues,
collections,
selectingFrom,
stageSelection,
templates,
o2mRelation,
anyRelation,
currentlyEditing,
relatedPrimaryKey,
editsAtStart,
stageEdits,
cancelEdit,
editExisting,
createNew,
previewLoading,
deselect,
relatedItemValues,
hideDragImage,
onSort,
allowedCollections,
};
function useRelations() {
const relationsForField = computed<Relation[]>(() => {
return relationsStore.getRelationsForField(props.collection, props.field);
});
const o2mRelation = computed(() => relationsForField.value.find((relation) => relation.one_collection !== null)!);
const anyRelation = computed(() => relationsForField.value.find((relation) => relation.one_collection === null)!);
const allowedCollections = computed(() => anyRelation.value.one_allowed_collections!);
return { relationsForField, o2mRelation, anyRelation, allowedCollections };
}
function useCollections() {
const collections = computed<Record<string, Collection>>(() => {
const collections: Record<string, Collection> = {};
const collectionInfo = allowedCollections.value
.map((collection: string) => collectionsStore.getCollection(collection))
.filter((c) => c) as Collection[];
for (const collection of collectionInfo) {
collections[collection.collection] = collection;
}
return collections;
});
const primaryKeys = computed(() => {
const keys: Record<string, string> = {};
for (const collection of Object.values(collections.value)) {
keys[collection.collection] = fieldsStore.getPrimaryKeyFieldForCollection(collection.collection).field!;
}
return keys;
});
const templates = computed(() => {
const templates: Record<string, string> = {};
for (const collection of Object.values(collections.value)) {
const primaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(collection.collection);
templates[collection.collection] = collection.meta?.display_template || `{{${primaryKeyField.field}}}`;
}
return templates;
});
return { collections, primaryKeys, templates };
}
function useValues() {
const loading = ref(false);
const relatedItemValues = ref<Record<string, any[]>>({});
// Holds "expanded" junction rows so we can lookup what "raw" junction row ID in props.value goes with
// what related item for pre-saved-unchanged-items
const junctionRowMap = ref<any[]>();
const previewValues = computed(() => {
// Need to wait until junctionRowMap got properly populated
if (junctionRowMap.value === undefined) {
return [];
}
// Convert all string/number junction rows into junction row records from the map so we can inject the
// related values
const values = cloneDeep(props.value || [])
.map((val, index) => {
const junctionKey = isPlainObject(val) ? val[o2mRelation.value.many_primary] : val;
const savedValues = (junctionRowMap.value || []).find(
(junctionRow) => junctionRow[o2mRelation.value.many_primary] === junctionKey
);
if (isPlainObject(val)) {
return {
...savedValues,
...val,
$index: index,
};
} else {
if (savedValues === undefined) {
return null;
}
return {
...savedValues,
$index: index,
};
}
})
.filter((val) => val)
.map((val) => {
// Find and nest the related item values for use in the preview
const collection = val[anyRelation.value.one_collection_field!];
const key = isPlainObject(val[anyRelation.value.many_field])
? val[anyRelation.value.many_field][primaryKeys.value[collection]]
: val[anyRelation.value.many_field];
const item = relatedItemValues.value[collection]?.find(
(item) => item[primaryKeys.value[collection]] == key
);
// When this item is created new and it has a uuid / auto increment id, there's no key to lookup
if (key && item) {
if (isPlainObject(val[anyRelation.value.many_field])) {
val[anyRelation.value.many_field] = {
...item,
...val[anyRelation.value.many_field],
};
} else {
val[anyRelation.value.many_field] = cloneDeep(item);
}
}
return val;
});
if (o2mRelation.value?.sort_field) {
return [
...values
.filter((val) => val.hasOwnProperty(o2mRelation.value.sort_field!))
.sort((a, b) => a[o2mRelation.value.sort_field!] - b[o2mRelation.value.sort_field!]), // sort by sort field if it exists
...values
.filter((val) => !val.hasOwnProperty(o2mRelation.value.sort_field!))
.sort((a, b) => a.$index - b.$index), // sort the rest with $index
];
} else {
return [...values.sort((a, b) => a.$index - b.$index)];
}
});
return {
fetchValues,
previewValues,
loading,
junctionRowMap,
relatedItemValues,
};
async function fetchValues() {
if (props.value === null) return;
loading.value = true;
try {
// When we only know the ID of the junction row, we'll have to retrieve those rows to get to the related
// item primary key
const junctionRowsToInspect: (string | number)[] = [];
// We want to fetch the minimal data needed to render the preview rows from the source collections
// These will be the IDs per related collection in the m2a that have to be read
const itemsToFetchPerCollection: Record<string, (string | number)[]> = {};
for (const collection of Object.values(collections.value)) {
itemsToFetchPerCollection[collection.collection] = [];
}
// Reminder: props.value holds junction table rows/ids
for (const stagedValue of props.value || []) {
// If the staged value is a primitive string or number, it's the ID of the junction row
// In that case, we have to fetch the row in order to get the info we need on the related item
if (typeof stagedValue === 'string' || typeof stagedValue === 'number') {
junctionRowsToInspect.push(stagedValue);
}
// There's a case where you sort with no other changes where the one_collection_field doesn't exist
// and there's no further changes nested in the many field
else if (anyRelation.value.one_collection_field! in stagedValue === false) {
junctionRowsToInspect.push(stagedValue[o2mRelation.value.many_primary]);
}
// Otherwise, it's an object with the edits on an existing item, or a newly added item
// In both cases, it'll have the "one_collection_field" set. Both theoretically can have a primary key
// though the primary key could be a newly created one
else {
const relatedCollection = stagedValue[anyRelation.value.one_collection_field!];
const relatedCollectionPrimaryKey = primaryKeys.value[relatedCollection];
// stagedValue could contain the primary key as a primitive in many_field or nested as primaryKeyField
// in an object
const relatedKey = isPlainObject(stagedValue[anyRelation.value.many_field])
? stagedValue[anyRelation.value.many_field][relatedCollectionPrimaryKey]
: stagedValue[anyRelation.value.many_field];
// Could be that the key doesn't exist (add new item without manual primary key)
if (relatedKey) {
itemsToFetchPerCollection[relatedCollection].push(relatedKey);
}
}
}
// If there's junction row IDs, we'll have to fetch the related collection / key from them in order to fetch
// the correct data from those related collections
if (junctionRowsToInspect.length > 0) {
const junctionInfoResponse = await api.get(`/items/${o2mRelation.value.many_collection}`, {
params: {
filter: {
[o2mRelation.value.many_primary]: {
_in: junctionRowsToInspect,
},
},
fields: [
o2mRelation.value.many_primary,
anyRelation.value.many_field,
anyRelation.value.one_collection_field!,
o2mRelation.value.sort_field,
],
},
});
for (const junctionRow of junctionInfoResponse.data.data) {
const relatedCollection = junctionRow[anyRelation.value.one_collection_field!];
// When the collection exists in the setup
if (relatedCollection in itemsToFetchPerCollection) {
itemsToFetchPerCollection[relatedCollection].push(junctionRow[anyRelation.value.many_field]);
}
}
junctionRowMap.value = junctionInfoResponse.data.data;
} else {
junctionRowMap.value = [];
}
// Fetch all related items from their individual endpoints using the fields from their templates
const responses = await Promise.all(
Object.entries(itemsToFetchPerCollection).map(([collection, relatedKeys]) => {
// Don't attempt fetching anything if there's no keys to fetch
if (relatedKeys.length === 0) return Promise.resolve({ data: { data: [] } } as any);
const fields = getFieldsFromTemplate(templates.value[collection]);
// Make sure to always fetch the primary key, so we can match that with the value
if (fields.includes(primaryKeys.value[collection]) === false) fields.push(primaryKeys.value[collection]);
return api.get(getEndpoint(collection), {
params: {
filter: {
[primaryKeys.value[collection]]: {
_in: relatedKeys,
},
},
fields,
},
});
})
);
if (!relatedItemValues.value) relatedItemValues.value = {};
for (let i = 0; i < Object.keys(itemsToFetchPerCollection).length; i++) {
const collection = Object.keys(itemsToFetchPerCollection)[i];
relatedItemValues.value = {
...relatedItemValues.value,
[collection]: responses[i].data.data,
};
}
} catch (err) {
unexpectedError(err);
} finally {
loading.value = false;
}
}
}
function useSelection() {
const selectingFrom = ref<string | null>(null);
return { selectingFrom, stageSelection, deselect };
function stageSelection(selection: (number | string)[]) {
const { one_collection_field, many_field } = anyRelation.value;
const currentValue = props.value || [];
const selectionAsJunctionRows = selection.map((key) => {
return {
[one_collection_field!]: selectingFrom.value,
[many_field]: key,
};
});
emit('input', [...currentValue, ...selectionAsJunctionRows]);
}
function deselect(item: any) {
emit(
'input',
(props.value || []).filter((current) => current !== item)
);
}
}
function useEdits() {
const currentlyEditing = ref<string | number | null>(null);
const relatedPrimaryKey = ref<string | number | null>(null);
const editsAtStart = ref<Record<string, any>>({});
return {
currentlyEditing,
relatedPrimaryKey,
editsAtStart,
stageEdits,
cancelEdit,
editExisting,
createNew,
};
function stageEdits(edits: Record<string, any>) {
const currentValue = props.value || [];
// Whether or not the currently-being-edited item exists in the staged values
const hasBeenStaged =
currentValue.includes(editsAtStart.value) || currentValue.includes(currentlyEditing.value);
// Whether or not the currently-being-edited item has been saved to the database
const isNew = currentlyEditing.value === '+' && relatedPrimaryKey.value === '+';
if (isNew && hasBeenStaged === false) {
emit('input', [...currentValue, edits]);
} else {
emit(
'input',
currentValue.map((val) => {
if (val === editsAtStart.value || val == currentlyEditing.value) {
return edits;
}
return val;
})
);
}
}
function cancelEdit() {
currentlyEditing.value = null;
relatedPrimaryKey.value = null;
editsAtStart.value = {};
}
function editExisting(item: Record<string, any>) {
// Edit a saved item
if (typeof item === 'string' || typeof item === 'number') {
const junctionRow = (junctionRowMap.value || []).find((row) => {
return row[o2mRelation.value.many_primary] == item;
});
const collection = junctionRow[anyRelation.value.one_collection_field!];
const relatedKey = isPlainObject(junctionRow[anyRelation.value.many_field])
? junctionRow[anyRelation.value.many_field][primaryKeys.value[collection]]
: junctionRow[anyRelation.value.many_field];
editsAtStart.value = {
[o2mRelation.value.many_primary]: item,
[anyRelation.value.one_collection_field!]: collection,
[anyRelation.value.many_field]: {
[primaryKeys.value[collection]]: relatedKey,
},
};
if (o2mRelation.value.sort_field) {
editsAtStart.value[o2mRelation.value.sort_field] = junctionRow[o2mRelation.value.sort_field];
}
relatedPrimaryKey.value = relatedKey || '+';
currentlyEditing.value = item;
return;
}
const junctionPrimaryKey = item[o2mRelation.value.many_primary];
const relatedCollectiom = item[anyRelation.value.one_collection_field!];
let relatedKey = item[anyRelation.value.many_field];
if (isPlainObject(relatedKey)) {
relatedKey = item[anyRelation.value.many_field][primaryKeys.value[relatedCollectiom]];
}
editsAtStart.value = item;
relatedPrimaryKey.value = relatedKey || '+';
currentlyEditing.value = junctionPrimaryKey || '+';
}
function createNew(collection: string) {
const newItem = {
[anyRelation.value.one_collection_field!]: collection,
[anyRelation.value.many_field]: {},
};
if (previewValues.value && o2mRelation.value?.sort_field) {
const maxSort = Math.max(-1, ...previewValues.value.map((val) => val[o2mRelation.value.sort_field!]));
newItem[o2mRelation.value.sort_field!] = maxSort + 1;
}
editsAtStart.value = newItem;
relatedPrimaryKey.value = '+';
currentlyEditing.value = '+';
}
}
function useManualSort() {
return { onSort };
function onSort(sortedItems: any[]) {
emit(
'input',
props.value.map((rawValue, index) => {
if (!o2mRelation.value.sort_field) return rawValue;
const sortedItemIndex = sortedItems.findIndex((sortedItem) => {
return sortedItem.$index === index;
});
if (isPlainObject(rawValue)) {
return {
...rawValue,
[o2mRelation.value.sort_field]: sortedItemIndex + 1,
};
} else {
return {
...sortedItems[sortedItemIndex],
[o2mRelation.value.many_primary]: rawValue,
[o2mRelation.value.sort_field]: sortedItemIndex + 1,
};
}
})
);
}
}
},
});
</script>
<style lang="scss" scoped>
.v-list {
--v-list-padding: 0 0 4px;
}
.v-list-item {
.collection {
margin-right: 1ch;
color: var(--primary);
}
}
.loader {
.v-skeleton-loader {
height: 52px;
}
.v-skeleton-loader + .v-skeleton-loader {
margin-top: 12px;
}
}
.buttons {
margin-top: 8px;
}
.existing {
margin-left: 8px;
}
.drag-handle {
cursor: grab;
}
.invalid {
cursor: default;
.invalid-icon {
--v-icon-color: var(--danger);
}
}
.clear-icon {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--danger);
margin-right: 8px;
color: var(--foreground-subdued);
transition: color var(--fast) var(--transition);
&:hover {
color: var(--danger);
}
}
.launch-icon {
color: var(--foreground-subdued);
}
</style>