mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Merge pull request #619 from directus/relational-updates
Relational updates
This commit is contained in:
@@ -195,6 +195,7 @@ export class ItemsService implements AbstractService {
|
||||
async readByQuery(query: Query): Promise<null | Item | Item[]> {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
});
|
||||
|
||||
let ast = await getASTFromQuery(this.collection, query, {
|
||||
@@ -206,7 +207,7 @@ export class ItemsService implements AbstractService {
|
||||
ast = await authorizationService.processAST(ast);
|
||||
}
|
||||
|
||||
const records = await runAST(ast);
|
||||
const records = await runAST(ast, { knex: this.knex });
|
||||
return records;
|
||||
}
|
||||
|
||||
@@ -293,6 +294,7 @@ export class ItemsService implements AbstractService {
|
||||
if (this.accountability) {
|
||||
const authorizationService = new AuthorizationService({
|
||||
accountability: this.accountability,
|
||||
knex: this.knex,
|
||||
});
|
||||
|
||||
await authorizationService.checkAccess('update', this.collection, keys);
|
||||
@@ -427,7 +429,7 @@ export class ItemsService implements AbstractService {
|
||||
readQuery.fields = [primaryKeyField];
|
||||
|
||||
// Not authenticated:
|
||||
const itemsService = new ItemsService(this.collection);
|
||||
const itemsService = new ItemsService(this.collection, { knex: this.knex });
|
||||
|
||||
let itemsToUpdate = await itemsService.readByQuery(readQuery);
|
||||
itemsToUpdate = Array.isArray(itemsToUpdate) ? itemsToUpdate : [itemsToUpdate];
|
||||
@@ -439,8 +441,8 @@ export class ItemsService implements AbstractService {
|
||||
return await this.update(data, keys);
|
||||
}
|
||||
|
||||
upsert(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
upsert(data: Partial<Item>[]): Promise<PrimaryKey[]>;
|
||||
upsert(data: Partial<Item>): Promise<PrimaryKey>;
|
||||
async upsert(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
|
||||
const primaryKeyField = await this.schemaInspector.primary(this.collection);
|
||||
const payloads = Array.isArray(data) ? data : [data];
|
||||
|
||||
@@ -347,10 +347,7 @@ export class PayloadService {
|
||||
const relationsToProcess = relations.filter((relation) => {
|
||||
if (!relation.one_field) return false;
|
||||
|
||||
return (
|
||||
payload.hasOwnProperty(relation.one_field) &&
|
||||
Array.isArray(payload[relation.one_field])
|
||||
);
|
||||
return payload.hasOwnProperty(relation.one_field);
|
||||
});
|
||||
|
||||
for (const relation of relationsToProcess) {
|
||||
@@ -360,43 +357,59 @@ export class PayloadService {
|
||||
});
|
||||
|
||||
const relatedRecords: Partial<Item>[] = [];
|
||||
let savedPrimaryKeys: PrimaryKey[] = [];
|
||||
|
||||
for (const relatedRecord of payload[relation.one_field!]) {
|
||||
let record = cloneDeep(relatedRecord);
|
||||
if (payload[relation.one_field!] && Array.isArray(payload[relation.one_field!])) {
|
||||
for (const relatedRecord of payload[relation.one_field!] || []) {
|
||||
let record = cloneDeep(relatedRecord);
|
||||
|
||||
if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') {
|
||||
const exists = !!(await this.knex
|
||||
.select(relation.many_primary)
|
||||
.from(relation.many_collection)
|
||||
.where({ [relation.many_primary]: record })
|
||||
.first());
|
||||
if (
|
||||
typeof relatedRecord === 'string' ||
|
||||
typeof relatedRecord === 'number'
|
||||
) {
|
||||
const exists = !!(await this.knex
|
||||
.select(relation.many_primary)
|
||||
.from(relation.many_collection)
|
||||
.where({ [relation.many_primary]: record })
|
||||
.first());
|
||||
|
||||
if (exists === false)
|
||||
throw new ForbiddenException(undefined, {
|
||||
item: record,
|
||||
collection: relation.many_collection,
|
||||
});
|
||||
if (exists === false) {
|
||||
throw new ForbiddenException(undefined, {
|
||||
item: record,
|
||||
collection: relation.many_collection,
|
||||
});
|
||||
}
|
||||
|
||||
record = {
|
||||
[relation.many_primary]: relatedRecord,
|
||||
};
|
||||
record = {
|
||||
[relation.many_primary]: relatedRecord,
|
||||
};
|
||||
}
|
||||
|
||||
relatedRecords.push({
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary!],
|
||||
});
|
||||
}
|
||||
|
||||
relatedRecords.push({
|
||||
...record,
|
||||
[relation.many_field]: parent || payload[relation.one_primary!],
|
||||
});
|
||||
savedPrimaryKeys = await itemsService.upsert(relatedRecords);
|
||||
}
|
||||
|
||||
const primaryKeys = await itemsService.upsert(relatedRecords);
|
||||
|
||||
await itemsService.updateByQuery(
|
||||
{ [relation.many_field]: null },
|
||||
{
|
||||
filter: {
|
||||
[relation.many_primary]: {
|
||||
_nin: primaryKeys,
|
||||
},
|
||||
_and: [
|
||||
{
|
||||
[relation.many_field]: {
|
||||
_eq: parent,
|
||||
},
|
||||
},
|
||||
{
|
||||
[relation.many_primary]: {
|
||||
_nin: savedPrimaryKeys,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -263,7 +263,7 @@ export default async function getASTFromQuery(
|
||||
async function getFieldsInCollection(collection: string) {
|
||||
const columns = (await schemaInspector.columns(collection)).map((column) => column.column);
|
||||
const fields = (
|
||||
await database.select('field').from('directus_fields').where({ collection })
|
||||
await knex.select('field').from('directus_fields').where({ collection })
|
||||
).map((field) => field.field);
|
||||
|
||||
const fieldsInCollection = [
|
||||
|
||||
162
app/package-lock.json
generated
162
app/package-lock.json
generated
@@ -6601,6 +6601,51 @@
|
||||
"tslint": "^5.20.1",
|
||||
"webpack": "^4.0.0",
|
||||
"yorkie": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"fork-ts-checker-webpack-plugin-v5": {
|
||||
"version": "npm:fork-ts-checker-webpack-plugin@5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.0.tgz",
|
||||
"integrity": "sha512-NEKcI0+osT5bBFZ1SFGzJMQETjQWZrSvMO1g0nAR/w0t328Z41eN8BJEIZyFCl2HsuiJpa9AN474Nh2qLVwGLQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@types/json-schema": "^7.0.5",
|
||||
"chalk": "^4.1.0",
|
||||
"cosmiconfig": "^6.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"fs-extra": "^9.0.0",
|
||||
"memfs": "^3.1.2",
|
||||
"minimatch": "^3.0.4",
|
||||
"schema-utils": "2.7.0",
|
||||
"semver": "^7.3.2",
|
||||
"tapable": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
|
||||
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@types/json-schema": "^7.0.4",
|
||||
"ajv": "^6.12.2",
|
||||
"ajv-keywords": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@vue/cli-plugin-unit-jest": {
|
||||
@@ -6740,6 +6785,17 @@
|
||||
"unique-filename": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
@@ -6823,6 +6879,18 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^2.1.2"
|
||||
}
|
||||
},
|
||||
"locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
@@ -6936,6 +7004,18 @@
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"dev": true
|
||||
},
|
||||
"vue-loader-v16": {
|
||||
"version": "npm:vue-loader@16.0.0-beta.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-beta.8.tgz",
|
||||
"integrity": "sha512-oouKUQWWHbSihqSD7mhymGPX1OQ4hedzAHyvm8RdyHh6m3oIvoRF+NM45i/bhNOlo8jCnuJhaSUf/6oDjv978g==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"chalk": "^4.1.0",
|
||||
"hash-sum": "^2.0.0",
|
||||
"loader-utils": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
@@ -11664,51 +11744,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"fork-ts-checker-webpack-plugin-v5": {
|
||||
"version": "npm:fork-ts-checker-webpack-plugin@5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-5.2.0.tgz",
|
||||
"integrity": "sha512-NEKcI0+osT5bBFZ1SFGzJMQETjQWZrSvMO1g0nAR/w0t328Z41eN8BJEIZyFCl2HsuiJpa9AN474Nh2qLVwGLQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.8.3",
|
||||
"@types/json-schema": "^7.0.5",
|
||||
"chalk": "^4.1.0",
|
||||
"cosmiconfig": "^6.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"fs-extra": "^9.0.0",
|
||||
"memfs": "^3.1.2",
|
||||
"minimatch": "^3.0.4",
|
||||
"schema-utils": "2.7.0",
|
||||
"semver": "^7.3.2",
|
||||
"tapable": "^1.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"schema-utils": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz",
|
||||
"integrity": "sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"@types/json-schema": "^7.0.4",
|
||||
"ajv": "^6.12.2",
|
||||
"ajv-keywords": "^3.4.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"form-data": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
|
||||
@@ -20342,43 +20377,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-loader-v16": {
|
||||
"version": "npm:vue-loader@16.0.0-beta.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-loader/-/vue-loader-16.0.0-beta.8.tgz",
|
||||
"integrity": "sha512-oouKUQWWHbSihqSD7mhymGPX1OQ4hedzAHyvm8RdyHh6m3oIvoRF+NM45i/bhNOlo8jCnuJhaSUf/6oDjv978g==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"chalk": "^4.1.0",
|
||||
"hash-sum": "^2.0.0",
|
||||
"loader-utils": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
|
||||
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==",
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^2.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-router": {
|
||||
"version": "3.4.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.6.tgz",
|
||||
|
||||
@@ -1,55 +1,60 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="!relations || relations.length !== 2">
|
||||
<v-notice type="warning" v-if="!junction || !relation">
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div v-else class="files">
|
||||
<v-table
|
||||
inline
|
||||
:items="previewItems"
|
||||
:items="displayItems"
|
||||
:loading="loading"
|
||||
:headers.sync="tableHeaders"
|
||||
:item-key="junctionCollectionPrimaryKeyField.field"
|
||||
:item-key="relationFields.junctionPkField"
|
||||
:disabled="disabled"
|
||||
@click:row="editExisting"
|
||||
@click:row="editItem"
|
||||
>
|
||||
<template #item.$thumbnail="{ item }">
|
||||
<render-display
|
||||
:value="get(item, [relationCurrentToJunction.junction_field])"
|
||||
:value="item"
|
||||
display="file"
|
||||
:collection="junctionCollection"
|
||||
:field="relationCurrentToJunction.junction_field"
|
||||
:collection="relationFields.junctionCollection"
|
||||
:field="relationFields.relationPkField"
|
||||
type="file"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
<v-icon
|
||||
name="close"
|
||||
v-tooltip="$t('deselect')"
|
||||
class="deselect"
|
||||
@click.stop="deleteItem(item, items)"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<div class="actions" v-if="!disabled">
|
||||
<v-button class="new" @click="showUpload = true">{{ $t('upload_file') }}</v-button>
|
||||
<v-button class="existing" @click="showCollectionModal = true">
|
||||
<v-button class="existing" @click="selectModalActive = true">
|
||||
{{ $t('add_existing') }}
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<modal-item
|
||||
v-if="!disabled"
|
||||
:active="showDetailModal"
|
||||
:collection="junctionCollection"
|
||||
:primary-key="junctionRowPrimaryKey"
|
||||
:active="currentlyEditing !== null"
|
||||
:collection="relationFields.junctionCollection"
|
||||
:primary-key="currentlyEditing || '+'"
|
||||
:edits="editsAtStart"
|
||||
:junction-field="relationCurrentToJunction.junction_field"
|
||||
:related-primary-key="relatedRowPrimaryKey"
|
||||
:related-primary-key="relationFields.relationPkField"
|
||||
:junction-field="relationFields.junctionRelation"
|
||||
@input="stageEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
|
||||
<modal-collection
|
||||
v-if="!disabled"
|
||||
:active.sync="showCollectionModal"
|
||||
:collection="relationJunctionToRelated.one_collection"
|
||||
:active.sync="selectModalActive"
|
||||
:collection="relation.one_collection"
|
||||
:selection="[]"
|
||||
:filters="selectionFilters"
|
||||
@input="stageSelection"
|
||||
@@ -69,13 +74,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, toRefs } from '@vue/composition-api';
|
||||
import { defineComponent, ref, computed, toRefs, PropType } from '@vue/composition-api';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import ModalItem from '@/views/private/components/modal-item';
|
||||
import { get } from 'lodash';
|
||||
import i18n from '@/lang';
|
||||
|
||||
import useActions from '@/interfaces/many-to-many/use-actions';
|
||||
import useRelation from '@/interfaces/many-to-many/use-relation';
|
||||
import useSelection from '@/interfaces/many-to-many/use-selection';
|
||||
import usePreview from '@/interfaces/many-to-many/use-preview';
|
||||
@@ -97,8 +103,8 @@ export default defineComponent({
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
type: Array as PropType<(string | number | Record<string, any>)[] | null>,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
@@ -108,24 +114,26 @@ export default defineComponent({
|
||||
setup(props, { emit }) {
|
||||
const { collection, field, value, primaryKey } = toRefs(props);
|
||||
|
||||
const { junction, junctionCollection, relation, relationCollection, relationFields } = useRelation(
|
||||
collection,
|
||||
field
|
||||
);
|
||||
|
||||
function emitter(newVal: any[] | null) {
|
||||
emit('input', newVal);
|
||||
}
|
||||
|
||||
const {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
} = useRelation({ collection, field });
|
||||
deleteItem,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys,
|
||||
getNewSelectedItems,
|
||||
getJunctionItem,
|
||||
getJunctionFromRelatedId,
|
||||
} = useActions(value, relationFields, emitter);
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
const jf = relationCurrentToJunction.value.junction_field;
|
||||
|
||||
return ['id', 'type', 'title'].map((key) => `${jf}.${key}`);
|
||||
});
|
||||
const fields = ref(['id', 'type', 'title']);
|
||||
|
||||
const tableHeaders = ref<TableHeader[]>([
|
||||
{
|
||||
@@ -138,133 +146,86 @@ export default defineComponent({
|
||||
{
|
||||
text: i18n.t('title'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value: relationCurrentToJunction.value!.junction_field + '.title',
|
||||
value: 'title',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
const { loading, previewItems, error } = usePreview({
|
||||
const { loading, displayItems, error, items } = usePreview(
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
fields,
|
||||
});
|
||||
relationFields,
|
||||
getNewSelectedItems,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys
|
||||
);
|
||||
|
||||
const {
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
} = useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
const { cancelEdit, stageEdits, editsAtStart, editItem, currentlyEditing } = useEdit(
|
||||
value,
|
||||
onEdit: (newValue) => emit('input', newValue),
|
||||
});
|
||||
items,
|
||||
relationFields,
|
||||
emitter,
|
||||
getJunctionFromRelatedId
|
||||
);
|
||||
|
||||
const { showCollectionModal, stageSelection, selectionFilters } = useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection: (selectionAsJunctionRows) => {
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
},
|
||||
});
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection(
|
||||
value,
|
||||
displayItems,
|
||||
relationFields,
|
||||
emitter
|
||||
);
|
||||
|
||||
const { showUpload, onUpload } = useUpload();
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junction,
|
||||
relation,
|
||||
tableHeaders,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
loading,
|
||||
previewItems,
|
||||
displayItems,
|
||||
error,
|
||||
showDetailModal,
|
||||
currentlyEditing,
|
||||
cancelEdit,
|
||||
showUpload,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
showCollectionModal,
|
||||
selectModalActive,
|
||||
stageSelection,
|
||||
selectionFilters,
|
||||
relatedCollection,
|
||||
initialValues,
|
||||
deleteItem,
|
||||
items,
|
||||
get,
|
||||
deselect,
|
||||
onUpload,
|
||||
relationFields,
|
||||
editItem,
|
||||
};
|
||||
|
||||
/**
|
||||
* Deselect an item. This either means undoing any changes made (new item), or adding $delete: true
|
||||
* if the junction row already exists.
|
||||
*/
|
||||
function deselect(junctionRow: any) {
|
||||
const primaryKey = junctionRow[junctionCollectionPrimaryKeyField.value.field];
|
||||
|
||||
// If the junction row has a primary key, it's an existing item in the junction row, and
|
||||
// we want to add the $delete flag so the API can delete the row in the junction table,
|
||||
// effectively deselecting the related item from this item
|
||||
if (primaryKey) {
|
||||
// Once you deselect an item, it's removed from the preview table. You can only
|
||||
// deselect an item once, so we don't have to check if this item was already disabled
|
||||
emit('input', [
|
||||
...(props.value || []),
|
||||
{
|
||||
[junctionCollectionPrimaryKeyField.value.field]: primaryKey,
|
||||
$delete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the item doesn't exist yet, there must be a staged edit for it's creation, that's
|
||||
// the thing we want to filter out of the staged edits.
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => {
|
||||
return stagedValue !== junctionRow && stagedValue !== junctionRow['$stagedEdits'];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function useUpload() {
|
||||
const showUpload = ref(false);
|
||||
|
||||
return { showUpload, onUpload };
|
||||
|
||||
function onUpload(file: { id: number; [key: string]: any }) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
function onUpload(files: Record<string, any>[]) {
|
||||
showUpload.value = false;
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
const { junctionRelation } = relationFields.value;
|
||||
const file = files[0];
|
||||
|
||||
const fileAsJunctionRow = {
|
||||
[relationCurrentToJunction.value.junction_field]: {
|
||||
[junctionRelation]: {
|
||||
id: file.id,
|
||||
title: file.title,
|
||||
type: file.type,
|
||||
},
|
||||
};
|
||||
|
||||
emit('input', [...(props.value || []), fileAsJunctionRow]);
|
||||
|
||||
showUpload.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,60 +1,64 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="!relations || relations.length !== 2">
|
||||
<v-notice type="warning" v-if="!junction || !relation">
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div v-else>
|
||||
<div class="one-to-many" v-else>
|
||||
<v-table
|
||||
inline
|
||||
:items="previewItems"
|
||||
:loading="loading"
|
||||
:items="displayItems"
|
||||
:headers.sync="tableHeaders"
|
||||
:item-key="junctionCollectionPrimaryKeyField.field"
|
||||
:disabled="disabled"
|
||||
@click:row="editExisting"
|
||||
show-resize
|
||||
inline
|
||||
@click:row="editItem"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<template v-for="header in tableHeaders" v-slot:[`item.${header.value}`]="{ item }">
|
||||
<render-display
|
||||
:key="header.value"
|
||||
:value="get(item, header.value)"
|
||||
:value="item[header.value]"
|
||||
:display="header.field.display"
|
||||
:options="header.field.displayOptions"
|
||||
:interface="header.field.interface"
|
||||
:interface-options="header.field.interfaceOptions"
|
||||
:type="header.field.type"
|
||||
:collection="junctionCollection"
|
||||
:collection="junctionCollection.collection"
|
||||
:field="header.field.field"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
<v-icon
|
||||
name="close"
|
||||
v-tooltip="$t('deselect')"
|
||||
class="deselect"
|
||||
@click.stop="deleteItem(item, items)"
|
||||
/>
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
<div class="actions" v-if="!disabled">
|
||||
<v-button class="new" @click="addNew">{{ $t('create_new') }}</v-button>
|
||||
<v-button class="existing" @click="showCollectionModal = true">
|
||||
<v-button class="new" @click="currentlyEditing = '+'">{{ $t('create_new') }}</v-button>
|
||||
<v-button class="existing" @click="selectModalActive = true">
|
||||
{{ $t('add_existing') }}
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<modal-item
|
||||
v-if="!disabled"
|
||||
:active.sync="showDetailModal"
|
||||
:collection="junctionCollection"
|
||||
:primary-key="junctionRowPrimaryKey"
|
||||
:active="currentlyEditing !== null"
|
||||
:collection="relationCollection.collection"
|
||||
:primary-key="currentlyEditing || '+'"
|
||||
:related-primary-key="relationFields.relationPkField"
|
||||
:junction-field="relationFields.junctionRelation"
|
||||
:edits="editsAtStart"
|
||||
:junction-field="relationCurrentToJunction.junction_field"
|
||||
:related-primary-key="relatedRowPrimaryKey"
|
||||
@input="stageEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
|
||||
<modal-collection
|
||||
v-if="!disabled"
|
||||
:active.sync="showCollectionModal"
|
||||
:collection="relationJunctionToRelated.one_collection"
|
||||
:active.sync="selectModalActive"
|
||||
:collection="relationCollection.collection"
|
||||
:selection="[]"
|
||||
:filters="selectionFilters"
|
||||
@input="stageSelection"
|
||||
@@ -64,41 +68,23 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, watch, PropType, toRefs, computed } from '@vue/composition-api';
|
||||
import { useFieldsStore } from '@/stores/';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import { defineComponent, ref, computed, watch, PropType, toRefs } from '@vue/composition-api';
|
||||
import ModalItem from '@/views/private/components/modal-item';
|
||||
import { get } from 'lodash';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
|
||||
import useActions from './use-actions';
|
||||
import useRelation from './use-relation';
|
||||
import useSelection from './use-selection';
|
||||
import usePreview from './use-preview';
|
||||
import useEdit from './use-edit';
|
||||
import { Field } from '@/types';
|
||||
|
||||
/**
|
||||
* Hi there!
|
||||
*
|
||||
* The many to many is super complex. Please take proper care when jumping in here and making changes,
|
||||
* you might break more than you'd imagine.
|
||||
*
|
||||
* If you have any questions, please feel free to reach out to Rijk <rijkvanzanten@me.com>
|
||||
*
|
||||
* NOTE: Some of the logic here is based on the fact that you can only have 1 copy of a related item
|
||||
* associated in the m2m at a time. Without this requirement, there isn't a way to know which item
|
||||
* you're editing at a time. It would also be near impossible to keep track of the changes made to the
|
||||
* related item. Seeing we stage the made edits nested so the api is able to update it, we would have
|
||||
* to apply the same edits nested to all the junction rows or something like that, pretty tricky stuff
|
||||
*
|
||||
* Another NOTE: There's one other tricky case to be aware of: selecting an existing related item. In that case,
|
||||
* the junction row doesn't exist yet, but the related item does. Be aware that you can't rely on the
|
||||
* primary key of the junction row in some cases.
|
||||
*/
|
||||
import useSelection from './use-selection';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalCollection, ModalItem },
|
||||
components: { ModalItem, ModalCollection },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
|
||||
default: null,
|
||||
},
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
@@ -111,181 +97,82 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fields: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const { value, collection, field, fields } = toRefs(props);
|
||||
|
||||
const { collection, field, value, primaryKey } = toRefs(props);
|
||||
function emitter(newVal: any[] | null) {
|
||||
emit('input', newVal);
|
||||
}
|
||||
|
||||
const { junction, junctionCollection, relation, relationCollection, relationFields } = useRelation(
|
||||
collection,
|
||||
field
|
||||
);
|
||||
|
||||
const {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
} = useRelation({ collection, field });
|
||||
deleteItem,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys,
|
||||
getNewSelectedItems,
|
||||
getJunctionItem,
|
||||
getJunctionFromRelatedId,
|
||||
} = useActions(value, relationFields, emitter);
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!junctionCollection) return [];
|
||||
return (
|
||||
props.fields ||
|
||||
fieldsStore.getFieldsForCollection(junctionCollection.value).map((field: Field) => field.field)
|
||||
);
|
||||
});
|
||||
|
||||
const { tableHeaders } = useTable();
|
||||
|
||||
const { loading, previewItems, error } = usePreview({
|
||||
const { tableHeaders, items, loading, error, displayItems } = usePreview(
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
fields,
|
||||
});
|
||||
relationFields,
|
||||
getNewSelectedItems,
|
||||
getUpdatedItems,
|
||||
getNewItems,
|
||||
getPrimaryKeys
|
||||
);
|
||||
|
||||
const {
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
addNew,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
} = useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdit(
|
||||
value,
|
||||
onEdit: (newValue) => emit('input', newValue),
|
||||
});
|
||||
items,
|
||||
relationFields,
|
||||
emitter,
|
||||
getJunctionFromRelatedId
|
||||
);
|
||||
|
||||
const { showCollectionModal, stageSelection, selectionFilters } = useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection: (selectionAsJunctionRows) => {
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
},
|
||||
});
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection(
|
||||
value,
|
||||
displayItems,
|
||||
relationFields,
|
||||
emitter
|
||||
);
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junction,
|
||||
relation,
|
||||
tableHeaders,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
loading,
|
||||
previewItems,
|
||||
error,
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
addNew,
|
||||
stageEdits,
|
||||
currentlyEditing,
|
||||
editItem,
|
||||
junctionCollection,
|
||||
relationCollection,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
showCollectionModal,
|
||||
stageEdits,
|
||||
cancelEdit,
|
||||
stageSelection,
|
||||
selectModalActive,
|
||||
deleteItem,
|
||||
displayItems,
|
||||
selectionFilters,
|
||||
relatedCollection,
|
||||
initialValues,
|
||||
get,
|
||||
deselect,
|
||||
items,
|
||||
relationFields,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages the state of the table. This includes the table headers, and the event handlers for
|
||||
* the table events
|
||||
*/
|
||||
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<TableHeader[]>([]);
|
||||
|
||||
watch(fields, setHeaders, { immediate: true });
|
||||
|
||||
return { tableHeaders };
|
||||
|
||||
function setHeaders() {
|
||||
tableHeaders.value = fields.value.map(
|
||||
(fieldKey): TableHeader => {
|
||||
const field = fieldsStore.getField(junctionCollection.value, fieldKey);
|
||||
|
||||
return {
|
||||
text: field.name,
|
||||
value: fieldKey,
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: null,
|
||||
field: {
|
||||
display: field.meta?.display,
|
||||
displayOptions: field.meta?.display_options,
|
||||
interface: field.meta?.interface,
|
||||
interfaceOptions: field.meta?.options,
|
||||
type: field.type,
|
||||
field: field.field,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect an item. This either means undoing any changes made (new item), or adding $delete: true
|
||||
* if the junction row already exists.
|
||||
*/
|
||||
function deselect(junctionRow: any) {
|
||||
const primaryKey = junctionRow[junctionCollectionPrimaryKeyField.value.field];
|
||||
|
||||
// If the junction row has a primary key, it's an existing item in the junction row, and
|
||||
// we want to add the $delete flag so the API can delete the row in the junction table,
|
||||
// effectively deselecting the related item from this item
|
||||
if (primaryKey) {
|
||||
// Once you deselect an item, it's removed from the preview table. You can only
|
||||
// deselect an item once, so we don't have to check if this item was already disabled
|
||||
emit('input', [
|
||||
...(props.value || []),
|
||||
{
|
||||
[junctionCollectionPrimaryKeyField.value.field]: primaryKey,
|
||||
$delete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the item doesn't exist yet, there must be a staged edit for it's creation, that's
|
||||
// the thing we want to filter out of the staged edits.
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => {
|
||||
return stagedValue !== junctionRow && stagedValue !== junctionRow['$stagedEdits'];
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="junctionCollection === null">
|
||||
<v-notice type="warning" v-if="relatedCollection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select
|
||||
:collection="junctionCollection"
|
||||
:collection="relatedCollection"
|
||||
v-model="fields"
|
||||
:inject="
|
||||
junctionCollectionExists ? null : { fields: newFields, collections: newCollections, relations }
|
||||
"
|
||||
:inject="relatedCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,22 +64,33 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => {
|
||||
const relatedCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
|
||||
const { field } = props.fieldData;
|
||||
|
||||
const junctionRelation = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
return junctionRelation?.many_collection || null;
|
||||
|
||||
if (junctionRelation === undefined) return;
|
||||
|
||||
const relatedCollection = props.relations.find(
|
||||
(relation) =>
|
||||
relation.one_collection !== props.collection &&
|
||||
relation.many_field === junctionRelation.junction_field
|
||||
);
|
||||
|
||||
return relatedCollection?.one_collection || null;
|
||||
});
|
||||
|
||||
const junctionCollectionExists = computed(() => {
|
||||
const relatedCollectionExists = computed(() => {
|
||||
return !!collectionsStore.state.collections.find(
|
||||
(collection) => collection.collection === junctionCollection.value
|
||||
(collection) => collection.collection === relatedCollection.value
|
||||
);
|
||||
});
|
||||
|
||||
return { fields, junctionCollection, junctionCollectionExists };
|
||||
return { fields, relatedCollection, relatedCollectionExists };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
190
app/src/interfaces/many-to-many/use-actions.ts
Normal file
190
app/src/interfaces/many-to-many/use-actions.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Ref } from '@vue/composition-api';
|
||||
import { RelationInfo } from './use-relation';
|
||||
|
||||
export default function useActions(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
relation: Ref<RelationInfo>,
|
||||
emit: (newValue: any[] | null) => void
|
||||
) {
|
||||
function getJunctionItem(id: string | number) {
|
||||
const { junctionPkField } = relation.value;
|
||||
if (value.value === null) return null;
|
||||
|
||||
return (
|
||||
value.value.find(
|
||||
(item) =>
|
||||
(typeof item === 'object' && junctionPkField in item && item[junctionPkField] === id) || item === id
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function getNewSelectedItems() {
|
||||
const { junctionRelation } = relation.value;
|
||||
|
||||
if (value.value === null || junctionRelation === null) return [];
|
||||
|
||||
return value.value.filter(
|
||||
(item) => typeof item === 'object' && junctionRelation in item && typeof item[junctionRelation] !== 'object'
|
||||
) as Record<string, any>[];
|
||||
}
|
||||
|
||||
function getNewItems() {
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
if (value.value === null || junctionRelation === null) return [];
|
||||
|
||||
return value.value.filter(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
junctionRelation in item &&
|
||||
typeof item[junctionRelation] === 'object' &&
|
||||
relationPkField in item[junctionRelation] === false
|
||||
) as Record<string, any>[];
|
||||
}
|
||||
|
||||
function getUpdatedItems() {
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
if (value.value === null || junctionRelation === null) return [];
|
||||
|
||||
return value.value.filter(
|
||||
(item) =>
|
||||
typeof item === 'object' &&
|
||||
junctionRelation in item &&
|
||||
typeof item[junctionRelation] === 'object' &&
|
||||
relationPkField in item[junctionRelation] === true
|
||||
) as Record<string, any>[];
|
||||
}
|
||||
|
||||
function getExistingItems() {
|
||||
if (value.value === null) return [];
|
||||
|
||||
return value.value.filter((item) => typeof item === 'string' || typeof item === 'number');
|
||||
}
|
||||
|
||||
function getPrimaryKeys(): (string | number)[] {
|
||||
const { junctionPkField } = relation.value;
|
||||
|
||||
if (value.value === null) return [];
|
||||
|
||||
return value.value
|
||||
.map((item) => {
|
||||
if (typeof item === 'object') {
|
||||
if (junctionPkField in item) return item[junctionPkField];
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.filter((i) => i);
|
||||
}
|
||||
|
||||
function getRelatedPrimaryKeys(): (string | number)[] {
|
||||
if (value.value === null) return [];
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
return value.value
|
||||
.map((junctionItem) => {
|
||||
if (
|
||||
typeof junctionItem !== 'object' ||
|
||||
junctionRelation === null ||
|
||||
junctionRelation in junctionItem === false
|
||||
)
|
||||
return undefined;
|
||||
const item = junctionItem[junctionRelation];
|
||||
|
||||
if (typeof item === 'object') {
|
||||
if (junctionRelation in item) return item[relationPkField];
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.filter((i) => i);
|
||||
}
|
||||
|
||||
function deleteItem(item: Record<string, any>, items: Record<string, any>[]) {
|
||||
if (value.value === null) return;
|
||||
const { junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
const id = item[relationPkField] as number | string | undefined;
|
||||
|
||||
if (id !== undefined) return deleteItemWithId(id, items);
|
||||
if (junctionRelation === null) return;
|
||||
|
||||
const newVal = value.value.filter((junctionItem) => {
|
||||
if (typeof junctionItem !== 'object' || junctionRelation in junctionItem === false) return true;
|
||||
return junctionItem[junctionRelation] !== item;
|
||||
});
|
||||
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
}
|
||||
|
||||
function deleteItemWithId(id: string | number, items: Record<string, any>[]) {
|
||||
if (value.value === null) return;
|
||||
const { junctionRelation, relationPkField, junctionPkField } = relation.value;
|
||||
|
||||
const junctionItem = items.find(
|
||||
(item) =>
|
||||
junctionRelation in item &&
|
||||
relationPkField in item[junctionRelation] &&
|
||||
item[junctionRelation][relationPkField] === id
|
||||
);
|
||||
|
||||
if (junctionItem === undefined) return;
|
||||
|
||||
// If it is a newly selected Item
|
||||
if (junctionPkField in junctionItem === false) {
|
||||
const newVal = value.value.filter((item) => {
|
||||
if (typeof item === 'object' && junctionRelation in item) {
|
||||
const jItem = item[junctionRelation];
|
||||
return typeof jItem === 'object' ? jItem[relationPkField] !== id : jItem !== id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
return;
|
||||
}
|
||||
|
||||
// If it is an already existing item
|
||||
const newVal = value.value.filter((item) => {
|
||||
if (typeof item === 'object' && junctionPkField in item) {
|
||||
return junctionItem[junctionPkField] !== item[junctionPkField];
|
||||
} else {
|
||||
return junctionItem[junctionPkField] !== item;
|
||||
}
|
||||
});
|
||||
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
}
|
||||
|
||||
function getJunctionFromRelatedId(id: string | number, items: Record<string, any>[]) {
|
||||
const { relationPkField, junctionRelation } = relation.value;
|
||||
|
||||
return (
|
||||
items.find((item) => {
|
||||
return (
|
||||
typeof item === 'object' &&
|
||||
junctionRelation in item &&
|
||||
typeof item[junctionRelation] === 'object' &&
|
||||
relationPkField in item[junctionRelation] &&
|
||||
item[junctionRelation][relationPkField] === id
|
||||
);
|
||||
}) || null
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
getJunctionItem,
|
||||
getNewSelectedItems,
|
||||
getNewItems,
|
||||
getUpdatedItems,
|
||||
getExistingItems,
|
||||
getPrimaryKeys,
|
||||
getRelatedPrimaryKeys,
|
||||
getJunctionFromRelatedId,
|
||||
deleteItem,
|
||||
deleteItemWithId,
|
||||
};
|
||||
}
|
||||
@@ -1,141 +1,77 @@
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { Field, Relation } from '@/types';
|
||||
import { set } from 'lodash';
|
||||
import { Ref, ref } from '@vue/composition-api';
|
||||
import { RelationInfo } from './use-relation';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
type EditParam = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
value: Ref<any[] | null>;
|
||||
onEdit: (newValue: any[] | null) => void;
|
||||
};
|
||||
export default function useEdit(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
items: Ref<Record<string, any>[]>,
|
||||
relation: Ref<RelationInfo>,
|
||||
emit: (newVal: any[] | null) => void,
|
||||
getJunctionFromRelatedId: (id: string | number, items: Record<string, any>[]) => Record<string, any> | null
|
||||
) {
|
||||
// Primary key of the item we're currently editing. If null, the edit modal should be
|
||||
// closed
|
||||
const currentlyEditing = ref<string | number | null>(null);
|
||||
|
||||
/**
|
||||
* Everything regarding the edit experience in the detail modal. This also includes adding
|
||||
* a new item
|
||||
*/
|
||||
export default function useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
value,
|
||||
onEdit,
|
||||
}: EditParam) {
|
||||
const showDetailModal = ref(false);
|
||||
// The previously made edits when we're starting to edit the item
|
||||
const editsAtStart = ref<any>(null);
|
||||
const junctionRowPrimaryKey = ref<number | string>('+');
|
||||
const relatedRowPrimaryKey = ref<number | string>('+');
|
||||
const initialValues = ref<any>(null);
|
||||
const isNew = ref(false);
|
||||
// This keeps track of the starting values so we can match with it
|
||||
const editsAtStart = ref<Record<string, any>>({});
|
||||
|
||||
return {
|
||||
showDetailModal,
|
||||
editsAtStart,
|
||||
addNew,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
};
|
||||
function editItem(item: any) {
|
||||
const { relationPkField } = relation.value;
|
||||
const hasPrimaryKey = relationPkField in item;
|
||||
|
||||
function addNew() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
initialValues.value = null;
|
||||
isNew.value = true;
|
||||
editsAtStart.value = item;
|
||||
currentlyEditing.value = hasPrimaryKey ? item[relationPkField] : -1;
|
||||
}
|
||||
|
||||
// The row here is the item in previewItems that's passed to the table
|
||||
function editExisting(item: any) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
function stageEdits(edits: any) {
|
||||
const { relationPkField, junctionRelation, junctionPkField } = relation.value;
|
||||
const editsWrapped = { [junctionRelation]: edits };
|
||||
const hasPrimaryKey = relationPkField in editsAtStart.value;
|
||||
const junctionItem = hasPrimaryKey
|
||||
? getJunctionFromRelatedId(editsAtStart.value[relationPkField], items.value)
|
||||
: null;
|
||||
|
||||
if (item.$new === true) isNew.value = true;
|
||||
const newValue = (value.value || []).map((item) => {
|
||||
if (junctionItem !== null && junctionPkField in junctionItem) {
|
||||
const id = junctionItem[junctionPkField];
|
||||
|
||||
if (isNew.value === true) {
|
||||
editsAtStart.value = item;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
showDetailModal.value = true;
|
||||
initialValues.value = null;
|
||||
return;
|
||||
if (typeof item === 'object' && junctionPkField in item) {
|
||||
if (item[junctionPkField] === id) return { [junctionRelation]: edits, [junctionPkField]: id };
|
||||
} else if (typeof item === 'number' || typeof item === 'string') {
|
||||
if (item === id) return { [junctionRelation]: edits, [junctionPkField]: id };
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof item === 'object' && relationPkField in edits && junctionRelation in item) {
|
||||
const id = edits[relationPkField];
|
||||
const relatedItem = item[junctionRelation] as string | number | Record<string, any>;
|
||||
if (typeof relatedItem === 'object' && relationPkField in relatedItem) {
|
||||
if (relatedItem[relationPkField] === id) return editsWrapped;
|
||||
} else if (typeof relatedItem === 'string' || typeof relatedItem === 'number') {
|
||||
if (relatedItem === id) return editsWrapped;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEqual({ [junctionRelation]: editsAtStart.value }, item)) {
|
||||
return editsWrapped;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
if (hasPrimaryKey === false && newValue.includes(editsWrapped) === false) {
|
||||
newValue.push(editsWrapped);
|
||||
}
|
||||
|
||||
initialValues.value = item;
|
||||
|
||||
/**
|
||||
* @NOTE: Keep in mind there's a case where the junction row doesn't exist yet, but
|
||||
* the related item does (when selecting an existing item)
|
||||
*/
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
junctionRowPrimaryKey.value = item[junctionPrimaryKey] || '+';
|
||||
relatedRowPrimaryKey.value = item[junctionField]?.[relatedPrimaryKey] || '+';
|
||||
editsAtStart.value = item.$stagedEdits || null;
|
||||
showDetailModal.value = true;
|
||||
if (newValue.length === 0) emit(null);
|
||||
else emit(newValue);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editsAtStart.value = {};
|
||||
showDetailModal.value = false;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
currentlyEditing.value = null;
|
||||
}
|
||||
|
||||
function stageEdits(edits: any) {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
if (isNew.value) {
|
||||
edits.$new = true;
|
||||
}
|
||||
|
||||
const currentValue = [...(value.value || [])];
|
||||
|
||||
// If there weren't any previously made edits, it's safe to assume this change value
|
||||
// doesn't exist yet in the staged value
|
||||
if (!editsAtStart.value) {
|
||||
// If the item that we edited has any of the primary keys (junction/related), we
|
||||
// have to make sure we stage those as well. Otherwise the API will treat it as
|
||||
// a newly created item instead of updated existing
|
||||
if (junctionRowPrimaryKey.value !== '+') {
|
||||
set(edits, junctionPrimaryKey, junctionRowPrimaryKey.value);
|
||||
}
|
||||
|
||||
if (relatedRowPrimaryKey.value !== '+') {
|
||||
set(edits, [junctionField, relatedPrimaryKey], relatedRowPrimaryKey.value);
|
||||
}
|
||||
|
||||
onEdit([...currentValue, edits]);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue =
|
||||
value.value?.map((stagedValue: any) => {
|
||||
if (stagedValue === editsAtStart.value) return edits;
|
||||
return stagedValue;
|
||||
}) || null;
|
||||
|
||||
onEdit(newValue);
|
||||
reset();
|
||||
|
||||
function reset() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
isNew.value = false;
|
||||
}
|
||||
}
|
||||
return { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit };
|
||||
}
|
||||
|
||||
@@ -1,235 +1,175 @@
|
||||
import { Ref, ref, watch } from '@vue/composition-api';
|
||||
import { Ref, ref, watch, computed } from '@vue/composition-api';
|
||||
import { Header } from '@/components/v-table/types';
|
||||
import { RelationInfo } from './use-relation';
|
||||
import { useFieldsStore } from '@/stores/';
|
||||
import { Field, Collection } from '@/types';
|
||||
import api from '@/api';
|
||||
import { Field, Relation } from '@/types';
|
||||
import { merge } from 'lodash';
|
||||
import adjustFieldsForDisplay from '@/utils/adjust-fields-for-displays';
|
||||
import { cloneDeep } from 'lodash';
|
||||
|
||||
/**
|
||||
* Controls what preview is shown in the table. Has some black magic logic to ensure we're able
|
||||
* to show the latest edits, while also maintaining a clean staged value set. This is not responsible
|
||||
* for setting or modifying any data. Preview items should be considered read only
|
||||
*/
|
||||
export default function usePreview(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
fields: Ref<string[]>,
|
||||
relation: Ref<RelationInfo>,
|
||||
getNewSelectedItems: () => Record<string, any>[],
|
||||
getUpdatedItems: () => Record<string, any>[],
|
||||
getNewItems: () => Record<string, any>[],
|
||||
getPrimaryKeys: () => (string | number)[]
|
||||
) {
|
||||
// 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
|
||||
|
||||
type PreviewParam = {
|
||||
value: Ref<any[] | null>;
|
||||
primaryKey: Ref<string | number>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
junctionCollection: Ref<string>;
|
||||
relatedCollection: Ref<string>;
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
relationJunctionToRelated: Ref<Relation | null | undefined>;
|
||||
fields: Ref<readonly string[]>;
|
||||
};
|
||||
|
||||
export default function usePreview({
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
fields,
|
||||
}: PreviewParam) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const tableHeaders = ref<Header[]>([]);
|
||||
const loading = ref(false);
|
||||
const previewItems = ref<readonly any[]>([]);
|
||||
const items = ref<Record<string, any>[]>([]);
|
||||
const error = ref(null);
|
||||
|
||||
// Every time the value changes, we'll reset the preview values. This ensures that we'll
|
||||
// almost show the most up to date information in the preview table, regardless of if this
|
||||
// is the first load or a subsequent edit.
|
||||
watch(value, setPreview, { immediate: true });
|
||||
watch(
|
||||
() => value.value,
|
||||
async (newVal) => {
|
||||
if (newVal === null) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
return { loading, previewItems, error };
|
||||
loading.value = true;
|
||||
const { junctionRelation, relationPkField, junctionPkField } = relation.value;
|
||||
if (junctionRelation === null) return;
|
||||
|
||||
async function setPreview() {
|
||||
loading.value = true;
|
||||
// Load the junction items so we have access to the id's in the related collection
|
||||
const junctionItems = await loadRelatedIds();
|
||||
const relatedPrimaryKeys = junctionItems.map((junction) => junction[junctionRelation]);
|
||||
|
||||
const filteredFields = [...(fields.value.length > 0 ? fields.value : getDefaultFields())];
|
||||
|
||||
if (filteredFields.includes(relationPkField) === false) filteredFields.push(relationPkField);
|
||||
|
||||
try {
|
||||
let responseData: Record<string, any>[] = [];
|
||||
|
||||
if (relatedPrimaryKeys.length > 0) {
|
||||
const endpoint = relation.value.relationCollection.startsWith('directus_')
|
||||
? `/${relation.value.relationCollection.substring(9)}`
|
||||
: `/items/${relation.value.relationCollection}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: filteredFields,
|
||||
[`filter[${relationPkField}][_in]`]: relatedPrimaryKeys.join(','),
|
||||
},
|
||||
});
|
||||
responseData = response?.data.data as Record<string, any>[];
|
||||
}
|
||||
|
||||
// Insert the related items into the junction items
|
||||
const existingItems = responseData.map((data) => {
|
||||
const id = data[relationPkField];
|
||||
const junction = junctionItems.find((junction) => junction[junctionRelation] === id);
|
||||
if (junction === undefined) return;
|
||||
|
||||
const newJunction = cloneDeep(junction);
|
||||
newJunction[junctionRelation] = data;
|
||||
return newJunction;
|
||||
}) as Record<string, any>[];
|
||||
|
||||
const updatedItems = getUpdatedItems();
|
||||
const newItems = getNewItems();
|
||||
|
||||
// Replace existing items with it's updated counterparts
|
||||
const newVal = existingItems
|
||||
.map((item) => {
|
||||
const updatedItem = updatedItems.find(
|
||||
(updated) => updated[junctionPkField] === item[junctionPkField]
|
||||
);
|
||||
if (updatedItem !== undefined) return updatedItem;
|
||||
return item;
|
||||
})
|
||||
.concat(...newItems);
|
||||
items.value = newVal;
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function loadRelatedIds() {
|
||||
const { junctionPkField, junctionRelation, relationPkField } = relation.value;
|
||||
|
||||
try {
|
||||
const existingItems = await fetchExisting();
|
||||
const updatedExistingItems = applyUpdatesToExisting(existingItems);
|
||||
const newlyAddedItems = getNewlyAdded();
|
||||
const newlySelectedItems = await fetchNewlySelectedItems();
|
||||
previewItems.value = [...updatedExistingItems, ...newlyAddedItems, ...newlySelectedItems].filter(
|
||||
(stagedEdit: any) => !stagedEdit['$delete']
|
||||
);
|
||||
let data: Record<string, any>[] = [];
|
||||
const primaryKeys = getPrimaryKeys();
|
||||
|
||||
if (primaryKeys.length > 0) {
|
||||
const endpoint = relation.value.junctionCollection.startsWith('directus_')
|
||||
? `/${relation.value.junctionCollection.substring(9)}`
|
||||
: `/items/${relation.value.junctionCollection}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
[`filter[${junctionPkField}][_in]`]: getPrimaryKeys().join(','),
|
||||
},
|
||||
});
|
||||
data = response?.data.data as Record<string, any>[];
|
||||
}
|
||||
|
||||
const updatedItems = getUpdatedItems().map((item) => ({
|
||||
[junctionRelation]: item[junctionRelation][relationPkField],
|
||||
}));
|
||||
|
||||
// Add all items that already had the id of it's related item
|
||||
return data.concat(...getNewSelectedItems(), ...updatedItems);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks through props.value and applies all staged changes to the existing selected
|
||||
* items. The array of existing items is an array of junction rows, so we can assume
|
||||
* those have a primary key
|
||||
*/
|
||||
function applyUpdatesToExisting(existing: any[]) {
|
||||
return existing.map((existingValue) => {
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const existingPrimaryKey = existingValue[junctionPrimaryKey];
|
||||
const displayItems = computed(() => {
|
||||
const { junctionRelation } = relation.value;
|
||||
return items.value.map((item) => item[junctionRelation]);
|
||||
});
|
||||
|
||||
const stagedEdits: any = (value.value || []).find((update: any) => {
|
||||
const updatePrimaryKey = update[junctionPrimaryKey];
|
||||
return existingPrimaryKey === updatePrimaryKey;
|
||||
});
|
||||
// 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(
|
||||
() => fields.value,
|
||||
() => {
|
||||
tableHeaders.value = (fields.value.length > 0 ? fields.value : getDefaultFields())
|
||||
.map((fieldKey) => {
|
||||
const field = fieldsStore.getField(relation.value.relationCollection, fieldKey);
|
||||
|
||||
if (stagedEdits === undefined) return existingValue;
|
||||
if (!field) return null;
|
||||
|
||||
return {
|
||||
...merge(existingValue, stagedEdits),
|
||||
$stagedEdits: stagedEdits,
|
||||
};
|
||||
});
|
||||
const header: Header = {
|
||||
text: field.name,
|
||||
value: fieldKey,
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: null,
|
||||
field: {
|
||||
display: field.meta?.display,
|
||||
displayOptions: field.meta?.display_options,
|
||||
interface: field.meta?.interface,
|
||||
interfaceOptions: field.meta?.options,
|
||||
type: field.type,
|
||||
field: field.field,
|
||||
},
|
||||
};
|
||||
|
||||
return header;
|
||||
})
|
||||
.filter((h) => h) as Header[];
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function getDefaultFields(): string[] {
|
||||
const fields = fieldsStore.getFieldsForCollection(relation.value.relationCollection);
|
||||
return fields.slice(0, 3).map((field: Field) => field.field);
|
||||
}
|
||||
|
||||
/**
|
||||
* To get the currently selected items, we'll fetch the rows from the junction table
|
||||
* where the field back to the current collection is equal to the primary key. We go
|
||||
* this route as it's more performant than trying to go an extra level deep in the
|
||||
* current item.
|
||||
*/
|
||||
async function fetchExisting() {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
if (!relationJunctionToRelated.value) return;
|
||||
if (!relationJunctionToRelated.value.junction_field) return;
|
||||
|
||||
// If the current item is being created, we don't have to search for existing relations
|
||||
// yet, as they can't have been saved yet.
|
||||
if (primaryKey.value === '+') return [];
|
||||
|
||||
const junctionTable = relationCurrentToJunction.value.many_collection;
|
||||
|
||||
// The stuff we want to fetch is the related junction row, and the content of the
|
||||
// deeply related item nested. This should match the value that's set in the fields
|
||||
// option. We have to make sure we're fetching the primary key of both the junction
|
||||
// as the related item though, as that makes sure we're able to update the item later,
|
||||
// instead of adding a new one in the API.
|
||||
const fieldsToFetch = [...fields.value];
|
||||
|
||||
// The following will add the PK and related items PK to the request fields, like
|
||||
// "id" and "related.id"
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const currentInJunction = relationJunctionToRelated.value.junction_field;
|
||||
|
||||
if (fieldsToFetch.includes(junctionPrimaryKey) === false) fieldsToFetch.push(junctionPrimaryKey);
|
||||
if (fieldsToFetch.includes(`${junctionField}.${relatedPrimaryKey}`) === false)
|
||||
fieldsToFetch.push(`${junctionField}.${relatedPrimaryKey}`);
|
||||
|
||||
const response = await api.get(`/items/${junctionTable}`, {
|
||||
params: {
|
||||
fields: adjustFieldsForDisplay(fieldsToFetch, junctionCollection.value),
|
||||
[`filter[${currentInJunction}][_eq]`]: primaryKey.value,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the newly created rows from props.value. Values that don't have a junction row
|
||||
* primary key and no primary key in the related item are created "totally" new and should
|
||||
* be added to the array of previews as is.
|
||||
* NOTE: This does not included items where the junction row is new, but the related item
|
||||
* isn't.
|
||||
*/
|
||||
function getNewlyAdded() {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
/**
|
||||
* @NOTE There's an interesting case here:
|
||||
*
|
||||
* If you create both a new junction row _and_ a new related row, any selected existing
|
||||
* many to one record won't have it's data object staged, as it already exists (so it's just)
|
||||
* the primary key. This will case a template display to show ???, as it only gets the
|
||||
* primary key. If you saw an issue about that on GitHub, this is where to find it.
|
||||
*
|
||||
* Unlike in fetchNewlySelectedItems(), we can't just fetch the related item, as both
|
||||
* junction and related are new. We _could_ traverse through the object of changes, see
|
||||
* if there's any relational field, and fetch the data based on that combined with the
|
||||
* fields adjusted for the display. While that should work, it's too much of an edge case
|
||||
* for me for now to worry about..
|
||||
*/
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
|
||||
return (value.value || []).filter(
|
||||
(stagedEdit: any) => !stagedEdit.$delete && !stagedEdit[junctionPrimaryKey] && stagedEdit.$new === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tricky case where the user selects an existing item from the related collection
|
||||
* This means the junction doesn't have a primary key yet, and the only value that is
|
||||
* staged is the related item's primary key
|
||||
* In this function, we'll fetch the full existing item from the related collection,
|
||||
* so we can still show it's data in the preview table
|
||||
*/
|
||||
async function fetchNewlySelectedItems() {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
if (!relationJunctionToRelated.value) return [];
|
||||
if (!relationJunctionToRelated.value.junction_field) return [];
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
const newlySelectedStagedItems = (value.value || []).filter(
|
||||
(stagedEdit: any) => !stagedEdit.$delete && !stagedEdit[junctionPrimaryKey] && !stagedEdit.$new
|
||||
);
|
||||
|
||||
const newlySelectedRelatedKeys = newlySelectedStagedItems.map(
|
||||
(stagedEdit: any) => stagedEdit[junctionField][relatedPrimaryKey]
|
||||
);
|
||||
|
||||
// If there's no newly selected related items, we can return here, as there's nothing
|
||||
// to fetch
|
||||
if (newlySelectedRelatedKeys.length === 0) return [];
|
||||
|
||||
// The fields option are set from the viewport of the junction table. Seeing we only
|
||||
// fetch from the related table, we have to filter out all the fields from the junction
|
||||
// table and remove the junction field prefix from the related table columns
|
||||
const fieldsToFetch = fields.value
|
||||
.filter((field) => field.startsWith(junctionField))
|
||||
.map((relatedField) => {
|
||||
return relatedField.replace(junctionField + '.', '');
|
||||
});
|
||||
|
||||
if (fieldsToFetch.includes(relatedPrimaryKey) === false) fieldsToFetch.push(relatedPrimaryKey);
|
||||
|
||||
const endpoint = relatedCollection.value.startsWith('directus_')
|
||||
? `/${relatedCollection.value.substring(9)}/${newlySelectedRelatedKeys.join(',')}`
|
||||
: `/items/${relatedCollection.value}/${newlySelectedRelatedKeys.join(',')}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: adjustFieldsForDisplay(fieldsToFetch, junctionCollection.value),
|
||||
},
|
||||
});
|
||||
|
||||
const data = Array.isArray(response.data.data) ? response.data.data : [response.data.data];
|
||||
|
||||
return newlySelectedStagedItems.map((stagedEdit: any) => {
|
||||
const pk = stagedEdit[junctionField][relatedPrimaryKey];
|
||||
|
||||
const relatedItem = data.find((relatedItem: any) => relatedItem[relatedPrimaryKey] === pk);
|
||||
|
||||
return merge(
|
||||
{
|
||||
[junctionField]: relatedItem,
|
||||
$stagedEdits: stagedEdit,
|
||||
},
|
||||
stagedEdit
|
||||
);
|
||||
});
|
||||
}
|
||||
return { tableHeaders, displayItems, items, loading, error };
|
||||
}
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
import { Ref, computed } from '@vue/composition-api';
|
||||
import { useCollectionsStore, useRelationsStore } from '@/stores/';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { Relation } from '@/types/';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
import { Relation } from '@/types';
|
||||
|
||||
type RelationParams = {
|
||||
collection: Ref<string>;
|
||||
field: Ref<string>;
|
||||
export type RelationInfo = {
|
||||
junctionPkField: string;
|
||||
relationPkField: string;
|
||||
junctionRelation: string;
|
||||
junctionCollection: string;
|
||||
relationCollection: string;
|
||||
};
|
||||
|
||||
export default function useRelation({ collection, field }: RelationParams) {
|
||||
export default function useRelation(collection: Ref<string>, field: Ref<string>) {
|
||||
const relationsStore = useRelationsStore();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
|
||||
// We expect two relations to exist for this field: one from this field to the junction
|
||||
// table, and one from the junction table to the related collection
|
||||
const relations = computed<Relation[]>(() => {
|
||||
return relationsStore.getRelationsForField(collection.value, field.value);
|
||||
const relations = computed(() => {
|
||||
return relationsStore.getRelationsForField(collection.value, field.value) as Relation[];
|
||||
});
|
||||
|
||||
const relationCurrentToJunction = computed(() => {
|
||||
return relations.value.find(
|
||||
(relation: Relation) => relation.one_collection === collection.value && relation.one_field === field.value
|
||||
);
|
||||
const junction = computed(() => {
|
||||
return relations.value.find((relation) => relation.one_collection === collection.value) as Relation;
|
||||
});
|
||||
|
||||
const relationJunctionToRelated = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return null;
|
||||
|
||||
const index = relations.value.indexOf(relationCurrentToJunction.value) === 1 ? 0 : 1;
|
||||
return relations.value[index];
|
||||
const relation = computed(() => {
|
||||
return relations.value.find((relation) => relation.one_collection !== collection.value) as Relation;
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => relations.value[0].many_collection);
|
||||
const relatedCollection = computed(() => relations.value[1].one_collection);
|
||||
const junctionCollection = computed(() => {
|
||||
return collectionsStore.getCollection(junction.value.many_collection)!;
|
||||
});
|
||||
|
||||
const { primaryKeyField: junctionCollectionPrimaryKeyField } = useCollection(junctionCollection);
|
||||
const { primaryKeyField: relatedCollectionPrimaryKeyField } = useCollection(relatedCollection);
|
||||
const relationCollection = computed(() => {
|
||||
return collectionsStore.getCollection(relation.value.one_collection)!;
|
||||
});
|
||||
|
||||
const { primaryKeyField: junctionPrimaryKeyField } = useCollection(junctionCollection.value.collection);
|
||||
const { primaryKeyField: relationPrimaryKeyField } = useCollection(relationCollection.value.collection);
|
||||
|
||||
const relationFields = computed(() => {
|
||||
return {
|
||||
junctionPkField: junctionPrimaryKeyField.value.field,
|
||||
relationPkField: relationPrimaryKeyField.value.field,
|
||||
junctionRelation: junction.value.junction_field as string,
|
||||
junctionCollection: junctionCollection.value.collection,
|
||||
relationCollection: relationCollection.value.collection,
|
||||
} as RelationInfo;
|
||||
});
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junction,
|
||||
junctionCollection,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relation,
|
||||
relationCollection,
|
||||
relationFields,
|
||||
junctionPrimaryKeyField,
|
||||
relationPrimaryKeyField,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,77 +1,54 @@
|
||||
import { Relation, Filter } from '@/types/';
|
||||
import { Field } from '@/types';
|
||||
import { Ref, ref, computed } from '@vue/composition-api';
|
||||
import { RelationInfo } from './use-relation';
|
||||
import { Filter } from '@/types';
|
||||
|
||||
type SelectionParam = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
previewItems: Ref<readonly any[]>;
|
||||
onStageSelection: (selectionAsJunctionRows: any[]) => void;
|
||||
};
|
||||
export default function useSelection(
|
||||
value: Ref<(string | number | Record<string, any>)[] | null>,
|
||||
displayItems: Ref<Record<string, any>[]>,
|
||||
relation: Ref<RelationInfo>,
|
||||
emit: (newVal: any[] | null) => void
|
||||
) {
|
||||
const selectModalActive = ref(false);
|
||||
|
||||
export default function useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection,
|
||||
}: SelectionParam) {
|
||||
const showCollectionModal = ref(false);
|
||||
const selectedPrimaryKeys = computed(() => {
|
||||
if (displayItems.value === null) return [];
|
||||
|
||||
const alreadySelectedRelatedPrimaryKeys = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
const { relationPkField } = relation.value;
|
||||
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const selectedKeys: (number | string)[] = displayItems.value
|
||||
.filter((currentItem) => relationPkField in currentItem)
|
||||
.map((currentItem) => currentItem[relationPkField]);
|
||||
|
||||
return previewItems.value
|
||||
.filter((previewItem: any) => previewItem[junctionField])
|
||||
.map((previewItem: any) => {
|
||||
if (typeof previewItem[junctionField] === 'string' || typeof previewItem[junctionField] === 'number') {
|
||||
return previewItem[junctionField];
|
||||
}
|
||||
|
||||
return previewItem[junctionField][relatedPrimaryKey];
|
||||
})
|
||||
.filter((p) => p);
|
||||
return selectedKeys;
|
||||
});
|
||||
|
||||
const selectionFilters = computed<Filter[]>(() => {
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const { relationPkField } = relation.value;
|
||||
|
||||
if (selectedPrimaryKeys.value.length === 0) return [];
|
||||
|
||||
const filter: Filter = {
|
||||
key: 'selection',
|
||||
field: relatedPrimaryKey,
|
||||
field: relationPkField,
|
||||
operator: 'nin',
|
||||
value: alreadySelectedRelatedPrimaryKeys.value.join(','),
|
||||
value: selectedPrimaryKeys.value.join(','),
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return [filter];
|
||||
});
|
||||
|
||||
return { showCollectionModal, stageSelection, selectionFilters };
|
||||
function stageSelection(newSelection: (number | string)[]) {
|
||||
const { junctionRelation } = relation.value;
|
||||
|
||||
function stageSelection(selection: any) {
|
||||
const selectionAsJunctionRows = selection.map((relatedPrimaryKey: string | number) => {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
const selection = newSelection
|
||||
.filter((item) => selectedPrimaryKeys.value.includes(item) === false)
|
||||
.map((item) => ({ [junctionRelation]: item }));
|
||||
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKeyField = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
return {
|
||||
[junctionField]: {
|
||||
// Technically, "junctionField: primaryKey" should be enough for the api
|
||||
// to do it's thing for newly selected items. However, that would require
|
||||
// the previewItems check to be way more complex. This shouldn't introduce
|
||||
// too much overhead in the API, while drastically simplifying this interface
|
||||
[relatedPrimaryKeyField]: relatedPrimaryKey,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Seeing the collection modal only shows items that haven't been selected yet (using the
|
||||
// filter above), we can safely assume that the items don't exist yet in props.value
|
||||
onStageSelection(selectionAsJunctionRows);
|
||||
const newVal = [...selection, ...(value.value || [])];
|
||||
if (newVal.length === 0) emit(null);
|
||||
else emit(newVal);
|
||||
}
|
||||
|
||||
return { stageSelection, selectModalActive, selectedPrimaryKeys, selectionFilters };
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
</v-notice>
|
||||
<div class="one-to-many" v-else>
|
||||
<v-table
|
||||
:loading="currentLoading"
|
||||
:items="currentItems"
|
||||
:loading="loading"
|
||||
:items="displayItems"
|
||||
:headers.sync="tableHeaders"
|
||||
show-resize
|
||||
inline
|
||||
@@ -27,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deleteItem(item)" />
|
||||
</template>
|
||||
</v-table>
|
||||
|
||||
@@ -69,13 +69,14 @@ import ModalItem from '@/views/private/components/modal-item';
|
||||
import ModalCollection from '@/views/private/components/modal-collection';
|
||||
import { Filter, Field } from '@/types';
|
||||
import { Header } from '@/components/v-table/types';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalItem, ModalCollection },
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
|
||||
default: null,
|
||||
},
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
@@ -104,16 +105,14 @@ export default defineComponent({
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const { relation, relatedCollection, relatedPrimaryKeyField } = useRelation();
|
||||
const { loading: currentLoading, items: currentItems } = useCurrent();
|
||||
const { tableHeaders } = useTable();
|
||||
const { tableHeaders, displayItems, loading, error } = useTable();
|
||||
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdits();
|
||||
const { stageSelection, selectModalActive, selectionFilters } = useSelection();
|
||||
|
||||
return {
|
||||
currentLoading,
|
||||
currentItems,
|
||||
relation,
|
||||
tableHeaders,
|
||||
loading,
|
||||
currentlyEditing,
|
||||
editItem,
|
||||
relatedCollection,
|
||||
@@ -122,10 +121,85 @@ export default defineComponent({
|
||||
cancelEdit,
|
||||
stageSelection,
|
||||
selectModalActive,
|
||||
deleteItem,
|
||||
displayItems,
|
||||
selectionFilters,
|
||||
deselect,
|
||||
};
|
||||
|
||||
function getItem(id: string | number) {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return null;
|
||||
return (
|
||||
props.value.find(
|
||||
(item) => (typeof item === 'object' && pkField in item && item[pkField] === id) || item === id
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
function getNewItems() {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return [];
|
||||
return props.value.filter((item) => typeof item === 'object' && pkField in item === false) as Record<
|
||||
string,
|
||||
any
|
||||
>[];
|
||||
}
|
||||
|
||||
function getUpdatedItems() {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
if (props.value === null) return [];
|
||||
return props.value.filter((item) => typeof item === 'object' && pkField in item === true) as Record<
|
||||
string,
|
||||
any
|
||||
>[];
|
||||
}
|
||||
|
||||
function getExistingItems() {
|
||||
if (props.value === null) return [];
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
return props.value.filter((item) => typeof item === 'string' || typeof item === 'number');
|
||||
}
|
||||
|
||||
function getPrimaryKeys() {
|
||||
if (props.value === null) return [];
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
return props.value
|
||||
.map((item) => {
|
||||
if (typeof item === 'object') {
|
||||
if (pkField in item) return item[pkField];
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
})
|
||||
.filter((i) => i);
|
||||
}
|
||||
|
||||
function deleteItem(item: Record<string, any>) {
|
||||
if (props.value === null) return;
|
||||
|
||||
const relatedPrimKey = relatedPrimaryKeyField.value.field;
|
||||
|
||||
if (relatedPrimKey in item === false) {
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((val) => isEqual(item, val) === false)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = item[relatedPrimKey];
|
||||
emit(
|
||||
'input',
|
||||
props.value.filter((item) => {
|
||||
if (typeof item === 'number' || typeof item === 'string') return item !== id;
|
||||
if (typeof item === 'object' && relatedPrimKey in item) {
|
||||
return item[relatedPrimKey] !== id;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds info about the current relationship, like related collection, primary key field
|
||||
* of the other collection etc
|
||||
@@ -144,162 +218,64 @@ export default defineComponent({
|
||||
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();
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
// 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() {
|
||||
loading.value = true;
|
||||
|
||||
let fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
|
||||
|
||||
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 endpoint = props.collection.startsWith('directus_')
|
||||
? `/${props.collection.substring(9)}/${props.primaryKey}`
|
||||
: `/items/${props.collection}/${props.primaryKey}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
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[]) {
|
||||
loading.value = true;
|
||||
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const itemsWithChangesApplied = 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.many_field] !== 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 = itemsWithChangesApplied.some((update) => update[pkField] === primaryKey);
|
||||
|
||||
return isAlsoUpdate === false;
|
||||
});
|
||||
|
||||
let selectedItems: any[] = [];
|
||||
|
||||
if (selectedPrimaryKeys.length > 0) {
|
||||
const fields = [...props.fields];
|
||||
|
||||
if (fields.includes(relatedPrimaryKeyField.value.field) === false) {
|
||||
fields.push(relatedPrimaryKeyField.value.field);
|
||||
}
|
||||
|
||||
const endpoint = props.collection.startsWith('directus_')
|
||||
? `/${props.collection.substring(9)}/${selectedPrimaryKeys.join(',')}`
|
||||
: `/items/${relatedCollection.value.collection}/${selectedPrimaryKeys.join(',')}`;
|
||||
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: fields,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(response.data.data)) {
|
||||
selectedItems = response.data.data;
|
||||
} else {
|
||||
selectedItems = [response.data.data];
|
||||
}
|
||||
}
|
||||
|
||||
items.value = [...itemsWithChangesApplied, ...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[]>([]);
|
||||
const loading = ref(false);
|
||||
const displayItems = ref<Record<string, any>[]>([]);
|
||||
const error = ref(null);
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
async (newVal) => {
|
||||
loading.value = true;
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const fields = [...(props.fields.length > 0 ? props.fields : getDefaultFields())];
|
||||
|
||||
if (fields.includes(pkField) === false) {
|
||||
fields.push(pkField);
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = relatedCollection.value.collection.startsWith('directus_')
|
||||
? `/${relatedCollection.value.collection.substring(9)}`
|
||||
: `/items/${relatedCollection.value.collection}`;
|
||||
|
||||
const primaryKeys = getPrimaryKeys();
|
||||
|
||||
let existingItems: any[] = [];
|
||||
|
||||
if (primaryKeys && primaryKeys.length > 0) {
|
||||
const response = await api.get(endpoint, {
|
||||
params: {
|
||||
fields: fields,
|
||||
[`filter[${pkField}][_in]`]: primaryKeys.join(','),
|
||||
},
|
||||
});
|
||||
|
||||
existingItems = response.data.data;
|
||||
}
|
||||
|
||||
const updatedItems = getUpdatedItems();
|
||||
const newItems = getNewItems();
|
||||
|
||||
displayItems.value = existingItems
|
||||
.map((item) => {
|
||||
const updatedItem = updatedItems.find((updated) => updated[pkField] === item[pkField]);
|
||||
if (updatedItem !== undefined) return updatedItem;
|
||||
return item;
|
||||
})
|
||||
.concat(...newItems);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 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)
|
||||
@@ -335,7 +311,7 @@ export default defineComponent({
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return { tableHeaders };
|
||||
return { tableHeaders, displayItems, loading, error };
|
||||
}
|
||||
|
||||
function useEdits() {
|
||||
@@ -349,65 +325,45 @@ export default defineComponent({
|
||||
return { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit };
|
||||
|
||||
function editItem(item: any) {
|
||||
const primaryKey = item[relatedPrimaryKeyField.value.field];
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
const hasPrimaryKey = pkField in item;
|
||||
|
||||
// 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;
|
||||
editsAtStart.value = item;
|
||||
currentlyEditing.value = hasPrimaryKey ? item[pkField] : -1;
|
||||
}
|
||||
|
||||
function stageEdits(edits: any) {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const hasPrimaryKey = edits.hasOwnProperty(pkField);
|
||||
const hasPrimaryKey = pkField in edits;
|
||||
|
||||
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);
|
||||
const newValue = (props.value || []).map((item) => {
|
||||
if (
|
||||
typeof item === 'object' &&
|
||||
pkField in item &&
|
||||
pkField in edits &&
|
||||
item[pkField] === edits[pkField]
|
||||
) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
emit('input', newValue);
|
||||
} else {
|
||||
emit('input', [edits]);
|
||||
if (item === edits[pkField]) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
if (editsAtStart.value === item) {
|
||||
return edits;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
if (hasPrimaryKey === false && newValue.includes(edits) === false) {
|
||||
newValue.push(edits);
|
||||
}
|
||||
|
||||
if (newValue.length === 0) emit('input', null);
|
||||
else emit('input', newValue);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
@@ -420,19 +376,26 @@ export default defineComponent({
|
||||
const selectModalActive = ref(false);
|
||||
|
||||
const selectedPrimaryKeys = computed<(number | string)[]>(() => {
|
||||
if (!currentItems.value) return [];
|
||||
if (displayItems.value === null) return [];
|
||||
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
return currentItems.value
|
||||
.filter((currentItem) => currentItem.hasOwnProperty(pkField))
|
||||
|
||||
return displayItems.value
|
||||
.filter((currentItem) => pkField in currentItem)
|
||||
.map((currentItem) => currentItem[pkField]);
|
||||
});
|
||||
|
||||
const selectionFilters = computed<Filter[]>(() => {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
if (selectedPrimaryKeys.value.length === 0) return [];
|
||||
|
||||
const filter: Filter = {
|
||||
key: 'selection',
|
||||
field: relatedPrimaryKeyField.value.field,
|
||||
field: pkField,
|
||||
operator: 'nin',
|
||||
value: selectedPrimaryKeys.value.join(','),
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return [filter];
|
||||
@@ -441,83 +404,17 @@ export default defineComponent({
|
||||
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);
|
||||
}
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const selection = newSelection.filter((item) => selectedPrimaryKeys.value.includes(item) === false);
|
||||
|
||||
const newVal = [...selection, ...(props.value || [])];
|
||||
|
||||
if (newVal.length === 0) emit('input', null);
|
||||
else emit('input', newVal);
|
||||
}
|
||||
}
|
||||
|
||||
function deselect(item: any) {
|
||||
const pkField = relatedPrimaryKeyField.value.field;
|
||||
|
||||
const itemPrimaryKey = item[pkField];
|
||||
|
||||
// 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
|
||||
if (itemPrimaryKey === undefined) {
|
||||
return emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => stagedValue !== item)
|
||||
);
|
||||
}
|
||||
|
||||
// If there's no staged value, it's safe to assume this item was already selected before
|
||||
// and has to be deselected
|
||||
if (props.value === null) {
|
||||
return emit('input', [
|
||||
{
|
||||
[pkField]: itemPrimaryKey,
|
||||
[relation.value.many_field]: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// If the item is selected in the current edits, it will only have staged the primary
|
||||
// key so the API is able to properly set it on first creation. In that case, we have
|
||||
// to filter out the primary key
|
||||
const itemWasNewlySelect = !!props.value.find((stagedItem) => stagedItem === itemPrimaryKey);
|
||||
|
||||
if (itemWasNewlySelect) {
|
||||
currentItems.value = currentItems.value.filter(
|
||||
(itemPreview) => itemPreview[pkField] !== itemPrimaryKey
|
||||
);
|
||||
|
||||
return emit(
|
||||
'input',
|
||||
props.value.filter((stagedValue) => stagedValue !== itemPrimaryKey)
|
||||
);
|
||||
}
|
||||
|
||||
const itemHasEdits =
|
||||
props.value.find((stagedItem: any) => stagedItem[pkField] === itemPrimaryKey) !== undefined;
|
||||
|
||||
if (itemHasEdits) {
|
||||
return emit(
|
||||
'input',
|
||||
props.value.map((stagedValue: any) => {
|
||||
if (stagedValue[pkField] === itemPrimaryKey) {
|
||||
return {
|
||||
[pkField]: itemPrimaryKey,
|
||||
[relation.value.many_field]: null,
|
||||
};
|
||||
}
|
||||
|
||||
return stagedValue;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return emit('input', [
|
||||
...props.value,
|
||||
{
|
||||
[pkField]: itemPrimaryKey,
|
||||
[relation.value.many_field]: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function getDefaultFields(): string[] {
|
||||
const fields = fieldsStore.getFieldsForCollection(relatedCollection.value.collection);
|
||||
return fields.slice(0, 3).map((field: Field) => field.field);
|
||||
|
||||
@@ -708,7 +708,7 @@ function initLocalStore(
|
||||
}
|
||||
|
||||
function fieldExists(collection: string, field: string) {
|
||||
return collectionExists(collection) && fieldsStore.getField(collection, field) !== null;
|
||||
return collectionExists(collection) && !!fieldsStore.getField(collection, field);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user