-
- {activeTab !== 'custom' && (
-
+
+
+
+
+ {banner &&
{banner}
}
+
imports.setImportOpen(true)}
+ showReset={isReady && !isEditingScope && !scopeNotFound && baseOverrides.count > 0}
+ resetLabel={localize('com_config_review_reset_button', {
+ count: baseOverrides.count,
+ })}
+ resetDisabled={isDirty || !canManageConfig}
+ resetTitle={resetBaseTitle}
+ onResetClick={resetBase.openResetBase}
+ showPreview={isReady && permissions.canView}
+ onPreviewClick={() => setPreviewOpen(true)}
+ showScope={permissions.canView}
+ scopeSelection={selectedScope}
+ scopeLoading={isResolvingScope && !scopeLoadError}
+ scopeUnavailable={scopeLoadError != null}
+ onScopeClick={() => setScopeSelectorOpen(true)}
+ />
+
+ {isReady && editingScope && (
+ paths.forEach(edits.handleResetField)
+ : undefined
+ }
+ onUnhide={permissions.canAssign ? edits.handleUnhideField : undefined}
+ visibility={edits.visibilityEdits}
+ fields={schemaTree}
+ config={activeConfigValues}
+ onRevertEntry={permissions.canAssign ? revertScopeEntry : undefined}
+ isEntryPending={isScopeEntryPending}
+ />
+ )}
+ {isReady && (
0}
+ filter={filterText}
+ onFilterChange={setFilterText}
+ filterHits={filterHits}
+ onFilterSelect={revealSetting}
+ customizedOnly={customizedOnly}
+ onCustomizedOnlyChange={setCustomizedOnly}
+ jumpTargets={jumpTargets}
+ onJump={jumpTo}
+ nothingCustomized={layers.activeConfiguredPaths.size === 0}
/>
+ )}
+
+
+
+
+
+ {baseline.status === 'error' && (
+
+ )}
+ {baseline.status !== 'error' && scopeLoadError && (
+
+ )}
+ {baseline.status !== 'error' &&
+ !scopeLoadError &&
+ (baseline.status === 'pending' || isResolvingScope) && }
+ {isReady && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
- )}
-
- notifyError(message)}
- editSessionId={editSessionId}
+
-
-
-
-
- {isDirty && canEditActiveTab && (
- setConfirmSaveOpen(true)}
- />
- )}
-
- setConfirmSaveOpen(false)}
- />
- notifyError(msg)}
- />
-
- setImportOpen(false)}
- onImport={handleImport}
- onImportAsProfile={handleImportAsProfile}
- />
-
- {
- if (resettingBase) return;
- setResetBaseOpen(false);
- setResetBaseError(null);
- }}
- />
-
- );
-}
-
-function HeaderActions({
- showImport,
- importDisabled,
- importTitle,
- onImportClick,
- showReset,
- resetDisabled,
- resetTitle,
- onResetClick,
- showScope,
- scopeSelection,
- onScopeClick,
-}: {
- showImport: boolean;
- importDisabled: boolean;
- importTitle?: string;
- onImportClick: () => void;
- showReset: boolean;
- resetDisabled: boolean;
- resetTitle?: string;
- onResetClick: () => void;
- showScope: boolean;
- scopeSelection: t.ScopeSelection;
- onScopeClick: () => void;
-}) {
- const localize = useLocalize();
- const [portalTarget, setPortalTarget] = useState
(null);
-
- useEffect(() => {
- setPortalTarget(document.getElementById('header-actions-portal'));
- }, []);
-
- const content = (
- <>
- {showImport && (
-
-
-
-
- {localize('com_config_import_yaml')}
-
- )}
- {showReset && (
-
-
-
-
- {localize('com_config_reset_base')}
-
- )}
- {showScope && }
- >
+ {isReady && }
+
+
+
+
+ setPreviewOpen(false)}
+ initialTarget={previewTarget}
+ />
+
+ imports.setImportOpen(false)}
+ currentScope={editingScope}
+ onImport={imports.handleImport}
+ schemaTree={schemaTree}
+ schemaPathSet={layers.schemaPathSet}
+ />
+
+
+
+
+
+
+
);
-
- if (portalTarget) return createPortal(content, portalTarget);
- return null;
}
diff --git a/src/components/configuration/ConfigRow.tsx b/src/components/configuration/ConfigRow.tsx
index 1e453d69..7f596612 100644
--- a/src/components/configuration/ConfigRow.tsx
+++ b/src/components/configuration/ConfigRow.tsx
@@ -1,10 +1,47 @@
-import { Icon } from '@clickhouse/click-ui';
+import type { ReactNode } from 'react';
import type * as t from '@/types';
-import { SectionControls } from './SectionControls';
-import { SectionHeader } from './SectionHeader';
-import { useLocalize } from '@/hooks';
-import { cn } from '@/utils';
+import {
+ useLocalize,
+ useFieldState,
+ useRevertLabels,
+ useValuesScope,
+ useFieldProfiles,
+ useHasValueLayers,
+ useRefocusWhenGone,
+} from '@/hooks';
+import {
+ ShadowBadge,
+ showsShadow,
+ sourceLabel,
+ FieldProvenance,
+ formatConfigValue,
+} from './values';
+import { activeConfigTab, fieldControl } from '@/utils';
+import { ProfileIndicator } from './ProfileIndicator';
+import { StateChip } from '@/components/shared';
+import { inferControlWidth } from './width';
+import { SettingsRow } from './SettingsRow';
+import { getFieldMeta } from './meta';
+/** Provenance from the field state, falling back to the row's own flags outside a values provider. */
+function resolveSource(
+ state: t.FieldState,
+ isDbOverride: boolean | undefined,
+ isConfigured: boolean | undefined,
+): t.ProvenanceSource {
+ if (state.source !== 'default') return state.source;
+ if (isDbOverride) return 'admin';
+ if (isConfigured) return 'yaml';
+ return 'default';
+}
+
+/**
+ * A schema field's row (VS-1): full contrast whatever its state, with chips
+ * for where the value comes from (librechat.yaml, Override, a profile,
+ * Inherited; WS4's provenance chip with its layer stack) or what is pending
+ * (Edited with Undo, Pending revert, Error). Default values carry no chip; the
+ * "Default: …" hint under the label says so.
+ */
export function ConfigRow({
title,
description,
@@ -15,88 +52,198 @@ export function ConfigRow({
hidden,
fieldId,
fieldPath,
+ previewScope,
previewChangedPaths,
permissions,
onResetField,
+ onDiscardField,
isConfigured,
isDbOverride,
- isTouched,
isPendingReset,
defaultHint,
+ controlWidth,
+ required,
+ error,
}: t.ConfigRowProps) {
const localize = useLocalize();
- if (hidden) return null;
-
- const isChanged = fieldPath && previewChangedPaths?.includes(fieldPath);
- const showReset =
- !isPendingReset &&
- (isDbOverride || isChanged) &&
+ const state = useFieldState(fieldPath ?? '');
+ const hasLayers = useHasValueLayers();
+ const revertLabels = useRevertLabels(fieldPath ?? '', title);
+ const overriddenBy = useFieldProfiles(fieldPath);
+ const valuesScope = useValuesScope();
+ const isPending = Boolean(isPendingReset) || state.isPendingReset;
+ const isEdited = !isPending && state.isEdited;
+ const isChanged = fieldPath != null && previewChangedPaths?.includes(fieldPath);
+ /** A stored override LibreChat ignores can be removed even where the section is read-only (D5). */
+ const canReset =
+ !isPending &&
+ !isEdited &&
+ (isDbOverride || state.isOverride || isChanged) &&
fieldPath &&
- !disabled &&
+ (!disabled || state.ignoredOverride) &&
permissions?.canEdit &&
onResetField;
+ /** The reset takes its own button away: focus goes to the field it reset. */
+ const requestRefocus = useRefocusWhenGone(Boolean(canReset), [
+ () => fieldControl(fieldId),
+ activeConfigTab,
+ ]);
+ if (hidden) return null;
+
+ const errorText = error ?? state.error;
+ const source = resolveSource(state, isDbOverride, isConfigured);
+ const undo = fieldPath && onDiscardField ? () => onDiscardField(fieldPath) : undefined;
+ const undoLabel = localize('com_config_ui_undo_change', { name: title });
+
+ /** Outside a values provider with layers, the row's own flags decide the chip. */
+ const fallbackProvenanceChip = (): ReactNode => {
+ if (source === 'admin') {
+ return (
+
+ );
+ }
+ if (source === 'scope') {
+ return (
+
+ );
+ }
+ if (source === 'yaml') {
+ return
;
+ }
+ if (source === 'inherited' && state.effective !== undefined) {
+ return (
+
+ );
+ }
+ return null;
+ };
+
+ /** What the value was before the pending change: for a pending hide, what the profile inherited. */
+ const savedValue = (): t.RevertTarget | undefined => {
+ if (state.visibilityEdit === 'hide') return state.inherited;
+ if (state.isHidden) return undefined;
+ return { value: state.effective, source: state.source };
+ };
+
+ /** The Edited chip keeps what the value was and where it came from ("Was 1,000,000 (librechat.yaml)", "Was hidden"). */
+ const editedDescription = (): string => {
+ const unsaved = localize('com_config_ui_state_edited_desc');
+ if (state.visibilityEdit === 'unhide') {
+ return `${unsaved}. ${localize('com_config_ui_state_edited_was_hidden')}`;
+ }
+ const was = savedValue();
+ if (!fieldPath || !hasLayers || was?.value === undefined) return unsaved;
+ const description = localize('com_config_ui_state_edited_was', {
+ value: formatConfigValue(was.value, localize, fieldPath),
+ source: sourceLabel(was.source, localize, valuesScope),
+ });
+ return `${unsaved}. ${description}`;
+ };
+
+ const stateChip = (): ReactNode => {
+ if (errorText) return
;
+ if (isPending) {
+ return (
+
+ );
+ }
+ if (isEdited) {
+ return (
+ <>
+
+ {showsShadow(state) && (
+
+ )}
+ >
+ );
+ }
+ if (fieldPath && hasLayers) return
;
+ return fallbackProvenanceChip();
+ };
+
+ const resetLabel = revertLabels?.label ?? localize('com_ui_reset');
+ const resetAriaLabel =
+ revertLabels?.ariaLabel ??
+ (isChanged
+ ? localize('com_a11y_reset_override', { name: title })
+ : localize('com_a11y_reset_to_default', { name: title }));
- const resetAction = showReset ? (
+ const resetAction = canReset ? (
onResetField(fieldPath)}
- aria-label={
- isChanged
- ? localize('com_a11y_remove_override', { name: title })
- : localize('com_a11y_reset_to_default', { name: title })
- }
- className="inline-flex items-center gap-0.5 text-[11px] text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
+ className="settings-row-action"
+ onClick={() => {
+ requestRefocus();
+ onResetField(fieldPath);
+ }}
+ aria-label={resetAriaLabel}
>
-
- {localize('com_ui_reset')}
+ {resetLabel}
) : null;
- const pendingResetHint = isPendingReset ? (
-
- {localize('com_config_pending_reset')}
-
- ) : null;
-
- const configuredDot =
- isConfigured && !isPendingReset ? (
-
+ const cascade =
+ overriddenBy && fieldPath ? (
+
) : null;
- const defaultHintEl =
- defaultHint != null ? (
-
- {localize('com_config_default_hint', { value: String(defaultHint) })}
-
- ) : null;
+ const actions =
+ resetAction || badge || cascade ? (
+ <>
+ {resetAction}
+ {badge}
+ {cascade}
+ >
+ ) : undefined;
- const hasSubContent = description || resetAction || pendingResetHint || badge;
+ const marker = (): t.SettingsRowMarker | undefined => {
+ if (errorText) return 'error';
+ if (isPending) return 'pending';
+ if (isEdited) return 'edited';
+ return undefined;
+ };
return (
-
-
- {resetAction}
- {pendingResetHint}
- {badge}
-
- {children}
-
+
+ {localize('com_config_ui_default_hint', { value: defaultHint })}
+
+ ) : undefined
+ }
+ actions={actions}
+ error={errorText}
+ required={required}
+ width={
+ controlWidth ??
+ (fieldPath ? getFieldMeta(fieldPath)?.width : undefined) ??
+ inferControlWidth(fieldPath, state.effective)
+ }
+ marker={marker()}
+ />
);
}
diff --git a/src/components/configuration/ConfigSection.tsx b/src/components/configuration/ConfigSection.tsx
index 605107f8..21feef19 100644
--- a/src/components/configuration/ConfigSection.tsx
+++ b/src/components/configuration/ConfigSection.tsx
@@ -1,27 +1,60 @@
-import { Icon } from '@clickhouse/click-ui';
-import { useState, useCallback, useEffect, useRef } from 'react';
-import type { ReactNode } from 'react';
+import { useState, useContext, useCallback, useEffect, useId, useMemo, useRef } from 'react';
import type * as t from '@/types';
-import { useLocalize } from '@/hooks';
-import { cn } from '@/utils';
+import { RowFilterContext } from '@/contexts/RowFilterContext';
+import { SoleFieldContext } from '@/contexts/SettingsContext';
+import { useLocalize, useExpandSignal } from '@/hooks';
+import { SettingsSection } from './SettingsSection';
+import { CountBadge } from '@/components/shared';
+
+/**
+ * A single-field section (VS-2): one card whose header row is the field row.
+ * The section title and description fill the label column, so the field's own
+ * label is not repeated; `SettingsRow` names the control " ".
+ */
+function SoleFieldSection({
+ domId,
+ title,
+ description,
+ learnMoreUrl,
+ children,
+}: t.SoleFieldSectionProps) {
+ const localize = useLocalize();
+ const headingId = `${useId()}-heading`;
+ const context = useMemo(() => ({ headingId, sectionTitle: title }), [headingId, title]);
-function renderCollapsible(isExpanded: boolean, hasEverExpanded: boolean, children: ReactNode) {
- const shouldRender = isExpanded || hasEverExpanded;
return (
-
-
-
{shouldRender && children}
+
-
+
);
}
+/** A top-level configuration section: an h2 card that collapses, or a single-field card. */
export function ConfigSection({
sectionId,
title,
@@ -31,122 +64,104 @@ export function ConfigSection({
hidden,
configuredCount = 0,
totalCount = 0,
+ countsEntries,
+ overrideCount,
defaultExpanded,
inline,
showConfiguredOnly,
}: t.ConfigSectionProps) {
- const localize = useLocalize();
const [isExpanded, setIsExpanded] = useState(() => defaultExpanded ?? configuredCount > 0);
const [hasEverExpanded, setHasEverExpanded] = useState(isExpanded);
const sectionRef = useRef
(null);
+ const userToggledRef = useRef(false);
useEffect(() => {
if (isExpanded && !hasEverExpanded) setHasEverExpanded(true);
}, [isExpanded, hasEverExpanded]);
+ useEffect(() => {
+ if (defaultExpanded === undefined && configuredCount > 0 && !userToggledRef.current) {
+ setIsExpanded(true);
+ }
+ }, [defaultExpanded, configuredCount]);
+
useEffect(() => {
if (showConfiguredOnly && configuredCount > 0 && !isExpanded) setIsExpanded(true);
}, [showConfiguredOnly]);
- const toggle = useCallback(() => setIsExpanded((prev) => !prev), []);
+ /** While the tab filter is active only sections holding a match render, and they open. */
+ const rowFilter = useContext(RowFilterContext);
+ useEffect(() => {
+ if (rowFilter) setIsExpanded(true);
+ }, [rowFilter]);
+
+ const toggle = useCallback(() => {
+ userToggledRef.current = true;
+ setIsExpanded((prev) => !prev);
+ }, []);
+
+ /** Expand all / Collapse all (WS5 `ExpandProvider`). */
+ const applyExpandSignal = useCallback((expanded: boolean) => {
+ if (!expanded) userToggledRef.current = true;
+ setIsExpanded(expanded);
+ }, []);
+ useExpandSignal(applyExpandSignal);
useEffect(() => {
const el = sectionRef.current;
if (!el) return;
const expand = () => setIsExpanded(true);
- const collapse = () => setIsExpanded(false);
+ const collapse = () => {
+ userToggledRef.current = true;
+ setIsExpanded(false);
+ };
el.addEventListener('config:expand', expand);
el.addEventListener('config:collapse', collapse);
return () => {
el.removeEventListener('config:expand', expand);
el.removeEventListener('config:collapse', collapse);
};
- }, []);
+ }, [inline, hidden]);
if (hidden) return null;
- const hasConfigured = configuredCount > 0;
+ const domId = sectionId ? `section-${sectionId}` : undefined;
if (inline) {
return (
-
-
-
- {title}
- {description && (
- {description}
- )}
-
-
{children}
-
-
-
+ {children}
+
);
}
return (
-
+ }
+ expanded={isExpanded}
+ onToggle={toggle}
+ hasEverExpanded={hasEverExpanded}
+ sticky
>
-
-
-
-
-
-
- {title}
- {totalCount > 0 && (
-
- {configuredCount}/{totalCount}
-
- )}
-
- {description && (
-
- {description}
- {learnMoreUrl && (
- <>
- {' '}
- e.stopPropagation()}
- >
- {localize('com_ui_read_more')}
-
- >
- )}
-
- )}
-
-
- {renderCollapsible(isExpanded, hasEverExpanded, children)}
-
-
+ {children}
+
);
}
diff --git a/src/components/configuration/ConfigSkeleton.tsx b/src/components/configuration/ConfigSkeleton.tsx
new file mode 100644
index 00000000..5e617b2a
--- /dev/null
+++ b/src/components/configuration/ConfigSkeleton.tsx
@@ -0,0 +1,53 @@
+import type * as t from '@/types';
+import { useLocalize } from '@/hooks';
+import { cn } from '@/utils';
+
+const BAR = 'rounded bg-(--cui-color-background-secondary)';
+const ROW_WIDTHS = ['w-40', 'w-32', 'w-48', 'w-36', 'w-44', 'w-28'];
+
+/** Placeholder rows for the tab content while the baseline loads; never editable. */
+export function ConfigContentSkeleton({ rows = ROW_WIDTHS.length }: t.ConfigContentSkeletonProps) {
+ const localize = useLocalize();
+ return (
+
+
{localize('com_config_shell_loading')}
+
+
+ {ROW_WIDTHS.slice(0, rows).map((width, i) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+/** Route-level placeholder: tab bar, toolbar and rows (shown while the route loader runs). */
+export function ConfigSkeleton() {
+ return (
+
+
+ {['w-24', 'w-32', 'w-24', 'w-12', 'w-20', 'w-28', 'w-16'].map((width, i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/configuration/ConfigTabBar.tsx b/src/components/configuration/ConfigTabBar.tsx
index 83886866..17bc7f6f 100644
--- a/src/components/configuration/ConfigTabBar.tsx
+++ b/src/components/configuration/ConfigTabBar.tsx
@@ -1,16 +1,58 @@
import { Tabs } from '@clickhouse/click-ui';
+import { useEffect, useRef, useState, useCallback } from 'react';
import type * as t from '@/types';
+import { TabCountBadge } from './values';
import { useLocalize } from '@/hooks';
import { cn } from '@/utils';
+const EDGE = 4;
+
+/**
+ * Configuration tabs in one horizontally scrolling row: labels never wrap,
+ * fading edges show there is more, and the active tab scrolls into view (SH-7).
+ */
export function ConfigTabBar({
tabs,
activeTab,
onTabChange,
- tabCounts,
+ tabBadges,
children,
}: t.ConfigTabBarProps) {
const localize = useLocalize();
+ const rowRef = useRef(null);
+ const [edges, setEdges] = useState({ start: false, end: false });
+
+ const updateEdges = useCallback(() => {
+ const row = rowRef.current;
+ if (!row) return;
+ const start = row.scrollLeft > EDGE;
+ const end = row.scrollLeft + row.clientWidth < row.scrollWidth - EDGE;
+ setEdges((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));
+ }, []);
+
+ useEffect(() => {
+ const row = rowRef.current;
+ if (!row) return;
+ updateEdges();
+ const observer = new ResizeObserver(updateEdges);
+ observer.observe(row);
+ return () => observer.disconnect();
+ }, [updateEdges]);
+
+ /** Again once badges load (in a profile they widen the tabs before the active one). */
+ useEffect(() => {
+ const row = rowRef.current;
+ const active = row?.querySelector('[role="tab"][data-state="active"]');
+ if (!row || !active) return;
+ const rowRect = row.getBoundingClientRect();
+ const tabRect = active.getBoundingClientRect();
+ if (tabRect.left < rowRect.left) {
+ row.scrollLeft -= rowRect.left - tabRect.left + 16;
+ } else if (tabRect.right > rowRect.right) {
+ row.scrollLeft += tabRect.right - rowRect.right + 16;
+ }
+ updateEdges();
+ }, [activeTab, tabs.length, tabBadges, updateEdges]);
return (
-
- {tabs.map((tab) => {
- const count = tabCounts?.[tab.id];
- return (
-
+
+
+ {tabs.map((tab) => (
+
{localize(tab.labelKey)}
- {count !== undefined && (
- 0 ? 'config-tab-count-active' : 'config-tab-count-zero',
- )}
- >
- {count}
-
- )}
+
- );
- })}
-
+ ))}
+
+
{children}
);
diff --git a/src/components/configuration/ConfigTabContent.tsx b/src/components/configuration/ConfigTabContent.tsx
index fa0fcd7e..49da3310 100644
--- a/src/components/configuration/ConfigTabContent.tsx
+++ b/src/components/configuration/ConfigTabContent.tsx
@@ -1,55 +1,100 @@
-import { useMemo } from 'react';
-import { MultiAccordion } from '@clickhouse/click-ui';
+import { Button } from '@clickhouse/click-ui';
+import { useMemo, useEffect, useContext } from 'react';
+import type { KeyboardEvent } from 'react';
import type * as t from '@/types';
-import { SECTION_RENDERERS, SELF_CONTAINED_SECTION_RENDERERS } from './sections';
-import { FieldRenderer, SingleFieldRenderer } from './FieldRenderer';
+import {
+ SECTION_RENDERERS,
+ READ_ONLY_DETAIL_KEYS,
+ SECTION_GROUP_RENDERERS,
+ UNFOLDED_READ_ONLY_SECTIONS,
+ SELF_CONTAINED_SECTION_RENDERERS,
+} from './sections';
+import {
+ countSection,
+ hasPendingValueIn,
+ countSectionOverrides,
+ countEntrySectionOverrides,
+} from './counts';
+import { useLocalize, useAnnouncement, useConfigContract } from '@/hooks';
+import { ConfigValuesProvider } from '@/contexts/ConfigValuesContext';
+import { RevealTargetContext } from '@/contexts/RevealTargetContext';
+import { FieldRenderer, SingleFieldRenderer } from './renderer';
+import { RowFilterContext } from '@/contexts/RowFilterContext';
+import { deriveFieldValue, isSectionDisabled } from '@/utils';
+import { ScreenReaderAnnouncer } from '@/components/shared';
+import { SECTION_META, isPathInSection } from './configMeta';
+import { ReadOnlySection } from './ReadOnlySection';
+import { UnknownSettings } from './UnknownSettings';
import { ConfigSection } from './ConfigSection';
import { CodeField } from './fields/CodeField';
-import { isSectionDisabled } from '@/utils';
import { InfoBanner } from './InfoBanner';
-import { hasDescendant } from './utils';
-import { useLocalize } from '@/hooks';
+import { ScopeGate } from './ScopeGate';
+import { getWriteRules } from '@/constants';
-/** Sections where the configured count should reflect record entries rather
- * than leaf schema paths. Add section IDs here for any record-type section
- * whose schema describes the value shape (not the entry keys). */
-const RECORD_ENTRY_COUNT_SECTIONS = new Set(['mcpServers']);
+/**
+ * Enter in an input of the tab form must never submit it (and so "click" its
+ * default button). Dialogs portaled from the tab (Create MCP server, Create
+ * endpoint) are left alone: their keydown bubbles here through the React
+ * tree, but they live outside the form's DOM and keep Enter-to-submit.
+ */
+function preventImplicitSubmit(event: KeyboardEvent) {
+ const { target } = event;
+ if (event.key !== 'Enter' || !(target instanceof HTMLInputElement)) return;
+ if (event.currentTarget.contains(target)) event.preventDefault();
+}
+/** One row's worth of field: a scalar or a list of scalars (no nested fields, records or objects). */
function isSimpleScalar(f: t.SchemaField): boolean {
- return !f.children?.length && !f.isArray && f.type !== 'record' && f.type !== 'object';
+ return !f.children?.length && f.type !== 'record' && f.type !== 'object';
}
-function countLeafFields(fields: t.SchemaField[], parentPath: string): string[] {
- const paths: string[] = [];
- for (const field of fields) {
- const path = `${parentPath}.${field.key}`;
- if (field.children && field.children.length > 0) {
- paths.push(...countLeafFields(field.children, path));
- } else {
- paths.push(path);
- }
- }
- return paths;
+/** Why the tab shows no settings (a filter, "Set values only"), also put in the tab's status region. */
+function EmptyTabState({ message, action, onShown }: t.EmptyTabStateProps) {
+ useEffect(() => {
+ onShown(message);
+ return () => onShown('');
+ }, [message, onShown]);
+ return (
+
+ {message}
+ {action}
+
+ );
+}
+
+/**
+ * The open tab's settings. Why it shows none (a filter that matches nothing,
+ * "Set values only") is a status message (WCAG 4.1.3) from a region that
+ * stays mounted while the settings come and go, so screen readers hear it
+ * arrive.
+ */
+export function ConfigTabContent(props: t.ConfigTabContentProps) {
+ const { message, announce } = useAnnouncement();
+ return (
+ <>
+
+
+ >
+ );
}
-export function ConfigTabContent({
+function TabSettings({
sections,
configValues,
editedValues,
onFieldChange,
onResetField,
onDiscardField,
- profileMap,
previewMode,
previewScope,
previewChangedPaths,
resolvedValues,
permissions,
- onProfileChange,
showChangedOnly,
readOnly,
configuredPaths,
dbOverridePaths,
+ listOverrideCounts,
touchedPaths,
pendingResets,
sectionPermissions,
@@ -59,21 +104,23 @@ export function ConfigTabContent({
baseRecordKeys,
onValidationError,
editSessionId,
-}: t.ConfigTabContentProps) {
+ unknownSettings,
+ valueLayers,
+ visibilityEdits,
+ onHideField,
+ onUnhideField,
+ onRenameEntry,
+ visibleSectionIds,
+ filterPaths,
+ filterText,
+ onClearFilter,
+ onShowAll,
+ onEmptyState,
+}: t.TabSettingsProps) {
const localize = useLocalize();
+ const revealTarget = useContext(RevealTargetContext);
const fieldsDisabled = readOnly;
- const getValue = (path: string, fallback: t.ConfigValue): t.ConfigValue => {
- if (path in editedValues) {
- if (editedValues[path] === undefined) return schemaDefaults?.[path] ?? fallback;
- return editedValues[path];
- }
- if (resolvedValues && path in resolvedValues) {
- return resolvedValues[path];
- }
- return fallback;
- };
-
const filtering = showChangedOnly;
const sectionHasChanges = useMemo(() => {
@@ -88,42 +135,78 @@ export function ConfigTabContent({
return result;
}, [filtering, previewChangedPaths, sections]);
+ /**
+ * Counted over the fields each section shows (custom endpoints and providers
+ * split `endpoints`). In a profile a section's count already is what it
+ * sets, so only collections add the entries it overrides or hides.
+ */
const sectionCounts = useMemo(() => {
- return sections.map((s) => {
- const key = s.schemaKey ?? s.id;
-
- // Record-type sections (e.g. mcpServers): count entries in the record
- // value rather than leaf schema paths, since the schema describes the
- // *value* shape while configured paths include dynamic entry keys
- // (mcpServers.cloudflare.type, not mcpServers.type).
- if (RECORD_ENTRY_COUNT_SECTIONS.has(s.id) && configValues) {
- const sectionValue = configValues[key];
- if (sectionValue && typeof sectionValue === 'object' && !Array.isArray(sectionValue)) {
- const count = Object.keys(sectionValue as Record).length;
- return { id: s.id, total: count, configured: count };
- }
+ const overridesOf = (section: t.ConfigSectionConfig): number => {
+ if (isEditingScope && configuredPaths) {
+ return countEntrySectionOverrides(section, configuredPaths, listOverrideCounts) ?? 0;
}
-
- const leafPaths = s.sectionField ? [key] : countLeafFields(s.fields, key);
- if (!configuredPaths || configuredPaths.size === 0) {
- return { id: s.id, total: leafPaths.length, configured: 0 };
- }
- let configured = 0;
- for (const p of leafPaths) {
- if (configuredPaths.has(p) || hasDescendant(p, configuredPaths)) configured++;
- }
- return { id: s.id, total: leafPaths.length, configured };
+ if (!dbOverridePaths) return 0;
+ return countSectionOverrides(section, dbOverridePaths, listOverrideCounts);
+ };
+ return sections.map((s) => {
+ const dataKey = s.schemaKey ?? s.id;
+ const fields = s.fields.filter((f) => isPathInSection(s.id, dataKey, `${dataKey}.${f.key}`));
+ const section = { ...s, fields };
+ return {
+ id: s.id,
+ ...countSection(section, configuredPaths, configValues),
+ overrides: overridesOf(section),
+ };
});
- }, [sections, configuredPaths, configValues]);
+ }, [
+ sections,
+ configuredPaths,
+ configValues,
+ isEditingScope,
+ dbOverridePaths,
+ listOverrideCounts,
+ ]);
const countsById = useMemo(() => {
- const map: Record = {};
- for (const c of sectionCounts) {
- map[c.id] = { total: c.total, configured: c.configured };
- }
+ const map: Record = {};
+ for (const { id, ...count } of sectionCounts) map[id] = count;
return map;
}, [sectionCounts]);
+ const contract = useConfigContract();
+ const valuesData = useMemo(
+ () => ({
+ writeRules: getWriteRules(contract),
+ titleResolution: contract.rules.titleResolution,
+ configValues,
+ editedValues,
+ resolvedValues,
+ schemaDefaults,
+ configuredPaths,
+ overridePaths: dbOverridePaths,
+ pendingResets,
+ isEditingScope,
+ layers: valueLayers,
+ visibilityEdits,
+ }),
+ [
+ configValues,
+ editedValues,
+ resolvedValues,
+ schemaDefaults,
+ configuredPaths,
+ dbOverridePaths,
+ pendingResets,
+ isEditingScope,
+ valueLayers,
+ visibilityEdits,
+ contract,
+ ],
+ );
+
+ const getValue = (path: string, fallback: t.ConfigValue): t.ConfigValue =>
+ deriveFieldValue(path, fallback, valuesData);
+
if (sections.length === 0) {
return (
@@ -140,45 +223,83 @@ export function ConfigTabContent({
filtering && sectionHasChanges ? sections.filter((s) => sectionHasChanges[s.id]) : sections;
if (showConfiguredOnly && !previewMode) {
- visibleSections = visibleSections.filter((s) => {
- const counts = countsById[s.id];
- return counts && counts.configured > 0;
- });
+ visibleSections = visibleSections.filter(
+ (s) => (countsById[s.id]?.configured ?? 0) > 0 || hasPendingValueIn(s, editedValues),
+ );
}
- if ((filtering || showConfiguredOnly) && visibleSections.length === 0) {
+ if (visibleSectionIds) {
+ visibleSections = visibleSections.filter((s) => visibleSectionIds.has(s.id));
+ }
+
+ if (visibleSections.length === 0 && filtering) {
return (
-
-
- {localize(filtering ? 'com_scope_no_changed_in_tab' : 'com_config_no_fields')}
-
-
+
);
}
- const renderSectionContent = (section: t.ConfigSectionConfig) => {
+ if (visibleSections.length === 0 && visibleSectionIds) {
+ return (
+
+ )
+ }
+ />
+ );
+ }
+
+ if (visibleSections.length === 0 && showConfiguredOnly) {
+ return (
+
+ )
+ }
+ />
+ );
+ }
+
+ const isSectionReadOnly = (section: t.ConfigSectionConfig): boolean =>
+ section.scopeGate != null ||
+ section.readOnlyReason != null ||
+ isSectionDisabled(!!fieldsDisabled, sectionPermissions, section.schemaKey ?? section.id);
+
+ const buildFieldRendererProps = (section: t.ConfigSectionConfig): t.FieldRendererProps => {
const dataKey = section.schemaKey ?? section.id;
- const sectionValue = configValues?.[dataKey];
- const sectionDisabled = isSectionDisabled(!!fieldsDisabled, sectionPermissions, dataKey);
- const CustomSectionRenderer =
- section.fields.length > 0 ? SECTION_RENDERERS[section.id] : undefined;
- const fieldRendererProps: t.FieldRendererProps = {
+ const sectionDisabled = isSectionReadOnly(section);
+ return {
fields: section.fields,
- parentValue: sectionValue,
+ parentValue: configValues?.[dataKey],
parentPath: dataKey,
getValue,
onChange: onFieldChange,
onResetField,
onDiscardField,
+ onHideField: sectionDisabled ? undefined : onHideField,
editedValues,
disabled: sectionDisabled,
- profileMap,
previewMode,
previewScope,
previewChangedPaths,
resolvedValues,
permissions,
- onProfileChange,
showChangedOnly,
configuredPaths,
dbOverridePaths,
@@ -190,12 +311,28 @@ export function ConfigTabContent({
yamlBaseKeys: baseRecordKeys?.[dataKey],
onValidationError,
editSessionId,
+ onUnhideField: sectionDisabled ? undefined : onUnhideField,
+ onRenameEntry,
};
- return (
+ };
+
+ const renderSectionContent = (section: t.ConfigSectionConfig) => {
+ const dataKey = section.schemaKey ?? section.id;
+ const sectionValue = configValues?.[dataKey];
+ const sectionDisabled = isSectionReadOnly(section);
+ const CustomSectionRenderer =
+ section.fields.length > 0 ? SECTION_RENDERERS[section.id] : undefined;
+ const fieldRendererProps = buildFieldRendererProps(section);
+ const content = (
<>
- {section.bannerText && (
+ {section.bannerText && !section.readOnlyReason && !section.scopeGate && (
-
+
)}
{section.fields.length > 0 &&
@@ -204,6 +341,12 @@ export function ConfigTabContent({
) : (
))}
+ {section.fields.length > 0 &&
+ !CustomSectionRenderer &&
+ !section.isRecord &&
+ !filterPaths && (
+
+ )}
{section.fields.length === 0 && section.sectionField && (
);
+ if (section.scopeGate) {
+ return (
+
+ {content}
+
+ );
+ }
+ if (!section.readOnlyReason) return content;
+ /** A single-field section is its row: nothing to fold. */
+ const isEmpty =
+ countsById[section.id]?.configured === 0 &&
+ !UNFOLDED_READ_ONLY_SECTIONS.has(section.id) &&
+ !isInlineSection(section);
+ const detailKey = READ_ONLY_DETAIL_KEYS[section.id];
+ const reveal =
+ revealTarget != null && (revealTarget === dataKey || revealTarget.startsWith(`${dataKey}.`));
+ return (
+
+ {content}
+
+ );
};
const isInlineSection = (section: t.ConfigSectionConfig): boolean =>
@@ -249,9 +421,21 @@ export function ConfigTabContent({
type SectionGroup =
| { kind: 'inline'; section: t.ConfigSectionConfig }
| { kind: 'accordion'; sections: t.ConfigSectionConfig[] }
- | { kind: 'flat'; section: t.ConfigSectionConfig };
+ | { kind: 'flat'; section: t.ConfigSectionConfig }
+ | { kind: 'group'; key: string; sections: t.ConfigSectionConfig[] };
const groups = visibleSections.reduce
((acc, section) => {
+ /** Sections sharing a `SectionMeta.group` with a group renderer render as one card (e.g. Storage). */
+ const groupKey = SECTION_META[section.id]?.group;
+ if (groupKey && SECTION_GROUP_RENDERERS[groupKey]) {
+ const existing = acc.find(
+ (g): g is Extract =>
+ g.kind === 'group' && g.key === groupKey,
+ );
+ if (existing) existing.sections.push(section);
+ else acc.push({ kind: 'group', key: groupKey, sections: [section] });
+ return acc;
+ }
if (SELF_CONTAINED_SECTION_RENDERERS.has(section.id)) {
acc.push({ kind: 'flat', section });
return acc;
@@ -270,63 +454,93 @@ export function ConfigTabContent({
}, []);
return (
-
+
+
+
+
+
);
}
diff --git a/src/components/configuration/ConfigTableOfContents.tsx b/src/components/configuration/ConfigTableOfContents.tsx
index 23813366..32542577 100644
--- a/src/components/configuration/ConfigTableOfContents.tsx
+++ b/src/components/configuration/ConfigTableOfContents.tsx
@@ -1,235 +1,118 @@
-import { memo, useCallback, useRef } from 'react';
+import { memo, useCallback, useEffect, useRef } from 'react';
import type * as t from '@/types';
-import { controlSortKey } from './utils';
+import { revealConfigPath } from './reveal';
import { useLocalize } from '@/hooks';
+import { entryHasPath } from './toc';
+import { cn } from '@/utils';
-const EXPAND_SETTLE_MS = 280;
+const ITEM =
+ 'toc-item block w-full min-w-0 cursor-pointer truncate border-none bg-transparent text-left text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)';
-function getStickyHeaderOffset(el: HTMLElement, container: HTMLElement): number {
- let current: HTMLElement | null = el.parentElement;
- while (current && current !== container) {
- if (current.tagName === 'SECTION' && current.id?.startsWith('section-')) {
- const header = current.querySelector(':scope > [role="button"], :scope > button');
- if (header) {
- const style = window.getComputedStyle(header);
- if (style.position === 'sticky') return header.getBoundingClientRect().height;
- }
- }
- current = current.parentElement;
- }
- return 0;
+function Markers({ dirty, error }: { dirty: boolean; error: boolean }) {
+ const localize = useLocalize();
+ if (!dirty && !error) return null;
+ return (
+
+ {error && (
+
+ {localize('com_config_shell_toc_error')}
+
+ )}
+ {dirty && (
+
+ {localize('com_config_shell_toc_dirty')}
+
+ )}
+
+ );
}
+/**
+ * "On this page" navigation (SH-4): sections, with their entries or groups
+ * shown for the active section only; dirty and error markers; every item has
+ * a title; clicking reveals the target (expanding what leads to it).
+ */
export const ConfigTableOfContents = memo(function ConfigTableOfContents({
- sections,
+ entries,
scrollContainer,
tocRef,
showConfiguredOnly,
configuredPaths,
onNavigate,
+ dirtyPaths,
+ errorPaths,
}: t.ConfigTableOfContentsProps) {
const localize = useLocalize();
- const navRef = useRef(null);
- const scrollTimerRef = useRef | undefined>(undefined);
+ const cancelRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => () => cancelRef.current?.(), []);
const handleClick = useCallback(
- (id: string) => {
+ (entry: t.TocEntry, isSection: boolean) => {
if (!scrollContainer) return;
- onNavigate?.(id);
-
- clearTimeout(scrollTimerRef.current);
-
- const scrollToEl = (el: HTMLElement) => {
- const containerRect = scrollContainer.getBoundingClientRect();
- const elRect = el.getBoundingClientRect();
- const stickyOffset = getStickyHeaderOffset(el, scrollContainer);
- const top = scrollContainer.scrollTop + elRect.top - containerRect.top - stickyOffset;
- scrollContainer.scrollTo({ top, behavior: 'smooth' });
- };
-
- const expandAncestors = (targetId: string): boolean => {
- let needsExpand = false;
- const parts = targetId.split('.');
- for (let i = 1; i <= parts.length; i++) {
- const ancestorId = parts.slice(0, i).join('.');
- const ancestor = document.getElementById(ancestorId);
- if (ancestor) {
- const grid = ancestor.querySelector('.config-section-grid-closed');
- if (grid) {
- ancestor.dispatchEvent(new Event('config:expand'));
- needsExpand = true;
- }
- }
- }
- return needsExpand;
- };
-
- // For entry-level TOC items (e.g. section-mcpServers-cloudflare), expand
- // the parent ConfigSection if it's collapsed. The parent section's DOM id
- // is the section prefix portion of the item id (e.g. section-mcpServers).
- const expandParentSection = (targetId: string): boolean => {
- // Walk up candidate prefixes to find a collapsed parent section.
- // TOC item ids are formatted as `section-{sectionId}-{entryKey}`.
- const sectionPrefix = 'section-';
- if (!targetId.startsWith(sectionPrefix)) return false;
- const rest = targetId.slice(sectionPrefix.length);
- const hyphenIdx = rest.indexOf('-');
- if (hyphenIdx === -1) return false;
- const parentId = sectionPrefix + rest.slice(0, hyphenIdx);
- const parent = document.getElementById(parentId);
- if (!parent) return false;
- const grid = parent.querySelector(':scope > .config-section-grid-closed');
- if (!grid) return false;
- parent.dispatchEvent(new Event('config:expand'));
- return true;
- };
-
- const parentExpanded = expandParentSection(id);
- const existing = document.getElementById(id);
- const needsExpand = expandAncestors(id);
-
- // Expand collapsed entry cards (ObjectEntryCard) — they use their own
- // expand state rather than config:expand events on section elements.
- const expandCard = (el: HTMLElement): boolean => {
- const grid = el.querySelector(':scope > .config-section-grid-closed');
- if (!grid) return false;
- const header = el.querySelector(':scope > [role="button"]');
- if (header) header.click();
- return true;
- };
-
- // Expand collapsed Radix accordion items (MultiAccordion.Item).
- const expandAccordionItem = (el: HTMLElement): boolean => {
- if (el.getAttribute('data-state') !== 'closed') return false;
- const trigger = el.querySelector(':scope > button[data-state="closed"]');
- if (trigger) {
- trigger.click();
- return true;
- }
- return false;
- };
-
- const cardExpanded = existing
- ? expandCard(existing) || expandAccordionItem(existing)
- : false;
- const needsSettle = parentExpanded || needsExpand || cardExpanded;
-
- if (needsSettle) {
- scrollTimerRef.current = setTimeout(() => {
- expandAncestors(id);
- const el = document.getElementById(id) ?? existing;
- if (el) {
- expandCard(el);
- expandAccordionItem(el);
- }
- setTimeout(() => {
- const target = document.getElementById(id) ?? existing;
- if (target) scrollToEl(target);
- }, 50);
- }, EXPAND_SETTLE_MS);
- } else if (existing) {
- scrollToEl(existing);
- }
+ onNavigate?.(entry.id);
+ cancelRef.current?.();
+ cancelRef.current = revealConfigPath(
+ scrollContainer,
+ { path: entry.path, sectionId: isSection ? undefined : entry.sectionId },
+ { align: 'start', focus: false, highlight: false },
+ );
},
[scrollContainer, onNavigate],
);
+ /** Configured-only keeps what is set, and what a pending edit sets, reachable. */
+ const isShown = (entry: t.TocEntry, isSection: boolean) =>
+ !showConfiguredOnly ||
+ !configuredPaths ||
+ entryHasPath(entry, configuredPaths, isSection) ||
+ (!!dirtyPaths?.length && entryHasPath(entry, dirtyPaths, isSection, false));
+
+ const renderItem = (entry: t.TocEntry, isSection: boolean) => (
+ handleClick(entry, isSection)}
+ className={cn(
+ ITEM,
+ isSection
+ ? 'toc-item-parent py-1.5 pr-1 pl-3 text-[13px] font-medium'
+ : 'toc-item-child py-1 pr-1 pl-6 text-xs',
+ )}
+ >
+ {entry.label}
+
+
+ );
+
return (
{
- navRef.current = el;
- tocRef(el);
- }}
+ ref={tocRef}
aria-label={localize('com_config_on_this_page')}
className="hidden w-48 max-w-48 min-w-48 shrink-0 grow-0 overflow-hidden xl:block"
>
-
+
- {sections.map((section) => {
- if (showConfiguredOnly && configuredPaths) {
- const dataKey = section.schemaKey ?? section.id;
- const prefix = `${dataKey}.`;
- const hasConfigured = [...configuredPaths].some(
- (p) => p === dataKey || p.startsWith(prefix),
- );
- if (!hasConfigured) return null;
- }
- const sectionDomId = `section-${section.id}`;
-
- // Use explicit tocItems when provided, filtering by configured state.
- let tocItems = section.tocItems;
- if (tocItems && showConfiguredOnly && configuredPaths) {
- tocItems = tocItems.filter((item) => {
- const dataPath = item.dataPath ?? item.id.replace(/^section-/, '');
- const prefix = `${dataPath}.`;
- return [...configuredPaths].some((p) => p === dataPath || p.startsWith(prefix));
- });
- }
- const children = tocItems
- ? null
- : section.fields
- .filter(
- (f) =>
- (f.children && f.children.length > 0 && !f.isArray && f.type !== 'record') ||
- (f.isArray && f.children && f.children.length > 0) ||
- (f.type === 'record' && f.recordValueType === 'complex'),
- )
- .sort((a, b) => controlSortKey(a) - controlSortKey(b));
-
+ {entries.map((entry) => {
+ if (!isShown(entry, true)) return null;
+ const children = entry.children.filter((child) => isShown(child, false));
return (
-
- handleClick(sectionDomId)}
- className="toc-item toc-item-parent block w-full cursor-pointer truncate border-none bg-transparent py-1.5 pr-1 pl-3 text-left text-[13px] font-medium text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
- >
- {localize(section.titleKey)}
-
- {tocItems && tocItems.length > 0 && (
-
- {tocItems.map((item) => (
-
- handleClick(item.id)}
- className="toc-item toc-item-child block w-full cursor-pointer truncate border-none bg-transparent py-1 pr-1 pl-6 text-left text-xs text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
- title={item.label}
- >
- {item.label}
-
-
+
+ {renderItem(entry, true)}
+ {children.length > 0 && (
+
+ {children.map((child) => (
+ {renderItem(child, false)}
))}
)}
- {children && children.length > 0 && (
-
- {children.map((child) => {
- if (showConfiguredOnly && configuredPaths) {
- const childPrefix = `${section.id}.${child.key}.`;
- const childPath = `${section.id}.${child.key}`;
- const childHasConfigured = [...configuredPaths].some(
- (p) => p === childPath || p.startsWith(childPrefix),
- );
- if (!childHasConfigured) return null;
- }
- const childDomId = `section-${section.id}.${child.key}`;
- return (
-
- handleClick(childDomId)}
- className="toc-item toc-item-child block w-full cursor-pointer truncate border-none bg-transparent py-1 pr-1 pl-6 text-left text-xs text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
- >
- {localize(`com_config_field_${child.key}`)}
-
-
- );
- })}
-
- )}
);
})}
diff --git a/src/components/configuration/ConfirmSaveDialog.tsx b/src/components/configuration/ConfirmSaveDialog.tsx
deleted file mode 100644
index 6946b968..00000000
--- a/src/components/configuration/ConfirmSaveDialog.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import yaml from 'js-yaml';
-import { Badge, Button, Dialog } from '@clickhouse/click-ui';
-import type { ReactNode } from 'react';
-import type * as t from '@/types';
-import { useLocalize } from '@/hooks';
-
-export function ConfirmSaveDialog({
- open,
- editedValues,
- originalValues,
- saving,
- error,
- onConfirm,
- onCancel,
-}: t.ConfirmSaveDialogProps) {
- const localize = useLocalize();
- const entries = Object.entries(editedValues).sort(([a], [b]) => a.localeCompare(b));
- const count = entries.length;
- const countLabel =
- count === 1
- ? localize('com_config_field_change_count', { count })
- : localize('com_config_field_change_count_plural', { count });
-
- return (
- {
- if (!isOpen) onCancel();
- }}
- >
-
-
-
{countLabel}
-
-
- {entries.map(([path, newValue]) => {
- const oldValue = originalValues?.[path];
- return ;
- })}
-
-
- {error &&
{error}
}
-
-
-
-
-
-
-
-
- );
-}
-
-function resolvePathLabel(path: string, newValue: t.ConfigValue, oldValue: t.ConfigValue): string {
- const match = /^(.+)\.(\d+)$/.exec(path);
- if (!match) return path;
- const val = (newValue ?? oldValue) as Record | undefined;
- const name = val && typeof val === 'object' && !Array.isArray(val)
- ? (val as Record).name
- : undefined;
- return name ? `${match[1]}[${match[2]}] (${name})` : `${match[1]}[${match[2]}]`;
-}
-
-function ChangeCard({
- path,
- oldValue,
- newValue,
-}: {
- path: string;
- oldValue: t.ConfigValue;
- newValue: t.ConfigValue;
-}) {
- const localize = useLocalize();
- const notSet = localize('com_config_field_not_set');
- const isRemoval = newValue === undefined || newValue === null;
- const isAddition = oldValue === undefined || oldValue === null;
- const displayPath = resolvePathLabel(path, newValue, oldValue);
-
- return (
-
-
-
- {displayPath}
-
- {isAddition && (
-
- )}
- {isRemoval && (
-
- )}
-
-
-
- {!isAddition && (
-
-
- {localize('com_config_field_before')}
-
-
-
-
-
- )}
- {!isRemoval && (
-
-
- {localize('com_config_field_after')}
-
-
-
-
-
- )}
-
-
- );
-}
-
-function isPrimitive(v: t.ConfigValue): v is string | number | boolean {
- return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
-}
-
-function ValueDisplay({
- value,
- notSet,
- variant,
-}: {
- value: t.ConfigValue;
- notSet: string;
- variant: 'old' | 'new';
-}) {
- const formatted = formatValue(value, notSet);
- const isBlock = typeof formatted === 'string' && formatted.includes('\n');
-
- if (isBlock) {
- return (
-
- {formatted}
-
- );
- }
-
- return (
-
- {formatted}
-
- );
-}
-
-function formatValue(value: t.ConfigValue, notSetLabel: string): ReactNode {
- if (value === undefined || value === null) return notSetLabel;
- if (typeof value === 'boolean') return value ? 'true' : 'false';
- if (typeof value === 'number' || typeof value === 'string') return String(value);
-
- if (Array.isArray(value)) {
- if (value.length === 0) return '[]';
- if (value.length <= 5 && value.every(isPrimitive)) {
- return value.map(String).join(', ');
- }
- return yaml.dump(value, { lineWidth: -1 }).trimEnd();
- }
-
- if (typeof value === 'object') {
- if (Object.keys(value).length === 0) return '{}';
- return yaml.dump(value, { lineWidth: -1 }).trimEnd();
- }
-
- try {
- return yaml.dump(value, { lineWidth: -1 }).trimEnd();
- } catch {
- return String(value);
- }
-}
diff --git a/src/components/configuration/ContentToolbar.tsx b/src/components/configuration/ContentToolbar.tsx
index dd32e456..1de88723 100644
--- a/src/components/configuration/ContentToolbar.tsx
+++ b/src/components/configuration/ContentToolbar.tsx
@@ -1,120 +1,154 @@
-import { Icon } from '@clickhouse/click-ui';
-import { useState, useCallback } from 'react';
+import { useId, useState, useCallback } from 'react';
+import { Button, Checkbox } from '@clickhouse/click-ui';
import type * as t from '@/types';
-import { useLocalize } from '@/hooks';
+import { useLocalize, useExpandRequest } from '@/hooks';
+import { DecorativeIcon } from '@/components/shared';
+import { ToolbarFilter } from './ToolbarFilter';
+import { JumpToSelect } from './JumpToSelect';
+import { cn } from '@/utils';
-const BTN =
- 'flex cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1.5 py-0.5 text-xs text-(--cui-color-text-muted) transition-colors hover:bg-(--cui-color-background-hover) hover:text-(--cui-color-text-default)';
-
-function clickAccordionTrigger(item: Element) {
- const trigger = item.querySelector(':scope > button');
- if (trigger) {
- trigger.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
- }
-}
-
-function expandTopLevelAccordions(container: HTMLElement) {
- const roots = container.querySelectorAll('[data-top-level-accordion]');
- for (const root of roots) {
- for (const item of root.querySelectorAll(':scope > [data-state="closed"]')) {
- clickAccordionTrigger(item);
- }
- }
-}
-
-function collapseTopLevelAccordions(container: HTMLElement) {
- const roots = container.querySelectorAll('[data-top-level-accordion]');
- for (const root of roots) {
- for (const item of root.querySelectorAll(':scope > [data-state="open"]')) {
- clickAccordionTrigger(item);
- }
- }
+/**
+ * Opens or closes Radix accordions that other renderers own (e.g. the AI
+ * providers list): they keep their own state, so their triggers are clicked.
+ */
+function toggleUnmanagedAccordions(container: HTMLElement | null, expand: boolean) {
+ if (!container) return;
+ const state = expand ? 'closed' : 'open';
+ const item = `[data-top-level-accordion]:not([data-expand-managed]) > [data-state="${state}"]`;
+ const triggers = container.querySelectorAll(
+ `${item} > button, ${item} > h3 > button`,
+ );
+ for (const trigger of triggers) trigger.click();
}
+/**
+ * The tab's toolbar, in normal flow under the tab bar: filter, "Set values
+ * only", jump list (below xl, where the table of contents is hidden), and
+ * Expand all / Collapse all for every section, group and entry card. Below
+ * `md` the filter, checkbox and jump list fold behind one Filter button, so
+ * the toolbar is a single row and the settings keep the screen.
+ */
export function ContentToolbar({
scrollContainer,
- showConfiguredOnly,
- onShowConfiguredOnlyChange,
- showConfiguredToggle,
+ filter,
+ onFilterChange,
+ filterHits,
+ onFilterSelect,
+ customizedOnly,
+ onCustomizedOnlyChange,
+ nothingCustomized,
+ jumpTargets,
+ onJump,
}: t.ContentToolbarProps) {
const localize = useLocalize();
- const [collapsed, setCollapsed] = useState(false);
+ const requestExpand = useExpandRequest();
+ const filtersId = useId();
+ const [filtersOpen, setFiltersOpen] = useState(() => filter !== '' || customizedOnly);
+ const filtering = filter !== '' || customizedOnly;
const handleExpandAll = useCallback(() => {
- if (!scrollContainer) return;
- expandTopLevelAccordions(scrollContainer);
- }, [scrollContainer]);
+ requestExpand('expand');
+ toggleUnmanagedAccordions(scrollContainer, true);
+ }, [requestExpand, scrollContainer]);
const handleCollapseAll = useCallback(() => {
- if (!scrollContainer) return;
- collapseTopLevelAccordions(scrollContainer);
- scrollContainer.scrollTo({ top: 0, behavior: 'smooth' });
- }, [scrollContainer]);
+ requestExpand('collapse');
+ toggleUnmanagedAccordions(scrollContainer, false);
+ scrollContainer?.scrollTo({ top: 0 });
+ }, [requestExpand, scrollContainer]);
+
+ const customizedDisabled = nothingCustomized && !customizedOnly;
return (
-
- {collapsed ? (
+
+
setCollapsed(false)}
- title={localize('com_config_show_toolbar')}
- className="flex cursor-pointer items-center border-none bg-transparent px-2.5 py-1.5 text-(--cui-color-text-muted) transition-colors hover:text-(--cui-color-text-default)"
+ aria-expanded={filtersOpen}
+ aria-controls={filtersId}
+ onClick={() => setFiltersOpen((open) => !open)}
+ className="config-add-btn ml-0"
>
-
-
- ) : (
-
-
-
- {localize('com_config_expand_all')}
-
-
/
-
-
- {localize('com_config_collapse_all')}
-
- {showConfiguredToggle && (
- <>
-
-
{
- if (e.key === 'Enter') onShowConfiguredOnlyChange(!showConfiguredOnly);
- }}
- >
- onShowConfiguredOnlyChange(e.target.checked)}
- className="accent-(--cui-color-accent) focus-visible:outline-none"
- />
-
- {localize('com_config_show_configured_only')}
-
-
- >
+
+
{localize('com_config_shell_filters')}
+ {filtering && (
+
+ {localize('com_config_shell_filters_active')}
+
)}
-
-
setCollapsed(true)}
- title={localize('com_config_minimize')}
- className={BTN}
- >
-
-
+
+
+
+
+
+ onCustomizedOnlyChange(checked === true)}
+ />
- )}
+ {jumpTargets && jumpTargets.length > 0 && onJump && (
+
+ )}
+
+
+
+
+
+
+
+
+ {localize('com_config_expand_all')}
+
+
+
+ {localize('com_config_collapse_all')}
+
+
);
}
diff --git a/src/components/configuration/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx
deleted file mode 100644
index f67ce196..00000000
--- a/src/components/configuration/DeleteProfileValueModal.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Icon, Button, Dialog } from '@clickhouse/click-ui';
-import type * as t from '@/types';
-import { getScopeTypeConfig } from '@/constants';
-import { useLocalize } from '@/hooks';
-
-export function DeleteProfileValueModal({
- scope,
- fieldLabel,
- saving,
- onConfirm,
- onCancel,
-}: t.DeleteProfileValueModalProps) {
- const localize = useLocalize();
- const scopeConfig = scope ? getScopeTypeConfig(scope.principalType) : null;
-
- return (
- {
- if (!isOpen) onCancel();
- }}
- >
-
- {scope && (
-
-
- {scopeConfig && (
-
-
-
- )}
- {fieldLabel}
- {scope.name}
-
-
-
- onConfirm(scope)}
- disabled={saving}
- />
-
-
- )}
-
-
- );
-}
diff --git a/src/components/configuration/FieldProfilePopover.tsx b/src/components/configuration/FieldProfilePopover.tsx
index f495d964..e167182e 100644
--- a/src/components/configuration/FieldProfilePopover.tsx
+++ b/src/components/configuration/FieldProfilePopover.tsx
@@ -1,316 +1,114 @@
-import { Icon } from '@clickhouse/click-ui';
import { useQuery } from '@tanstack/react-query';
-import { useCallback, useMemo, useState } from 'react';
+import { Badge, Button, Icon } from '@clickhouse/click-ui';
import type * as t from '@/types';
-import { ProfileValueModal, getDefaultValue } from './ProfileValueModal';
-import { DeleteProfileValueModal } from './DeleteProfileValueModal';
-import { EditButton, TrashButton } from '@/components/shared';
-import { useProfileMutations, useLocalize } from '@/hooks';
-import { availableScopesOptions } from '@/server';
+import { formatScopeName, maskSecretValues } from '@/utils';
+import { useFieldState, useLocalize } from '@/hooks';
+import { fieldProfileValuesOptions } from '@/server';
import { getScopeTypeConfig } from '@/constants';
-import { serializeKVPairs, cn } from '@/utils';
-import { getControlType } from './utils';
-export function FieldProfilePopover({
- fieldPath,
- fieldLabel,
- fieldSchema,
- profileValues,
- permissions,
- onProfileChange,
- baseValue,
- onBaseValueChange,
-}: t.FieldProfilePopoverProps) {
- const localize = useLocalize();
- const [adding, setAdding] = useState(false);
- const [selectedAddScope, setSelectedAddScope] = useState(null);
- const [deleteScope, setDeleteScope] = useState(null);
-
- const { data: allScopes = [] } = useQuery(availableScopesOptions);
-
- const availableScopes = useMemo(() => {
- const existingKeys = new Set(
- profileValues.map((o) => `${o.scope.principalType}:${o.scope.principalId}`),
- );
- return allScopes.filter((s) => !existingKeys.has(`${s.principalType}:${s.principalId}`));
- }, [allScopes, profileValues]);
-
- const [modalOpen, setModalOpen] = useState(false);
- const [modalValue, setModalValue] = useState('');
- const [modalScope, setModalScope] = useState(null);
- const [modalMode, setModalMode] = useState<'edit' | 'add'>('edit');
- const [modalIsBase, setModalIsBase] = useState(false);
-
- const controlType = fieldSchema ? getControlType(fieldSchema) : 'text';
-
- const canAssign = permissions.canAssign ?? permissions.canEdit;
-
- const { saveMutation, removeMutation, saving } = useProfileMutations({
- fieldPath,
- onProfileChange,
- });
-
- const openEditModal = useCallback((scope: t.ConfigScope, currentValue: t.ConfigValue) => {
- setModalIsBase(false);
- setModalScope(scope);
- setModalValue(currentValue);
- setModalMode('edit');
- setModalOpen(true);
- }, []);
+function principalKey(scope: t.ConfigScope): string {
+ return `${scope.principalType}:${scope.principalId}`;
+}
- const openAddModal = useCallback(
- (scope: t.ConfigScope) => {
- setModalIsBase(false);
- setModalScope(scope);
- setModalValue(getDefaultValue(controlType, fieldSchema));
- setModalMode('add');
- setModalOpen(true);
- },
- [controlType, fieldSchema],
+function isHiddenAt(scope: t.ConfigScope, path: string): boolean {
+ return (scope.tombstones ?? []).some(
+ (hidden) => path === hidden || path.startsWith(`${hidden}.`),
);
+}
- const handleModalSave = useCallback(() => {
- if (modalIsBase && onBaseValueChange) {
- onBaseValueChange(serializeKVPairs(modalValue));
- setModalOpen(false);
- setModalIsBase(false);
- return;
- }
- if (!modalScope) return;
- saveMutation.mutate(
- {
- principalType: modalScope.principalType,
- principalId: modalScope.principalId,
- value: serializeKVPairs(modalValue),
- },
- {
- onSuccess: () => {
- setModalOpen(false);
- setModalScope(null);
- if (modalMode === 'add') {
- setAdding(false);
- setSelectedAddScope(null);
- }
- },
- },
- );
- }, [modalIsBase, modalScope, modalValue, modalMode, saveMutation, onBaseValueChange]);
-
- const handleModalCancel = useCallback(() => {
- setModalOpen(false);
- setModalScope(null);
- setModalIsBase(false);
- }, []);
-
- const handleRemove = useCallback(
- (scope: t.ConfigScope) => {
- removeMutation.mutate({
- principalType: scope.principalType,
- principalId: scope.principalId,
- });
- },
- [removeMutation],
- );
+function formatValue(value: t.ConfigValue | undefined, key: string): string {
+ const masked = maskSecretValues(value, key);
+ if (masked === undefined || masked === null || masked === '') return '—';
+ if (typeof masked === 'string') return masked;
+ return JSON.stringify(masked);
+}
- const handleStartAdd = useCallback(() => {
- setAdding(true);
- }, []);
+/**
+ * Read-only cascade of one field: the Base value, then each profile that
+ * overrides it, lowest priority first, each with a way to open that profile
+ * (WS8: PR-8). Edits go through the profile itself, so they are reviewed and
+ * saved like any other change.
+ */
+export function FieldProfilePopover({
+ fieldPath,
+ profiles,
+ onOpenProfile,
+}: t.FieldCascadeListProps) {
+ const localize = useLocalize();
+ const base = useFieldState(fieldPath);
+ const key = fieldPath.slice(fieldPath.lastIndexOf('.') + 1);
+ const { data: values, isLoading } = useQuery(fieldProfileValuesOptions(fieldPath));
+ const valueByScope = new Map((values ?? []).map((v) => [principalKey(v.scope), v.value]));
+ const baseConfig = getScopeTypeConfig('BASE');
+ const describeProfileValue = (
+ hidden: boolean,
+ loading: boolean,
+ value: t.ConfigValue | undefined,
+ ): string => {
+ if (hidden) return localize('com_scope_cascade_hidden');
+ if (loading) return localize('com_ui_loading');
+ return formatValue(value, key);
+ };
return (
- <>
-
-
-
-
-
- {permissions.canEdit && onBaseValueChange && (
-
- {
- setModalIsBase(true);
- setModalScope(null);
- setModalValue(baseValue);
- setModalMode('edit');
- setModalOpen(true);
- }}
- ariaLabel={`${localize('com_scope_edit_profile_value')}: ${localize('com_scope_base_config')}`}
- disabled={saving}
- />
+
+
{localize('com_scope_cascade_help')}
+
+
+
+
+
+
+
+ {localize('com_scope_base_config')}
- )}
-
-
- {profileValues.length === 0 && !adding && (
-
- {localize('com_scope_no_field_profiles')}
-
- )}
-
- {profileValues.map((pv) => {
- const config = getScopeTypeConfig(pv.scope.principalType);
- const scopeKey = `${pv.scope.principalType}:${pv.scope.principalId}`;
-
+
+ {formatValue(base.effective, key)}
+
+
+
+ {profiles.map((scope) => {
+ const config = getScopeTypeConfig(scope.principalType);
+ const hidden = isHiddenAt(scope, fieldPath);
+ const value = describeProfileValue(
+ hidden,
+ isLoading,
+ valueByScope.get(principalKey(scope)),
+ );
return (
-
-
-
-
- {canAssign && (
-
- openEditModal(pv.scope, pv.value)}
- ariaLabel={`${localize('com_scope_edit_profile_value')}: ${pv.scope.name}`}
- disabled={saving}
- />
- setDeleteScope(pv.scope)}
- ariaLabel={`${localize('com_scope_remove_profile_value')}: ${pv.scope.name}`}
- disabled={saving}
- />
+
+
+
+
+
+
+
+ {formatScopeName(scope)}
+
+
+ {localize('com_scope_priority', { priority: scope.priority })}
+
+ {!scope.isActive && (
+
+ )}
- )}
-
+
+ {value}
+
+
+
onOpenProfile(scope)}
+ />
+
);
})}
-
- {adding && (
-
-
- {localize('com_scope_select')}
-
- {availableScopes.length === 0 ? (
-
- {localize('com_scope_no_results')}
-
- ) : (
-
- {availableScopes.map((scope) => {
- const config = getScopeTypeConfig(scope.principalType);
- const isSelected =
- selectedAddScope?.principalType === scope.principalType &&
- selectedAddScope?.principalId === scope.principalId;
- return (
- {
- setSelectedAddScope(scope);
- openAddModal(scope);
- }}
- className={cn(
- 'flex items-center gap-2 rounded-(--cui-radii-sm) px-2 py-1.5 text-left text-xs transition-colors',
- isSelected
- ? 'bg-(--cui-color-background-active)'
- : 'hover:bg-(--cui-color-background-hover)',
- )}
- >
-
-
-
- {scope.name}
-
- );
- })}
-
- )}
-
- )}
- {!adding && canAssign && (
-
-
-
-
-
- {localize('com_scope_add_profile_value')}
-
-
- )}
-
-
-
-
-
{
- handleRemove(scope);
- setDeleteScope(null);
- }}
- onCancel={() => setDeleteScope(null)}
- />
- >
- );
-}
-
-/* ---------- Helpers ---------- */
-
-function CascadeItem({ label, icon, color, sublabel }: t.CascadeItemProps) {
- return (
-
-
-
-
-
-
- {label}
-
-
- {sublabel}
-
-
+
);
}
-
-function formatProfileValue(value: t.ConfigValue): string {
- if (value === undefined || value === null) return '—';
- if (typeof value === 'boolean') return value ? 'true' : 'false';
- if (typeof value === 'number') return String(value);
- if (typeof value === 'string') return value || '—';
- if (Array.isArray(value)) {
- if (value.length === 0) return '[]';
- return value
- .map((v) => (typeof v === 'string' || typeof v === 'number' ? String(v) : JSON.stringify(v)))
- .join(', ');
- }
- if (typeof value === 'object') {
- const entries = Object.entries(value);
- if (entries.length === 0) return '{}';
- return entries
- .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`)
- .join(', ');
- }
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
-}
diff --git a/src/components/configuration/FieldRenderer.tsx b/src/components/configuration/FieldRenderer.tsx
deleted file mode 100644
index bc1e6d0b..00000000
--- a/src/components/configuration/FieldRenderer.tsx
+++ /dev/null
@@ -1,1424 +0,0 @@
-import { Icon } from '@clickhouse/click-ui';
-import { useState, useRef, useEffect, useCallback } from 'react';
-import type { ReactNode } from 'react';
-import type * as t from '@/types';
-import {
- coerceEnumValue,
- controlSortKey,
- countConfigured,
- getArrayItemType,
- getControlType,
- getEnumOptions,
- hasDescendant,
- toKVPair,
- isStringLikeItemType,
- splitUnionTypes,
-} from './utils';
-import { useCollapsibleSection } from './useCollapsibleSection';
-import { SwitchObjectField } from './fields/SwitchObjectField';
-import { RecordObjectField } from './fields/RecordObjectField';
-import { ArrayObjectField } from './fields/ArrayObjectField';
-import { TextRecordField } from './fields/TextRecordField';
-import { ListRecordField } from './fields/ListRecordField';
-import { renderCollapsible } from './renderCollapsible';
-import { TextareaField } from './fields/TextareaField';
-import { KeyValueField } from './fields/KeyValueField';
-import { NumberField } from './fields/NumberField';
-import { SecretField } from './fields/SecretField';
-import { ToggleField } from './fields/ToggleField';
-import { SelectField } from './fields/SelectField';
-import { TextField } from './fields/TextField';
-import { ListField } from './fields/ListField';
-import { CodeField } from './fields/CodeField';
-import { ConfigRow } from './ConfigRow';
-import { useLocalize } from '@/hooks';
-import { cn, getSecretPreviewValue } from '@/utils';
-
-function formatDefault(value: t.ConfigValue): string | null {
- if (value === undefined || value === null) return null;
- if (typeof value === 'boolean') return value ? 'on' : 'off';
- if (typeof value === 'number') return String(value);
- if (typeof value === 'string') return value.length > 40 ? `${value.slice(0, 37)}…` : value;
- return null;
-}
-
-/** Wrapper that wires a NestedGroup header "+ Add entry" button to
- * ArrayObjectField's add handler via a ref. */
-function ArrayObjectNestedGroup({
- fieldId,
- fieldLabel,
- path,
- currentValue,
- field,
- onChange,
- disabled,
- isSoleField,
- editSessionId,
-}: {
- fieldId: string;
- fieldLabel: string;
- path: string;
- currentValue: t.ConfigValue;
- field: t.SchemaField;
- onChange: (path: string, value: t.ConfigValue) => void;
- disabled?: boolean;
- isSoleField?: boolean;
- editSessionId?: number;
-}) {
- const items = Array.isArray(currentValue) ? currentValue : [];
- const addTriggerRef = useRef<(() => void) | null>(null);
- const handleAdd = disabled ? undefined : () => addTriggerRef.current?.();
-
- const handleEntryChange = useCallback(
- (index: number, value: t.ConfigValue) => onChange(`${path}.${index}`, value),
- [onChange, path],
- );
-
- const arrayField = (
- onChange(path, v)}
- onEntryChange={handleEntryChange}
- disabled={disabled}
- hideAddButton
- addTriggerRef={addTriggerRef}
- renderFields={renderCollectionEntryFields}
- editSessionId={editSessionId}
- />
- );
- if (isSoleField) return arrayField;
- return (
-
- {arrayField}
-
- );
-}
-
-/** Wrapper that wires a NestedGroup header "+ Add entry" button to
- * RecordObjectField's add-key input via a ref. */
-function RecordObjectNestedGroup({
- fieldId,
- fieldLabel,
- path,
- currentValue,
- field,
- entries,
- onChange,
- disabled,
- isSoleField,
- editSessionId,
-}: {
- fieldId: string;
- fieldLabel: string;
- path: string;
- currentValue: t.ConfigValue;
- field: t.SchemaField;
- entries: string[];
- onChange: (path: string, value: t.ConfigValue) => void;
- disabled?: boolean;
- isSoleField?: boolean;
- editSessionId?: number;
-}) {
- const addTriggerRef = useRef<(() => void) | null>(null);
- const handleAdd = disabled ? undefined : () => addTriggerRef.current?.();
-
- const recordField = (
- onChange(path, v)}
- disabled={disabled}
- allowPrimitiveValues={field.recordValueAllowsPrimitive}
- addTriggerRef={addTriggerRef}
- renderFields={renderCollectionEntryFields}
- editSessionId={editSessionId}
- />
- );
- if (isSoleField) return recordField;
- return (
-
- {recordField}
-
- );
-}
-
-export function SingleFieldRenderer({
- field,
- value,
- path,
- getValue,
- onChange,
- onResetField,
- onDiscardField,
- editedValues,
- disabled,
- permissions,
- onProfileChange,
- previewMode,
- previewScope,
- previewChangedPaths,
- resolvedValues,
- configuredPaths,
- dbOverridePaths,
- touchedPaths,
- pendingResets,
- schemaDefaults,
- showConfiguredOnly,
- isSoleField,
- secretPreviewValue,
- editSessionId,
-}: t.SingleFieldRendererProps) {
- const localize = useLocalize();
- const controlType = getControlType(field);
- const description = field.description || undefined;
- const currentValue = getValue(path, value);
- const fieldId = path.replace(/\./g, '-');
- const fieldLabel = localize(`com_config_field_${field.key}`);
- const isConfigured = configuredPaths?.has(path);
- const isDbOverride = dbOverridePaths?.has(path);
- const isTouched = touchedPaths?.has(path);
- const isPendingReset = pendingResets?.has(path) ?? false;
- const hasPendingEdit = editedValues ? path in editedValues : false;
- const defaultHint = schemaDefaults ? formatDefault(schemaDefaults[path]) : null;
-
- if (showConfiguredOnly && !isConfigured && !hasDescendant(path, configuredPaths)) return null;
-
- const rowProps = {
- fieldPath: path,
- permissions,
- onProfileChange,
- onResetField,
- previewMode,
- previewScope,
- previewChangedPaths,
- resolvedValues,
- isConfigured,
- isDbOverride,
- isTouched,
- isPendingReset,
- defaultHint,
- };
-
- if (controlType === 'toggle') {
- const control = disabled ? (
-
- ) : (
- onChange(path, checked)}
- aria-label={fieldLabel}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- if (controlType === 'select') {
- const options = getEnumOptions(field.type);
- const control = (
- onChange(path, coerceEnumValue(v))}
- disabled={disabled}
- aria-label={fieldLabel}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- if (controlType === 'number') {
- const control = (
- onChange(path, v)}
- disabled={disabled}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- if (controlType === 'array-object') {
- return (
-
- );
- }
-
- if (controlType === 'record-object') {
- const entries =
- currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue)
- ? Object.keys(currentValue as Record)
- : [];
- return (
-
- );
- }
-
- if (controlType === 'array') {
- const arrayValue = Array.isArray(currentValue) ? currentValue : [];
- const itemType = getArrayItemType(field.type);
-
- if (isStringLikeItemType(itemType)) {
- return (
-
- onChange(path, v)}
- itemLabel={localize(`com_config_field_${field.key}_item`)}
- disabled={disabled}
- options={itemType.startsWith('enum(') ? getEnumOptions(itemType) : undefined}
- aria-label={fieldLabel}
- />
-
- );
- }
-
- return (
-
- onChange(path, v)}
- disabled={disabled}
- />
-
- );
- }
-
- if (controlType === 'text') {
- const stringValue = typeof currentValue === 'string' ? currentValue : '';
- const baselineEmpty = typeof value !== 'string' || value === '';
- const maskedSecret =
- secretPreviewValue != null && baselineEmpty && !isPendingReset ? secretPreviewValue : null;
-
- if (maskedSecret != null) {
- const control = (
- onChange(path, v)}
- onCancel={() => onDiscardField?.(path)}
- hasPendingEdit={hasPendingEdit}
- disabled={disabled}
- aria-label={fieldLabel}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- const isMultiline = stringValue.includes('\n') || field.key.toLowerCase().includes('content');
-
- if (isMultiline) {
- const control = (
- onChange(path, v)}
- rows={5}
- disabled={disabled}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- const control = (
- onChange(path, v)}
- type="text"
- disabled={disabled}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- if (controlType === 'record') {
- const isEditedPairs = Array.isArray(currentValue);
- const pairs: t.KeyValuePair[] = isEditedPairs
- ? (currentValue as t.KeyValuePair[])
- : Object.entries(
- typeof currentValue === 'object' && currentValue !== null
- ? (currentValue as Record)
- : {},
- ).map(([k, v]) => toKVPair(k, v));
-
- return (
-
- onChange(path, newPairs)}
- disabled={disabled}
- valueTypes={field.recordValueKVTypes}
- aria-label={fieldLabel}
- />
-
- );
- }
-
- if (controlType === 'switch-object') {
- const isObj =
- typeof currentValue === 'object' && currentValue !== null && !Array.isArray(currentValue);
- const isEnabled = isObj || currentValue === true;
- const subFields = field.children ?? [];
- const control = (
- onChange(path, v)}
- disabled={disabled}
- aria-label={fieldLabel}
- >
- {isEnabled && subFields.length > 0 && (
-
- )}
-
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- if (controlType === 'text-record') {
- const types = splitUnionTypes(field.type);
- const variant = types.some((u) => u.startsWith('array')) ? 'array' : 'record';
- const control = (
- onChange(path, v)}
- disabled={disabled}
- variant={variant as 'record' | 'array'}
- aria-label={fieldLabel}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- if (controlType === 'list-record') {
- const control = (
- onChange(path, v)}
- disabled={disabled}
- aria-label={fieldLabel}
- />
- );
- if (isSoleField) return control;
- return (
-
- {control}
-
- );
- }
-
- return (
-
- onChange(path, v)}
- disabled={disabled}
- />
-
- );
-}
-
-function BooleanChip({ value }: { value: boolean }) {
- const localize = useLocalize();
- return (
-
- {localize(value ? 'com_ui_true' : 'com_ui_false')}
-
- );
-}
-
-export function NestedGroup({
- label,
- sectionId,
- configuredCount = 0,
- totalCount = 0,
- depth = 0,
- onAdd,
- addLabel,
- disabled,
- children,
-}: {
- label: string;
- sectionId?: string;
- configuredCount?: number;
- totalCount?: number;
- depth?: number;
- /** When provided, renders a "+ Add" button inline in the header. */
- onAdd?: () => void;
- addLabel?: string;
- /**
- * When true, the caret/collapse affordance is dropped and the group renders
- * flat with a static heading. Used in fully read-only sections where the
- * expand/collapse interaction would be misleading.
- */
- disabled?: boolean;
- children: ReactNode;
-}) {
- const localize = useLocalize();
- const hasConfigured = configuredCount > 0;
- const indent = depth > 0 ? `${depth}rem` : undefined;
-
- const { isExpanded, hasEverExpanded, sectionRef, toggle, handleAddClick } = useCollapsibleSection(
- { defaultExpanded: hasConfigured, onAdd },
- );
-
- if (disabled) {
- return (
- 0 ? 'mt-3' : 'mt-4', 'flex flex-col')}
- style={indent ? { paddingLeft: indent } : undefined}
- >
-
- {label}
- {totalCount > 0 && (
-
- {configuredCount}/{totalCount}
-
- )}
-
- {children}
-
- );
- }
-
- return (
- 0 ? 'mt-3' : 'mt-4', 'flex flex-col')}
- style={indent ? { paddingLeft: indent } : undefined}
- >
-
-
-
-
-
- {label}
- {totalCount > 0 && (
-
- {configuredCount}/{totalCount}
-
- )}
-
- {onAdd && (
-
-
-
- {addLabel ?? localize('com_ui_add_item', { item: localize('com_ui_entry') })}
-
-
- )}
-
- {renderCollapsible(isExpanded, hasEverExpanded, children)}
-
- );
-}
-
-function CollectionRow({
- title,
- description,
- fieldId,
- children,
-}: {
- title: string;
- description?: string;
- fieldId: string;
- children: ReactNode;
-}) {
- return (
-
-
-
- {title}
-
- {description &&
{description}
}
-
- {children}
-
- );
-}
-
-export function renderCollectionEntryFields(
- fields: t.SchemaField[],
- parentValue: t.ConfigValue,
- parentPath: string,
- onChange: (path: string, value: t.ConfigValue) => void,
- addFieldTriggerRef?: React.MutableRefObject<(() => void) | null>,
- editSessionId?: number,
-): ReactNode {
- return (
-
- );
-}
-
-/**
- * Lightweight field renderer for collection entry cards.
- * Renders fields without the full config-page context (no profiles, no preview, no configured tracking).
- * Changes propagate up through the collection's onChange, which stores the entire collection value.
- */
-function hasValue(v: t.ConfigValue): boolean {
- if (v === undefined || v === null) return false;
- if (typeof v === 'string') return v !== '';
- if (typeof v === 'boolean' || typeof v === 'number') return true;
- if (Array.isArray(v)) return v.length > 0;
- if (typeof v === 'object') return Object.keys(v).length > 0;
- return false;
-}
-
-function InlineFieldRenderer({
- fields,
- parentValue,
- parentPath,
- onChange,
- disabled,
- addFieldTriggerRef,
- onHiddenFieldsChange,
- editSessionId,
-}: {
- fields: t.SchemaField[];
- parentValue: t.ConfigValue;
- parentPath: string;
- onChange: (path: string, value: t.ConfigValue) => void;
- disabled?: boolean;
- addFieldTriggerRef?: React.MutableRefObject<(() => void) | null>;
- onHiddenFieldsChange?: (hasHidden: boolean) => void;
- editSessionId?: number;
-}) {
- const localize = useLocalize();
- const [addedKeys, setAddedKeys] = useState>(() => new Set());
- const [showDropdown, setShowDropdown] = useState(false);
- const values =
- typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue)
- ? (parentValue as Record)
- : {};
-
- // Derive the parent collection key from the schema field paths when available.
- // For array-object entries (e.g. endpoints.custom[*]), the first field's path
- // is like "endpoints.custom.name" — extract "custom" as the collection key.
- // Falls back to the last segment of parentPath.
- const parentKey = parentPath.split('.').pop() ?? '';
- const schemaParentKey = fields[0]?.path ? fields[0].path.split('.').slice(-2, -1)[0] : undefined;
- const lookupKey =
- schemaParentKey &&
- (PROGRESSIVE_DISCLOSURE_PATHS.has(schemaParentKey) || FIELD_ORDER[schemaParentKey])
- ? schemaParentKey
- : parentKey;
- const useProgressiveDisclosure = PROGRESSIVE_DISCLOSURE_PATHS.has(lookupKey);
- const order = FIELD_ORDER[lookupKey];
-
- // Apply field ordering: pinned fields first (in order), then remaining in schema order
- const orderedFields = order
- ? [
- ...order
- .map((k) => fields.find((f) => f.key === k))
- .filter((f): f is t.SchemaField => f != null),
- ...fields.filter((f) => !order.includes(f.key)),
- ]
- : fields;
-
- // Pinned fields are always visible even under progressive disclosure
- const pinnedKeys = order ? new Set(order) : null;
- const visibleFields = useProgressiveDisclosure
- ? orderedFields.filter(
- (f) => pinnedKeys?.has(f.key) || hasValue(values[f.key]) || addedKeys.has(f.key),
- )
- : orderedFields;
- const hiddenFields = useProgressiveDisclosure
- ? orderedFields.filter(
- (f) => !pinnedKeys?.has(f.key) && !hasValue(values[f.key]) && !addedKeys.has(f.key),
- )
- : [];
-
- const handleAddField = (key: string) => {
- setAddedKeys((prev) => new Set(prev).add(key));
- setShowDropdown(false);
- // Focus the newly visible field after render
- requestAnimationFrame(() => {
- const fieldId = `${parentPath}-${key}`.replace(/\./g, '-');
- const el = document.getElementById(fieldId);
- if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
- const focusable = el.querySelector('input, select, textarea, [role="switch"]');
- if (focusable) setTimeout(() => focusable.focus(), 150);
- }
- });
- };
-
- // Register the add-field trigger with parent ObjectEntryCard
- useEffect(() => {
- if (addFieldTriggerRef && hiddenFields.length > 0) {
- addFieldTriggerRef.current = () => setShowDropdown(true);
- return () => {
- addFieldTriggerRef.current = null;
- };
- } else if (addFieldTriggerRef) {
- addFieldTriggerRef.current = null;
- }
- }, [addFieldTriggerRef, hiddenFields.length]);
-
- // Notify parent about hidden fields availability (for NestedGroup header button)
- useEffect(() => {
- onHiddenFieldsChange?.(hiddenFields.length > 0);
- }, [hiddenFields.length, onHiddenFieldsChange]);
-
- return (
-
- {visibleFields.map((field) =>
- renderInlineField(
- field,
- parentValue,
- parentPath,
- onChange,
- localize,
- disabled,
- undefined,
- undefined,
- editSessionId,
- ),
- )}
- {!disabled && hiddenFields.length > 0 && showDropdown && (
-
setShowDropdown(false)}
- />
- )}
- {!disabled && hiddenFields.length > 0 && !showDropdown && !addFieldTriggerRef && (
- setShowDropdown(true)}
- className="config-add-btn self-start"
- >
-
- {localize('com_config_add_field')}
-
- )}
-
- );
-}
-
-/** Wraps a NestedGroup + InlineFieldRenderer, wiring the add-field trigger to the group header. */
-function NestedGroupWithAddField({
- label,
- fields,
- parentValue,
- parentPath,
- onChange,
- disabled,
- editSessionId,
-}: {
- label: string;
- fields: t.SchemaField[];
- parentValue: t.ConfigValue;
- parentPath: string;
- onChange: (path: string, value: t.ConfigValue) => void;
- disabled?: boolean;
- editSessionId?: number;
-}) {
- const addFieldRef = useRef<(() => void) | null>(null);
- const localize = useLocalize();
-
- // Static initial value: compute from schema + values so the button shows
- // before children mount (NestedGroup defers rendering when collapsed).
- const parentKey = parentPath.split('.').pop() ?? '';
- const usesPD = PROGRESSIVE_DISCLOSURE_PATHS.has(parentKey);
- const pinnedOrder = FIELD_ORDER[parentKey];
- const pinnedSet = pinnedOrder ? new Set(pinnedOrder) : null;
- const values =
- typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue)
- ? (parentValue as Record)
- : {};
- const initialHasHideable =
- usesPD && fields.some((f) => !pinnedSet?.has(f.key) && !hasValue(values[f.key]));
-
- // Reactive: child InlineFieldRenderer reports actual hidden field count
- // (accounts for addedKeys state that the static check can't see).
- const [hasHideable, setHasHideable] = useState(initialHasHideable);
-
- return (
- addFieldRef.current?.() : undefined}
- addLabel={localize('com_config_add_field')}
- disabled={disabled}
- >
-
-
- );
-}
-
-function AddFieldDropdown({
- fields,
- onAdd,
- onCancel,
-}: {
- fields: t.SchemaField[];
- onAdd: (key: string) => void;
- onCancel?: () => void;
-}) {
- const localize = useLocalize();
-
- return (
-
- {
- if (e.target.value) onAdd(e.target.value);
- }}
- onBlur={() => onCancel?.()}
- >
-
- {localize('com_config_select_field')}
-
- {fields.map((f) => (
-
- {localize(`com_config_field_${f.key}`)}
-
- ))}
-
-
- );
-}
-
-/**
- * Renders a single schema field as an inline label + control row.
- * Exported so custom collection renderers (e.g. grouped endpoint fields)
- * can render individual fields without going through InlineFieldRenderer's
- * progressive disclosure / ordering logic.
- */
-export function renderInlineField(
- field: t.SchemaField,
- parentValue: t.ConfigValue,
- parentPath: string,
- onChange: (path: string, value: t.ConfigValue) => void,
- localize: (key: string, interpolation?: Record) => string,
- disabled?: boolean,
- /** Optional per-field-key overrides for array-object / record-object renderFields. */
- collectionRenderOverrides?: Record,
- /** When true, non-optional fields show a required indicator (*). */
- showRequired?: boolean,
- /** See `SingleFieldRendererProps.editSessionId`. */
- editSessionId?: number,
-): ReactNode {
- const values =
- typeof parentValue === 'object' && parentValue !== null && !Array.isArray(parentValue)
- ? (parentValue as Record)
- : {};
- const fieldValue = values[field.key];
- const controlType = getControlType(field);
- const fieldId = `${parentPath}-${field.key}`.replace(/\./g, '-');
- const fieldLabel = localize(`com_config_field_${field.key}`);
- const required = showRequired && !field.isOptional;
-
- if (field.children && field.children.length > 0 && !field.isArray && field.type !== 'record') {
- return (
- {
- const segments = p.split('.');
- const leafKey = segments[segments.length - 1];
- const current =
- typeof fieldValue === 'object' && fieldValue !== null && !Array.isArray(fieldValue)
- ? (fieldValue as Record)
- : {};
- onChange(field.key, { ...current, [leafKey]: v });
- }}
- disabled={disabled}
- editSessionId={editSessionId}
- />
- );
- }
-
- if (controlType === 'toggle') {
- return (
-
- onChange(field.key, checked)}
- disabled={disabled}
- aria-label={fieldLabel}
- />
-
- );
- }
-
- if (controlType === 'select') {
- const options = getEnumOptions(field.type);
- return (
-
- onChange(field.key, coerceEnumValue(v))}
- disabled={disabled}
- aria-label={fieldLabel}
- />
-
- );
- }
-
- if (controlType === 'number') {
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- />
-
- );
- }
-
- if (controlType === 'text') {
- // A defined string (including '') means this field has a queued edit for
- // this entry; an absent/undefined value means it still matches baseline.
- // Distinguishing on the raw value (not just whether it's empty) keeps a
- // queued empty replacement visible across a remount, matching the
- // top-level SingleFieldRenderer fix.
- const hasPendingEdit = typeof fieldValue === 'string';
- const stringValue = hasPendingEdit ? fieldValue : '';
- const secretPreviewValue = getSecretPreviewValue(values, field.key);
- const maskedSecret =
- secretPreviewValue != null && stringValue === '' ? secretPreviewValue : null;
-
- if (maskedSecret != null) {
- return (
-
- onChange(field.key, v)}
- onCancel={() => {
- if (hasPendingEdit) onChange(field.key, undefined);
- }}
- hasPendingEdit={hasPendingEdit}
- disabled={disabled}
- aria-label={fieldLabel}
- />
-
- );
- }
-
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- />
-
- );
- }
-
- if (controlType === 'array') {
- const arrayValue = Array.isArray(fieldValue) ? fieldValue : [];
- const itemType = getArrayItemType(field.type);
- if (isStringLikeItemType(itemType)) {
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- options={itemType.startsWith('enum(') ? getEnumOptions(itemType) : undefined}
- aria-label={fieldLabel}
- />
-
- );
- }
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- />
-
- );
- }
-
- if (controlType === 'array-object') {
- const renderFn = collectionRenderOverrides?.[field.key] ?? renderCollectionEntryFields;
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- renderFields={renderFn}
- editSessionId={editSessionId}
- />
-
- );
- }
-
- if (controlType === 'record-object') {
- const renderFn = collectionRenderOverrides?.[field.key] ?? renderCollectionEntryFields;
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- allowPrimitiveValues={field.recordValueAllowsPrimitive}
- renderFields={renderFn}
- editSessionId={editSessionId}
- />
-
- );
- }
-
- if (controlType === 'record') {
- const pairs: t.KeyValuePair[] = Array.isArray(fieldValue)
- ? (fieldValue as t.KeyValuePair[])
- : Object.entries(
- typeof fieldValue === 'object' && fieldValue !== null
- ? (fieldValue as Record)
- : {},
- ).map(([k, v]) => toKVPair(k, v));
- return (
-
- onChange(field.key, p)}
- disabled={disabled}
- valueTypes={field.recordValueKVTypes}
- aria-label={fieldLabel}
- />
-
- );
- }
-
- return (
-
- onChange(field.key, v)}
- disabled={disabled}
- />
-
- );
-}
-
-function InlineRow({
- label,
- fieldId,
- required,
- children,
-}: {
- label: string;
- fieldId: string;
- required?: boolean;
- children: ReactNode;
-}) {
- return (
-
-
- {label}
- {required && * }
-
-
{children}
-
- );
-}
-
-/** Parent keys whose InlineFieldRenderer hides empty fields behind an "Add field"
- * dropdown. Add new keys here when an object has too many optional fields to
- * show by default (e.g. modelSpecs.list[].preset has 20+ fields). */
-const PROGRESSIVE_DISCLOSURE_PATHS = new Set(['preset', 'custom']);
-
-/** Optional field ordering for InlineFieldRenderer. Fields listed here are
- * placed first (in the given order) and are always visible even when
- * progressive disclosure is active. Unlisted fields follow in schema order
- * and may be hidden behind "Add field" if progressive disclosure applies. */
-const FIELD_ORDER: Record = {
- custom: ['name', 'apiKey', 'baseURL', 'iconURL', 'models', 'modelDisplayLabel'],
-};
-
-export function FieldRenderer({
- fields,
- parentValue,
- parentPath,
- getValue,
- onChange,
- onResetField,
- onDiscardField,
- editedValues,
- disabled,
- profileMap,
- previewMode,
- previewScope,
- previewChangedPaths,
- resolvedValues,
- permissions,
- onProfileChange,
- showChangedOnly,
- configuredPaths,
- dbOverridePaths,
- touchedPaths,
- pendingResets,
- schemaDefaults,
- showConfiguredOnly,
- editSessionId,
-}: t.FieldRendererProps) {
- const localize = useLocalize();
- const values =
- typeof parentValue === 'object' && parentValue !== null
- ? (parentValue as Record)
- : {};
-
- const filtering = showChangedOnly && previewMode && previewChangedPaths;
- const isPathChanged = (p: string) =>
- previewChangedPaths?.some((op) => op === p || op.startsWith(`${p}.`)) ?? false;
-
- const sorted = [...fields].sort((a, b) => controlSortKey(a) - controlSortKey(b));
-
- const groups: Array<
- | { type: 'single'; field: t.SchemaField; value: t.ConfigValue; path: string }
- | { type: 'nested'; field: t.SchemaField; value: t.ConfigValue; path: string }
- > = [];
-
- for (const field of sorted) {
- const value = values[field.key];
- const path = `${parentPath}.${field.key}`;
-
- if (filtering && !isPathChanged(path)) continue;
-
- if (showConfiguredOnly && !previewMode) {
- const isLeafConfigured = configuredPaths?.has(path);
- if (!isLeafConfigured && !hasDescendant(path, configuredPaths)) continue;
- }
-
- if (field.children && field.children.length > 0 && !field.isArray && field.type !== 'record') {
- groups.push({ type: 'nested', field, value, path });
- } else {
- groups.push({ type: 'single', field, value, path });
- }
- }
-
- return (
- <>
- {groups.map((group) => {
- if (group.type === 'nested') {
- const nestedValue =
- typeof group.value === 'object' && group.value !== null ? group.value : {};
- const nestedLabel = localize(`com_config_field_${group.field.key}`);
- const nestedCounts = countConfigured(group.field.children!, group.path, configuredPaths);
- return (
-
-
-
- );
- }
-
- return (
-
- );
- })}
- >
- );
-}
diff --git a/src/components/configuration/HeaderActions.tsx b/src/components/configuration/HeaderActions.tsx
new file mode 100644
index 00000000..c3ddb41c
--- /dev/null
+++ b/src/components/configuration/HeaderActions.tsx
@@ -0,0 +1,139 @@
+import { createPortal } from 'react-dom';
+import { useState, useEffect } from 'react';
+import { Icon } from '@clickhouse/click-ui';
+import type * as t from '@/types';
+import { ScopeTriggerButton } from './ScopeSelector';
+import { KebabMenu } from '@/components/shared';
+import { useLocalize } from '@/hooks';
+
+/** Unavailable actions stay focusable (`aria-disabled`), so their reason (the title) can be read and hovered. */
+const ACTION_BUTTON =
+ 'flex shrink-0 cursor-pointer items-center gap-1.5 rounded-lg border border-(--cui-color-stroke-default) bg-transparent px-3 py-1.5 text-sm text-(--cui-color-text-default) transition-colors hover:bg-(--cui-color-background-hover) aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent';
+
+/**
+ * Preview / Import / Reset / scope actions, portaled into the page header
+ * (WS5). Below `md` Preview, Import and Reset move into a menu and the scope
+ * label truncates, so nothing overlaps the title at phone width (SH-7).
+ */
+export function HeaderActions({
+ showImport,
+ importDisabled,
+ importTitle,
+ onImportClick,
+ showReset,
+ resetDisabled,
+ resetTitle,
+ onResetClick,
+ resetLabel,
+ showPreview = false,
+ onPreviewClick,
+ showScope,
+ scopeSelection,
+ scopeLoading,
+ scopeUnavailable,
+ onScopeClick,
+}: t.HeaderActionsProps) {
+ const localize = useLocalize();
+ const [portalTarget, setPortalTarget] = useState(null);
+
+ useEffect(() => {
+ setPortalTarget(document.getElementById('header-actions-portal'));
+ }, []);
+
+ const menuItems: t.KebabMenuItem[] = [
+ ...(showPreview && onPreviewClick
+ ? [{ label: localize('com_config_preview_button'), onClick: onPreviewClick }]
+ : []),
+ ...(showImport
+ ? [
+ {
+ label: localize('com_config_import_yaml'),
+ disabled: importDisabled,
+ title: importTitle,
+ onClick: onImportClick,
+ },
+ ]
+ : []),
+ ...(showReset
+ ? [
+ {
+ label: resetLabel ?? localize('com_config_reset_base'),
+ danger: true,
+ disabled: resetDisabled,
+ title: resetTitle,
+ onClick: onResetClick,
+ },
+ ]
+ : []),
+ ];
+
+ const content = (
+ <>
+ {showPreview && onPreviewClick && (
+
+
+
+
+
+ {localize('com_config_preview_button')}
+
+
+ )}
+ {showImport && (
+
+
+
+
+
+ {localize('com_config_import_yaml')}
+
+
+ )}
+ {showReset && (
+
+
+
+
+
+ {resetLabel ?? localize('com_config_reset_base')}
+
+
+ )}
+ {menuItems.length > 0 && (
+
+
+
+ )}
+ {showScope && (
+
+
+
+ )}
+ >
+ );
+
+ if (portalTarget) return createPortal(content, portalTarget);
+ return null;
+}
diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx
index 0cdd5ae9..562b080a 100644
--- a/src/components/configuration/ImportYamlDialog.tsx
+++ b/src/components/configuration/ImportYamlDialog.tsx
@@ -1,195 +1,233 @@
-import { useQuery } from '@tanstack/react-query';
import { PrincipalType } from 'librechat-data-provider';
-import { useState, useRef, useCallback, useEffect } from 'react';
-import { Icon, Button, Dialog, Tabs } from '@clickhouse/click-ui';
+import { Link, Button, Dialog } from '@clickhouse/click-ui';
+import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import type * as t from '@/types';
-import { availableScopesOptions, createGroupFn, createRoleFn, parseImportedYaml } from '@/server';
-import { getScopeTypeConfig } from '@/constants';
-import { useLocalize } from '@/hooks';
-import { cn } from '@/utils';
-
+import { useLocalize, useReturnFocus, useImportPreview, importTargetLabel } from '@/hooks';
+import { errorText, firstControl, termsRefusalUrl, restoreFocusWhenShown } from '@/utils';
+import { Source, Targets, Preview, SyntaxFrame } from './wizard';
+import { parseImportedYaml } from '@/server';
+
+const STEPS: t.ImportStep[] = ['input', 'target', 'preview'];
+const STEP_KEYS: Readonly> = {
+ input: 'com_config_review_import_step_source',
+ target: 'com_config_review_import_step_target',
+ preview: 'com_config_review_import_step_preview',
+};
+
+/**
+ * The YAML import wizard (WS7, RV-6): paste or upload, choose any role,
+ * group or user (or Base) as the target, preview exactly the YAML's keys
+ * against what the target has now, and apply the chosen ones through the
+ * normal validated save. Imports merge; librechat.yaml is never modified.
+ */
export function ImportYamlDialog({
open,
onClose,
+ currentScope,
+ schemaTree,
+ schemaPathSet,
onImport,
- onImportAsProfile,
}: t.ImportYamlDialogProps) {
const localize = useLocalize();
- const fileInputRef = useRef(null);
- const targetRef = useRef(null);
-
+ const returnFocus = useReturnFocus(open);
+ const [step, setStep] = useState('input');
const [activeTab, setActiveTab] = useState('upload');
const [yamlText, setYamlText] = useState('');
const [fileName, setFileName] = useState();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
+ const [termsUrl, setTermsUrl] = useState(null);
+ const [errorMark, setErrorMark] = useState<{ line: number; column: number }>();
const [validationErrors, setValidationErrors] = useState();
-
- const [step, setStep] = useState('input');
- const [parsedConfig, setParsedConfig] = useState | null>(null);
- const [targetMode, setTargetMode] = useState