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