Files
directus/app/src/stores/fields.ts
Azri Kahar 1c93cc661e Reduce translate function calls in the App (#16038)
* translate fields during hydration

* patch untranslated raw values on field edit

* simplify/remove existing translate usages

* minor codestyle

* translate field note and validation_message

* stub api call in fleld-detail store test

* skip fields translation before user locale sets in

* optimize hydration calls

* use translateLiteral for note & validation_message

* cherry pick field meta properties to patch

* reduce setLanguage calls & settings requests

* settings store test & type update

* tweak variable casing

* test server store & fix existing type/ref issues

* update fields store test for skipTranslation

* fix render-display merge

* await field update before opening drawer

* fetch meta at the end to load relationships first

* add loading state to prevent editing some fields

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
2022-11-30 13:33:31 +01:00

365 lines
12 KiB
TypeScript

import api from '@/api';
import { i18n } from '@/lang';
import { useCollectionsStore } from '@/stores/collections';
import { useRelationsStore } from '@/stores/relations';
import { getLiteralInterpolatedTranslation } from '@/utils/get-literal-interpolated-translation';
import { translate as translateLiteral } from '@/utils/translate-literal';
import { translate } from '@/utils/translate-object-values';
import { unexpectedError } from '@/utils/unexpected-error';
import formatTitle from '@directus/format-title';
import { DeepPartial, Field, FieldRaw, Relation } from '@directus/shared/types';
import { isEqual, isNil, merge, omit, orderBy } from 'lodash';
import { nanoid } from 'nanoid';
import { defineStore } from 'pinia';
type HydrateOptions = {
/**
* Allow disabling field translation on hydrate. Used in global app hydration to account for
* user's custom locale instead of the default en-US locale.
*/
skipTranslation?: boolean;
};
/**
* directus_files is a special case. For it to play nice with interfaces/layouts/displays, we need
* to treat the actual image thumbnail as a separate available field, instead of part of the regular
* item (normally all file related info is nested within a separate column). This allows layouts to
* render out files as it if were a "normal" collection, where the actual file is a fake m2o to
* itself.
*/
const fakeFilesField: Field = {
collection: 'directus_files',
field: '$thumbnail',
schema: null,
name: '$thumbnail',
type: 'integer',
meta: {
id: -1,
collection: 'directus_files',
field: '$thumbnail',
sort: null,
special: null,
interface: null,
options: null,
display: 'file',
display_options: null,
hidden: false,
translations: null,
readonly: true,
width: 'full',
group: null,
note: null,
required: false,
conditions: null,
},
};
/**
* @NOTE
* This keeps track of what update is the last one that's in progress. After every update, the store
* gets flushed with the updated values, which means that you can have racing conditions if you do
* multiple updates at the same time. By keeping track which one is the last one that's fired, we
* can ensure that only the last update gets used to flush the store with.
*/
let currentUpdate: string;
export const useFieldsStore = defineStore({
id: 'fieldsStore',
state: () => ({
fields: [] as Field[],
}),
actions: {
async hydrate(options?: HydrateOptions) {
const fieldsResponse = await api.get<any>(`/fields`, { params: { limit: -1 } });
const fields: FieldRaw[] = fieldsResponse.data.data;
this.fields = [...fields.map(this.parseField), fakeFilesField];
if (options?.skipTranslation !== true) this.translateFields();
},
async dehydrate() {
this.$reset();
},
parseField(field: FieldRaw): Field {
let name = formatTitle(field.field);
const localesToKeep =
field.meta && !isNil(field.meta.translations) && Array.isArray(field.meta.translations)
? field.meta.translations.map((translation) => translation.language)
: [];
for (const locale of i18n.global.availableLocales) {
if (
i18n.global.te(`fields.${field.collection}.${field.field}`, locale) &&
!localesToKeep.includes(locale) &&
!field.meta?.system
) {
i18n.global.mergeLocaleMessage(locale, { fields: { [field.collection]: { [field.field]: undefined } } });
}
}
if (field.meta && !isNil(field.meta.translations) && Array.isArray(field.meta.translations)) {
for (let i = 0; i < field.meta.translations.length; i++) {
const { language, translation } = field.meta.translations[i];
i18n.global.mergeLocaleMessage(language, {
...(translation
? {
fields: {
[field.collection]: {
[field.field]: getLiteralInterpolatedTranslation(translation),
},
},
}
: {}),
});
}
}
if (i18n.global.te(`fields.${field.collection}.${field.field}`)) {
name = i18n.global.t(`fields.${field.collection}.${field.field}`);
}
return {
...field,
name,
};
},
translateFields() {
this.fields = this.fields.map((field) => {
if (i18n.global.te(`fields.${field.collection}.${field.field}`)) {
field.name = i18n.global.t(`fields.${field.collection}.${field.field}`);
}
if (field.meta?.note) field.meta.note = translateLiteral(field.meta.note);
if (field.meta?.options) field.meta.options = translate(field.meta.options);
if (field.meta?.display_options) field.meta.display_options = translate(field.meta.display_options);
if (field.meta?.validation_message)
field.meta.validation_message = translateLiteral(field.meta.validation_message);
return field;
});
},
async upsertField(collection: string, field: string, values: DeepPartial<Field>) {
const existing = this.getField(collection, field);
// Strip out auto-generated fields the app might've added
const rawField = omit(values, ['name']);
if (existing) {
if (isEqual(values, existing)) return;
return await this.updateField(collection, field, rawField);
} else {
return await this.createField(collection, rawField);
}
},
async createField(collectionKey: string, newField: DeepPartial<Field>) {
const stateClone = [...this.fields];
// Save to API, and update local state again to make sure everything is in sync with the
// API
try {
const response = await api.post<{ data: Field }>(`/fields/${collectionKey}`, newField);
const createdField = this.parseField(response.data.data);
this.fields = [...this.fields, createdField];
return createdField;
} catch (err: any) {
// reset the changes if the api sync failed
this.fields = stateClone;
unexpectedError(err);
}
},
async updateField(collectionKey: string, fieldKey: string, updates: DeepPartial<Field>) {
const stateClone = [...this.fields];
// Update locally first, so the changes are visible immediately
this.fields = this.fields.map((field) => {
if (field.collection === collectionKey && field.field === fieldKey) {
return merge({}, field, updates);
}
return field;
});
// Save to API, and update local state again to make sure everything is in sync with the
// API
try {
const response = await api.patch<any>(`/fields/${collectionKey}/${fieldKey}`, updates);
this.fields = this.fields.map((field) => {
if (field.collection === collectionKey && field.field === fieldKey) {
return this.parseField(response.data.data);
}
return field;
});
} catch (err: any) {
// reset the changes if the api sync failed
this.fields = stateClone;
unexpectedError(err);
}
},
async updateFields(collectionKey: string, updates: DeepPartial<Field>[]) {
const updateID = nanoid();
const stateClone = [...this.fields];
currentUpdate = updateID;
// Update locally first, so the changes are visible immediately
this.fields = this.fields.map((field) => {
if (field.collection === collectionKey) {
const updatesForThisField = updates.find((update) => update.field === field.field);
if (updatesForThisField) {
return merge({}, field, updatesForThisField);
}
}
return field;
});
try {
// Save to API, and update local state again to make sure everything is in sync with the
// API
const response = await api.patch(`/fields/${collectionKey}`, updates);
if (currentUpdate === updateID) {
this.fields = this.fields.map((field) => {
if (field.collection === collectionKey) {
const newDataForField = response.data.data.find((update: Field) => update.field === field.field);
if (newDataForField) return this.parseField(newDataForField);
}
return field;
});
this.translateFields();
}
} catch (err: any) {
// reset the changes if the api sync failed
this.fields = stateClone;
unexpectedError(err);
}
},
async deleteField(collectionKey: string, fieldKey: string) {
const relationsStore = useRelationsStore();
const collectionsStore = useCollectionsStore();
const stateClone = [...this.fields];
const relationsStateClone = [...relationsStore.relations];
this.fields = this.fields.filter((field) => {
if (field.field === fieldKey && field.collection === collectionKey) return false;
return true;
});
relationsStore.relations = relationsStore.relations.filter((relation) => {
if (
(relation.collection === collectionKey && relation.field === fieldKey) ||
(relation.related_collection === collectionKey && relation.meta?.one_field === fieldKey)
) {
return false;
}
return true;
});
try {
await api.delete(`/fields/${collectionKey}/${fieldKey}`);
await collectionsStore.hydrate();
} catch (err: any) {
this.fields = stateClone;
relationsStore.relations = relationsStateClone;
unexpectedError(err);
}
},
getPrimaryKeyFieldForCollection(collection: string): Field | null {
const primaryKeyField = this.fields.find(
(field) => field.collection === collection && field.schema?.is_primary_key === true
);
return primaryKeyField ?? null;
},
getFieldsForCollection(collection: string): Field[] {
return orderBy(
this.fields.filter((field) => field.collection === collection),
[(field) => field.meta?.system === true, (field) => (field.meta?.sort ? Number(field.meta?.sort) : null)],
['desc', 'asc']
);
},
getFieldsForCollectionAlphabetical(collection: string): Field[] {
return this.getFieldsForCollection(collection).sort((a: Field, b: Field) => {
if (a.field < b.field) return -1;
else if (a.field > b.field) return 1;
else return 1;
});
},
/**
* Retrieve sorted fields including groups. This is necessary because
* fields inside groups starts their sort number from 1 to N again.
*/
getFieldsForCollectionSorted(collection: string): Field[] {
const fields = orderBy(
this.fields.filter((field) => field.collection === collection),
'meta.sort'
);
const nonGroupFields = fields.filter((field: Field) => !field.meta?.group);
for (const [index, field] of nonGroupFields.entries()) {
const groupFields = fields.filter((groupField: Field) => groupField.meta?.group === field.field);
if (groupFields.length) {
nonGroupFields.splice(index + 1, 0, ...orderBy(groupFields, 'meta.sort'));
}
}
return nonGroupFields;
},
/**
* Retrieve field info for a field or a related field
*/
getField(collection: string, fieldKey: string): Field | null {
if (fieldKey.includes('.')) {
return this.getRelationalField(collection, fieldKey) || null;
} else {
return this.fields.find((field) => field.collection === collection && field.field === fieldKey) || null;
}
},
/**
* Retrieve nested fields for a given group field
*/
getFieldGroupChildren(collection: string, fieldKey: string): Field[] | null {
return this.fields.filter((field) => field.collection === collection && field.meta?.group === fieldKey) || null;
},
/**
* Retrieve field info for a (deeply) nested field
* Recursively searches through the relationships to find the field info that matches the
* dot notation.
*/
getRelationalField(collection: string, fields: string) {
const relationsStore = useRelationsStore();
const [field, ...path] = fields.split('.');
if (field.includes(':')) {
const [_, collection] = field.split(':');
return this.getField(collection, path.join('.'));
}
const relations = relationsStore.getRelationsForField(collection, field);
const relation = relations?.find((relation: Relation) => {
return relation.field === field || relation.meta?.one_field === field;
});
if (relation === undefined) return false;
const relatedCollection = relation.field === field ? relation.related_collection : relation.collection;
if (relatedCollection === null) return false;
const relatedField = path.join('.');
return this.getField(relatedCollection, relatedField);
},
},
});