Skip to content

Commit e24a6e9

Browse files
authored
Merge pull request #64 from smartlabsAT/fix/issue-60-m2a-template-syntax
Fix M2A display templates from the native picker (#60)
2 parents d6f73e7 + 35db029 commit e24a6e9

16 files changed

Lines changed: 685 additions & 107 deletions

CHANGELOG.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ All notable changes to the Super Layout Table Extension will be documented in th
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## v0.5.1 — M2A picker templates, field scopes & guard fix (Issue #60)
9+
10+
### Fixed
11+
- **M2A column displays built with the native picker showed an empty cell.**
12+
Rooted at the parent collection, the picker emits field-key-prefixed tokens
13+
(`{{treatment.collection}}`, `{{treatment.item:service.name}}`) — not the
14+
hand-written `{{item:...}}` form the renderer expected. A shared
15+
`stripM2AFieldPrefix` helper now normalises both forms on the query and render
16+
sides, so picked templates resolve correctly.
17+
- **Discriminator-only / non-item templates blanked the whole cell.** A template
18+
with no `item:` token (e.g. `{{collection}}`) made the API omit the junction
19+
`item`, and a guard then skipped every row as if the item were
20+
permission-denied. The guard now distinguishes a not-fetched item
21+
(`undefined`) from a genuinely absent one (`null`), so these templates render.
22+
23+
### Added
24+
- **Parent-row and junction-level fields in M2A templates.** Bare tokens
25+
(`{{code}}`) resolve against the parent row and field-prefixed tokens
26+
(`{{treatment.sort}}`) against the junction row, alongside the discriminator
27+
and per-collection `item:` values. On a name clash the most specific (deepest)
28+
token wins. Every token is validated against its target before the request, so
29+
an unknown token is dropped instead of 403'ing.
30+
31+
### Refactor
32+
- M2A junction-row rendering extracted into a pure, unit-tested `buildM2ASegments`
33+
helper; `resolveM2ARelation` now also reports the junction collection. The
34+
conventional `collection` discriminator token lives in `displayHeuristics` and
35+
is shared by the query and render sides so they cannot drift.
36+
837
## v0.5.0 — Many-to-Any support (Issue #60)
938

1039
### Added

README.md

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Smart image handling with an enlarged hover preview, proper aspect ratios, and a
3737
Duplicate items with all their relationships and translations. Perfect for creating variations of complex data structures.
3838

3939
### 🧩 Many-to-Any (M2A) Display
40-
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.
40+
Render polymorphic Many-to-Any relationships directly in the table — built straight from the native field picker or by hand. Use `{{item:<collection>.<field>}}` 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.
4141

4242

4343

@@ -164,24 +164,28 @@ sidebar under **Layout Options → Column Displays**:
164164
3. **Templates** use the `{{ field }}` mustache syntax and may reference related fields
165165

166166
#### Many-to-Any (M2A) templates
167-
M2A fields are polymorphic — each row points at one of several target collections —
168-
so their templates use a dedicated `item:` syntax. Tokens are written **relative to
169-
the field** (do not prefix the field name):
170-
171-
- `{{collection}}` — the name of the row's target collection
172-
- `{{item:<collection>.<field>}}` — a field on a specific target collection
173-
- Nested paths are supported, e.g. `{{item:partners_catalog.catalog_id.title}}`
174-
(M2A → M2O → value)
175-
176-
Each junction row only resolves the token whose `<collection>` matches its own
167+
M2A fields are polymorphic — each junction row points at one of several target
168+
collections. Build the template straight from the **native field picker** (it now
169+
resolves correctly), or write tokens by hand. Tokens resolve per junction row; on a
170+
name clash the most specific (deepest) match wins:
171+
172+
- `{{item:<collection>.<field>}}` — a field on a specific target collection;
173+
nested paths work, e.g. `{{item:partners_catalog.catalog_id.title}}` (M2A → M2O → value)
174+
- `{{collection}}` — the name of the row's target collection (the discriminator)
175+
- The field-key-prefixed forms the picker emits also work, e.g.
176+
`{{treatment.collection}}`, `{{treatment.item:service.name}}`, or a junction
177+
column like `{{treatment.sort}}`
178+
- `{{<parentField>}}` — a bare token reads a field on the parent row, e.g.
179+
`{{code}}` shows the order's own code next to each item
180+
181+
Each junction row only resolves the item token whose `<collection>` matches its own
177182
target, so a template can cover every allowed collection at once:
178183

179184
```
180185
{{collection}}: {{item:partners_catalog.name}} {{item:service.name}}
181186
```
182187

183-
The editor shows the allowed collections and an example for the selected field. A
184-
bare token (e.g. `{{name}}`) is intentionally dropped for M2A — use the `item:` form.
188+
The editor shows the allowed collections and an example for the selected field.
185189

186190
### Bookmarks
187191
Save table configurations for quick access:

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "directus-extension-super-table",
3-
"version": "0.5.0",
3+
"version": "0.5.1",
44
"description": "A powerful and feature-rich table layout extension for Directus 11+ with inline editing, quick filters, and manual sorting",
55
"icon": "table_rows",
66
"keywords": [

src/components/ColumnDisplayEditor.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
@input="form.template = $event ?? ''"
2525
/>
2626
<div v-if="m2aHelp" class="hint">
27-
Many-to-Any field — use <code>{{ itemToken }}</code> (no
28-
<code>{{ m2aHelp.fieldKey }}.</code> prefix).
27+
Many-to-Any field — pick fields from the tree, or write
28+
<code>{{ itemToken }}</code> by hand.
2929
</div>
3030
</div>
3131

@@ -103,10 +103,10 @@ const m2aHelp = computed(() => {
103103
: '{{collection}}: {{item:<collection>.name}}';
104104
const allowed = collections.length ? `Allowed collections: ${collections.join(', ')}. ` : '';
105105
const tooltip =
106-
`Many-to-Any field. Write tokens relative to the field (no "${rootField}." prefix). ` +
107-
`Use ${collectionToken} for the target collection and ${itemToken} for its values. ` +
106+
`Many-to-Any field. Pick fields from the template tree, or write tokens by hand: ` +
107+
`${collectionToken} for the target collection and ${itemToken} for its values. ` +
108108
`${allowed}Example: ${example}`;
109-
return { fieldKey: rootField, collections, example, tooltip };
109+
return { tooltip };
110110
});
111111
112112
const canSave = computed(() => {

src/components/EditableCellRelational.vue

Lines changed: 13 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ import TagCell from './TagCell.vue';
192192
import { isFieldEditable, getFieldEditWarning, getFieldSupportLevel } from '../utils/fieldSupport';
193193
import { pickHeuristic, isM2A } from '../utils/displayHeuristics';
194194
import { resolveM2ARelation } from '../utils/resolveM2ARelation';
195-
import { renderM2ATemplate } from '../utils/renderM2ATemplate';
195+
import { buildM2ASegments, isBlockedSegment, type M2ASegment } from '../utils/buildM2ASegments';
196196
import { resolveTranslationValue } from '../utils/resolveTranslationValue';
197197
import { usePermissions } from '../composables/usePermissions';
198198
@@ -401,16 +401,11 @@ const displayValue = computed(() => {
401401
402402
const isM2AField = computed(() => isM2A(props.field));
403403
404-
// M2A cells render structurally so each junction row can show either its
405-
// resolved template value or a `block` icon when its target collection is not
406-
// readable by the current user.
407-
type M2ASegment = { text: string } | { blocked: true; collection: string };
408-
404+
// M2A cells render structurally (see buildM2ASegments) so each junction row can
405+
// show either its resolved template value or a `block` icon when its target
406+
// collection is not readable by the current user.
409407
const m2aSegments = computed<M2ASegment[]>(() => {
410408
if (!isM2AField.value) return [];
411-
const relationalValue = props.item[props.fieldKey];
412-
if (!Array.isArray(relationalValue) || relationalValue.length === 0) return [];
413-
414409
const collection = props.field?.collection;
415410
const fieldName = props.field?.field;
416411
const m2a =
@@ -425,27 +420,17 @@ const m2aSegments = computed<M2ASegment[]>(() => {
425420
props.field?.meta?.display_options?.template ||
426421
`{{${m2a.discriminator}}}`;
427422
428-
const segments: M2ASegment[] = [];
429-
for (const row of relationalValue) {
430-
if (!row || typeof row !== 'object') continue;
431-
const rowCollection = row[m2a.discriminator];
432-
// Missing item: only a permission denial (not a dangling FK) earns the icon.
433-
if (rowCollection && row[m2a.itemField] == null) {
434-
if (!permissions.canRead(String(rowCollection))) {
435-
segments.push({ blocked: true, collection: String(rowCollection) });
436-
}
437-
continue;
438-
}
439-
const text = renderM2ATemplate(row, template, m2a.itemField, m2a.discriminator).trim();
440-
if (text && text !== '') segments.push({ text });
441-
}
442-
return segments;
423+
return buildM2ASegments(
424+
props.item[props.fieldKey],
425+
template,
426+
m2a.itemField,
427+
m2a.discriminator,
428+
String(fieldName),
429+
props.item,
430+
(c) => permissions.canRead(c)
431+
);
443432
});
444433
445-
function isBlockedSegment(seg: M2ASegment): seg is { blocked: true; collection: string } {
446-
return 'blocked' in seg;
447-
}
448-
449434
type ResolvedDisplay = {
450435
display: string | null;
451436
options: Record<string, unknown>;

src/utils/adjustFieldsForDisplays.ts

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import {
88
pickHeuristic,
99
parseM2AToken,
1010
buildM2AFieldPath,
11+
isM2APrefix,
12+
stripM2AFieldPrefix,
13+
M2A_COLLECTION_TOKEN,
1114
} from './displayHeuristics';
1215
import { resolveM2ARelation } from './resolveM2ARelation';
1316

@@ -160,26 +163,54 @@ export function expandTokensThroughRelation(
160163
if (isM2A) {
161164
const m2a = resolveM2ARelation(parentCollection, fieldKey, relationsStore, fieldsStore);
162165
if (!m2a) return [];
163-
const { itemField, discriminator, allowedCollections } = m2a;
166+
const { itemField, discriminator, allowedCollections, junctionCollection } = m2a;
164167

165168
// The discriminator is always needed so the renderer knows which target
166169
// collection each row points at before resolving per-collection tokens.
167170
const expanded: string[] = [`${fieldKey}.${discriminator}`];
168-
for (const tok of tokens) {
169-
if (tok === discriminator) continue;
170-
// Per-collection M2A token: "item:collection.path". Bare tokens are dropped
171-
// on purpose — they would resolve against the wrong collection and 403.
171+
const add = (path: string) => {
172+
if (!expanded.includes(path)) expanded.push(path);
173+
};
174+
175+
for (const rawTok of tokens) {
176+
// The native picker prefixes relation tokens with the field key. A prefix
177+
// means a junction/item field; a bare token is a parent-level field.
178+
const hadPrefix = rawTok.startsWith(`${fieldKey}.`);
179+
const tok = stripM2AFieldPrefix(rawTok, fieldKey);
180+
// The discriminator (and its conventional `collection` alias) is always
181+
// emitted above; skip so it's never re-emitted as a junction/parent field.
182+
if (tok === discriminator || tok === M2A_COLLECTION_TOKEN) continue;
183+
184+
// Per-collection item token: "item:collection.path".
172185
const parsed = parseM2AToken(tok);
173-
if (!parsed) continue;
174-
const { prefix, collection: col, path } = parsed;
175-
if (prefix !== itemField && prefix !== 'item') continue;
176-
if (allowedCollections.length > 0 && !allowedCollections.includes(col)) continue;
177-
// Only the first path segment is validated against the target — deep
178-
// leaves are unvalidated, matching the M2M/M2O branches below.
179-
const firstSegment = (path.split('.')[0] ?? '') as string;
180-
if (!fieldsStore.getField(col, firstSegment)) continue;
181-
const expandedPath = buildM2AFieldPath(fieldKey, itemField, col, path);
182-
if (!expanded.includes(expandedPath)) expanded.push(expandedPath);
186+
if (parsed && isM2APrefix(parsed.prefix, fieldKey, itemField)) {
187+
const { collection: col, path } = parsed;
188+
if (allowedCollections.length > 0 && !allowedCollections.includes(col)) continue;
189+
// Only the first path segment is validated against the target — deep
190+
// leaves are unvalidated, matching the M2M/M2O branches below.
191+
const firstSegment = (path.split('.')[0] ?? '') as string;
192+
if (!fieldsStore.getField(col, firstSegment)) continue;
193+
add(buildM2AFieldPath(fieldKey, itemField, col, path));
194+
continue;
195+
}
196+
197+
const firstSegment = (tok.split('.')[0] ?? '') as string;
198+
if (!firstSegment) continue;
199+
200+
if (hadPrefix) {
201+
// Junction-level field (e.g. `treatment.sort`); validate against the
202+
// junction so an unknown token never reaches the API and 403s.
203+
if (junctionCollection && fieldsStore.getField(junctionCollection, firstSegment)) {
204+
add(`${fieldKey}.${tok}`);
205+
}
206+
continue;
207+
}
208+
209+
// Bare token → parent-level field (e.g. `code`), fetched at the top level.
210+
// Validated against the parent so a stray token is dropped, not 403'd.
211+
if (fieldsStore.getField(parentCollection, firstSegment)) {
212+
add(tok);
213+
}
183214
}
184215
return expanded;
185216
}

src/utils/buildM2ASegments.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { renderM2ATemplate } from './renderM2ATemplate';
2+
3+
/**
4+
* One rendered M2A junction row: either resolved template text, or a `block`
5+
* marker when its target collection is not readable by the current user.
6+
*/
7+
export type M2ASegment = { text: string } | { blocked: true; collection: string };
8+
9+
/** Narrow a segment to the blocked variant (for template branching). */
10+
export function isBlockedSegment(seg: M2ASegment): seg is { blocked: true; collection: string } {
11+
return 'blocked' in seg;
12+
}
13+
14+
/**
15+
* Build the per-junction-row segments for an M2A cell.
16+
*
17+
* The `item` value distinguishes three cases:
18+
* - `null` → the item is genuinely absent: a permission denial (emit a `block`
19+
* marker when the target can't be read) or a dangling FK (skip the row).
20+
* - `undefined` → the item simply wasn't fetched because the template references
21+
* no item field; render anyway so a discriminator-only template still shows.
22+
* - present (object or scalar FK) → render the template.
23+
*
24+
* Pure: `canRead` is injected so this is unit-testable without the stores.
25+
*/
26+
export function buildM2ASegments(
27+
rows: unknown,
28+
template: string,
29+
itemField: string,
30+
discriminator: string,
31+
fieldName: string,
32+
parentRow: Record<string, any> | null | undefined,
33+
canRead: (collection: string) => boolean
34+
): M2ASegment[] {
35+
if (!Array.isArray(rows) || rows.length === 0) return [];
36+
37+
const segments: M2ASegment[] = [];
38+
for (const row of rows) {
39+
if (!row || typeof row !== 'object') continue;
40+
const rowCollection = (row as Record<string, any>)[discriminator];
41+
42+
if (rowCollection && (row as Record<string, any>)[itemField] === null) {
43+
if (!canRead(String(rowCollection))) {
44+
segments.push({ blocked: true, collection: String(rowCollection) });
45+
}
46+
continue;
47+
}
48+
49+
const text = renderM2ATemplate(
50+
row as Record<string, any>,
51+
template,
52+
itemField,
53+
discriminator,
54+
fieldName,
55+
parentRow
56+
).trim();
57+
if (text && text !== '—') segments.push({ text });
58+
}
59+
return segments;
60+
}

src/utils/displayHeuristics.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ export function parseTemplateTokens(template: string): string[] {
3232
return [...new Set(fields)];
3333
}
3434

35+
/**
36+
* Conventional token for the M2A discriminator, accepted on both the query and
37+
* render sides regardless of the relation's actual `one_collection_field` name
38+
* (which it usually is anyway). Kept here so both sides resolve it identically.
39+
*/
40+
export const M2A_COLLECTION_TOKEN = 'collection';
41+
3542
/**
3643
* Grammar for a per-collection M2A template token: `item:collection.path`
3744
* (e.g. `item:articles.title`). Captures [, prefix, collection, path].
@@ -65,6 +72,30 @@ export function buildM2AFieldPath(
6572
return `${fieldKey}.${itemField}:${collection}.${path}`;
6673
}
6774

75+
/**
76+
* Whether a token prefix addresses this M2A relation, once any parent field-key
77+
* prefix has been stripped (see `stripM2AFieldPrefix`). The polymorphic item
78+
* field (`item:col.field`) is the picker/conventional form; the parent field
79+
* name is also accepted for hand-written `field:col.field` shorthand.
80+
*/
81+
export function isM2APrefix(prefix: string, fieldName: string, itemField: string): boolean {
82+
return prefix === fieldName || prefix === itemField || prefix === 'item';
83+
}
84+
85+
/**
86+
* Strip the parent field-key prefix the native display-template picker prepends
87+
* to M2A tokens. Rooted at the parent collection (M2A has no single related
88+
* collection to root at), the picker emits `treatment.collection` and
89+
* `treatment.item:service.name`; stripping `<fieldName>.` makes them field-
90+
* relative (`collection`, `item:service.name`), matching a hand-written
91+
* template. Returns the token unchanged when it carries no such prefix.
92+
*/
93+
export function stripM2AFieldPrefix(token: string, fieldName: string): string {
94+
if (!fieldName) return token;
95+
const prefix = `${fieldName}.`;
96+
return token.startsWith(prefix) ? token.slice(prefix.length) : token;
97+
}
98+
6899
interface RelationsStoreLike {
69100
getRelationsForField: (
70101
collection: string,

0 commit comments

Comments
 (0)