@directus/composables 9.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from './use-collection.js';
2
+ export * from './use-custom-selection.js';
3
+ export * from './use-element-size.js';
4
+ export * from './use-filter-fields.js';
5
+ export * from './use-groupable.js';
6
+ export * from './use-items.js';
7
+ export * from './use-layout.js';
8
+ export * from './use-size-class.js';
9
+ export * from './use-sync.js';
10
+ export * from './use-system.js';
@@ -0,0 +1,48 @@
1
+ import { computed, ref } from 'vue';
2
+ import { useStores } from './use-system.js';
3
+ export function useCollection(collectionKey) {
4
+ const { useCollectionsStore, useFieldsStore } = useStores();
5
+ const collectionsStore = useCollectionsStore();
6
+ const fieldsStore = useFieldsStore();
7
+ const collection = typeof collectionKey === 'string' ? ref(collectionKey) : collectionKey;
8
+ const info = computed(() => {
9
+ return (collectionsStore.collections.find(({ collection: key }) => key === collection.value) || null);
10
+ });
11
+ const fields = computed(() => {
12
+ if (!collection.value)
13
+ return [];
14
+ return fieldsStore.getFieldsForCollectionSorted(collection.value);
15
+ });
16
+ const defaults = computed(() => {
17
+ if (!fields.value)
18
+ return {};
19
+ const defaults = {};
20
+ for (const field of fields.value) {
21
+ if (field.schema !== null && 'default_value' in field.schema) {
22
+ defaults[field.field] = field.schema.default_value;
23
+ }
24
+ }
25
+ return defaults;
26
+ });
27
+ const primaryKeyField = computed(() => {
28
+ return (fields.value.find((field) => field.collection === collection.value && field.schema?.is_primary_key === true) ||
29
+ null);
30
+ });
31
+ const userCreatedField = computed(() => {
32
+ return fields.value?.find((field) => (field.meta?.special || []).includes('user_created')) || null;
33
+ });
34
+ const sortField = computed(() => {
35
+ return info.value?.meta?.sort_field || null;
36
+ });
37
+ const isSingleton = computed(() => {
38
+ return info.value?.meta?.singleton === true;
39
+ });
40
+ const accountabilityScope = computed(() => {
41
+ if (!info.value)
42
+ return null;
43
+ if (!info.value.meta)
44
+ return null;
45
+ return info.value.meta.accountability;
46
+ });
47
+ return { info, fields, defaults, primaryKeyField, userCreatedField, sortField, isSingleton, accountabilityScope };
48
+ }
@@ -0,0 +1,84 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { computed, ref, watch } from 'vue';
3
+ export function useCustomSelection(currentValue, items, emit) {
4
+ const localOtherValue = ref('');
5
+ const otherValue = computed({
6
+ get() {
7
+ return localOtherValue.value || (usesOtherValue.value ? currentValue.value : '');
8
+ },
9
+ set(newValue) {
10
+ if (newValue === null) {
11
+ localOtherValue.value = '';
12
+ emit(null);
13
+ }
14
+ else {
15
+ localOtherValue.value = newValue;
16
+ emit(newValue);
17
+ }
18
+ },
19
+ });
20
+ const usesOtherValue = computed(() => {
21
+ if (items.value === null)
22
+ return false;
23
+ // Check if set value is one of the existing keys
24
+ const values = items.value.map((item) => item.value);
25
+ return (currentValue.value !== null && currentValue.value.length > 0 && values.includes(currentValue.value) === false);
26
+ });
27
+ return { otherValue, usesOtherValue };
28
+ }
29
+ export function useCustomSelectionMultiple(currentValues, items, emit) {
30
+ const otherValues = ref([]);
31
+ watch(currentValues, (newValue) => {
32
+ if (newValue === null)
33
+ return;
34
+ if (Array.isArray(newValue) === false)
35
+ return;
36
+ if (items.value === null)
37
+ return;
38
+ newValue.forEach((value) => {
39
+ if (items.value === null)
40
+ return;
41
+ const values = items.value.map((item) => item.value);
42
+ const existsInValues = values.includes(value) === true;
43
+ if (existsInValues === false) {
44
+ const other = otherValues.value.map((o) => o.value);
45
+ const existsInOtherValues = other.includes(value) === true;
46
+ if (existsInOtherValues === false) {
47
+ addOtherValue(value);
48
+ }
49
+ }
50
+ });
51
+ }, { immediate: true });
52
+ return { otherValues, addOtherValue, setOtherValue };
53
+ function addOtherValue(value = '') {
54
+ otherValues.value = [
55
+ ...otherValues.value,
56
+ {
57
+ key: nanoid(),
58
+ value: value,
59
+ },
60
+ ];
61
+ }
62
+ function setOtherValue(key, newValue) {
63
+ const previousValue = otherValues.value.find((o) => o.key === key);
64
+ const valueWithoutPrevious = (currentValues.value || []).filter((val) => val !== previousValue?.value);
65
+ if (newValue === null) {
66
+ otherValues.value = otherValues.value.filter((o) => o.key !== key);
67
+ if (valueWithoutPrevious.length === 0) {
68
+ emit(null);
69
+ }
70
+ else {
71
+ emit(valueWithoutPrevious);
72
+ }
73
+ }
74
+ else {
75
+ otherValues.value = otherValues.value.map((otherValue) => {
76
+ if (otherValue.key === key)
77
+ otherValue.value = newValue;
78
+ return otherValue;
79
+ });
80
+ const newEmitValue = [...valueWithoutPrevious, newValue];
81
+ emit(newEmitValue);
82
+ }
83
+ }
84
+ }
@@ -0,0 +1,22 @@
1
+ import { isNil } from 'lodash-es';
2
+ import { isRef, onMounted, onUnmounted, ref } from 'vue';
3
+ export function useElementSize(target) {
4
+ const width = ref(0);
5
+ const height = ref(0);
6
+ const resizeObserver = new ResizeObserver(([entry]) => {
7
+ if (entry === undefined)
8
+ return;
9
+ width.value = entry.contentRect.width;
10
+ height.value = entry.contentRect.height;
11
+ });
12
+ onMounted(() => {
13
+ const t = isRef(target) ? target.value : target;
14
+ if (!isNil(t)) {
15
+ resizeObserver.observe(t);
16
+ }
17
+ });
18
+ onUnmounted(() => {
19
+ resizeObserver.disconnect();
20
+ });
21
+ return { width, height };
22
+ }
@@ -0,0 +1,18 @@
1
+ import { computed } from 'vue';
2
+ export function useFilterFields(fields, filters) {
3
+ const fieldGroups = computed(() => {
4
+ const acc = {};
5
+ for (const name in filters) {
6
+ acc[name] = [];
7
+ }
8
+ return fields.value.reduce((acc, field) => {
9
+ for (const name in filters) {
10
+ if (filters[name](field) === false)
11
+ continue;
12
+ acc[name].push(field);
13
+ }
14
+ return acc;
15
+ }, acc);
16
+ });
17
+ return { fieldGroups };
18
+ }
@@ -0,0 +1,183 @@
1
+ import { isEqual, isNil } from 'lodash-es';
2
+ import { computed, inject, nextTick, onBeforeUnmount, provide, ref, shallowRef, watch } from 'vue';
3
+ export function useGroupable(options) {
4
+ // Injects the registration / toggle functions from the parent scope
5
+ const parentFunctions = inject(options?.group || 'item-group', null);
6
+ if (isNil(parentFunctions)) {
7
+ return {
8
+ active: ref(false),
9
+ toggle: () => {
10
+ // Do nothing
11
+ },
12
+ activate: () => {
13
+ // Do nothing
14
+ },
15
+ deactivate: () => {
16
+ // Do nothing
17
+ },
18
+ };
19
+ }
20
+ const { register, unregister, toggle, selection, } = parentFunctions;
21
+ let startActive = false;
22
+ if (options?.active?.value === true)
23
+ startActive = true;
24
+ if (options?.value && selection.value.includes(options.value))
25
+ startActive = true;
26
+ const active = ref(startActive);
27
+ const item = { active, value: options?.value };
28
+ register(item);
29
+ if (options?.active !== undefined && options.watch === true) {
30
+ watch(options.active, () => {
31
+ if (options.active === undefined)
32
+ return;
33
+ if (options.active.value === true) {
34
+ if (active.value === false)
35
+ toggle(item);
36
+ active.value = true;
37
+ }
38
+ if (options.active.value === false) {
39
+ if (active.value === true)
40
+ toggle(item);
41
+ active.value = false;
42
+ }
43
+ });
44
+ }
45
+ onBeforeUnmount(() => unregister(item));
46
+ return {
47
+ active,
48
+ toggle: () => {
49
+ toggle(item);
50
+ },
51
+ activate: () => {
52
+ if (active.value === false)
53
+ toggle(item);
54
+ },
55
+ deactivate: () => {
56
+ if (active.value === true)
57
+ toggle(item);
58
+ },
59
+ };
60
+ }
61
+ /**
62
+ * Used to make a component a group parent component. Provides the registration / toggle functions
63
+ * to its group children
64
+ */
65
+ export function useGroupableParent(state = {}, options = {}, group = 'item-group') {
66
+ // References to the active state and value of the individual child items
67
+ const items = shallowRef([]);
68
+ // Internal copy of the selection. This allows the composition to work without the state option
69
+ // being passed
70
+ const internalSelection = ref([]);
71
+ // Uses either the internal state, or the passed in state. Will call the onSelectionChange
72
+ // handler if it's passed
73
+ const selection = computed({
74
+ get() {
75
+ if (!isNil(state.selection) && !isNil(state.selection.value)) {
76
+ return state.selection.value;
77
+ }
78
+ return internalSelection.value;
79
+ },
80
+ set(newSelection) {
81
+ if (!isNil(state.onSelectionChange)) {
82
+ state.onSelectionChange(newSelection);
83
+ }
84
+ internalSelection.value = [...newSelection];
85
+ },
86
+ });
87
+ // Provide the needed functions to all children groupable components. Note: nested item groups
88
+ // will override the item-group namespace, making nested item groups possible.
89
+ provide(group, { register, unregister, toggle, selection });
90
+ // Whenever the value of the selection changes, we have to update all the children's internal
91
+ // states. If not, you can have an activated item that's not actually active.
92
+ watch(selection, updateChildren, { immediate: true });
93
+ // It takes a tick before all children are rendered, this will make sure the start state of the
94
+ // children matches the start selection
95
+ nextTick().then(updateChildren);
96
+ watch(() => options?.mandatory?.value, (newValue, oldValue) => {
97
+ if (isEqual(newValue, oldValue))
98
+ return;
99
+ // If you're required to select a value, make sure a value is selected on first render
100
+ if (!selection.value || (selection.value.length === 0 && options?.mandatory?.value === true)) {
101
+ if (items.value[0])
102
+ selection.value = [getValueForItem(items.value[0])];
103
+ }
104
+ });
105
+ // These aren't exported with any particular use in mind. It's mostly for testing purposes.
106
+ // Treat them as readonly.
107
+ return { items, selection, internalSelection, getValueForItem, updateChildren };
108
+ // Register a child within the context of this group
109
+ function register(item) {
110
+ items.value = [...items.value, item];
111
+ const value = getValueForItem(item);
112
+ // If you're required to select a value, make sure a value is selected on first render
113
+ if (selection.value.length === 0 && options?.mandatory?.value === true && items.value.length === 1) {
114
+ selection.value = [value];
115
+ }
116
+ if (item.active.value && selection.value.includes(value) === false) {
117
+ toggle(item);
118
+ }
119
+ }
120
+ // Remove a child within the context of this group. Needed to avoid memory leaks.
121
+ function unregister(item) {
122
+ items.value = items.value.filter((existingItem) => {
123
+ return existingItem !== item;
124
+ });
125
+ }
126
+ // Toggle the active state for the given item
127
+ function toggle(item) {
128
+ if (options?.multiple?.value === true) {
129
+ toggleMultiple(item);
130
+ }
131
+ else {
132
+ toggleSingle(item);
133
+ }
134
+ if (!isNil(state.onToggle)) {
135
+ state.onToggle(item);
136
+ }
137
+ }
138
+ function toggleSingle(item) {
139
+ const itemValue = getValueForItem(item);
140
+ if (selection.value[0] === itemValue && options?.mandatory?.value !== true) {
141
+ selection.value = [];
142
+ return;
143
+ }
144
+ if (selection.value[0] !== itemValue) {
145
+ selection.value = [itemValue];
146
+ }
147
+ }
148
+ function toggleMultiple(item) {
149
+ const itemValue = getValueForItem(item);
150
+ // Remove the item if it is already selected. Don't remove it if it's the last item and
151
+ // the mandatory option is set
152
+ if (selection.value.includes(itemValue)) {
153
+ if (options?.mandatory?.value === true && selection.value.length === 1) {
154
+ updateChildren();
155
+ return;
156
+ }
157
+ selection.value = selection.value.filter((value) => value !== itemValue);
158
+ return;
159
+ }
160
+ // Don't add it if when we're already at the maximum number of selections
161
+ if (options?.max?.value && options.max.value !== -1 && selection.value.length >= options.max.value) {
162
+ // Even though we don't alter selection, we should flush the internal active state of
163
+ // the children to make sure we don't have any invalid internal active states
164
+ updateChildren();
165
+ return;
166
+ }
167
+ // Add the selected item to the selection
168
+ selection.value = [...selection.value, itemValue];
169
+ }
170
+ // Converts the item reference into the value that's used in the selection. This value is either
171
+ // the index of the item in the items array (by default), or the custom value that's passed in
172
+ // the groupable composition
173
+ function getValueForItem(item) {
174
+ return item.value || items.value.findIndex((child) => item === child);
175
+ }
176
+ // Loop over all children and make sure their internal active state matches the selection array
177
+ // of the parent
178
+ function updateChildren() {
179
+ items.value.forEach((item) => {
180
+ item.active.value = selection.value.includes(getValueForItem(item));
181
+ });
182
+ }
183
+ }
@@ -0,0 +1,231 @@
1
+ import { getEndpoint, moveInArray } from '@directus/utils';
2
+ import axios from 'axios';
3
+ import { isEqual, throttle } from 'lodash-es';
4
+ import { computed, ref, unref, watch } from 'vue';
5
+ import { useCollection } from './use-collection.js';
6
+ import { useApi } from './use-system.js';
7
+ export function useItems(collection, query) {
8
+ const api = useApi();
9
+ const { primaryKeyField } = useCollection(collection);
10
+ const { fields, alias, limit, sort, search, filter, page } = query;
11
+ const endpoint = computed(() => {
12
+ if (!collection.value)
13
+ return null;
14
+ return getEndpoint(collection.value);
15
+ });
16
+ const items = ref([]);
17
+ const loading = ref(false);
18
+ const error = ref(null);
19
+ const itemCount = ref(null);
20
+ const totalCount = ref(null);
21
+ const totalPages = computed(() => {
22
+ if (itemCount.value === null)
23
+ return 1;
24
+ if (itemCount.value < (unref(limit) ?? 100))
25
+ return 1;
26
+ return Math.ceil(itemCount.value / (unref(limit) ?? 100));
27
+ });
28
+ const existingRequests = {
29
+ items: null,
30
+ total: null,
31
+ filter: null,
32
+ };
33
+ let loadingTimeout = null;
34
+ const fetchItems = throttle(getItems, 500);
35
+ watch([collection, limit, sort, search, filter, fields, page], async (after, before) => {
36
+ if (isEqual(after, before))
37
+ return;
38
+ const [newCollection, newLimit, newSort, newSearch, newFilter, _newFields, _newPage] = after;
39
+ const [oldCollection, oldLimit, oldSort, oldSearch, oldFilter, _oldFields, _oldPage] = before;
40
+ if (!newCollection || !query)
41
+ return;
42
+ if (newCollection !== oldCollection) {
43
+ reset();
44
+ }
45
+ if (!isEqual(newFilter, oldFilter) ||
46
+ !isEqual(newSort, oldSort) ||
47
+ newLimit !== oldLimit ||
48
+ newSearch !== oldSearch) {
49
+ if (oldCollection) {
50
+ page.value = 1;
51
+ }
52
+ }
53
+ if (newCollection !== oldCollection || !isEqual(newFilter, oldFilter) || newSearch !== oldSearch) {
54
+ getItemCount();
55
+ }
56
+ fetchItems();
57
+ }, { deep: true, immediate: true });
58
+ return {
59
+ itemCount,
60
+ totalCount,
61
+ items,
62
+ totalPages,
63
+ loading,
64
+ error,
65
+ changeManualSort,
66
+ getItems,
67
+ getItemCount,
68
+ getTotalCount,
69
+ };
70
+ async function getItems() {
71
+ if (!endpoint.value)
72
+ return;
73
+ let isCurrentRequestCanceled = false;
74
+ if (existingRequests.items)
75
+ existingRequests.items.abort();
76
+ existingRequests.items = new AbortController();
77
+ error.value = null;
78
+ if (loadingTimeout) {
79
+ clearTimeout(loadingTimeout);
80
+ }
81
+ loadingTimeout = setTimeout(() => {
82
+ loading.value = true;
83
+ }, 150);
84
+ if (unref(totalCount) === null) {
85
+ getTotalCount();
86
+ }
87
+ let fieldsToFetch = [...(unref(fields) ?? [])];
88
+ // Make sure the primary key is always fetched
89
+ if (!unref(fields)?.includes('*') &&
90
+ primaryKeyField.value &&
91
+ fieldsToFetch.includes(primaryKeyField.value.field) === false) {
92
+ fieldsToFetch.push(primaryKeyField.value.field);
93
+ }
94
+ // Filter out fake internal columns. This is (among other things) for a fake $thumbnail m2o field
95
+ // on directus_files
96
+ fieldsToFetch = fieldsToFetch.filter((field) => field.startsWith('$') === false);
97
+ try {
98
+ const response = await api.get(endpoint.value, {
99
+ params: {
100
+ limit: unref(limit),
101
+ fields: fieldsToFetch,
102
+ ...(alias ? { alias: unref(alias) } : {}),
103
+ sort: unref(sort),
104
+ page: unref(page),
105
+ search: unref(search),
106
+ filter: unref(filter),
107
+ },
108
+ signal: existingRequests.items.signal,
109
+ });
110
+ let fetchedItems = response.data.data;
111
+ existingRequests.items = null;
112
+ /**
113
+ * @NOTE
114
+ *
115
+ * This is used in conjunction with the fake field in /src/stores/fields/fields.ts to be
116
+ * able to render out the directus_files collection (file library) using regular layouts
117
+ *
118
+ * Layouts expect the file to be a m2o of a `file` type, however, directus_files is the
119
+ * only collection that doesn't have this (obviously). This fake $thumbnail field is used to
120
+ * pretend there is a file m2o, so we can use the regular layout logic for files as well
121
+ */
122
+ if (collection.value === 'directus_files') {
123
+ fetchedItems = fetchedItems.map((file) => ({
124
+ ...file,
125
+ $thumbnail: file,
126
+ }));
127
+ }
128
+ items.value = fetchedItems;
129
+ if (page && fetchedItems.length === 0 && page?.value !== 1) {
130
+ page.value = 1;
131
+ }
132
+ }
133
+ catch (err) {
134
+ if (axios.isCancel(err)) {
135
+ isCurrentRequestCanceled = true;
136
+ }
137
+ else {
138
+ error.value = err;
139
+ }
140
+ }
141
+ finally {
142
+ if (loadingTimeout && !isCurrentRequestCanceled) {
143
+ clearTimeout(loadingTimeout);
144
+ loadingTimeout = null;
145
+ }
146
+ if (!loadingTimeout)
147
+ loading.value = false;
148
+ }
149
+ }
150
+ function reset() {
151
+ items.value = [];
152
+ totalCount.value = null;
153
+ itemCount.value = null;
154
+ }
155
+ async function changeManualSort({ item, to }) {
156
+ const pk = primaryKeyField.value?.field;
157
+ if (!pk)
158
+ return;
159
+ const fromIndex = items.value.findIndex((existing) => existing[pk] === item);
160
+ const toIndex = items.value.findIndex((existing) => existing[pk] === to);
161
+ items.value = moveInArray(items.value, fromIndex, toIndex);
162
+ const endpoint = computed(() => `/utils/sort/${collection.value}`);
163
+ await api.post(endpoint.value, { item, to });
164
+ }
165
+ async function getTotalCount() {
166
+ if (!endpoint.value)
167
+ return;
168
+ try {
169
+ if (existingRequests.total)
170
+ existingRequests.total.abort();
171
+ existingRequests.total = new AbortController();
172
+ const aggregate = primaryKeyField.value
173
+ ? {
174
+ countDistinct: primaryKeyField.value.field,
175
+ }
176
+ : {
177
+ count: '*',
178
+ };
179
+ const response = await api.get(endpoint.value, {
180
+ params: {
181
+ aggregate,
182
+ },
183
+ signal: existingRequests.total.signal,
184
+ });
185
+ const count = primaryKeyField.value
186
+ ? Number(response.data.data[0].countDistinct[primaryKeyField.value.field])
187
+ : Number(response.data.data[0].count);
188
+ existingRequests.total = null;
189
+ totalCount.value = count;
190
+ }
191
+ catch (err) {
192
+ if (!axios.isCancel(err)) {
193
+ throw err;
194
+ }
195
+ }
196
+ }
197
+ async function getItemCount() {
198
+ if (!endpoint.value)
199
+ return;
200
+ try {
201
+ if (existingRequests.filter)
202
+ existingRequests.filter.abort();
203
+ existingRequests.filter = new AbortController();
204
+ const aggregate = primaryKeyField.value
205
+ ? {
206
+ countDistinct: primaryKeyField.value.field,
207
+ }
208
+ : {
209
+ count: '*',
210
+ };
211
+ const response = await api.get(endpoint.value, {
212
+ params: {
213
+ filter: unref(filter),
214
+ search: unref(search),
215
+ aggregate,
216
+ },
217
+ signal: existingRequests.filter.signal,
218
+ });
219
+ const count = primaryKeyField.value
220
+ ? Number(response.data.data[0].countDistinct[primaryKeyField.value.field])
221
+ : Number(response.data.data[0].count);
222
+ existingRequests.filter = null;
223
+ itemCount.value = count;
224
+ }
225
+ catch (err) {
226
+ if (!axios.isCancel(err)) {
227
+ throw err;
228
+ }
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,175 @@
1
+ import { flushPromises } from '@vue/test-utils';
2
+ import { isEqual } from 'lodash-es';
3
+ import { afterEach, expect, test, vi } from 'vitest';
4
+ import { computed, ref, unref } from 'vue';
5
+ import { useItems } from './use-items.js';
6
+ import { useCollection } from './use-collection.js';
7
+ const mockData = { id: 1 };
8
+ const mockCountData = { count: 2 };
9
+ const mockCountDistinctData = { countDistinct: { id: 3 } };
10
+ const mockPrimaryKeyField = {
11
+ collection: 'test_collection',
12
+ field: 'id',
13
+ name: 'id',
14
+ type: 'integer',
15
+ schema: null,
16
+ meta: null,
17
+ };
18
+ const mockApiGet = vi.fn();
19
+ const mockApiPost = vi.fn();
20
+ function isGetItemsRequest(config) {
21
+ if (!config.params)
22
+ return false;
23
+ return Object.keys(config.params).includes('fields');
24
+ }
25
+ function isTotalCountRequest(config) {
26
+ if (!config.params)
27
+ return false;
28
+ return isEqual(Object.keys(config.params), ['aggregate']);
29
+ }
30
+ function isFilterCountRequest(config) {
31
+ if (!config.params)
32
+ return false;
33
+ return isEqual(Object.keys(config.params), ['filter', 'search', 'aggregate']);
34
+ }
35
+ vi.mock('./use-system.js', () => ({
36
+ useApi: vi.fn().mockImplementation(() => ({
37
+ get: mockApiGet.mockImplementation((_path, config) => {
38
+ if (isTotalCountRequest(config) || isFilterCountRequest(config)) {
39
+ if (config.params.aggregate?.countDistinct)
40
+ return Promise.resolve({ data: { data: [mockCountDistinctData] } });
41
+ return Promise.resolve({ data: { data: [mockCountData] } });
42
+ }
43
+ return Promise.resolve({ data: { data: [mockData] } });
44
+ }),
45
+ post: mockApiPost,
46
+ })),
47
+ }));
48
+ vi.mock('./use-collection.js');
49
+ afterEach(() => {
50
+ vi.clearAllMocks();
51
+ });
52
+ test('should fetch filter count and total count only once', async () => {
53
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => null) });
54
+ const { totalCount, itemCount } = useItems(ref('test_collection'), {
55
+ fields: ref(['*']),
56
+ limit: ref(1),
57
+ sort: ref(null),
58
+ search: ref(null),
59
+ filter: ref(null),
60
+ page: ref(1),
61
+ });
62
+ // Wait until computed values are updated
63
+ await flushPromises();
64
+ expect(unref(totalCount)).toBe(mockCountData.count);
65
+ expect(unref(itemCount)).toBe(mockCountData.count);
66
+ expect(mockApiGet.mock.calls.filter((call) => isGetItemsRequest(call[1])).length).toBe(1);
67
+ expect(mockApiGet.mock.calls.filter((call) => isTotalCountRequest(call[1])).length).toBe(1);
68
+ expect(mockApiGet.mock.calls.filter((call) => isFilterCountRequest(call[1])).length).toBe(1);
69
+ });
70
+ test('should fetch distinct filter count and total count only once', async () => {
71
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => mockPrimaryKeyField) });
72
+ const { totalCount, itemCount } = useItems(ref('test_collection'), {
73
+ fields: ref(['*']),
74
+ limit: ref(1),
75
+ sort: ref(null),
76
+ search: ref(null),
77
+ filter: ref(null),
78
+ page: ref(1),
79
+ });
80
+ // Wait until computed values are updated
81
+ await flushPromises();
82
+ expect(unref(totalCount)).toBe(mockCountDistinctData.countDistinct.id);
83
+ expect(unref(itemCount)).toBe(mockCountDistinctData.countDistinct.id);
84
+ expect(mockApiGet.mock.calls.filter((call) => isGetItemsRequest(call[1])).length).toBe(1);
85
+ expect(mockApiGet.mock.calls.filter((call) => isTotalCountRequest(call[1])).length).toBe(1);
86
+ expect(mockApiGet.mock.calls.filter((call) => isFilterCountRequest(call[1])).length).toBe(1);
87
+ });
88
+ test('should not re-fetch filter count when changing fields query', async () => {
89
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => null) });
90
+ const fields = ref(['*']);
91
+ useItems(ref('test_collection'), {
92
+ fields,
93
+ limit: ref(1),
94
+ sort: ref(null),
95
+ search: ref(null),
96
+ filter: ref(null),
97
+ page: ref(1),
98
+ });
99
+ // update fields query
100
+ fields.value = ['id'];
101
+ // Wait until computed values are updated
102
+ await flushPromises();
103
+ expect(mockApiGet.mock.calls.filter((call) => isFilterCountRequest(call[1])).length).toBe(1);
104
+ });
105
+ test('should re-fetch filter count when changing filters query', async () => {
106
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => null) });
107
+ const filter = ref(null);
108
+ useItems(ref('test_collection'), {
109
+ fields: ref(['*']),
110
+ limit: ref(1),
111
+ sort: ref(null),
112
+ search: ref(null),
113
+ filter,
114
+ page: ref(1),
115
+ });
116
+ // update filter query
117
+ filter.value = { id: { _eq: 1 } };
118
+ // Wait until computed values are updated
119
+ await flushPromises();
120
+ expect(mockApiGet.mock.calls.filter((call) => isTotalCountRequest(call[1])).length).toBe(1);
121
+ expect(mockApiGet.mock.calls.filter((call) => isFilterCountRequest(call[1])).length).toBe(2);
122
+ });
123
+ test('should re-fetch filter count when changing search query', async () => {
124
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => null) });
125
+ const search = ref(null);
126
+ useItems(ref('test_collection'), {
127
+ fields: ref(['*']),
128
+ limit: ref(1),
129
+ sort: ref(null),
130
+ search,
131
+ filter: ref(null),
132
+ page: ref(1),
133
+ });
134
+ // update search query
135
+ search.value = 'test';
136
+ // Wait until computed values are updated
137
+ await flushPromises();
138
+ expect(mockApiGet.mock.calls.filter((call) => isTotalCountRequest(call[1])).length).toBe(1);
139
+ expect(mockApiGet.mock.calls.filter((call) => isFilterCountRequest(call[1])).length).toBe(2);
140
+ });
141
+ test('should reset when collection changes', async () => {
142
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => null) });
143
+ const collection = ref('old_collection');
144
+ const { items } = useItems(collection, {
145
+ fields: ref(['*']),
146
+ limit: ref(1),
147
+ sort: ref(null),
148
+ search: ref(null),
149
+ filter: ref(null),
150
+ page: ref(1),
151
+ });
152
+ // Wait until computed values are updated
153
+ await flushPromises();
154
+ expect(unref(items)).toEqual([mockData]);
155
+ // update collection ref
156
+ collection.value = 'new_collection';
157
+ // Wait until computed values are updated again
158
+ await flushPromises();
159
+ expect(unref(items)).toEqual([]);
160
+ });
161
+ test('should append $thumbnail to fetched items when collection is directus_files', async () => {
162
+ vi.mocked(useCollection).mockReturnValueOnce({ primaryKeyField: computed(() => null) });
163
+ const collection = ref('directus_files');
164
+ const { items } = useItems(collection, {
165
+ fields: ref(['*']),
166
+ limit: ref(1),
167
+ sort: ref(null),
168
+ search: ref(null),
169
+ filter: ref(null),
170
+ page: ref(1),
171
+ });
172
+ // Wait until computed values are updated
173
+ await flushPromises();
174
+ expect(unref(items)).toEqual([{ id: mockData.id, $thumbnail: mockData }]);
175
+ });
@@ -0,0 +1,96 @@
1
+ import { computed, defineComponent, reactive, toRefs } from 'vue';
2
+ import { useExtensions } from './use-system.js';
3
+ const NAME_SUFFIX = 'wrapper';
4
+ const WRITABLE_PROPS = ['selection', 'layoutOptions', 'layoutQuery'];
5
+ function isWritableProp(prop) {
6
+ return WRITABLE_PROPS.includes(prop);
7
+ }
8
+ function createLayoutWrapper(layout) {
9
+ return defineComponent({
10
+ name: `${layout.id}-${NAME_SUFFIX}`,
11
+ props: {
12
+ collection: {
13
+ type: String,
14
+ required: true,
15
+ },
16
+ selection: {
17
+ type: Array,
18
+ default: () => [],
19
+ },
20
+ layoutOptions: {
21
+ type: Object,
22
+ default: () => ({}),
23
+ },
24
+ layoutQuery: {
25
+ type: Object,
26
+ default: () => ({}),
27
+ },
28
+ filter: {
29
+ type: Object,
30
+ default: null,
31
+ },
32
+ filterUser: {
33
+ type: Object,
34
+ default: null,
35
+ },
36
+ filterSystem: {
37
+ type: Object,
38
+ default: null,
39
+ },
40
+ search: {
41
+ type: String,
42
+ default: null,
43
+ },
44
+ showSelect: {
45
+ type: String,
46
+ default: 'multiple',
47
+ },
48
+ selectMode: {
49
+ type: Boolean,
50
+ default: false,
51
+ },
52
+ readonly: {
53
+ type: Boolean,
54
+ default: false,
55
+ },
56
+ resetPreset: {
57
+ type: Function,
58
+ default: null,
59
+ },
60
+ clearFilters: {
61
+ type: Function,
62
+ default: null,
63
+ },
64
+ },
65
+ emits: WRITABLE_PROPS.map((prop) => `update:${prop}`),
66
+ setup(props, { emit }) {
67
+ const state = reactive({ ...layout.setup(props, { emit }), ...toRefs(props) });
68
+ for (const key in state) {
69
+ state[`onUpdate:${key}`] = (value) => {
70
+ if (isWritableProp(key)) {
71
+ emit(`update:${key}`, value);
72
+ }
73
+ else if (!Object.keys(props).includes(key)) {
74
+ state[key] = value;
75
+ }
76
+ };
77
+ }
78
+ return { state };
79
+ },
80
+ render(ctx) {
81
+ return ctx.$slots.default !== undefined ? ctx.$slots.default({ layoutState: ctx.state }) : null;
82
+ },
83
+ });
84
+ }
85
+ export function useLayout(layoutId) {
86
+ const { layouts } = useExtensions();
87
+ const layoutWrappers = computed(() => layouts.value.map((layout) => createLayoutWrapper(layout)));
88
+ const layoutWrapper = computed(() => {
89
+ const layout = layoutWrappers.value.find((layout) => layout.name === `${layoutId.value}-${NAME_SUFFIX}`);
90
+ if (layout === undefined) {
91
+ return layoutWrappers.value.find((layout) => layout.name === `tabular-${NAME_SUFFIX}`);
92
+ }
93
+ return layout;
94
+ });
95
+ return { layoutWrapper };
96
+ }
@@ -0,0 +1,33 @@
1
+ import { computed } from 'vue';
2
+ export const sizeProps = {
3
+ xSmall: {
4
+ type: Boolean,
5
+ default: false,
6
+ },
7
+ small: {
8
+ type: Boolean,
9
+ default: false,
10
+ },
11
+ large: {
12
+ type: Boolean,
13
+ default: false,
14
+ },
15
+ xLarge: {
16
+ type: Boolean,
17
+ default: false,
18
+ },
19
+ };
20
+ export function useSizeClass(props) {
21
+ const sizeClass = computed(() => {
22
+ if (props.xSmall)
23
+ return 'x-small';
24
+ if (props.small)
25
+ return 'small';
26
+ if (props.large)
27
+ return 'large';
28
+ if (props.xLarge)
29
+ return 'x-large';
30
+ return null;
31
+ });
32
+ return sizeClass;
33
+ }
@@ -0,0 +1,11 @@
1
+ import { computed } from 'vue';
2
+ export function useSync(props, key, emit) {
3
+ return computed({
4
+ get() {
5
+ return props[key];
6
+ },
7
+ set(newVal) {
8
+ emit(`update:${key}`, newVal);
9
+ },
10
+ });
11
+ }
@@ -0,0 +1,20 @@
1
+ import { inject } from 'vue';
2
+ import { API_INJECT, EXTENSIONS_INJECT, STORES_INJECT } from '@directus/constants';
3
+ export function useStores() {
4
+ const stores = inject(STORES_INJECT);
5
+ if (!stores)
6
+ throw new Error('[useStores]: The stores could not be found.');
7
+ return stores;
8
+ }
9
+ export function useApi() {
10
+ const api = inject(API_INJECT);
11
+ if (!api)
12
+ throw new Error('[useApi]: The api could not be found.');
13
+ return api;
14
+ }
15
+ export function useExtensions() {
16
+ const extensions = inject(EXTENSIONS_INJECT);
17
+ if (!extensions)
18
+ throw new Error('[useExtensions]: The extensions could not be found.');
19
+ return extensions;
20
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@directus/composables",
3
+ "version": "9.25.0",
4
+ "type": "module",
5
+ "description": "Shared Vue composables for Directus use",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/directus/directus.git",
9
+ "directory": "packages/composables"
10
+ },
11
+ "funding": "https://github.com/directus/directus?sponsor=1",
12
+ "license": "GPL-3.0",
13
+ "author": "Rijk van Zanten <rijkvanzanten@me.com>",
14
+ "exports": {
15
+ ".": "./dist/index.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "main": "dist/index.js",
19
+ "files": [
20
+ "dist",
21
+ "!**/*.d.ts?(.map)"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "devDependencies": {
27
+ "@directus/tsconfig": "0.0.6",
28
+ "@types/lodash-es": "4.17.7",
29
+ "@vitest/coverage-c8": "0.29.3",
30
+ "@vue/test-utils": "2.3.1",
31
+ "axios": "1.3.4",
32
+ "typescript": "4.9.5",
33
+ "vitest": "0.29.3",
34
+ "@directus/types": "9.25.0"
35
+ },
36
+ "dependencies": {
37
+ "lodash-es": "4.17.21",
38
+ "nanoid": "4.0.2",
39
+ "vue": "3.2.47",
40
+ "@directus/constants": "9.25.0",
41
+ "@directus/utils": "9.25.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc --build",
45
+ "dev": "tsc --watch",
46
+ "test": "vitest --watch=false"
47
+ }
48
+ }