diff --git a/client_v3/src/__tests__/common/ui/ConfigDialog.test.tsx b/client_v3/src/__tests__/common/ui/ConfigDialog.test.tsx new file mode 100644 index 000000000..d616049d2 --- /dev/null +++ b/client_v3/src/__tests__/common/ui/ConfigDialog.test.tsx @@ -0,0 +1,119 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent, screen } from '@solidjs/testing-library'; + +const { themeMock } = vi.hoisted(() => { + const proxy: any = new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === Symbol.toPrimitive || prop === 'toString') { + return () => ''; + } + return proxy; + }, + }, + ); + return { themeMock: proxy }; +}); + +vi.mock('panel/lib/theme', () => ({ + default: themeMock, +})); + +vi.mock('panel/common/intl', () => ({ + default: { + getMessage: (key: string, _values?: Record) => key, + }, +})); + +import { ConfigDialog } from 'panel/common/ui/ConfigDialog'; + +describe('ConfigDialog', () => { + it('renders children when open', () => { + render(() => ( + +
Body Content
+
+ )); + expect(screen.getByText('Body Content')).toBeDefined(); + expect(screen.getByText('Test Dialog')).toBeDefined(); + }); + + it('save disabled when processing is true', () => { + render(() => ( + +
Content
+
+ )); + const saveButton = screen.getByTestId('config-dialog-save'); + expect(saveButton).toBeDisabled(); + }); + + it('save disabled when submitDisabled is true', () => { + render(() => ( + +
Content
+
+ )); + const saveButton = screen.getByTestId('config-dialog-save'); + expect(saveButton).toBeDisabled(); + }); + + it('clicking save fires onSubmit', () => { + const onSubmit = vi.fn(); + render(() => ( + +
Content
+
+ )); + const saveButton = screen.getByTestId('config-dialog-save'); + fireEvent.click(saveButton); + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('renders footer before save button', () => { + render(() => ( + Secondary} + > +
Content
+
+ )); + expect(screen.getByTestId('secondary-action')).toBeDefined(); + // The footer div contains both secondary and save button + const footer = screen.getByTestId('secondary-action').parentElement; + expect(footer?.querySelector('[data-testid="config-dialog-save"]')).toBeDefined(); + }); + + it('processing disables fieldset', () => { + render(() => ( + + + + )); + const fieldset = screen.getByTestId('test-input').closest('fieldset'); + expect(fieldset).toBeDisabled(); + }); +}); diff --git a/client_v3/src/__tests__/components/Settings/SettingRow.test.tsx b/client_v3/src/__tests__/components/Settings/SettingRow.test.tsx new file mode 100644 index 000000000..1ec3ff929 --- /dev/null +++ b/client_v3/src/__tests__/components/Settings/SettingRow.test.tsx @@ -0,0 +1,214 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, fireEvent, screen } from '@solidjs/testing-library'; + +const { themeMock } = vi.hoisted(() => { + const proxy: any = new Proxy( + {}, + { + get: (_target, prop) => { + if (prop === Symbol.toPrimitive || prop === 'toString') { + return () => ''; + } + return proxy; + }, + }, + ); + return { themeMock: proxy }; +}); + +vi.mock('panel/lib/theme', () => ({ + default: themeMock, +})); + +vi.mock('panel/common/intl', () => ({ + default: { + getMessage: (key: string, _values?: Record) => key, + }, +})); + +import { SettingRow } from 'panel/common/ui/SettingRow'; + +describe('SettingRow', () => { + describe('switch variant', () => { + it('renders title and description', () => { + render(() => ( + + )); + expect(screen.getByText('Test Title')).toBeDefined(); + expect(screen.getByText('Test Description')).toBeDefined(); + }); + + it('fires onChange when row is clicked', () => { + const onChange = vi.fn(); + render(() => ( + + )); + const row = screen.getByRole('button'); + fireEvent.click(row); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(true); + }); + + it('fires onChange when switch is toggled off', () => { + const onChange = vi.fn(); + render(() => ( + + )); + const row = screen.getByRole('button'); + fireEvent.click(row); + expect(onChange).toHaveBeenCalledWith(false); + }); + + it('does not fire onChange when disabled', () => { + const onChange = vi.fn(); + render(() => ( + + )); + const row = screen.getByRole('button'); + fireEvent.click(row); + expect(onChange).not.toHaveBeenCalled(); + }); + }); + + describe('link variant', () => { + it('renders value in semibold', () => { + render(() => ( + + )); + expect(screen.getByText('90 days · 3 ignored domains')).toBeDefined(); + }); + + it('fires onClick when link is clicked', () => { + const onClick = vi.fn(); + render(() => ( + + )); + const rows = screen.getAllByRole('button'); + fireEvent.click(rows[0]); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not fire onClick when disabled', () => { + const onClick = vi.fn(); + render(() => ( + + )); + const rows = screen.getAllByRole('button'); + fireEvent.click(rows[0]); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe('switch-link variant', () => { + it('renders switch without arrow or configure label', () => { + render(() => ( + + )); + expect(screen.queryByText('settings_configure')).toBeNull(); + expect(screen.getByText('Enabled · 4 providers')).toBeDefined(); + }); + + it('row click fires onClick, not onChange', () => { + const onChange = vi.fn(); + const onClick = vi.fn(); + render(() => ( + + )); + const rows = screen.getAllByRole('button'); + fireEvent.click(rows[0]); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('disabled suppresses switch', () => { + const onChange = vi.fn(); + const onClick = vi.fn(); + render(() => ( + + )); + const rows = screen.getAllByRole('button'); + fireEvent.click(rows[0]); + expect(onChange).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + }); + }); + + describe('children slot', () => { + it('renders children when provided', () => { + render(() => ( + +
Child Content
+
+ )); + expect(screen.getByTestId('child-content')).toBeDefined(); + expect(screen.getByText('Child Content')).toBeDefined(); + }); + }); +}); diff --git a/client_v3/src/common/styles/colors/light.css b/client_v3/src/common/styles/colors/light.css index 9a682ccec..f5daae511 100644 --- a/client_v3/src/common/styles/colors/light.css +++ b/client_v3/src/common/styles/colors/light.css @@ -17,7 +17,7 @@ --default-main-text: var(--gray-80); --disabled-main-text: var(--gray-50); /* Description text */ - --default-description-text: var(--gray-60); + --default-description-text: var(--gray-70); /* Forms */ --default-labels: var(--gray-70); /* Input */ diff --git a/client_v3/src/common/ui/ConfigDialog/ConfigDialog.module.pcss b/client_v3/src/common/ui/ConfigDialog/ConfigDialog.module.pcss new file mode 100644 index 000000000..ca1ac3eb8 --- /dev/null +++ b/client_v3/src/common/ui/ConfigDialog/ConfigDialog.module.pcss @@ -0,0 +1,33 @@ +.configDialog { + :global(.rc-dialog-wrap) { + padding: 0; + } +} + +.body { + max-height: 600px; + overflow-y: auto; + border: none; + padding: 0 16px; + + @media (max-width: 1023px) { + max-height: 70vh; + } +} + +.footer { + display: flex; + gap: 8px; + padding: 16px; +} + +.saveButton { + width: auto; + max-width: none; + min-width: 200px; +} + +.description { + padding: 8px 16px; + color: var(--default-description-text); +} diff --git a/client_v3/src/common/ui/ConfigDialog/ConfigDialog.tsx b/client_v3/src/common/ui/ConfigDialog/ConfigDialog.tsx new file mode 100644 index 000000000..8cb138595 --- /dev/null +++ b/client_v3/src/common/ui/ConfigDialog/ConfigDialog.tsx @@ -0,0 +1,52 @@ +import { type JSX } from 'solid-js'; +import cn from 'clsx'; + +import { Dialog } from 'panel/common/ui/Dialog'; +import { Button } from 'panel/common/ui/Button'; +import intl from 'panel/common/intl'; + +import s from './ConfigDialog.module.pcss'; + +type Props = { + open: boolean; + title: string; + onClose: () => void; + onSubmit: () => void; + processing?: boolean; + submitDisabled?: boolean; + class?: string; + children?: JSX.Element; + footer?: JSX.Element; + description?: string; +}; + +export const ConfigDialog = (props: Props) => { + const isDisabled = () => !!props.processing || !!props.submitDisabled; + + return ( + + {props.description &&
{props.description}
} + +
+ {props.children} +
+
+ {props.footer} + +
+
+ ); +}; diff --git a/client_v3/src/common/ui/ConfigDialog/index.ts b/client_v3/src/common/ui/ConfigDialog/index.ts new file mode 100644 index 000000000..3113bc762 --- /dev/null +++ b/client_v3/src/common/ui/ConfigDialog/index.ts @@ -0,0 +1 @@ +export { ConfigDialog } from './ConfigDialog'; diff --git a/client_v3/src/common/ui/SettingRow/SettingRow.module.pcss b/client_v3/src/common/ui/SettingRow/SettingRow.module.pcss new file mode 100644 index 000000000..0b69a34bf --- /dev/null +++ b/client_v3/src/common/ui/SettingRow/SettingRow.module.pcss @@ -0,0 +1,122 @@ +.switch { + width: 100%; + padding: 8px 16px; + transition: background-color var(--t2); + border-radius: 8px; + outline: none; + + &:hover, + &:focus { + background-color: var(--page-background-additional); + } +} + +.switchDisabled { + cursor: default; + + &:hover, + &:focus { + background-color: transparent; + } + + .title, + .desc, + .value { + color: var(--disabled-main-text); + } + + .row { + cursor: default; + } +} + +.row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + cursor: pointer; + + &.rowCenter { + align-items: center; + } +} + +.divider { + margin: 8px 8px 8px auto; + width: 1px; + align-self: stretch; + background-color: var(--default-item-divider); + flex-shrink: 0; +} + +.text { + padding: 8px 0; +} + +.title { + margin-bottom: 4px; + font-weight: var(--weight-semi-bold); + color: var(--default-main-text); +} + +.titleDisabled { + color: var(--disabled-main-text); +} + +.desc { + color: var(--default-description-text); +} + +.descDisabled { + color: var(--disabled-main-text); +} + +.value { + margin-top: 8px; + font-weight: var(--weight-semi-bold); + color: var(--default-description-text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.valueDisabled { + color: var(--disabled-main-text); +} + +.input { + padding: 8px 0; + display: flex; + align-items: flex-start; + gap: 12px; + align-self: stretch; +} + +.link { + display: flex; + align-items: center; + gap: 4px; + color: var(--default-gray-icons); + cursor: pointer; + white-space: nowrap; + border: none; + background: none; + padding: 0; + font-size: inherit; + + &:disabled { + cursor: default; + opacity: 0.5; + } +} + +.arrow { + color: var(--default-description-text); +} + +.content { + padding: 8px 0; +} diff --git a/client_v3/src/common/ui/SettingRow/SettingRow.tsx b/client_v3/src/common/ui/SettingRow/SettingRow.tsx new file mode 100644 index 000000000..06a2731da --- /dev/null +++ b/client_v3/src/common/ui/SettingRow/SettingRow.tsx @@ -0,0 +1,169 @@ +import { type JSX, Show, createMemo } from 'solid-js'; +import cn from 'clsx'; + +import { Switch } from 'panel/common/controls/Switch'; +import { Icon } from 'panel/common/ui/Icon'; + +import s from './SettingRow.module.pcss'; +import theme from 'panel/lib/theme'; + +type SettingRowVariant = 'switch' | 'link' | 'switch-link'; + +type Props = { + id: string; + title: string; + description?: string | JSX.Element; + value?: string; + variant: SettingRowVariant; + checked?: boolean; + disabled?: boolean; + titleClass?: string; + onChange?: (checked: boolean) => void; + onClick?: () => void; + class?: string; + children?: JSX.Element; + divider?: boolean; + align?: 'top' | 'center'; +}; + +export const SettingRow = (props: Props) => { + let inputRef: HTMLInputElement | undefined; + + const isSwitch = createMemo(() => props.variant === 'switch'); + const isLink = createMemo(() => props.variant === 'link'); + const isSwitchLink = createMemo(() => props.variant === 'switch-link'); + + const handleRowClick = (e?: MouseEvent) => { + if (props.disabled) { + return; + } + // Skip programmatic click if the user already clicked the switch/label + // — the native label behaviour already toggles it. + if (e) { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.closest('label')) { + return; + } + } + if (isSwitch()) { + inputRef?.click(); + } else if (isLink() || isSwitchLink()) { + props.onClick?.(); + } + }; + + const handleSwitchChange = (e: Event) => { + e.stopPropagation(); + if (props.disabled) { + return; + } + const target = e.target as HTMLInputElement; + props.onChange?.(target.checked); + }; + + const handleLinkClick = (e: Event) => { + e.stopPropagation(); + if (props.disabled) { + return; + } + props.onClick?.(); + }; + + const handleInputClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const isSwitchClick = target.tagName === 'INPUT' || !!target.closest('label'); + + // Native label click already toggled the switch — don't double-fire. + if (isSwitchClick) { + return; + } + + if ((isSwitch() || isSwitchLink()) && !props.disabled) { + e.stopPropagation(); + inputRef?.click(); + } + }; + + const isSwitchVariant = () => isSwitch() || isSwitchLink(); + + const isLinkVariant = () => isLink(); + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleRowClick(); + } + }} + > +
+
+
+ {props.title} +
+ +
+ {props.description} +
+
+ +
+ {props.value} +
+
+
+ +
+ +
+ + { + inputRef = el; + }} + /> + + + + +
+
+ +
{props.children}
+
+
+ ); +}; diff --git a/client_v3/src/common/ui/SettingRow/index.ts b/client_v3/src/common/ui/SettingRow/index.ts new file mode 100644 index 000000000..ad9a32b5a --- /dev/null +++ b/client_v3/src/common/ui/SettingRow/index.ts @@ -0,0 +1 @@ +export { SettingRow } from './SettingRow'; diff --git a/client_v3/src/common/ui/SettingsGroup/RadioGroup.tsx b/client_v3/src/common/ui/SettingsGroup/RadioGroup.tsx index 4bb471f35..4c9d7ef5f 100644 --- a/client_v3/src/common/ui/SettingsGroup/RadioGroup.tsx +++ b/client_v3/src/common/ui/SettingsGroup/RadioGroup.tsx @@ -9,7 +9,7 @@ import s from './styles.module.pcss'; type Option = { text: string; value: T }; type Props = { - title: string; + title?: string; description?: string; disabled?: boolean; value: T; @@ -22,16 +22,20 @@ type Props = { export const RadioGroup = (props: Props) => { return ( -
-
-
-
{props.title}
- -
{props.description}
-
+
+ +
+
+
+ {props.title} +
+ +
{props.description}
+
+
+
-
-
+
{ }; return ( -
+
{ }); return ( -
- ( - e.stopPropagation()} - > - {text} - - ), - b: (text: string) => ( - e.stopPropagation()} - > - {text} - - ), - c: (text: string) => ( - e.stopPropagation()} - > - {text} - - ), - })} - disabled={props.processing} - onChange={(e: Event) => setEnabled((e.target as HTMLInputElement).checked)} - id="filters_enabled" - checked={enabled()} - /> -
+ ( + e.stopPropagation()} + > + {text} + + ), + b: (text: string) => ( + e.stopPropagation()} + > + {text} + + ), + c: (text: string) => ( + e.stopPropagation()} + > + {text} + + ), + })} + checked={enabled()} + onChange={(v) => setEnabled(v)} + /> ); }; diff --git a/client_v3/src/components/Settings/IgnoredDomains/index.ts b/client_v3/src/components/Settings/IgnoredDomains/index.ts deleted file mode 100644 index 44a0d34ce..000000000 --- a/client_v3/src/components/Settings/IgnoredDomains/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IgnoredDomains } from './IgnoredDomains'; diff --git a/client_v3/src/components/Settings/IgnoredDomains/styles.module.pcss b/client_v3/src/components/Settings/IgnoredDomains/styles.module.pcss deleted file mode 100644 index 985b2be3d..000000000 --- a/client_v3/src/components/Settings/IgnoredDomains/styles.module.pcss +++ /dev/null @@ -1,14 +0,0 @@ -.link { - margin-left: auto; -} - -.label { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; -} - -.dropdownTitle { - margin-bottom: 16px; -} diff --git a/client_v3/src/components/Settings/IgnoredDomainsModal/IgnoredDomainsModal.module.pcss b/client_v3/src/components/Settings/IgnoredDomainsModal/IgnoredDomainsModal.module.pcss new file mode 100644 index 000000000..fb7c56fa6 --- /dev/null +++ b/client_v3/src/components/Settings/IgnoredDomainsModal/IgnoredDomainsModal.module.pcss @@ -0,0 +1,7 @@ +.dropdownTitle { + margin-bottom: 8px; +} + +.link { + margin-left: auto; +} diff --git a/client_v3/src/components/Settings/IgnoredDomains/IgnoredDomains.tsx b/client_v3/src/components/Settings/IgnoredDomainsModal/IgnoredDomainsModal.tsx similarity index 64% rename from client_v3/src/components/Settings/IgnoredDomains/IgnoredDomains.tsx rename to client_v3/src/components/Settings/IgnoredDomainsModal/IgnoredDomainsModal.tsx index 5b3fbd375..fc79728fd 100644 --- a/client_v3/src/components/Settings/IgnoredDomains/IgnoredDomains.tsx +++ b/client_v3/src/components/Settings/IgnoredDomainsModal/IgnoredDomainsModal.tsx @@ -1,49 +1,63 @@ +import { createSignal, createEffect, on } from 'solid-js'; import cn from 'clsx'; +import { ConfigDialog } from 'panel/common/ui/ConfigDialog'; import { Textarea } from 'panel/common/controls/Textarea'; -import intl from 'panel/common/intl'; -import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers'; -import theme from 'panel/lib/theme'; -import { SwitchGroup } from 'panel/common/ui/SettingsGroup'; - import { FaqTooltip } from 'panel/common/ui/FaqTooltip'; -import s from './styles.module.pcss'; +import intl from 'panel/common/intl'; +import theme from 'panel/lib/theme'; +import { trimLinesAndRemoveEmpty } from 'panel/helpers/helpers'; + +import s from './IgnoredDomainsModal.module.pcss'; type Props = { - ignoredValue: string; - onIgnoredChange: (value: string) => void; + open: boolean; + title: string; + ignored: string[]; processing: boolean; - ignoreEnabled: boolean; - onIgnoreEnabledChange: (checked: boolean) => void; - switchId: string; - textareaId: string; - description: string; - error?: string; + onClose: () => void; + onSave: (ignored: string[]) => void; }; -export const IgnoredDomains = (props: Props) => { +export const IgnoredDomainsModal = (props: Props) => { + const [value, setValue] = createSignal(''); + + createEffect( + on( + () => props.open, + (open) => { + if (!open) return; + setValue(props.ignored.join('\n')); + }, + ), + ); + + const handleSave = () => { + const trimmed = trimLinesAndRemoveEmpty(value()); + const ignoredArray = trimmed.split('\n').filter(Boolean); + props.onSave(ignoredArray); + }; + return ( - - props.onIgnoreEnabledChange((e.target as HTMLInputElement).checked) - } - disabled={props.processing} +