From a4c99863012e87798cbcf417ab6317096fcdc683 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:42:54 +0200 Subject: [PATCH 01/11] test(#55): pin M2M junction traversal expected for related-values --- .../utils/adjustFieldsForDisplays.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/unit/utils/adjustFieldsForDisplays.test.ts b/tests/unit/utils/adjustFieldsForDisplays.test.ts index 228430e..d689639 100644 --- a/tests/unit/utils/adjustFieldsForDisplays.test.ts +++ b/tests/unit/utils/adjustFieldsForDisplays.test.ts @@ -85,3 +85,64 @@ describe('adjustFieldsForDisplays — override path', () => { expect(result).toEqual(expect.arrayContaining(['author.first_name', 'author.last_name'])); }); }); + +describe('adjustFieldsForDisplays — M2M related-values display (issue #55)', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('expands a related-values template through the junction_field for M2M (no name on pivot)', async () => { + // Setup mirrors a real M2M: Products → Products_Tags (junction, no `name` field) → Tags (`name` exists) + vi.doMock('@directus/extensions-sdk', () => ({ + useStores: () => ({ + useFieldsStore: () => ({ + getField: (col: string, f: string) => { + if (col === 'products' && f === 'product_tags') { + return { + field: 'product_tags', + collection: 'products', + meta: { + special: ['m2m'], + display: 'related-values', + display_options: { template: '{{name}}' }, + }, + }; + } + if (col === 'tags' && f === 'name') return { field: 'name' }; + // CRITICAL: junction has no `name` field + if (col === 'products_tags' && f === 'name') return null; + if (col === 'products_tags' && f === 'id') return { field: 'id' }; + if (col === 'products_tags' && f === 'tag_id') + return { field: 'tag_id', schema: { foreign_key_table: 'tags' } }; + if (col === 'tags' && f === 'id') return { field: 'id' }; + return null; + }, + getFieldsForCollection: () => [], + }), + useRelationsStore: () => ({ + getRelationsForField: (col: string, f: string) => + col === 'products' && f === 'product_tags' + ? [ + { + collection: 'products_tags', + field: 'product_id', + related_collection: 'products', + meta: { junction_field: 'tag_id' }, + }, + ] + : [], + }), + }), + useCollection: () => ({ primaryKeyField: { value: { field: 'id' } } }), + useExtensions: () => ({ displays: { value: [] } }), + })); + const { adjustFieldsForDisplays } = await import('@/utils/adjustFieldsForDisplays'); + const result = adjustFieldsForDisplays(['product_tags'], 'products'); + + // MUST traverse the junction: product_tags.tag_id.name, NEVER product_tags.name + expect(result).not.toContain('product_tags.name'); + expect( + result.some((f) => f === 'product_tags.tag_id.name' || f === 'product_tags.tag_id.id') + ).toBe(true); + }); +}); From a323b42ccb599623833311cd50aa903f46f97a10 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:45:59 +0200 Subject: [PATCH 02/11] feat(#55): add expandTokensThroughRelation helper with target-collection validation --- src/utils/adjustFieldsForDisplays.ts | 82 ++++++ .../utils/expandTokensThroughRelation.test.ts | 264 ++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 tests/unit/utils/expandTokensThroughRelation.test.ts diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index 5151260..cac2208 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -123,6 +123,88 @@ function getDisplayFieldsForRelation( return [`${fieldKey}.${pkField}`]; } +/** + * Issue #55: Single source of truth for expanding template tokens into API + * field paths. Handles three relation classes: + * + * M2M → fieldKey.. (e.g. product_tags.tag_id.name) + * M2O / O2M → fieldKey. (e.g. author.first_name) + * translations → fieldKey. (deep parameter handles depth) + * + * Tokens that do not resolve to a real field on the actual *target* collection + * are dropped, so we never enqueue `${junction}.name` for a junction without + * a `name` column. Translations skip validation because schemas vary and the + * existing client-side render path resolves missing fields gracefully. + * + * For dotted tokens (e.g. "category.name") only the first segment is validated; + * the rest is forwarded verbatim. If the user's M2M template already starts + * with the junction_field (e.g. {{tag_id.name}}), the junction prefix is NOT + * duplicated. + */ +export function expandTokensThroughRelation( + field: { meta?: { special?: string[] } } | null, + fieldKey: string, + parentCollection: string, + tokens: string[], + fieldsStore: { getField: (collection: string, fieldName: string) => any }, + relationsStore: { getRelationsForField: (collection: string, fieldName: string) => any[] } +): string[] { + if (!tokens.length) return []; + const isM2M = field?.meta?.special?.includes('m2m') === true; + const isTranslations = field?.meta?.special?.includes('translations') === true; + + if (isTranslations) { + return tokens.map((tok) => `${fieldKey}.${tok}`); + } + + if (isM2M) { + const relations = relationsStore.getRelationsForField(parentCollection, fieldKey); + const rel = relations?.[0]; + const junctionField = rel?.meta?.junction_field as string | undefined; + const junctionCollection = rel?.collection as string | undefined; + if (!junctionField || !junctionCollection) { + return []; + } + const junctionFieldDef = fieldsStore.getField(junctionCollection, junctionField); + const targetCollection = junctionFieldDef?.schema?.foreign_key_table as + | string + | undefined; + if (!targetCollection) return []; + + const expanded: string[] = []; + for (const tok of tokens) { + const parts = tok.split('.'); + // If user wrote the junction_field as the first segment already, strip it + // so we don't double-prefix. + const tokWithoutJunctionPrefix = + parts[0] === junctionField ? parts.slice(1).join('.') : tok; + if (!tokWithoutJunctionPrefix) continue; + const firstSegment = tokWithoutJunctionPrefix.split('.')[0]!; + if (!fieldsStore.getField(targetCollection, firstSegment)) continue; + expanded.push(`${fieldKey}.${junctionField}.${tokWithoutJunctionPrefix}`); + } + return expanded; + } + + // M2O / O2M / files: direct paths, validated against related_collection. + const relations = relationsStore.getRelationsForField(parentCollection, fieldKey); + const rel = relations?.[0]; + const target = + (rel?.related_collection as string | undefined) ?? (rel?.collection as string | undefined); + if (!target) { + // Best-effort: return as-is when we have no target info to validate against. + return tokens.map((tok) => `${fieldKey}.${tok}`); + } + + const expanded: string[] = []; + for (const tok of tokens) { + const firstSegment = tok.includes('.') ? (tok.split('.')[0] as string) : tok; + if (!fieldsStore.getField(target, firstSegment)) continue; + expanded.push(`${fieldKey}.${tok}`); + } + return expanded; +} + /** * Adjusts fields based on their display configuration, following the original Directus pattern. * This function replicates the core logic from Directus core for proper display field resolution. diff --git a/tests/unit/utils/expandTokensThroughRelation.test.ts b/tests/unit/utils/expandTokensThroughRelation.test.ts new file mode 100644 index 0000000..2cc7c9d --- /dev/null +++ b/tests/unit/utils/expandTokensThroughRelation.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from 'vitest'; +import { expandTokensThroughRelation } from '@/utils/adjustFieldsForDisplays'; + +const makeStores = ( + fieldsByPath: Record, + relationsByPath: Record +) => ({ + fieldsStore: { + getField: (col: string, f: string) => fieldsByPath[`${col}.${f}`] ?? null, + }, + relationsStore: { + getRelationsForField: (col: string, f: string) => relationsByPath[`${col}.${f}`] ?? [], + }, +}); + +describe('expandTokensThroughRelation', () => { + it('returns fieldKey.token for plain M2O when target field exists', () => { + const { fieldsStore, relationsStore } = makeStores( + { 'directus_users.first_name': { field: 'first_name' } }, + { + 'parent.author': [ + { collection: 'parent', field: 'author', related_collection: 'directus_users' }, + ], + } + ); + const field = { collection: 'parent', field: 'author', meta: { special: ['m2o'] } } as any; + const result = expandTokensThroughRelation( + field, + 'author', + 'parent', + ['first_name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['author.first_name']); + }); + + it('inserts junction_field for M2M relations', () => { + const { fieldsStore, relationsStore } = makeStores( + { + 'products_tags.tag_id': { + field: 'tag_id', + schema: { foreign_key_table: 'tags' }, + }, + 'tags.name': { field: 'name' }, + }, + { + 'products.product_tags': [ + { + collection: 'products_tags', + field: 'product_id', + related_collection: 'products', + meta: { junction_field: 'tag_id' }, + }, + ], + } + ); + const field = { + collection: 'products', + field: 'product_tags', + meta: { special: ['m2m'] }, + } as any; + const result = expandTokensThroughRelation( + field, + 'product_tags', + 'products', + ['name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['product_tags.tag_id.name']); + }); + + it('drops invalid tokens for M2M when target lacks the field', () => { + const { fieldsStore, relationsStore } = makeStores( + { + 'products_tags.tag_id': { + field: 'tag_id', + schema: { foreign_key_table: 'tags' }, + }, + 'tags.id': { field: 'id' }, + // NOTE: tags.name intentionally missing + }, + { + 'products.product_tags': [ + { + collection: 'products_tags', + field: 'product_id', + related_collection: 'products', + meta: { junction_field: 'tag_id' }, + }, + ], + } + ); + const field = { + collection: 'products', + field: 'product_tags', + meta: { special: ['m2m'] }, + } as any; + const result = expandTokensThroughRelation( + field, + 'product_tags', + 'products', + ['name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual([]); + }); + + it('drops invalid tokens for M2O when target lacks the field', () => { + const { fieldsStore, relationsStore } = makeStores( + { 'directus_users.id': { field: 'id' } }, + { + 'parent.author': [ + { collection: 'parent', field: 'author', related_collection: 'directus_users' }, + ], + } + ); + const field = { collection: 'parent', field: 'author', meta: { special: ['m2o'] } } as any; + const result = expandTokensThroughRelation( + field, + 'author', + 'parent', + ['nonexistent'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual([]); + }); + + it('keeps dotted-path tokens as-is and prepends fieldKey + junction (M2M)', () => { + const { fieldsStore, relationsStore } = makeStores( + { + 'products_tags.tag_id': { + field: 'tag_id', + schema: { foreign_key_table: 'tags' }, + }, + 'tags.category': { field: 'category' }, + }, + { + 'products.product_tags': [ + { + collection: 'products_tags', + field: 'product_id', + related_collection: 'products', + meta: { junction_field: 'tag_id' }, + }, + ], + } + ); + const field = { + collection: 'products', + field: 'product_tags', + meta: { special: ['m2m'] }, + } as any; + const result = expandTokensThroughRelation( + field, + 'product_tags', + 'products', + ['category.name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['product_tags.tag_id.category.name']); + }); + + it('does NOT double-prepend junction_field if the token already starts with it', () => { + // User wrote {{tag_id.name}} directly in the template — junction_field is + // already explicit. We must avoid producing product_tags.tag_id.tag_id.name. + const { fieldsStore, relationsStore } = makeStores( + { + 'products_tags.tag_id': { + field: 'tag_id', + schema: { foreign_key_table: 'tags' }, + }, + 'tags.name': { field: 'name' }, + }, + { + 'products.product_tags': [ + { + collection: 'products_tags', + field: 'product_id', + related_collection: 'products', + meta: { junction_field: 'tag_id' }, + }, + ], + } + ); + const field = { + collection: 'products', + field: 'product_tags', + meta: { special: ['m2m'] }, + } as any; + const result = expandTokensThroughRelation( + field, + 'product_tags', + 'products', + ['tag_id.name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['product_tags.tag_id.name']); + }); + + it('returns fieldKey.token for translations without field-existence validation', () => { + const { fieldsStore, relationsStore } = makeStores( + { + // NOTE: translations target has no `title` — but we still emit the path + // because translations have client-side render logic that handles missing. + }, + { + 'parent.translations': [ + { + collection: 'parent_translations', + field: 'parent_id', + related_collection: 'parent', + meta: { junction_field: 'languages_code' }, + }, + ], + } + ); + const field = { + collection: 'parent', + field: 'translations', + meta: { special: ['translations'] }, + } as any; + const result = expandTokensThroughRelation( + field, + 'translations', + 'parent', + ['title'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['translations.title']); + }); + + it('returns [] when M2M has no junction_field meta (defensive)', () => { + const { fieldsStore, relationsStore } = makeStores( + {}, + { + 'parent.tags': [ + { + collection: 'parent_tags', + field: 'parent_id', + related_collection: 'parent', + // NOTE: no meta.junction_field + }, + ], + } + ); + const field = { collection: 'parent', field: 'tags', meta: { special: ['m2m'] } } as any; + const result = expandTokensThroughRelation( + field, + 'tags', + 'parent', + ['name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual([]); + }); +}); From 00feab0f9cd7d02232f4d0f847a7ac245d943468 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:47:49 +0200 Subject: [PATCH 03/11] fix(#55): traverse junction_field in related-values display for M2M fields --- src/utils/adjustFieldsForDisplays.ts | 63 ++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 8 deletions(-) diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index cac2208..82dacdc 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -325,19 +325,66 @@ export function adjustFieldsForDisplays( // Handle different display types with their specific field requirements switch (displayId) { case 'related-values': { - // For related-values, we need fields for the template + // Issue #55: M2M fields must traverse junction_field, not query the junction + // collection directly. We delegate token expansion to expandTokensThroughRelation + // which knows about M2M junctions, validates token existence on the actual + // target collection, and drops tokens that would 403. const template = field.meta?.display_options?.template; if (template) { - // Parse template to extract field requirements - const templateFields = extractFieldsFromTemplate(template); - displayFields = templateFields.map((f) => `${fieldKey}.${f}`); + const templateTokens = extractFieldsFromTemplate(template); + const expanded = expandTokensThroughRelation( + field, + fieldKey, + parentCollection, + templateTokens, + fieldsStore, + relationsStore + ); + + // Always include the PK of the actual target so the row can be keyed. + // For M2M, the PK lives behind the junction_field path; for M2O/O2M, it's + // a direct child of fieldKey. + const isM2M = field.meta?.special?.includes('m2m') === true; + let pkPath: string | null = null; + if (isM2M) { + const relations = relationsStore.getRelationsForField(parentCollection, fieldKey); + const rel = relations?.[0]; + const junctionField = rel?.meta?.junction_field as string | undefined; + const junctionCollection = rel?.collection as string | undefined; + if (junctionField && junctionCollection) { + const junctionFieldDef = fieldsStore.getField(junctionCollection, junctionField); + const targetCollection = junctionFieldDef?.schema?.foreign_key_table as + | string + | undefined; + if (targetCollection) { + const targetPk = getPrimaryKeyForCollection(targetCollection); + pkPath = `${fieldKey}.${junctionField}.${targetPk}`; + } + } + } else { + const rootField = (fieldKey.split('.')[0] ?? fieldKey) as string; + const relatedCollection = getRelatedCollection( + parentCollection, + rootField, + relationsStore + ); + if (relatedCollection) { + const targetPk = getPrimaryKeyForCollection(relatedCollection); + pkPath = `${fieldKey}.${targetPk}`; + } + } + + displayFields = pkPath && !expanded.includes(pkPath) ? [...expanded, pkPath] : expanded; + // Final safety: if nothing valid, fall back to the bare fieldKey so the + // request still loads the relation and the row renders. + if (displayFields.length === 0) displayFields = [fieldKey]; } else { - // Default fields for related-values without template - // Get the primary key of the related collection - const fieldName = fieldKey.split('.')[0]; + // No template: just request the PK of the related collection so the row + // can be keyed. For M2M, traverse junction_field; for others, direct path. + const rootField = (fieldKey.split('.')[0] ?? fieldKey) as string; const relatedCollection = getRelatedCollection( parentCollection, - fieldName, + rootField, relationsStore ); const pkField = getPrimaryKeyForCollection(relatedCollection); From a7e496ba621bc90e364ac10295498281c3733efd Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:50:22 +0200 Subject: [PATCH 04/11] refactor(#55): unify M2M junction traversal across override and heuristic branches --- src/utils/adjustFieldsForDisplays.ts | 41 ++++----- .../utils/adjustFieldsForDisplays.test.ts | 89 +++++++++++++++++++ 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index 82dacdc..bae69c7 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -264,19 +264,15 @@ export function adjustFieldsForDisplays( return fieldKey; } - // M2M: paths must traverse the junction's junction_field. - const isM2M = fieldDef?.meta?.special?.includes('m2m'); - if (isM2M) { - const relations = relationsStore?.getRelationsForField(parentCollection, fieldKey); - const junctionField = relations?.[0]?.meta?.junction_field; - if (junctionField) { - return tokens.map((tok) => `${fieldKey}.${junctionField}.${tok}`); - } - return [`${fieldKey}.${tokens[0]}`]; // best-effort fallback - } - - // M2O / O2M / files: direct dotted paths - return tokens.map((tok) => `${fieldKey}.${tok}`); + const expanded = expandTokensThroughRelation( + fieldDef, + fieldKey, + parentCollection, + tokens, + fieldsStore, + relationsStore + ); + return expanded.length > 0 ? expanded : [fieldKey]; } // Heuristic branch (Issue #48): when no override exists, the field is @@ -295,16 +291,15 @@ export function adjustFieldsForDisplays( if (heuristicTemplate) { const heuristicTokens = parseTemplateTokens(heuristicTemplate); if (heuristicTokens.length > 0) { - const isM2M = fieldDefForHeuristic.meta?.special?.includes('m2m'); - if (isM2M) { - const relations = relationsStore?.getRelationsForField(parentCollection, fieldKey); - const junctionField = relations?.[0]?.meta?.junction_field; - if (junctionField) { - return heuristicTokens.map((tok) => `${fieldKey}.${junctionField}.${tok}`); - } - return [`${fieldKey}.${heuristicTokens[0]}`]; - } - return heuristicTokens.map((tok) => `${fieldKey}.${tok}`); + const expanded = expandTokensThroughRelation( + fieldDefForHeuristic, + fieldKey, + parentCollection, + heuristicTokens, + fieldsStore, + relationsStore + ); + return expanded.length > 0 ? expanded : [fieldKey]; } } } diff --git a/tests/unit/utils/adjustFieldsForDisplays.test.ts b/tests/unit/utils/adjustFieldsForDisplays.test.ts index d689639..e0ebebe 100644 --- a/tests/unit/utils/adjustFieldsForDisplays.test.ts +++ b/tests/unit/utils/adjustFieldsForDisplays.test.ts @@ -146,3 +146,92 @@ describe('adjustFieldsForDisplays — M2M related-values display (issue #55)', ( ).toBe(true); }); }); + +describe('adjustFieldsForDisplays — override branch M2M validation (issue #55)', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('drops invalid override-template tokens for M2M when target lacks the field', async () => { + vi.doMock('@directus/extensions-sdk', () => ({ + useStores: () => ({ + useFieldsStore: () => ({ + getField: (col: string, f: string) => { + if (col === 'parent' && f === 'tags') + return { field: 'tags', collection: 'parent', meta: { special: ['m2m'] } }; + if (col === 'parent_tags' && f === 'tag_id') + return { field: 'tag_id', schema: { foreign_key_table: 'tags' } }; + if (col === 'tags' && f === 'id') return { field: 'id' }; + // CRITICAL: tags has no 'name' + return null; + }, + getFieldsForCollection: () => [], + }), + useRelationsStore: () => ({ + getRelationsForField: (col: string, f: string) => + col === 'parent' && f === 'tags' + ? [ + { + collection: 'parent_tags', + field: 'parent_id', + related_collection: 'parent', + meta: { junction_field: 'tag_id' }, + }, + ] + : [], + }), + }), + useCollection: () => ({ primaryKeyField: { value: { field: 'id' } } }), + useExtensions: () => ({ displays: { value: [] } }), + })); + const { adjustFieldsForDisplays } = await import('@/utils/adjustFieldsForDisplays'); + const result = adjustFieldsForDisplays( + ['tags'], + 'parent', + { tags: { template: '{{name}}' } } + ); + // `name` does not exist on `tags` (the target) → must NOT appear in the path + expect(result).not.toContain('tags.tag_id.name'); + expect(result).not.toContain('tags.name'); + }); + + it('expands valid override-template tokens for M2M through junction_field', async () => { + vi.doMock('@directus/extensions-sdk', () => ({ + useStores: () => ({ + useFieldsStore: () => ({ + getField: (col: string, f: string) => { + if (col === 'parent' && f === 'tags') + return { field: 'tags', collection: 'parent', meta: { special: ['m2m'] } }; + if (col === 'parent_tags' && f === 'tag_id') + return { field: 'tag_id', schema: { foreign_key_table: 'tags' } }; + if (col === 'tags' && f === 'label') return { field: 'label' }; + return null; + }, + getFieldsForCollection: () => [], + }), + useRelationsStore: () => ({ + getRelationsForField: (col: string, f: string) => + col === 'parent' && f === 'tags' + ? [ + { + collection: 'parent_tags', + field: 'parent_id', + related_collection: 'parent', + meta: { junction_field: 'tag_id' }, + }, + ] + : [], + }), + }), + useCollection: () => ({ primaryKeyField: { value: { field: 'id' } } }), + useExtensions: () => ({ displays: { value: [] } }), + })); + const { adjustFieldsForDisplays } = await import('@/utils/adjustFieldsForDisplays'); + const result = adjustFieldsForDisplays( + ['tags'], + 'parent', + { tags: { template: '{{label}}' } } + ); + expect(result).toContain('tags.tag_id.label'); + }); +}); From 70e6dcd632263df3ce3603324fde95b3df37f70d Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:51:23 +0200 Subject: [PATCH 05/11] style: prettier auto-fix in adjustFieldsForDisplays --- src/utils/adjustFieldsForDisplays.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index bae69c7..b1c3b00 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -166,9 +166,7 @@ export function expandTokensThroughRelation( return []; } const junctionFieldDef = fieldsStore.getField(junctionCollection, junctionField); - const targetCollection = junctionFieldDef?.schema?.foreign_key_table as - | string - | undefined; + const targetCollection = junctionFieldDef?.schema?.foreign_key_table as string | undefined; if (!targetCollection) return []; const expanded: string[] = []; @@ -176,8 +174,7 @@ export function expandTokensThroughRelation( const parts = tok.split('.'); // If user wrote the junction_field as the first segment already, strip it // so we don't double-prefix. - const tokWithoutJunctionPrefix = - parts[0] === junctionField ? parts.slice(1).join('.') : tok; + const tokWithoutJunctionPrefix = parts[0] === junctionField ? parts.slice(1).join('.') : tok; if (!tokWithoutJunctionPrefix) continue; const firstSegment = tokWithoutJunctionPrefix.split('.')[0]!; if (!fieldsStore.getField(targetCollection, firstSegment)) continue; @@ -369,7 +366,8 @@ export function adjustFieldsForDisplays( } } - displayFields = pkPath && !expanded.includes(pkPath) ? [...expanded, pkPath] : expanded; + displayFields = + pkPath && !expanded.includes(pkPath) ? [...expanded, pkPath] : expanded; // Final safety: if nothing valid, fall back to the bare fieldKey so the // request still loads the relation and the row renders. if (displayFields.length === 0) displayFields = [fieldKey]; From c3af8f43e671577319565aa8c5e8c94be0a4195c Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:52:36 +0200 Subject: [PATCH 06/11] test(#55): pin useCollection reactive-ref contract via source regex --- .../useCollectionReactivity.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 tests/unit/composables/useCollectionReactivity.test.ts diff --git a/tests/unit/composables/useCollectionReactivity.test.ts b/tests/unit/composables/useCollectionReactivity.test.ts new file mode 100644 index 0000000..1edfc76 --- /dev/null +++ b/tests/unit/composables/useCollectionReactivity.test.ts @@ -0,0 +1,44 @@ +/** + * Issue #55 (comment by draxx318): The layout must pass a reactive Ref to + * `useCollection` so the SDK can update `fieldsInCollection` when the + * collection prop changes. Passing `collection.value` extracts a static + * string, breaking reactivity and causing 403s on collection-switch (a + * second collection's request includes the first collection's field list). + * + * Directus core itself accepts `string | Ref` in + * useCollection — passing the Ref is the canonical pattern. + * + * We use source-text assertions here instead of a component mount because + * super-table.vue has a large dependency surface; the bug is fundamentally + * a single-character regression and a source-level guard is sufficient + * (and immune to vacuous-pass risk from setup() never running). + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const root = resolve(__dirname, '..', '..', '..'); + +describe('useCollection reactivity contract (issue #55 comment)', () => { + it('src/super-table.vue does NOT pass collection.value to useCollection', () => { + const src = readFileSync(resolve(root, 'src/super-table.vue'), 'utf-8'); + expect(src).not.toMatch(/useCollection\s*\(\s*collection\.value\s*\)/); + }); + + it('src/super-table.vue passes the reactive collection ref to useCollection', () => { + const src = readFileSync(resolve(root, 'src/super-table.vue'), 'utf-8'); + // Accept either `useCollection(collection)` (ref) or + // `useCollection()` but not the buggy `.value` form. + // Easiest positive assertion: useCollection( collection ) without `.value`. + expect(src).toMatch(/useCollection\s*\(\s*collection\s*\)/); + }); + + it('index.ts does NOT pass props.collection (raw string) to useCollection', () => { + const src = readFileSync(resolve(root, 'index.ts'), 'utf-8'); + // The setup-time call in index.ts was always unused (the return value + // is discarded). After Task 6 it is either removed entirely, or rewritten + // to take a ref. Either way, the buggy raw-string form must not be there. + expect(src).not.toMatch(/useCollection\s*\(\s*props\.collection\s*\)/); + }); +}); From 553887c1739d5c4d90cb81e2c97c9fe7e904b605 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 10:58:27 +0200 Subject: [PATCH 07/11] fix(#55): pass reactive collection ref to useCollection (collection-switch regression) --- index.ts | 3 +-- src/super-table.vue | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index a9f0c3c..e9b9707 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,5 @@ import { computed } from 'vue'; -import { defineLayout, useCollection, useStores } from '@directus/extensions-sdk'; +import { defineLayout, useStores } from '@directus/extensions-sdk'; import { formatTitle } from '@directus/format-title'; import LayoutComponent from './src/super-table.vue'; import ActionsComponent from './src/actions.vue'; @@ -21,7 +21,6 @@ const layoutConfig = { setup(props: any, { emit }: any) { const { useFieldsStore } = useStores(); const fieldsStore = useFieldsStore(); - useCollection(props.collection); // parity with super-table.vue // Issue #48: Expose a flat list of currently visible columns to slot // components (options sidebar). Each entry uses the *root* field key as id diff --git a/src/super-table.vue b/src/super-table.vue index abe3fc3..0908e83 100644 --- a/src/super-table.vue +++ b/src/super-table.vue @@ -346,7 +346,12 @@ const layoutQuery = useSync(props, 'layoutQuery', emit); // Collection info const { collection, filter, search, readonly } = toRefs(props); -const { primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection.value); +// Pass the reactive Ref (not collection.value) so useCollection re-reads +// fields when the collection prop changes — critical for M2M relation editors +// that swap the layout between collections. See issue #55. +// @ts-expect-error — peer-dep Vue version skew between SDK and host produces +// a spurious `RefSymbol` mismatch; the runtime contract is correct. +const { primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection); // Helper to get primary key field name with proper typing const getPrimaryKeyFieldName = () => { From 149ccd90186f0b5e118818c9d4961a2813df275e Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 11:27:30 +0200 Subject: [PATCH 08/11] test(#55): playwright smoke test for M2M and collection-switch scenarios --- .../issue-55-m2m-pivot-repro.js | 410 ++++++++++++++++++ .../issue-55-smoke-report.json | 106 +++++ 2 files changed, 516 insertions(+) create mode 100644 playwright-tools/project-specific/issue-55-m2m-pivot-repro.js create mode 100644 playwright-tools/project-specific/issue-55-smoke-report.json diff --git a/playwright-tools/project-specific/issue-55-m2m-pivot-repro.js b/playwright-tools/project-specific/issue-55-m2m-pivot-repro.js new file mode 100644 index 0000000..f274b00 --- /dev/null +++ b/playwright-tools/project-specific/issue-55-m2m-pivot-repro.js @@ -0,0 +1,410 @@ +import { chromium } from 'playwright'; +import chalk from 'chalk'; +import { writeFileSync, existsSync, mkdirSync } from 'fs'; +import path from 'path'; + +/** + * Issue #55 — Smoke test for v0.4.1 hotfix + * + * Verifies two regressions are fixed: + * - Bug 1: M2M pivot 403 on /items/ caused by the display-field + * expansion path injecting pivot-bypass field selectors. + * - Bug 2: Collection-switch leaking stale display fields into the next + * collection's fetch (`fields[]=.foo`). + * + * Strategy: + * - Scenario 1: navigate sequentially to N collections that use the + * super-layout-table, capture every 4xx/5xx response on /items/. + * - Scenario 2: switch between collections in sequence and capture the + * first /items/* request after each switch, so we can verify the + * `fields[]` param does not contain a previous-collection prefix. + * + * Environment: + * - BASE_URL defaults to http://localhost:8058 (nginx proxy returns 502). + * - HEADLESS=false to watch the browser run. + */ + +const BASE_URL = process.env.BASE_URL || 'http://localhost:8058'; +const HEADLESS = process.env.HEADLESS !== 'false'; +const EMAIL = 'admin@example.com'; +const PASSWORD = 'd1r3ctu5'; + +const SCREENSHOT_DIR = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '../../screenshots/issue-55' +); +const REPORT_PATH = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + 'issue-55-smoke-report.json' +); + +const NAV_TIMEOUT_MS = 15_000; + +// Collections to probe for Bug 1 (M2M smoke). +const SCENARIO_1_COLLECTIONS = [ + 'pages', + 'content_block', + 'content_headline', + 'content_image', + 'expandable', +]; + +// Ordered transitions for Bug 2 (collection switch). +const SCENARIO_2_TRANSITIONS = [ + { from: 'pages', to: 'content_headline' }, + { from: 'content_headline', to: 'content_block' }, + { from: 'content_block', to: 'pages' }, +]; + +function ensureDir(dir) { + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +/** + * Pulls the `fields[]` query params (preserving order) from an /items/ URL, + * so the report can show whether stale collection-prefixed fields leaked in. + */ +function extractFieldsParam(url) { + try { + const u = new URL(url); + const values = u.searchParams.getAll('fields[]'); + if (values.length > 0) return values; + const single = u.searchParams.get('fields'); + if (single) return single.split(','); + return []; + } catch { + return []; + } +} + +/** + * Extracts the collection name out of `/items/` so we can tell + * which collection a request is targeting (and detect cross-collection leaks). + */ +function extractCollectionFromUrl(url) { + const match = url.match(/\/items\/([^/?]+)/); + return match ? match[1] : null; +} + +async function attachItemsNetworkTracking(page) { + const errors = []; + const itemsRequests = []; + + page.on('response', async (response) => { + const url = response.url(); + if (!url.includes('/items/')) return; + + const status = response.status(); + const requestUrl = response.request().url(); + const collection = extractCollectionFromUrl(url); + const fields = extractFieldsParam(requestUrl); + + const entry = { + time: new Date().toISOString(), + url, + status, + collection, + fields, + }; + + itemsRequests.push(entry); + + if (status >= 400) { + const errorEntry = { ...entry }; + // Best-effort: capture the response body for debugging context. + try { + const text = await response.text(); + errorEntry.bodyExcerpt = text.substring(0, 500); + } catch { + // Body may already be consumed or stream closed — non-fatal. + } + errors.push(errorEntry); + console.log( + chalk.red(`[items 4xx/5xx] ${status} ${collection} — ${url}`) + ); + } + }); + + return { errors, itemsRequests }; +} + +async function login(page) { + console.log(chalk.cyan(`Navigating to ${BASE_URL}/admin/login`)); + await page.goto(`${BASE_URL}/admin/login`, { + waitUntil: 'domcontentloaded', + timeout: NAV_TIMEOUT_MS, + }); + + // Directus is a SPA — the login form mounts after the JS bundle parses. + // Wait for the actual input fields to exist before typing. + await page.waitForSelector('input[type="email"]', { + timeout: NAV_TIMEOUT_MS, + }); + + // If already authenticated, Directus client-side redirects out of /login. + if (!page.url().includes('/login')) { + console.log(chalk.green('Already authenticated — skipping login form.')); + return; + } + + await page.fill('input[type="email"]', EMAIL); + await page.fill('input[type="password"]', PASSWORD); + await page.click('button[type="submit"]'); + + // SPA routing — wait for the URL to leave /login rather than waitForNavigation. + try { + await page.waitForURL((url) => !url.toString().includes('/login'), { + timeout: NAV_TIMEOUT_MS, + }); + } catch { + throw new Error( + `Login failed — still on ${page.url()} after submitting credentials.` + ); + } + + console.log(chalk.green(`Logged in — now at ${page.url()}`)); +} + +/** + * Navigates to a collection's content view and waits for the table to render. + * Returns whether at least one row materialised, so the report can flag empty + * collections separately from rendering failures. + */ +async function gotoCollection(page, collection) { + const target = `${BASE_URL}/admin/content/${collection}`; + await page.goto(target, { + waitUntil: 'domcontentloaded', + timeout: NAV_TIMEOUT_MS, + }); + + // The table renders after the first /items/ response settles; give it a beat. + try { + await page.waitForSelector('.v-table, .render-template, .no-items', { + timeout: NAV_TIMEOUT_MS, + }); + } catch { + // Don't throw — the report will still log network errors and screenshot. + } + + // Allow lazy-rendered cells to paint before snapshotting. + await page.waitForTimeout(1500); + + const rowCount = await page + .locator('.v-table tbody tr') + .count() + .catch(() => 0); + + return rowCount > 0; +} + +/** + * Snapshots both viewport and full page so issues that appear below the fold + * (pagination, footer errors) are still captured. + */ +async function snapshot(page, name) { + const filePath = path.join(SCREENSHOT_DIR, `${name}.png`); + await page.screenshot({ path: filePath, fullPage: true }); + return filePath; +} + +async function runScenario1(page, tracker) { + console.log(chalk.cyan('\n=== Scenario 1: M2M-relations smoke ===')); + const results = []; + + for (const collection of SCENARIO_1_COLLECTIONS) { + console.log(chalk.gray(`→ ${collection}`)); + const errorsBefore = tracker.errors.length; + let tableRendered = false; + let navError = null; + + try { + tableRendered = await gotoCollection(page, collection); + } catch (err) { + navError = err.message; + } + + const errorsForThisCollection = tracker.errors + .slice(errorsBefore) + .filter((e) => e.collection === collection); + + const screenshotPath = await snapshot(page, `scenario1-${collection}`); + + results.push({ + collection, + tableRendered, + navError, + errors: errorsForThisCollection, + screenshot: screenshotPath, + }); + + const status = + errorsForThisCollection.length === 0 + ? chalk.green('OK') + : chalk.red(`${errorsForThisCollection.length} error(s)`); + console.log( + ` ${status} — table rendered: ${tableRendered ? 'yes' : 'no'}` + ); + } + + return results; +} + +async function runScenario2(page, tracker) { + console.log(chalk.cyan('\n=== Scenario 2: Collection-switch ===')); + + // Seed at the first "from" so the first transition's `firstItemsRequest` + // really reflects the switch and not the initial cold load. + await gotoCollection(page, SCENARIO_2_TRANSITIONS[0].from); + + const results = []; + + for (const { from, to } of SCENARIO_2_TRANSITIONS) { + console.log(chalk.gray(`→ ${from} → ${to}`)); + + const errorsBefore = tracker.errors.length; + const requestsBefore = tracker.itemsRequests.length; + + let navError = null; + let tableRendered = false; + try { + tableRendered = await gotoCollection(page, to); + } catch (err) { + navError = err.message; + } + + const newRequests = tracker.itemsRequests.slice(requestsBefore); + const firstItemsRequest = + newRequests.find((r) => r.collection === to) || null; + + // Specifically interesting: any request that hit the destination + // collection's URL but carried a field prefixed with the previous + // collection's name. That's the Bug 2 fingerprint. + const leakedFields = newRequests + .filter((r) => r.collection === to) + .flatMap((r) => + r.fields + .filter((f) => f.includes('.') && f.startsWith(`${from}.`)) + .map((f) => ({ url: r.url, field: f })) + ); + + const errorsForTransition = tracker.errors + .slice(errorsBefore) + .filter((e) => e.collection === to); + + const screenshotPath = await snapshot(page, `scenario2-${from}-to-${to}`); + + results.push({ + from, + to, + tableRendered, + navError, + firstItemsRequest, + leakedFields, + errors: errorsForTransition, + screenshot: screenshotPath, + }); + + const status = + errorsForTransition.length === 0 && leakedFields.length === 0 + ? chalk.green('OK') + : chalk.red('issues'); + console.log( + ` ${status} — first /items/ ${ + firstItemsRequest ? firstItemsRequest.status : 'n/a' + }, leaks: ${leakedFields.length}` + ); + } + + return results; +} + +async function main() { + ensureDir(SCREENSHOT_DIR); + + const browser = await chromium.launch({ headless: HEADLESS }); + const context = await browser.newContext(); + const page = await context.newPage(); + + page.setDefaultNavigationTimeout(NAV_TIMEOUT_MS); + + const tracker = await attachItemsNetworkTracking(page); + + try { + await login(page); + } catch (err) { + console.log(chalk.red(`Login failure: ${err.message}`)); + await browser.close(); + process.exit(1); + } + + let scenario1 = []; + let scenario2 = []; + + try { + scenario1 = await runScenario1(page, tracker); + scenario2 = await runScenario2(page, tracker); + } catch (err) { + console.log(chalk.red(`Scenario crashed: ${err.message}`)); + } + + // Aggregate pass/fail. PASS requires zero 4xx/5xx on /items/ AND no + // cross-collection leaked fields after a switch. + const scenario1Errors = scenario1.reduce( + (acc, r) => acc + r.errors.length, + 0 + ); + const scenario2Errors = scenario2.reduce( + (acc, r) => acc + r.errors.length, + 0 + ); + const scenario2Leaks = scenario2.reduce( + (acc, r) => acc + r.leakedFields.length, + 0 + ); + + const overallStatus = + scenario1Errors === 0 && scenario2Errors === 0 && scenario2Leaks === 0 + ? 'PASS' + : 'FAIL'; + + const report = { + generatedAt: new Date().toISOString(), + baseUrl: BASE_URL, + overallStatus, + summary: { + scenario1Errors, + scenario2Errors, + scenario2Leaks, + totalItemsRequests: tracker.itemsRequests.length, + }, + scenario1, + scenario2, + allItemsErrors: tracker.errors, + }; + + writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2)); + + console.log('\n' + chalk.bold('=== Smoke summary ===')); + console.log(`Scenario 1 errors: ${scenario1Errors}`); + console.log(`Scenario 2 errors: ${scenario2Errors}`); + console.log(`Scenario 2 leaks: ${scenario2Leaks}`); + console.log( + `Overall: ${ + overallStatus === 'PASS' + ? chalk.green(overallStatus) + : chalk.red(overallStatus) + }` + ); + console.log(`Report: ${REPORT_PATH}`); + + await browser.close(); + process.exit(overallStatus === 'PASS' ? 0 : 1); +} + +main().catch((err) => { + console.log(chalk.red(`Fatal: ${err.message}`)); + console.log(err.stack); + process.exit(1); +}); diff --git a/playwright-tools/project-specific/issue-55-smoke-report.json b/playwright-tools/project-specific/issue-55-smoke-report.json new file mode 100644 index 0000000..465a7ef --- /dev/null +++ b/playwright-tools/project-specific/issue-55-smoke-report.json @@ -0,0 +1,106 @@ +{ + "generatedAt": "2026-05-23T09:26:58.281Z", + "baseUrl": "http://localhost:8058", + "overallStatus": "PASS", + "summary": { + "scenario1Errors": 0, + "scenario2Errors": 0, + "scenario2Leaks": 0, + "totalItemsRequests": 34 + }, + "scenario1": [ + { + "collection": "pages", + "tableRendered": true, + "navError": null, + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario1-pages.png" + }, + { + "collection": "content_block", + "tableRendered": true, + "navError": null, + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario1-content_block.png" + }, + { + "collection": "content_headline", + "tableRendered": true, + "navError": null, + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario1-content_headline.png" + }, + { + "collection": "content_image", + "tableRendered": true, + "navError": null, + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario1-content_image.png" + }, + { + "collection": "expandable", + "tableRendered": true, + "navError": null, + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario1-expandable.png" + } + ], + "scenario2": [ + { + "from": "pages", + "to": "content_headline", + "tableRendered": true, + "navError": null, + "firstItemsRequest": { + "time": "2026-05-23T09:26:51.525Z", + "url": "http://localhost:8058/items/content_headline?aggregate[count]=*&filter[status][_neq]=archived", + "status": 200, + "collection": "content_headline", + "fields": [] + }, + "leakedFields": [], + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario2-pages-to-content_headline.png" + }, + { + "from": "content_headline", + "to": "content_block", + "tableRendered": true, + "navError": null, + "firstItemsRequest": { + "time": "2026-05-23T09:26:54.046Z", + "url": "http://localhost:8058/items/content_block?aggregate[countDistinct]=id&filter[status][_neq]=archived", + "status": 200, + "collection": "content_block", + "fields": [] + }, + "leakedFields": [], + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario2-content_headline-to-content_block.png" + }, + { + "from": "content_block", + "to": "pages", + "tableRendered": true, + "navError": null, + "firstItemsRequest": { + "time": "2026-05-23T09:26:56.446Z", + "url": "http://localhost:8058/items/pages?fields[]=id&fields[]=slug&fields[]=status&fields[]=subtitle&fields[]=template&fields[]=title&page=1&limit=25&filter[status][_neq]=archived", + "status": 200, + "collection": "pages", + "fields": [ + "id", + "slug", + "status", + "subtitle", + "template", + "title" + ] + }, + "leakedFields": [], + "errors": [], + "screenshot": "/private/var/www/directus/extensions/super-layout-table/screenshots/issue-55/scenario2-content_block-to-pages.png" + } + ], + "allItemsErrors": [] +} \ No newline at end of file From 834ec37a1b26a9212c7b7ead21fc39ef077323d8 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 15:03:53 +0200 Subject: [PATCH 09/11] fix(#55): render M2M templates with junction unwrap regardless of source + primitive override path The display rendering had two latent bugs surfacing in the issue #55 setup: 1. M2M unwrap (mapping junction items through junction_field before applying the template) only ran for override- and heuristic-sourced templates, skipping the field-settings-display path. Result: with display set to related-values + template {{name}}, the template would look up 'name' on the pivot row (which has no 'name') and leak the literal {{name}}. 2. A column-display override on a simple, non-relational field (e.g. template {{name}} on a string field 'name') fell through to a primitive-value fallback that returned em-dash. The template should apply to the primitive directly. --- src/components/EditableCellRelational.vue | 28 +++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/components/EditableCellRelational.vue b/src/components/EditableCellRelational.vue index bf9eaac..229d203 100644 --- a/src/components/EditableCellRelational.vue +++ b/src/components/EditableCellRelational.vue @@ -293,17 +293,22 @@ const displayValue = computed(() => { isHeuristicPath = true; } } - if (template) { const relationalValue = props.item[props.fieldKey]; - // M2M unwrap when override OR heuristic provides the template. (Field-display - // renders via the existing path which handles its own shape.) + // M2M unwrap: when the value is the junction array, dereference each item + // through `junction_field` so the template sees the target row directly. + // Issue #55: this must run regardless of how the template was sourced + // (override / field-settings / heuristic). The non-edit-mode cell does NOT + // delegate to render-display — it runs renderTemplate() directly on + // valueForTemplate, so without unwrapping junction items, `{{name}}` would + // look up `name` on the pivot row (which has no `name` field) and leak as + // a literal. + void isOverridePath; + void isHeuristicPath; let valueForTemplate = relationalValue; const needsM2MUnwrap = - (isOverridePath || isHeuristicPath) && - Array.isArray(relationalValue) && - props.field?.meta?.special?.includes('m2m'); + Array.isArray(relationalValue) && props.field?.meta?.special?.includes('m2m'); if (needsM2MUnwrap) { const collection = props.field?.collection; const fieldName = props.field?.field; @@ -334,7 +339,16 @@ const displayValue = computed(() => { return renderTemplate(valueForTemplate, template); } - // If corrupted (primitive value), try cache fallback + // Primitive value with override template (issue #55): a column-display + // override can be set on a simple, non-relational field — the override + // template (e.g. `{{name}}`) is then applied to the field's primitive + // value. Without this branch the cell renders em-dash even though the + // underlying value is present. + if (valueForTemplate !== undefined && valueForTemplate !== null) { + return renderTemplate(valueForTemplate, template); + } + + // If corrupted (null/undefined), try cache fallback const cachedValue = relationalCache.value[props.fieldKey]; if (cachedValue) { return renderTemplate(cachedValue, template); From 56bf86459846e7e32047ffbc5e820b6b87ca2a45 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 15:21:46 +0200 Subject: [PATCH 10/11] style: trim verbose comments in hotfix code --- src/components/EditableCellRelational.vue | 18 ++----------- src/super-table.vue | 6 +---- src/utils/adjustFieldsForDisplays.ts | 33 +++-------------------- 3 files changed, 7 insertions(+), 50 deletions(-) diff --git a/src/components/EditableCellRelational.vue b/src/components/EditableCellRelational.vue index 229d203..21fb5de 100644 --- a/src/components/EditableCellRelational.vue +++ b/src/components/EditableCellRelational.vue @@ -296,17 +296,11 @@ const displayValue = computed(() => { if (template) { const relationalValue = props.item[props.fieldKey]; - // M2M unwrap: when the value is the junction array, dereference each item - // through `junction_field` so the template sees the target row directly. - // Issue #55: this must run regardless of how the template was sourced - // (override / field-settings / heuristic). The non-edit-mode cell does NOT - // delegate to render-display — it runs renderTemplate() directly on - // valueForTemplate, so without unwrapping junction items, `{{name}}` would - // look up `name` on the pivot row (which has no `name` field) and leak as - // a literal. void isOverridePath; void isHeuristicPath; let valueForTemplate = relationalValue; + // M2M: unwrap junction items through junction_field so the template + // resolves against the target row, not the pivot row. const needsM2MUnwrap = Array.isArray(relationalValue) && props.field?.meta?.special?.includes('m2m'); if (needsM2MUnwrap) { @@ -323,7 +317,6 @@ const displayValue = computed(() => { } } - // If we have an array (M2M / O2M), render each item with the template and join if (Array.isArray(valueForTemplate)) { if (valueForTemplate.length === 0) return '—'; return valueForTemplate @@ -334,21 +327,14 @@ const displayValue = computed(() => { .join(', '); } - // Single object → render once if (valueForTemplate && typeof valueForTemplate === 'object') { return renderTemplate(valueForTemplate, template); } - // Primitive value with override template (issue #55): a column-display - // override can be set on a simple, non-relational field — the override - // template (e.g. `{{name}}`) is then applied to the field's primitive - // value. Without this branch the cell renders em-dash even though the - // underlying value is present. if (valueForTemplate !== undefined && valueForTemplate !== null) { return renderTemplate(valueForTemplate, template); } - // If corrupted (null/undefined), try cache fallback const cachedValue = relationalCache.value[props.fieldKey]; if (cachedValue) { return renderTemplate(cachedValue, template); diff --git a/src/super-table.vue b/src/super-table.vue index 0908e83..28eea03 100644 --- a/src/super-table.vue +++ b/src/super-table.vue @@ -346,11 +346,7 @@ const layoutQuery = useSync(props, 'layoutQuery', emit); // Collection info const { collection, filter, search, readonly } = toRefs(props); -// Pass the reactive Ref (not collection.value) so useCollection re-reads -// fields when the collection prop changes — critical for M2M relation editors -// that swap the layout between collections. See issue #55. -// @ts-expect-error — peer-dep Vue version skew between SDK and host produces -// a spurious `RefSymbol` mismatch; the runtime contract is correct. +// @ts-expect-error — Vue peer-dep skew between SDK and host: RefSymbol mismatch const { primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection); // Helper to get primary key field name with proper typing diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index b1c3b00..11d5f77 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -123,24 +123,9 @@ function getDisplayFieldsForRelation( return [`${fieldKey}.${pkField}`]; } -/** - * Issue #55: Single source of truth for expanding template tokens into API - * field paths. Handles three relation classes: - * - * M2M → fieldKey.. (e.g. product_tags.tag_id.name) - * M2O / O2M → fieldKey. (e.g. author.first_name) - * translations → fieldKey. (deep parameter handles depth) - * - * Tokens that do not resolve to a real field on the actual *target* collection - * are dropped, so we never enqueue `${junction}.name` for a junction without - * a `name` column. Translations skip validation because schemas vary and the - * existing client-side render path resolves missing fields gracefully. - * - * For dotted tokens (e.g. "category.name") only the first segment is validated; - * the rest is forwarded verbatim. If the user's M2M template already starts - * with the junction_field (e.g. {{tag_id.name}}), the junction prefix is NOT - * duplicated. - */ +// Expands template tokens to API field paths. For M2M, traverses +// junction_field to reach the target collection; drops tokens that don't +// exist on the target. Translations skip validation (varying schemas). export function expandTokensThroughRelation( field: { meta?: { special?: string[] } } | null, fieldKey: string, @@ -317,10 +302,6 @@ export function adjustFieldsForDisplays( // Handle different display types with their specific field requirements switch (displayId) { case 'related-values': { - // Issue #55: M2M fields must traverse junction_field, not query the junction - // collection directly. We delegate token expansion to expandTokensThroughRelation - // which knows about M2M junctions, validates token existence on the actual - // target collection, and drops tokens that would 403. const template = field.meta?.display_options?.template; if (template) { const templateTokens = extractFieldsFromTemplate(template); @@ -333,9 +314,7 @@ export function adjustFieldsForDisplays( relationsStore ); - // Always include the PK of the actual target so the row can be keyed. - // For M2M, the PK lives behind the junction_field path; for M2O/O2M, it's - // a direct child of fieldKey. + // PK path so the row can be keyed; M2M needs the junction prefix. const isM2M = field.meta?.special?.includes('m2m') === true; let pkPath: string | null = null; if (isM2M) { @@ -368,12 +347,8 @@ export function adjustFieldsForDisplays( displayFields = pkPath && !expanded.includes(pkPath) ? [...expanded, pkPath] : expanded; - // Final safety: if nothing valid, fall back to the bare fieldKey so the - // request still loads the relation and the row renders. if (displayFields.length === 0) displayFields = [fieldKey]; } else { - // No template: just request the PK of the related collection so the row - // can be keyed. For M2M, traverse junction_field; for others, direct path. const rootField = (fieldKey.split('.')[0] ?? fieldKey) as string; const relatedCollection = getRelatedCollection( parentCollection, From 87cbe6626726c59c7e8518ad4ae6b5300f973e63 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Sat, 23 May 2026 22:50:40 +0200 Subject: [PATCH 11/11] chore: bump version to 0.4.1 for issue #55 hotfix --- CHANGELOG.md | 22 ++++++++++++++++++++++ package.json | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12d5a40..a028e27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to the Super Layout Table Extension will be documented in th The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v0.4.1 — Hotfix: M2M pivot 403 + collection-switch (Issue #55) + +### Fixed +- **M2M pivot 403:** `related-values` display no longer queries + `${field}.name` on the junction collection. Field paths now traverse + `junction_field` to the actual target and drop tokens missing there. +- **Collection-switch stale fields:** `useCollection` now receives a + reactive `Ref`, so the field list refreshes when the collection prop + changes. Reported by @draxx318 in #55. +- **M2M display rendering:** Junction-row unwrap also runs for + field-settings displays (was previously override/heuristic only), + fixing literal `{{name}}` leaks. +- **Override on primitive fields:** A column-display template on a + non-relational field now applies to the value instead of returning + em-dash. + +### Refactor +- New `expandTokensThroughRelation` helper unifies M2M-junction + traversal across the override, heuristic, and `related-values` + branches of `adjustFieldsForDisplays`. Closes the drift class behind + #34 and #55. + ## v0.4.0 — Permission-aware layout (Issue #37) ### Fixed diff --git a/package.json b/package.json index 250a094..092dc55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "directus-extension-super-table", - "version": "0.4.0", + "version": "0.4.1", "description": "A powerful and feature-rich table layout extension for Directus 11+ with inline editing, quick filters, and manual sorting", "keywords": [ "directus",