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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user