From 279b0ee1674205cb5de2cff18ea40d1db880707f Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Mon, 1 Jun 2026 09:55:44 +0200 Subject: [PATCH 1/4] feat: add Many-to-Any (M2A) field support (#60) Render and query M2A relations in the table. M2A fields previously showed the raw display template (or an empty cell) and a column-display override on them blanked the whole table with a 403. - expandTokensThroughRelation now handles M2A: emits the collection discriminator plus per-collection `item:.` paths, dropping bare/disallowed tokens so the items request can't 403. - New resolveM2ARelation helper centralises the junction-shape lookup (discriminator, item field, allowed collections) with a permission fallback: when the polymorphic relation is filtered out, it reconstructs the shape from junction_field + the field's allowedCollections so a restricted user still sees what they can read. - New renderM2ATemplate util resolves `{{collection}}` and `{{item:col.field}}` tokens against each junction row, sharing parseM2AToken with the query side so the two can't drift. - Rows whose target collection is not readable show a block icon (only on genuine permission denial, not on a dangling FK). - Add an in-body error state so a failed items request no longer leaves a blank body under a live pagination bar. - isM2A helper plus M2A_TOKEN_RE/parseM2AToken/buildM2AFieldPath in displayHeuristics. --- src/components/EditableCellRelational.vue | 98 ++++++++++++- src/super-table.vue | 33 +++-- src/utils/adjustFieldsForDisplays.ts | 71 ++++++++- src/utils/displayHeuristics.ts | 39 +++++ src/utils/renderM2ATemplate.ts | 58 ++++++++ src/utils/resolveM2ARelation.ts | 118 +++++++++++++++ tests/setup.ts | 5 + .../utils/adjustFieldsForDisplays.test.ts | 85 +++++++++++ tests/unit/utils/displayHeuristics.test.ts | 112 +++++++++++++++ .../utils/expandTokensThroughRelation.test.ts | 135 ++++++++++++++++++ tests/unit/utils/fieldSupport.test.ts | 12 ++ tests/unit/utils/renderM2ATemplate.test.ts | 63 ++++++++ tests/unit/utils/resolveM2ARelation.test.ts | 119 +++++++++++++++ 13 files changed, 928 insertions(+), 20 deletions(-) create mode 100644 src/utils/renderM2ATemplate.ts create mode 100644 src/utils/resolveM2ARelation.ts create mode 100644 tests/unit/utils/renderM2ATemplate.test.ts create mode 100644 tests/unit/utils/resolveM2ARelation.test.ts diff --git a/src/components/EditableCellRelational.vue b/src/components/EditableCellRelational.vue index 21fb5de..eaf87c3 100644 --- a/src/components/EditableCellRelational.vue +++ b/src/components/EditableCellRelational.vue @@ -137,6 +137,29 @@ + +
+ + + + +
+
{ return props.fieldKey; }); +// columnDisplays is keyed by the root field (no language suffix). +const storageKey = computed(() => + props.fieldKey.includes(':') ? props.fieldKey.split(':')[0] : props.fieldKey +); + const displayValue = computed(() => { // For edited values if (props.edits !== undefined) { @@ -271,8 +301,7 @@ const displayValue = computed(() => { // Display-template resolution priority: column-display override → // field's own display template → relational heuristic → none. - const storageKey = props.fieldKey.includes(':') ? props.fieldKey.split(':')[0] : props.fieldKey; - const override = props.columnDisplays?.[storageKey]; + const override = props.columnDisplays?.[storageKey.value]; const fieldTemplate = props.field?.displayOptions?.template || props.field?.meta?.display_options?.template; @@ -299,6 +328,13 @@ const displayValue = computed(() => { void isOverridePath; void isHeuristicPath; let valueForTemplate = relationalValue; + + // M2A is rendered structurally (see m2aSegments) so blocked rows can show + // an icon; the string path below only covers non-M2A relations. + if (isM2AField.value) { + return ''; + } + // M2M: unwrap junction items through junction_field so the template // resolves against the target row, not the pivot row. const needsM2MUnwrap = @@ -363,6 +399,53 @@ const displayValue = computed(() => { return props.item[props.fieldKey]; }); +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 }; + +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 = + collection && fieldName + ? resolveM2ARelation(collection, fieldName, relationsStore, fieldsStore) + : null; + if (!m2a) return []; + + const template = + props.columnDisplays?.[storageKey.value]?.template || + props.field?.displayOptions?.template || + 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; +}); + +function isBlockedSegment(seg: M2ASegment): seg is { blocked: true; collection: string } { + return 'blocked' in seg; +} + type ResolvedDisplay = { display: string | null; options: Record; @@ -370,12 +453,9 @@ type ResolvedDisplay = { }; const resolvedDisplay = computed(() => { - // Storage key for translations is the root (no language suffix) - const storageKey = props.fieldKey.includes(':') ? props.fieldKey.split(':')[0] : props.fieldKey; - // 1. Layout-level override (renders via related-values unless the user // explicitly stored a different display id alongside the template) - const override = props.columnDisplays?.[storageKey]; + const override = props.columnDisplays?.[storageKey.value]; if (override?.template) { return { display: override.display ?? 'related-values', @@ -700,6 +780,10 @@ function navigateToPrevCell() { From f74152100d493060a748f1adb9909196a6300cdf Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Mon, 1 Jun 2026 09:56:40 +0200 Subject: [PATCH 3/4] docs: document M2A support and correct outdated README sections - Add the Many-to-Any feature and a "Column Displays" usage section covering the `item:.` template syntax. - Fix factual drift verified against the code: layout name ("Super Table"), emitted events, layout-options block, composable names, project-structure tree, default page size, workflow list, repo URL and Node version. --- README.md | 192 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 109 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 26c5e88..f4198b0 100644 --- a/README.md +++ b/README.md @@ -31,26 +31,28 @@ Powerful search functionality across all table fields, including nested relation Switch seamlessly between read-only view mode and interactive edit mode. Control when and how users can modify data. ### 🖼️ Advanced Image Display & Selection -Smart image handling with hover preview, proper aspect ratios, and built-in file browser for selecting media files directly from table cells. +Smart image handling with an enlarged hover preview, proper aspect ratios, and a built-in file browser for selecting media files directly from table cells. ### 🔄 Deep Duplication 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. + ### Display Features - **Custom Cell Rendering** - Specialized display for different field types - **Relationship Support** - Handle M2O, O2M, M2M, M2A relationships with deep data access -- **Image Preview** - Inline image display with lightbox support and file browser +- **Image Preview** - Inline image display with an enlarged hover preview and file browser - **Tag Support** - Automatic detection and display of tag fields as visual chips with inline popover editor for adding/removing tags - **Status Indicators** - Visual representation of boolean and select fields - **Translation Support** - Display multiple language translations as separate columns - **Column Alignment** - Configurable text alignment per column (left, center, right) ### Performance -- **Optimized Loading** - Default 1000 rows with efficient pagination -- **Smart Caching** - Intelligent data caching for better performance -- **Lazy Loading** - Load data as needed for large datasets +- **Native Pagination** - Server-side pagination with selectable page sizes (25, 50, 100, 250, 500, 1000; default 25) +- **Separate Count Query** - Item count is fetched independently so the table still renders when the count query is restricted by permissions ## Installation @@ -87,12 +89,12 @@ npx directus start 1. Clone or download the extension to your Directus extensions folder: ```bash cd /path/to/directus/extensions - git clone https://github.com/yourusername/super-layout-table.git + git clone https://github.com/smartlabsAT/directus-super-table.git ``` 2. Install dependencies: ```bash - cd super-layout-table + cd directus-super-table pnpm install # or npm install ``` @@ -105,11 +107,10 @@ npx directus start ### Configuration -1. Navigate to your collection settings in Directus Admin Panel -2. Click on "Layout Options" in the collection settings -3. Select "Super Layout Table" from the layout dropdown -4. Configure the layout options according to your needs -5. Save your changes +1. Open a collection in the Directus Content module +2. Open the layout dropdown in the sidebar +3. Select **"Super Table"** as the layout +4. Configure the layout options in the **Layout Options** sidebar panel ## Usage Guide @@ -127,21 +128,24 @@ Quick Filters provide fast access to frequently used filter combinations: ### Inline Editing Edit data directly in the table without opening a separate form: -1. **Entering Edit Mode**: Click on any editable cell +1. **Entering Edit Mode**: Enable "Edit Mode" in the **Layout Options** sidebar (or the edit toggle in the table header), then click an editable cell 2. **Editor Types**: - Text fields: Simple input or WYSIWYG editor - Boolean: Checkbox toggle - Select: Dropdown menu - Date/Time: Full date picker with calendar - Color: Color picker with alignment support - - Image/File: Enhanced file browser with larger previews (✨ IMPROVED in v0.2.6) -3. **Unified Header Actions** (✨ NEW in v0.2.3): - - Save/Cancel buttons now in popover header for all field types + - Image/File: File browser with larger previews +3. **Unified Header Actions**: + - Save/Cancel buttons in the popover header for all field types - Consistent UI across all editors - Icon-only buttons matching native Directus style 4. **Saving**: Click save button (✓) or press Enter 5. **Canceling**: Click cancel button (✗) or press Escape +> **Note:** Relational fields (M2O, O2M, M2M, M2A) are display-only and are not +> editable inline — open the item detail view to edit them. + ### Column Management Customize which columns are displayed and how: @@ -151,6 +155,34 @@ Customize which columns are displayed and how: 4. **Reorder Columns**: Drag column headers to reorder 5. **Resize Columns**: Drag column borders to resize +### Column Displays +Override how a column renders by attaching a display template, configured in the +sidebar under **Layout Options → Column Displays**: + +1. **Add a display**: Click "Add Column Display", pick a column, and enter a template +2. **Edit / Remove**: Click an existing entry to edit it, or the ✗ icon to remove it +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 +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. + ### Bookmarks Save table configurations for quick access: @@ -163,35 +195,38 @@ Save table configurations for quick access: ``` super-layout-table/ +├── index.ts # Extension entry point (defineLayout) ├── src/ -│ ├── index.ts # Extension entry point -│ ├── super-layout-table.vue # Main component -│ ├── actions.vue # Row/bulk actions component -│ ├── types.ts # TypeScript definitions -│ ├── constants.ts # Constants and defaults -│ └── components/ -│ ├── InlineEditPopover.vue # Inline editor popover -│ ├── QuickFilters.vue # Quick filter management -│ └── CellRenderers/ # Custom cell renderers -│ ├── ImageCell.vue # Image display -│ ├── SelectCell.vue # Select/status display -│ └── BooleanCell.vue # Boolean checkbox -├── composables/ -│ ├── api.ts # API operations -│ ├── useAliasFields.ts # Field aliasing logic -│ └── useLanguageSelector.ts # Translation language selection -├── utils/ -│ ├── adjustFieldsForDisplays.ts # Display field adjustments -│ └── getDefaultDisplayForType.ts # Default display mapping -├── package.json # Package configuration -├── tsconfig.json # TypeScript configuration -└── README.md # This file +│ ├── super-table.vue # Main layout component +│ ├── options.vue # Sidebar layout options +│ ├── actions.vue # Row/bulk actions component +│ ├── components/ +│ │ ├── InlineEditPopover.vue # Inline editor popover +│ │ ├── QuickFilters.vue # Quick filter management +│ │ ├── ColumnDisplaysSection.vue # Column-display list + editor +│ │ ├── ColumnDisplayEditor.vue # Single column-display form +│ │ ├── EditableCellRelational.vue# Relational/M2A cell rendering +│ │ └── CellRenderers/ # Custom cell renderers (image, select, …) +│ ├── composables/ +│ │ ├── api.ts # API operations (useTableApi) +│ │ ├── useAliasFields.ts # Field aliasing logic +│ │ ├── useColumnDisplays.ts # Column-display override CRUD +│ │ └── … # pagination, sort, permissions, translations +│ └── utils/ +│ ├── adjustFieldsForDisplays.ts# Display field expansion (incl. M2A) +│ ├── displayHeuristics.ts # Template tokenising + relation helpers +│ ├── resolveM2ARelation.ts # M2A junction-shape resolver +│ └── getDefaultDisplayForType.ts# Default display mapping +├── tests/ # Vitest unit tests +├── package.json # Package configuration +├── tsconfig.json # TypeScript configuration +└── README.md # This file ``` ## Development ### Prerequisites -- Node.js 18+ +- Node.js 20+ (CI runs on 20.x and 22.x) - pnpm package manager - Directus 11.0.0+ @@ -238,10 +273,12 @@ Every push and pull request triggers automated quality validation: ``` .github/workflows/ -├── quality-checks.yml # Main quality validation (runs on push/PR) -├── pr-checks.yml # PR-specific checks with auto-comments -├── release.yml # Automated release creation on tags -└── badges.yml # Status badge updates +├── ci.yml # CI/CD pipeline (build, test, quality on Node 20.x & 22.x) +├── quality-checks.yml # Type-check, lint, format, build (push/PR) +├── test.yml # Vitest unit tests +├── pr-checks.yml # PR-specific checks with auto-comments +├── release.yml # Automated release creation on version tags +└── badges.yml # Status badge updates ``` ### Running Locally @@ -266,56 +303,45 @@ pnpm run build # Build test 5. GitHub Actions will automatically validate your code 6. PR will receive an automated quality report comment -### Extension Configuration -The extension can be configured through the Directus interface with these options: +### Layout Options +The layout persists these options (those marked *(sidebar)* have a control in the +**Layout Options** panel; the rest are set through table interactions): ```typescript { - // Number of items to load initially - defaultRowCount: 1000, - - // Row height: 'compact' | 'cozy' | 'comfortable' - rowHeight: 'comfortable', - - // Selection mode: 'none' | 'single' | 'multiple' - showSelect: 'multiple', - - // Enable fixed header - fixedHeader: true, - - // Allow column resizing - showResize: true, - - // Enable inline editing - allowInlineEdit: true, - - // Enable bookmark system - allowBookmarks: true, - - // Enable quick filters - allowQuickFilters: true + showToolbar?: boolean; // (sidebar) show the toolbar with actions + editMode?: boolean; // (sidebar) enable inline editing + directBooleanToggle?: boolean; // (sidebar, requires editMode) single-click boolean toggle + languageCodeField?: string; // (sidebar) language-code field for translations (default 'languages_code') + customFieldNames?: Record; // per-column header label overrides + columnDisplays?: Record; // per-column display templates + showSelect?: boolean; // show row-selection checkboxes + spacing?: 'compact' | 'cozy' | 'comfortable'; // row height + align?: Record; // per-column text alignment + widths?: Record; // per-column widths + quickFilters?: QuickFilter[]; // saved quick filters } ``` ## API Reference ### Events -The extension emits the following events: +As a Directus layout, the component emits the standard layout sync events: - `update:selection` - When item selection changes -- `update:filters` - When filters are modified -- `update:search` - When search query changes -- `update:limit` - When page size changes -- `update:page` - When current page changes -- `update:sort` - When sort order changes -- `update:fields` - When visible fields change +- `update:layoutOptions` - When a layout option changes (column displays, edit mode, alignment, widths, …) +- `update:layoutQuery` - When the query changes (fields, sort, page, limit) +- `update:search` - When the search query changes + +The `filter` prop is consumed read-only (filtering is driven by Directus), so no +`update:filter` event is emitted. ### Composables Available composables for extension development: -- `useApi()` - API operations wrapper +- `useTableApi()` - API operations wrapper (fetch, count, update, delete, export) - `useAliasFields()` - Field aliasing for complex queries -- `useLanguageSelector()` - Translation language management +- `useColumnDisplays()` - Per-column display-template overrides ## Browser Support @@ -349,14 +375,14 @@ Contributions are welcome! Please follow these guidelines: - Try clearing browser cache ### Inline editing not working -- Check field permissions in Directus -- Ensure fields are not read-only -- Verify field types are supported +- Make sure "Edit Mode" is enabled in the Layout Options sidebar +- Check field permissions in Directus (no update permission = read-only) +- Ensure fields are not configured as read-only +- Note: relational fields (M2O, O2M, M2M, M2A) are display-only by design ### Performance issues -- Reduce default row count -- Enable pagination for large datasets -- Check browser console for errors +- Choose a smaller page size (the per-page selector defaults to 25) +- Check the browser console for errors ## Changelog From e773b2163f678c5fb981d91a95835f9ec20df680 Mon Sep 17 00:00:00 2001 From: Christopher Schwarz Date: Mon, 1 Jun 2026 09:56:54 +0200 Subject: [PATCH 4/4] chore: bump version to 0.5.0 for M2A support --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ package.json | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f56eea..4db30e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,40 @@ 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.0 — Many-to-Any support (Issue #60) + +### Added +- **Many-to-Any (M2A) fields are now supported** as display columns. The + `related-values` display and per-column overrides resolve the polymorphic + `item:collection.field` template syntax, including nested chains such as + `{{item:partners_catalog.catalog_id.title}}` (M2A → M2O → scalar). Each + junction row renders against its own collection discriminator. Reported by + @Abdallah-Awwad in #60. +- **In-body error state:** when the items request fails, the layout now shows + an explicit error notice instead of a blank body under a live pagination bar. + +### Fixed +- **M2A raw template / empty cell:** M2A columns previously rendered the literal + display template (or an empty cell) because the query never expanded the + per-collection `item:` paths and the renderer never unwrapped the junction + rows. Both paths now handle M2A. +- **M2A column display → 0 results:** adding a column-display template on an M2A + field expanded into an invalid junction field path, 403'd the items request, + and blanked the table while pagination stayed visible. Invalid/bare tokens are + now dropped before the request, and the footer is gated on the same + renderable-data condition as the table. + +### Refactor +- New `resolveM2ARelation` helper centralises the M2A junction-shape lookup + (discriminator, item field, allowed collections) in one place, mirroring the + `resolveTranslationsCollection` pattern — replacing three hand-written copies + across the query and render layers. +- M2A template grammar (`M2A_TOKEN_RE`, `parseM2AToken`, `buildM2AFieldPath`) + now lives in `displayHeuristics`, so the field-path emit and the render-side + read share a single source and cannot drift. +- Cell rendering reuses `get` from `@directus/utils` for nested-path access + instead of a bespoke walker. + ## v0.4.2 — Marketplace metadata ### Changed diff --git a/package.json b/package.json index 3c55cad..ca63e5c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "directus-extension-super-table", - "version": "0.4.2", + "version": "0.5.0", "description": "A powerful and feature-rich table layout extension for Directus 11+ with inline editing, quick filters, and manual sorting", "icon": "table_rows", "keywords": [