From ece95fdcb77536f576d775379dc0138951cf11fc Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Mon, 1 Jun 2026 22:30:14 +0200 Subject: [PATCH 1/5] fix: accept native picker token prefix for M2A column displays (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An M2A column display built with the native display-template picker rendered an empty cell. The picker emits per-collection tokens prefixed with the parent field name (e.g. `{{treatment:partners_catalog.name}}`), but only the hand-written `{{item:collection.field}}` form was recognised, so the picker tokens were dropped on both the query and render sides. - Add a shared isM2APrefix helper in displayHeuristics that accepts the parent field name, the junction item field, and the literal `item` as valid M2A token prefixes — matching Directus core's render-template behaviour. - Use it in expandTokensThroughRelation (query) and renderM2ATemplate (render); renderM2ATemplate now takes the field name to resolve the prefix. - Both token formats now produce identical field paths and output. --- src/components/EditableCellRelational.vue | 8 +- src/utils/adjustFieldsForDisplays.ts | 5 +- src/utils/displayHeuristics.ts | 9 ++ src/utils/renderM2ATemplate.ts | 8 +- tests/unit/utils/displayHeuristics.test.ts | 16 ++++ .../utils/expandTokensThroughRelation.test.ts | 18 ++++ tests/unit/utils/renderM2ATemplate.test.ts | 95 +++++++++++++------ 7 files changed, 125 insertions(+), 34 deletions(-) diff --git a/src/components/EditableCellRelational.vue b/src/components/EditableCellRelational.vue index eaf87c3..38f1507 100644 --- a/src/components/EditableCellRelational.vue +++ b/src/components/EditableCellRelational.vue @@ -436,7 +436,13 @@ const m2aSegments = computed(() => { } continue; } - const text = renderM2ATemplate(row, template, m2a.itemField, m2a.discriminator).trim(); + const text = renderM2ATemplate( + row, + template, + m2a.itemField, + m2a.discriminator, + String(fieldName) + ).trim(); if (text && text !== '—') segments.push({ text }); } return segments; diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index 54353fd..86256fe 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -8,6 +8,7 @@ import { pickHeuristic, parseM2AToken, buildM2AFieldPath, + isM2APrefix, } from './displayHeuristics'; import { resolveM2ARelation } from './resolveM2ARelation'; @@ -172,7 +173,9 @@ export function expandTokensThroughRelation( const parsed = parseM2AToken(tok); if (!parsed) continue; const { prefix, collection: col, path } = parsed; - if (prefix !== itemField && prefix !== 'item') continue; + // The picker emits the field name as prefix; a hand-written template may + // use `item`. fieldKey is the parent field name here. + if (!isM2APrefix(prefix, fieldKey, itemField)) continue; if (allowedCollections.length > 0 && !allowedCollections.includes(col)) continue; // Only the first path segment is validated against the target — deep // leaves are unvalidated, matching the M2M/M2O branches below. diff --git a/src/utils/displayHeuristics.ts b/src/utils/displayHeuristics.ts index 53b90ba..06a99d8 100644 --- a/src/utils/displayHeuristics.ts +++ b/src/utils/displayHeuristics.ts @@ -65,6 +65,15 @@ export function buildM2AFieldPath( return `${fieldKey}.${itemField}:${collection}.${path}`; } +/** + * Whether a token prefix addresses this M2A relation. The native display-template + * picker emits the parent field name (`treatment:col.field`); a hand-written + * template may use `item:col.field`. Both are accepted. + */ +export function isM2APrefix(prefix: string, fieldName: string, itemField: string): boolean { + return prefix === fieldName || prefix === itemField || prefix === 'item'; +} + interface RelationsStoreLike { getRelationsForField: ( collection: string, diff --git a/src/utils/renderM2ATemplate.ts b/src/utils/renderM2ATemplate.ts index a828180..5c8790a 100644 --- a/src/utils/renderM2ATemplate.ts +++ b/src/utils/renderM2ATemplate.ts @@ -1,9 +1,8 @@ import { get } from '@directus/utils'; -import { parseM2AToken } from './displayHeuristics'; +import { parseM2AToken, isM2APrefix } from './displayHeuristics'; /** Conventional M2A token aliases accepted alongside the resolved field names. */ export const M2A_COLLECTION_TOKEN = 'collection'; -export const M2A_ITEM_TOKEN = 'item'; /** * Render one M2A junction row against a related-values template. @@ -16,7 +15,8 @@ export function renderM2ATemplate( row: Record | null | undefined, template: string, itemField: string, - discriminator: string + discriminator: string, + fieldName: string ): string { if (!row || typeof row !== 'object') return '—'; const rowCollection = row[discriminator]; @@ -30,7 +30,7 @@ export function renderM2ATemplate( const parsed = parseM2AToken(token); if (parsed) { - if (parsed.prefix !== itemField && parsed.prefix !== M2A_ITEM_TOKEN) return ''; + if (!isM2APrefix(parsed.prefix, fieldName, itemField)) return ''; // Only the branch matching this row's collection contributes a value. if (parsed.collection !== rowCollection) return ''; return scalarOrEmpty(getNestedValue(item, parsed.path)); diff --git a/tests/unit/utils/displayHeuristics.test.ts b/tests/unit/utils/displayHeuristics.test.ts index cffa01d..58810d5 100644 --- a/tests/unit/utils/displayHeuristics.test.ts +++ b/tests/unit/utils/displayHeuristics.test.ts @@ -7,6 +7,7 @@ import { pickHeuristic, parseM2AToken, buildM2AFieldPath, + isM2APrefix, } from '@/utils/displayHeuristics'; describe('isRelational', () => { @@ -362,3 +363,18 @@ describe('buildM2AFieldPath', () => { }); }); }); + +describe('isM2APrefix', () => { + it('accepts the parent field name (picker output)', () => { + expect(isM2APrefix('treatment', 'treatment', 'item')).toBe(true); + }); + + it('accepts the junction item field and the literal "item"', () => { + expect(isM2APrefix('item', 'treatment', 'item')).toBe(true); + expect(isM2APrefix('item', 'treatment', 'other_fk')).toBe(true); + }); + + it('rejects an unrelated prefix', () => { + expect(isM2APrefix('something', 'treatment', 'item')).toBe(false); + }); +}); diff --git a/tests/unit/utils/expandTokensThroughRelation.test.ts b/tests/unit/utils/expandTokensThroughRelation.test.ts index 5b693df..0c2c187 100644 --- a/tests/unit/utils/expandTokensThroughRelation.test.ts +++ b/tests/unit/utils/expandTokensThroughRelation.test.ts @@ -316,6 +316,24 @@ describe('expandTokensThroughRelation', () => { ]); }); + it('accepts the picker prefix (field name) as well as item', () => { + const { fieldsStore, relationsStore } = m2aStores(); + // The native display-template picker emits `:collection.field`. + const result = expandTokensThroughRelation( + m2aField, + 'treatment', + 'orders', + ['treatment:partners_catalog.name', 'treatment:service.name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual([ + 'treatment.collection', + 'treatment.item:partners_catalog.name', + 'treatment.item:service.name', + ]); + }); + it('keeps nested item paths intact (M2A -> M2O -> scalar)', () => { const { fieldsStore, relationsStore } = m2aStores(); const result = expandTokensThroughRelation( diff --git a/tests/unit/utils/renderM2ATemplate.test.ts b/tests/unit/utils/renderM2ATemplate.test.ts index e11936a..9db74e4 100644 --- a/tests/unit/utils/renderM2ATemplate.test.ts +++ b/tests/unit/utils/renderM2ATemplate.test.ts @@ -2,62 +2,101 @@ import { describe, it, expect } from 'vitest'; import { renderM2ATemplate } from '@/utils/renderM2ATemplate'; // Junction rows mirror the live orders.treatment shape: a `collection` -// discriminator + a polymorphic `item` payload. +// discriminator + a polymorphic `item` payload. `treatment` is the parent +// field name the native picker uses as the token prefix. const itemField = 'item'; const discriminator = 'collection'; +const fieldName = 'treatment'; describe('renderM2ATemplate', () => { it('resolves {{collection}} from the discriminator', () => { const row = { collection: 'service', item: { name: 'Installation' } }; - expect(renderM2ATemplate(row, '{{collection}}', itemField, discriminator)).toBe('service'); - }); - - it('resolves {{item:col.field}} only for the matching collection', () => { - const row = { collection: 'service', item: { name: 'Installation' } }; - const tpl = '{{item:partners_catalog.name}} {{item:service.name}}'; - // partners_catalog branch is skipped (row is a service), service branch wins - expect(renderM2ATemplate(row, tpl, itemField, discriminator).trim()).toBe('Installation'); + expect(renderM2ATemplate(row, '{{collection}}', itemField, discriminator, fieldName)).toBe( + 'service' + ); }); it('walks a nested path (M2A -> M2O -> scalar)', () => { const row = { collection: 'partners_catalog', item: { catalog_id: { title: 'Premium' } } }; expect( - renderM2ATemplate(row, '{{item:partners_catalog.catalog_id.title}}', itemField, discriminator) + renderM2ATemplate( + row, + '{{item:partners_catalog.catalog_id.title}}', + itemField, + discriminator, + fieldName + ) ).toBe('Premium'); }); - it('combines discriminator and item tokens', () => { - const row = { collection: 'service', item: { name: 'Repair' } }; - expect(renderM2ATemplate(row, '{{collection}}: {{item:service.name}}', itemField, discriminator)).toBe( - 'service: Repair' - ); - }); - it('returns "—" for a null/non-object row', () => { - expect(renderM2ATemplate(null, '{{collection}}', itemField, discriminator)).toBe('—'); + expect(renderM2ATemplate(null, '{{collection}}', itemField, discriminator, fieldName)).toBe('—'); }); it('renders empty for an item token whose collection does not match the row', () => { const row = { collection: 'service', item: { name: 'X' } }; - expect(renderM2ATemplate(row, '{{item:partners_catalog.name}}', itemField, discriminator).trim()).toBe( - '' - ); + expect( + renderM2ATemplate(row, '{{item:partners_catalog.name}}', itemField, discriminator, fieldName).trim() + ).toBe(''); }); it('collapses object/array leaves to empty (no "[object Object]")', () => { const row = { collection: 'service', item: { nested: { a: 1 }, list: [1, 2] } }; - expect(renderM2ATemplate(row, '{{item:service.nested}}', itemField, discriminator).trim()).toBe(''); - expect(renderM2ATemplate(row, '{{item:service.list}}', itemField, discriminator).trim()).toBe(''); + expect( + renderM2ATemplate(row, '{{item:service.nested}}', itemField, discriminator, fieldName).trim() + ).toBe(''); + expect( + renderM2ATemplate(row, '{{item:service.list}}', itemField, discriminator, fieldName).trim() + ).toBe(''); }); it('renders empty when the item payload is null (blocked / dangling)', () => { const row = { collection: 'service', item: null }; - expect(renderM2ATemplate(row, '{{item:service.name}}', itemField, discriminator).trim()).toBe(''); + expect( + renderM2ATemplate(row, '{{item:service.name}}', itemField, discriminator, fieldName).trim() + ).toBe(''); }); - it('honors a custom discriminator name via {{collection}} alias', () => { - const row = { kind: 'service', item: { name: 'Installation' } }; - // discriminator is 'kind', but the literal {{collection}} alias still resolves it - expect(renderM2ATemplate(row, '{{collection}}', itemField, 'kind')).toBe('service'); + // Both token formats must work: `item:` (hand-written) and `:` + // (emitted by the native display-template picker, issue #60 follow-up). + describe('token prefix formats', () => { + const row = { collection: 'service', item: { name: 'Installation' } }; + + it('resolves the hand-written {{item:col.field}} form', () => { + expect( + renderM2ATemplate(row, '{{item:service.name}}', itemField, discriminator, fieldName).trim() + ).toBe('Installation'); + }); + + it('resolves the picker {{:col.field}} form', () => { + expect( + renderM2ATemplate(row, '{{treatment:service.name}}', itemField, discriminator, fieldName).trim() + ).toBe('Installation'); + }); + + it('both forms yield identical output', () => { + const manual = renderM2ATemplate( + row, + '{{collection}}: {{item:service.name}}', + itemField, + discriminator, + fieldName + ); + const picker = renderM2ATemplate( + row, + '{{collection}}: {{treatment:service.name}}', + itemField, + discriminator, + fieldName + ); + expect(picker).toBe(manual); + expect(picker).toBe('service: Installation'); + }); + + it('ignores an unrelated prefix that is neither the field name nor item', () => { + expect( + renderM2ATemplate(row, '{{other:service.name}}', itemField, discriminator, fieldName).trim() + ).toBe(''); + }); }); }); From 64b902c577d456ee252534a9116dd57ac4e5fd82 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Mon, 1 Jun 2026 22:30:37 +0200 Subject: [PATCH 2/5] chore: bump version to 0.5.1 --- CHANGELOG.md | 10 ++++++++++ package.json | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db30e1..561c734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.5.1 — M2A template syntax fix (Issue #60) + +### Fixed +- M2A column displays built with the native display-template picker showed an + empty cell. The picker emits tokens prefixed with the parent field name + (`{{treatment:collection.field}}`), while only the hand-written `{{item:...}}` + form was recognised. Both prefixes are now accepted on the query and render + sides (shared `isM2APrefix` helper), matching Directus core's + `render-template` behaviour. + ## v0.5.0 — Many-to-Any support (Issue #60) ### Added diff --git a/package.json b/package.json index ca63e5c..56c7af3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "directus-extension-super-table", - "version": "0.5.0", + "version": "0.5.1", "description": "A powerful and feature-rich table layout extension for Directus 11+ with inline editing, quick filters, and manual sorting", "icon": "table_rows", "keywords": [ From 51386c09c4ca27b47757505fc303215359fd6789 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Tue, 2 Jun 2026 00:52:48 +0200 Subject: [PATCH 3/5] fix: resolve M2A display templates from the native picker (#60) The native display-template picker is rooted at the parent collection, so it emits field-key-prefixed tokens (treatment.collection, treatment.item:service.name) that the renderer did not recognise, leaving M2A cells blank. A shared stripM2AFieldPrefix helper normalises the picker and hand-written forms on both the query and render sides. Also fixes templates without an item token (e.g. {{collection}}), which blanked the whole cell: the segment guard now distinguishes a not-fetched item (undefined) from a permission-denied/dangling one (null). Parent-row ({{code}}) and junction-level ({{treatment.sort}}) fields are now resolvable, the deepest scope wins on a name clash, and every token is validated before the request to avoid a 403. M2A segment building is extracted into a unit-tested buildM2ASegments helper. --- src/components/ColumnDisplayEditor.vue | 10 +- src/components/EditableCellRelational.vue | 47 ++----- src/utils/adjustFieldsForDisplays.ts | 62 ++++++--- src/utils/buildM2ASegments.ts | 60 +++++++++ src/utils/displayHeuristics.ts | 28 ++++- src/utils/renderM2ATemplate.ts | 36 ++++-- src/utils/resolveM2ARelation.ts | 5 + .../utils/adjustFieldsForDisplays.test.ts | 9 +- tests/unit/utils/buildM2ASegments.test.ts | 107 ++++++++++++++++ tests/unit/utils/displayHeuristics.test.ts | 32 ++++- .../utils/expandTokensThroughRelation.test.ts | 82 +++++++++++- tests/unit/utils/renderM2ATemplate.test.ts | 119 +++++++++++++++++- tests/unit/utils/resolveM2ARelation.test.ts | 19 ++- 13 files changed, 535 insertions(+), 81 deletions(-) create mode 100644 src/utils/buildM2ASegments.ts create mode 100644 tests/unit/utils/buildM2ASegments.test.ts diff --git a/src/components/ColumnDisplayEditor.vue b/src/components/ColumnDisplayEditor.vue index b39fa91..89f7b1f 100644 --- a/src/components/ColumnDisplayEditor.vue +++ b/src/components/ColumnDisplayEditor.vue @@ -24,8 +24,8 @@ @input="form.template = $event ?? ''" />
- Many-to-Any field — use {{ itemToken }} (no - {{ m2aHelp.fieldKey }}. prefix). + Many-to-Any field — pick fields from the tree, or write + {{ itemToken }} by hand.
@@ -103,10 +103,10 @@ const m2aHelp = computed(() => { : '{{collection}}: {{item:.name}}'; const allowed = collections.length ? `Allowed collections: ${collections.join(', ')}. ` : ''; const tooltip = - `Many-to-Any field. Write tokens relative to the field (no "${rootField}." prefix). ` + - `Use ${collectionToken} for the target collection and ${itemToken} for its values. ` + + `Many-to-Any field. Pick fields from the template tree, or write tokens by hand: ` + + `${collectionToken} for the target collection and ${itemToken} for its values. ` + `${allowed}Example: ${example}`; - return { fieldKey: rootField, collections, example, tooltip }; + return { tooltip }; }); const canSave = computed(() => { diff --git a/src/components/EditableCellRelational.vue b/src/components/EditableCellRelational.vue index 38f1507..02437a6 100644 --- a/src/components/EditableCellRelational.vue +++ b/src/components/EditableCellRelational.vue @@ -192,7 +192,7 @@ import TagCell from './TagCell.vue'; import { isFieldEditable, getFieldEditWarning, getFieldSupportLevel } from '../utils/fieldSupport'; import { pickHeuristic, isM2A } from '../utils/displayHeuristics'; import { resolveM2ARelation } from '../utils/resolveM2ARelation'; -import { renderM2ATemplate } from '../utils/renderM2ATemplate'; +import { buildM2ASegments, isBlockedSegment, type M2ASegment } from '../utils/buildM2ASegments'; import { resolveTranslationValue } from '../utils/resolveTranslationValue'; import { usePermissions } from '../composables/usePermissions'; @@ -401,16 +401,11 @@ const displayValue = computed(() => { const isM2AField = computed(() => isM2A(props.field)); -// M2A cells render structurally so each junction row can show either its -// resolved template value or a `block` icon when its target collection is not -// readable by the current user. -type M2ASegment = { text: string } | { blocked: true; collection: string }; - +// M2A cells render structurally (see buildM2ASegments) so each junction row can +// show either its resolved template value or a `block` icon when its target +// collection is not readable by the current user. const m2aSegments = computed(() => { if (!isM2AField.value) return []; - const relationalValue = props.item[props.fieldKey]; - if (!Array.isArray(relationalValue) || relationalValue.length === 0) return []; - const collection = props.field?.collection; const fieldName = props.field?.field; const m2a = @@ -425,33 +420,17 @@ const m2aSegments = computed(() => { props.field?.meta?.display_options?.template || `{{${m2a.discriminator}}}`; - const segments: M2ASegment[] = []; - for (const row of relationalValue) { - if (!row || typeof row !== 'object') continue; - const rowCollection = row[m2a.discriminator]; - // Missing item: only a permission denial (not a dangling FK) earns the icon. - if (rowCollection && row[m2a.itemField] == null) { - if (!permissions.canRead(String(rowCollection))) { - segments.push({ blocked: true, collection: String(rowCollection) }); - } - continue; - } - const text = renderM2ATemplate( - row, - template, - m2a.itemField, - m2a.discriminator, - String(fieldName) - ).trim(); - if (text && text !== '—') segments.push({ text }); - } - return segments; + return buildM2ASegments( + props.item[props.fieldKey], + template, + m2a.itemField, + m2a.discriminator, + String(fieldName), + props.item, + (c) => permissions.canRead(c) + ); }); -function isBlockedSegment(seg: M2ASegment): seg is { blocked: true; collection: string } { - return 'blocked' in seg; -} - type ResolvedDisplay = { display: string | null; options: Record; diff --git a/src/utils/adjustFieldsForDisplays.ts b/src/utils/adjustFieldsForDisplays.ts index 86256fe..049d11c 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -9,6 +9,8 @@ import { parseM2AToken, buildM2AFieldPath, isM2APrefix, + stripM2AFieldPrefix, + M2A_COLLECTION_TOKEN, } from './displayHeuristics'; import { resolveM2ARelation } from './resolveM2ARelation'; @@ -161,28 +163,54 @@ export function expandTokensThroughRelation( if (isM2A) { const m2a = resolveM2ARelation(parentCollection, fieldKey, relationsStore, fieldsStore); if (!m2a) return []; - const { itemField, discriminator, allowedCollections } = m2a; + const { itemField, discriminator, allowedCollections, junctionCollection } = m2a; // The discriminator is always needed so the renderer knows which target // collection each row points at before resolving per-collection tokens. const expanded: string[] = [`${fieldKey}.${discriminator}`]; - for (const tok of tokens) { - if (tok === discriminator) continue; - // Per-collection M2A token: "item:collection.path". Bare tokens are dropped - // on purpose — they would resolve against the wrong collection and 403. + const add = (path: string) => { + if (!expanded.includes(path)) expanded.push(path); + }; + + for (const rawTok of tokens) { + // The native picker prefixes relation tokens with the field key. A prefix + // means a junction/item field; a bare token is a parent-level field. + const hadPrefix = rawTok.startsWith(`${fieldKey}.`); + const tok = stripM2AFieldPrefix(rawTok, fieldKey); + // The discriminator (and its conventional `collection` alias) is always + // emitted above; skip so it's never re-emitted as a junction/parent field. + if (tok === discriminator || tok === M2A_COLLECTION_TOKEN) continue; + + // Per-collection item token: "item:collection.path". const parsed = parseM2AToken(tok); - if (!parsed) continue; - const { prefix, collection: col, path } = parsed; - // The picker emits the field name as prefix; a hand-written template may - // use `item`. fieldKey is the parent field name here. - if (!isM2APrefix(prefix, fieldKey, itemField)) continue; - if (allowedCollections.length > 0 && !allowedCollections.includes(col)) continue; - // Only the first path segment is validated against the target — deep - // leaves are unvalidated, matching the M2M/M2O branches below. - const firstSegment = (path.split('.')[0] ?? '') as string; - if (!fieldsStore.getField(col, firstSegment)) continue; - const expandedPath = buildM2AFieldPath(fieldKey, itemField, col, path); - if (!expanded.includes(expandedPath)) expanded.push(expandedPath); + if (parsed && isM2APrefix(parsed.prefix, fieldKey, itemField)) { + const { collection: col, path } = parsed; + if (allowedCollections.length > 0 && !allowedCollections.includes(col)) continue; + // Only the first path segment is validated against the target — deep + // leaves are unvalidated, matching the M2M/M2O branches below. + const firstSegment = (path.split('.')[0] ?? '') as string; + if (!fieldsStore.getField(col, firstSegment)) continue; + add(buildM2AFieldPath(fieldKey, itemField, col, path)); + continue; + } + + const firstSegment = (tok.split('.')[0] ?? '') as string; + if (!firstSegment) continue; + + if (hadPrefix) { + // Junction-level field (e.g. `treatment.sort`); validate against the + // junction so an unknown token never reaches the API and 403s. + if (junctionCollection && fieldsStore.getField(junctionCollection, firstSegment)) { + add(`${fieldKey}.${tok}`); + } + continue; + } + + // Bare token → parent-level field (e.g. `code`), fetched at the top level. + // Validated against the parent so a stray token is dropped, not 403'd. + if (fieldsStore.getField(parentCollection, firstSegment)) { + add(tok); + } } return expanded; } diff --git a/src/utils/buildM2ASegments.ts b/src/utils/buildM2ASegments.ts new file mode 100644 index 0000000..ecbdd7f --- /dev/null +++ b/src/utils/buildM2ASegments.ts @@ -0,0 +1,60 @@ +import { renderM2ATemplate } from './renderM2ATemplate'; + +/** + * One rendered M2A junction row: either resolved template text, or a `block` + * marker when its target collection is not readable by the current user. + */ +export type M2ASegment = { text: string } | { blocked: true; collection: string }; + +/** Narrow a segment to the blocked variant (for template branching). */ +export function isBlockedSegment(seg: M2ASegment): seg is { blocked: true; collection: string } { + return 'blocked' in seg; +} + +/** + * Build the per-junction-row segments for an M2A cell. + * + * The `item` value distinguishes three cases: + * - `null` → the item is genuinely absent: a permission denial (emit a `block` + * marker when the target can't be read) or a dangling FK (skip the row). + * - `undefined` → the item simply wasn't fetched because the template references + * no item field; render anyway so a discriminator-only template still shows. + * - present (object or scalar FK) → render the template. + * + * Pure: `canRead` is injected so this is unit-testable without the stores. + */ +export function buildM2ASegments( + rows: unknown, + template: string, + itemField: string, + discriminator: string, + fieldName: string, + parentRow: Record | null | undefined, + canRead: (collection: string) => boolean +): M2ASegment[] { + if (!Array.isArray(rows) || rows.length === 0) return []; + + const segments: M2ASegment[] = []; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + const rowCollection = (row as Record)[discriminator]; + + if (rowCollection && (row as Record)[itemField] === null) { + if (!canRead(String(rowCollection))) { + segments.push({ blocked: true, collection: String(rowCollection) }); + } + continue; + } + + const text = renderM2ATemplate( + row as Record, + template, + itemField, + discriminator, + fieldName, + parentRow + ).trim(); + if (text && text !== '—') segments.push({ text }); + } + return segments; +} diff --git a/src/utils/displayHeuristics.ts b/src/utils/displayHeuristics.ts index 06a99d8..b09d03a 100644 --- a/src/utils/displayHeuristics.ts +++ b/src/utils/displayHeuristics.ts @@ -32,6 +32,13 @@ export function parseTemplateTokens(template: string): string[] { return [...new Set(fields)]; } +/** + * Conventional token for the M2A discriminator, accepted on both the query and + * render sides regardless of the relation's actual `one_collection_field` name + * (which it usually is anyway). Kept here so both sides resolve it identically. + */ +export const M2A_COLLECTION_TOKEN = 'collection'; + /** * Grammar for a per-collection M2A template token: `item:collection.path` * (e.g. `item:articles.title`). Captures [, prefix, collection, path]. @@ -66,14 +73,29 @@ export function buildM2AFieldPath( } /** - * Whether a token prefix addresses this M2A relation. The native display-template - * picker emits the parent field name (`treatment:col.field`); a hand-written - * template may use `item:col.field`. Both are accepted. + * Whether a token prefix addresses this M2A relation, once any parent field-key + * prefix has been stripped (see `stripM2AFieldPrefix`). The polymorphic item + * field (`item:col.field`) is the picker/conventional form; the parent field + * name is also accepted for hand-written `field:col.field` shorthand. */ export function isM2APrefix(prefix: string, fieldName: string, itemField: string): boolean { return prefix === fieldName || prefix === itemField || prefix === 'item'; } +/** + * Strip the parent field-key prefix the native display-template picker prepends + * to M2A tokens. Rooted at the parent collection (M2A has no single related + * collection to root at), the picker emits `treatment.collection` and + * `treatment.item:service.name`; stripping `.` makes them field- + * relative (`collection`, `item:service.name`), matching a hand-written + * template. Returns the token unchanged when it carries no such prefix. + */ +export function stripM2AFieldPrefix(token: string, fieldName: string): string { + if (!fieldName) return token; + const prefix = `${fieldName}.`; + return token.startsWith(prefix) ? token.slice(prefix.length) : token; +} + interface RelationsStoreLike { getRelationsForField: ( collection: string, diff --git a/src/utils/renderM2ATemplate.ts b/src/utils/renderM2ATemplate.ts index 5c8790a..4937931 100644 --- a/src/utils/renderM2ATemplate.ts +++ b/src/utils/renderM2ATemplate.ts @@ -1,8 +1,10 @@ import { get } from '@directus/utils'; -import { parseM2AToken, isM2APrefix } from './displayHeuristics'; - -/** Conventional M2A token aliases accepted alongside the resolved field names. */ -export const M2A_COLLECTION_TOKEN = 'collection'; +import { + parseM2AToken, + isM2APrefix, + stripM2AFieldPrefix, + M2A_COLLECTION_TOKEN, +} from './displayHeuristics'; /** * Render one M2A junction row against a related-values template. @@ -16,27 +18,41 @@ export function renderM2ATemplate( template: string, itemField: string, discriminator: string, - fieldName: string + fieldName: string, + parentRow?: Record | null ): string { if (!row || typeof row !== 'object') return '—'; const rowCollection = row[discriminator]; const item = row[itemField]; return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, rawToken: string) => { - const token = String(rawToken).trim(); + const raw = String(rawToken).trim(); + // The native picker prefixes relation tokens with the field key. A prefix + // selects the relation scope (junction/item); a bare token reads the parent + // row. On a name clash the more specific (deeper) token wins by shape. + const hadPrefix = raw.startsWith(`${fieldName}.`); + const token = stripM2AFieldPrefix(raw, fieldName); + + // 1. Discriminator (junction level). if (token === discriminator || token === M2A_COLLECTION_TOKEN) { return rowCollection != null ? String(rowCollection) : ''; } + // 2. Per-collection item value (deepest); only the branch matching this + // row's collection contributes a value. const parsed = parseM2AToken(token); - if (parsed) { - if (!isM2APrefix(parsed.prefix, fieldName, itemField)) return ''; - // Only the branch matching this row's collection contributes a value. + if (parsed && isM2APrefix(parsed.prefix, fieldName, itemField)) { if (parsed.collection !== rowCollection) return ''; return scalarOrEmpty(getNestedValue(item, parsed.path)); } - return scalarOrEmpty(getNestedValue(item, token)); + // 3. Other junction-level field (prefixed, e.g. `treatment.sort`). + if (hadPrefix) { + return scalarOrEmpty(getNestedValue(row, token)); + } + + // 4. Bare token → parent row field (shallowest). + return scalarOrEmpty(getNestedValue(parentRow, token)); }); } diff --git a/src/utils/resolveM2ARelation.ts b/src/utils/resolveM2ARelation.ts index 0c0a000..7dd2ea5 100644 --- a/src/utils/resolveM2ARelation.ts +++ b/src/utils/resolveM2ARelation.ts @@ -9,6 +9,7 @@ interface RelationsStoreLike { field: string ) => | Array<{ + collection?: string; field?: string; meta?: { one_field?: string | null; @@ -38,6 +39,8 @@ export interface M2ARelation { discriminator: string; /** Collections the relation may point at; empty when unconstrained. */ allowedCollections: string[]; + /** The junction collection itself (e.g. `orders_treatment`), or null if unknown. */ + junctionCollection: string | null; } /** Default discriminator column name used by Directus M2A junctions. */ @@ -84,6 +87,7 @@ export function resolveM2ARelation( itemField: itemRel.field, discriminator: itemRel.meta.one_collection_field, allowedCollections: itemRel.meta.one_allowed_collections ?? [], + junctionCollection: itemRel.collection ?? null, }; } @@ -99,6 +103,7 @@ export function resolveM2ARelation( itemField, discriminator: DEFAULT_DISCRIMINATOR, allowedCollections, + junctionCollection: parentRel?.collection ?? null, }; } diff --git a/tests/unit/utils/adjustFieldsForDisplays.test.ts b/tests/unit/utils/adjustFieldsForDisplays.test.ts index 556f869..627bbc2 100644 --- a/tests/unit/utils/adjustFieldsForDisplays.test.ts +++ b/tests/unit/utils/adjustFieldsForDisplays.test.ts @@ -262,6 +262,7 @@ describe('adjustFieldsForDisplays — M2A (issue #60)', () => { if (col === 'partners_catalog' && (f === 'name' || f === 'catalog_id')) return { field: f }; if (col === 'service' && f === 'name') return { field: 'name' }; + if (col === 'orders' && f === 'code') return { field: 'code' }; return null; }, getFieldsForCollection: () => [], @@ -307,13 +308,15 @@ describe('adjustFieldsForDisplays — M2A (issue #60)', () => { ); }); - it('never emits an invalid bare M2A path from a column-display override', async () => { + it('emits a bare parent token at the top level, never prefixed under the junction', async () => { mockM2A(); const { adjustFieldsForDisplays } = await import('@/utils/adjustFieldsForDisplays'); - const result = adjustFieldsForDisplays(['code', 'treatment'], 'orders', { + const result = adjustFieldsForDisplays(['treatment'], 'orders', { treatment: { template: '{{code}}' }, }); - // The bare token must be dropped — these paths would 403 against the junction. + // `code` is a parent field: fetched at the top level, never prefixed under + // the junction (which would 403). The discriminator is always emitted. + expect(result).toContain('code'); expect(result).not.toContain('treatment.code'); expect(result).not.toContain('treatment.item'); expect(result).not.toContain('treatment.id'); diff --git a/tests/unit/utils/buildM2ASegments.test.ts b/tests/unit/utils/buildM2ASegments.test.ts new file mode 100644 index 0000000..43e95fe --- /dev/null +++ b/tests/unit/utils/buildM2ASegments.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect } from 'vitest'; +import { buildM2ASegments, isBlockedSegment } from '@/utils/buildM2ASegments'; + +const itemField = 'item'; +const discriminator = 'collection'; +const fieldName = 'treatment'; +const allow = () => true; +const deny = () => false; + +describe('buildM2ASegments', () => { + it('returns [] for non-array / empty input', () => { + expect(buildM2ASegments(null, '{{collection}}', itemField, discriminator, fieldName, null, allow)).toEqual([]); + expect(buildM2ASegments(undefined, '{{collection}}', itemField, discriminator, fieldName, null, allow)).toEqual([]); + expect(buildM2ASegments('x', '{{collection}}', itemField, discriminator, fieldName, null, allow)).toEqual([]); + expect(buildM2ASegments([], '{{collection}}', itemField, discriminator, fieldName, null, allow)).toEqual([]); + }); + + it('skips non-object rows', () => { + const rows = [null, 'x', 5, { collection: 'service', item: { name: 'A' } }]; + expect( + buildM2ASegments(rows, '{{item:service.name}}', itemField, discriminator, fieldName, null, allow) + ).toEqual([{ text: 'A' }]); + }); + + // The guard's reason for being: distinguish "not fetched" from "absent". + it('renders a discriminator-only template when item was not fetched (undefined)', () => { + const rows = [{ collection: 'service' }, { collection: 'partners_catalog' }]; + expect( + buildM2ASegments(rows, '{{collection}}', itemField, discriminator, fieldName, null, allow) + ).toEqual([{ text: 'service' }, { text: 'partners_catalog' }]); + }); + + it('shows a block segment when item is null AND the collection is unreadable', () => { + const rows = [{ collection: 'service', item: null }]; + const segs = buildM2ASegments( + rows, + '{{item:service.name}}', + itemField, + discriminator, + fieldName, + null, + (c) => c !== 'service' + ); + expect(segs).toEqual([{ blocked: true, collection: 'service' }]); + expect(isBlockedSegment(segs[0]!)).toBe(true); + }); + + it('skips a row when item is null but the collection IS readable (dangling FK)', () => { + const rows = [{ collection: 'service', item: null }]; + expect( + buildM2ASegments(rows, '{{item:service.name}}', itemField, discriminator, fieldName, null, allow) + ).toEqual([]); + }); + + it('renders item values when the item is present', () => { + const rows = [{ collection: 'service', item: { name: 'Installation' } }]; + expect( + buildM2ASegments(rows, '{{item:service.name}}', itemField, discriminator, fieldName, null, allow) + ).toEqual([{ text: 'Installation' }]); + }); + + it('threads the parent row into bare tokens', () => { + const rows = [{ collection: 'service', item: { name: 'Installation' } }]; + expect( + buildM2ASegments( + rows, + '{{collection}}: {{code}}', + itemField, + discriminator, + fieldName, + { code: 'V-2600' }, + allow + ) + ).toEqual([{ text: 'service: V-2600' }]); + }); + + it('drops rows whose template renders empty', () => { + const rows = [{ collection: 'service', item: {} }]; + expect( + buildM2ASegments(rows, '{{item:service.name}}', itemField, discriminator, fieldName, null, allow) + ).toEqual([]); + }); + + it('mixes readable text and blocked segments across rows', () => { + const rows = [ + { collection: 'partners_catalog', item: { name: 'Partner Alpha' } }, + { collection: 'service', item: null }, + ]; + const segs = buildM2ASegments( + rows, + '{{item:partners_catalog.name}}{{item:service.name}}', + itemField, + discriminator, + fieldName, + null, + (c) => c !== 'service' + ); + expect(segs).toEqual([{ text: 'Partner Alpha' }, { blocked: true, collection: 'service' }]); + }); + + it('never blocks on a readable target even with no permission helper hits', () => { + const rows = [{ collection: 'service', item: { name: 'X' } }]; + expect( + buildM2ASegments(rows, '{{collection}}', itemField, discriminator, fieldName, null, deny) + ).toEqual([{ text: 'service' }]); + }); +}); diff --git a/tests/unit/utils/displayHeuristics.test.ts b/tests/unit/utils/displayHeuristics.test.ts index 58810d5..47417cc 100644 --- a/tests/unit/utils/displayHeuristics.test.ts +++ b/tests/unit/utils/displayHeuristics.test.ts @@ -8,6 +8,7 @@ import { parseM2AToken, buildM2AFieldPath, isM2APrefix, + stripM2AFieldPrefix, } from '@/utils/displayHeuristics'; describe('isRelational', () => { @@ -365,7 +366,7 @@ describe('buildM2AFieldPath', () => { }); describe('isM2APrefix', () => { - it('accepts the parent field name (picker output)', () => { + it('accepts the parent field name (hand-written shorthand)', () => { expect(isM2APrefix('treatment', 'treatment', 'item')).toBe(true); }); @@ -378,3 +379,32 @@ describe('isM2APrefix', () => { expect(isM2APrefix('something', 'treatment', 'item')).toBe(false); }); }); + +describe('stripM2AFieldPrefix', () => { + it('strips the field-key prefix the native picker prepends', () => { + expect(stripM2AFieldPrefix('treatment.collection', 'treatment')).toBe('collection'); + expect(stripM2AFieldPrefix('treatment.item:service.name', 'treatment')).toBe( + 'item:service.name' + ); + }); + + it('leaves an already field-relative token unchanged', () => { + expect(stripM2AFieldPrefix('collection', 'treatment')).toBe('collection'); + expect(stripM2AFieldPrefix('item:service.name', 'treatment')).toBe('item:service.name'); + }); + + it('does not strip the `:` colon shorthand (no dot prefix)', () => { + expect(stripM2AFieldPrefix('treatment:service.name', 'treatment')).toBe( + 'treatment:service.name' + ); + }); + + it('only strips its own field key, not a same-named target collection', () => { + // A token addressing a collection literally named `treatment` must survive. + expect(stripM2AFieldPrefix('item:treatment.name', 'treatment')).toBe('item:treatment.name'); + }); + + it('returns the token unchanged when no field name is given', () => { + expect(stripM2AFieldPrefix('treatment.collection', '')).toBe('treatment.collection'); + }); +}); diff --git a/tests/unit/utils/expandTokensThroughRelation.test.ts b/tests/unit/utils/expandTokensThroughRelation.test.ts index 0c2c187..d213b3f 100644 --- a/tests/unit/utils/expandTokensThroughRelation.test.ts +++ b/tests/unit/utils/expandTokensThroughRelation.test.ts @@ -271,6 +271,10 @@ describe('expandTokensThroughRelation', () => { 'partners_catalog.name': { field: 'name' }, 'partners_catalog.catalog_id': { field: 'catalog_id' }, 'service.name': { field: 'name' }, + // Parent (orders) and junction (orders_treatment) own fields, used to + // validate bare parent tokens and field-prefixed junction tokens. + 'orders.code': { field: 'code' }, + 'orders_treatment.sort': { field: 'sort' }, }, { 'orders.treatment': [ @@ -316,9 +320,8 @@ describe('expandTokensThroughRelation', () => { ]); }); - it('accepts the picker prefix (field name) as well as item', () => { + it('accepts the hand-written `:collection.field` shorthand', () => { const { fieldsStore, relationsStore } = m2aStores(); - // The native display-template picker emits `:collection.field`. const result = expandTokensThroughRelation( m2aField, 'treatment', @@ -334,6 +337,25 @@ describe('expandTokensThroughRelation', () => { ]); }); + it('expands the native picker tokens `.collection` / `.item:col.field`', () => { + // What the field-key-prefixed picker actually emits (verified live against + // Directus 11.11.0): the parent field key is prepended to every token. + const { fieldsStore, relationsStore } = m2aStores(); + const result = expandTokensThroughRelation( + m2aField, + 'treatment', + 'orders', + ['treatment.collection', 'treatment.item:partners_catalog.name', 'treatment.item:service.name'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual([ + 'treatment.collection', + 'treatment.item:partners_catalog.name', + 'treatment.item:service.name', + ]); + }); + it('keeps nested item paths intact (M2A -> M2O -> scalar)', () => { const { fieldsStore, relationsStore } = m2aStores(); const result = expandTokensThroughRelation( @@ -350,13 +372,13 @@ describe('expandTokensThroughRelation', () => { ]); }); - it('drops bare tokens so they never 403 against the junction', () => { + it('drops bare tokens that match no parent or junction field (no 403)', () => { const { fieldsStore, relationsStore } = m2aStores(); const result = expandTokensThroughRelation( m2aField, 'treatment', 'orders', - ['code', 'time'], + ['time', 'unknown_field'], fieldsStore as any, relationsStore as any ); @@ -389,6 +411,58 @@ describe('expandTokensThroughRelation', () => { expect(result).toEqual(['treatment.collection']); }); + it('emits a bare parent-level token at the top level (validated against parent)', () => { + const { fieldsStore, relationsStore } = m2aStores(); + const result = expandTokensThroughRelation( + m2aField, + 'treatment', + 'orders', + ['treatment.collection', 'code'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['treatment.collection', 'code']); + }); + + it('drops a bare token that is not a field on the parent collection', () => { + const { fieldsStore, relationsStore } = m2aStores(); + const result = expandTokensThroughRelation( + m2aField, + 'treatment', + 'orders', + ['ghost_field'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['treatment.collection']); + }); + + it('emits a field-prefixed junction-level token validated against the junction', () => { + const { fieldsStore, relationsStore } = m2aStores(); + const result = expandTokensThroughRelation( + m2aField, + 'treatment', + 'orders', + ['treatment.sort'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['treatment.collection', 'treatment.sort']); + }); + + it('drops a field-prefixed token that is not a junction field', () => { + const { fieldsStore, relationsStore } = m2aStores(); + const result = expandTokensThroughRelation( + m2aField, + 'treatment', + 'orders', + ['treatment.bogus'], + fieldsStore as any, + relationsStore as any + ); + expect(result).toEqual(['treatment.collection']); + }); + it('returns [] when the M2A item relation is missing (defensive)', () => { const { fieldsStore, relationsStore } = makeStores( {}, diff --git a/tests/unit/utils/renderM2ATemplate.test.ts b/tests/unit/utils/renderM2ATemplate.test.ts index 9db74e4..0dbdd24 100644 --- a/tests/unit/utils/renderM2ATemplate.test.ts +++ b/tests/unit/utils/renderM2ATemplate.test.ts @@ -57,8 +57,9 @@ describe('renderM2ATemplate', () => { ).toBe(''); }); - // Both token formats must work: `item:` (hand-written) and `:` - // (emitted by the native display-template picker, issue #60 follow-up). + // Hand-written shorthands both resolve: the conventional `item:col.field` and + // the parent field name `:col.field`. (The native picker emits a + // field-key-prefixed form instead — covered separately below.) describe('token prefix formats', () => { const row = { collection: 'service', item: { name: 'Installation' } }; @@ -68,7 +69,7 @@ describe('renderM2ATemplate', () => { ).toBe('Installation'); }); - it('resolves the picker {{:col.field}} form', () => { + it('resolves the {{:col.field}} shorthand', () => { expect( renderM2ATemplate(row, '{{treatment:service.name}}', itemField, discriminator, fieldName).trim() ).toBe('Installation'); @@ -99,4 +100,116 @@ describe('renderM2ATemplate', () => { ).toBe(''); }); }); + + // The native display-template picker is rooted at the PARENT collection, so it + // prefixes every M2A token with the field key: `treatment.collection` and + // `treatment.item:service.name` (verified live against Directus 11.11.0). + describe('native picker field-key-prefixed tokens (issue #60)', () => { + const row = { collection: 'service', item: { name: 'Installation' } }; + + it('resolves the prefixed discriminator {{.collection}}', () => { + expect( + renderM2ATemplate(row, '{{treatment.collection}}', itemField, discriminator, fieldName).trim() + ).toBe('service'); + }); + + it('resolves the prefixed item token {{.item:col.field}}', () => { + expect( + renderM2ATemplate( + row, + '{{treatment.item:service.name}}', + itemField, + discriminator, + fieldName + ).trim() + ).toBe('Installation'); + }); + + it('renders a full picker template identically to the hand-written form', () => { + const picker = renderM2ATemplate( + row, + '{{treatment.collection}}: {{treatment.item:service.name}}', + itemField, + discriminator, + fieldName + ); + const manual = renderM2ATemplate( + row, + '{{collection}}: {{item:service.name}}', + itemField, + discriminator, + fieldName + ); + expect(picker).toBe('service: Installation'); + expect(picker).toBe(manual); + }); + + it('renders empty when the prefixed item collection does not match the row', () => { + expect( + renderM2ATemplate( + row, + '{{treatment.item:partners_catalog.name}}', + itemField, + discriminator, + fieldName + ).trim() + ).toBe(''); + }); + }); + + // Parent-row fields (bare tokens) and junction-level fields (field-prefixed) + // are also resolvable. On a name clash the deeper token wins by shape: + // `{{item:col.f}}` (item) > `{{.f}}` (junction) > `{{f}}` (parent). + describe('parent and junction field scopes (issue #60)', () => { + const parentRow = { code: 'V-2600', name: 'PARENT' }; + + it('resolves a bare token against the parent row', () => { + const row = { collection: 'service', item: { name: 'Installation' } }; + expect( + renderM2ATemplate( + row, + '{{treatment.collection}}: {{code}}', + itemField, + discriminator, + fieldName, + parentRow + ).trim() + ).toBe('service: V-2600'); + }); + + it('resolves a field-prefixed non-item token against the junction row', () => { + const row = { collection: 'service', item: { name: 'X' }, sort: 5 }; + expect( + renderM2ATemplate( + row, + '{{treatment.sort}}', + itemField, + discriminator, + fieldName, + parentRow + ).trim() + ).toBe('5'); + }); + + it('lets the deeper item token win over a same-named parent field', () => { + const row = { collection: 'service', item: { name: 'ITEM' } }; + expect( + renderM2ATemplate( + row, + '{{treatment.item:service.name}} / {{name}}', + itemField, + discriminator, + fieldName, + parentRow + ).trim() + ).toBe('ITEM / PARENT'); + }); + + it('renders a bare token empty when no parent row is supplied (back-compat)', () => { + const row = { collection: 'service', item: { name: 'Installation' } }; + expect(renderM2ATemplate(row, '{{code}}', itemField, discriminator, fieldName).trim()).toBe( + '' + ); + }); + }); }); diff --git a/tests/unit/utils/resolveM2ARelation.test.ts b/tests/unit/utils/resolveM2ARelation.test.ts index b0215ea..575337a 100644 --- a/tests/unit/utils/resolveM2ARelation.test.ts +++ b/tests/unit/utils/resolveM2ARelation.test.ts @@ -4,6 +4,7 @@ import { resolveM2ARelation } from '@/utils/resolveM2ARelation'; // Mirrors the live orders.treatment shape: the junction's polymorphic `item` // relation carries the collection discriminator and the allowed collections. const itemRelation = { + collection: 'orders_treatment', field: 'item', meta: { one_collection_field: 'collection', @@ -11,12 +12,13 @@ const itemRelation = { }, }; const parentRelation = { + collection: 'orders_treatment', field: 'orders_id', meta: { one_collection_field: null }, }; describe('resolveM2ARelation', () => { - it('resolves itemField, discriminator and allowedCollections from the item relation', () => { + it('resolves itemField, discriminator, allowedCollections and junctionCollection', () => { const relationsStore = { getRelationsForField: vi.fn().mockReturnValue([parentRelation, itemRelation]), }; @@ -24,6 +26,7 @@ describe('resolveM2ARelation', () => { itemField: 'item', discriminator: 'collection', allowedCollections: ['partners_catalog', 'service'], + junctionCollection: 'orders_treatment', }); }); @@ -45,6 +48,17 @@ describe('resolveM2ARelation', () => { ); }); + it('returns junctionCollection null when the relation carries no collection', () => { + const relationsStore = { + getRelationsForField: vi.fn().mockReturnValue([ + { field: 'item', meta: { one_collection_field: 'collection' } }, + ]), + }; + expect( + resolveM2ARelation('orders', 'treatment', relationsStore)?.junctionCollection + ).toBeNull(); + }); + it('returns null when no relation carries one_collection_field', () => { const relationsStore = { getRelationsForField: vi.fn().mockReturnValue([parentRelation]), @@ -83,6 +97,7 @@ describe('resolveM2ARelation — permission fallback', () => { const parentOnly = { getRelationsForField: vi.fn().mockReturnValue([ { + collection: 'orders_treatment', field: 'orders_id', meta: { one_field: 'treatment', junction_field: 'item', one_collection_field: null }, }, @@ -99,6 +114,7 @@ describe('resolveM2ARelation — permission fallback', () => { itemField: 'item', discriminator: 'collection', allowedCollections: ['partners_catalog', 'service'], + junctionCollection: 'orders_treatment', }); }); @@ -107,6 +123,7 @@ describe('resolveM2ARelation — permission fallback', () => { itemField: 'item', discriminator: 'collection', allowedCollections: [], + junctionCollection: 'orders_treatment', }); }); From 73293dbe708c5309c0f1131abd610d3eea9ed9fd Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Tue, 2 Jun 2026 00:52:59 +0200 Subject: [PATCH 4/5] docs: correct M2A template guidance for picker and field scopes Document that the native field picker now works directly, the resolvable token scopes (item, discriminator, junction-prefixed, bare parent field) and that the deepest match wins. Removes the now-false "bare token is dropped" and "do not prefix the field name" notes. --- README.md | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f4198b0..24cf71d 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Smart image handling with an enlarged hover preview, proper aspect ratios, and a Duplicate items with all their relationships and translations. Perfect for creating variations of complex data structures. ### 🧩 Many-to-Any (M2A) Display -Render polymorphic Many-to-Any relationships directly in the table. Use the `related-values` display or a per-column template with the `{{item:collection.field}}` syntax — including nested chains like `{{item:partners_catalog.catalog_id.title}}`. Each junction row resolves against its own target collection. +Render polymorphic Many-to-Any relationships directly in the table — built straight from the native field picker or by hand. Use `{{item:.}}` for a target's fields (nested chains like `{{item:partners_catalog.catalog_id.title}}` work), `{{collection}}` for the discriminator, and bare tokens like `{{code}}` for parent-row fields. Each junction row resolves against its own target collection. @@ -164,24 +164,28 @@ sidebar under **Layout Options → Column Displays**: 3. **Templates** use the `{{ field }}` mustache syntax and may reference related fields #### Many-to-Any (M2A) templates -M2A fields are polymorphic — each row points at one of several target collections — -so their templates use a dedicated `item:` syntax. Tokens are written **relative to -the field** (do not prefix the field name): - -- `{{collection}}` — the name of the row's target collection -- `{{item:.}}` — a field on a specific target collection -- Nested paths are supported, e.g. `{{item:partners_catalog.catalog_id.title}}` - (M2A → M2O → value) - -Each junction row only resolves the token whose `` matches its own +M2A fields are polymorphic — each junction row points at one of several target +collections. Build the template straight from the **native field picker** (it now +resolves correctly), or write tokens by hand. Tokens resolve per junction row; on a +name clash the most specific (deepest) match wins: + +- `{{item:.}}` — a field on a specific target collection; + nested paths work, e.g. `{{item:partners_catalog.catalog_id.title}}` (M2A → M2O → value) +- `{{collection}}` — the name of the row's target collection (the discriminator) +- The field-key-prefixed forms the picker emits also work, e.g. + `{{treatment.collection}}`, `{{treatment.item:service.name}}`, or a junction + column like `{{treatment.sort}}` +- `{{}}` — a bare token reads a field on the parent row, e.g. + `{{code}}` shows the order's own code next to each item + +Each junction row only resolves the item token whose `` matches its own target, so a template can cover every allowed collection at once: ``` {{collection}}: {{item:partners_catalog.name}} {{item:service.name}} ``` -The editor shows the allowed collections and an example for the selected field. A -bare token (e.g. `{{name}}`) is intentionally dropped for M2A — use the `item:` form. +The editor shows the allowed collections and an example for the selected field. ### Bookmarks Save table configurations for quick access: From 35db0293ed2c84c5f7e2999927e093dc86380ac6 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Tue, 2 Jun 2026 00:52:59 +0200 Subject: [PATCH 5/5] chore: update CHANGELOG for v0.5.1 --- CHANGELOG.md | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561c734..a06a8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,15 +5,34 @@ 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.5.1 — M2A template syntax fix (Issue #60) +## v0.5.1 — M2A picker templates, field scopes & guard fix (Issue #60) ### Fixed -- M2A column displays built with the native display-template picker showed an - empty cell. The picker emits tokens prefixed with the parent field name - (`{{treatment:collection.field}}`), while only the hand-written `{{item:...}}` - form was recognised. Both prefixes are now accepted on the query and render - sides (shared `isM2APrefix` helper), matching Directus core's - `render-template` behaviour. +- **M2A column displays built with the native picker showed an empty cell.** + Rooted at the parent collection, the picker emits field-key-prefixed tokens + (`{{treatment.collection}}`, `{{treatment.item:service.name}}`) — not the + hand-written `{{item:...}}` form the renderer expected. A shared + `stripM2AFieldPrefix` helper now normalises both forms on the query and render + sides, so picked templates resolve correctly. +- **Discriminator-only / non-item templates blanked the whole cell.** A template + with no `item:` token (e.g. `{{collection}}`) made the API omit the junction + `item`, and a guard then skipped every row as if the item were + permission-denied. The guard now distinguishes a not-fetched item + (`undefined`) from a genuinely absent one (`null`), so these templates render. + +### Added +- **Parent-row and junction-level fields in M2A templates.** Bare tokens + (`{{code}}`) resolve against the parent row and field-prefixed tokens + (`{{treatment.sort}}`) against the junction row, alongside the discriminator + and per-collection `item:` values. On a name clash the most specific (deepest) + token wins. Every token is validated against its target before the request, so + an unknown token is dropped instead of 403'ing. + +### Refactor +- M2A junction-row rendering extracted into a pure, unit-tested `buildM2ASegments` + helper; `resolveM2ARelation` now also reports the junction collection. The + conventional `collection` discriminator token lives in `displayHeuristics` and + is shared by the query and render sides so they cannot drift. ## v0.5.0 — Many-to-Any support (Issue #60)