mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-06 19:57:50 +00:00
feat: establish Settings layout system (#1321)
This commit is contained in:
parent
06c0bd848d
commit
400b9dce36
13 changed files with 1025 additions and 449 deletions
35
docs/design/SETTINGS-INFORMATION-ARCHITECTURE.md
Normal file
35
docs/design/SETTINGS-INFORMATION-ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Settings information architecture
|
||||
|
||||
This document is the reviewed navigation and layout contract for Settings. It keeps every existing destination available while replacing the flat 20-item list with stable task-oriented families.
|
||||
|
||||
## Navigation map
|
||||
|
||||
| Family | Destinations | Purpose |
|
||||
| ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| Core | General, Board, Tasks, Agents, Data | Everyday product behavior, execution defaults, and operational data |
|
||||
| Collaboration | Notifications, Multi-user, Workspaces, Delegation | People, communication, workspace membership, and delegated work |
|
||||
| Automation | Scheduler, Queues, Reflections, Trackers | Recurring work, queue observation, learning loops, and external tracking |
|
||||
| Governance | Security, Tool Policies, Enforcement, Shared Resources, Doc Freshness | Trust boundaries, policy, shared authority, and controlled knowledge |
|
||||
| System | Maintenance, Manage | Installation health, recovery, and managed taxonomies |
|
||||
|
||||
The family and destination order is canonical across desktop and compact navigation. Permission checks disable unavailable destinations without removing their location. `defaultTab` resolves only to an allowed destination and otherwise returns to the first allowed item.
|
||||
|
||||
In Board Only mode, General, Board, and Tasks are marked as the primary path. Every other destination remains in its normal family and carries an Advanced label. This preserves discovery and administrative access without giving the full advanced inventory equal emphasis.
|
||||
|
||||
## Layout contract
|
||||
|
||||
- `SettingsPage` provides one page title, short purpose statement, optional page-level status or actions, a consistent content width, and the page spacing rhythm.
|
||||
- `SettingsSection` provides the visible restart for one logical workflow: restrained border, neutral surface, heading, description, status, actions, and optional reset confirmation. Advanced and danger tones are semantic exceptions rather than decorative color.
|
||||
- `SettingRow` uses one responsive label/description and control grid. At narrow widths the control stacks below the label; at desktop widths controls share a stable column.
|
||||
- `SettingsFieldGrid` arranges related multi-field forms in one or two columns without horizontal scrolling.
|
||||
- `SettingsStatusCard` uses labeled neutral, success, warning, or error state. Color is supplemental to the icon, title, and description.
|
||||
- `SettingsLocalNav` is reserved for related sections on a long page. It uses in-page anchors, remains horizontally scrollable at narrow widths, and does not promote subsections into global destinations.
|
||||
- `SettingsHelpText`, `SettingsErrorText`, `SettingsUnit`, and `SettingsActionGroup` standardize secondary explanation, validation, numeric units, routine transfer actions, and destructive actions.
|
||||
|
||||
General, Board, Tasks, and Data are the reference migrations. Data demonstrates continuous local navigation; simpler pages omit it. Agents, Notifications, Multi-user, and Maintenance require dedicated workflow decomposition before migration because each combines several independent operational surfaces.
|
||||
|
||||
## Focus and responsive behavior
|
||||
|
||||
Changing a primary destination focuses and scrolls the page content to its heading. Arrow keys move through allowed destinations in canonical order. The sidebar scrolls independently at compact heights, while transfer and danger actions remain separated at its end. Compact layouts use the same grouped destination data in a select control.
|
||||
|
||||
The dialog grows to a wider desktop maximum while retaining viewport bounds. Page content is capped at a readable width. Rows and field grids stack below the small-screen breakpoint, semantic text remains visible at increased text size, and no section requires horizontal page scrolling.
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { SettingsDialog } from '@/components/settings/SettingsDialog';
|
||||
import { SETTINGS_NAVIGATION_GROUPS, SettingsDialog } from '@/components/settings/SettingsDialog';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
debouncedUpdate: vi.fn(),
|
||||
hasPermission: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
productMode: { selectedMode: 'advanced' as string },
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useFeatureSettings', () => ({
|
||||
|
|
@ -22,6 +23,7 @@ vi.mock('@/hooks/useFeatureSettings', () => ({
|
|||
docFreshness: {},
|
||||
archive: {},
|
||||
sharedResources: {},
|
||||
productMode: mocks.productMode,
|
||||
},
|
||||
}),
|
||||
useDebouncedFeatureUpdate: () => ({ debouncedUpdate: mocks.debouncedUpdate }),
|
||||
|
|
@ -98,6 +100,7 @@ vi.mock('@/components/settings/tabs/MultiUserTab', () => ({
|
|||
describe('SettingsDialog Mantine shell', () => {
|
||||
beforeEach(() => {
|
||||
mocks.hasPermission.mockReturnValue(true);
|
||||
mocks.productMode.selectedMode = 'advanced';
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
|
|
@ -117,6 +120,42 @@ describe('SettingsDialog Mantine shell', () => {
|
|||
expect(baseElement.querySelector('.mantine-Select-root')).toBeDefined();
|
||||
});
|
||||
|
||||
it('groups every destination exactly once and separates destructive actions', async () => {
|
||||
const { baseElement } = renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
|
||||
await screen.findByText('General settings loaded');
|
||||
const tabIds = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.tabs.map((tab) => tab.id));
|
||||
expect(tabIds).toHaveLength(20);
|
||||
expect(new Set(tabIds).size).toBe(20);
|
||||
expect(baseElement.querySelectorAll('[data-settings-nav-group]')).toHaveLength(5);
|
||||
expect(baseElement.querySelector('[data-settings-actions="routine"]')).not.toBeNull();
|
||||
expect(baseElement.querySelector('[data-settings-actions="danger"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps advanced destinations visible but de-emphasized in Board Only mode', async () => {
|
||||
mocks.productMode.selectedMode = 'board-only';
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
|
||||
await screen.findByText('General settings loaded');
|
||||
expect(screen.getByText('Board Only')).toBeDefined();
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Board' }).getAttribute('data-board-only-priority')
|
||||
).toBe('primary');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Agents' }).getAttribute('data-board-only-priority')
|
||||
).toBe('advanced');
|
||||
});
|
||||
|
||||
it('skips permission-disabled destinations during keyboard navigation', async () => {
|
||||
mocks.hasPermission.mockImplementation((permission: string) => permission !== 'agent:read');
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} defaultTab="agents" />);
|
||||
|
||||
const generalTab = screen.getByRole('tab', { name: 'General' });
|
||||
expect(screen.getByRole('tab', { name: 'Agents' }).hasAttribute('disabled')).toBe(true);
|
||||
fireEvent.keyDown(generalTab, { key: 'ArrowDown' });
|
||||
expect(await screen.findByText('Board settings loaded')).toBeDefined();
|
||||
});
|
||||
|
||||
it('switches tab content through the Mantine sidebar buttons', async () => {
|
||||
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, fireEvent, screen } from '@testing-library/react';
|
||||
import { SectionHeader, SettingRow, ToggleRow } from '@/components/settings/shared';
|
||||
import {
|
||||
SectionHeader,
|
||||
SettingRow,
|
||||
SettingsActionGroup,
|
||||
SettingsErrorText,
|
||||
SettingsFieldGrid,
|
||||
SettingsHelpText,
|
||||
SettingsLocalNav,
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
SettingsStatusCard,
|
||||
ToggleRow,
|
||||
} from '@/components/settings/shared';
|
||||
import { renderWithProviders } from './test-utils';
|
||||
|
||||
describe('Settings shared Mantine rows', () => {
|
||||
|
|
@ -8,7 +20,7 @@ describe('Settings shared Mantine rows', () => {
|
|||
cleanup();
|
||||
});
|
||||
|
||||
it('renders setting rows with direct Mantine layout and text primitives', () => {
|
||||
it('renders responsive setting rows with a stable control column', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SettingRow label="Example setting" description="Helpful context">
|
||||
<span>Control</span>
|
||||
|
|
@ -17,10 +29,50 @@ describe('Settings shared Mantine rows', () => {
|
|||
|
||||
expect(screen.getByText('Example setting')).toBeDefined();
|
||||
expect(screen.getByText('Helpful context')).toBeDefined();
|
||||
expect(container.querySelector('.mantine-Group-root')).toBeDefined();
|
||||
expect(container.querySelector('[data-settings-row]')?.className).toContain('sm:grid-cols-');
|
||||
expect(container.querySelector('.mantine-Text-root')).toBeDefined();
|
||||
});
|
||||
|
||||
it('provides the page, section, field-grid, and local-navigation layout contract', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<SettingsPage title="Example" description="Page context">
|
||||
<SettingsLocalNav label="Example sections" items={[{ id: 'section-one', label: 'One' }]} />
|
||||
<SettingsSection id="section-one" title="Section one" description="Section context">
|
||||
<SettingsFieldGrid>
|
||||
<span>Field one</span>
|
||||
<span>Field two</span>
|
||||
</SettingsFieldGrid>
|
||||
</SettingsSection>
|
||||
</SettingsPage>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Example' })).toBeDefined();
|
||||
expect(screen.getByRole('heading', { name: 'Section one' })).toBeDefined();
|
||||
expect(screen.getByRole('navigation', { name: 'Example sections' })).toBeDefined();
|
||||
expect(screen.getByRole('link', { name: 'One' }).getAttribute('href')).toBe('#section-one');
|
||||
expect(container.querySelector('[data-settings-field-grid]')?.className).toContain(
|
||||
'sm:grid-cols-2'
|
||||
);
|
||||
});
|
||||
|
||||
it('defines status, help, error, and destructive-action treatments', () => {
|
||||
const { container } = renderWithProviders(
|
||||
<>
|
||||
<SettingsStatusCard title="Connected" description="Provider is ready" tone="success" />
|
||||
<SettingsHelpText>Helpful context</SettingsHelpText>
|
||||
<SettingsErrorText>Fix this field</SettingsErrorText>
|
||||
<SettingsActionGroup label="Danger zone" tone="danger">
|
||||
<button type="button">Reset</button>
|
||||
</SettingsActionGroup>
|
||||
</>
|
||||
);
|
||||
|
||||
expect(container.querySelector('[data-settings-status="success"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-settings-help]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-settings-error][role="alert"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-settings-actions="danger"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders toggles through Mantine Switch and preserves checked changes', () => {
|
||||
const onCheckedChange = vi.fn();
|
||||
const { container } = renderWithProviders(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
import { useState, useRef, useCallback, lazy, Suspense, useEffect, useMemo } from 'react';
|
||||
import { Button, Group, Modal, ScrollArea, Select, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
|
||||
import { useIdentity } from '@/hooks/useIdentity';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
|
@ -30,7 +40,7 @@ import {
|
|||
} from 'lucide-react';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import type { ClientAuthPermission } from '@veritas-kanban/shared';
|
||||
import { SettingsErrorBoundary } from './shared';
|
||||
import { SettingsActionGroup, SettingsErrorBoundary } from './shared';
|
||||
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
|
||||
|
||||
// Lazy-load tab components
|
||||
|
|
@ -133,61 +143,110 @@ interface TabDef {
|
|||
label: string;
|
||||
icon: React.ElementType;
|
||||
requiredPermission?: ClientAuthPermission;
|
||||
boardOnlyPrimary?: boolean;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'general', label: 'General', icon: Settings2 },
|
||||
{ id: 'board', label: 'Board', icon: Layout },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo },
|
||||
{ id: 'agents', label: 'Agents', icon: Cpu, requiredPermission: 'agent:read' },
|
||||
{ id: 'data', label: 'Data', icon: Database, requiredPermission: 'backup:read' },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{ id: 'security', label: 'Security', icon: Shield, requiredPermission: 'settings:read' },
|
||||
{ id: 'multi-user', label: 'Multi-user', icon: UserCog, requiredPermission: 'workspace:read' },
|
||||
interface NavigationGroup {
|
||||
id: 'core' | 'collaboration' | 'automation' | 'governance' | 'system';
|
||||
label: string;
|
||||
tabs: TabDef[];
|
||||
}
|
||||
|
||||
export const SETTINGS_NAVIGATION_GROUPS: NavigationGroup[] = [
|
||||
{
|
||||
id: 'workspace-capabilities',
|
||||
label: 'Workspaces',
|
||||
icon: Network,
|
||||
requiredPermission: 'workspace:read',
|
||||
id: 'core',
|
||||
label: 'Core',
|
||||
tabs: [
|
||||
{ id: 'general', label: 'General', icon: Settings2, boardOnlyPrimary: true },
|
||||
{ id: 'board', label: 'Board', icon: Layout, boardOnlyPrimary: true },
|
||||
{ id: 'tasks', label: 'Tasks', icon: ListTodo, boardOnlyPrimary: true },
|
||||
{ id: 'agents', label: 'Agents', icon: Cpu, requiredPermission: 'agent:read' },
|
||||
{ id: 'data', label: 'Data', icon: Database, requiredPermission: 'backup:read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'scheduler',
|
||||
label: 'Scheduler',
|
||||
icon: CalendarClock,
|
||||
requiredPermission: 'workflow:read',
|
||||
id: 'collaboration',
|
||||
label: 'Collaboration',
|
||||
tabs: [
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{
|
||||
id: 'multi-user',
|
||||
label: 'Multi-user',
|
||||
icon: UserCog,
|
||||
requiredPermission: 'workspace:read',
|
||||
},
|
||||
{
|
||||
id: 'workspace-capabilities',
|
||||
label: 'Workspaces',
|
||||
icon: Network,
|
||||
requiredPermission: 'workspace:read',
|
||||
},
|
||||
{ id: 'delegation', label: 'Delegation', icon: Plane, requiredPermission: 'agent:read' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'queue-monitors',
|
||||
label: 'Queues',
|
||||
icon: ListChecks,
|
||||
requiredPermission: 'workflow:read',
|
||||
id: 'automation',
|
||||
label: 'Automation',
|
||||
tabs: [
|
||||
{
|
||||
id: 'scheduler',
|
||||
label: 'Scheduler',
|
||||
icon: CalendarClock,
|
||||
requiredPermission: 'workflow:read',
|
||||
},
|
||||
{
|
||||
id: 'queue-monitors',
|
||||
label: 'Queues',
|
||||
icon: ListChecks,
|
||||
requiredPermission: 'workflow:read',
|
||||
},
|
||||
{
|
||||
id: 'reflections',
|
||||
label: 'Reflections',
|
||||
icon: BrainCircuit,
|
||||
requiredPermission: 'workflow:read',
|
||||
},
|
||||
{
|
||||
id: 'trackers',
|
||||
label: 'Trackers',
|
||||
icon: Waypoints,
|
||||
requiredPermission: 'settings:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'reflections',
|
||||
label: 'Reflections',
|
||||
icon: BrainCircuit,
|
||||
requiredPermission: 'workflow:read',
|
||||
id: 'governance',
|
||||
label: 'Governance',
|
||||
tabs: [
|
||||
{ id: 'security', label: 'Security', icon: Shield, requiredPermission: 'settings:read' },
|
||||
{
|
||||
id: 'tool-policies',
|
||||
label: 'Tool Policies',
|
||||
icon: Lock,
|
||||
requiredPermission: 'policy:read',
|
||||
},
|
||||
{
|
||||
id: 'enforcement',
|
||||
label: 'Enforcement',
|
||||
icon: CheckCircle2,
|
||||
requiredPermission: 'policy:read',
|
||||
},
|
||||
{ id: 'shared-resources', label: 'Shared Resources', icon: Boxes },
|
||||
{ id: 'doc-freshness', label: 'Doc Freshness', icon: BookOpen },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'trackers',
|
||||
label: 'Trackers',
|
||||
icon: Waypoints,
|
||||
requiredPermission: 'settings:read',
|
||||
id: 'system',
|
||||
label: 'System',
|
||||
tabs: [
|
||||
{ id: 'maintenance', label: 'Maintenance', icon: Wrench, requiredPermission: 'backup:read' },
|
||||
{ id: 'manage', label: 'Manage', icon: Archive, requiredPermission: 'backup:read' },
|
||||
],
|
||||
},
|
||||
{ id: 'maintenance', label: 'Maintenance', icon: Wrench, requiredPermission: 'backup:read' },
|
||||
{ id: 'delegation', label: 'Delegation', icon: Plane, requiredPermission: 'agent:read' },
|
||||
{ id: 'tool-policies', label: 'Tool Policies', icon: Lock, requiredPermission: 'policy:read' },
|
||||
{
|
||||
id: 'enforcement',
|
||||
label: 'Enforcement',
|
||||
icon: CheckCircle2,
|
||||
requiredPermission: 'policy:read',
|
||||
},
|
||||
{ id: 'shared-resources', label: 'Shared Resources', icon: Boxes },
|
||||
{ id: 'doc-freshness', label: 'Doc Freshness', icon: BookOpen },
|
||||
{ id: 'manage', label: 'Manage', icon: Archive, requiredPermission: 'backup:read' },
|
||||
];
|
||||
|
||||
const TABS = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.tabs);
|
||||
|
||||
// ============ Settings Dialog Props ============
|
||||
|
||||
interface SettingsDialogProps {
|
||||
|
|
@ -201,19 +260,24 @@ interface SettingsDialogProps {
|
|||
export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialogProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('general');
|
||||
const { hasPermission } = useIdentity();
|
||||
const { settings: currentSettings } = useFeatureSettings();
|
||||
const canWriteSettings = hasPermission('settings:write');
|
||||
const canUseTab = useCallback(
|
||||
(tab: TabDef) => !tab.requiredPermission || hasPermission(tab.requiredPermission),
|
||||
[hasPermission]
|
||||
);
|
||||
const isBoardOnly = currentSettings.productMode?.selectedMode === 'board-only';
|
||||
const mobileTabOptions = useMemo(
|
||||
() =>
|
||||
TABS.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: tab.label,
|
||||
disabled: !canUseTab(tab),
|
||||
SETTINGS_NAVIGATION_GROUPS.map((group) => ({
|
||||
group: group.label,
|
||||
items: group.tabs.map((tab) => ({
|
||||
value: tab.id,
|
||||
label: `${tab.label}${isBoardOnly && !tab.boardOnlyPrimary ? ' · Advanced' : ''}`,
|
||||
disabled: !canUseTab(tab),
|
||||
})),
|
||||
})),
|
||||
[canUseTab]
|
||||
[canUseTab, isBoardOnly]
|
||||
);
|
||||
|
||||
// Set active tab when defaultTab changes
|
||||
|
|
@ -230,7 +294,6 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
setActiveTab(TABS.find(canUseTab)?.id ?? 'general');
|
||||
}
|
||||
}, [activeTab, canUseTab]);
|
||||
const { settings: currentSettings } = useFeatureSettings();
|
||||
const { debouncedUpdate } = useDebouncedFeatureUpdate();
|
||||
const settingsFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
|
@ -251,6 +314,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
useEffect(() => {
|
||||
if (contentAreaRef.current) {
|
||||
contentAreaRef.current.focus();
|
||||
contentAreaRef.current.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
|
|
@ -522,7 +586,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={<span className="sr-only">Settings</span>}
|
||||
size={800}
|
||||
size={1040}
|
||||
padding={0}
|
||||
centered
|
||||
trapFocus
|
||||
|
|
@ -545,9 +609,21 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
onKeyDown={handleDialogKeyDown}
|
||||
>
|
||||
{/* Sidebar Tabs — hidden on narrow screens, shown as dropdown instead */}
|
||||
<div className="hidden min-h-0 w-48 flex-col border-r bg-muted/30 py-4 sm:flex">
|
||||
<div className="hidden min-h-0 w-56 flex-col border-r bg-muted/25 py-4 sm:flex">
|
||||
<div className="px-4 pb-3">
|
||||
<h2 className="text-sm font-semibold">Settings</h2>
|
||||
<Group gap="xs">
|
||||
<h2 className="text-sm font-semibold">Settings</h2>
|
||||
{isBoardOnly && (
|
||||
<Badge size="xs" variant="light" color="cyan">
|
||||
Board Only
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{isBoardOnly
|
||||
? 'Board essentials first. Advanced settings remain available.'
|
||||
: 'Workspace configuration and governance.'}
|
||||
</Text>
|
||||
</div>
|
||||
<nav
|
||||
className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pr-1"
|
||||
|
|
@ -555,41 +631,78 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
aria-orientation="vertical"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{TABS.map((tab, index) => {
|
||||
const Icon = tab.icon;
|
||||
const allowed = canUseTab(tab);
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
ref={index === 0 ? firstTabButtonRef : undefined}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls="settings-tab-content"
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
disabled={!allowed}
|
||||
title={allowed ? tab.label : `${tab.requiredPermission} permission required`}
|
||||
variant={active ? 'light' : 'subtle'}
|
||||
color={active ? 'violet' : 'gray'}
|
||||
<Stack gap="sm">
|
||||
{SETTINGS_NAVIGATION_GROUPS.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
role="group"
|
||||
aria-labelledby={`settings-group-${group.id}`}
|
||||
data-settings-nav-group={group.id}
|
||||
>
|
||||
<Text
|
||||
id={`settings-group-${group.id}`}
|
||||
size="xs"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<Icon className="h-4 w-4 flex-shrink-0" />}
|
||||
c="dimmed"
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
px="xs"
|
||||
mb={3}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{group.label}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
{group.tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const allowed = canUseTab(tab);
|
||||
const active = activeTab === tab.id;
|
||||
const advancedInBoardOnly = isBoardOnly && !tab.boardOnlyPrimary;
|
||||
return (
|
||||
<Button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
ref={tab.id === 'general' ? firstTabButtonRef : undefined}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-label={tab.label}
|
||||
aria-selected={active}
|
||||
aria-controls="settings-tab-content"
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
disabled={!allowed}
|
||||
title={
|
||||
allowed ? tab.label : `${tab.requiredPermission} permission required`
|
||||
}
|
||||
variant={active ? 'light' : 'subtle'}
|
||||
color={active ? 'violet' : 'gray'}
|
||||
size="xs"
|
||||
radius="md"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<Icon className="h-4 w-4 flex-shrink-0" />}
|
||||
data-board-only-priority={advancedInBoardOnly ? 'advanced' : 'primary'}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate">{tab.label}</span>
|
||||
{advancedInBoardOnly && (
|
||||
<span
|
||||
className="rounded bg-muted px-1 py-0.5 text-[9px] font-bold uppercase tracking-wide text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
Advanced
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</nav>
|
||||
|
||||
{/* Import/Export/Reset */}
|
||||
<Stack gap={4} className="mt-auto shrink-0 border-t px-3 pt-3 pb-6">
|
||||
<Stack gap="sm" className="mt-auto shrink-0 border-t px-3 pt-3 pb-6">
|
||||
<input
|
||||
ref={settingsFileInputRef}
|
||||
type="file"
|
||||
|
|
@ -598,46 +711,52 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
className="hidden"
|
||||
aria-label="Import settings file"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExportSettings}
|
||||
aria-label="Export settings as JSON file"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<Download className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
|
||||
>
|
||||
Export Settings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => settingsFileInputRef.current?.click()}
|
||||
disabled={!canWriteSettings}
|
||||
aria-label="Import settings from JSON file"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<Upload className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
|
||||
>
|
||||
Import Settings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canWriteSettings}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<RotateCcw className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
onClick={() => setResetAllOpen(true)}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
<SettingsActionGroup label="Transfer">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleExportSettings}
|
||||
aria-label="Export settings as JSON file"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={
|
||||
<Download className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
|
||||
}
|
||||
>
|
||||
Export Settings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => settingsFileInputRef.current?.click()}
|
||||
disabled={!canWriteSettings}
|
||||
aria-label="Import settings from JSON file"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<Upload className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
|
||||
>
|
||||
Import Settings
|
||||
</Button>
|
||||
</SettingsActionGroup>
|
||||
<SettingsActionGroup label="Danger zone" tone="danger">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canWriteSettings}
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
fullWidth
|
||||
justify="flex-start"
|
||||
leftSection={<RotateCcw className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
onClick={() => setResetAllOpen(true)}
|
||||
>
|
||||
Reset All
|
||||
</Button>
|
||||
</SettingsActionGroup>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
|
|
@ -662,7 +781,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
|
|||
<div
|
||||
id="settings-tab-content"
|
||||
ref={contentAreaRef}
|
||||
className="px-4 py-4 focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 sm:px-6"
|
||||
className="mx-auto w-full max-w-3xl px-4 py-4 focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 sm:px-6 sm:py-6"
|
||||
role="tabpanel"
|
||||
tabIndex={-1}
|
||||
aria-labelledby={`tab-${activeTab}`}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, useState, useEffect } from 'react';
|
||||
import { NumberInput } from '@mantine/core';
|
||||
import { SettingRow } from './SettingRow';
|
||||
import { SettingsUnit } from './SettingsLayout';
|
||||
|
||||
export const NumberRow = memo(function NumberRow({
|
||||
label,
|
||||
|
|
@ -83,7 +84,7 @@ export const NumberRow = memo(function NumberRow({
|
|||
className="w-28"
|
||||
styles={{ input: { textAlign: 'right' } }}
|
||||
/>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
{unit && <SettingsUnit>{unit}</SettingsUnit>}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
|
|
@ -107,7 +108,7 @@ export const NumberRow = memo(function NumberRow({
|
|||
className="w-24"
|
||||
styles={{ input: { textAlign: 'right' } }}
|
||||
/>
|
||||
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
|
||||
{unit && <SettingsUnit>{unit}</SettingsUnit>}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,25 @@
|
|||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, Group, Modal, Stack, Text } from '@mantine/core';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function SectionHeader({ title, onReset }: { title: string; onReset?: () => void }) {
|
||||
export function SectionHeader({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
actions,
|
||||
onReset,
|
||||
contained = false,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
onReset?: () => void;
|
||||
contained?: boolean;
|
||||
}) {
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
|
||||
const handleReset = () => {
|
||||
|
|
@ -11,41 +28,62 @@ export function SectionHeader({ title, onReset }: { title: string; onReset?: ()
|
|||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" align="center" className="mb-2 border-b pb-2">
|
||||
<Text size="sm" fw={600} c="dimmed" tt="uppercase">
|
||||
{title}
|
||||
</Text>
|
||||
{onReset && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="gray"
|
||||
leftSection={<RotateCcw className="h-3 w-3" />}
|
||||
onClick={() => setResetOpen(true)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Modal
|
||||
opened={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
title="Reset to defaults?"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
This will reset all {title.toLowerCase()} settings to their default values.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={() => setResetOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
gap="md"
|
||||
wrap="wrap"
|
||||
className={contained ? 'mb-2 border-b pb-3' : 'mb-2 border-b pb-2'}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Text id={id} component="h3" size="sm" fw={650}>
|
||||
{title}
|
||||
</Text>
|
||||
{status}
|
||||
</Group>
|
||||
{description && (
|
||||
<Text size="xs" c="dimmed" mt={3}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{(actions || onReset) && (
|
||||
<Group gap="xs">
|
||||
{actions}
|
||||
{onReset && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
color="gray"
|
||||
leftSection={<RotateCcw className="h-3 w-3" />}
|
||||
onClick={() => setResetOpen(true)}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Modal
|
||||
opened={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
title="Reset to defaults?"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
This will reset all {title.toLowerCase()} settings to their default values.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="subtle" color="gray" onClick={() => setResetOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleReset}>Reset</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { memo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Group, Stack, Text } from '@mantine/core';
|
||||
import { Stack, Text } from '@mantine/core';
|
||||
|
||||
export const SettingRow = memo(function SettingRow({
|
||||
label,
|
||||
|
|
@ -12,7 +12,10 @@ export const SettingRow = memo(function SettingRow({
|
|||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" gap="md" py="sm" wrap="nowrap">
|
||||
<div
|
||||
className="grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)] sm:items-center"
|
||||
data-settings-row
|
||||
>
|
||||
<Stack gap={2} className="min-w-0 flex-1">
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
|
|
@ -23,7 +26,9 @@ export const SettingRow = memo(function SettingRow({
|
|||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</Group>
|
||||
<div className="min-w-0 sm:justify-self-end [&_.mantine-InputWrapper-root]:w-full [&_.mantine-Select-root]:w-full">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
208
web/src/components/settings/shared/SettingsLayout.tsx
Normal file
208
web/src/components/settings/shared/SettingsLayout.tsx
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import type { ReactNode } from 'react';
|
||||
import { Alert, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { Info, TriangleAlert } from 'lucide-react';
|
||||
import { SectionHeader } from './SectionHeader';
|
||||
|
||||
export function SettingsPage({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="lg" data-settings-page={title.toLowerCase().replace(/\s+/g, '-')}>
|
||||
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
|
||||
<div className="min-w-0 max-w-2xl">
|
||||
<Text component="h2" size="lg" fw={700} lh={1.25}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
{actions && <div className="shrink-0">{actions}</div>}
|
||||
</Group>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
actions,
|
||||
onReset,
|
||||
tone = 'default',
|
||||
divided = false,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
onReset?: () => void;
|
||||
tone?: 'default' | 'advanced' | 'danger';
|
||||
divided?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const headingId = id ? `${id}-heading` : undefined;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
component="section"
|
||||
id={id}
|
||||
aria-labelledby={headingId}
|
||||
withBorder
|
||||
radius="md"
|
||||
p={{ base: 'sm', sm: 'md' }}
|
||||
data-settings-section={tone}
|
||||
className={
|
||||
tone === 'danger'
|
||||
? 'border-red-500/35 bg-red-500/[0.035]'
|
||||
: tone === 'advanced'
|
||||
? 'bg-muted/20'
|
||||
: 'bg-card'
|
||||
}
|
||||
>
|
||||
<SectionHeader
|
||||
id={headingId}
|
||||
title={title}
|
||||
description={description}
|
||||
status={status}
|
||||
actions={actions}
|
||||
onReset={onReset}
|
||||
contained
|
||||
/>
|
||||
<div className={divided ? 'divide-y' : undefined}>{children}</div>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsFieldGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2" data-settings-field-grid>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsStatusCard({
|
||||
title,
|
||||
description,
|
||||
tone = 'neutral',
|
||||
actions,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
tone?: 'neutral' | 'success' | 'warning' | 'error';
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const color =
|
||||
tone === 'error'
|
||||
? 'red'
|
||||
: tone === 'warning'
|
||||
? 'yellow'
|
||||
: tone === 'success'
|
||||
? 'green'
|
||||
: 'gray';
|
||||
const Icon = tone === 'warning' || tone === 'error' ? TriangleAlert : Info;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color={color}
|
||||
variant="light"
|
||||
role={tone === 'warning' || tone === 'error' ? 'alert' : 'status'}
|
||||
icon={<Icon className="h-4 w-4" aria-hidden="true" />}
|
||||
title={title}
|
||||
data-settings-status={tone}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" gap="sm" wrap="wrap">
|
||||
<Text size="sm">{description}</Text>
|
||||
{actions}
|
||||
</Group>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsLocalNav({
|
||||
label,
|
||||
items,
|
||||
}: {
|
||||
label: string;
|
||||
items: Array<{ id: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
aria-label={label}
|
||||
className="sticky top-0 z-10 -mx-1 overflow-x-auto bg-background/95 px-1 py-2 backdrop-blur"
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{items.map((item) => (
|
||||
<a
|
||||
key={item.id}
|
||||
href={`#${item.id}`}
|
||||
className="whitespace-nowrap rounded-md border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground hover:border-primary/50 hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</Group>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsHelpText({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="xs" c="dimmed" data-settings-help>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsErrorText({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text size="xs" c="red" role="alert" data-settings-error>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsActionGroup({
|
||||
label,
|
||||
tone = 'routine',
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
tone?: 'routine' | 'danger';
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack
|
||||
gap={4}
|
||||
aria-label={label}
|
||||
data-settings-actions={tone}
|
||||
className={tone === 'danger' ? 'border-t border-red-500/30 pt-2' : undefined}
|
||||
>
|
||||
<Text size="xs" c={tone === 'danger' ? 'red' : 'dimmed'} fw={700} tt="uppercase">
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsUnit({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text component="span" size="xs" c="dimmed" className="min-w-10 text-left">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,3 +4,14 @@ export { NumberRow } from './NumberRow';
|
|||
export { SectionHeader } from './SectionHeader';
|
||||
export { SaveIndicator } from './SaveIndicator';
|
||||
export { SettingsErrorBoundary } from './SettingsErrorBoundary';
|
||||
export {
|
||||
SettingsActionGroup,
|
||||
SettingsErrorText,
|
||||
SettingsFieldGrid,
|
||||
SettingsHelpText,
|
||||
SettingsLocalNav,
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
SettingsStatusCard,
|
||||
SettingsUnit,
|
||||
} from './SettingsLayout';
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ import {
|
|||
normalizeBoardColumns,
|
||||
normalizeBoardDefaultStatus,
|
||||
type BoardColumnConfig,
|
||||
type BoardSettings,
|
||||
type DashboardWidgetSettings,
|
||||
} from '@veritas-kanban/shared';
|
||||
import { SettingRow, ToggleRow, SectionHeader, SaveIndicator } from '../shared';
|
||||
import { SettingRow, ToggleRow, SaveIndicator, SettingsPage, SettingsSection } from '../shared';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
export function BoardTab() {
|
||||
|
|
@ -17,7 +18,7 @@ export function BoardTab() {
|
|||
const columns = normalizeBoardColumns(boardSettings.columns);
|
||||
const defaultStatus = normalizeBoardDefaultStatus(boardSettings.defaultStatus, columns);
|
||||
|
||||
const update = (key: string, value: any) => {
|
||||
const update = <K extends keyof BoardSettings>(key: K, value: BoardSettings[K]) => {
|
||||
debouncedUpdate({ board: { [key]: value } });
|
||||
};
|
||||
|
||||
|
|
@ -78,12 +79,18 @@ export function BoardTab() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionHeader title="Board & Display" onReset={resetBoard} />
|
||||
<SaveIndicator isPending={isPending} />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
<SettingsPage
|
||||
title="Board"
|
||||
description="Control board structure, card density, visible metadata, and direct manipulation."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
>
|
||||
<SettingsSection
|
||||
id="board-display"
|
||||
title="Board and display"
|
||||
description="Changes apply to the shared board experience."
|
||||
onReset={resetBoard}
|
||||
divided
|
||||
>
|
||||
<ToggleRow
|
||||
label="Show Dashboard"
|
||||
description="Display the metrics dashboard section above the board"
|
||||
|
|
@ -151,7 +158,9 @@ export function BoardTab() {
|
|||
<SettingRow label="Card Density" description="Compact cards use less space">
|
||||
<Select
|
||||
value={boardSettings.cardDensity ?? DEFAULT_FEATURE_SETTINGS.board.cardDensity}
|
||||
onChange={(value) => value && update('cardDensity', value)}
|
||||
onChange={(value) =>
|
||||
value && update('cardDensity', value as BoardSettings['cardDensity'])
|
||||
}
|
||||
data={[
|
||||
{ value: 'normal', label: 'Normal' },
|
||||
{ value: 'compact', label: 'Compact' },
|
||||
|
|
@ -272,7 +281,7 @@ export function BoardTab() {
|
|||
checked={boardSettings.showDoneMetrics ?? DEFAULT_FEATURE_SETTINGS.board.showDoneMetrics}
|
||||
onCheckedChange={(v) => update('showDoneMetrics', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import { Select, TextInput } from '@mantine/core';
|
||||
import { ToggleRow, NumberRow, SectionHeader, SaveIndicator, SettingRow } from '../shared';
|
||||
import {
|
||||
ToggleRow,
|
||||
NumberRow,
|
||||
SaveIndicator,
|
||||
SettingRow,
|
||||
SettingsLocalNav,
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
} from '../shared';
|
||||
|
||||
export function DataTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
|
|
@ -45,14 +53,28 @@ export function DataTab() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionHeader title="Telemetry & Data" onReset={resetData} />
|
||||
<SaveIndicator isPending={isPending} />
|
||||
</div>
|
||||
<SettingsPage
|
||||
title="Data"
|
||||
description="Manage telemetry retention, operating budgets, and archive lifecycle."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
>
|
||||
<SettingsLocalNav
|
||||
label="Data settings sections"
|
||||
items={[
|
||||
{ id: 'data-telemetry', label: 'Telemetry' },
|
||||
{ id: 'data-budget', label: 'Budget' },
|
||||
{ id: 'data-archive', label: 'Archive' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Telemetry */}
|
||||
<div className="divide-y">
|
||||
<SettingsSection
|
||||
id="data-telemetry"
|
||||
title="Telemetry and data"
|
||||
description="Event collection and retention. Reset restores all settings on this page."
|
||||
onReset={resetData}
|
||||
divided
|
||||
>
|
||||
<ToggleRow
|
||||
label="Telemetry Collection"
|
||||
description="Master toggle for all telemetry event collection"
|
||||
|
|
@ -94,221 +116,221 @@ export function DataTab() {
|
|||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Budget Tracking */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Budget Tracking
|
||||
</h4>
|
||||
<div className="divide-y">
|
||||
<ToggleRow
|
||||
label="Budget Tracking"
|
||||
description="Track monthly token usage against budget limits"
|
||||
checked={settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled}
|
||||
onCheckedChange={(v) => updateBudget('enabled', v)}
|
||||
/>
|
||||
{(settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled) && (
|
||||
<>
|
||||
<NumberRow
|
||||
label="Monthly Token Limit"
|
||||
description="Set monthly token budget (0 = no limit)"
|
||||
value={
|
||||
settings.budget?.monthlyTokenLimit ??
|
||||
DEFAULT_FEATURE_SETTINGS.budget.monthlyTokenLimit
|
||||
}
|
||||
onChange={(v) => updateBudget('monthlyTokenLimit', v)}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
unit="tokens"
|
||||
hideSpinners
|
||||
maxLength={10}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Monthly Cost Limit"
|
||||
description="Set monthly cost budget in dollars (0 = no limit)"
|
||||
value={
|
||||
settings.budget?.monthlyCostLimit ??
|
||||
DEFAULT_FEATURE_SETTINGS.budget.monthlyCostLimit
|
||||
}
|
||||
onChange={(v) => updateBudget('monthlyCostLimit', v)}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
unit="USD"
|
||||
hideSpinners
|
||||
maxLength={10}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Warning Threshold"
|
||||
description="Show warning when usage exceeds this percentage of budget"
|
||||
value={
|
||||
settings.budget?.warningThreshold ??
|
||||
DEFAULT_FEATURE_SETTINGS.budget.warningThreshold
|
||||
}
|
||||
onChange={(v) => updateBudget('warningThreshold', v)}
|
||||
min={50}
|
||||
max={99}
|
||||
step={5}
|
||||
unit="%"
|
||||
hideSpinners
|
||||
maxLength={2}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Default Run Budget"
|
||||
description="Enforce workspace defaults on agent and workflow runs"
|
||||
checked={defaultRunBudget?.enabled ?? false}
|
||||
onCheckedChange={(v) =>
|
||||
updateDefaultRunBudget({
|
||||
enabled: v,
|
||||
scope: 'workspace',
|
||||
name: defaultRunBudget?.name ?? 'Workspace default run budget',
|
||||
})
|
||||
}
|
||||
/>
|
||||
{(defaultRunBudget?.enabled ?? false) && (
|
||||
<>
|
||||
<NumberRow
|
||||
label="Run Token Limit"
|
||||
description="Maximum total tokens per run (0 = no limit)"
|
||||
value={defaultRunLimits.totalTokens ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('totalTokens', v)}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
unit="tokens"
|
||||
hideSpinners
|
||||
maxLength={10}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Run Cost Limit"
|
||||
description="Maximum provider-reported spend per run (0 = no limit)"
|
||||
value={defaultRunLimits.costUsd ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('costUsd', v)}
|
||||
min={0}
|
||||
max={1_000_000}
|
||||
unit="USD"
|
||||
hideSpinners
|
||||
maxLength={8}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Tool Call Limit"
|
||||
description="Maximum counted tool calls per run (0 = no limit)"
|
||||
value={defaultRunLimits.toolCalls ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('toolCalls', v)}
|
||||
min={0}
|
||||
max={100_000}
|
||||
unit="calls"
|
||||
hideSpinners
|
||||
maxLength={6}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Runtime Limit"
|
||||
description="Maximum wall-clock runtime per run (0 = no limit)"
|
||||
value={defaultRunLimits.runtimeSeconds ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('runtimeSeconds', v)}
|
||||
min={0}
|
||||
max={604_800}
|
||||
unit="sec"
|
||||
hideSpinners
|
||||
maxLength={6}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Retry Limit"
|
||||
description="Maximum workflow retry count per run (0 = no limit)"
|
||||
value={defaultRunLimits.retries ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('retries', v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="retries"
|
||||
hideSpinners
|
||||
maxLength={3}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Fan-out Limit"
|
||||
description="Maximum parallel branch width per workflow run (0 = no limit)"
|
||||
value={defaultRunLimits.fanOut ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('fanOut', v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="branches"
|
||||
hideSpinners
|
||||
maxLength={3}
|
||||
<SettingsSection
|
||||
id="data-budget"
|
||||
title="Budget tracking"
|
||||
description="Monthly and per-run limits with explicit hard-threshold behavior."
|
||||
divided
|
||||
>
|
||||
<ToggleRow
|
||||
label="Budget Tracking"
|
||||
description="Track monthly token usage against budget limits"
|
||||
checked={settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled}
|
||||
onCheckedChange={(v) => updateBudget('enabled', v)}
|
||||
/>
|
||||
{(settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled) && (
|
||||
<>
|
||||
<NumberRow
|
||||
label="Monthly Token Limit"
|
||||
description="Set monthly token budget (0 = no limit)"
|
||||
value={
|
||||
settings.budget?.monthlyTokenLimit ??
|
||||
DEFAULT_FEATURE_SETTINGS.budget.monthlyTokenLimit
|
||||
}
|
||||
onChange={(v) => updateBudget('monthlyTokenLimit', v)}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
unit="tokens"
|
||||
hideSpinners
|
||||
maxLength={10}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Monthly Cost Limit"
|
||||
description="Set monthly cost budget in dollars (0 = no limit)"
|
||||
value={
|
||||
settings.budget?.monthlyCostLimit ??
|
||||
DEFAULT_FEATURE_SETTINGS.budget.monthlyCostLimit
|
||||
}
|
||||
onChange={(v) => updateBudget('monthlyCostLimit', v)}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
unit="USD"
|
||||
hideSpinners
|
||||
maxLength={10}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Warning Threshold"
|
||||
description="Show warning when usage exceeds this percentage of budget"
|
||||
value={
|
||||
settings.budget?.warningThreshold ??
|
||||
DEFAULT_FEATURE_SETTINGS.budget.warningThreshold
|
||||
}
|
||||
onChange={(v) => updateBudget('warningThreshold', v)}
|
||||
min={50}
|
||||
max={99}
|
||||
step={5}
|
||||
unit="%"
|
||||
hideSpinners
|
||||
maxLength={2}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Default Run Budget"
|
||||
description="Enforce workspace defaults on agent and workflow runs"
|
||||
checked={defaultRunBudget?.enabled ?? false}
|
||||
onCheckedChange={(v) =>
|
||||
updateDefaultRunBudget({
|
||||
enabled: v,
|
||||
scope: 'workspace',
|
||||
name: defaultRunBudget?.name ?? 'Workspace default run budget',
|
||||
})
|
||||
}
|
||||
/>
|
||||
{(defaultRunBudget?.enabled ?? false) && (
|
||||
<>
|
||||
<NumberRow
|
||||
label="Run Token Limit"
|
||||
description="Maximum total tokens per run (0 = no limit)"
|
||||
value={defaultRunLimits.totalTokens ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('totalTokens', v)}
|
||||
min={0}
|
||||
max={9_999_999_999}
|
||||
unit="tokens"
|
||||
hideSpinners
|
||||
maxLength={10}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Run Cost Limit"
|
||||
description="Maximum provider-reported spend per run (0 = no limit)"
|
||||
value={defaultRunLimits.costUsd ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('costUsd', v)}
|
||||
min={0}
|
||||
max={1_000_000}
|
||||
unit="USD"
|
||||
hideSpinners
|
||||
maxLength={8}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Tool Call Limit"
|
||||
description="Maximum counted tool calls per run (0 = no limit)"
|
||||
value={defaultRunLimits.toolCalls ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('toolCalls', v)}
|
||||
min={0}
|
||||
max={100_000}
|
||||
unit="calls"
|
||||
hideSpinners
|
||||
maxLength={6}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Runtime Limit"
|
||||
description="Maximum wall-clock runtime per run (0 = no limit)"
|
||||
value={defaultRunLimits.runtimeSeconds ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('runtimeSeconds', v)}
|
||||
min={0}
|
||||
max={604_800}
|
||||
unit="sec"
|
||||
hideSpinners
|
||||
maxLength={6}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Retry Limit"
|
||||
description="Maximum workflow retry count per run (0 = no limit)"
|
||||
value={defaultRunLimits.retries ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('retries', v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="retries"
|
||||
hideSpinners
|
||||
maxLength={3}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Fan-out Limit"
|
||||
description="Maximum parallel branch width per workflow run (0 = no limit)"
|
||||
value={defaultRunLimits.fanOut ?? 0}
|
||||
onChange={(v) => updateDefaultRunLimit('fanOut', v)}
|
||||
min={0}
|
||||
max={100}
|
||||
unit="branches"
|
||||
hideSpinners
|
||||
maxLength={3}
|
||||
/>
|
||||
<SettingRow
|
||||
label="Hard Threshold Action"
|
||||
description="Action when a run reaches a hard budget limit"
|
||||
>
|
||||
<Select
|
||||
aria-label="Hard Threshold Action"
|
||||
className="w-48"
|
||||
data={[
|
||||
{ value: 'require-approval', label: 'Require approval' },
|
||||
{ value: 'pause', label: 'Pause' },
|
||||
{ value: 'downgrade', label: 'Downgrade model' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
]}
|
||||
value={defaultRunBudget?.hardAction ?? 'require-approval'}
|
||||
onChange={(value) =>
|
||||
updateDefaultRunBudget({ hardAction: value ?? 'require-approval' })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{defaultRunBudget?.hardAction === 'downgrade' && (
|
||||
<SettingRow
|
||||
label="Hard Threshold Action"
|
||||
description="Action when a run reaches a hard budget limit"
|
||||
label="Downgrade Model"
|
||||
description="Model route to use after a hard threshold downgrade"
|
||||
>
|
||||
<Select
|
||||
aria-label="Hard Threshold Action"
|
||||
<TextInput
|
||||
aria-label="Downgrade Model"
|
||||
className="w-48"
|
||||
data={[
|
||||
{ value: 'require-approval', label: 'Require approval' },
|
||||
{ value: 'pause', label: 'Pause' },
|
||||
{ value: 'downgrade', label: 'Downgrade model' },
|
||||
{ value: 'cancel', label: 'Cancel' },
|
||||
]}
|
||||
value={defaultRunBudget?.hardAction ?? 'require-approval'}
|
||||
onChange={(value) =>
|
||||
updateDefaultRunBudget({ hardAction: value ?? 'require-approval' })
|
||||
value={defaultRunBudget?.downgradeModel ?? ''}
|
||||
onChange={(event) =>
|
||||
updateDefaultRunBudget({ downgradeModel: event.currentTarget.value })
|
||||
}
|
||||
placeholder="gpt-4.1-mini"
|
||||
/>
|
||||
</SettingRow>
|
||||
{defaultRunBudget?.hardAction === 'downgrade' && (
|
||||
<SettingRow
|
||||
label="Downgrade Model"
|
||||
description="Model route to use after a hard threshold downgrade"
|
||||
>
|
||||
<TextInput
|
||||
aria-label="Downgrade Model"
|
||||
className="w-48"
|
||||
value={defaultRunBudget?.downgradeModel ?? ''}
|
||||
onChange={(event) =>
|
||||
updateDefaultRunBudget({ downgradeModel: event.currentTarget.value })
|
||||
}
|
||||
placeholder="gpt-4.1-mini"
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{/* Archive */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Archive
|
||||
</h4>
|
||||
<div className="divide-y">
|
||||
<ToggleRow
|
||||
label="Auto-Archive"
|
||||
description="Automatically archive completed sprints"
|
||||
checked={
|
||||
settings.archive?.autoArchiveEnabled ??
|
||||
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled
|
||||
<SettingsSection
|
||||
id="data-archive"
|
||||
title="Archive"
|
||||
description="Automatic lifecycle policy for completed sprints."
|
||||
divided
|
||||
>
|
||||
<ToggleRow
|
||||
label="Auto-Archive"
|
||||
description="Automatically archive completed sprints"
|
||||
checked={
|
||||
settings.archive?.autoArchiveEnabled ??
|
||||
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled
|
||||
}
|
||||
onCheckedChange={(v) => updateArchive('autoArchiveEnabled', v)}
|
||||
/>
|
||||
{(settings.archive?.autoArchiveEnabled ??
|
||||
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled) && (
|
||||
<NumberRow
|
||||
label="Archive After"
|
||||
description="Days after completion before auto-archiving"
|
||||
value={
|
||||
settings.archive?.autoArchiveAfterDays ??
|
||||
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveAfterDays
|
||||
}
|
||||
onCheckedChange={(v) => updateArchive('autoArchiveEnabled', v)}
|
||||
onChange={(v) => updateArchive('autoArchiveAfterDays', v)}
|
||||
min={1}
|
||||
max={365}
|
||||
unit="days"
|
||||
hideSpinners
|
||||
maxLength={3}
|
||||
/>
|
||||
{(settings.archive?.autoArchiveEnabled ??
|
||||
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled) && (
|
||||
<NumberRow
|
||||
label="Archive After"
|
||||
description="Days after completion before auto-archiving"
|
||||
value={
|
||||
settings.archive?.autoArchiveAfterDays ??
|
||||
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveAfterDays
|
||||
}
|
||||
onChange={(v) => updateArchive('autoArchiveAfterDays', v)}
|
||||
min={1}
|
||||
max={365}
|
||||
unit="days"
|
||||
hideSpinners
|
||||
maxLength={3}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { PRODUCT_MODE_DEFINITIONS, productModeDefinition } from '@/lib/product-modes';
|
||||
import { SettingRow, SettingsHelpText, SettingsPage, SettingsSection } from '../shared';
|
||||
|
||||
const PRODUCT_MODE_OPTIONS = PRODUCT_MODE_DEFINITIONS.map((mode) => ({
|
||||
value: mode.id,
|
||||
|
|
@ -58,38 +59,44 @@ export function GeneralTab() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SettingsPage
|
||||
title="General"
|
||||
description="Personalize the app, choose its working mode, and connect the repositories and agents used by default."
|
||||
>
|
||||
{/* Appearance */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Appearance</h3>
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<SettingsSection
|
||||
id="general-appearance"
|
||||
title="Appearance"
|
||||
description="Theme preference for this browser or desktop app."
|
||||
>
|
||||
<SettingRow
|
||||
label="Dark mode"
|
||||
description={theme === 'dark' ? 'Dark theme active' : 'Light theme active'}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{theme === 'dark' ? (
|
||||
<Moon className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-sm">Dark Mode</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{theme === 'dark' ? 'Dark theme active' : 'Light theme active'}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={theme === 'dark'}
|
||||
onChange={(event) => setTheme(event.currentTarget.checked ? 'dark' : 'light')}
|
||||
aria-label="Toggle dark mode"
|
||||
className="flex min-h-8 items-center"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Switch
|
||||
checked={theme === 'dark'}
|
||||
onChange={(event) => setTheme(event.currentTarget.checked ? 'dark' : 'light')}
|
||||
aria-label="Toggle dark mode"
|
||||
className="flex min-h-8 items-center"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Product Mode */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Product Mode</h3>
|
||||
<div className="rounded-md border p-4 bg-card space-y-4">
|
||||
<SettingsSection
|
||||
id="general-product-mode"
|
||||
title="Product mode"
|
||||
description="Prioritize the surfaces and shortcuts that match the way you work."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
data-testid="product-mode-layout"
|
||||
className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
|
||||
|
|
@ -131,12 +138,15 @@ export function GeneralTab() {
|
|||
<ModeDetail label="Shortcuts" items={activeProductMode.commandShortcuts} />
|
||||
</SimpleGrid>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* User Preferences */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">User Preferences</h3>
|
||||
<div className="rounded-md border p-4 bg-card space-y-3">
|
||||
<SettingsSection
|
||||
id="general-user-preferences"
|
||||
title="User preferences"
|
||||
description="Identity used in collaborative surfaces."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2">
|
||||
<TextInput
|
||||
id="human-display-name"
|
||||
|
|
@ -154,19 +164,21 @@ export function GeneralTab() {
|
|||
placeholder="Human"
|
||||
maw={320}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<SettingsHelpText>
|
||||
How your messages appear in Squad Chat. Shows as "{localDisplayName} (Human)" in the
|
||||
chat.
|
||||
</p>
|
||||
</SettingsHelpText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Repositories */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">Git Repositories</h3>
|
||||
{!showAddForm && (
|
||||
<SettingsSection
|
||||
id="general-repositories"
|
||||
title="Git repositories"
|
||||
description="Repositories available for task worktrees and source operations."
|
||||
actions={
|
||||
!showAddForm ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
|
|
@ -175,8 +187,9 @@ export function GeneralTab() {
|
|||
>
|
||||
Add Repo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{showAddForm && <AddRepoForm onClose={() => setShowAddForm(false)} />}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
|
|
@ -191,11 +204,14 @@ export function GeneralTab() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Default Agent */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium">Default Agent</h3>
|
||||
<SettingsSection
|
||||
id="general-default-agent"
|
||||
title="Default agent"
|
||||
description="Agent selected when a task does not specify one."
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
|
|
@ -211,8 +227,8 @@ export function GeneralTab() {
|
|||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,28 @@
|
|||
import { Select } from '@mantine/core';
|
||||
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
|
||||
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
|
||||
import { SettingRow, ToggleRow, NumberRow, SectionHeader, SaveIndicator } from '../shared';
|
||||
import {
|
||||
DEFAULT_FEATURE_SETTINGS,
|
||||
type MarkdownSettings,
|
||||
type TaskBehaviorSettings,
|
||||
} from '@veritas-kanban/shared';
|
||||
import {
|
||||
SettingRow,
|
||||
ToggleRow,
|
||||
NumberRow,
|
||||
SaveIndicator,
|
||||
SettingsPage,
|
||||
SettingsSection,
|
||||
} from '../shared';
|
||||
|
||||
export function TasksTab() {
|
||||
const { settings } = useFeatureSettings();
|
||||
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
|
||||
|
||||
const update = (key: string, value: any) => {
|
||||
const update = <K extends keyof TaskBehaviorSettings>(key: K, value: TaskBehaviorSettings[K]) => {
|
||||
debouncedUpdate({ tasks: { [key]: value } });
|
||||
};
|
||||
|
||||
const updateMarkdown = (key: string, value: any) => {
|
||||
const updateMarkdown = <K extends keyof MarkdownSettings>(key: K, value: MarkdownSettings[K]) => {
|
||||
debouncedUpdate({ markdown: { [key]: value } });
|
||||
};
|
||||
|
||||
|
|
@ -24,12 +35,18 @@ export function TasksTab() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionHeader title="Task Behavior" onReset={resetTasks} />
|
||||
<SaveIndicator isPending={isPending} />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
<SettingsPage
|
||||
title="Tasks"
|
||||
description="Set defaults and optional capabilities for task authoring and completion."
|
||||
actions={<SaveIndicator isPending={isPending} />}
|
||||
>
|
||||
<SettingsSection
|
||||
id="task-behavior"
|
||||
title="Task behavior"
|
||||
description="Common task defaults and dependent attachment controls."
|
||||
onReset={resetTasks}
|
||||
divided
|
||||
>
|
||||
<ToggleRow
|
||||
label="Time Tracking"
|
||||
description="Enable time tracking on tasks"
|
||||
|
|
@ -143,7 +160,9 @@ export function TasksTab() {
|
|||
value={
|
||||
settings.tasks?.defaultPriority ?? DEFAULT_FEATURE_SETTINGS.tasks.defaultPriority
|
||||
}
|
||||
onChange={(value) => value && update('defaultPriority', value)}
|
||||
onChange={(value) =>
|
||||
value && update('defaultPriority', value as TaskBehaviorSettings['defaultPriority'])
|
||||
}
|
||||
data={[
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
|
|
@ -156,13 +175,15 @@ export function TasksTab() {
|
|||
w={128}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<SectionHeader title="Markdown" onReset={resetMarkdown} />
|
||||
<SaveIndicator isPending={isPending} />
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
<SettingsSection
|
||||
id="task-markdown"
|
||||
title="Markdown"
|
||||
description="Formatting behavior for task descriptions and comments."
|
||||
onReset={resetMarkdown}
|
||||
divided
|
||||
>
|
||||
<ToggleRow
|
||||
label="Enable Markdown"
|
||||
description="Use Markdown formatting in task descriptions and comments"
|
||||
|
|
@ -180,7 +201,7 @@ export function TasksTab() {
|
|||
}
|
||||
onCheckedChange={(v) => updateMarkdown('enableCodeHighlighting', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue