diff --git a/CHANGELOG.md b/CHANGELOG.md index 4db30e1..a06a8a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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 picker templates, field scopes & guard fix (Issue #60) + +### Fixed +- **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) ### Added 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: 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": [ 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 eaf87c3..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,27 +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).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 54353fd..049d11c 100644 --- a/src/utils/adjustFieldsForDisplays.ts +++ b/src/utils/adjustFieldsForDisplays.ts @@ -8,6 +8,9 @@ import { pickHeuristic, parseM2AToken, buildM2AFieldPath, + isM2APrefix, + stripM2AFieldPrefix, + M2A_COLLECTION_TOKEN, } from './displayHeuristics'; import { resolveM2ARelation } from './resolveM2ARelation'; @@ -160,26 +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; - if (prefix !== itemField && prefix !== 'item') 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 53b90ba..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]. @@ -65,6 +72,30 @@ export function buildM2AFieldPath( return `${fieldKey}.${itemField}:${collection}.${path}`; } +/** + * 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 a828180..4937931 100644 --- a/src/utils/renderM2ATemplate.ts +++ b/src/utils/renderM2ATemplate.ts @@ -1,9 +1,10 @@ import { get } from '@directus/utils'; -import { parseM2AToken } from './displayHeuristics'; - -/** Conventional M2A token aliases accepted alongside the resolved field names. */ -export const M2A_COLLECTION_TOKEN = 'collection'; -export const M2A_ITEM_TOKEN = 'item'; +import { + parseM2AToken, + isM2APrefix, + stripM2AFieldPrefix, + M2A_COLLECTION_TOKEN, +} from './displayHeuristics'; /** * Render one M2A junction row against a related-values template. @@ -16,27 +17,42 @@ export function renderM2ATemplate( row: Record | null | undefined, template: string, itemField: string, - discriminator: string + discriminator: 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 (parsed.prefix !== itemField && parsed.prefix !== M2A_ITEM_TOKEN) 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 cffa01d..47417cc 100644 --- a/tests/unit/utils/displayHeuristics.test.ts +++ b/tests/unit/utils/displayHeuristics.test.ts @@ -7,6 +7,8 @@ import { pickHeuristic, parseM2AToken, buildM2AFieldPath, + isM2APrefix, + stripM2AFieldPrefix, } from '@/utils/displayHeuristics'; describe('isRelational', () => { @@ -362,3 +364,47 @@ describe('buildM2AFieldPath', () => { }); }); }); + +describe('isM2APrefix', () => { + it('accepts the parent field name (hand-written shorthand)', () => { + 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); + }); +}); + +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 5b693df..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,6 +320,42 @@ describe('expandTokensThroughRelation', () => { ]); }); + it('accepts the hand-written `:collection.field` shorthand', () => { + const { fieldsStore, relationsStore } = m2aStores(); + 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('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( @@ -332,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 ); @@ -371,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 e11936a..0dbdd24 100644 --- a/tests/unit/utils/renderM2ATemplate.test.ts +++ b/tests/unit/utils/renderM2ATemplate.test.ts @@ -2,62 +2,214 @@ 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(''); + }); + + // 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' } }; + + it('resolves the hand-written {{item:col.field}} form', () => { + expect( + renderM2ATemplate(row, '{{item:service.name}}', itemField, discriminator, fieldName).trim() + ).toBe('Installation'); + }); + + it('resolves the {{:col.field}} shorthand', () => { + 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(''); + }); }); - 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'); + // 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', }); });