directus-extension-duplicate-value-from-relation 1.0.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.
Files changed (3) hide show
  1. package/README.md +51 -0
  2. package/dist/index.js +407 -0
  3. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # duplicate-value-from-relation
2
+
3
+ Directus interface that copies a value from a related item into the current field.
4
+
5
+ Useful when you have a relation (e.g. `bank_account`) and want another field on the same item to stay in sync with something on the related record (e.g. `currency`).
6
+
7
+ ## Install
8
+
9
+ Build the extension and make sure Directus can load it from your `extensions` folder:
10
+
11
+ ```bash
12
+ npm install
13
+ npm run build
14
+ ```
15
+
16
+ For local development:
17
+
18
+ ```bash
19
+ npm run dev
20
+ ```
21
+
22
+ Requires Directus `^10.10.0`.
23
+
24
+ ## Usage
25
+
26
+ 1. Add a field to your collection.
27
+ 2. Set the interface to **Duplicate Value From Relation**.
28
+ 3. Pick the **Relation Field** — the M2O field pointing at the other collection.
29
+ 4. Pick the **Source Field** — the field on that related collection to copy from.
30
+
31
+ The field is read-only in the item editor. When the relation changes, the value updates automatically.
32
+
33
+ Match the field type to the source field (`string`, `uuid`, `integer`, etc.).
34
+
35
+ ## Example
36
+
37
+ Collection: `invoices`
38
+
39
+ - Relation field: `bank_account` → `currency`
40
+ - Synced field: `currency`
41
+ - Interface options:
42
+ - Relation Field: `bank_account`
43
+ - Source Field: `currency`
44
+
45
+ Selecting an account on an invoice will fill `currency` with that account's currency.
46
+
47
+ ## Supported types
48
+
49
+ `string`, `text`, `integer`, `bigInteger`, `float`, `decimal`, `boolean`, `uuid`, `date`, `time`, `datetime`, `timestamp`, `json`
50
+
51
+ Only many-to-one relations are supported for the relation field picker.
package/dist/index.js ADDED
@@ -0,0 +1,407 @@
1
+ import { useApi, useStores, getRelationType, defineInterface } from '@directus/extensions-sdk';
2
+ import { inject, ref, computed, watch, resolveComponent, openBlock, createElementBlock, createBlock, withCtx, createTextVNode, toDisplayString, Fragment, createVNode, createCommentVNode } from 'vue';
3
+
4
+ var e=[],t=[];function n(n,r){if(n&&"undefined"!=typeof document){var a,s=true===r.prepend?"prepend":"append",d=true===r.singleTag,i="string"==typeof r.container?document.querySelector(r.container):document.getElementsByTagName("head")[0];if(d){var u=e.indexOf(i);-1===u&&(u=e.push(i)-1,t[u]={}),a=t[u]&&t[u][s]?t[u][s]:t[u][s]=c();}else a=c();65279===n.charCodeAt(0)&&(n=n.substring(1)),a.styleSheet?a.styleSheet.cssText+=n:a.appendChild(document.createTextNode(n));}function c(){var e=document.createElement("style");if(e.setAttribute("type","text/css"),r.attributes)for(var t=Object.keys(r.attributes),n=0;n<t.length;n++)e.setAttribute(t[n],r.attributes[t[n]]);var a="prepend"===s?"afterbegin":"beforeend";return i.insertAdjacentElement(a,e),e}}
5
+
6
+ var css$1 = "\n.duplicate-value-from-relation[data-v-9d64cc82] {\n\twidth: 100%;\n}\n";
7
+ n(css$1,{});
8
+
9
+ var _export_sfc = (sfc, props) => {
10
+ const target = sfc.__vccOpts || sfc;
11
+ for (const [key, val] of props) {
12
+ target[key] = val;
13
+ }
14
+ return target;
15
+ };
16
+
17
+ const _hoisted_1$1 = { class: "duplicate-value-from-relation" };
18
+
19
+
20
+ const _sfc_main$1 = {
21
+ __name: 'interface',
22
+ props: {
23
+ value: {
24
+ default: null,
25
+ },
26
+ collection: {
27
+ type: String,
28
+ default: null,
29
+ },
30
+ type: {
31
+ type: String,
32
+ default: 'string',
33
+ },
34
+ relationField: {
35
+ type: String,
36
+ default: null,
37
+ },
38
+ sourceField: {
39
+ type: String,
40
+ default: null,
41
+ },
42
+ disabled: {
43
+ type: Boolean,
44
+ default: false,
45
+ },
46
+ },
47
+ emits: ['input'],
48
+ setup(__props, { emit: __emit }) {
49
+
50
+ const props = __props;
51
+
52
+ const emit = __emit;
53
+
54
+ const api = useApi();
55
+ const { useRelationsStore } = useStores();
56
+ const relationsStore = useRelationsStore();
57
+ const values = inject('values');
58
+
59
+ const loading = ref(false);
60
+
61
+ const isConfigured = computed(() => Boolean(props.relationField && props.sourceField));
62
+
63
+ const relationValue = computed(() => {
64
+ if (!values?.value || !props.relationField) return null;
65
+ return values.value[props.relationField];
66
+ });
67
+
68
+ const hasRelationValue = computed(() => {
69
+ const current = relationValue.value;
70
+ return current !== null && current !== undefined && current !== '';
71
+ });
72
+
73
+ const displayValue = computed(() => {
74
+ if (props.value === null || props.value === undefined) return '';
75
+
76
+ if (props.type === 'json' && typeof props.value === 'object') {
77
+ return JSON.stringify(props.value, null, 2);
78
+ }
79
+
80
+ return String(props.value);
81
+ });
82
+
83
+ const placeholder = computed(() => {
84
+ if (!props.sourceField) return '';
85
+ return `Synced from ${props.sourceField}`;
86
+ });
87
+
88
+ watch(
89
+ [relationValue, () => props.relationField, () => props.sourceField, () => props.collection],
90
+ () => {
91
+ if (!isConfigured.value || props.disabled) return;
92
+ syncValue();
93
+ },
94
+ { deep: true, immediate: true },
95
+ );
96
+
97
+ async function syncValue() {
98
+ const currentRelationValue = relationValue.value;
99
+
100
+ if (currentRelationValue === null || currentRelationValue === undefined || currentRelationValue === '') {
101
+ if (props.value !== null && props.value !== undefined) {
102
+ emit('input', null);
103
+ }
104
+
105
+ return;
106
+ }
107
+
108
+ const syncedValue = await resolveSourceValue(currentRelationValue);
109
+
110
+ if (!valuesEqual(syncedValue, props.value)) {
111
+ emit('input', syncedValue);
112
+ }
113
+ }
114
+
115
+ async function resolveSourceValue(relationItem) {
116
+ if (typeof relationItem === 'object' && relationItem !== null && props.sourceField in relationItem) {
117
+ return relationItem[props.sourceField];
118
+ }
119
+
120
+ const relation = relationsStore.getRelationForField(props.collection, props.relationField);
121
+ const relatedCollection = relation?.related_collection;
122
+
123
+ if (!relatedCollection) return null;
124
+
125
+ const primaryKey =
126
+ typeof relationItem === 'object' && relationItem !== null
127
+ ? relationItem[relation.meta?.related_field || 'id']
128
+ : relationItem;
129
+
130
+ if (primaryKey === null || primaryKey === undefined || primaryKey === '') {
131
+ return null;
132
+ }
133
+
134
+ loading.value = true;
135
+
136
+ try {
137
+ const response = await api.get(`/items/${relatedCollection}/${primaryKey}`, {
138
+ params: {
139
+ fields: [props.sourceField],
140
+ },
141
+ });
142
+
143
+ return response.data.data?.[props.sourceField] ?? null;
144
+ } catch {
145
+ return null;
146
+ } finally {
147
+ loading.value = false;
148
+ }
149
+ }
150
+
151
+ function valuesEqual(a, b) {
152
+ return JSON.stringify(a) === JSON.stringify(b);
153
+ }
154
+
155
+ return (_ctx, _cache) => {
156
+ const _component_v_notice = resolveComponent("v-notice");
157
+ const _component_v_skeleton_loader = resolveComponent("v-skeleton-loader");
158
+ const _component_v_checkbox = resolveComponent("v-checkbox");
159
+ const _component_v_textarea = resolveComponent("v-textarea");
160
+ const _component_v_input = resolveComponent("v-input");
161
+
162
+ return (openBlock(), createElementBlock("div", _hoisted_1$1, [
163
+ (!isConfigured.value)
164
+ ? (openBlock(), createBlock(_component_v_notice, {
165
+ key: 0,
166
+ type: "warning"
167
+ }, {
168
+ default: withCtx(() => [...(_cache[0] || (_cache[0] = [
169
+ createTextVNode(" Configure the relation field and source field in the interface settings. ", -1 /* CACHED */)
170
+ ]))]),
171
+ _: 1 /* STABLE */
172
+ }))
173
+ : (!hasRelationValue.value)
174
+ ? (openBlock(), createBlock(_component_v_notice, {
175
+ key: 1,
176
+ type: "info"
177
+ }, {
178
+ default: withCtx(() => [
179
+ createTextVNode(" Select a related item in \"" + toDisplayString(__props.relationField) + "\" to sync this field. ", 1 /* TEXT */)
180
+ ]),
181
+ _: 1 /* STABLE */
182
+ }))
183
+ : (openBlock(), createElementBlock(Fragment, { key: 2 }, [
184
+ (loading.value)
185
+ ? (openBlock(), createBlock(_component_v_skeleton_loader, {
186
+ key: 0,
187
+ type: "input"
188
+ }))
189
+ : (__props.type === 'boolean')
190
+ ? (openBlock(), createBlock(_component_v_checkbox, {
191
+ key: 1,
192
+ "model-value": Boolean(__props.value),
193
+ disabled: ""
194
+ }, {
195
+ label: withCtx(() => [...(_cache[1] || (_cache[1] = [
196
+ createTextVNode("Synced value", -1 /* CACHED */)
197
+ ]))]),
198
+ _: 1 /* STABLE */
199
+ }, 8 /* PROPS */, ["model-value"]))
200
+ : (__props.type === 'json' || __props.type === 'text')
201
+ ? (openBlock(), createBlock(_component_v_textarea, {
202
+ key: 2,
203
+ "model-value": displayValue.value,
204
+ disabled: "",
205
+ placeholder: placeholder.value
206
+ }, null, 8 /* PROPS */, ["model-value", "placeholder"]))
207
+ : (openBlock(), createBlock(_component_v_input, {
208
+ key: 3,
209
+ "model-value": displayValue.value,
210
+ disabled: "",
211
+ placeholder: placeholder.value
212
+ }, null, 8 /* PROPS */, ["model-value", "placeholder"]))
213
+ ], 64 /* STABLE_FRAGMENT */))
214
+ ]))
215
+ }
216
+ }
217
+
218
+ };
219
+ var InterfaceComponent = /*#__PURE__*/_export_sfc(_sfc_main$1, [['__scopeId',"data-v-9d64cc82"]]);
220
+
221
+ var css = "\n.options[data-v-36ad6c04] {\n\tdisplay: grid;\n\tgap: 20px;\n}\n";
222
+ n(css,{});
223
+
224
+ const _hoisted_1 = { class: "options" };
225
+
226
+
227
+ const _sfc_main = {
228
+ __name: 'options',
229
+ props: {
230
+ value: {
231
+ type: Object,
232
+ default: () => ({}),
233
+ },
234
+ collection: {
235
+ type: String,
236
+ default: null,
237
+ },
238
+ },
239
+ emits: ['input'],
240
+ setup(__props, { emit: __emit }) {
241
+
242
+ const props = __props;
243
+
244
+ const emit = __emit;
245
+
246
+ const { useFieldsStore, useRelationsStore } = useStores();
247
+ const fieldsStore = useFieldsStore();
248
+ const relationsStore = useRelationsStore();
249
+
250
+ const relationField = computed(() => props.value?.relationField ?? null);
251
+ const sourceField = computed(() => props.value?.sourceField ?? null);
252
+
253
+ const relatedCollection = computed(() => {
254
+ if (!props.collection || !relationField.value) return null;
255
+
256
+ const relation = relationsStore.getRelationForField(props.collection, relationField.value);
257
+ if (!relation) return null;
258
+
259
+ const type = getRelationType({
260
+ relation,
261
+ collection: props.collection,
262
+ field: relationField.value,
263
+ });
264
+
265
+ return type === 'm2o' ? relation.related_collection : null;
266
+ });
267
+
268
+ const relationFieldChoices = computed(() => {
269
+ if (!props.collection) return [];
270
+
271
+ return fieldsStore
272
+ .getFieldsForCollection(props.collection)
273
+ .filter((field) => {
274
+ if (field.meta?.hidden) return false;
275
+
276
+ const relation = relationsStore.getRelationForField(props.collection, field.field);
277
+ if (!relation) return false;
278
+
279
+ return (
280
+ getRelationType({
281
+ relation,
282
+ collection: props.collection,
283
+ field: field.field,
284
+ }) === 'm2o'
285
+ );
286
+ })
287
+ .map((field) => ({
288
+ text: field.name || field.field,
289
+ value: field.field,
290
+ }));
291
+ });
292
+
293
+ const sourceFieldChoices = computed(() => {
294
+ if (!relatedCollection.value) return [];
295
+
296
+ return fieldsStore
297
+ .getFieldsForCollection(relatedCollection.value)
298
+ .filter((field) => {
299
+ if (field.meta?.hidden) return false;
300
+ if (field.type === 'alias') return false;
301
+ if (field.meta?.special?.includes('group')) return false;
302
+ return true;
303
+ })
304
+ .map((field) => ({
305
+ text: field.name || field.field,
306
+ value: field.field,
307
+ }));
308
+ });
309
+
310
+ function setRelationField(field) {
311
+ emit('input', {
312
+ ...(props.value || {}),
313
+ relationField: field,
314
+ sourceField: null,
315
+ });
316
+ }
317
+
318
+ function setSourceField(field) {
319
+ emit('input', {
320
+ ...(props.value || {}),
321
+ sourceField: field,
322
+ });
323
+ }
324
+
325
+ return (_ctx, _cache) => {
326
+ const _component_v_select = resolveComponent("v-select");
327
+ const _component_v_notice = resolveComponent("v-notice");
328
+
329
+ return (openBlock(), createElementBlock("div", _hoisted_1, [
330
+ createVNode(_component_v_select, {
331
+ "model-value": relationField.value,
332
+ items: relationFieldChoices.value,
333
+ disabled: !__props.collection,
334
+ placeholder: "Select a relation field",
335
+ "onUpdate:modelValue": setRelationField
336
+ }, {
337
+ label: withCtx(() => [...(_cache[0] || (_cache[0] = [
338
+ createTextVNode("Relation Field", -1 /* CACHED */)
339
+ ]))]),
340
+ _: 1 /* STABLE */
341
+ }, 8 /* PROPS */, ["model-value", "items", "disabled"]),
342
+ createVNode(_component_v_select, {
343
+ "model-value": sourceField.value,
344
+ items: sourceFieldChoices.value,
345
+ disabled: !relatedCollection.value,
346
+ placeholder: "Select a source field",
347
+ "onUpdate:modelValue": setSourceField
348
+ }, {
349
+ label: withCtx(() => [...(_cache[1] || (_cache[1] = [
350
+ createTextVNode("Source Field", -1 /* CACHED */)
351
+ ]))]),
352
+ _: 1 /* STABLE */
353
+ }, 8 /* PROPS */, ["model-value", "items", "disabled"]),
354
+ (!__props.collection)
355
+ ? (openBlock(), createBlock(_component_v_notice, {
356
+ key: 0,
357
+ type: "warning"
358
+ }, {
359
+ default: withCtx(() => [...(_cache[2] || (_cache[2] = [
360
+ createTextVNode(" Save the field first to configure relation syncing. ", -1 /* CACHED */)
361
+ ]))]),
362
+ _: 1 /* STABLE */
363
+ }))
364
+ : (relationField.value && !relatedCollection.value)
365
+ ? (openBlock(), createBlock(_component_v_notice, {
366
+ key: 1,
367
+ type: "warning"
368
+ }, {
369
+ default: withCtx(() => [...(_cache[3] || (_cache[3] = [
370
+ createTextVNode(" The selected field is not a many-to-one relation. ", -1 /* CACHED */)
371
+ ]))]),
372
+ _: 1 /* STABLE */
373
+ }))
374
+ : createCommentVNode("v-if", true)
375
+ ]))
376
+ }
377
+ }
378
+
379
+ };
380
+ var OptionsComponent = /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-36ad6c04"]]);
381
+
382
+ var index = defineInterface({
383
+ id: 'duplicate-value-from-relation',
384
+ name: 'Duplicate Value From Relation',
385
+ icon: 'content_copy',
386
+ description: 'Automatically sync this field from a field on a related item.',
387
+ component: InterfaceComponent,
388
+ options: OptionsComponent,
389
+ types: [
390
+ 'string',
391
+ 'text',
392
+ 'integer',
393
+ 'bigInteger',
394
+ 'float',
395
+ 'decimal',
396
+ 'boolean',
397
+ 'uuid',
398
+ 'date',
399
+ 'time',
400
+ 'datetime',
401
+ 'timestamp',
402
+ 'json',
403
+ ],
404
+ group: 'standard',
405
+ });
406
+
407
+ export { index as default };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "directus-extension-duplicate-value-from-relation",
3
+ "description": "Sync a field value from a related collection item",
4
+ "icon": "extension",
5
+ "version": "1.0.0",
6
+ "keywords": [
7
+ "directus",
8
+ "directus-extension",
9
+ "directus-extension-interface"
10
+ ],
11
+ "type": "module",
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "homepage": "https://github.com/Abdallah-Awwad/directus-extension-duplicate-value-from-relation-interface",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/Abdallah-Awwad/directus-extension-duplicate-value-from-relation-interface"
19
+ },
20
+ "author": {
21
+ "name": "Abdallah Awwad",
22
+ "email": "abdallahhawwad@gmail.com"
23
+ },
24
+ "license": "MIT",
25
+ "directus:extension": {
26
+ "type": "interface",
27
+ "path": "dist/index.js",
28
+ "source": "src/index.js",
29
+ "host": "^10.10.0"
30
+ },
31
+ "scripts": {
32
+ "build": "directus-extension build",
33
+ "dev": "directus-extension build -w --no-minify",
34
+ "link": "directus-extension link",
35
+ "validate": "directus-extension validate"
36
+ },
37
+ "devDependencies": {
38
+ "@directus/extensions-sdk": "18.0.1",
39
+ "vue": "^3.5.39"
40
+ }
41
+ }