Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<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.



Expand Down Expand Up @@ -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:<collection>.<field>}}` — 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 `<collection>` 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:<collection>.<field>}}` — 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}}`
- `{{<parentField>}}` — 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 `<collection>` 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:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
10 changes: 5 additions & 5 deletions src/components/ColumnDisplayEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
@input="form.template = $event ?? ''"
/>
<div v-if="m2aHelp" class="hint">
Many-to-Any field — use <code>{{ itemToken }}</code> (no
<code>{{ m2aHelp.fieldKey }}.</code> prefix).
Many-to-Any field — pick fields from the tree, or write
<code>{{ itemToken }}</code> by hand.
</div>
</div>

Expand Down Expand Up @@ -103,10 +103,10 @@ const m2aHelp = computed(() => {
: '{{collection}}: {{item:<collection>.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(() => {
Expand Down
41 changes: 13 additions & 28 deletions src/components/EditableCellRelational.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<M2ASegment[]>(() => {
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 =
Expand All @@ -425,27 +420,17 @@ const m2aSegments = computed<M2ASegment[]>(() => {
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<string, unknown>;
Expand Down
61 changes: 46 additions & 15 deletions src/utils/adjustFieldsForDisplays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
pickHeuristic,
parseM2AToken,
buildM2AFieldPath,
isM2APrefix,
stripM2AFieldPrefix,
M2A_COLLECTION_TOKEN,
} from './displayHeuristics';
import { resolveM2ARelation } from './resolveM2ARelation';

Expand Down Expand Up @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions src/utils/buildM2ASegments.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> | 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<string, any>)[discriminator];

if (rowCollection && (row as Record<string, any>)[itemField] === null) {
if (!canRead(String(rowCollection))) {
segments.push({ blocked: true, collection: String(rowCollection) });
}
continue;
}

const text = renderM2ATemplate(
row as Record<string, any>,
template,
itemField,
discriminator,
fieldName,
parentRow
).trim();
if (text && text !== '—') segments.push({ text });
}
return segments;
}
31 changes: 31 additions & 0 deletions src/utils/displayHeuristics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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].
Expand Down Expand Up @@ -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 `<fieldName>.` 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,
Expand Down
Loading
Loading