Skip to content

Commit 73f2a00

Browse files
authored
Merge pull request #50 from smartlabsAT/feature/issue-48-relational-display-config
feat: per-column display configuration for relational fields (#48)
2 parents 0aeb8db + ebf6f9b commit 73f2a00

19 files changed

Lines changed: 1331 additions & 36 deletions

.gitignore

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,12 @@ claude.md
99
.DS_Store
1010
**/.DS_Store
1111
/release/
12-
*.tar.gz
12+
*.tar.gz
13+
.superpowers/
14+
.playwright-mcp/
15+
issue47-*.png
16+
issue48-*.png
17+
live-test-*.png
18+
tags-vs-real-tags.png
19+
status-cell-debug.png
20+
current-page.png

index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
import { defineLayout } from '@directus/extensions-sdk';
1+
import { computed } from 'vue';
2+
import { defineLayout, useCollection, useStores } from '@directus/extensions-sdk';
3+
import { formatTitle } from '@directus/format-title';
24
import LayoutComponent from './src/super-table.vue';
35
import ActionsComponent from './src/actions.vue';
46
import OptionsComponent from './src/options.vue';
@@ -17,9 +19,34 @@ const layoutConfig = {
1719
},
1820
headerShadow: false,
1921
setup(props: any, { emit }: any) {
22+
const { useFieldsStore } = useStores();
23+
const fieldsStore = useFieldsStore();
24+
useCollection(props.collection); // parity with super-table.vue
25+
26+
// Issue #48: Expose a flat list of currently visible columns to slot
27+
// components (options sidebar). Each entry uses the *root* field key as id
28+
// (translations.title rather than translations.title:de-DE) so all
29+
// language variants of the same root field share a single override entry.
30+
// Multiple language columns of the same root collapse into one picker entry.
31+
const availableFieldChoices = computed(() => {
32+
const layoutFields: string[] = props.layoutQuery?.fields ?? [];
33+
const customFieldNames: Record<string, string> = props.layoutOptions?.customFieldNames ?? {};
34+
const seen = new Map<string, { key: string; label: string }>();
35+
for (const key of layoutFields) {
36+
const rootKey = key.includes(':') ? key.split(':')[0] : key;
37+
if (seen.has(rootKey)) continue;
38+
const root = rootKey.split('.')[0];
39+
const fieldData = fieldsStore.getField(props.collection, root);
40+
const fallbackName = fieldData?.name ?? formatTitle(rootKey);
41+
seen.set(rootKey, { key: rootKey, label: customFieldNames[key] ?? fallbackName });
42+
}
43+
return Array.from(seen.values());
44+
});
45+
2046
return {
2147
...props,
2248
emit,
49+
availableFieldChoices,
2350
};
2451
},
2552
} as any;

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"test:watch": "vitest --watch",
4646
"test:e2e": "node playwright-tools/test-extension.js",
4747
"test:e2e:issue-47": "node playwright-tools/test-issue-47.js",
48+
"test:e2e:issue-48": "node playwright-tools/test-issue-48.js",
4849
"inspect": "node playwright-tools/inspect-vertical-alignment.js",
4950
"console": "node playwright-tools/playwright-console-monitor.js",
5051
"analyze": "node playwright-tools/playwright-assistant.js --report",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<template>
2+
<div class="column-display-editor">
3+
<div class="field">
4+
<div class="label">Field</div>
5+
<v-select
6+
:model-value="form.fieldKey"
7+
:items="availableFieldChoices"
8+
:disabled="mode === 'edit'"
9+
placeholder="Select a column"
10+
@update:model-value="onFieldChange"
11+
/>
12+
</div>
13+
14+
<div class="field">
15+
<div class="label">Display Template</div>
16+
<interface-system-display-template
17+
:collection-name="targetCollection"
18+
:value="form.template"
19+
placeholder="{{ field }}"
20+
:include-relations="true"
21+
@input="form.template = $event ?? ''"
22+
/>
23+
</div>
24+
25+
<div class="actions">
26+
<v-button secondary small @click="$emit('cancel')">Cancel</v-button>
27+
<v-button :disabled="!canSave" small @click="onSave">Save</v-button>
28+
</div>
29+
</div>
30+
</template>
31+
32+
<script lang="ts" setup>
33+
import { computed, ref, watch } from 'vue';
34+
import { useStores } from '@directus/extensions-sdk';
35+
import type { ColumnDisplay } from '../composables/useColumnDisplays';
36+
import { isRelational, resolveTargetCollection } from '../utils/displayHeuristics';
37+
38+
const props = defineProps<{
39+
mode: 'add' | 'edit';
40+
initialFieldKey?: string;
41+
initialValue?: ColumnDisplay;
42+
collection: string;
43+
availableFieldChoices: Array<{ text: string; value: string }>;
44+
}>();
45+
46+
const emit = defineEmits<{
47+
(e: 'save', payload: { fieldKey: string; display: ColumnDisplay }): void;
48+
(e: 'cancel'): void;
49+
}>();
50+
51+
const { useFieldsStore, useRelationsStore } = useStores();
52+
const fieldsStore = useFieldsStore();
53+
const relationsStore = useRelationsStore();
54+
55+
const form = ref<{ fieldKey: string; template: string }>({
56+
fieldKey: props.initialFieldKey ?? '',
57+
template: props.initialValue?.template ?? '',
58+
});
59+
60+
const targetCollection = computed(() => {
61+
if (!form.value.fieldKey) return null;
62+
// Strip language suffix (translations.title:de-DE → translations.title)
63+
const rootKey = form.value.fieldKey.includes(':')
64+
? form.value.fieldKey.split(':')[0]
65+
: form.value.fieldKey;
66+
// Use root field on the parent for relational lookup
67+
const rootField = rootKey.split('.')[0];
68+
const fieldDef = fieldsStore.getField(props.collection, rootField);
69+
if (!fieldDef) return props.collection;
70+
if (!isRelational(fieldDef)) return props.collection;
71+
const target = resolveTargetCollection(fieldDef, relationsStore as any, fieldsStore as any);
72+
return target ?? props.collection;
73+
});
74+
75+
const canSave = computed(() => {
76+
if (!form.value.fieldKey) return false;
77+
// Save is disabled in both modes when the template is empty. To delete an
78+
// existing override the user clicks the ⊘ icon on the item, which is the
79+
// explicit, discoverable path. (Avoids a "Save erases my override" surprise.)
80+
if (!form.value.template.trim()) return false;
81+
return true;
82+
});
83+
84+
function onFieldChange(value: string) {
85+
form.value.fieldKey = value;
86+
// When the chosen field changes, clear the template to avoid stale tokens
87+
form.value.template = '';
88+
}
89+
90+
function onSave() {
91+
emit('save', {
92+
fieldKey: form.value.fieldKey,
93+
display: { template: form.value.template },
94+
});
95+
}
96+
97+
watch(
98+
() => props.initialValue,
99+
(val) => {
100+
if (val) form.value.template = val.template;
101+
}
102+
);
103+
</script>
104+
105+
<style scoped>
106+
.column-display-editor {
107+
width: 100%;
108+
margin-bottom: 12px;
109+
}
110+
.field {
111+
margin-bottom: 12px;
112+
}
113+
.label {
114+
display: block;
115+
margin-bottom: 4px;
116+
color: var(--foreground-normal);
117+
font-weight: 600;
118+
font-size: 13px;
119+
}
120+
.actions {
121+
display: flex;
122+
gap: 8px;
123+
justify-content: flex-end;
124+
}
125+
</style>
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<template>
2+
<div class="column-display-item" @click="$emit('edit')">
3+
<div class="header">
4+
<span class="field-label">{{ fieldLabel }}</span>
5+
<div class="actions" @click.stop>
6+
<v-icon name="edit" small clickable @click="$emit('edit')" />
7+
<v-icon name="close" small clickable @click="onDelete" />
8+
</div>
9+
</div>
10+
<div class="template-preview">{{ display.template }}</div>
11+
</div>
12+
</template>
13+
14+
<script lang="ts" setup>
15+
import type { ColumnDisplay } from '../composables/useColumnDisplays';
16+
17+
defineProps<{
18+
fieldKey: string;
19+
fieldLabel: string;
20+
display: ColumnDisplay;
21+
}>();
22+
23+
const emit = defineEmits<{
24+
(e: 'edit'): void;
25+
(e: 'delete'): void;
26+
}>();
27+
28+
function onDelete() {
29+
emit('delete');
30+
}
31+
</script>
32+
33+
<style scoped>
34+
.column-display-item {
35+
width: 100%;
36+
padding: 8px 10px;
37+
border: 1px solid var(--border-subdued);
38+
background: var(--background-subdued);
39+
border-radius: var(--border-radius);
40+
cursor: pointer;
41+
margin-bottom: 6px;
42+
}
43+
.column-display-item:hover {
44+
border-color: var(--border-normal);
45+
background: var(--background-normal-alt, var(--background-subdued));
46+
}
47+
.header {
48+
display: flex;
49+
justify-content: space-between;
50+
align-items: center;
51+
}
52+
.field-label {
53+
font-weight: 600;
54+
font-size: 13px;
55+
color: var(--foreground-normal);
56+
}
57+
.actions {
58+
display: flex;
59+
gap: 4px;
60+
color: var(--foreground-subdued);
61+
}
62+
.template-preview {
63+
font-family: var(--family-monospace);
64+
font-size: 12px;
65+
color: var(--foreground-subdued);
66+
margin-top: 4px;
67+
overflow: hidden;
68+
text-overflow: ellipsis;
69+
white-space: nowrap;
70+
}
71+
</style>
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<template>
2+
<div class="column-displays-section">
3+
<div class="type-label">Column Displays</div>
4+
5+
<template v-for="(display, fieldKey) in columnDisplays" :key="fieldKey">
6+
<ColumnDisplayEditor
7+
v-if="editingFieldKey === fieldKey"
8+
mode="edit"
9+
:initial-field-key="String(fieldKey)"
10+
:initial-value="display"
11+
:collection="collection"
12+
:available-field-choices="choicesIncluding(String(fieldKey))"
13+
@save="onSave"
14+
@cancel="editingFieldKey = null"
15+
/>
16+
<ColumnDisplayItem
17+
v-else
18+
:field-key="String(fieldKey)"
19+
:field-label="labelFor(String(fieldKey))"
20+
:display="display"
21+
@edit="editingFieldKey = String(fieldKey)"
22+
@delete="onDelete(String(fieldKey))"
23+
/>
24+
</template>
25+
26+
<ColumnDisplayEditor
27+
v-if="editingFieldKey === '__new__'"
28+
mode="add"
29+
:collection="collection"
30+
:available-field-choices="addModeChoices"
31+
@save="onSave"
32+
@cancel="editingFieldKey = null"
33+
/>
34+
35+
<button v-if="editingFieldKey === null" class="add-button" @click="editingFieldKey = '__new__'">
36+
<v-icon name="add" small /> Add Column Display
37+
</button>
38+
</div>
39+
</template>
40+
41+
<script lang="ts" setup>
42+
import { computed, ref } from 'vue';
43+
import ColumnDisplayItem from './ColumnDisplayItem.vue';
44+
import ColumnDisplayEditor from './ColumnDisplayEditor.vue';
45+
import type { ColumnDisplay } from '../composables/useColumnDisplays';
46+
47+
const props = defineProps<{
48+
collection: string;
49+
columnDisplays: Record<string, ColumnDisplay>;
50+
availableFields: Array<{ key: string; label: string }>;
51+
}>();
52+
53+
const emit = defineEmits<{
54+
(e: 'set', payload: { fieldKey: string; display: ColumnDisplay }): void;
55+
(e: 'remove', fieldKey: string): void;
56+
}>();
57+
58+
const editingFieldKey = ref<string | null>(null);
59+
60+
const addModeChoices = computed(() =>
61+
props.availableFields
62+
.filter((f) => !(f.key in props.columnDisplays))
63+
.map((f) => ({ text: f.label, value: f.key }))
64+
);
65+
66+
function choicesIncluding(key: string) {
67+
// In edit mode the field is locked, but still pass the current key as a choice
68+
const current = props.availableFields.find((f) => f.key === key);
69+
if (!current) return addModeChoices.value;
70+
return [{ text: current.label, value: current.key }];
71+
}
72+
73+
function labelFor(key: string): string {
74+
return props.availableFields.find((f) => f.key === key)?.label ?? key;
75+
}
76+
77+
function onSave(payload: { fieldKey: string; display: ColumnDisplay }) {
78+
emit('set', payload);
79+
editingFieldKey.value = null;
80+
}
81+
82+
function onDelete(fieldKey: string) {
83+
emit('remove', fieldKey);
84+
}
85+
</script>
86+
87+
<style scoped>
88+
.column-displays-section {
89+
/* The Directus layout-options sidebar is a 2-col CSS grid; span both cols */
90+
grid-column: 1 / -1;
91+
margin-top: var(--form-vertical-gap);
92+
}
93+
.type-label {
94+
display: block;
95+
margin-bottom: 8px;
96+
color: var(--foreground-normal);
97+
font-weight: 600;
98+
font-size: 14px;
99+
}
100+
.add-button {
101+
width: 100%;
102+
padding: 8px;
103+
border: 1px dashed var(--border-normal);
104+
background: transparent;
105+
border-radius: var(--border-radius);
106+
color: var(--foreground-subdued);
107+
font-size: 13px;
108+
cursor: pointer;
109+
display: flex;
110+
align-items: center;
111+
justify-content: center;
112+
gap: 6px;
113+
}
114+
.add-button:hover {
115+
border-color: var(--primary);
116+
color: var(--primary);
117+
}
118+
</style>

0 commit comments

Comments
 (0)