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