From 7f136f38d5716c334395b5b40c179fd014f9fd5f Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Fri, 28 Aug 2026 16:02:58 +0200 Subject: [PATCH 1/2] feat(app): allow per-map layer labels for shared layers A layer can be attached to several maps, but its name and menu text were global. A map shared between a German and an English audience therefore had to settle on one language. The `layers_maps` junction row already means "this layer, on this map", so it is the natural place for the label. Two optional fields there, `name` and `menuText`, now override the layer's own labels for that map only. Blank or missing values fall back to the layer, so every existing map keeps its current labels untouched. Co-Authored-By: Claude Opus 5 (1M context) --- app/package.json | 6 ++- app/src/api/layerLabels.spec.ts | 76 +++++++++++++++++++++++++++++++++ app/src/api/layerLabels.ts | 46 ++++++++++++++++++++ app/src/api/layersApi.ts | 10 +++-- app/vite.config.ts | 5 +++ 5 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 app/src/api/layerLabels.spec.ts create mode 100644 app/src/api/layerLabels.ts diff --git a/app/package.json b/app/package.json index cf28739d3..e02c95226 100644 --- a/app/package.json +++ b/app/package.json @@ -10,7 +10,8 @@ "dev": "vite --host", "build": "tsc && vite build", "test:lint:eslint": "eslint --max-warnings 0 .", - "preview": "vite preview" + "preview": "vite preview", + "test:unit": "vitest run" }, "dependencies": { "@directus/sdk": "^17.0.2", @@ -50,6 +51,7 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.9.0", "vite": "^7.3.1", - "vite-plugin-pwa": "^1.2.0" + "vite-plugin-pwa": "^1.2.0", + "vitest": "^4.0.16" } } diff --git a/app/src/api/layerLabels.spec.ts b/app/src/api/layerLabels.spec.ts new file mode 100644 index 000000000..193ff7b33 --- /dev/null +++ b/app/src/api/layerLabels.spec.ts @@ -0,0 +1,76 @@ +/* eslint-disable camelcase -- `maps_id` is the Directus junction field name */ +import { describe, expect, it } from 'vitest' + +import { applyMapLabelOverrides } from './layerLabels' + +const MAP = 'map-de' +const OTHER_MAP = 'map-en' + +const layer = (maps: unknown) => ({ name: 'Gärten', menuText: 'Garten eintragen', maps }) + +describe('applyMapLabelOverrides', () => { + it('uses the override belonging to the requested map', () => { + const [result] = applyMapLabelOverrides( + [ + layer([ + { maps_id: OTHER_MAP, name: 'Gardens', menuText: 'add a garden' }, + { maps_id: MAP, name: null, menuText: null }, + ]), + ], + OTHER_MAP, + ) + + expect(result.name).toBe('Gardens') + expect(result.menuText).toBe('add a garden') + }) + + it('falls back to the layer label when the override is empty', () => { + const [result] = applyMapLabelOverrides( + [layer([{ maps_id: MAP, name: null, menuText: ' ' }])], + MAP, + ) + + expect(result.name).toBe('Gärten') + expect(result.menuText).toBe('Garten eintragen') + }) + + it('overrides each label independently', () => { + const [result] = applyMapLabelOverrides( + [layer([{ maps_id: MAP, name: 'Gemeinschaftsgärten', menuText: null }])], + MAP, + ) + + expect(result.name).toBe('Gemeinschaftsgärten') + expect(result.menuText).toBe('Garten eintragen') + }) + + it('accepts an expanded maps_id object', () => { + const [result] = applyMapLabelOverrides( + [layer([{ maps_id: { id: MAP }, name: 'Gemeinschaftsgärten' }])], + MAP, + ) + + expect(result.name).toBe('Gemeinschaftsgärten') + }) + + it('ignores junction rows of other maps and dangling rows', () => { + const [result] = applyMapLabelOverrides( + [ + layer([ + { maps_id: null, name: 'Waise' }, + { maps_id: OTHER_MAP, name: 'Gardens' }, + ]), + ], + MAP, + ) + + expect(result.name).toBe('Gärten') + }) + + it('leaves layers untouched when the junction is not expanded', () => { + const input = layer([701, 702]) + const [result] = applyMapLabelOverrides([input], MAP) + + expect(result).toBe(input) + }) +}) diff --git a/app/src/api/layerLabels.ts b/app/src/api/layerLabels.ts new file mode 100644 index 000000000..a442b1545 --- /dev/null +++ b/app/src/api/layerLabels.ts @@ -0,0 +1,46 @@ +/** + * A row of the `layers_maps` junction, which represents "this layer, on this map". + * `name` and `menuText` are optional per-map overrides: when a layer is shared + * between maps, each map can label it in its own language. + */ +interface LayerMapAssignment { + maps_id?: string | { id?: string } | null + name?: string | null + menuText?: string | null +} + +const assignedMapId = (assignment: LayerMapAssignment): string | undefined => + typeof assignment.maps_id === 'string' + ? assignment.maps_id + : (assignment.maps_id?.id ?? undefined) + +/** Treats null, undefined and blank strings alike, so an emptied field falls back. */ +const override = (value: string | null | undefined, fallback: string): string => + typeof value === 'string' && value.trim() !== '' ? value : fallback + +const isAssignment = (candidate: unknown): candidate is LayerMapAssignment => + typeof candidate === 'object' && candidate !== null + +/** + * Applies the per-map label overrides stored on the `layers_maps` junction. + * Layers without an override for `mapId` are returned untouched, so maps that + * never set one keep the labels defined on the layer itself. + */ +export function applyMapLabelOverrides< + T extends { name?: string; menuText?: string; maps?: unknown }, +>(layers: T[], mapId: string): T[] { + return layers.map((layer) => { + const assignments = Array.isArray(layer.maps) ? layer.maps : [] + const assignment = assignments + .filter(isAssignment) + .find((candidate) => assignedMapId(candidate) === mapId) + + if (!assignment) return layer + + return { + ...layer, + name: override(assignment.name, layer.name ?? ''), + menuText: override(assignment.menuText, layer.menuText ?? ''), + } + }) +} diff --git a/app/src/api/layersApi.ts b/app/src/api/layersApi.ts index fa779ecf8..921647b0b 100644 --- a/app/src/api/layersApi.ts +++ b/app/src/api/layersApi.ts @@ -4,6 +4,9 @@ import { readItems } from '@directus/sdk' import { directusClient } from './directus' +import { applyMapLabelOverrides } from './layerLabels' + +import type { LayerProps } from 'utopia-ui' export class layersApi { mapId: string @@ -14,20 +17,21 @@ export class layersApi { async getItems() { try { - const layers = await directusClient.request( + const layers = (await directusClient.request( readItems('layers' as any, { fields: [ '*', { itemType: ['*.*', { profileTemplate: ['*', 'item.*.*.*.*'] }] }, { markerIcon: ['*'] } as any, + { maps: ['maps_id', 'name', 'menuText'] } as any, ], // eslint-disable-next-line camelcase filter: { maps: { maps_id: { id: { _eq: this.mapId } } } }, limit: 500, sort: ['sort'], }), - ) - return layers + )) as unknown as LayerProps[] + return applyMapLabelOverrides(layers, this.mapId) } catch (error: any) { console.log(error) if (error.errors[0]?.message) throw error.errors[0].message diff --git a/app/vite.config.ts b/app/vite.config.ts index fa2b89108..828a69a2a 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' @@ -20,6 +21,10 @@ export default defineConfig({ */ }, plugins: [react(), tailwindcss(), tsConfigPaths()], + test: { + environment: 'node', + include: ['src/**/*.spec.ts'], + }, build: { sourcemap: true, modulePreload: { From afda863560d8d5dede1873845dc903e28b220300 Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Fri, 28 Aug 2026 16:20:13 +0200 Subject: [PATCH 2/2] fix(backend): add the per-map label fields to the schema snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The label overrides were only created on the running instance, so a freshly seeded backend did not have them. `layersApi` requests `maps.name` and `maps.menuText`, which then made Directus reject the whole layer query and left the app with no layers to render — the E2E suite saw a blank page. Adding both fields to the snapshot keeps any newly seeded instance in step with the query. Co-Authored-By: Claude Opus 5 (1M context) --- .../snapshot/fields/layers_maps/menuText.json | 46 +++++++++++++++++++ .../snapshot/fields/layers_maps/name.json | 46 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 backend/directus-config/development/snapshot/fields/layers_maps/menuText.json create mode 100644 backend/directus-config/development/snapshot/fields/layers_maps/name.json diff --git a/backend/directus-config/development/snapshot/fields/layers_maps/menuText.json b/backend/directus-config/development/snapshot/fields/layers_maps/menuText.json new file mode 100644 index 000000000..2d89eae15 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/layers_maps/menuText.json @@ -0,0 +1,46 @@ +{ + "collection": "layers_maps", + "field": "menuText", + "type": "string", + "meta": { + "collection": "layers_maps", + "conditions": null, + "display": null, + "display_options": null, + "field": "menuText", + "group": null, + "hidden": false, + "interface": "input", + "note": "Optional: Text im Plus-Menü nur für diese Karte. Leer = menuText vom Layer.", + "options": { + "placeholder": "leer = menuText vom Layer übernehmen" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 5, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "menuText", + "table": "layers_maps", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +} diff --git a/backend/directus-config/development/snapshot/fields/layers_maps/name.json b/backend/directus-config/development/snapshot/fields/layers_maps/name.json new file mode 100644 index 000000000..4311c95b3 --- /dev/null +++ b/backend/directus-config/development/snapshot/fields/layers_maps/name.json @@ -0,0 +1,46 @@ +{ + "collection": "layers_maps", + "field": "name", + "type": "string", + "meta": { + "collection": "layers_maps", + "conditions": null, + "display": null, + "display_options": null, + "field": "name", + "group": null, + "hidden": false, + "interface": "input", + "note": "Optional: Layer-Name nur für diese Karte (z.B. englisch auf der einen, deutsch auf der anderen). Leer = Name vom Layer.", + "options": { + "placeholder": "leer = Name vom Layer übernehmen" + }, + "readonly": false, + "required": false, + "searchable": true, + "sort": 4, + "special": null, + "translations": null, + "validation": null, + "validation_message": null, + "width": "half" + }, + "schema": { + "name": "name", + "table": "layers_maps", + "data_type": "character varying", + "default_value": null, + "max_length": 255, + "numeric_precision": null, + "numeric_scale": null, + "is_nullable": true, + "is_unique": false, + "is_indexed": false, + "is_primary_key": false, + "is_generated": false, + "generation_expression": null, + "has_auto_increment": false, + "foreign_key_table": null, + "foreign_key_column": null + } +}