feat(N2): Dynamischer Scope-Editor — WorkspaceScopeEditor ersetzt JSON-Textarea (#366)
- 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
This commit is contained in:
+20
-1
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
**Offene Roadmap-Phasen (user-abgestimmt, startklar):**
|
**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 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)
|
- **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):**
|
**Vorbestands-Findings (nicht blockierend, dokumentiert):**
|
||||||
@@ -104,6 +104,25 @@
|
|||||||
|
|
||||||
**Offen in Phase M:** — (Phase M abgeschlossen).
|
**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) ✅
|
## 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)
|
**Spec:** [#365](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/365) | **Roadmap:** Phase N, N1 | **Milestone:** Phase N — Workspace-Scopes (#14)
|
||||||
|
|||||||
@@ -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(<WorkspaceManager />);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import('@/api/hooks/workspaces')>();
|
||||||
|
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<string, unknown> = {},
|
||||||
|
onChange: (key: string, value: unknown) => void = vi.fn(),
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<WorkspaceScopeEditor
|
||||||
|
moduleKey="contacts"
|
||||||
|
dimensions={dims}
|
||||||
|
config={config}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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(
|
||||||
|
<WorkspaceScopeEditor moduleKey="dashboard" dimensions={[]} config={{}} onChange={vi.fn()} />,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, ScopeDimension[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWorkspaceScopeDefinitions() {
|
||||||
|
return useQuery<WorkspaceScopeDefinitions>({
|
||||||
|
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<string, unknown>)[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<string, unknown>;
|
||||||
|
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<ScopeItem[]>({
|
||||||
|
queryKey: ['workspace-scope-values', source?.endpoint ?? null],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!source) return [];
|
||||||
|
const response = await apiGet<unknown>(source.endpoint);
|
||||||
|
return resolveScopeItems(response, source.items_path, source.value_key, source.label_key);
|
||||||
|
},
|
||||||
|
enabled: !!source,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { usePluginStore } from '@/store/pluginStore';
|
||||||
import { LayoutGrid, Plus, Trash2, Edit, Users, Save, X, Check } from 'lucide-react';
|
import { LayoutGrid, Plus, Trash2, Edit, Users, Save, X, Check } from 'lucide-react';
|
||||||
|
|
||||||
@@ -45,7 +46,9 @@ export function WorkspaceManager() {
|
|||||||
|
|
||||||
const [moduleWsId, setModuleWsId] = useState<string | null>(null);
|
const [moduleWsId, setModuleWsId] = useState<string | null>(null);
|
||||||
const [moduleConfig, setModuleConfig] = useState<WorkspaceModule[]>([]);
|
const [moduleConfig, setModuleConfig] = useState<WorkspaceModule[]>([]);
|
||||||
const [configEditingKey, setConfigEditingKey] = useState<string | null>(null);
|
|
||||||
|
const { data: scopeDefData } = useWorkspaceScopeDefinitions();
|
||||||
|
const scopeDefinitions = scopeDefData?.modules || {};
|
||||||
|
|
||||||
const workspaces = wsData?.items || [];
|
const workspaces = wsData?.items || [];
|
||||||
|
|
||||||
@@ -153,44 +156,34 @@ export function WorkspaceManager() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
<div className="space-y-2">
|
||||||
{moduleConfig.map(m => {
|
{moduleConfig.map(m => {
|
||||||
const mod = availableModules.find(a => a.key === m.module_key);
|
const mod = availableModules.find(a => a.key === m.module_key);
|
||||||
return (
|
return (
|
||||||
<div key={m.module_key} className={`p-2 border rounded-md ${m.is_visible ? 'border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/10' : 'border-gray-200 dark:border-gray-700'}`}>
|
<div key={m.module_key} className={`p-3 border rounded-md ${m.is_visible ? 'border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/10' : 'border-gray-200 dark:border-gray-700'}`}>
|
||||||
<label className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded p-1">
|
<label className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded p-1 min-h-touch">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={m.is_visible}
|
checked={m.is_visible}
|
||||||
onChange={() => toggleModule(m.module_key)}
|
onChange={() => toggleModule(m.module_key)}
|
||||||
|
aria-label={mod?.label || m.module_key}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm flex-1">{mod?.label || m.module_key}</span>
|
<span className="text-sm font-medium flex-1">{mod?.label || m.module_key}</span>
|
||||||
{m.is_visible && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.preventDefault(); setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: x.config } : x)); setConfigEditingKey(configEditingKey === m.module_key ? null : m.module_key); }}
|
|
||||||
className="text-xs px-1.5 py-0.5 border rounded hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
||||||
title="Konfiguration bearbeiten"
|
|
||||||
>
|
|
||||||
⚙
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
{m.is_visible && configEditingKey === m.module_key && (
|
{m.is_visible && (
|
||||||
<div className="mt-2 space-y-1">
|
<div className="mt-2 pl-6">
|
||||||
<textarea
|
<WorkspaceScopeEditor
|
||||||
className="w-full text-xs font-mono p-1.5 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-900 h-20"
|
moduleKey={m.module_key}
|
||||||
placeholder='{"visible_folder_ids": []}'
|
dimensions={scopeDefinitions[m.module_key] || []}
|
||||||
value={JSON.stringify(m.config || {}, null, 2)}
|
config={m.config}
|
||||||
onChange={(e) => {
|
onChange={(key, value) => {
|
||||||
try {
|
setModuleConfig(prev => prev.map(x =>
|
||||||
const parsed = JSON.parse(e.target.value);
|
x.module_key === m.module_key
|
||||||
setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: parsed } : x));
|
? { ...x, config: { ...x.config, [key]: value } }
|
||||||
} catch {
|
: x
|
||||||
// Invalid JSON — keep raw text for editing
|
));
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400">JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* WorkspaceScopeEditor — dynamic per-module scope filter UI (Phase N2).
|
||||||
|
*
|
||||||
|
* Renders controls from the N1 /scope-definitions contract: multiselects
|
||||||
|
* (static options OR fetched value_source), selects, toggles. Scope values
|
||||||
|
* are stored in workspace_modules.config — an EMPTY selection means NO
|
||||||
|
* restriction (Phase N invariant: scopes can only restrict, never grant).
|
||||||
|
*
|
||||||
|
* Generic like WidgetSettingsForm (M3): the component knows nothing about
|
||||||
|
* specific modules; plugins declare their dimensions via contracts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useScopeValues, type ScopeDimension } from '@/api/hooks/workspaces';
|
||||||
|
|
||||||
|
// Re-exported for consumers/tests — resolution logic lives with the hook.
|
||||||
|
export { resolveScopeItems } from '@/api/hooks/workspaces';
|
||||||
|
|
||||||
|
interface WorkspaceScopeEditorProps {
|
||||||
|
moduleKey: string;
|
||||||
|
dimensions: ScopeDimension[];
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (key: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultiSelectDimension({
|
||||||
|
dimension,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
dimension: ScopeDimension;
|
||||||
|
selected: string[];
|
||||||
|
onToggle: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { data: fetched = [], isLoading, isError } = useScopeValues(dimension.value_source);
|
||||||
|
|
||||||
|
const options = dimension.options.length > 0 ? dimension.options : fetched;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <p className="text-xs text-secondary-500" data-testid={`scope-loading-${dimension.key}`}>…</p>;
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-danger-600" data-testid={`scope-error-${dimension.key}`}>
|
||||||
|
{t('workspaces.scopeEditor.loadError', 'Werte konnten nicht geladen werden')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (options.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-secondary-500" data-testid={`scope-empty-${dimension.key}`}>
|
||||||
|
{t('workspaces.scopeEditor.noValues', 'Keine Werte verfügbar')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1" data-testid={`scope-options-${dimension.key}`}>
|
||||||
|
{options.map((option) => {
|
||||||
|
const checked = selected.includes(option.value);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className="flex items-center gap-2 text-sm text-secondary-700 cursor-pointer min-h-touch"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => onToggle(option.value)}
|
||||||
|
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
aria-label={option.label}
|
||||||
|
data-testid={`scope-option-${dimension.key}-${option.value}`}
|
||||||
|
/>
|
||||||
|
{option.label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkspaceScopeEditor({
|
||||||
|
moduleKey,
|
||||||
|
dimensions,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: WorkspaceScopeEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (dimensions.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-xs text-secondary-500" data-testid={`scope-no-dimensions-${moduleKey}`}>
|
||||||
|
{t('workspaces.scopeEditor.noDimensions', 'Für dieses Modul sind keine Filter verfügbar.')}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMultiToggle = (dimension: ScopeDimension, value: string) => {
|
||||||
|
const current = Array.isArray(config[dimension.key]) ? (config[dimension.key] as string[]) : [];
|
||||||
|
const next = current.includes(value)
|
||||||
|
? current.filter((v) => v !== value)
|
||||||
|
: [...current, value];
|
||||||
|
onChange(dimension.key, next);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3" data-testid={`scope-editor-${moduleKey}`}>
|
||||||
|
<p className="text-xs text-secondary-500" data-testid="scope-no-restriction-hint">
|
||||||
|
{t(
|
||||||
|
'workspaces.scopeEditor.hint',
|
||||||
|
'Nichts ausgewählt = keine Einschränkung. Filter können nur einschränken, nie erweitern.',
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{dimensions.map((dimension) => {
|
||||||
|
const selected = Array.isArray(config[dimension.key])
|
||||||
|
? (config[dimension.key] as string[])
|
||||||
|
: [];
|
||||||
|
return (
|
||||||
|
<div key={dimension.key} className="space-y-1" data-testid={`scope-field-${dimension.key}`}>
|
||||||
|
<p className="text-xs font-medium text-secondary-600">{dimension.label}</p>
|
||||||
|
{dimension.control === 'multiselect' && (
|
||||||
|
<MultiSelectDimension
|
||||||
|
dimension={dimension}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={(value) => handleMultiToggle(dimension, value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dimension.control === 'select' && (
|
||||||
|
<select
|
||||||
|
value={typeof config[dimension.key] === 'string' ? (config[dimension.key] as string) : ''}
|
||||||
|
onChange={(e) => onChange(dimension.key, e.target.value)}
|
||||||
|
className="block w-full rounded-md border border-secondary-300 px-3 py-2 text-sm min-h-touch bg-white dark:bg-gray-900"
|
||||||
|
aria-label={dimension.label}
|
||||||
|
data-testid={`scope-select-${dimension.key}`}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{t('workspaces.scopeEditor.noRestriction', 'Keine Einschränkung')}
|
||||||
|
</option>
|
||||||
|
{dimension.options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{dimension.control === 'toggle' && (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-secondary-700 min-h-touch">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={config[dimension.key] === true || config[dimension.key] === 'true'}
|
||||||
|
onChange={(e) => onChange(dimension.key, e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
aria-label={dimension.label}
|
||||||
|
data-testid={`scope-toggle-${dimension.key}`}
|
||||||
|
/>
|
||||||
|
{dimension.label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1595,5 +1595,14 @@
|
|||||||
"noBlocks": "KI hat keine gültigen Blöcke vorgeschlagen",
|
"noBlocks": "KI hat keine gültigen Blöcke vorgeschlagen",
|
||||||
"failed": "KI-Vorschlag fehlgeschlagen"
|
"failed": "KI-Vorschlag fehlgeschlagen"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"scopeEditor": {
|
||||||
|
"hint": "Nichts ausgewählt = keine Einschränkung. Filter können nur einschränken, nie erweitern.",
|
||||||
|
"noRestriction": "Keine Einschränkung",
|
||||||
|
"noDimensions": "Für dieses Modul sind keine Filter verfügbar.",
|
||||||
|
"noValues": "Keine Werte verfügbar",
|
||||||
|
"loadError": "Werte konnten nicht geladen werden"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1595,5 +1595,14 @@
|
|||||||
"noBlocks": "AI did not suggest valid blocks",
|
"noBlocks": "AI did not suggest valid blocks",
|
||||||
"failed": "AI suggestion failed"
|
"failed": "AI suggestion failed"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"workspaces": {
|
||||||
|
"scopeEditor": {
|
||||||
|
"hint": "Nothing selected = no restriction. Filters can only restrict, never extend.",
|
||||||
|
"noRestriction": "No restriction",
|
||||||
|
"noDimensions": "No filters available for this module.",
|
||||||
|
"noValues": "No values available",
|
||||||
|
"loadError": "Failed to load values"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user