diff --git a/app/package.json b/app/package.json index cf28739d..e02c9522 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 00000000..193ff7b3 --- /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 00000000..a442b154 --- /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 fa779ecf..921647b0 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 fa2b8910..828a69a2 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: { 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 00000000..2d89eae1 --- /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 00000000..4311c95b --- /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 + } +}