From b40adfdd3a9ca5c4d9071c608930647cf5f79d9c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Tue, 1 Sep 2026 08:31:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(N2):=20Dynamischer=20Scope-Editor=20?= =?UTF-8?q?=E2=80=94=20WorkspaceScopeEditor=20ersetzt=20JSON-Textarea=20(#?= =?UTF-8?q?366)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspaceScopeEditor.tsx: generisches Filter-UI aus /scope-definitions (multiselect mit value_source-Fetch, select mit Keine-Einschränkung-Placeholder, toggle) — WidgetSettingsForm-Philosophie - resolveScopeItems: Wertequellen-Auflösung (items-Wrapper, Root-Listen, DMS-Ordner-Baum-Flattening), nie-crashend - Hooks: useWorkspaceScopeDefinitions + useScopeValues (TanStack Query, staleTime 60s) - WorkspaceManager: JSON-Textarea entfernt, Scope-Editor inline pro sichtbarem Modul, Speicherung in workspace_modules.config - i18n: workspaces.scopeEditor.* 5 Keys de/en (Security-Invariante im UI: nichts ausgewählt = keine Einschränkung) - Tests: 21/21 (TDD rot 4→grün), tsc clean, Production-Build OK --- PROGRESS.md | 21 +- .../WorkspaceManager.scopeEditor.test.tsx | 127 +++++++++ .../settings/WorkspaceScopeEditor.test.tsx | 253 ++++++++++++++++++ frontend/src/api/hooks/workspaces.ts | 92 +++++++ .../components/settings/WorkspaceManager.tsx | 51 ++-- .../settings/WorkspaceScopeEditor.tsx | 166 ++++++++++++ frontend/src/i18n/locales/de.json | 9 + frontend/src/i18n/locales/en.json | 9 + 8 files changed, 698 insertions(+), 30 deletions(-) create mode 100644 frontend/src/__tests__/settings/WorkspaceManager.scopeEditor.test.tsx create mode 100644 frontend/src/__tests__/settings/WorkspaceScopeEditor.test.tsx create mode 100644 frontend/src/components/settings/WorkspaceScopeEditor.tsx diff --git a/PROGRESS.md b/PROGRESS.md index c0fc261..130686d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -8,7 +8,7 @@ **Offene Roadmap-Phasen (user-abgestimmt, startklar):** - **Phase M** — MiniApp-Plattform & Dashboard-Builder (M1-M6). **M1 ✓** (Universal-Registry, `/api/v1/miniapps`), **M2 ✓** (persönliche Dashboards: Tabelle, CRUD, Seed, RLS), **M3 ✓** (Dashboard-Builder: Edit-Modus, Drag&Drop, Palette, Tabs), **M4 ✓** (System-Rückbau, Core = reiner Host), **M5 ✓** (Plugin-MiniApps), **M6 ✓ erledigt — PHASE M KOMPLETT** (Windows-Host + AI-Agenten-Tool send_miniapp — siehe Phase-M6-Section). -- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract: workspace_scopes() für contacts/dms/mail/calendar, Pydantic fail-closed, `/api/v1/workspaces/scope-definitions` — siehe Phase-N1-Section). **Nächster Schritt:** N2 Dynamischer Scope-Editor. +- **Phase N** — Workspace-Scopes (N1-N4). 0 Umbau — Fundament (config JSONB, X-Workspace-ID, /context, Sidebar-Consumer) existiert bereits. **N1 ✓** (Scope-Registry via Contract: workspace_scopes() für contacts/dms/mail/calendar, Pydantic fail-closed, `/api/v1/workspaces/scope-definitions` — siehe Phase-N1-Section). **N2 ✓** (Dynamischer Scope-Editor: WorkspaceScopeEditor ersetzt JSON-Textarea, Filter-UI aus Registry, Speicherung workspace_modules.config — siehe Phase-N2-Section). **Nächster Schritt:** N3 Erste vier Module integrieren (Backend respektiert X-Workspace-ID bei Listen, additive UND-Einschränkung). - **Phase O** — UI-Overhaul (umbenannt von Doppel-L, Bug-Verifikation steht im Roadmap-Eintrag: 5/7 Bugs bereits erledigt, offen: 1.2 Kontakte-Drag-Drop in Ordner, 1.3 MoveDialog) **Vorbestands-Findings (nicht blockierend, dokumentiert):** @@ -104,6 +104,25 @@ **Offen in Phase M:** — (Phase M abgeschlossen). +## Phase N2 — Dynamischer Scope-Editor (2026-09-01) ✅ + +**Spec:** [#366](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/366) | **Roadmap:** Phase N, N2 | **Milestone:** Phase N — Workspace-Scopes (#14) + +**Umgesetzt:** +- **WorkspaceScopeEditor.tsx** (neu): generisches Filter-UI aus /scope-definitions — multiselect (statische Options ODER value_source-Fetch), select (mit 'Keine Einschränkung'-Placeholder), toggle. WidgetSettingsForm-Philosophie (M3): die Komponente kennt keine spezifischen Module, Plugins deklarieren via Contracts. +- **resolveScopeItems** (api/hooks/workspaces.ts): Wertequellen-Auflösung für alle N1-Formate — items-Wrapper (contact-folders), Root-Listen (mail/accounts, calendars, saved-views), DMS-Ordner-Baum (children-Flattening). Nie-crashend: defekte Responses → leere Liste. +- **Hooks:** useWorkspaceScopeDefinitions (queryKey workspace-scope-definitions) + useScopeValues (endpoint-spezifisch, staleTime 60s). +- **WorkspaceManager:** JSON-Textarea-Editor ENTFERNT — dynamischer Scope-Editor inline pro sichtbarem Modul; Speicherung unverändert über POST /{id}/modules in workspace_modules.config. +- **i18n:** workspaces.scopeEditor.* 5 Keys (de/en) — hint (Security-Invariante im UI), noRestriction, noDimensions, noValues, loadError. + +**Verifiziert (2026-09-01):** +- TDD: Rot 4 failed → ✅ Grün **21/21** (WorkspaceScopeEditor 17: resolveScopeItems-Unit 4, Rendering 7, onChange 6; WorkspaceManager-Integration 4: Textarea weg + Scope-Fields da, Config-Roundtrip checked, Save-Payload config korrekt, No-Dimensions-Hinweis nach Toggle) +- ✅ npx tsc --noEmit: clean (0 Errors) +- ✅ Production-Build: OK (vite build, nur Chunk-Size-Warnung Vorbestand) +- ✅ Frontend-only-Deploy + Bundle live verifiziert + +**Offen in Phase N:** N3 Backend-Listen-Filterung via X-Workspace-ID (additive UND-Einschränkung: contacts Ordner/Typen/View, dms Ordner/Typen, mail Postfächer, calendar Kalender/View), N4 restliche Module. + ## Phase N1 — Scope-Registry via Contract (2026-08-31) ✅ **Spec:** [#365](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/365) | **Roadmap:** Phase N, N1 | **Milestone:** Phase N — Workspace-Scopes (#14) diff --git a/frontend/src/__tests__/settings/WorkspaceManager.scopeEditor.test.tsx b/frontend/src/__tests__/settings/WorkspaceManager.scopeEditor.test.tsx new file mode 100644 index 0000000..d93774d --- /dev/null +++ b/frontend/src/__tests__/settings/WorkspaceManager.scopeEditor.test.tsx @@ -0,0 +1,127 @@ +/** + * N2 — WorkspaceManager integration: dynamic scope editor replaces the + * raw JSON textarea (Phase N). The module editor renders filter UI from + * /scope-definitions and saves scope values inside workspace_modules.config. + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { WorkspaceManager } from '@/components/settings/WorkspaceManager'; + +const mockWorkspaces = { + items: [ + { + id: 'ws1', + name: 'Vertrieb', + icon: 'LayoutGrid', + description: null, + is_default: false, + is_active: true, + created_by: null, + created_at: null, + updated_at: null, + user_count: 2, + modules: [ + { + module_key: 'contacts', + is_visible: true, + menu_order: 0, + config: { contact_types: ['company'] }, + }, + ], + }, + ], + total: 1, +}; + +const mockScopeDefinitions = { + modules: { + contacts: [ + { + key: 'contact_types', + label: 'Kontakt-Typen', + control: 'multiselect', + options: [ + { value: 'company', label: 'Firmen' }, + { value: 'person', label: 'Personen' }, + ], + value_source: null, + default: null, + }, + ], + // dashboard: module without scope dimensions + }, +}; + +const setModulesMock = vi.fn().mockResolvedValue({}); + +vi.mock('@/api/hooks/workspaces', () => ({ + useWorkspaces: () => ({ data: mockWorkspaces, isLoading: false, isError: false, error: null, refetch: vi.fn() }), + useCreateWorkspace: () => ({ mutateAsync: vi.fn().mockResolvedValue({ id: 'ws2' }), isPending: false }), + useUpdateWorkspace: () => ({ mutateAsync: vi.fn().mockResolvedValue({}), isPending: false }), + useDeleteWorkspace: () => ({ mutateAsync: vi.fn().mockResolvedValue({}), isPending: false }), + useSetWorkspaceModules: () => ({ mutateAsync: setModulesMock, isPending: false }), + useAssignWorkspaceUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useRemoveWorkspaceUser: () => ({ mutateAsync: vi.fn(), isPending: false }), + useWorkspaceScopeDefinitions: () => ({ data: mockScopeDefinitions, isLoading: false, isError: false }), + useScopeValues: () => ({ data: [], isLoading: false, isError: false }), +})); + +vi.mock('@/store/pluginStore', () => ({ + usePluginStore: (selector: (state: { manifests: unknown[] }) => unknown) => + selector({ + manifests: [ + { menu_items: [{ path: '/contacts', label: 'Kontakte', label_key: 'nav.contacts' }] }, + ], + }), +})); + +function renderManager() { + return render(); +} + +function openModuleEditor() { + fireEvent.click(screen.getByTitle('Module')); +} + +describe('WorkspaceManager N2 scope editor integration', () => { + it('replaces the raw JSON textarea with the dynamic scope editor', () => { + renderManager(); + openModuleEditor(); + // N1 scope editor fields are rendered ... + expect(screen.getByTestId('scope-field-contact_types')).toBeInTheDocument(); + // ... and the legacy JSON textarea is gone. + expect(document.querySelector('textarea')).toBeNull(); + }); + + it('loads existing scope values from the stored module config', () => { + renderManager(); + openModuleEditor(); + const company = screen.getByTestId('scope-option-contact_types-company') as HTMLInputElement; + const person = screen.getByTestId('scope-option-contact_types-person') as HTMLInputElement; + expect(company.checked).toBe(true); + expect(person.checked).toBe(false); + }); + + it('saves scope values inside module config on save', async () => { + renderManager(); + openModuleEditor(); + fireEvent.click(screen.getByTestId('scope-option-contact_types-person')); + fireEvent.click(screen.getByText('Speichern')); + + await waitFor(() => expect(setModulesMock).toHaveBeenCalled()); + const payload = setModulesMock.mock.calls[0][0]; + expect(payload.workspaceId).toBe('ws1'); + const contactsModule = payload.modules.find((m: any) => m.module_key === 'contacts'); + expect(contactsModule.config).toEqual({ contact_types: ['company', 'person'] }); + }); + + it('shows the no-dimensions hint for modules without scope definitions', () => { + renderManager(); + openModuleEditor(); + // dashboard starts hidden (workspace config only covers contacts) — + // make it visible first, then the scope editor renders its hint. + fireEvent.click(screen.getByLabelText('Dashboard')); + expect(screen.getByTestId('scope-no-dimensions-dashboard')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/__tests__/settings/WorkspaceScopeEditor.test.tsx b/frontend/src/__tests__/settings/WorkspaceScopeEditor.test.tsx new file mode 100644 index 0000000..a0f8e94 --- /dev/null +++ b/frontend/src/__tests__/settings/WorkspaceScopeEditor.test.tsx @@ -0,0 +1,253 @@ +/** + * N2 — Dynamischer Scope-Editor (Phase N). + * + * Renders per-module filter UI from the N1 /scope-definitions contract: + * multiselects (static options OR value_source with items_path/tree + * flattening), selects, toggles. Scope values are stored in + * workspace_modules.config — empty selection = no restriction (Phase N + * security invariant: scopes can only restrict, never grant). + */ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { + WorkspaceScopeEditor, + resolveScopeItems, +} from '@/components/settings/WorkspaceScopeEditor'; +import type { ScopeDimension } from '@/api/hooks/workspaces'; + +// ─── Hook-Mocks (endpoint-bewusst für value_source) ─────────── + +const folderOptions = [ + { value: 'f1', label: 'Ordner A' }, + { value: 'f2', label: 'Ordner B' }, +]; +const dmsOptions = [ + { value: 'd1', label: 'Angebote' }, + { value: 'd2', label: '2026' }, +]; + +vi.mock('@/api/hooks/workspaces', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useScopeValues: (source: { endpoint: string } | null) => { + if (!source) return { data: [], isLoading: false, isError: false }; + if (source.endpoint === '/api/v1/contact-folders') { + return { data: folderOptions, isLoading: false, isError: false }; + } + if (source.endpoint === '/api/v1/dms/folders') { + return { data: dmsOptions, isLoading: false, isError: false }; + } + return { data: [], isLoading: false, isError: false }; + }, + }; +}); + +const dims: ScopeDimension[] = [ + { + key: 'folder_ids', + label: 'Kontakt-Ordner', + control: 'multiselect', + options: [], + value_source: { + endpoint: '/api/v1/contact-folders', + items_path: 'items', + value_key: 'id', + label_key: 'name', + }, + default: null, + }, + { + key: 'contact_types', + label: 'Kontakt-Typen', + control: 'multiselect', + options: [ + { value: 'company', label: 'Firmen' }, + { value: 'person', label: 'Personen' }, + ], + value_source: null, + default: null, + }, + { + key: 'default_view', + label: 'Standard-Ansicht', + control: 'select', + options: [ + { value: 'month', label: 'Monat' }, + { value: 'week', label: 'Woche' }, + ], + value_source: null, + default: null, + }, + { + key: 'only_mine', + label: 'Nur meine', + control: 'toggle', + options: [], + value_source: null, + default: false, + }, +]; + +function renderEditor( + config: Record = {}, + onChange: (key: string, value: unknown) => void = vi.fn(), +) { + return render( + , + ); +} + +// ─── Unit: resolveScopeItems (Wertequellen-Auflösung) ───────── + +describe('resolveScopeItems', () => { + it('resolves items from a wrapped response (items_path="items")', () => { + const response = { items: [{ id: 'f1', name: 'Ordner A' }], total: 1 }; + const result = resolveScopeItems(response, 'items', 'id', 'name'); + expect(result).toEqual([{ value: 'f1', label: 'Ordner A' }]); + }); + + it('resolves items from a root list (items_path="")', () => { + const response = [ + { id: 'a1', email: 'vertrieb@example.com' }, + { id: 'a2', email: 'info@example.com' }, + ]; + const result = resolveScopeItems(response, '', 'id', 'email'); + expect(result).toEqual([ + { value: 'a1', label: 'vertrieb@example.com' }, + { value: 'a2', label: 'info@example.com' }, + ]); + }); + + it('flattens nested folder trees (children arrays)', () => { + const response = [ + { + id: 'd1', + name: 'Angebote', + children: [{ id: 'd2', name: '2026', children: [] }], + }, + ]; + const result = resolveScopeItems(response, '', 'id', 'name'); + expect(result).toEqual([ + { value: 'd1', label: 'Angebote' }, + { value: 'd2', label: '2026' }, + ]); + }); + + it('returns empty list for non-array responses instead of crashing', () => { + expect(resolveScopeItems(null, 'items', 'id', 'name')).toEqual([]); + expect(resolveScopeItems({ items: null }, 'items', 'id', 'name')).toEqual([]); + expect(resolveScopeItems({ error: true }, '', 'id', 'name')).toEqual([]); + }); +}); + +// ─── Komponente: Control-Rendering ──────────────────────────── + +describe('WorkspaceScopeEditor', () => { + it('renders a field per dimension', () => { + renderEditor(); + expect(screen.getByTestId('scope-field-folder_ids')).toBeInTheDocument(); + expect(screen.getByTestId('scope-field-contact_types')).toBeInTheDocument(); + expect(screen.getByTestId('scope-field-default_view')).toBeInTheDocument(); + expect(screen.getByTestId('scope-field-only_mine')).toBeInTheDocument(); + }); + + it('renders value_source options as checkboxes (fetched)', () => { + renderEditor(); + expect(screen.getByTestId('scope-option-folder_ids-f1')).toBeInTheDocument(); + expect(screen.getByTestId('scope-option-folder_ids-f2')).toBeInTheDocument(); + expect(screen.getByText('Ordner A')).toBeInTheDocument(); + }); + + it('renders static options as checkboxes', () => { + renderEditor(); + expect(screen.getByTestId('scope-option-contact_types-company')).toBeInTheDocument(); + expect(screen.getByTestId('scope-option-contact_types-person')).toBeInTheDocument(); + }); + + it('renders select control with a no-restriction placeholder option', () => { + renderEditor(); + const select = screen.getByTestId('scope-select-default_view') as HTMLSelectElement; + expect(select).toBeInTheDocument(); + const placeholder = select.querySelector('option[value=""]'); + expect(placeholder).not.toBeNull(); + expect(Array.from(select.options).map((o) => o.value)).toContain('month'); + }); + + it('renders toggle control as a checkbox', () => { + renderEditor(); + const toggle = screen.getByTestId('scope-toggle-only_mine') as HTMLInputElement; + expect(toggle).toBeInTheDocument(); + expect(toggle.type).toBe('checkbox'); + expect(toggle.checked).toBe(false); + }); + + it('shows the no-restriction hint', () => { + renderEditor(); + expect(screen.getByTestId('scope-no-restriction-hint')).toBeInTheDocument(); + }); + + it('shows a hint instead of UI when a module has no scope dimensions', () => { + render( + , + ); + expect(screen.getByTestId('scope-no-dimensions-dashboard')).toBeInTheDocument(); + }); +}); + +// ─── Komponente: onChange-Semantik ───────────────────────────── + +describe('WorkspaceScopeEditor onChange', () => { + it('multiselect adds values to the array and reports them', () => { + const onChange = vi.fn(); + renderEditor({ contact_types: ['person'] }, onChange); + fireEvent.click(screen.getByTestId('scope-option-contact_types-company')); + expect(onChange).toHaveBeenCalledWith('contact_types', ['person', 'company']); + }); + + it('multiselect removes values from the array on uncheck', () => { + const onChange = vi.fn(); + renderEditor({ contact_types: ['company', 'person'] }, onChange); + fireEvent.click(screen.getByTestId('scope-option-contact_types-company')); + expect(onChange).toHaveBeenCalledWith('contact_types', ['person']); + }); + + it('multiselect starts from [] when config key is unset', () => { + const onChange = vi.fn(); + renderEditor({}, onChange); + fireEvent.click(screen.getByTestId('scope-option-contact_types-company')); + expect(onChange).toHaveBeenCalledWith('contact_types', ['company']); + }); + + it('select reports the chosen value', () => { + const onChange = vi.fn(); + renderEditor({}, onChange); + fireEvent.change(screen.getByTestId('scope-select-default_view'), { + target: { value: 'month' }, + }); + expect(onChange).toHaveBeenCalledWith('default_view', 'month'); + }); + + it('toggle reports boolean state', () => { + const onChange = vi.fn(); + renderEditor({}, onChange); + fireEvent.click(screen.getByTestId('scope-toggle-only_mine')); + expect(onChange).toHaveBeenCalledWith('only_mine', true); + }); + + it('marks already selected values as checked (round-trip from config)', () => { + renderEditor({ folder_ids: ['f1'] }); + expect( + (screen.getByTestId('scope-option-folder_ids-f1') as HTMLInputElement).checked, + ).toBe(true); + expect( + (screen.getByTestId('scope-option-folder_ids-f2') as HTMLInputElement).checked, + ).toBe(false); + }); +}); diff --git a/frontend/src/api/hooks/workspaces.ts b/frontend/src/api/hooks/workspaces.ts index 3bedc17..610a21a 100644 --- a/frontend/src/api/hooks/workspaces.ts +++ b/frontend/src/api/hooks/workspaces.ts @@ -216,3 +216,95 @@ export function useSetDefaultWorkspace() { }, }); } + +// ─── N2: Workspace Scope-Registry (Phase N) ─────────────────── + +export interface ScopeOption { + value: string; + label: string; +} + +export interface ScopeValueSource { + endpoint: string; + items_path: string; + value_key: string; + label_key: string; +} + +export interface ScopeDimension { + key: string; + label: string; + control: 'multiselect' | 'select' | 'toggle'; + options: ScopeOption[]; + value_source: ScopeValueSource | null; + default: boolean | string | null; +} + +export interface WorkspaceScopeDefinitions { + modules: Record; +} + +export function useWorkspaceScopeDefinitions() { + return useQuery({ + queryKey: ['workspace-scope-definitions'], + queryFn: () => apiGet('/api/v1/workspaces/scope-definitions'), + }); +} + +export interface ScopeItem { + value: string; + label: string; +} + +/** + * Resolve selectable items from a value-source response (N2). + * + * Supported shapes (all verified live in N1): + * - items_path "items" → { items: [...] } wrapper (contact-folders) + * - items_path "" → root list (mail accounts, calendars, saved-views) + * - root list with nested `children` arrays (dms folder tree) → flattened + * + * Never throws: malformed responses resolve to an empty list so the + * editor degrades gracefully instead of crashing. + */ +export function resolveScopeItems( + response: unknown, + itemsPath: string, + valueKey: string, + labelKey: string, +): ScopeItem[] { + let raw: unknown = response; + if (itemsPath) { + if (typeof response !== 'object' || response === null) return []; + raw = (response as Record)[itemsPath]; + } + if (!Array.isArray(raw)) return []; + + const items: ScopeItem[] = []; + const walk = (entry: unknown): void => { + if (typeof entry !== 'object' || entry === null) return; + const record = entry as Record; + const value = record[valueKey]; + const label = record[labelKey]; + if (value !== undefined && value !== null) { + items.push({ value: String(value), label: String(label ?? value) }); + } + const children = record['children']; + if (Array.isArray(children)) children.forEach(walk); + }; + raw.forEach(walk); + return items; +} + +export function useScopeValues(source: ScopeValueSource | null) { + return useQuery({ + queryKey: ['workspace-scope-values', source?.endpoint ?? null], + queryFn: async () => { + if (!source) return []; + const response = await apiGet(source.endpoint); + return resolveScopeItems(response, source.items_path, source.value_key, source.label_key); + }, + enabled: !!source, + staleTime: 60_000, + }); +} diff --git a/frontend/src/components/settings/WorkspaceManager.tsx b/frontend/src/components/settings/WorkspaceManager.tsx index 6447eef..2cab584 100644 --- a/frontend/src/components/settings/WorkspaceManager.tsx +++ b/frontend/src/components/settings/WorkspaceManager.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace, useSetWorkspaceModules, useAssignWorkspaceUser, useRemoveWorkspaceUser, type Workspace, type WorkspaceModule } from '@/api/hooks/workspaces'; +import { useWorkspaces, useCreateWorkspace, useUpdateWorkspace, useDeleteWorkspace, useSetWorkspaceModules, useAssignWorkspaceUser, useRemoveWorkspaceUser, useWorkspaceScopeDefinitions, type Workspace, type WorkspaceModule } from '@/api/hooks/workspaces'; +import { WorkspaceScopeEditor } from '@/components/settings/WorkspaceScopeEditor'; import { usePluginStore } from '@/store/pluginStore'; import { LayoutGrid, Plus, Trash2, Edit, Users, Save, X, Check } from 'lucide-react'; @@ -45,7 +46,9 @@ export function WorkspaceManager() { const [moduleWsId, setModuleWsId] = useState(null); const [moduleConfig, setModuleConfig] = useState([]); - const [configEditingKey, setConfigEditingKey] = useState(null); + + const { data: scopeDefData } = useWorkspaceScopeDefinitions(); + const scopeDefinitions = scopeDefData?.modules || {}; const workspaces = wsData?.items || []; @@ -153,44 +156,34 @@ export function WorkspaceManager() { -
+
{moduleConfig.map(m => { const mod = availableModules.find(a => a.key === m.module_key); return ( -
-