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:
Nitwel
2021-09-15 22:41:08 +02:00
committed by GitHub
parent 4e624d5c7a
commit ce8401b940
101 changed files with 178 additions and 270 deletions

View File

@@ -57,6 +57,7 @@
"lodash": "4.17.21",
"pino": "*",
"vue": "3",
"vue-i18n": "9",
"vue-router": "4"
},
"devDependencies": {

View File

@@ -1 +1,5 @@
export * from './use-collection';
export * from './use-filter-fields';
export * from './use-items';
export * from './use-sync';
export * from './use-system';

View 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 };
}

View 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 };
}

View 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 });
}
}

View 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);
},
});
}

View 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;
}

View File

@@ -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';

View 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>;
};

View 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 };
}

View 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[];
}

View 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;
}

View File

@@ -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';

View 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;
}