mirror of
https://github.com/directus/directus.git
synced 2026-04-03 03:00:39 -04:00
Move some compositons, utils and types to shared (#8059)
* move composables, types and utils to shared * move composables, utils and types to shared * expose utils and composables in extensionsSDK * fix missing dependencies * Sort index.ts exports * Do the thing Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -57,6 +57,7 @@
|
||||
"lodash": "4.17.21",
|
||||
"pino": "*",
|
||||
"vue": "3",
|
||||
"vue-i18n": "9",
|
||||
"vue-router": "4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
export * from './use-collection';
|
||||
export * from './use-filter-fields';
|
||||
export * from './use-items';
|
||||
export * from './use-sync';
|
||||
export * from './use-system';
|
||||
|
||||
74
packages/shared/src/composables/use-collection.ts
Normal file
74
packages/shared/src/composables/use-collection.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useStores } from './use-system';
|
||||
import { Collection, Field } from '../types';
|
||||
import { computed, ref, Ref, ComputedRef } from 'vue';
|
||||
|
||||
type UsableCollection = {
|
||||
info: ComputedRef<Collection | null>;
|
||||
fields: ComputedRef<Field[]>;
|
||||
defaults: Record<string, any>;
|
||||
primaryKeyField: ComputedRef<Field | null>;
|
||||
userCreatedField: ComputedRef<Field | null>;
|
||||
sortField: ComputedRef<string | null>;
|
||||
isSingleton: ComputedRef<boolean>;
|
||||
accountabilityScope: ComputedRef<'all' | 'activity' | null>;
|
||||
};
|
||||
|
||||
export function useCollection(collectionKey: string | Ref<string | null>): UsableCollection {
|
||||
const { useCollectionsStore, useFieldsStore } = useStores();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const collection: Ref<string | null> = typeof collectionKey === 'string' ? ref(collectionKey) : collectionKey;
|
||||
|
||||
const info = computed(() => {
|
||||
return (
|
||||
(collectionsStore.collections as Collection[]).find(({ collection: key }) => key === collection.value) || null
|
||||
);
|
||||
});
|
||||
|
||||
const fields = computed(() => {
|
||||
if (!collection.value) return [];
|
||||
return fieldsStore.getFieldsForCollection(collection.value) as Field[];
|
||||
});
|
||||
|
||||
const defaults = computed(() => {
|
||||
if (!fields.value) return {};
|
||||
|
||||
const defaults: Record<string, any> = {};
|
||||
|
||||
for (const field of fields.value) {
|
||||
if (field.schema?.default_value) {
|
||||
defaults[field.field] = field.schema.default_value;
|
||||
}
|
||||
}
|
||||
|
||||
return defaults;
|
||||
});
|
||||
|
||||
const primaryKeyField = computed(() => {
|
||||
return (
|
||||
fields.value.find((field) => field.collection === collection.value && field.schema?.is_primary_key === true) ||
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const userCreatedField = computed(() => {
|
||||
return fields.value?.find((field) => (field.meta?.special || []).includes('user_created')) || null;
|
||||
});
|
||||
|
||||
const sortField = computed(() => {
|
||||
return info.value?.meta?.sort_field || null;
|
||||
});
|
||||
|
||||
const isSingleton = computed(() => {
|
||||
return info.value?.meta?.singleton === true;
|
||||
});
|
||||
|
||||
const accountabilityScope = computed(() => {
|
||||
if (!info.value) return null;
|
||||
if (!info.value.meta) return null;
|
||||
return info.value.meta.accountability;
|
||||
});
|
||||
|
||||
return { info, fields, defaults, primaryKeyField, userCreatedField, sortField, isSingleton, accountabilityScope };
|
||||
}
|
||||
21
packages/shared/src/composables/use-filter-fields.ts
Normal file
21
packages/shared/src/composables/use-filter-fields.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Field } from '../types';
|
||||
import { Ref, computed } from 'vue';
|
||||
|
||||
export function useFilterFields<T extends string>(fields: Ref<Field[]>, filters: Record<T, (field: Field) => boolean>) {
|
||||
const fieldGroups = computed(() => {
|
||||
const acc = {} as Record<Extract<T, string>, Field[]>;
|
||||
for (const name in filters) {
|
||||
acc[name] = [];
|
||||
}
|
||||
|
||||
return fields.value.reduce((acc, field) => {
|
||||
for (const name in filters) {
|
||||
if (filters[name](field) === false) continue;
|
||||
acc[name].push(field);
|
||||
}
|
||||
return acc;
|
||||
}, acc);
|
||||
});
|
||||
|
||||
return { fieldGroups };
|
||||
}
|
||||
272
packages/shared/src/composables/use-items.ts
Normal file
272
packages/shared/src/composables/use-items.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useApi } from './use-system';
|
||||
import { useCollection } from './use-collection';
|
||||
import { Filter, Item } from '../types';
|
||||
import { moveInArray, filtersToQuery } from '../utils';
|
||||
import { isEqual, orderBy, throttle } from 'lodash';
|
||||
import { computed, ComputedRef, nextTick, ref, Ref, watch } from 'vue';
|
||||
|
||||
type Query = {
|
||||
limit: Ref<number>;
|
||||
fields: Ref<readonly string[]>;
|
||||
sort: Ref<string>;
|
||||
page: Ref<number>;
|
||||
filters: Ref<readonly Filter[]>;
|
||||
searchQuery: Ref<string | null>;
|
||||
};
|
||||
|
||||
type ManualSortData = {
|
||||
item: string | number;
|
||||
to: string | number;
|
||||
};
|
||||
|
||||
type UsableItems = {
|
||||
itemCount: Ref<number | null>;
|
||||
totalCount: Ref<number | null>;
|
||||
items: Ref<Item[]>;
|
||||
totalPages: ComputedRef<number>;
|
||||
loading: Ref<boolean>;
|
||||
error: Ref<any>;
|
||||
changeManualSort: (data: ManualSortData) => Promise<void>;
|
||||
getItems: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function useItems(collection: Ref<string | null>, query: Query, fetchOnInit = true): UsableItems {
|
||||
const api = useApi();
|
||||
const { primaryKeyField, sortField } = useCollection(collection);
|
||||
|
||||
let loadingTimeout: number | null = null;
|
||||
|
||||
const { limit, fields, sort, page, filters, searchQuery } = query;
|
||||
|
||||
const endpoint = computed(() => {
|
||||
if (!collection.value) return null;
|
||||
return collection.value.startsWith('directus_')
|
||||
? `/${collection.value.substring(9)}`
|
||||
: `/items/${collection.value}`;
|
||||
});
|
||||
|
||||
const items = ref<Item[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<any>(null);
|
||||
|
||||
const itemCount = ref<number | null>(null);
|
||||
const totalCount = ref<number | null>(null);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (itemCount.value === null) return 1;
|
||||
if (itemCount.value < limit.value) return 1;
|
||||
return Math.ceil(itemCount.value / limit.value);
|
||||
});
|
||||
|
||||
if (fetchOnInit) {
|
||||
getItems();
|
||||
}
|
||||
|
||||
watch(
|
||||
collection,
|
||||
async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Waiting for the tick here makes sure the query have been adjusted for the new
|
||||
// collection
|
||||
await nextTick();
|
||||
reset();
|
||||
getItems();
|
||||
},
|
||||
{ immediate: fetchOnInit }
|
||||
);
|
||||
|
||||
watch([page, fields], async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (loading.value === false) {
|
||||
getItems();
|
||||
}
|
||||
});
|
||||
|
||||
watch(sort, async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// When all items are on page, we only sort locally
|
||||
const hasAllItems = limit.value > (itemCount.value || 0);
|
||||
|
||||
if (hasAllItems) {
|
||||
sortItems(after);
|
||||
return;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (loading.value === false) {
|
||||
getItems();
|
||||
}
|
||||
});
|
||||
|
||||
watch([filters, limit], async (after, before) => {
|
||||
if (!before || isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
page.value = 1;
|
||||
await nextTick();
|
||||
if (loading.value === false) {
|
||||
getItems();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
searchQuery,
|
||||
throttle(
|
||||
async (after, before) => {
|
||||
if (isEqual(after, before)) {
|
||||
return;
|
||||
}
|
||||
page.value = 1;
|
||||
await nextTick();
|
||||
if (loading.value === false) {
|
||||
getItems();
|
||||
}
|
||||
},
|
||||
500,
|
||||
{ trailing: true }
|
||||
)
|
||||
);
|
||||
|
||||
return { itemCount, totalCount, items, totalPages, loading, error, changeManualSort, getItems };
|
||||
|
||||
async function getItems() {
|
||||
if (loadingTimeout || !endpoint.value) return;
|
||||
|
||||
error.value = null;
|
||||
|
||||
loadingTimeout = window.setTimeout(() => {
|
||||
loading.value = true;
|
||||
}, 250);
|
||||
|
||||
let fieldsToFetch = [...fields.value];
|
||||
|
||||
// Make sure the primary key is always fetched
|
||||
if (
|
||||
fields.value.includes('*') === false &&
|
||||
primaryKeyField.value &&
|
||||
fieldsToFetch.includes(primaryKeyField.value.field) === false
|
||||
) {
|
||||
fieldsToFetch.push(primaryKeyField.value.field);
|
||||
}
|
||||
|
||||
// Make sure all fields that are used to filter are fetched
|
||||
if (fields.value.includes('*') === false) {
|
||||
filters.value.forEach((filter) => {
|
||||
if (fieldsToFetch.includes(filter.field as string) === false) {
|
||||
fieldsToFetch.push(filter.field as string);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Make sure that the field we're sorting on is fetched
|
||||
if (fields.value.includes('*') === false && sortField.value && sort.value) {
|
||||
const sortFieldKey = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
|
||||
if (fieldsToFetch.includes(sortFieldKey) === false) {
|
||||
fieldsToFetch.push(sortFieldKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out fake internal columns. This is (among other things) for a fake $thumbnail m2o field
|
||||
// on directus_files
|
||||
fieldsToFetch = fieldsToFetch.filter((field) => field.startsWith('$') === false);
|
||||
|
||||
try {
|
||||
const response = await api.get(endpoint.value, {
|
||||
params: {
|
||||
limit: limit.value,
|
||||
fields: fieldsToFetch,
|
||||
sort: sort.value,
|
||||
page: page.value,
|
||||
search: searchQuery.value,
|
||||
...filtersToQuery(filters.value),
|
||||
},
|
||||
});
|
||||
|
||||
let fetchedItems = response.data.data;
|
||||
|
||||
/**
|
||||
* @NOTE
|
||||
*
|
||||
* This is used in conjunction with the fake field in /src/stores/fields/fields.ts to be
|
||||
* able to render out the directus_files collection (file library) using regular layouts
|
||||
*
|
||||
* Layouts expect the file to be a m2o of a `file` type, however, directus_files is the
|
||||
* only collection that doesn't have this (obviously). This fake $thumbnail field is used to
|
||||
* pretend there is a file m2o, so we can use the regular layout logic for files as well
|
||||
*/
|
||||
if (collection.value === 'directus_files') {
|
||||
fetchedItems = fetchedItems.map((file: any) => ({
|
||||
...file,
|
||||
$thumbnail: file,
|
||||
}));
|
||||
}
|
||||
|
||||
items.value = fetchedItems;
|
||||
itemCount.value = response.data.data.length;
|
||||
|
||||
if (fetchedItems.length === 0 && page.value !== 1) {
|
||||
page.value = 1;
|
||||
}
|
||||
|
||||
getItemCount();
|
||||
} catch (err: any) {
|
||||
error.value = err;
|
||||
} finally {
|
||||
clearTimeout(loadingTimeout);
|
||||
loadingTimeout = null;
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getItemCount() {
|
||||
if (!primaryKeyField.value || !endpoint.value) return;
|
||||
|
||||
const response = await api.get(endpoint.value, {
|
||||
params: {
|
||||
limit: 0,
|
||||
fields: primaryKeyField.value.field,
|
||||
meta: ['filter_count', 'total_count'],
|
||||
search: searchQuery.value,
|
||||
...filtersToQuery(filters.value),
|
||||
},
|
||||
});
|
||||
|
||||
totalCount.value = response.data.meta.total_count;
|
||||
itemCount.value = response.data.meta.filter_count;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
items.value = [];
|
||||
totalCount.value = null;
|
||||
itemCount.value = null;
|
||||
}
|
||||
|
||||
function sortItems(sortBy: string) {
|
||||
const field = sortBy.startsWith('-') ? sortBy.substring(1) : sortBy;
|
||||
const descending = sortBy.startsWith('-');
|
||||
items.value = orderBy(items.value, [field], [descending ? 'desc' : 'asc']);
|
||||
}
|
||||
|
||||
async function changeManualSort({ item, to }: ManualSortData) {
|
||||
const pk = primaryKeyField.value?.field;
|
||||
if (!pk) return;
|
||||
|
||||
const fromIndex = items.value.findIndex((existing: Record<string, any>) => existing[pk] === item);
|
||||
const toIndex = items.value.findIndex((existing: Record<string, any>) => existing[pk] === to);
|
||||
|
||||
items.value = moveInArray(items.value, fromIndex, toIndex);
|
||||
|
||||
const endpoint = computed(() => `/utils/sort/${collection.value}`);
|
||||
await api.post(endpoint.value, { item, to });
|
||||
}
|
||||
}
|
||||
16
packages/shared/src/composables/use-sync.ts
Normal file
16
packages/shared/src/composables/use-sync.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { computed, Ref } from 'vue';
|
||||
|
||||
export function useSync<T, K extends keyof T & string, E extends (event: `update:${K}`, ...args: any[]) => void>(
|
||||
props: T,
|
||||
key: K,
|
||||
emit: E
|
||||
): Ref<T[K]> {
|
||||
return computed<T[K]>({
|
||||
get() {
|
||||
return props[key];
|
||||
},
|
||||
set(newVal) {
|
||||
emit(`update:${key}` as const, newVal);
|
||||
},
|
||||
});
|
||||
}
|
||||
35
packages/shared/src/types/collection.ts
Normal file
35
packages/shared/src/types/collection.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
type Translations = {
|
||||
language: string;
|
||||
translation: string;
|
||||
singular: string;
|
||||
plural: string;
|
||||
};
|
||||
|
||||
export interface CollectionRaw {
|
||||
collection: string;
|
||||
meta: {
|
||||
note: string | null;
|
||||
hidden: boolean;
|
||||
singleton: boolean;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
translations: Translations[] | null;
|
||||
display_template: string | null;
|
||||
sort_field: string | null;
|
||||
archive_field: string | null;
|
||||
archive_value: string | null;
|
||||
unarchive_value: string | null;
|
||||
archive_app_filter: boolean;
|
||||
item_duplication_fields: string[] | null;
|
||||
accountability: 'all' | 'activity' | null;
|
||||
} | null;
|
||||
schema: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface Collection extends CollectionRaw {
|
||||
name: string | TranslateResult;
|
||||
icon: string;
|
||||
color?: string | null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './accountability';
|
||||
export * from './collection';
|
||||
export * from './displays';
|
||||
export * from './endpoints';
|
||||
export * from './extensions';
|
||||
@@ -13,5 +14,6 @@ export * from './misc';
|
||||
export * from './modules';
|
||||
export * from './permissions';
|
||||
export * from './presets';
|
||||
export * from './relations';
|
||||
export * from './settings';
|
||||
export * from './users';
|
||||
|
||||
18
packages/shared/src/types/relations.ts
Normal file
18
packages/shared/src/types/relations.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type Relation = {
|
||||
collection: string;
|
||||
field: string;
|
||||
related_collection: string | null;
|
||||
meta?: {
|
||||
id: number;
|
||||
many_collection: string;
|
||||
many_field: string;
|
||||
one_collection: string;
|
||||
one_field: null | string;
|
||||
junction_field: null | string;
|
||||
sort_field: null | string;
|
||||
one_deselect_action: 'nullify' | 'delete';
|
||||
one_collection_field: null | string;
|
||||
one_allowed_collections: null | string[];
|
||||
};
|
||||
schema?: Record<string, unknown>;
|
||||
};
|
||||
40
packages/shared/src/utils/filters-to-query.ts
Normal file
40
packages/shared/src/utils/filters-to-query.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Filter } from '../types';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
export function filtersToQuery(filters: readonly Filter[]): { filter: Record<string, any> } {
|
||||
const filterList: Record<string, any>[] = [];
|
||||
|
||||
for (const filter of filters) {
|
||||
const { field, operator } = clone(filter) as any;
|
||||
let { value } = clone(filter) as any;
|
||||
|
||||
if (['empty', 'nempty', 'null', 'nnull'].includes(operator)) {
|
||||
value = true;
|
||||
}
|
||||
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (field.includes('.')) {
|
||||
let filter: Record<string, any> = { [`_${operator}`]: value };
|
||||
const path = field.split('.');
|
||||
|
||||
for (const field of path.reverse()) {
|
||||
filter = { [field]: filter };
|
||||
}
|
||||
|
||||
filterList.push(filter);
|
||||
} else {
|
||||
filterList.push({ [field]: { [`_${operator}`]: value } });
|
||||
}
|
||||
}
|
||||
|
||||
let filterQuery: Record<string, any> = {};
|
||||
|
||||
if (filterList.length === 1 && filterList[0] !== undefined) {
|
||||
filterQuery = filterList[0];
|
||||
} else if (filterList.length > 1) {
|
||||
filterQuery = { _and: filterList };
|
||||
}
|
||||
|
||||
return { filter: filterQuery };
|
||||
}
|
||||
16
packages/shared/src/utils/get-fields-from-template.ts
Normal file
16
packages/shared/src/utils/get-fields-from-template.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function getFieldsFromTemplate(template: string | null): string[] {
|
||||
if (template === null) return [];
|
||||
|
||||
const regex = /{{(.*?)}}/g;
|
||||
let fields = template.match(regex);
|
||||
|
||||
if (!Array.isArray(fields)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
fields = fields.map((field) => {
|
||||
return field.replace(/{{/g, '').replace(/}}/g, '').trim();
|
||||
});
|
||||
|
||||
return fields as string[];
|
||||
}
|
||||
30
packages/shared/src/utils/get-relation-type.ts
Normal file
30
packages/shared/src/utils/get-relation-type.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Relation } from '../types';
|
||||
|
||||
export function getRelationType(getRelationOptions: {
|
||||
relation: Relation;
|
||||
collection: string | null;
|
||||
field: string;
|
||||
}): 'm2o' | 'o2m' | 'm2a' | null {
|
||||
const { relation, collection, field } = getRelationOptions;
|
||||
|
||||
if (!relation) return null;
|
||||
|
||||
if (
|
||||
relation.collection === collection &&
|
||||
relation.field === field &&
|
||||
relation.meta?.one_collection_field &&
|
||||
relation.meta?.one_allowed_collections
|
||||
) {
|
||||
return 'm2a';
|
||||
}
|
||||
|
||||
if (relation.collection === collection && relation.field === field) {
|
||||
return 'm2o';
|
||||
}
|
||||
|
||||
if (relation.related_collection === collection && relation.meta?.one_field === field) {
|
||||
return 'o2m';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
export * from './adjust-date';
|
||||
export * from './deep-map';
|
||||
export * from './define-extension';
|
||||
export * from './filters-to-query';
|
||||
export * from './generate-joi';
|
||||
export * from './get-fields-from-template';
|
||||
export * from './get-filter-operators-for-type';
|
||||
export * from './get-relation-type';
|
||||
export * from './is-extension';
|
||||
export * from './move-in-array';
|
||||
export * from './parse-filter';
|
||||
export * from './pluralize';
|
||||
export * from './to-array';
|
||||
|
||||
28
packages/shared/src/utils/move-in-array.ts
Normal file
28
packages/shared/src/utils/move-in-array.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function moveInArray<T = any>(array: T[], fromIndex: number, toIndex: number): T[] {
|
||||
const item = array[fromIndex];
|
||||
const length = array.length;
|
||||
const diff = fromIndex - toIndex;
|
||||
|
||||
if (item === undefined) return array;
|
||||
|
||||
if (diff > 0) {
|
||||
// move left
|
||||
return [
|
||||
...array.slice(0, toIndex),
|
||||
item,
|
||||
...array.slice(toIndex, fromIndex),
|
||||
...array.slice(fromIndex + 1, length),
|
||||
];
|
||||
} else if (diff < 0) {
|
||||
// move right
|
||||
const targetIndex = toIndex + 1;
|
||||
return [
|
||||
...array.slice(0, fromIndex),
|
||||
...array.slice(fromIndex + 1, targetIndex),
|
||||
item,
|
||||
...array.slice(targetIndex, length),
|
||||
];
|
||||
}
|
||||
|
||||
return array;
|
||||
}
|
||||
Reference in New Issue
Block a user