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:
@@ -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<string | null>(null);
|
||||
const [moduleConfig, setModuleConfig] = useState<WorkspaceModule[]>([]);
|
||||
const [configEditingKey, setConfigEditingKey] = useState<string | null>(null);
|
||||
|
||||
const { data: scopeDefData } = useWorkspaceScopeDefinitions();
|
||||
const scopeDefinitions = scopeDefData?.modules || {};
|
||||
|
||||
const workspaces = wsData?.items || [];
|
||||
|
||||
@@ -153,44 +156,34 @@ export function WorkspaceManager() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<div className="space-y-2">
|
||||
{moduleConfig.map(m => {
|
||||
const mod = availableModules.find(a => a.key === m.module_key);
|
||||
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'}`}>
|
||||
<label className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded p-1">
|
||||
<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 min-h-touch">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.is_visible}
|
||||
onChange={() => toggleModule(m.module_key)}
|
||||
aria-label={mod?.label || m.module_key}
|
||||
/>
|
||||
<span className="text-sm 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>
|
||||
)}
|
||||
<span className="text-sm font-medium flex-1">{mod?.label || m.module_key}</span>
|
||||
</label>
|
||||
{m.is_visible && configEditingKey === m.module_key && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<textarea
|
||||
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"
|
||||
placeholder='{"visible_folder_ids": []}'
|
||||
value={JSON.stringify(m.config || {}, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
setModuleConfig(prev => prev.map(x => x.module_key === m.module_key ? { ...x, config: parsed } : x));
|
||||
} catch {
|
||||
// Invalid JSON — keep raw text for editing
|
||||
}
|
||||
{m.is_visible && (
|
||||
<div className="mt-2 pl-6">
|
||||
<WorkspaceScopeEditor
|
||||
moduleKey={m.module_key}
|
||||
dimensions={scopeDefinitions[m.module_key] || []}
|
||||
config={m.config}
|
||||
onChange={(key, value) => {
|
||||
setModuleConfig(prev => prev.map(x =>
|
||||
x.module_key === m.module_key
|
||||
? { ...x, config: { ...x.config, [key]: value } }
|
||||
: x
|
||||
));
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-gray-400">JSON-Konfiguration für dieses Modul (z.B. sichtbare Ordner-IDs)</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user