mirror of
https://github.com/directus/directus.git
synced 2026-01-28 03:08:03 -05:00
Multiple files (#685)
* Start on files structure * Move relationship helper to separate file * Extract useSelection to file * Extract use preview * Remove unused imports * Extract use-edit * Remove unused import * Use m2m base to create files
This commit is contained in:
290
src/interfaces/files/files.vue
Normal file
290
src/interfaces/files/files.vue
Normal file
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="!relations || relations.length !== 2">
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div v-else class="files">
|
||||
<v-table
|
||||
inline
|
||||
:items="previewItems"
|
||||
:loading="loading"
|
||||
:headers.sync="tableHeaders"
|
||||
:item-key="junctionCollectionPrimaryKeyField.field"
|
||||
:disabled="disabled"
|
||||
@click:row="editExisting"
|
||||
>
|
||||
<template #item.$thumbnail="{ item }">
|
||||
<render-display
|
||||
:value="get(item, [relationCurrentToJunction.junction_field])"
|
||||
display="file"
|
||||
:collection="junctionCollection"
|
||||
:field="relationCurrentToJunction.junction_field"
|
||||
type="file"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item-append="{ item }" v-if="!disabled">
|
||||
<v-icon name="close" v-tooltip="$t('deselect')" class="deselect" @click.stop="deselect(item)" />
|
||||
</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="showBrowseModal = true">
|
||||
{{ $t('add_existing') }}
|
||||
</v-button>
|
||||
</div>
|
||||
|
||||
<modal-detail
|
||||
v-if="!disabled"
|
||||
:active="showDetailModal"
|
||||
:collection="junctionCollection"
|
||||
:primary-key="junctionRowPrimaryKey"
|
||||
:edits="editsAtStart"
|
||||
:junction-field="relationCurrentToJunction.junction_field"
|
||||
:related-primary-key="relatedRowPrimaryKey"
|
||||
@input="stageEdits"
|
||||
@update:active="cancelEdit"
|
||||
/>
|
||||
|
||||
<modal-browse
|
||||
v-if="!disabled"
|
||||
:active.sync="showBrowseModal"
|
||||
:collection="relationJunctionToRelated.collection_one"
|
||||
:selection="[]"
|
||||
:filters="selectionFilters"
|
||||
@input="stageSelection"
|
||||
multiple
|
||||
/>
|
||||
|
||||
<v-dialog v-model="showUpload">
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('upload_file') }}</v-card-title>
|
||||
<v-card-text><v-upload @upload="onUpload" /></v-card-text>
|
||||
<v-card-actions>
|
||||
<v-button @click="showUpload = false">{{ $t('done') }}</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, computed, toRefs } from '@vue/composition-api';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalDetail from '@/views/private/components/modal-detail';
|
||||
import { get } from 'lodash';
|
||||
import i18n from '@/lang';
|
||||
|
||||
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';
|
||||
import useEdit from '@/interfaces/many-to-many/use-edit';
|
||||
|
||||
export default defineComponent({
|
||||
components: { ModalBrowse, ModalDetail },
|
||||
props: {
|
||||
primaryKey: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: undefined,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const { collection, field, value, primaryKey } = toRefs(props);
|
||||
|
||||
const {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
} = useRelation({ collection, field });
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
const jf = relationCurrentToJunction.value.junction_field;
|
||||
|
||||
return ['id', 'data', 'type', 'title'].map((key) => `${jf}.${key}`);
|
||||
});
|
||||
|
||||
const tableHeaders = ref<TableHeader[]>([
|
||||
{
|
||||
text: '',
|
||||
value: '$thumbnail',
|
||||
align: 'left',
|
||||
sortable: false,
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
text: i18n.t('title'),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
value: relationCurrentToJunction.value!.junction_field + '.title',
|
||||
align: 'left',
|
||||
sortable: true,
|
||||
width: 250,
|
||||
},
|
||||
]);
|
||||
|
||||
const { loading, previewItems, error } = usePreview({
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
fields,
|
||||
});
|
||||
|
||||
const {
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
} = useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
value,
|
||||
onEdit: (newValue) => emit('input', newValue),
|
||||
});
|
||||
|
||||
const { showBrowseModal, stageSelection, selectionFilters } = useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection: (selectionAsJunctionRows) => {
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
},
|
||||
});
|
||||
|
||||
const { showUpload, onUpload } = useUpload();
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
tableHeaders,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
loading,
|
||||
previewItems,
|
||||
error,
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
showUpload,
|
||||
stageEdits,
|
||||
editsAtStart,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
showBrowseModal,
|
||||
stageSelection,
|
||||
selectionFilters,
|
||||
relatedCollection,
|
||||
initialValues,
|
||||
get,
|
||||
deselect,
|
||||
onUpload,
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
const fileAsJunctionRow = {
|
||||
[relationCurrentToJunction.value.junction_field]: {
|
||||
id: file.id,
|
||||
},
|
||||
};
|
||||
|
||||
emit('input', [...(props.value || []), fileAsJunctionRow]);
|
||||
|
||||
showUpload.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.existing {
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.deselect {
|
||||
--v-icon-color: var(--foreground-subdued);
|
||||
|
||||
&:hover {
|
||||
--v-icon-color: var(--danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
10
src/interfaces/files/index.ts
Normal file
10
src/interfaces/files/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceFiles from './files.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'files',
|
||||
name: i18n.t('files'),
|
||||
icon: 'note_add',
|
||||
component: InterfaceFiles,
|
||||
options: [],
|
||||
}));
|
||||
@@ -27,6 +27,7 @@ import InterfaceFile from './file';
|
||||
import InterfaceCollections from './collections';
|
||||
import InterfaceTranslations from './translations';
|
||||
import InterfaceManyToMany from './many-to-many';
|
||||
import InterfaceFiles from './files';
|
||||
|
||||
export const interfaces = [
|
||||
InterfaceTextInput,
|
||||
@@ -58,6 +59,7 @@ export const interfaces = [
|
||||
InterfaceCollections,
|
||||
InterfaceTranslations,
|
||||
InterfaceManyToMany,
|
||||
InterfaceFiles,
|
||||
];
|
||||
|
||||
export default interfaces;
|
||||
|
||||
25
src/interfaces/many-to-many/is-new.ts
Normal file
25
src/interfaces/many-to-many/is-new.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Ref } from '@vue/composition-api';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
|
||||
type IsNewContext = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
};
|
||||
|
||||
export default function isNew(
|
||||
item: any,
|
||||
{ relationCurrentToJunction, junctionCollectionPrimaryKeyField, relatedCollectionPrimaryKeyField }: IsNewContext
|
||||
) {
|
||||
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;
|
||||
const hasPrimaryKey = !!item[junctionPrimaryKey];
|
||||
const hasRelatedPrimaryKey = !!item[junctionField]?.[relatedPrimaryKey];
|
||||
|
||||
return hasPrimaryKey === false && hasRelatedPrimaryKey === false;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
<v-notice type="warning" v-if="!relations || relations.length !== 2">
|
||||
{{ $t('relationship_not_setup') }}
|
||||
</v-notice>
|
||||
<div v-else class="files">
|
||||
<div v-else>
|
||||
<v-table
|
||||
inline
|
||||
:items="previewItems"
|
||||
@@ -64,26 +64,23 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref, watch, PropType } from '@vue/composition-api';
|
||||
import useRelationsStore from '@/stores/relations';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { defineComponent, ref, watch, PropType, toRefs } from '@vue/composition-api';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import { Header as TableHeader } from '@/components/v-table/types';
|
||||
import ModalBrowse from '@/views/private/components/modal-browse';
|
||||
import ModalDetail from '@/views/private/components/modal-detail';
|
||||
import api from '@/api';
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
import { merge, set, get } from 'lodash';
|
||||
import adjustFieldsForDisplay from '@/utils/adjust-fields-for-displays';
|
||||
import { get } from 'lodash';
|
||||
|
||||
import useRelation from './use-relation';
|
||||
import useSelection from './use-selection';
|
||||
import usePreview from './use-preview';
|
||||
import useEdit from './use-edit';
|
||||
|
||||
/**
|
||||
* Hi there!
|
||||
*
|
||||
* As you can see by the length of this file and the amount of comments, getting the many to many
|
||||
* right is super complex. Please take proper care when jumping in here and making changes, you might
|
||||
* break more than you'd imagine.
|
||||
* 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>
|
||||
*
|
||||
@@ -93,7 +90,7 @@ import adjustFieldsForDisplay from '@/utils/adjust-fields-for-displays';
|
||||
* 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 1 tricky case to be aware of: selecting an existing related item. In that case,
|
||||
* 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.
|
||||
*/
|
||||
@@ -127,9 +124,9 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
const fieldsStore = useFieldsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const { collection, field, value, primaryKey, fields } = toRefs(props);
|
||||
|
||||
const {
|
||||
relations,
|
||||
@@ -139,9 +136,22 @@ export default defineComponent({
|
||||
junctionCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
} = useRelation();
|
||||
} = useRelation({ collection, field });
|
||||
|
||||
const { tableHeaders } = useTable();
|
||||
const { loading, previewItems, error } = usePreview();
|
||||
|
||||
const { loading, previewItems, error } = usePreview({
|
||||
value,
|
||||
primaryKey,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
junctionCollection,
|
||||
relatedCollection,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
fields,
|
||||
});
|
||||
|
||||
const {
|
||||
showDetailModal,
|
||||
cancelEdit,
|
||||
@@ -152,8 +162,22 @@ export default defineComponent({
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
} = useEdit();
|
||||
const { showBrowseModal, stageSelection, selectionFilters } = useSelection();
|
||||
} = useEdit({
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
value,
|
||||
onEdit: (newValue) => emit('input', newValue),
|
||||
});
|
||||
|
||||
const { showBrowseModal, stageSelection, selectionFilters } = useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection: (selectionAsJunctionRows) => {
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
relations,
|
||||
@@ -182,438 +206,6 @@ export default defineComponent({
|
||||
deselect,
|
||||
};
|
||||
|
||||
/**
|
||||
* Information on the relation to the junction and related collection
|
||||
*/
|
||||
function useRelation() {
|
||||
// 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(props.collection, props.field);
|
||||
});
|
||||
|
||||
const relationCurrentToJunction = computed(() => {
|
||||
return relations.value.find(
|
||||
(relation: Relation) =>
|
||||
relation.collection_one === props.collection && relation.field_one === props.field
|
||||
);
|
||||
});
|
||||
|
||||
const relationJunctionToRelated = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return null;
|
||||
|
||||
const index = relations.value.indexOf(relationCurrentToJunction.value) === 1 ? 0 : 1;
|
||||
return relations.value[index];
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => relations.value[0].collection_many);
|
||||
const relatedCollection = computed(() => relations.value[1].collection_one);
|
||||
|
||||
const { primaryKeyField: junctionCollectionPrimaryKeyField } = useCollection(junctionCollection);
|
||||
const { primaryKeyField: relatedCollectionPrimaryKeyField } = useCollection(relatedCollection);
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollection,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
function usePreview() {
|
||||
const loading = ref(false);
|
||||
const previewItems = ref<readonly 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(() => props.value, setPreview);
|
||||
|
||||
return { loading, previewItems, error };
|
||||
|
||||
async function setPreview() {
|
||||
loading.value = true;
|
||||
|
||||
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']
|
||||
);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 stagedEdits: any = (props.value || []).find((update: any) => {
|
||||
const updatePrimaryKey = update[junctionPrimaryKey];
|
||||
return existingPrimaryKey === updatePrimaryKey;
|
||||
});
|
||||
|
||||
if (stagedEdits === undefined) return existingValue;
|
||||
|
||||
return {
|
||||
...merge(existingValue, stagedEdits),
|
||||
$stagedEdits: stagedEdits,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (props.primaryKey === '+') return [];
|
||||
|
||||
const { currentProjectKey } = projectsStore.state;
|
||||
const junctionTable = relationCurrentToJunction.value.collection_many;
|
||||
|
||||
// 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 fields = [...props.fields];
|
||||
|
||||
// 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 (fields.includes(junctionPrimaryKey) === false) fields.push(junctionPrimaryKey);
|
||||
if (fields.includes(`${junctionField}.${relatedPrimaryKey}`) === false)
|
||||
fields.push(`${junctionField}.${relatedPrimaryKey}`);
|
||||
|
||||
const response = await api.get(`/${currentProjectKey}/items/${junctionTable}`, {
|
||||
params: {
|
||||
fields: adjustFieldsForDisplay(fields, junctionCollection.value),
|
||||
[`filter[${currentInJunction}][eq]`]: props.primaryKey,
|
||||
},
|
||||
});
|
||||
|
||||
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..
|
||||
*/
|
||||
|
||||
return (props.value || []).filter((stagedEdit: any) => !stagedEdit['$delete']).filter(isNew);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { currentProjectKey } = projectsStore.state;
|
||||
|
||||
const junctionPrimaryKey = junctionCollectionPrimaryKeyField.value.field;
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
const newlySelectedStagedItems = (props.value || [])
|
||||
.filter((stagedEdit: any) => !stagedEdit['$delete'])
|
||||
.filter((stagedEdit: any) => {
|
||||
return (
|
||||
stagedEdit[junctionPrimaryKey] === undefined &&
|
||||
stagedEdit[junctionField]?.[relatedPrimaryKey] !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
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 fields = props.fields
|
||||
.filter((field) => field.startsWith(junctionField))
|
||||
.map((relatedField) => {
|
||||
return relatedField.replace(junctionField + '.', '');
|
||||
});
|
||||
|
||||
if (fields.includes(relatedPrimaryKey) === false) fields.push(relatedPrimaryKey);
|
||||
|
||||
const response = await api.get(
|
||||
`/${currentProjectKey}/items/${relatedCollection.value}/${newlySelectedRelatedKeys.join(',')}`,
|
||||
{
|
||||
params: {
|
||||
fields: adjustFieldsForDisplay(fields, 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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything regarding the edit experience in the detail modal. This also includes adding
|
||||
* a new item
|
||||
*/
|
||||
function useEdit() {
|
||||
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);
|
||||
|
||||
return {
|
||||
showDetailModal,
|
||||
editsAtStart,
|
||||
addNew,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
};
|
||||
|
||||
function addNew() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
initialValues.value = null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
if (isNew(item)) {
|
||||
editsAtStart.value = item;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
showDetailModal.value = true;
|
||||
initialValues.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editsAtStart.value = {};
|
||||
showDetailModal.value = false;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const currentValue = [...(props.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);
|
||||
}
|
||||
|
||||
emit('input', [...currentValue, edits]);
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = props.value.map((stagedValue: any) => {
|
||||
if (stagedValue === editsAtStart.value) return edits;
|
||||
return stagedValue;
|
||||
});
|
||||
|
||||
emit('input', newValue);
|
||||
reset();
|
||||
|
||||
function reset() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything regarding the selection of existing related items.
|
||||
*/
|
||||
function useSelection() {
|
||||
const showBrowseModal = ref(false);
|
||||
|
||||
const alreadySelectedRelatedPrimaryKeys = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const selectionFilters = computed<Filter[]>(() => {
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const filter: Filter = {
|
||||
key: 'selection',
|
||||
field: relatedPrimaryKey,
|
||||
operator: 'nin',
|
||||
value: alreadySelectedRelatedPrimaryKeys.value.join(','),
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return [filter];
|
||||
});
|
||||
|
||||
return { showBrowseModal, stageSelection, selectionFilters };
|
||||
|
||||
function stageSelection(selection: any) {
|
||||
const selectionAsJunctionRows = selection.map((relatedPrimaryKey: string | number) => {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
|
||||
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 browse 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
|
||||
emit('input', [...(props.value || []), ...selectionAsJunctionRows]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the state of the table. This includes the table headers, and the event handlers for
|
||||
* the table events
|
||||
@@ -647,19 +239,6 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
function isNew(item: 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;
|
||||
const hasPrimaryKey = !!item[junctionPrimaryKey];
|
||||
const hasRelatedPrimaryKey = !!item[junctionField]?.[relatedPrimaryKey];
|
||||
|
||||
return hasPrimaryKey === false && hasRelatedPrimaryKey === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deselect an item. This either means undoing any changes made (new item), or adding $delete: true
|
||||
* if the junction row already exists.
|
||||
|
||||
140
src/interfaces/many-to-many/use-edit.ts
Normal file
140
src/interfaces/many-to-many/use-edit.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { ref, Ref } from '@vue/composition-api';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
import isNew from './is-new';
|
||||
import { set } from 'lodash';
|
||||
|
||||
type EditParam = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
junctionCollectionPrimaryKeyField: Ref<Field>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
value: Ref<any[] | null>;
|
||||
onEdit: (newValue: any[] | null) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
return {
|
||||
showDetailModal,
|
||||
editsAtStart,
|
||||
addNew,
|
||||
cancelEdit,
|
||||
stageEdits,
|
||||
junctionRowPrimaryKey,
|
||||
editExisting,
|
||||
relatedRowPrimaryKey,
|
||||
initialValues,
|
||||
};
|
||||
|
||||
function addNew() {
|
||||
editsAtStart.value = null;
|
||||
showDetailModal.value = true;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
relatedRowPrimaryKey.value = '+';
|
||||
initialValues.value = null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
if (
|
||||
isNew(item, {
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
})
|
||||
) {
|
||||
editsAtStart.value = item;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
showDetailModal.value = true;
|
||||
initialValues.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editsAtStart.value = {};
|
||||
showDetailModal.value = false;
|
||||
junctionRowPrimaryKey.value = '+';
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 = '+';
|
||||
}
|
||||
}
|
||||
}
|
||||
251
src/interfaces/many-to-many/use-preview.ts
Normal file
251
src/interfaces/many-to-many/use-preview.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { Ref, ref, watch } from '@vue/composition-api';
|
||||
import api from '@/api';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
import { merge } from 'lodash';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import adjustFieldsForDisplay from '@/utils/adjust-fields-for-displays';
|
||||
import isNew from './is-new';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
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 projectsStore = useProjectsStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const previewItems = ref<readonly 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);
|
||||
|
||||
return { loading, previewItems, error };
|
||||
|
||||
async function setPreview() {
|
||||
loading.value = true;
|
||||
|
||||
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']
|
||||
);
|
||||
} catch (err) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 stagedEdits: any = (value.value || []).find((update: any) => {
|
||||
const updatePrimaryKey = update[junctionPrimaryKey];
|
||||
return existingPrimaryKey === updatePrimaryKey;
|
||||
});
|
||||
|
||||
if (stagedEdits === undefined) return existingValue;
|
||||
|
||||
return {
|
||||
...merge(existingValue, stagedEdits),
|
||||
$stagedEdits: stagedEdits,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { currentProjectKey } = projectsStore.state;
|
||||
const junctionTable = relationCurrentToJunction.value.collection_many;
|
||||
|
||||
// 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(`/${currentProjectKey}/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..
|
||||
*/
|
||||
|
||||
return (value.value || [])
|
||||
.filter((stagedEdit: any) => !stagedEdit['$delete'])
|
||||
.filter((item) =>
|
||||
isNew(item, {
|
||||
relationCurrentToJunction,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 { currentProjectKey } = projectsStore.state;
|
||||
|
||||
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'])
|
||||
.filter((stagedEdit: any) => {
|
||||
return (
|
||||
stagedEdit[junctionPrimaryKey] === undefined &&
|
||||
stagedEdit[junctionField]?.[relatedPrimaryKey] !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
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_')
|
||||
? `/${currentProjectKey}/${relatedCollection.value.substring(9)}/${newlySelectedRelatedKeys.join(',')}`
|
||||
: `/${currentProjectKey}/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
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
48
src/interfaces/many-to-many/use-relation.ts
Normal file
48
src/interfaces/many-to-many/use-relation.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Ref, computed } from '@vue/composition-api';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import useRelationsStore from '@/stores/relations';
|
||||
|
||||
type RelationParams = {
|
||||
collection: Ref<string>;
|
||||
field: Ref<string>;
|
||||
};
|
||||
|
||||
export default function useRelation({ collection, field }: RelationParams) {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
// 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 relationCurrentToJunction = computed(() => {
|
||||
return relations.value.find(
|
||||
(relation: Relation) => relation.collection_one === collection.value && relation.field_one === field.value
|
||||
);
|
||||
});
|
||||
|
||||
const relationJunctionToRelated = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return null;
|
||||
|
||||
const index = relations.value.indexOf(relationCurrentToJunction.value) === 1 ? 0 : 1;
|
||||
return relations.value[index];
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => relations.value[0].collection_many);
|
||||
const relatedCollection = computed(() => relations.value[1].collection_one);
|
||||
|
||||
const { primaryKeyField: junctionCollectionPrimaryKeyField } = useCollection(junctionCollection);
|
||||
const { primaryKeyField: relatedCollectionPrimaryKeyField } = useCollection(relatedCollection);
|
||||
|
||||
return {
|
||||
relations,
|
||||
relationCurrentToJunction,
|
||||
relationJunctionToRelated,
|
||||
junctionCollection,
|
||||
junctionCollectionPrimaryKeyField,
|
||||
relatedCollection,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
};
|
||||
}
|
||||
78
src/interfaces/many-to-many/use-selection.ts
Normal file
78
src/interfaces/many-to-many/use-selection.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Filter } from '@/stores/collection-presets/types';
|
||||
import { Relation } from '@/stores/relations/types';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
import { Ref, ref, computed } from '@vue/composition-api';
|
||||
|
||||
type SelectionParam = {
|
||||
relationCurrentToJunction: Ref<Relation | undefined>;
|
||||
relatedCollectionPrimaryKeyField: Ref<Field>;
|
||||
previewItems: Ref<readonly any[]>;
|
||||
onStageSelection: (selectionAsJunctionRows: any[]) => void;
|
||||
};
|
||||
|
||||
export default function useSelection({
|
||||
relationCurrentToJunction,
|
||||
relatedCollectionPrimaryKeyField,
|
||||
previewItems,
|
||||
onStageSelection,
|
||||
}: SelectionParam) {
|
||||
const showBrowseModal = ref(false);
|
||||
|
||||
const alreadySelectedRelatedPrimaryKeys = computed(() => {
|
||||
if (!relationCurrentToJunction.value) return [];
|
||||
if (!relationCurrentToJunction.value.junction_field) return [];
|
||||
|
||||
const junctionField = relationCurrentToJunction.value.junction_field;
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
const selectionFilters = computed<Filter[]>(() => {
|
||||
const relatedPrimaryKey = relatedCollectionPrimaryKeyField.value.field;
|
||||
const filter: Filter = {
|
||||
key: 'selection',
|
||||
field: relatedPrimaryKey,
|
||||
operator: 'nin',
|
||||
value: alreadySelectedRelatedPrimaryKeys.value.join(','),
|
||||
locked: true,
|
||||
};
|
||||
|
||||
return [filter];
|
||||
});
|
||||
|
||||
return { showBrowseModal, stageSelection, selectionFilters };
|
||||
|
||||
function stageSelection(selection: any) {
|
||||
const selectionAsJunctionRows = selection.map((relatedPrimaryKey: string | number) => {
|
||||
if (!relationCurrentToJunction.value) return;
|
||||
if (!relationCurrentToJunction.value.junction_field) return;
|
||||
|
||||
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 browse 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);
|
||||
}
|
||||
}
|
||||
@@ -422,6 +422,7 @@
|
||||
"search_for_icon": "Search for icon...",
|
||||
|
||||
"drop_to_upload": "Drop to Upload",
|
||||
"upload_file": "Upload File",
|
||||
"upload_file_indeterminate": "Uploading File...",
|
||||
"upload_file_success": "File Uploaded",
|
||||
"upload_file_failed": "Couldn't Upload File",
|
||||
|
||||
Reference in New Issue
Block a user