diff --git a/.claude/skills/click-ui.md b/.claude/skills/click-ui.md index 3073c606..cb361e13 100644 --- a/.claude/skills/click-ui.md +++ b/.claude/skills/click-ui.md @@ -10,7 +10,7 @@ alwaysApply: false ## Overview -Click UI is the official design system and component library for ClickHouse, built with React, TypeScript, and styled-components. This skill provides guidance for effectively using Click UI in development workflows. +Click UI is the official design system and component library for ClickHouse, built with React, TypeScript, and CSS Modules. This skill provides guidance for effectively using Click UI in development workflows. **Official Documentation**: https://clickhouse.design/click-ui **GitHub Repository**: https://github.com/ClickHouse/click-ui @@ -64,7 +64,7 @@ function App() { } ``` -**Note**: Click UI uses styled-components for styling, so no CSS import is required. The styles are injected automatically via JavaScript. +**Note**: Since 0.10, Click UI ships CSS Modules that each component imports itself, so no CSS import is required. All of its styles sit in one `clickui` cascade layer: unlayered app CSS always beats them, and Tailwind's layers lose to them. ## Core Architecture Concepts @@ -509,13 +509,13 @@ const [open, setOpen] = useState(false) ## Critical: Avoid Custom CSS -**NEVER write custom CSS or styled-components unless absolutely necessary.** Click UI provides components for almost every use case. When you must customize: +**NEVER write custom CSS unless absolutely necessary.** Click UI provides components for almost every use case. When you must customize: ### Hierarchy of Approaches (Best to Worst) 1. **Use existing Click UI components** - Check if a component already exists 2. **Use component props** - Most components have extensive prop options -3. **Extend with styled-components** - Only for adding theme-aware styles +3. **Extend with Tailwind classes on theme tokens** - Only for adding theme-aware styles 4. **Inline styles** - Only for one-off positioning tweaks 5. **Custom CSS** - Last resort, avoid if possible @@ -523,12 +523,9 @@ const [open, setOpen] = useState(false) ```typescript // WRONG - Don't rebuild what Click UI provides -const CustomDropdown = styled.div` - background: white; - border: 1px solid #ccc; - border-radius: 4px; - /* ... 50 more lines of CSS ... */ -`; +const CustomDropdown = () => ( +
{/* ... 50 more lines ... */}
+); // CORRECT - Use the Select component + {!removed && ( + + )} + setOpen(false)} + returnFocusTo={() => search} + /> + + ); +} + +/** Opens the dialog from the row's Delete and presses its Delete. */ +async function confirmDelete(): Promise { + const opener = screen.getByRole('button', { name: 'Delete Support' }); + opener.focus(); + fireEvent.click(opener); + const dialog = await findFocusedDialog(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + return opener; +} + +const searchBox = () => screen.getByRole('textbox', { name: 'Search roles' }); + +describe('ConfirmDialog', () => { + it('is described by its message and opens on Cancel', async () => { + render(); + const dialog = await findFocusedDialog(); + expect(dialog).toHaveAccessibleName('Delete role'); + expect(dialog).toHaveAccessibleDescription(MESSAGE); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toHaveFocus(); + }); + + it('stays open while the action saves, busy but still focusable, until the caller closes it', async () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + const { rerender } = render( + , + ); + const dialog = await findFocusedDialog(); + const confirm = within(dialog).getByRole('button', { name: 'Delete' }); + confirm.focus(); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + expect(onCancel).not.toHaveBeenCalled(); + + rerender(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(confirm).toHaveAttribute('aria-busy', 'true'); + expect(confirm).toHaveAttribute('aria-disabled', 'true'); + expect(confirm).toBeEnabled(); + expect(confirm).toHaveFocus(); + fireEvent.click(confirm); + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it('shows an error as an alert below its message', async () => { + render(); + const dialog = await findFocusedDialog(); + expect(within(dialog).getByRole('alert')).toHaveTextContent('Role is in use'); + expect(dialog).toHaveAccessibleDescription(MESSAGE); + }); + + it.each([ + ...DIALOG_CLOSERS, + [ + 'Cancel', + (dialog: HTMLElement) => + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })), + ] as const, + ])('gives focus back to the Delete button that opened it on %s', async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + opener.focus(); + fireEvent.click(opener); + close(await findFocusedDialog()); + await expectFocusBack(opener); + }); + + it('gives focus to the fallback after a confirmed delete, before the refetch removes its opener', async () => { + const { rerender } = render(); + await confirmDelete(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(document.body).not.toHaveFocus()); + + rerender(); + expect(screen.queryByRole('button', { name: 'Delete Support' })).toBeNull(); + expect(searchBox()).toHaveFocus(); + }); + + it('gives focus to the fallback when the confirmed delete removed its opener first', async () => { + const { rerender } = render(); + await confirmDelete(); + rerender(); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(searchBox()).toHaveFocus()); + }); + + it('gives focus to the fallback when Escape closes it while the delete still saves', async () => { + render(); + await confirmDelete(); + const dialog = screen.getByRole('dialog'); + await waitFor(() => + expect(within(dialog).getByRole('button', { name: 'Delete' })).toHaveAttribute( + 'aria-busy', + 'true', + ), + ); + pressEscape(dialog); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + await waitFor(() => expect(searchBox()).toHaveFocus()); + }); + + it('gives focus back to its opener on Cancel after a confirmed delete failed', async () => { + render(); + const opener = await confirmDelete(); + const dialog = screen.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await expectFocusBack(opener); + }); +}); diff --git a/src/components/access/__tests__/CreateGroupDialog.test.tsx b/src/components/access/__tests__/CreateGroupDialog.test.tsx new file mode 100644 index 00000000..030ae874 --- /dev/null +++ b/src/components/access/__tests__/CreateGroupDialog.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { AdminUserSearchResult } from '@librechat/data-schemas'; +import type * as t from '@/types'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { addGroupMemberFn, createGroupFn } from '@/server'; +import { CreateGroupDialog } from '../CreateGroupDialog'; + +const USERS: AdminUserSearchResult[] = [ + { id: 'u1', name: 'Jane', email: 'jane@example.com' }, + { id: 'u2', name: 'Raj', email: 'raj@example.com' }, +]; + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/components/shared', async (importOriginal) => ({ + ...(await importOriginal()), + UserSearchInline: ({ onAdd }: t.UserSearchInlineProps) => ( + + ), +})); + +vi.mock('@/server', () => ({ + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + createGroupFn: vi.fn(), + addGroupMemberFn: vi.fn(), +})); + +describe('CreateGroupDialog save', () => { + it('refreshes the server-resolved reads when a later member addition fails', async () => { + vi.mocked(createGroupFn).mockResolvedValue({ + group: { + id: 'g1', + name: 'Platform', + description: '', + memberCount: 0, + topMembers: [], + isActive: true, + }, + }); + vi.mocked(addGroupMemberFn) + .mockResolvedValueOnce({ success: true }) + .mockRejectedValueOnce(new Error('boom')); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['groups', 'all'], []); + fireEvent.change(await screen.findByLabelText('com_access_col_name'), { + target: { value: 'Platform' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'add-users' })); + fireEvent.click(screen.getByRole('button', { name: 'com_access_create_group' })); + await waitFor(() => expect(addGroupMemberFn).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['groups', 'all'])?.isInvalidated).toBe(true); + }); +}); + +describe('CreateGroupDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the button that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/CreateRoleDialog.test.tsx b/src/components/access/__tests__/CreateRoleDialog.test.tsx new file mode 100644 index 00000000..08f58fb7 --- /dev/null +++ b/src/components/access/__tests__/CreateRoleDialog.test.tsx @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { AdminUserSearchResult } from '@librechat/data-schemas'; +import type * as t from '@/types'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { addRoleMemberFn, createRoleFn, updateRolePermissionsFn } from '@/server'; +import { CreateRoleDialog } from '../CreateRoleDialog'; + +const USERS: AdminUserSearchResult[] = [ + { id: 'u1', name: 'Jane', email: 'jane@example.com' }, + { id: 'u2', name: 'Raj', email: 'raj@example.com' }, +]; + +const role = { + id: 'r1', + name: 'WRITERS', + description: '', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: {}, +} as t.Role; + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/components/shared', async (importOriginal) => ({ + ...(await importOriginal()), + UserSearchInline: ({ onAdd }: t.UserSearchInlineProps) => ( + + ), +})); + +vi.mock('../RolePermissionsPanel', () => ({ RolePermissionsPanel: () => null })); + +vi.mock('@/server', () => ({ + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + createRoleFn: vi.fn(), + addRoleMemberFn: vi.fn(), + updateRolePermissionsFn: vi.fn(), +})); + +describe('CreateRoleDialog save', () => { + it('refreshes the server-resolved reads when a later member addition fails', async () => { + vi.mocked(createRoleFn).mockResolvedValue({ role }); + vi.mocked(updateRolePermissionsFn).mockResolvedValue({ role }); + vi.mocked(addRoleMemberFn) + .mockResolvedValueOnce({ success: true }) + .mockRejectedValueOnce(new Error('boom')); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['roles', 'all'], []); + fireEvent.change(await screen.findByLabelText('com_access_col_name'), { + target: { value: 'WRITERS' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'add-users' })); + fireEvent.click(screen.getByRole('button', { name: 'com_access_create_role' })); + await waitFor(() => expect(addRoleMemberFn).toHaveBeenCalledTimes(2)); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['roles', 'all'])?.isInvalidated).toBe(true); + }); +}); + +describe('CreateRoleDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the button that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/EditGroupDialog.test.tsx b/src/components/access/__tests__/EditGroupDialog.test.tsx new file mode 100644 index 00000000..6ce60c4d --- /dev/null +++ b/src/components/access/__tests__/EditGroupDialog.test.tsx @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { AdminGroup } from '@librechat/data-schemas'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { removeGroupMemberFn, updateGroupFn } from '@/server'; +import { EditGroupDialog } from '../EditGroupDialog'; + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/server', () => ({ + MEMBERS_PAGE_SIZE: 20, + groupMembersQueryOptions: (id: string, page: number) => ({ + queryKey: ['groupMembers', id, page], + queryFn: async () => ({ + members: [{ userId: 'u1', name: 'Jane', email: 'jane@example.com' }], + total: 1, + }), + }), + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + updateGroupFn: vi.fn(), + addGroupMemberFn: vi.fn(), + removeGroupMemberFn: vi.fn(), +})); + +const group: AdminGroup = { + id: 'g1', + name: 'Engineering', + description: '', + memberCount: 1, + topMembers: [{ name: 'Jane' }], + isActive: true, +}; + +describe('EditGroupDialog save', () => { + it('refreshes the server-resolved reads when part of the save fails after other parts landed', async () => { + vi.mocked(updateGroupFn).mockResolvedValue({ group: { ...group, name: 'Platform' } }); + vi.mocked(removeGroupMemberFn).mockRejectedValue(new Error('boom')); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['envStatus'], {}); + fireEvent.change(await screen.findByLabelText('com_access_col_name'), { + target: { value: 'Platform' }, + }); + fireEvent.mouseDown(screen.getByRole('tab', { name: 'com_access_tab_members' })); + fireEvent.click(await screen.findByLabelText('com_access_remove_member Jane')); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => expect(removeGroupMemberFn).toHaveBeenCalled()); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['envStatus'])?.isInvalidated).toBe(true); + }); +}); + +describe('EditGroupDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the group row that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/EditRoleDialog.test.tsx b/src/components/access/__tests__/EditRoleDialog.test.tsx new file mode 100644 index 00000000..f2e1436f --- /dev/null +++ b/src/components/access/__tests__/EditRoleDialog.test.tsx @@ -0,0 +1,170 @@ +import { PrincipalType } from 'librechat-data-provider'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type * as t from '@/types'; +import { DIALOG_CLOSERS, expectFocusBack, openFrom, renderWithOpener } from '@/test/focus'; +import { removeRoleMemberFn, updateRoleFn } from '@/server'; +import { BUNDLED_CONFIG_FEATURES } from '@/constants'; +import { EditRoleDialog } from '../EditRoleDialog'; + +const state = vi.hoisted(() => ({ + features: {} as Record, + scopes: [] as Array>, + members: [] as Array<{ userId: string; name: string; email: string }>, +})); + +vi.mock('@/hooks', async () => { + const { useRefreshScopes } = + await vi.importActual('@/hooks/config/scope'); + const { useReturnFocus } = + await vi.importActual('@/hooks/dialogs'); + return { + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useConfigFeatures: () => state.features, + useRefreshScopes, + useReturnFocus, + }; +}); + +vi.mock('@/server', () => ({ + MEMBERS_PAGE_SIZE: 20, + roleQueryOptions: (name: string) => ({ + queryKey: ['role', name], + queryFn: async () => ({ ...role, permissions: {} }), + }), + roleMembersQueryOptions: (id: string, page: number) => ({ + queryKey: ['roleMembers', id, page], + queryFn: async () => ({ members: state.members, total: state.members.length }), + }), + availableScopesOptions: { + queryKey: ['availableScopes'], + queryFn: async () => state.scopes, + }, + updateRoleFn: vi.fn(), + addRoleMemberFn: vi.fn(), + removeRoleMemberFn: vi.fn(), + updateRolePermissionsFn: vi.fn(), +})); + +vi.mock('../RolePermissionsPanel', () => ({ RolePermissionsPanel: () => null })); + +const role: t.Role = { + id: 'r1', + name: 'EDITORS', + description: '', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: {}, +} as t.Role; + +function renderDialog(): QueryClient { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return queryClient; +} + +async function rename(name: string) { + const input = await screen.findByLabelText('com_access_col_name'); + fireEvent.change(input, { target: { value: name } }); +} + +beforeEach(() => { + state.features = { ...BUNDLED_CONFIG_FEATURES }; + state.scopes = [{ principalType: PrincipalType.ROLE, principalId: 'EDITORS' }]; + state.members = []; +}); + +describe('EditRoleDialog rename note', () => { + it('warns that the profile stays under the old name on a server that does not move it', async () => { + renderDialog(); + await rename('WRITERS'); + expect( + await screen.findByText('com_access_rename_profile_warning(EDITORS)'), + ).toBeInTheDocument(); + }); + + it('says the profile moves with the role when the server moves it', async () => { + state.features = { ...BUNDLED_CONFIG_FEATURES, roleRenameMovesProfile: true }; + renderDialog(); + await rename('WRITERS'); + expect(await screen.findByText('com_access_rename_profile_moves')).toBeInTheDocument(); + expect(screen.queryByText(/com_access_rename_profile_warning/)).not.toBeInTheDocument(); + }); + + it('says nothing once the name is back to the current one', async () => { + renderDialog(); + await rename('WRITERS'); + expect(await screen.findByText(/com_access_rename_profile_warning/)).toBeInTheDocument(); + await rename('EDITORS'); + expect(screen.queryByText(/com_access_rename_profile/)).not.toBeInTheDocument(); + }); + + it('says nothing for a role without a profile', async () => { + state.scopes = [{ principalType: PrincipalType.GROUP, principalId: 'EDITORS' }]; + renderDialog(); + await rename('WRITERS'); + await act(async () => {}); + expect(screen.queryByText(/com_access_rename_profile/)).not.toBeInTheDocument(); + }); +}); + +describe('EditRoleDialog save', () => { + it("refreshes the profiles and every server-resolved read, since a user's role resolves their config", async () => { + vi.mocked(updateRoleFn).mockResolvedValue({ role: { ...role, name: 'WRITERS' } }); + const queryClient = renderDialog(); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['configCatalog', 'role', 'EDITORS'], {}); + queryClient.setQueryData(['envStatus'], {}); + await rename('WRITERS'); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['configCatalog', 'role', 'EDITORS'])?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(['envStatus'])?.isInvalidated).toBe(true); + }); + + it('refreshes the server-resolved reads when part of the save fails after other parts landed', async () => { + state.members = [{ userId: 'u1', name: 'Jane', email: 'jane@example.com' }]; + vi.mocked(updateRoleFn).mockResolvedValue({ role }); + vi.mocked(removeRoleMemberFn).mockRejectedValue(new Error('boom')); + const queryClient = renderDialog(); + queryClient.setQueryData(['configPreview', 'user', 'u1'], { ok: true }); + queryClient.setQueryData(['envStatus'], {}); + fireEvent.mouseDown(await screen.findByRole('tab', { name: 'com_access_tab_members' })); + fireEvent.click(await screen.findByLabelText('com_access_remove_member Jane')); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_save' })); + await waitFor(() => expect(removeRoleMemberFn).toHaveBeenCalled()); + await waitFor(() => + expect(queryClient.getQueryState(['configPreview', 'user', 'u1'])?.isInvalidated).toBe(true), + ); + expect(queryClient.getQueryState(['envStatus'])?.isInvalidated).toBe(true); + }); +}); + +describe('EditRoleDialog focus', () => { + it.each(DIALOG_CLOSERS)( + 'gives focus back to the role row that opened it on %s', + async (_, close) => { + const opener = renderWithOpener((open, onClose) => ( + + )); + close(await openFrom(opener)); + await expectFocusBack(opener); + }, + ); +}); diff --git a/src/components/access/__tests__/GroupsTab.test.tsx b/src/components/access/__tests__/GroupsTab.test.tsx new file mode 100644 index 00000000..623da53c --- /dev/null +++ b/src/components/access/__tests__/GroupsTab.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { apiFetch } from '@/server/utils/api'; +import { GroupsTab } from '../GroupsTab'; + +vi.mock('@tanstack/react-start', () => ({ + createServerFn: () => ({ + handler: (fn: (...args: never[]) => unknown) => fn, + validator: () => ({ handler: (fn: (...args: never[]) => unknown) => fn }), + }), +})); + +vi.mock('@/server/utils/api', () => ({ + apiFetch: vi.fn( + async () => + new Response( + JSON.stringify({ + groups: [{ _id: 'g1', name: 'Engineering', memberIds: [], source: 'local' }], + total: 1, + }), + { status: 200 }, + ), + ), + extractApiError: async () => { + throw new Error('unexpected'); + }, +})); + +vi.mock('@/server', () => vi.importActual('@/server/groups')); + +vi.mock('@/hooks', async () => ({ + useLocalize: () => (key: string, options?: Record) => + options ? `${key}(${Object.values(options).join(',')})` : key, + useCapabilities: () => ({ hasCapability: () => true }), + useRefreshScopes: () => vi.fn(), + useClearButtonLabel: vi.fn(), + useReturnFocus: (await vi.importActual('@/hooks/dialogs')) + .useReturnFocus, + useAnnouncement: ( + await vi.importActual('@/hooks/useAnnouncement') + ).useAnnouncement, +})); + +/** The `search` of every groups list request sent so far. */ +function sentSearches(): string[] { + return vi + .mocked(apiFetch) + .mock.calls.map(([path]) => new URL(path, 'http://admin.test').searchParams.get('search')) + .filter((search): search is string => search != null); +} + +describe('GroupsTab search', () => { + it('clamps a pasted search to the 200 characters the backend accepts, in the field, query and request', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + const field = await screen.findByPlaceholderText('com_access_search_groups'); + fireEvent.change(field, { target: { value: 'x'.repeat(250) } }); + expect(field).toHaveValue('x'.repeat(200)); + await waitFor(() => expect(sentSearches()).toEqual(['x'.repeat(200)])); + expect(queryClient.getQueryData(['groups', 1, 'x'.repeat(200)])).toBeDefined(); + const clamped = queryClient.getQueryCache().find({ queryKey: ['groups', 1, 'x'.repeat(250)] }); + expect(clamped).toBeUndefined(); + }); +}); + +describe('GroupsTab search announcement', () => { + it('announces the match count once a search has loaded, and not on the first load', async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + const field = await screen.findByPlaceholderText('com_access_search_groups'); + const status = screen.getByRole('status'); + expect(status).toBeEmptyDOMElement(); + fireEvent.change(field, { target: { value: 'eng' } }); + await waitFor(() => expect(status).toHaveTextContent('com_a11y_groups_found(1)')); + }); +}); diff --git a/src/components/access/__tests__/MemberList.test.tsx b/src/components/access/__tests__/MemberList.test.tsx new file mode 100644 index 00000000..8df7fc73 --- /dev/null +++ b/src/components/access/__tests__/MemberList.test.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { describe, expect, it } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { AdminMember } from '@librechat/data-schemas'; +import type * as t from '@/types'; +import { MemberList } from '../MemberList'; +import '@/locales/i18n'; + +const MEMBERS: AdminMember[] = [ + { userId: 'u1', name: 'Alice Chen', email: 'alice@example.com' }, + { userId: 'u2', name: 'Bob Martinez', email: 'bob@example.com' }, +]; + +const BASE: t.MemberListProps = { + members: MEMBERS, + loading: false, + error: false, + fetching: false, + removalIds: new Set(), + onRemove: () => {}, + onUndoRemove: () => {}, + canManage: true, + total: MEMBERS.length, + currentPage: 1, + totalPages: 1, + onPageChange: () => {}, +}; + +/** The list as an Edit dialog drives it: removals are staged in the dialog's state. */ +function StagedMemberList() { + const [removalIds, setRemovalIds] = useState>(new Set()); + return ( + setRemovalIds((prev) => new Set(prev).add(member.userId))} + onUndoRemove={(userId) => + setRemovalIds((prev) => new Set([...prev].filter((id) => id !== userId))) + } + /> + ); +} + +describe('MemberList', () => { + it('counts members in the singular and the plural', () => { + const { rerender } = render(); + expect(screen.getByText('2 members')).toBeInTheDocument(); + rerender(); + expect(screen.getByText('1 member')).toBeInTheDocument(); + }); + + it('marks a staged member "Will be removed" at full contrast and swaps Remove for a focused Undo', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Alice Chen' })); + + const undo = screen.getByRole('button', { name: 'Undo removing Alice Chen' }); + expect(undo).toHaveTextContent('Undo'); + expect(undo).toHaveFocus(); + expect(screen.queryByRole('button', { name: 'Remove Alice Chen' })).toBeNull(); + expect(screen.getByText('Will be removed')).toBeVisible(); + expect(screen.getByText('Alice Chen')).toHaveClass('line-through'); + expect(screen.getByText('Alice Chen').closest('.opacity-40')).toBeNull(); + + fireEvent.click(undo); + expect(screen.getByRole('button', { name: 'Remove Alice Chen' })).toHaveFocus(); + expect(screen.queryByText('Will be removed')).toBeNull(); + }); + + it('leaves the other rows alone', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Alice Chen' })); + expect(screen.getByRole('button', { name: 'Remove Bob Martinez' })).toBeEnabled(); + expect(screen.getByText('Bob Martinez')).not.toHaveClass('line-through'); + }); + + it('reports a failed load as an alert', () => { + render(); + expect(screen.getByRole('alert')).toHaveTextContent(/members/i); + }); + + it('offers no Remove or Undo without the manage capability', () => { + render(); + expect(screen.queryByRole('button')).toBeNull(); + expect(screen.getByText('Will be removed')).toBeVisible(); + }); +}); diff --git a/src/components/access/__tests__/RolePermissionsPanel.test.tsx b/src/components/access/__tests__/RolePermissionsPanel.test.tsx new file mode 100644 index 00000000..d2893387 --- /dev/null +++ b/src/components/access/__tests__/RolePermissionsPanel.test.tsx @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { PermissionTypes } from 'librechat-data-provider'; +import { RolePermissionsPanel } from '../RolePermissionsPanel'; +import { defaultPermissions } from '@/constants'; +import { nestedControls } from '@/test/a11y'; + +vi.mock('@/hooks', () => ({ useLocalize: () => (key: string) => key })); + +const PROMPTS = `com_perm_type_${PermissionTypes.PROMPTS}`; + +describe('RolePermissionsPanel permission headers', () => { + it('puts each Select all switch beside its permission toggle, not inside it', () => { + const { container } = render( + , + ); + + expect(nestedControls(container)).toEqual([]); + expect(screen.getByRole('switch', { name: `${PROMPTS}: com_ui_select_all` })).toBeVisible(); + }); + + it('opens a permission type from its toggle and turns it all on from Select all', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + const toggle = screen.getByRole('button', { name: new RegExp(`^${PROMPTS}`) }); + + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + await user.click(screen.getByRole('switch', { name: `${PROMPTS}: com_ui_select_all` })); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ [PermissionTypes.PROMPTS]: expect.any(Object) }), + ); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/src/components/access/__tests__/RolesTab.test.tsx b/src/components/access/__tests__/RolesTab.test.tsx new file mode 100644 index 00000000..dbb4c832 --- /dev/null +++ b/src/components/access/__tests__/RolesTab.test.tsx @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type * as t from '@/types'; +import { RolesTab } from '../RolesTab'; +import '@/locales/i18n'; + +const role = (name: string, description: string, isSystemRole = false): t.Role => ({ + id: name, + name, + description, + isSystemRole, + isActive: true, + userCount: 0, + permissions: {} as t.RolePermissions, +}); + +const ROLES = [ + role('ADMIN', '', true), + role('Billing', 'Invoices and usage reports'), + role('Support', 'Answers user tickets'), +]; + +vi.mock('@/hooks', async (importOriginal) => ({ + ...(await importOriginal()), + useCapabilities: () => ({ hasCapability: () => true }), + useRefreshScopes: () => vi.fn(), +})); + +vi.mock('@/server', () => ({ + ROLES_PAGE_SIZE: 50, + allRolesQueryOptions: { queryKey: ['roles'], queryFn: async () => ROLES }, + deleteRoleFn: vi.fn(), +})); + +vi.mock('../EditRoleDialog', () => ({ EditRoleDialog: () => null })); + +function renderTab() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +describe('RolesTab search', () => { + it('announces how many roles match each search', async () => { + renderTab(); + const field = await screen.findByPlaceholderText('Search roles…'); + const status = () => + screen.getAllByRole('status').find((el) => el.textContent?.includes('found')); + + fireEvent.change(field, { target: { value: 'support' } }); + await waitFor(() => expect(status()).toHaveTextContent('1 role found')); + + fireEvent.change(field, { target: { value: 'us' } }); + await waitFor(() => expect(status()).toHaveTextContent('2 roles found')); + + fireEvent.change(field, { target: { value: 'zzz' } }); + await waitFor(() => expect(status()).toHaveTextContent('0 roles found')); + }); +}); diff --git a/src/components/configuration/ConfigLoadError.tsx b/src/components/configuration/ConfigLoadError.tsx new file mode 100644 index 00000000..fe0ea5d2 --- /dev/null +++ b/src/components/configuration/ConfigLoadError.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef } from 'react'; +import { Button, Icon } from '@clickhouse/click-ui'; +import type * as t from '@/types'; +import { activeConfigTab, restoreFocus, restoreFocusWhenShown } from '@/utils'; +import { useLocalize } from '@/hooks'; + +function errorMessage(error: unknown): string | null { + if (error instanceof Error) return error.message || null; + if (typeof error === 'string') return error || null; + return null; +} + +/** + * The Retry of a load error card shown again after a retry failed: the route + * and the page both mount a new card in place of the one that retried. + */ +function retryShownAgain(): HTMLElement | null { + return document.querySelector('[data-config-load-error] button:not([disabled])'); +} + +/** The open tab once the page has loaded: none while a skeleton (`aria-busy`) stands in for it. */ +function loadedConfigTab(): HTMLElement | null { + return document.querySelector('[aria-busy="true"]') ? null : activeConfigTab(); +} + +/** + * Danger card with Retry, shown in place of the configuration form when its + * data fails to load. A retry takes the card, and the focused Retry with it, + * away while it loads: focus goes to the open tab once the page has loaded, + * or to the Retry of the card shown again if it failed again, unless the + * user has gone to another page by then. + */ +export function ConfigLoadError({ + error, + onRetry, + retrying, + titleKey = 'com_config_shell_load_error_title', + descriptionKey = 'com_config_shell_load_error_desc', +}: t.ConfigLoadErrorProps) { + const localize = useLocalize(); + const details = errorMessage(error); + const alertRef = useRef(null); + const retriedFrom = useRef(null); + const wasRetrying = useRef(retrying); + + useEffect(() => { + const was = wasRetrying.current; + wasRetrying.current = retrying; + if (!was || retrying) return; + retriedFrom.current = null; + restoreFocus([alertRef.current?.querySelector('button')]); + }, [retrying]); + + useEffect( + () => () => { + const path = retriedFrom.current; + if (path) restoreFocusWhenShown([retryShownAgain, loadedConfigTab], null, path); + }, + [], + ); + + return ( +
+
+
+ +
+

+ {localize(titleKey)} +

+

{localize(descriptionKey)}

+ {details && ( +
+ + {localize('com_config_shell_load_error_details')} + + + {details} + +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 51063b8c..4090d4b7 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -1,949 +1,523 @@ -import { createPortal } from 'react-dom'; -import { Icon } from '@clickhouse/click-ui'; -import { PrincipalType } from 'librechat-data-provider'; -import { getRouteApi, useBlocker, useNavigate } from '@tanstack/react-router'; -import { useState, useMemo, useRef, useCallback, useEffect, startTransition } from 'react'; -import { queryOptions, useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { getRouteApi, useNavigate } from '@tanstack/react-router'; +import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import type * as t from '@/types'; import { - removeFieldProfileValueFn, - tombstoneFieldProfileValueFn, - bulkSaveProfileValuesFn, - getBatchFieldProfilesFn, - availableScopesOptions, - resetBaseConfigFieldFn, - getResolvedConfigFn, - importBaseConfigFn, - resetBaseConfigFn, - baseConfigOptions, - saveBaseConfigFn, - getLangfuseConnectionFn, - LANGFUSE_CONNECTION_QUERY_KEY, -} from '@/server'; + useLocalize, + useTabBadges, + useConfigSave, + useTabSections, + useConfigEdits, + useConfigTabs, + useScopeIndex, + useConfigLayers, + useConfigImport, + useChangeReview, + useBaseOverrides, + useRevealField, + useConfigBaseline, + useNavigationGuard, + useActiveSection, + useScopeSelection, + useScopeGatedSections, + useResetBaseConfig, + useConfigSearchIndex, + usePendingConfigValues, +} from '@/hooks'; import { - flattenObject, - unflattenObject, - deepSerializeKVPairs, - normalizeImportConfig, - hasConfigCapability, - getTabsWithPermission, - mapSecretPreviewPaths, - secretPathForPreviewPath, - stripSecretPreviewValues, - notifySuccess, notifyError, + isRowVisible, + moveEntryPath, + notifyWarning, + findEntryIndex, + getValueAtPath, + parseScopeParam, + createPathIndex, + formatScopeParam, + findScopeByParam, + toScopeCandidate, + getKeyedCollection, + isConfigValueEqual, } from '@/utils'; -import { useLocalize, useHighlightRef, useActiveSection, useCapabilities } from '@/hooks'; -import { CONFIG_TABS, OTHER_TAB, SECTION_META, HIDDEN_SECTIONS } from './configMeta'; -import { - applyConfigEdit, - buildSavePayload, - mergeIndexedArrayEdits, - partitionScopeResetPaths, - withLangfuseConfiguredPath, -} from './utils'; -import { validateMcpCrossField } from './sections/McpServersRenderer'; -import { ScopeSelector, ScopeTriggerButton } from './ScopeSelector'; -import { StickyActionBar } from '@/components/shared'; +import { CollectionEditsContext } from '@/contexts/CollectionEditsContext'; +import { ConfigErrorsProvider } from '@/contexts/ConfigErrorsContext'; +import { buildTocEntries, findTocEntry, toJumpTargets } from './toc'; +import { RevealTargetContext } from '@/contexts/RevealTargetContext'; +import { GlobalRevealContext } from '@/contexts/GlobalRevealContext'; import { ConfigTableOfContents } from './ConfigTableOfContents'; import { ResetBaseConfigDialog } from './ResetBaseConfigDialog'; -import { ConfirmSaveDialog } from './ConfirmSaveDialog'; +import { NavigationGuardDialog } from './NavigationGuardDialog'; +import { ScopeActionsProvider } from '@/contexts/ScopeContext'; +import { RestatesContext } from '@/contexts/RestatesContext'; +import { RenamesContext } from '@/contexts/RenamesContext'; +import { ExpandProvider } from '@/contexts/ExpandContext'; +import { ConfigContentSkeleton } from './ConfigSkeleton'; +import { isPathUnder, tabForPath } from './configMeta'; import { ConfigTabContent } from './ConfigTabContent'; import { ImportYamlDialog } from './ImportYamlDialog'; +import { ChangeReview, ChangesBar } from './review'; +import { ConfigLoadError } from './ConfigLoadError'; import { ContentToolbar } from './ContentToolbar'; +import { availableScopesOptions } from '@/server'; import { SystemCapabilities } from '@/constants'; +import { ScopeSelector } from './ScopeSelector'; +import { ScopeNotFound } from './ScopeNotFound'; +import { HeaderActions } from './HeaderActions'; import { ConfigTabBar } from './ConfigTabBar'; -import { InfoBanner } from './InfoBanner'; +import { searchConfigIndex } from './search'; +import { revealConfigPath } from './reveal'; +import { PreviewDrawer } from './preview'; +import { ScopeBar } from './ScopeBar'; const routeApi = getRouteApi('/_app/configuration/'); -const LAST_SCOPE_KEY = 'config:lastScope'; - -function collectFieldPaths(fields: t.SchemaField[], prefix = ''): string[] { - const paths: string[] = []; - for (const field of fields) { - const path = prefix ? `${prefix}.${field.key}` : field.key; - if (field.children && field.children.length > 0) { - paths.push(...collectFieldPaths(field.children, path)); - } else { - paths.push(path); - } - } - return paths; -} -const profileMapOptions = (fieldPaths: string[]) => - queryOptions({ - queryKey: ['profileMap', fieldPaths], - queryFn: () => - getBatchFieldProfilesFn({ data: { paths: fieldPaths } }).then( - (r: { profileMap: Record }) => r.profileMap, - ), - enabled: fieldPaths.length > 0, - staleTime: 60_000, - }); +const NO_TOC_ENTRIES: t.TocEntry[] = []; -function resolvedConfigOptions(scope: t.ScopeSelection) { - const principalType = scope.type === 'SCOPE' ? scope.scope.principalType : null; - const principalId = scope.type === 'SCOPE' ? scope.scope.principalId : null; - return queryOptions({ - queryKey: ['resolvedConfig', principalType, principalId] as const, - queryFn: () => - getResolvedConfigFn({ - data: { - principalType: principalType!, - principalId: principalId!, - }, - }), - enabled: principalType != null && principalId != null, - staleTime: 60_000, - }); -} +/** Keys AppService adds to the resolved config that are runtime state, not settings. */ +const APP_SERVICE_INTERNAL_KEYS = new Set(['paths', 'config', 'availableTools']); -export function ConfigPage({ initialTab, highlightField, initialScope }: t.ConfigPageProps) { +export function ConfigPage({ + initialTab, + highlightField, + initialScope, + customizedOnly = false, +}: t.ConfigPageProps) { const localize = useLocalize(); - const queryClient = useQueryClient(); - const { hasCapability } = useCapabilities(); - const canManageConfig = hasCapability(SystemCapabilities.MANAGE_CONFIGS); - const canAssignConfigs = hasCapability(SystemCapabilities.ASSIGN_CONFIGS) || canManageConfig; const navigate = useNavigate({ from: '/configuration/' }); const { tree: schemaTree } = routeApi.useLoaderData(); - /** Per-section permission map: { [sectionKey]: { canView, canEdit } } */ - const sectionPermissions = useMemo(() => { - const perms: Record = {}; - for (const section of schemaTree) { - perms[section.key] = { - canView: hasConfigCapability(hasCapability, section.key, 'read'), - canEdit: hasConfigCapability(hasCapability, section.key, 'manage'), - }; - } - return perms; - }, [schemaTree, hasCapability]); - - const { data: baseConfigData } = useQuery(baseConfigOptions); - const configValues = baseConfigData?.config ?? null; - const dbOverrides = baseConfigData?.dbOverrides; - const configuredFromBase = baseConfigData?.configuredFromBase; - const schemaDefaults = baseConfigData?.schemaDefaults ?? {}; - const flatBaseline = useMemo(() => flattenObject(configValues ?? {}), [configValues]); - const [editedValues, setEditedValues] = useState({}); - const [touchedPaths, setTouchedPaths] = useState>(() => new Set()); - const [editSessionId, setEditSessionId] = useState(0); - - const fieldPaths = useMemo(() => collectFieldPaths(schemaTree), [schemaTree]); - const schemaPathSet = useMemo(() => new Set(fieldPaths), [fieldPaths]); - - const configuredPaths = useMemo(() => { - const paths = new Set(); - if (configuredFromBase) { - for (const p of configuredFromBase) paths.add(p); - } - if (dbOverrides) { - for (const p of Object.keys(flattenObject(dbOverrides))) paths.add(p); - } - return mapSecretPreviewPaths(paths, schemaPathSet); - }, [configuredFromBase, dbOverrides, schemaPathSet]); - - const dbOverridePaths = useMemo(() => { - if (!dbOverrides) return new Set(); - return mapSecretPreviewPaths(Object.keys(flattenObject(dbOverrides)), schemaPathSet); - }, [dbOverrides, schemaPathSet]); + const { + sectionPermissions, + visibleTabs, + activeTab, + handleTabChange, + canManageConfig, + canEditActiveTab, + permissions, + } = useConfigTabs({ + schemaTree, + initialTab: + initialTab ?? (highlightField?.includes('.') ? tabForPath(highlightField) : undefined), + }); - const baseRecordKeys = useMemo(() => { - const result: Record> = {}; - const yamlMcpKeys = baseConfigData?.yamlMcpKeys; - if (yamlMcpKeys && Array.isArray(yamlMcpKeys)) { - result.mcpServers = new Set(yamlMcpKeys); - } - return result; - }, [baseConfigData]); + const { + selectedScope, + selectScope, + isEditingScope, + editingScope, + scopeSelectorOpen, + setScopeSelectorOpen, + isResolvingScope = false, + scopeNotFound, + scopeLoadError, + retryScopes, + } = useScopeSelection({ initialScope }); + + const baseline = useConfigBaseline(selectedScope); + const isReady = baseline.status === 'ready' && !isResolvingScope; + const layers = useConfigLayers({ schemaTree, selectedScope, sectionPermissions }); + + const edits = useConfigEdits({ + isEditingScope, + flatBaseline: layers.flatBaseline, + scopeResolvedValues: layers.scopeResolvedValues, + baseActiveConfigValues: layers.baseActiveConfigValues, + schemaTree, + schemaPathSet: layers.schemaPathSet, + }); + const { editedValues, isDirty, discardEdits, resetEdits } = edits; - const hasUnmappedSections = useMemo( - () => - schemaTree.some( - (s: t.SchemaField) => !HIDDEN_SECTIONS.has(s.key) && !Object.hasOwn(SECTION_META, s.key), - ), - [schemaTree], + const activeConfigValues = usePendingConfigValues( + layers.displayConfigValues, + editedValues, + layers.valueLayers, ); - const { viewableTabIds, editableTabIds } = useMemo( - () => ({ - viewableTabIds: getTabsWithPermission( - schemaTree, - SECTION_META, - OTHER_TAB.id, - sectionPermissions, - 'canView', - HIDDEN_SECTIONS, - ), - editableTabIds: getTabsWithPermission( - schemaTree, - SECTION_META, - OTHER_TAB.id, - sectionPermissions, - 'canEdit', - HIDDEN_SECTIONS, - ), - }), - [schemaTree, sectionPermissions], + /** A list entry as the page shows it now and as the open profile inherits it from Base. */ + const scopeEntry = useCallback( + (collectionPath: string, identity: string) => { + const collection = getKeyedCollection(collectionPath); + if (!collection) return null; + const list = getValueAtPath(activeConfigValues, collectionPath); + const inheritedList = getValueAtPath(layers.valueLayers?.base ?? null, collectionPath); + const index = findEntryIndex(list, collection.key, identity); + const inheritedIndex = findEntryIndex(inheritedList, collection.key, identity); + return { + list: Array.isArray(list) ? list : [], + index, + inherited: Array.isArray(inheritedList) ? inheritedList[inheritedIndex] : undefined, + }; + }, + [activeConfigValues, layers.valueLayers], ); - const visibleTabs = useMemo(() => { - const allTabs = hasUnmappedSections ? [...CONFIG_TABS, OTHER_TAB] : CONFIG_TABS; - return allTabs.filter((tab) => viewableTabIds.has(tab.id)); - }, [hasUnmappedSections, viewableTabIds]); - - const activeTab = - initialTab && visibleTabs.some((tab) => tab.id === initialTab) - ? initialTab - : (visibleTabs[0]?.id ?? CONFIG_TABS[0].id); - - const handleTabChange = useCallback( - (newTab: string) => { - navigate({ search: (prev: Record) => ({ ...prev, tab: newTab }) }); + /** Removes the open profile's override of a list entry: back to the inherited entry, or out if it added it. */ + const revertScopeEntry = useCallback( + (collectionPath: string, identity: string) => { + const found = scopeEntry(collectionPath, identity); + if (!found || found.index < 0) return; + if (found.inherited === undefined) { + edits.handleFieldChange( + collectionPath, + found.list.filter((_, i) => i !== found.index), + ); + return; + } + edits.handleFieldChange(`${collectionPath}.${found.index}`, found.inherited); }, - [navigate], + [scopeEntry, edits.handleFieldChange], ); - const [importOpen, setImportOpen] = useState(false); - const [importSuccess, setImportSuccess] = useState(false); - const dismissTimer = useRef | undefined>(undefined); - useEffect(() => () => clearTimeout(dismissTimer.current), []); - - const [showConfiguredOnly, setShowConfiguredOnly] = useState(false); - - const [scopeSelectorOpen, setScopeSelectorOpen] = useState(false); - const [selectedScope, setSelectedScope] = useState({ type: 'BASE' }); - - const handleScopeChange = useCallback( - (newSelection: t.ScopeSelection) => { - if (Object.keys(editedValues).length > 0) { - if (!window.confirm(localize('com_config_unsaved_leave'))) return; - setEditedValues({}); - setTouchedPaths(new Set()); - } - setEditSessionId((id) => id + 1); - setConfirmSaveOpen(false); - setSelectedScope(newSelection); - const scopeId = - newSelection.type === 'SCOPE' && newSelection.scope._id - ? newSelection.scope._id - : undefined; - if (scopeId) { - localStorage.setItem(LAST_SCOPE_KEY, scopeId); - } else { - localStorage.removeItem(LAST_SCOPE_KEY); - } - navigate({ search: (prev: Record) => ({ ...prev, scope: scopeId }) }); + const isScopeEntryPending = useCallback( + (collectionPath: string, identity: string) => { + const found = scopeEntry(collectionPath, identity); + if (!found) return false; + const current = found.list[found.index]; + if (found.inherited === undefined) return found.index < 0; + return current !== undefined && isConfigValueEqual(current, found.inherited); }, - [editedValues, localize, navigate], + [scopeEntry], ); - const savedScope = useRef(localStorage.getItem(LAST_SCOPE_KEY) ?? undefined); - const scopeToRestore = initialScope ?? savedScope.current; - const { data: allScopes } = useQuery({ - ...availableScopesOptions, - enabled: !!scopeToRestore, + const tabBadges = useTabBadges({ + schemaTree, + visibleTabs, + activeConfiguredPaths: layers.activeConfiguredPaths, + activeConfigValues, + editedValues, + overridePaths: layers.overridePaths, + listOverrideCounts: layers.listOverrideCounts, + isEditingScope, }); - const initialScopeApplied = useRef(false); - useEffect(() => { - if (scopeToRestore && allScopes && !initialScopeApplied.current) { - const match = - allScopes.find((s) => s._id === scopeToRestore) ?? - (() => { - const [type, ...rest] = scopeToRestore.split(':'); - const id = rest.join(':'); - return allScopes.find( - (s) => s.principalType === (type as PrincipalType) && s.principalId === id, - ); - })(); - if (match) { - initialScopeApplied.current = true; - setSelectedScope({ type: 'SCOPE', scope: match }); - if (!initialScope) { - navigate({ search: (prev: Record) => ({ ...prev, scope: match._id }) }); - } - } - } - }, [scopeToRestore, allScopes, initialScope, navigate]); - - const isEditingScope = selectedScope.type === 'SCOPE'; - const editingScope: t.ConfigScope | undefined = - selectedScope.type === 'SCOPE' ? selectedScope.scope : undefined; - - const { data: profileMap = {} } = useQuery(profileMapOptions(fieldPaths)); - - const handleProfileChange = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['profileMap'] }); - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }); - }, [queryClient]); - - const { data: resolvedData } = useQuery(resolvedConfigOptions(selectedScope)); - const scopeChangedPaths = resolvedData?.changedPaths ?? null; - const scopeResolvedValues = resolvedData?.resolvedConfig ?? null; - const scopeConfigValues = useMemo(() => { - if (!isEditingScope || !scopeResolvedValues) return null; - return unflattenObject(scopeResolvedValues) as Record; - }, [isEditingScope, scopeResolvedValues]); - - 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; - return mergeIndexedArrayEdits(baseActiveConfigValues, indexedEdits); - }, [baseActiveConfigValues, editedValues]); - - const scopeConfiguredPaths = useMemo(() => { - if (!scopeChangedPaths) return new Set(); - return mapSecretPreviewPaths(scopeChangedPaths, schemaPathSet); - }, [scopeChangedPaths, schemaPathSet]); + const setCustomizedOnly = useCallback( + (value: boolean) => + navigate({ + search: (prev: t.ConfigSearchParams) => ({ ...prev, customized: value ? 1 : undefined }), + replace: true, + }), + [navigate], + ); + const showAllSettings = useCallback(() => setCustomizedOnly(false), [setCustomizedOnly]); - const scopeChangedPathsMapped = useMemo(() => { - if (!scopeChangedPaths) return null; - return Array.from(mapSecretPreviewPaths(scopeChangedPaths, schemaPathSet)); - }, [scopeChangedPaths, schemaPathSet]); + const [tabFilter, setTabFilter] = useState({ tab: activeTab, text: '' }); + const filterText = tabFilter.tab === activeTab ? tabFilter.text : ''; + const setFilterText = useCallback( + (text: string) => setTabFilter({ tab: activeTab, text }), + [activeTab], + ); + const clearFilter = useCallback(() => setFilterText(''), [setFilterText]); - const { data: langfuseConnection } = useQuery({ - queryKey: LANGFUSE_CONNECTION_QUERY_KEY, - queryFn: () => getLangfuseConnectionFn(), - enabled: - !isEditingScope && - schemaTree.some((section) => section.key === 'langfuse') && - sectionPermissions.langfuse?.canEdit === true, - retry: false, - refetchOnWindowFocus: false, + const save = useConfigSave({ + schemaTree, + schemaPathSet: layers.schemaPathSet, + editingScope, + layers, + edits, }); + const { cancelReview } = save; - const baseConfiguredPaths = useMemo( - () => withLangfuseConfiguredPath(configuredPaths, langfuseConnection?.configured === true), - [configuredPaths, langfuseConnection?.configured], + /** "Go to field" and error jumps from the review may target a setting the tab filter hides. */ + const goToTab = useCallback( + (tabId: string) => { + clearFilter(); + handleTabChange(tabId); + }, + [clearFilter, handleTabChange], ); + const { scopes: profiles } = useScopeIndex(); + const review = useChangeReview({ + schemaTree, + editingScope, + layers, + edits, + save, + onTabChange: goToTab, + profiles, + }); - const activeConfiguredPaths = isEditingScope ? scopeConfiguredPaths : baseConfiguredPaths; - - const tabConfiguredCounts = useMemo(() => { - if (activeConfiguredPaths.size === 0) return {}; - const schemaKeyToTabs: Record = {}; - for (const [metaKey, meta] of Object.entries(SECTION_META)) { - if (meta.schemaKey) { - (schemaKeyToTabs[meta.schemaKey] ??= []).push(meta.tab); - } - if (!meta.schemaKey) { - (schemaKeyToTabs[metaKey] ??= []).push(meta.tab); - } - } - - const counts: Record = {}; - for (const tab of visibleTabs) { - if (tab.id === 'mcp' && activeConfigValues) { - const mcpValue = activeConfigValues.mcpServers; - counts[tab.id] = - mcpValue && typeof mcpValue === 'object' && !Array.isArray(mcpValue) - ? Object.keys(mcpValue).length - : 0; - continue; - } - - if (tab.id === 'custom' && activeConfigValues) { - const endpointsValue = activeConfigValues.endpoints as - | Record - | undefined; - const customArray = endpointsValue?.custom; - counts[tab.id] = Array.isArray(customArray) ? customArray.length : 0; - continue; - } - - const tabSections = schemaTree.filter((section: t.SchemaField) => { - if (HIDDEN_SECTIONS.has(section.key)) return false; - if (tab.id === OTHER_TAB.id) return !Object.hasOwn(SECTION_META, section.key); - return schemaKeyToTabs[section.key]?.includes(tab.id) ?? false; - }); - let count = 0; - for (const section of tabSections) { - const paths = section.children?.length - ? collectFieldPaths(section.children, section.key) - : [section.key]; - for (const p of paths) { - if (tab.id === 'providers' && p.startsWith('endpoints.custom')) continue; - if (activeConfiguredPaths.has(p)) count++; - } - } - counts[tab.id] = count; - } - return counts; - }, [activeConfiguredPaths, activeConfigValues, visibleTabs, schemaTree]); - - const scopeBaseline = useMemo(() => { - if (!isEditingScope) return flatBaseline; - return scopeResolvedValues ?? {}; - }, [isEditingScope, flatBaseline, scopeResolvedValues]); + const resetBase = useResetBaseConfig({ onReset: edits.resetEdits }); + const baseOverrides = useBaseOverrides({ schemaTree, layers, profiles }); - /** Container paths inferred from leaf baselines, used to tell apart subtree-deletes from no-op writes. */ - const baselineIntermediates = useMemo(() => { - const set = new Set(); - for (const leaf of Object.keys(scopeBaseline)) { - const parts = leaf.split('.'); - for (let i = 1; i < parts.length; i++) { - set.add(parts.slice(0, i).join('.')); - } - } - return set; - }, [scopeBaseline]); - - /** Container paths walked directly off the structured config, so an orphaned `{}` entry whose flatten dropped (or never produced) any leaf is still recognized as a real subtree-delete target. */ - const baselineContainerPaths = useMemo(() => { - const set = new Set(); - const walk = (obj: unknown, prefix: string): void => { - if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) return; - for (const k of Object.keys(obj as Record)) { - const path = prefix ? `${prefix}.${k}` : k; - const v = (obj as Record)[k]; - if (v !== null && typeof v === 'object' && !Array.isArray(v)) { - set.add(path); - walk(v, path); - } - } - }; - walk(baseActiveConfigValues, ''); - return set; - }, [baseActiveConfigValues]); + const imports = useConfigImport({ + writeChanges: save.writeChanges, + describeSkipped: save.describeSkipped, + }); - const handleFieldChange = useCallback( - (path: string, value: t.ConfigValue) => { - setTouchedPaths((prev) => { - if (prev.has(path)) return prev; - const next = new Set(prev); - next.add(path); - return next; - }); - setEditedValues((prev) => { - return applyConfigEdit( - prev, - path, - value, - scopeBaseline, - baselineIntermediates, - baselineContainerPaths, - ); - }); + /** Re-selecting the open scope keeps the edits; any other goes through the guard. */ + const handleScopeChange = useCallback( + (newSelection: t.ScopeSelection) => { + const current = editingScope ? formatScopeParam(editingScope) : undefined; + const next = newSelection.type === 'SCOPE' ? formatScopeParam(newSelection.scope) : undefined; + if (current === next && !scopeNotFound) return; + selectScope(newSelection); }, - [scopeBaseline, baselineIntermediates, baselineContainerPaths], + [editingScope, scopeNotFound, selectScope], + ); + const scopeActions = useMemo( + () => ({ selectScope: handleScopeChange, editingScope }), + [handleScopeChange, editingScope], ); - /** - * Removes `path` from `editedValues`/`touchedPaths` directly, bypassing - * `applyConfigEdit`'s baseline-match diffing. Abandoning an in-progress - * SecretField replacement (Cancel) is never a real edit — representing it - * as `onChange(path, undefined)` would mean the same thing as a real reset - * whenever a scope-resolved baseline happens to also read as empty. - */ - const handleDiscardField = useCallback((path: string) => { - setEditedValues((prev) => { - if (!(path in prev)) return prev; - const next = { ...prev }; - delete next[path]; - return next; - }); - setTouchedPaths((prev) => { - if (!prev.has(path)) return prev; - const next = new Set(prev); - next.delete(path); - return next; - }); - }, []); - - const isDirty = Object.keys(editedValues).length > 0; - - const pendingResets = useMemo(() => { - const resets = new Set(); - for (const [k, v] of Object.entries(editedValues)) { - if (v === undefined) resets.add(k); + const scopeKey = editingScope ? formatScopeParam(editingScope) : 'base'; + const lastScope = useRef({ key: scopeKey, name: editingScope?.name, param: initialScope }); + useEffect(() => { + const previous = lastScope.current; + if (previous.key === scopeKey) return; + lastScope.current = { key: scopeKey, name: editingScope?.name, param: initialScope }; + /** The open profile was deleted elsewhere (the link still names it): its edits can't be saved. */ + if (isDirty && scopeNotFound != null && previous.param === initialScope) { + notifyWarning( + localize('com_config_scope_vanished', { + name: previous.name ?? scopeNotFound, + count: Math.max(review.model.count, 1), + }), + ); } - return resets; - }, [editedValues]); + resetEdits(); + cancelReview(); + }, [ + scopeKey, + editingScope, + initialScope, + scopeNotFound, + isDirty, + review.model.count, + localize, + resetEdits, + cancelReview, + ]); - useBlocker({ - shouldBlockFn: ({ current, next }) => { - if (!isDirty) return false; - if (current.pathname === next.pathname) return false; - return !window.confirm(localize('com_config_unsaved_leave')); + const { data: availableScopes } = useQuery(availableScopesOptions); + const describeScope = useCallback( + (scope: string | undefined) => { + const param = parseScopeParam(scope); + if (!param) return localize('com_scope_base_config'); + return findScopeByParam(availableScopes ?? [], param)?.name ?? scope ?? ''; }, + [availableScopes, localize], + ); + const guard = useNavigationGuard({ + isDirty, + discardEdits, + openReview: save.openReview, + reviewOpen: save.confirmSaveOpen, + saving: save.saving, + describeScope, }); + const backToBase = useCallback(() => selectScope({ type: 'BASE' }), [selectScope]); + + /** The preview opens on whom the page edits: the open profile's principal, or everyone. */ + const [previewOpen, setPreviewOpen] = useState(false); + const previewTarget = useMemo( + (): t.PreviewTarget => + editingScope + ? { type: 'PRINCIPAL', principal: toScopeCandidate(editingScope) } + : { type: 'BASE' }, + [editingScope], + ); - const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - - const handleDiscard = useCallback(() => { - setEditedValues({}); - setTouchedPaths(new Set()); - setEditSessionId((id) => id + 1); - }, []); - - const clearEdits = useCallback(() => { - setEditedValues({}); - setTouchedPaths(new Set()); - setEditSessionId((id) => id + 1); - setConfirmSaveOpen(false); - setSaving(false); - setSaveError(null); - notifySuccess(localize('com_config_saved')); - }, [localize]); - - const invalidateAndResetBase = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); - clearEdits(); - }, [queryClient, clearEdits]); - - const invalidateAndResetScope = useCallback(() => { - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }); - queryClient.invalidateQueries({ queryKey: ['profileMap'] }); - queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); - clearEdits(); - }, [queryClient, clearEdits]); + const [scrollEl, setScrollEl] = useState(null); + const [tocEl, setTocEl] = useState(null); + const setActiveSection = useActiveSection(scrollEl, tocEl, activeTab); - const importMutation = useMutation({ - mutationFn: (config: Record) => importBaseConfigFn({ data: { config } }), - onError: (err: Error) => notifyError(err.message), - onSuccess: invalidateAndResetBase, + const tabSections = useTabSections({ + schemaTree, + activeTab, + activeConfigValues, + sectionPermissions, + editedValues, }); + const sectionsForActiveTab = useScopeGatedSections(tabSections, isEditingScope); - const [resetBaseOpen, setResetBaseOpen] = useState(false); - const [resettingBase, setResettingBase] = useState(false); - const [resetBaseError, setResetBaseError] = useState(null); - - const handleResetBaseConfig = useCallback(async () => { - if (resettingBase) return; - setResettingBase(true); - setResetBaseError(null); - try { - await resetBaseConfigFn(); - /** resolvedConfig holds each scope's own overrides (not a base merge), so a - * base reset doesn't make it stale on its own — but base-derived data - * (schemaDefaults, base values used for MCP inheritance) feeds scope mode, - * so flush it too, consistent with how scope saves invalidate. */ - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['baseConfig'] }), - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }), - ]); - setEditedValues({}); - setTouchedPaths(new Set()); - setEditSessionId((id) => id + 1); - setResettingBase(false); - setResetBaseOpen(false); - notifySuccess(localize('com_config_reset_base_success')); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setResettingBase(false); - setResetBaseError(message); - notifyError(message); - } - }, [resettingBase, queryClient, localize]); + const clearFieldParam = useCallback( + () => + navigate({ + search: (prev: t.ConfigSearchParams) => ({ ...prev, tab: activeTab, field: undefined }), + replace: true, + }), + [navigate, activeTab], + ); + useRevealField({ + container: scrollEl, + field: highlightField, + sections: sectionsForActiveTab, + ready: isReady, + onDone: clearFieldParam, + }); - const handleResetField = useCallback((fieldPath: string) => { - startTransition(() => { - setTouchedPaths((prev) => { - if (prev.has(fieldPath)) return prev; - const next = new Set(prev); - next.add(fieldPath); - return next; - }); - setEditedValues((prev) => ({ ...prev, [fieldPath]: undefined })); + const hasFilter = filterText.trim() !== ''; + /** + * The tab filter matches the pending values and the saved ones: a row the + * admin edits while it is matched (an entry renamed) keeps matching, so it + * never disappears mid-edit, and an entry pending deletion stays findable + * (with its Undo). New entries match by their pending values. + */ + const savedValues = isDirty ? layers.displayConfigValues : null; + const searchIndex = useConfigSearchIndex( + schemaTree, + hasFilter ? activeConfigValues : null, + localize, + hasFilter, + ); + const savedIndex = useConfigSearchIndex( + schemaTree, + hasFilter ? savedValues : null, + localize, + hasFilter && savedValues != null, + ); + const filterHits = useMemo(() => { + if (!hasFilter) return undefined; + const tabSectionIds = new Set(sectionsForActiveTab.map((section) => section.id)); + const onTab = (item: t.ConfigSearchItem) => + item.tab === activeTab && tabSectionIds.has(item.sectionId); + const hits = searchConfigIndex(searchIndex.filter(onTab), filterText, 500); + if (savedIndex.length === 0) return hits; + const seen = new Set(hits.map((hit) => hit.item.path)); + /** + * A saved hit inside a keyed list names a saved position; the page lists + * pending positions, so it moves with its entry (by identity). An entry + * pending deletion keeps its list shown (with its pending-deletion card) + * without matching whichever entry now sits at its old position. + */ + const saved = searchConfigIndex(savedIndex.filter(onTab), filterText, 500).flatMap((hit) => { + const path = moveEntryPath(hit.item.path, savedValues, activeConfigValues); + if (seen.has(path)) return []; + seen.add(path); + return [path === hit.item.path ? hit : { ...hit, item: { ...hit.item, path } }]; }); - }, []); - - const handleConfirmSave = useCallback(async () => { - if (saving) return; - const { touched, saves, resets } = buildSavePayload(touchedPaths, editedValues, schemaPathSet); - if (touched.length === 0) return; - - /** Per-leaf saves can land an MCP entry in a transport state whose required siblings are missing (e.g. type=stdio with no command/args). Server-side per-field validation only sees one path at a time, so do the cross-field check here against the merged effective entry before any PATCH fires. Use baseActiveConfigValues so scope-mode edits validate against the scope-resolved baseline (where prior scope overrides supply some required fields) instead of the base config alone. */ - const mcpBaseline = (() => { - const v = baseActiveConfigValues?.mcpServers; - if (v && typeof v === 'object' && !Array.isArray(v)) { - return v as Record; - } - return {}; - })(); - const mcpEdits: Array<[string, t.ConfigValue]> = touched - .filter((p) => p.startsWith('mcpServers.')) - .map((p) => [p, editedValues[p]] as [string, t.ConfigValue]); - /** A leaf reset (undefined write) removes the override and reveals the value of the next-lower layer. In scope mode that next layer is the base config; in base mode it is the un-merged YAML config (the baseOnly response). Feed whichever layer applies as the resetFallback so the cross-field validator does not falsely flag a reset-but-still-valid field as missing. */ - const mcpResetFallback = (() => { - const source = isEditingScope ? configValues?.mcpServers : baseConfigData?.yamlMcpServers; - if (source && typeof source === 'object' && !Array.isArray(source)) { - return source as Record; - } - return undefined; - })(); - if (mcpEdits.length > 0) { - const mcpErrors = validateMcpCrossField(mcpBaseline, mcpEdits, mcpResetFallback); - if (mcpErrors.length > 0) { - const { entryKey, missingField } = mcpErrors[0]; - const message = localize('com_config_mcp_invalid_after_edit', { - entry: entryKey, - field: missingField, - }); - setSaveError(message); - notifyError(message); - return; - } - } - - const inheritedMcpKeys = (() => { - const source = isEditingScope ? configValues?.mcpServers : undefined; - if (source && typeof source === 'object' && !Array.isArray(source)) { - return new Set(Object.keys(source as Record)); - } - return new Set(); - })(); - - setSaving(true); - setSaveError(null); - - try { - /** Resets must land before saves so a delete-then-recreate at the same path (e.g. MCP entry replaced with different fields) wipes stale fields first and the new leaf PATCHes don't race against the DELETE. */ - if (resets.length > 0) { - const resetPromises = isEditingScope - ? (() => { - const { resetPaths, tombstonePaths } = partitionScopeResetPaths( - resets, - inheritedMcpKeys, - ); - return [ - ...resetPaths.map((fieldPath) => - removeFieldProfileValueFn({ - data: { - fieldPath, - principalType: editingScope!.principalType, - principalId: editingScope!.principalId, - }, - }), - ), - ...tombstonePaths.map((fieldPath) => - tombstoneFieldProfileValueFn({ - data: { - fieldPath, - principalType: editingScope!.principalType, - principalId: editingScope!.principalId, - }, - }), - ), - ]; - })() - : resets.map((fieldPath) => resetBaseConfigFieldFn({ data: { fieldPath } })); - if (resetPromises.length > 0) { - await Promise.all(resetPromises); - } - } - - if (saves.length > 0) { - if (isEditingScope) { - await bulkSaveProfileValuesFn({ - data: { - principalType: editingScope!.principalType, - principalId: editingScope!.principalId, - entries: saves, - }, - }); - } else { - await saveBaseConfigFn({ data: { entries: saves } }); - } - } - - if (isEditingScope) { - invalidateAndResetScope(); - } else { - invalidateAndResetBase(); - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - setSaving(false); - setSaveError(message); - notifyError(message); - } + return [...hits, ...saved]; }, [ - touchedPaths, - editedValues, - schemaPathSet, - saving, - isEditingScope, - baseActiveConfigValues, - configValues, - baseConfigData, - localize, - editingScope, - invalidateAndResetScope, - invalidateAndResetBase, + hasFilter, + searchIndex, + savedIndex, + savedValues, + activeConfigValues, + sectionsForActiveTab, + activeTab, + filterText, ]); + const filterSectionIds = useMemo( + () => filterHits && new Set(filterHits.map((hit) => hit.item.sectionId)), + [filterHits], + ); + /** Matching rows, indexed; a section's own hit counts only when none of its rows match. */ + const filterPaths = useMemo(() => { + if (!filterHits) return undefined; + const withRows = new Set( + filterHits.filter((hit) => hit.item.kind !== 'section').map((hit) => hit.item.sectionId), + ); + return createPathIndex( + filterHits + .filter((hit) => hit.item.kind !== 'section' || !withRows.has(hit.item.sectionId)) + .map((hit) => hit.item.path), + ); + }, [filterHits]); + /** A jump (search, deep link) to a setting the tab filter hides clears the filter first. */ + useEffect(() => { + if (highlightField && filterPaths && !isRowVisible(filterPaths, highlightField)) clearFilter(); + }, [highlightField, filterPaths, clearFilter]); + + /** "Overridden by Global →" chips, on any tab, open the Global overrides setting that wins (D11). */ + const revealGlobal = useCallback( + (path: string) => + navigate({ + search: (prev: t.ConfigSearchParams) => ({ ...prev, tab: tabForPath(path), field: path }), + }), + [navigate], + ); - const serializedEditedValues = useMemo(() => { - const result: t.FlatConfigMap = {}; - for (const [k, v] of Object.entries(editedValues)) { - result[k] = stripSecretPreviewValues(deepSerializeKVPairs(v), k, schemaPathSet); - } - return result; - }, [editedValues, schemaPathSet]); - - const originalValuesForDialog = useMemo(() => { - const baseline = isEditingScope ? scopeBaseline : flatBaseline; - const result: t.FlatConfigMap = { ...baseline }; - for (const path of Object.keys(editedValues)) { - if (path in result) { - result[path] = stripSecretPreviewValues(result[path], path, schemaPathSet); - 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)[seg]; - } - if (current !== undefined) - result[path] = stripSecretPreviewValues(current, path, schemaPathSet); - } - return result; - }, [editedValues, flatBaseline, isEditingScope, scopeBaseline, configValues, schemaPathSet]); - - const [importSuccessMessage, setImportSuccessMessage] = useState(null); - - const showImportSuccess = useCallback((message?: string) => { - setImportSuccessMessage(message ?? null); - setImportSuccess(true); - clearTimeout(dismissTimer.current); - dismissTimer.current = setTimeout(() => setImportSuccess(false), 4000); - }, []); - - const handleImportAsProfile = useCallback( - async (appConfig: Record, scope: t.ConfigScope) => { - const normalized = normalizeImportConfig(appConfig); - const flat = flattenObject(normalized); - const entries = Object.entries(flat) - .filter( - ([fieldPath, value]) => - value != null && secretPathForPreviewPath(fieldPath, schemaPathSet) == null, - ) - .map(([fieldPath, value]) => ({ - fieldPath, - value: stripSecretPreviewValues(value, fieldPath, schemaPathSet), - })); - await bulkSaveProfileValuesFn({ - data: { - principalType: scope.principalType, - principalId: scope.principalId, - entries, - }, - }); - queryClient.invalidateQueries({ queryKey: ['profileMap'] }); - queryClient.invalidateQueries({ queryKey: ['resolvedConfig'] }); - queryClient.invalidateQueries({ queryKey: ['availableScopes'] }); - queryClient.invalidateQueries({ queryKey: ['roles'] }); - queryClient.invalidateQueries({ queryKey: ['groups'] }); - showImportSuccess( - localize('com_config_import_profile_success', { - count: entries.length, - name: scope.name, + const configuredPaths = layers.activeConfiguredPaths; + const revealSetting = useCallback( + (item: t.ConfigSearchItem) => { + const hidden = + customizedOnly && + ![...configuredPaths].some((p) => isPathUnder(p, item.path) || isPathUnder(item.path, p)); + navigate({ + search: (prev: t.ConfigSearchParams) => ({ + ...prev, + field: item.path, + ...(hidden && { customized: undefined }), }), - ); + replace: true, + }); }, - [queryClient, localize, showImportSuccess, schemaPathSet], + [navigate, customizedOnly, configuredPaths], ); - const handleImport = useCallback( - (appConfig: Record) => { - const normalized = normalizeImportConfig(appConfig); - if (isEditingScope && editingScope) { - handleImportAsProfile(normalized, editingScope).catch((err: Error) => { - notifyError(err.message); - }); - } else { - const stripped = stripSecretPreviewValues(normalized, '', schemaPathSet) as Record< - string, - t.ConfigValue - >; - importMutation.mutate(stripped, { onSuccess: () => showImportSuccess() }); - } - }, - [ - isEditingScope, - editingScope, - importMutation, - showImportSuccess, - handleImportAsProfile, - schemaPathSet, - ], + const tocSections = useMemo( + () => + filterSectionIds + ? sectionsForActiveTab.filter((section) => filterSectionIds.has(section.id)) + : sectionsForActiveTab, + [sectionsForActiveTab, filterSectionIds], ); - - const highlightRef = useHighlightRef(highlightField); - const [scrollEl, setScrollEl] = useState(null); - const [tocEl, setTocEl] = useState(null); - const scrollCallbackRef = useCallback( - (el: HTMLDivElement | null) => { - setScrollEl(el); - highlightRef(el); - }, - [highlightRef], + const tocEntries = useMemo( + () => buildTocEntries(tocSections, localize, filterPaths ?? null), + [tocSections, localize, filterPaths], ); - const setActiveSection = useActiveSection(scrollEl, tocEl, activeTab); - - const canEditActiveTab = editableTabIds.has(activeTab); - - /** Route-level gating ensures canView; canEdit reflects per-tab manage capability. */ - const permissions: t.ScopePermissions = useMemo( + const jumpTargets = useMemo(() => toJumpTargets(tocEntries), [tocEntries]); + const dirtyPaths = review.model.changedPaths; + const errorPaths = useMemo(() => Object.keys(save.fieldErrors), [save.fieldErrors]); + const restateEditable = canEditActiveTab && scopeNotFound == null; + const restates = useMemo( () => ({ - canView: true, - canEdit: canEditActiveTab, - canAssign: canAssignConfigs, + restates: edits.restateEdits, + onRestate: restateEditable ? edits.handleRestateCredential : undefined, }), - [canEditActiveTab, canAssignConfigs], + [edits.restateEdits, edits.handleRestateCredential, restateEditable], + ); + const entryRenames = useMemo( + () => ({ renames: edits.renameEdits, copies: edits.copyEdits }), + [edits.renameEdits, edits.copyEdits], + ); + /** "Jump to…" scrolls to the section and puts focus on its heading, as the list it was chosen from goes away. */ + const jumpTo = useCallback( + (id: string) => { + const entry = findTocEntry(tocEntries, id); + if (!entry || !scrollEl) return; + setActiveSection(id); + const isSection = entry.id === `section-${entry.sectionId}`; + revealConfigPath( + scrollEl, + { path: entry.path, sectionId: isSection ? undefined : entry.sectionId }, + { align: 'start', focus: 'heading', highlight: false }, + ); + }, + [tocEntries, scrollEl, setActiveSection], ); - const sectionsForActiveTab = useMemo((): t.ConfigSectionConfig[] => { - // Collect virtual section entries (those with schemaKey) that target this tab - const virtualEntries = Object.entries(SECTION_META).filter( - ([, m]) => m.schemaKey && m.tab === activeTab, - ); - - const directSections = schemaTree - .filter((section: t.SchemaField) => { - if (HIDDEN_SECTIONS.has(section.key)) return false; - if (activeTab === OTHER_TAB.id) return !Object.hasOwn(SECTION_META, section.key); - return SECTION_META[section.key]?.tab === activeTab; - }) - .map((section: t.SchemaField) => { - const meta = SECTION_META[section.key]; - const children = section.children ?? []; - const hasStructuredChildren = - (section.isObject || section.type === 'record') && children.length > 0; - return { - id: section.key, - titleKey: meta?.titleKey ?? `com_config_section_${section.key}`, - descriptionKey: meta?.descriptionKey, - fields: hasStructuredChildren ? children : [], - ...(!hasStructuredChildren && { sectionField: section }), - ...(section.key === 'interface' && { - bannerText: localize('com_config_interface_permissions_info'), - }), - }; - }); - - // Add virtual sections — these reference another schema section's data - // but render under a different tab with their own section renderer. - const virtualSections = virtualEntries.flatMap(([metaKey, meta]) => { - const schemaSection = schemaTree.find((s: t.SchemaField) => s.key === meta.schemaKey); - if (!schemaSection) return []; - const hasStructuredChildren = - (schemaSection.isObject || schemaSection.type === 'record') && - schemaSection.children && - schemaSection.children.length > 0; - return [ - { - id: metaKey, - schemaKey: meta.schemaKey, - titleKey: meta.titleKey, - descriptionKey: meta.descriptionKey, - fields: hasStructuredChildren ? (schemaSection.children ?? []) : [], - ...(!hasStructuredChildren && { sectionField: schemaSection }), - }, - ]; - }); - - const allSections: t.ConfigSectionConfig[] = [...directSections, ...virtualSections].filter( - (s) => { - const permKey = 'schemaKey' in s && s.schemaKey ? s.schemaKey : s.id; - return sectionPermissions[permKey]?.canView === true; - }, - ); - - // Custom Endpoints tab: show configured endpoint names in TOC - if (activeTab === 'custom' && activeConfigValues) { - for (const section of allSections) { - const dataKey = section.schemaKey ?? section.id; - const sectionValue = activeConfigValues[dataKey] as - | Record - | undefined; - const customArray = sectionValue?.custom; - section.titleKey = 'com_config_tab_custom_endpoints'; - if (Array.isArray(customArray) && customArray.length > 0) { - section.tocItems = customArray.map((entry, i) => { - const obj = - entry && typeof entry === 'object' && !Array.isArray(entry) - ? (entry as Record) - : {}; - const name = - typeof obj.name === 'string' && obj.name - ? obj.name - : localize('com_config_entry_n', { n: String(i + 1) }); - return { - id: `section-${dataKey}-custom-${i}`, - label: name, - dataPath: `${dataKey}.custom`, - }; - }); - } - } - } - - // MCP Servers tab: show configured server names in TOC - if (activeTab === 'mcp' && activeConfigValues) { - for (const section of allSections) { - if (section.id !== 'mcpServers') continue; - const dataKey = section.schemaKey ?? section.id; - const mcpValue = activeConfigValues[dataKey]; - if (mcpValue && typeof mcpValue === 'object' && !Array.isArray(mcpValue)) { - const serverKeys = Object.keys(mcpValue as Record); - if (serverKeys.length > 0) { - section.tocItems = serverKeys.map((name) => ({ - id: `section-mcpServers-${encodeURIComponent(name)}`, - label: name, - dataPath: `mcpServers.${name}`, - })); - } - } - } - } - - // AI Providers tab: show provider names in TOC (excluding 'custom') - if (activeTab === 'providers') { - for (const section of allSections) { - const providerFields = section.fields.filter( - (f) => f.key !== 'custom' && f.children && f.children.length > 0, - ); - if (providerFields.length > 0) { - const dataKey = section.schemaKey ?? section.id; - section.tocItems = providerFields.map((f) => ({ - id: `section-${dataKey}.${f.key}`, - label: localize(`com_config_field_${f.key}`), - })); - } - } + /** Top-level settings the bundled schema doesn't know (newer LibreChat), listed read-only on System. */ + const topLevelUnknown = useMemo(() => { + if (activeTab !== 'system' || !activeConfigValues) return undefined; + const value: Record = {}; + for (const [key, entry] of Object.entries(activeConfigValues)) { + if (!APP_SERVICE_INTERNAL_KEYS.has(key)) value[key] = entry; } - - return allSections; - }, [schemaTree, activeTab, activeConfigValues, localize, sectionPermissions]); + return { fields: schemaTree, value }; + }, [activeTab, activeConfigValues, schemaTree]); const renderBanner = () => { - if (importSuccess) { - return ( - - ); - } - return null; + if (!scopeNotFound) return null; + return ( + selectScope({ type: 'SCOPE', scope })} + /> + ); }; const banner = renderBanner(); + const importTitle = (() => { + if (!canManageConfig) { + return localize('com_cap_no_permission', { cap: SystemCapabilities.MANAGE_CONFIGS }); + } + if (isDirty) return localize('com_config_shell_import_dirty'); + return undefined; + })(); + const resetBaseTitle = (() => { if (!canManageConfig) { return localize('com_cap_no_permission', { cap: SystemCapabilities.MANAGE_CONFIGS }); @@ -953,212 +527,210 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi })(); return ( -
-
- {banner &&
{banner}
} - setImportOpen(true)} - showReset={!isEditingScope && dbOverridePaths.size > 0} - resetDisabled={isDirty || !canManageConfig} - resetTitle={resetBaseTitle} - onResetClick={() => { - setResetBaseError(null); - setResetBaseOpen(true); - }} - showScope={permissions.canView} - scopeSelection={selectedScope} - onScopeClick={() => setScopeSelectorOpen(true)} - /> - -
- -
-
- {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 && ( - - )} - {showReset && ( - - )} - {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 ? ( ) : null; - const pendingResetHint = isPendingReset ? ( - - {localize('com_config_pending_reset')} - - ) : null; - - const configuredDot = - isConfigured && !isPendingReset ? ( - + ) : 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}
+
+
+

+ {title} +

+ {description && ( +

+ {description} + {learnMoreUrl && ( + <> + {' '} + + {localize('com_ui_read_more')} + + + )} +

+ )} +
+ {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 > - - {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 ( +
+