Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
741 changes: 741 additions & 0 deletions .claude/skills/click-ui.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ jobs:
run: bun run test
env:
NODE_ENV: development
SESSION_SECRET: ci-test-secret-do-not-use-in-production
144 changes: 139 additions & 5 deletions bun.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import importPlugin from 'eslint-plugin-import-x';
import tailwindCanonicalClasses from 'eslint-plugin-tailwind-canonical-classes';
import clickUiPlugin from 'eslint-plugin-click-ui';

export default [
{
Expand All @@ -16,6 +17,7 @@ export default [
'@typescript-eslint': tsPlugin,
'import-x': importPlugin,
'tailwind-canonical-classes': tailwindCanonicalClasses,
'click-ui': clickUiPlugin,
},
rules: {
'@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': false }],
Expand Down Expand Up @@ -48,6 +50,9 @@ export default [
'warn',
{ cssPath: './src/styles.css' },
],
...clickUiPlugin.configs.recommended.rules,
'click-ui/require-provider': 'off',
'click-ui/select-requires-options': 'off',
},
},
];
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
"js-yaml": "^4.1.1",
"librechat-data-provider": "^0.8.406",
"librechat-data-provider": "^0.8.407",
"lucide-react": "^0.545.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.5.4",
"react-movable": "^3.4.1",
"react-textarea-autosize": "^8.5.9",
"styled-components": "^6.3.11",
"tailwindcss": "^4.1.18",
"zod": "^4.3.6"
Expand All @@ -67,6 +68,7 @@
"@typescript-eslint/parser": "^8.57.1",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^10.1.0",
"eslint-plugin-click-ui": "file:./tools/eslint-plugin-click-ui",
"eslint-plugin-import-x": "^4.16.2",
"eslint-plugin-tailwind-canonical-classes": "^1.3.1",
"jsdom": "^27.0.0",
Expand Down
1 change: 1 addition & 0 deletions src/components/PasswordInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>((p

return (
<div ref={wrapperRef} className="password-field-a11y w-full">
{/* eslint-disable-next-line click-ui/form-controlled-components -- value/onChange passed via ...props */}
<PasswordField ref={ref} {...props} />
</div>
);
Expand Down
5 changes: 2 additions & 3 deletions src/components/access/GroupsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
<Button
type="secondary"
iconLeft="plus"
label={localize('com_access_create_group')}
onClick={onCreateGroup}
disabled={!canManage}
aria-disabled={!canManage || undefined}
Expand All @@ -96,9 +97,7 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) {
? localize('com_cap_no_permission', { cap: SystemCapabilities.MANAGE_GROUPS })
: undefined
}
>
{localize('com_access_create_group')}
</Button>
/>
</div>

{groups.length === 0 ? (
Expand Down
5 changes: 2 additions & 3 deletions src/components/access/RolesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) {
<Button
type="secondary"
iconLeft="plus"
label={localize('com_access_create_role')}
onClick={onCreateRole}
disabled={!canManage}
aria-disabled={!canManage || undefined}
Expand All @@ -85,9 +86,7 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) {
? localize('com_cap_no_permission', { cap: SystemCapabilities.MANAGE_ROLES })
: undefined
}
>
{localize('com_access_create_role')}
</Button>
/>
</div>

{filtered.length === 0 ? (
Expand Down
68 changes: 63 additions & 5 deletions src/components/configuration/ConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
flattenObject,
unflattenObject,
serializeKVPairs,
deepSerializeKVPairs,
cn,
normalizeImportConfig,
hasConfigCapability,
Expand Down Expand Up @@ -62,6 +63,7 @@ const profileMapOptions = (fieldPaths: string[]) =>
(r: { profileMap: Record<string, string[]> }) => r.profileMap,
),
enabled: fieldPaths.length > 0,
staleTime: 60_000,
});

function resolvedConfigOptions(scope: t.ScopeSelection) {
Expand All @@ -77,6 +79,7 @@ function resolvedConfigOptions(scope: t.ScopeSelection) {
},
}),
enabled: principalType != null && principalId != null,
staleTime: 60_000,
});
}

Expand Down Expand Up @@ -267,7 +270,32 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
return unflattenObject(scopeResolvedValues) as Record<string, t.ConfigValue>;
}, [isEditingScope, scopeResolvedValues]);

const activeConfigValues = isEditingScope ? scopeConfigValues : configValues;
const baseActiveConfigValues = isEditingScope ? scopeConfigValues : configValues;

const activeConfigValues = useMemo(() => {
if (!baseActiveConfigValues) return baseActiveConfigValues;
const indexedEdits = Object.entries(editedValues).filter(([k]) => /\.\d+$/.test(k));
if (indexedEdits.length === 0) return baseActiveConfigValues;
const merged = { ...baseActiveConfigValues };
for (const [path, value] of indexedEdits) {
const segments = path.split('.');
const index = Number(segments.pop()!);
const arrayPath = segments;
let parent: Record<string, t.ConfigValue> = merged;
for (let i = 0; i < arrayPath.length - 1; i++) {
const seg = arrayPath[i];
if (parent[seg] && typeof parent[seg] === 'object' && !Array.isArray(parent[seg])) {
parent[seg] = { ...(parent[seg] as Record<string, t.ConfigValue>) };
parent = parent[seg] as Record<string, t.ConfigValue>;
} else break;
}
const lastSeg = arrayPath[arrayPath.length - 1];
const arr = Array.isArray(parent[lastSeg]) ? [...(parent[lastSeg] as t.ConfigValue[])] : [];
arr[index] = value;
parent[lastSeg] = arr;
}
return merged;
}, [baseActiveConfigValues, editedValues]);

const scopeConfiguredPaths = useMemo(() => {
if (!scopeChangedPaths) return new Set<string>();
Expand Down Expand Up @@ -354,7 +382,16 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
delete next[path];
return next;
}
return { ...prev, [path]: value };
const next = { ...prev, [path]: value };
if (Array.isArray(value)) {
const prefix = `${path}.`;
for (const k of Object.keys(next)) {
if (k.startsWith(prefix) && /\.\d+$/.test(k)) delete next[k];
}
}
const indexMatch = /^(.+)\.\d+$/.exec(path);
if (indexMatch) delete next[indexMatch[1]];
return next;
});
});
},
Expand Down Expand Up @@ -435,7 +472,10 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi

const saves = touched
.filter((p) => editedValues[p] !== undefined)
.map((p) => ({ fieldPath: p, value: serializeKVPairs(editedValues[p]) }));
.map((p) => ({
fieldPath: p,
value: /\.\d+$/.test(p) ? deepSerializeKVPairs(editedValues[p]) : serializeKVPairs(editedValues[p]),
}));
const resets = touched.filter((p) => editedValues[p] === undefined);

setSaving(true);
Expand Down Expand Up @@ -504,11 +544,29 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
const serializedEditedValues = useMemo(() => {
const result: t.FlatConfigMap = {};
for (const [k, v] of Object.entries(editedValues)) {
result[k] = serializeKVPairs(v);
result[k] = /\.\d+$/.test(k) ? deepSerializeKVPairs(v) : serializeKVPairs(v);
}
return result;
}, [editedValues]);

const originalValuesForDialog = useMemo(() => {
const baseline = isEditingScope ? scopeBaseline : flatBaseline;
const result: t.FlatConfigMap = { ...baseline };
for (const path of Object.keys(editedValues)) {
if (path in result) continue;
const segments = path.split('.');
let current: t.ConfigValue = configValues;
for (const seg of segments) {
if (current == null || typeof current !== 'object') { current = undefined; break; }
current = Array.isArray(current)
? (current as t.ConfigValue[])[Number(seg)]
: (current as Record<string, t.ConfigValue>)[seg];
}
if (current !== undefined) result[path] = current;
}
return result;
}, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues]);

const [importSuccessMessage, setImportSuccessMessage] = useState<string | null>(null);

const showImportSuccess = useCallback((message?: string) => {
Expand Down Expand Up @@ -844,7 +902,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi
<ConfirmSaveDialog
open={confirmSaveOpen}
editedValues={serializedEditedValues}
originalValues={isEditingScope ? scopeBaseline : flatBaseline}
originalValues={originalValuesForDialog}
saving={saving}
error={saveError}
onConfirm={handleConfirmSave}
Expand Down
2 changes: 2 additions & 0 deletions src/components/configuration/ConfigTabContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,8 @@ export function ConfigTabContent({
{group.sections.map((section) => (
<MultiAccordion.Item
key={section.id}
id={`section-${section.id}`}
data-section-id={`section-${section.id}`}
value={section.id}
title={localize(section.titleKey)}
>
Expand Down
20 changes: 18 additions & 2 deletions src/components/configuration/ConfigTableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,30 @@ export const ConfigTableOfContents = memo(function ConfigTableOfContents({
return true;
};

const cardExpanded = existing ? expandCard(existing) : false;
// Expand collapsed Radix accordion items (MultiAccordion.Item).
const expandAccordionItem = (el: HTMLElement): boolean => {
if (el.getAttribute('data-state') !== 'closed') return false;
const trigger = el.querySelector<HTMLElement>(':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);
if (el) {
expandCard(el);
expandAccordionItem(el);
}
setTimeout(() => {
const target = document.getElementById(id) ?? existing;
if (target) scrollToEl(target);
Expand Down
13 changes: 12 additions & 1 deletion src/components/configuration/ConfirmSaveDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ export function ConfirmSaveDialog({
);
}

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<string, t.ConfigValue> | undefined;
const name = val && typeof val === 'object' && !Array.isArray(val)
? (val as Record<string, string>).name
: undefined;
return name ? `${match[1]}[${match[2]}] (${name})` : `${match[1]}[${match[2]}]`;
}

function ChangeCard({
path,
oldValue,
Expand All @@ -79,12 +89,13 @@ function ChangeCard({
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 (
<div className="rounded-lg border border-(--cui-color-stroke-default) bg-(--cui-color-background-muted)">
<div className="flex items-center gap-2 border-b border-(--cui-color-stroke-default) px-3 py-2">
<span className="font-mono text-xs font-medium text-(--cui-color-text-default)">
{path}
{displayPath}
</span>
{isAddition && (
<Badge text={localize('com_config_field_added')} state="success" size="sm" />
Expand Down
27 changes: 14 additions & 13 deletions src/components/configuration/FieldRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Icon } from '@clickhouse/click-ui';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import type * as t from '@/types';
import {
Expand All @@ -10,7 +10,7 @@ import {
getControlType,
getEnumOptions,
hasDescendant,
inferKVType,
toKVPair,
isStringLikeItemType,
splitUnionTypes,
} from './utils';
Expand Down Expand Up @@ -66,12 +66,18 @@ function ArrayObjectNestedGroup({
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 = (
<ArrayObjectField
id={fieldId}
value={currentValue}
fields={field.children ?? []}
onChange={(v) => onChange(path, v)}
onEntryChange={handleEntryChange}
disabled={disabled}
hideAddButton
addTriggerRef={addTriggerRef}
Expand Down Expand Up @@ -413,11 +419,7 @@ export function SingleFieldRenderer({
typeof currentValue === 'object' && currentValue !== null
? (currentValue as Record<string, t.ConfigValue>)
: {},
).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : JSON.stringify(v ?? ''),
valueType: inferKVType(v),
}));
).map(([k, v]) => toKVPair(k, v));

return (
<ConfigRow
Expand All @@ -432,6 +434,7 @@ export function SingleFieldRenderer({
pairs={pairs}
onChange={(newPairs) => onChange(path, newPairs)}
disabled={disabled}
valueTypes={field.recordValueKVTypes}
aria-label={fieldLabel}
/>
</ConfigRow>
Expand Down Expand Up @@ -1071,23 +1074,21 @@ export function renderInlineField(
}

if (controlType === 'record') {
const pairs: { key: string; value: string }[] = Array.isArray(fieldValue)
? (fieldValue as { key: string; value: string }[])
const pairs: t.KeyValuePair[] = Array.isArray(fieldValue)
? (fieldValue as t.KeyValuePair[])
: Object.entries(
typeof fieldValue === 'object' && fieldValue !== null
? (fieldValue as Record<string, t.ConfigValue>)
: {},
).map(([k, v]) => ({
key: k,
value: typeof v === 'string' ? v : JSON.stringify(v),
}));
).map(([k, v]) => toKVPair(k, v));
return (
<InlineRow key={field.key} label={fieldLabel} fieldId={fieldId} required={required}>
<KeyValueField
id={fieldId}
pairs={pairs}
onChange={(p) => onChange(field.key, p)}
disabled={disabled}
valueTypes={field.recordValueKVTypes}
aria-label={fieldLabel}
/>
</InlineRow>
Expand Down
Loading