diff --git a/docs/component-testing.md b/docs/component-testing.md index d11d49afd..62a43dd47 100644 --- a/docs/component-testing.md +++ b/docs/component-testing.md @@ -238,6 +238,8 @@ A single-step widening cast like `value as unknown` or `[] as unknown[]` (inside Stock Lucide icons imported from `lucide-react` and custom SVG icons from `@/icons` (`src/libs/icons/icons.tsx`) should **always** use real implementations in tests—do not `vi.mock('lucide-react')` or `vi.mock('@/icons')` to stub icons. This ensures snapshots capture actual SVG output and visual regression tests detect icon changes. +`DynamicLucideIcon` is also real, but resolves its icon chunk **asynchronously**: a first render shows an empty size-preserving svg (`` with no children), and the resolved paths appear after the dynamic import settles. Before asserting on paths or matching a snapshot, either await resolution (`waitFor` on `svg.childElementCount > 0` — avoid `querySelector('svg *')`, jsdom's selector engine misses svg descendants) or warm the icon with `await loadLucideIconNode(name)` from `@/libs/utils/lucideIcons`. Note the icon cache is **module-level and persists across tests within a file** — a loading-state assertion needs an icon name no earlier test in the file has loaded. + Application import conventions (where to import icons, URL helpers, and what not to do) are documented in **`docs/components.md`** — _Icons (Lucide and custom)_. ### Radix UI Components: Always Real diff --git a/docs/components.md b/docs/components.md index 45863dddd..ccdf6bfee 100644 --- a/docs/components.md +++ b/docs/components.md @@ -144,7 +144,7 @@ export { Home as default } from '@/templates/Feed/Home/Home'; ## Icons (Lucide and custom) -Icons are split on purpose: **stock Lucide** ships from the `lucide-react` package; **app-owned SVGs** (brands, bespoke marks, non-Lucide shapes) live in a single module behind the **`@/icons`** path alias (`src/libs/icons/icons.tsx`). +Icons are split on purpose: **stock Lucide** ships from the `lucide-react` package; **app-owned SVGs** (brands, bespoke marks, non-Lucide shapes) live in a single module behind the **`@/icons`** path alias (`src/libs/icons/icons.tsx`); **data-driven Lucide icons** (an icon _name_ stored on a record, e.g. a custom feed's icon) render through the `DynamicLucideIcon` atom. ### Stock Lucide icons @@ -154,6 +154,16 @@ import { ChevronDown, Plus, Trash2 } from 'lucide-react'; Use named imports from `lucide-react` only. +### Data-driven Lucide icons (`DynamicLucideIcon`) + +When the icon is chosen at runtime from data (a kebab-case Lucide name like `"folder-heart"` stored on a feed), a static named import is impossible. Render it with the **`DynamicLucideIcon`** atom (`@/atoms/DynamicLucideIcon/DynamicLucideIcon`): + +```tsx + +``` + +Icon chunks load lazily via `lucide-react/dynamic.js` through a module-level cache in **`@/libs/utils/lucideIcons`** (`isLucideIconName`, `loadLucideIconNode`, `preloadLucideIcons`). Once an icon has resolved anywhere in the session it renders synchronously on first paint. While a valid name is still loading, the atom renders an empty size-preserving svg — never a wrong icon; the `fallback` prop (default `Activity`) applies only to names that are not Lucide icons at all. Call `preloadLucideIcons(names)` when the icon names become known (e.g. when feed data lands) so mounts hit the cache. Never use this path for static UI icons — those stay named imports. + ### Custom / brand icons ```tsx diff --git a/messages/en.json b/messages/en.json index 59d05e013..609f3f016 100644 --- a/messages/en.json +++ b/messages/en.json @@ -733,11 +733,23 @@ "urlPlaceholder": "https://twitter.com/satoshi", "saveButton": "Save link" }, + "iconPicker": { + "title": "Choose icon", + "searchPlaceholder": "Search for icon", + "emptyMessage": "No icons found", + "clearSearch": "Clear search" + }, "customFeed": { "createTitle": "Create Feed", "editTitle": "Edit Feed", "feedName": "Feed Name", "feedNamePlaceholder": "Not your keys...", + "feedIcon": "Feed Icon", + "chooseIcon": "Select icon", + "feedIconCreateDescription": "Choose a custom icon for your new feed.", + "feedIconEditDescription": "Choose a custom icon for your feed.", + "editFeedLabel": "Edit {name}", + "moreFeeds": "More feeds", "reach": "Reach", "reachPlaceholder": "Select a reach", "sort": "Sort", diff --git a/package-lock.json b/package-lock.json index ccf4093d4..582bb4333 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,7 @@ "next": "16.2.6", "next-intl": "4.12.0", "next-themes": "0.4.6", - "pubky-app-specs": "0.6.2", + "pubky-app-specs": "0.7.0", "qrcode.react": "4.2.0", "radix-ui": "1.4.3", "react": "19.2.6", @@ -16758,9 +16758,9 @@ "license": "MIT" }, "node_modules/pubky-app-specs": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/pubky-app-specs/-/pubky-app-specs-0.6.2.tgz", - "integrity": "sha512-BWUgXG1zXh8y8oitARw44QF10sdbz1LLlUkxIQhMuMBnzKKC2w8jRrFwRFJ3i1rxXy+OAaxKVsw104TByXMhkQ==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/pubky-app-specs/-/pubky-app-specs-0.7.0.tgz", + "integrity": "sha512-9ymC5m1EFz2wmwk3fu0mK0NSxzQDw3Gz4x//ZIZcCerON0cppHKFQNL4vf5I8NkPf1J+pJxfqg+T6ib+mEHP8Q==", "license": "MIT" }, "node_modules/pump": { diff --git a/package.json b/package.json index 4f91fa3d7..08782892e 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "next": "16.2.6", "next-intl": "4.12.0", "next-themes": "0.4.6", - "pubky-app-specs": "0.6.2", + "pubky-app-specs": "0.7.0", "qrcode.react": "4.2.0", "radix-ui": "1.4.3", "react": "19.2.6", diff --git a/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx b/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx new file mode 100644 index 000000000..62b08fed1 --- /dev/null +++ b/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx @@ -0,0 +1,69 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { Library } from 'lucide-react'; +import { describe, expect, it } from 'vitest'; +import { loadLucideIconNode } from '@/libs/utils/lucideIcons'; +import { DynamicLucideIcon } from './DynamicLucideIcon'; + +// The icon cache is module-level and persists across tests in this file, so +// every loading-state assertion uses an icon name no other test has loaded. +describe('DynamicLucideIcon', () => { + it('renders a valid dynamic icon once its chunk resolves', async () => { + render(); + + await waitFor(() => expect(screen.getByTestId('dynamic-icon').querySelector('path')).not.toBeNull()); + }); + + it('never shows the fallback while a valid icon is loading', async () => { + render(); + + const svg = screen.getByTestId('loading-icon'); + expect(svg).toHaveClass('lucide'); + expect(svg).toHaveClass('size-5'); + expect(svg).not.toHaveClass('lucide-activity'); + expect(svg.childElementCount).toBe(0); + + await waitFor(() => expect(svg.querySelector('path')).not.toBeNull()); + }); + + it('renders a cached icon synchronously on first paint', async () => { + await loadLucideIconNode('library'); + + render(); + + expect(screen.getByTestId('cached-icon').querySelector('path')).not.toBeNull(); + }); + + it('renders the default fallback for a missing icon', () => { + render(); + + expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-activity'); + }); + + it('renders a consumer-provided fallback for an invalid icon', () => { + render( + , + ); + + expect(screen.getByTestId('fallback-icon')).toHaveClass('lucide-library'); + expect(screen.getByTestId('fallback-icon')).toHaveClass('size-6'); + }); + + it('can omit the fallback while a consumer handles its own loading state', () => { + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); +}); + +describe('DynamicLucideIcon - Snapshots', () => { + it('matches snapshot for a consumer-provided fallback', () => { + const { container } = render(); + + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx.snap b/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx.snap new file mode 100644 index 000000000..531b36628 --- /dev/null +++ b/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.test.tsx.snap @@ -0,0 +1,30 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DynamicLucideIcon - Snapshots > matches snapshot for a consumer-provided fallback 1`] = ` + +`; diff --git a/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.tsx b/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.tsx new file mode 100644 index 000000000..c5c77ccb6 --- /dev/null +++ b/src/components/atoms/DynamicLucideIcon/DynamicLucideIcon.tsx @@ -0,0 +1,70 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Activity, Icon, type IconNode, type LucideIcon, type LucideProps } from 'lucide-react'; +import type { IconName } from 'lucide-react/dynamic.js'; +import { getLoadedLucideIconNode, isLucideIconName, loadLucideIconNode } from '@/libs/utils/lucideIcons'; + +const EMPTY_ICON_NODE: IconNode = []; + +export interface DynamicLucideIconProps extends Omit { + name?: string | null; + /** Rendered only for a missing/invalid name — never while a valid icon is loading. */ + fallback?: LucideIcon | null; +} + +interface ResolvedIcon { + name: IconName | null; + node: IconNode | null; + /** The chunk load failed; render the fallback and retry only on remount. */ + failed?: boolean; +} + +function resolveFromCache(name: IconName | null): ResolvedIcon { + return { name, node: name ? (getLoadedLucideIconNode(name) ?? null) : null }; +} + +/** + * Renders a Lucide icon by its dynamic (kebab-case) name without bundling the + * full icon set. Icon chunks resolve through a module-level cache, so an icon + * renders synchronously on first paint once it has loaded anywhere in the + * session. While a valid icon is genuinely loading it renders an empty, + * size-preserving svg — never a wrong icon. + */ +export function DynamicLucideIcon({ name, fallback, ...iconProps }: DynamicLucideIconProps) { + const FallbackIcon = fallback === undefined ? Activity : fallback; + const validName = isLucideIconName(name) ? name : null; + const [resolved, setResolved] = useState(() => resolveFromCache(validName)); + + // Adjust state during render when the requested icon changes, so a cached + // icon swaps in synchronously instead of after an effect roundtrip. + if (resolved.name !== validName) { + setResolved(resolveFromCache(validName)); + } + + useEffect(() => { + if (!validName || (resolved.name === validName && (resolved.node || resolved.failed))) return; + let cancelled = false; + void loadLucideIconNode(validName).then((node) => { + if (cancelled) return; + setResolved((current) => { + if (current.name !== validName) return current; + if (node) return current.node === node ? current : { name: validName, node }; + return current.failed ? current : { name: validName, node: null, failed: true }; + }); + }); + return () => { + cancelled = true; + }; + }, [validName, resolved]); + + if (!validName) { + return FallbackIcon ? : null; + } + + if (resolved.failed && !resolved.node) { + return FallbackIcon ? : null; + } + + return ; +} diff --git a/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx b/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx index 242ce1bb5..8b580cb23 100644 --- a/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx +++ b/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx @@ -1,7 +1,8 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { within } from '@testing-library/react'; import { PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort, PubkyAppPostKind } from 'pubky-app-specs'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadLucideIconNode } from '@/libs/utils/lucideIcons'; import type { FeedModelSchema } from '@/models/feed/feed.schema'; import { TAGGED_AS_FILTER_KEY } from '@/molecules/Filters/FilterReach/FilterReach'; import { CustomFeedDialog } from './CustomFeedDialog'; @@ -61,18 +62,16 @@ vi.mock('@/atoms/Dialog/Dialog', () => { // Mock router const mockPush = vi.fn(); +const mockReplace = vi.fn(); +const mockUsePathname = vi.fn(); vi.mock('next/navigation', () => ({ + usePathname: () => mockUsePathname(), useRouter: () => ({ push: mockPush, + replace: mockReplace, }), })); -// Mock hooks -const mockUseCustomFeed = vi.fn(); -vi.mock('@/hooks/useCustomFeed/useCustomFeed', () => ({ - useCustomFeed: () => mockUseCustomFeed(), -})); - // Mock toast const mockToast = vi.fn(); vi.mock('@/molecules/PostTag/PostTag', () => { @@ -145,6 +144,29 @@ vi.mock('@/molecules/Toaster/use-toast', () => { }; }); +vi.mock('@/organisms/IconPickerDialog/IconPickerDialog', () => ({ + IconPickerDialog: ({ + children, + value, + onSelect, + title, + description, + }: { + children: React.ReactNode; + value?: string | null; + onSelect: (iconName: string) => void; + title?: string; + description?: string; + }) => ( +
+ {children} + +
+ ), +})); + // Mock dependencies const mockCommitCreate = vi.fn(); const mockCommitUpdate = vi.fn(); @@ -167,6 +189,8 @@ vi.mock('@/atoms/Button/Button', () => { onClick, disabled, className, + type, + 'aria-label': ariaLabel, 'data-testid': dataTestId, }: { children: React.ReactNode; @@ -175,6 +199,8 @@ vi.mock('@/atoms/Button/Button', () => { onClick?: () => void; disabled?: boolean; className?: string; + type?: 'button' | 'submit' | 'reset'; + 'aria-label'?: string; 'data-testid'?: string; }) => ( @@ -320,6 +348,7 @@ vi.mock('@/atoms/Typography/Typography', () => { const createMockFeed = (overrides: Partial = {}): FeedModelSchema => ({ id: 'feed-abc123', name: 'Bitcoin News', + icon: 'activity', tags: ['bitcoin', 'lightning'], domain_tags: [], reach: PubkyAppFeedReach.Following, @@ -340,7 +369,7 @@ const changeSelectValue = (testId: string, value: string | number) => { describe('CustomFeedDialog', () => { beforeEach(() => { vi.clearAllMocks(); - mockUseCustomFeed.mockReturnValue(undefined); + mockUsePathname.mockReturnValue('/feed/feed-abc123'); }); // -- Sanity / Rendering -- @@ -371,10 +400,9 @@ describe('CustomFeedDialog', () => { it('renders dialog title with translated title for edit', () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -395,48 +423,58 @@ describe('CustomFeedDialog', () => { expect(input).toHaveAttribute('placeholder', 'Not your keys...'); }); - it('renders the post-tag copy and reveals the profile-tag copy for Tagged as', () => { + it('renders the generic icon picker with the default feed icon', () => { render( , ); - expect(screen.getByText('Post Tags')).toBeInTheDocument(); - expect(screen.getByText('Filter by what posts are about.')).toBeInTheDocument(); - expect(screen.queryByText('Profile Tags')).not.toBeInTheDocument(); - - changeSelectValue('reach-select', TAGGED_AS_FILTER_KEY); - - expect(screen.getByText('Profile Tags')).toBeInTheDocument(); - expect(screen.getByText('Filter by how people are tagged.')).toBeInTheDocument(); + expect(screen.getByTestId('feed-icon-picker-trigger')).toHaveTextContent('Select icon'); + expect(screen.getByTestId('icon-picker-dialog')).toHaveAttribute('data-value', 'activity'); + expect(screen.getByTestId('icon-picker-dialog')).toHaveAttribute('data-title', 'Feed Icon'); + expect(screen.getByTestId('icon-picker-dialog')).toHaveAttribute( + 'data-description', + 'Choose a custom icon for your new feed.', + ); }); - it('renders feed name input as enabled in edit mode when customFeed is defined', () => { - const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); + it('uses the selected icon when creating a feed', async () => { + mockCommitCreate.mockResolvedValue(createMockFeed({ id: 'new-feed-123', icon: 'mountain' })); render( - - + + , ); - const input = screen.getByTestId('feed-name-input'); - expect(input).not.toBeDisabled(); + fireEvent.click(screen.getByTestId('choose-mountain-icon')); + fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'Mountain Feed' } }); + fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'hiking' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + + fireEvent.click(screen.getByTestId('save-feed-button')); + + await waitFor(() => { + expect(mockCommitCreate).toHaveBeenCalledWith( + expect.objectContaining({ + icon: 'mountain', + }), + ); + }); }); - it('renders feed name input as disabled in edit mode when customFeed is undefined', () => { - mockUseCustomFeed.mockReturnValue(undefined); + it('renders feed name input as enabled in edit mode when customFeed is defined', () => { + const mockFeed = createMockFeed(); render( - + , ); const input = screen.getByTestId('feed-name-input'); - expect(input).toBeDisabled(); + expect(input).not.toBeDisabled(); }); it('renders feed name input as enabled in create mode', () => { @@ -470,36 +508,20 @@ describe('CustomFeedDialog', () => { , ); + expect(screen.getByText('Post Tags')).toBeInTheDocument(); + expect(screen.getByText('Filter by what posts are about.')).toBeInTheDocument(); expect(screen.getByTestId('feed-tag-input')).toBeInTheDocument(); expect(screen.queryByTestId('profile-tags-section')).not.toBeInTheDocument(); expect(screen.queryByTestId('feed-profile-tag-input')).not.toBeInTheDocument(); changeSelectValue('reach-select', TAGGED_AS_FILTER_KEY); + expect(screen.getByText('Profile Tags')).toBeInTheDocument(); + expect(screen.getByText('Filter by how people are tagged.')).toBeInTheDocument(); expect(screen.getByTestId('profile-tags-section')).toBeInTheDocument(); expect(screen.getByTestId('feed-profile-tag-input')).toBeInTheDocument(); }); - it('renders the standalone Tagged-as reach order', () => { - render( - - - , - ); - - const reachSection = within(screen.getByTestId('reach-filter-section')); - expect(reachSection.getAllByTestId(/^select-item-/).map((item) => item.getAttribute('data-value'))).toEqual([ - String(PubkyAppFeedReach.Wot), - TAGGED_AS_FILTER_KEY, - String(PubkyAppFeedReach.Following), - String(PubkyAppFeedReach.Friends), - String(PubkyAppFeedReach.Me), - String(PubkyAppFeedReach.All), - ]); - expect(reachSection.getByTestId(`select-item-${PubkyAppFeedReach.Wot}`)).toHaveTextContent('My network'); - expect(reachSection.getByTestId(`select-item-${TAGGED_AS_FILTER_KEY}`)).toHaveTextContent('Tagged as'); - }); - it('renders Save Feed button', () => { render( @@ -524,10 +546,9 @@ describe('CustomFeedDialog', () => { it('renders Delete Feed button in edit mode', () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -549,29 +570,16 @@ describe('CustomFeedDialog', () => { // -- Trigger disabled state -- - it('disables dialog trigger in edit mode when customFeed is undefined', () => { - mockUseCustomFeed.mockReturnValue(undefined); - - render( - - - , - ); - - expect(screen.getByTestId('custom-feed-dialog-trigger')).toHaveAttribute('data-disabled', 'true'); - }); - it('does not disable dialog trigger in edit mode when customFeed is defined', () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); - expect(screen.getByTestId('custom-feed-dialog-trigger')).toHaveAttribute('data-disabled', 'false'); + expect(screen.getByTestId('custom-feed-dialog-trigger')).not.toHaveAttribute('data-disabled', 'true'); }); it('does not disable dialog trigger in create mode', () => { @@ -581,7 +589,7 @@ describe('CustomFeedDialog', () => { , ); - expect(screen.getByTestId('custom-feed-dialog-trigger')).toHaveAttribute('data-disabled', 'false'); + expect(screen.getByTestId('custom-feed-dialog-trigger')).not.toHaveAttribute('data-disabled', 'true'); }); // -- Save button disabled state -- @@ -610,20 +618,7 @@ describe('CustomFeedDialog', () => { expect(screen.getByTestId('save-feed-button')).toBeDisabled(); }); - it('disables Save Feed button when the name contains only whitespace', () => { - render( - - - , - ); - - fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: ' ' } }); - fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'bitcoin' } }); - - expect(screen.getByTestId('save-feed-button')).toBeDisabled(); - }); - - it('requires a profile tag before saving an explicitly selected Tagged-as feed', () => { + it('requires a profile tag before saving an explicitly selected Tagged-as feed', async () => { render( @@ -638,19 +633,7 @@ describe('CustomFeedDialog', () => { fireEvent.change(screen.getByTestId('profile-tag-input-field'), { target: { value: 'developer' } }); - expect(screen.getByTestId('save-feed-button')).not.toBeDisabled(); - }); - - it('disables Save Feed button in edit mode when customFeed is undefined', () => { - mockUseCustomFeed.mockReturnValue(undefined); - - render( - - - , - ); - - expect(screen.getByTestId('save-feed-button')).toBeDisabled(); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); }); // -- Name input interaction -- @@ -701,10 +684,9 @@ describe('CustomFeedDialog', () => { it('displays existing tags from customFeed in edit mode', () => { const mockFeed = createMockFeed({ tags: ['bitcoin', 'lightning'] }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -713,54 +695,41 @@ describe('CustomFeedDialog', () => { expect(screen.getByTestId('post-tag-lightning')).toBeInTheDocument(); }); - it('hydrates existing profile tags from customFeed in edit mode', () => { - mockUseCustomFeed.mockReturnValue( - createMockFeed({ reach: PubkyAppFeedReach.Wot, tags: [], domain_tags: ['bitcoiner', '🔥'] }), - ); + it('hydrates existing profile tags from a Tagged-as feed in edit mode', () => { + const mockFeed = createMockFeed({ + reach: PubkyAppFeedReach.Wot, + tags: [], + domain_tags: ['bitcoiner', '🔥'], + }); render( - + , ); + expect(screen.getByTestId('reach-select')).toHaveAttribute('data-value', TAGGED_AS_FILTER_KEY); + expect(screen.getByTestId('profile-tags-section')).toBeInTheDocument(); expect(screen.getByTestId('post-tag-bitcoiner')).toBeInTheDocument(); expect(screen.getByTestId('post-tag-🔥')).toBeInTheDocument(); }); it('shows legacy Me profile tags read-only while keeping the editor hidden', () => { - mockUseCustomFeed.mockReturnValue( - createMockFeed({ reach: PubkyAppFeedReach.Me, tags: [], domain_tags: ['bitcoiner'] }), - ); + const mockFeed = createMockFeed({ + reach: PubkyAppFeedReach.Me, + tags: [], + domain_tags: ['bitcoiner'], + }); render( - + , ); - expect(screen.getByTestId('reach-select')).toHaveAttribute('data-value', String(PubkyAppFeedReach.Me)); - expect(screen.getByTestId('post-tag-bitcoiner')).toHaveAttribute('data-show-close', 'false'); expect(screen.getByTestId('profile-tags-section')).toBeInTheDocument(); expect(screen.queryByTestId('feed-profile-tag-input')).not.toBeInTheDocument(); - expect(screen.queryByTestId('remove-tag-bitcoiner')).not.toBeInTheDocument(); - }); - - it('shows legacy Following profile tags read-only while keeping the editor hidden', () => { - mockUseCustomFeed.mockReturnValue( - createMockFeed({ reach: PubkyAppFeedReach.Following, tags: ['bitcoin'], domain_tags: ['bitcoiner'] }), - ); - - render( - - - , - ); - expect(screen.getByTestId('post-tag-bitcoiner')).toHaveAttribute('data-show-close', 'false'); - expect(screen.getByTestId('profile-tags-section')).toBeInTheDocument(); - expect(screen.queryByTestId('feed-profile-tag-input')).not.toBeInTheDocument(); - expect(screen.queryByTestId('remove-tag-bitcoiner')).not.toBeInTheDocument(); }); it('clears profile tags on every explicit non-Tagged-as selection', () => { @@ -774,14 +743,13 @@ describe('CustomFeedDialog', () => { fireEvent.change(screen.getByTestId('profile-tag-input-field'), { target: { value: 'bitcoiner' } }); expect(screen.getByTestId('post-tag-bitcoiner')).toBeInTheDocument(); - changeSelectValue('reach-select', PubkyAppFeedReach.Me); + changeSelectValue('reach-select', PubkyAppFeedReach.Following); - expect(screen.queryByTestId('post-tag-bitcoiner')).not.toBeInTheDocument(); expect(screen.queryByTestId('profile-tags-section')).not.toBeInTheDocument(); expect(screen.queryByTestId('feed-profile-tag-input')).not.toBeInTheDocument(); }); - it('clears and disables profile tags when switching to All', () => { + it('caps profile tags at five and hides the emoji selector at the limit', () => { render( @@ -789,40 +757,109 @@ describe('CustomFeedDialog', () => { ); changeSelectValue('reach-select', TAGGED_AS_FILTER_KEY); - fireEvent.change(screen.getByTestId('profile-tag-input-field'), { target: { value: 'bitcoiner' } }); - expect(screen.getByTestId('post-tag-bitcoiner')).toBeInTheDocument(); - - changeSelectValue('reach-select', PubkyAppFeedReach.All); + const profileTagInput = screen.getByTestId('profile-tag-input-field'); + for (const tag of ['one', 'two', 'three', 'four', 'five', 'six']) { + fireEvent.change(profileTagInput, { target: { value: tag } }); + } - expect(screen.queryByTestId('post-tag-bitcoiner')).not.toBeInTheDocument(); - expect(screen.queryByTestId('profile-tags-section')).not.toBeInTheDocument(); - expect(screen.queryByTestId('feed-profile-tag-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('feed-profile-tag-input')).toHaveAttribute('data-current-tags-count', '5'); + expect(screen.getByTestId('feed-profile-tag-input')).toHaveAttribute('data-show-emoji-button', 'false'); + expect(screen.queryByTestId('post-tag-six')).not.toBeInTheDocument(); }); - it('caps profile tags at five and hides the emoji selector at the limit', () => { + it('creates a profile-only Tagged-as feed as Wot plus domain tags', async () => { + mockCommitCreate.mockResolvedValue( + createMockFeed({ id: 'profile-feed', reach: PubkyAppFeedReach.Wot, tags: [], domain_tags: ['🔥'] }), + ); + render( , ); + fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'Emoji Network' } }); changeSelectValue('reach-select', TAGGED_AS_FILTER_KEY); - const profileTagInput = screen.getByTestId('profile-tag-input-field'); - for (const tag of ['one', 'two', 'three', 'four', 'five', 'six']) { - fireEvent.change(profileTagInput, { target: { value: tag } }); - } + fireEvent.change(screen.getByTestId('profile-tag-input-field'), { target: { value: '🔥' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); - expect(screen.getByTestId('feed-profile-tag-input')).toHaveAttribute('data-current-tags-count', '5'); - expect(screen.getByTestId('feed-profile-tag-input')).toHaveAttribute('data-show-emoji-button', 'false'); - expect(screen.queryByTestId('post-tag-six')).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockCommitCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Emoji Network', + reach: PubkyAppFeedReach.Wot, + tags: [], + domain_tags: ['🔥'], + }), + ); + }); + }); + + it('preserves a legacy Me domain feed during a rename-only edit', async () => { + const mockFeed = createMockFeed({ + reach: PubkyAppFeedReach.Me, + tags: ['bitcoin'], + domain_tags: ['developer'], + }); + mockCommitUpdate.mockResolvedValue({ ...mockFeed, name: 'Renamed legacy feed' }); + + render( + + + , + ); + + fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'Renamed legacy feed' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); + + await waitFor(() => { + expect(mockCommitUpdate).toHaveBeenCalledWith({ + feedId: mockFeed.id, + changes: expect.objectContaining({ + name: 'Renamed legacy feed', + reach: PubkyAppFeedReach.Me, + domain_tags: ['developer'], + }), + }); + }); + }); + + it('clears a legacy domain tag list on an explicit Following to Friends change', async () => { + const mockFeed = createMockFeed({ + reach: PubkyAppFeedReach.Following, + tags: ['bitcoin'], + domain_tags: ['developer'], + }); + mockCommitUpdate.mockResolvedValue({ ...mockFeed, reach: PubkyAppFeedReach.Friends, domain_tags: [] }); + + render( + + + , + ); + + changeSelectValue('reach-select', PubkyAppFeedReach.Friends); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); + + await waitFor(() => { + expect(mockCommitUpdate).toHaveBeenCalledWith({ + feedId: mockFeed.id, + changes: expect.objectContaining({ + reach: PubkyAppFeedReach.Friends, + domain_tags: [], + }), + }); + }); }); it('shows close buttons on tags when not disabled', () => { const mockFeed = createMockFeed({ tags: ['bitcoin'] }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -839,10 +876,17 @@ describe('CustomFeedDialog', () => { , ); - const section = screen.getByTestId('reach-filter-section'); - expect(within(section).getByText('All')).toBeInTheDocument(); - expect(within(section).getByText('Following')).toBeInTheDocument(); - expect(within(section).getByText('Friends')).toBeInTheDocument(); + const reachSection = within(screen.getByTestId('reach-filter-section')); + expect(reachSection.getAllByTestId(/^select-item-/).map((item) => item.getAttribute('data-value'))).toEqual([ + String(PubkyAppFeedReach.Wot), + TAGGED_AS_FILTER_KEY, + String(PubkyAppFeedReach.Following), + String(PubkyAppFeedReach.Friends), + String(PubkyAppFeedReach.Me), + String(PubkyAppFeedReach.All), + ]); + expect(reachSection.getByTestId(`select-item-${PubkyAppFeedReach.Wot}`)).toHaveTextContent('My network'); + expect(reachSection.getByTestId(`select-item-${TAGGED_AS_FILTER_KEY}`)).toHaveTextContent('Tagged as'); }); it('renders all sort filter options', () => { @@ -992,10 +1036,9 @@ describe('CustomFeedDialog', () => { it('populates feed name input from customFeed in edit mode', () => { const mockFeed = createMockFeed({ name: 'Bitcoin News' }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -1003,12 +1046,41 @@ describe('CustomFeedDialog', () => { expect(screen.getByTestId('feed-name-input')).toHaveValue('Bitcoin News'); }); + it('uses an explicitly supplied feed when editing from another route', () => { + const mockFeed = createMockFeed({ id: 'feed-explicit', name: 'Explicit Feed', icon: 'mountain' }); + + render( + + + , + ); + + expect(screen.getByTestId('feed-name-input')).toHaveValue('Explicit Feed'); + expect(screen.getByTestId('icon-picker-dialog')).toHaveAttribute('data-value', 'mountain'); + expect(screen.getByTestId('icon-picker-dialog')).toHaveAttribute( + 'data-description', + 'Choose a custom icon for your feed.', + ); + expect(screen.getByTestId('custom-feed-dialog-trigger')).not.toHaveAttribute('data-disabled', 'true'); + }); + + it('falls back to the default icon for a legacy feed without an icon', () => { + const mockFeed = createMockFeed({ icon: undefined }); + + render( + + + , + ); + + expect(screen.getByTestId('icon-picker-dialog')).toHaveAttribute('data-value', 'activity'); + }); + it('populates reach select from customFeed in edit mode', () => { const mockFeed = createMockFeed({ reach: PubkyAppFeedReach.Friends }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -1018,10 +1090,9 @@ describe('CustomFeedDialog', () => { it('maps null content from customFeed to ALL in edit mode', () => { const mockFeed = createMockFeed({ content: null }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -1031,10 +1102,9 @@ describe('CustomFeedDialog', () => { it('populates visual layout from customFeed in edit mode', () => { const mockFeed = createMockFeed({ layout: PubkyAppFeedLayout.Visual, content: PubkyAppPostKind.Video }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -1044,10 +1114,9 @@ describe('CustomFeedDialog', () => { it('normalizes unsupported visual content from customFeed to ALL in edit mode', async () => { const mockFeed = createMockFeed({ layout: PubkyAppFeedLayout.Visual, content: PubkyAppPostKind.Short }); - mockUseCustomFeed.mockReturnValue(mockFeed); render( - + , ); @@ -1076,11 +1145,14 @@ describe('CustomFeedDialog', () => { fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'bitcoin' } }); // Click save + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { expect(mockCommitCreate).toHaveBeenCalledWith({ name: 'My Feed', + icon: 'activity', reach: PubkyAppFeedReach.All, sort: PubkyAppFeedSort.Recent, layout: PubkyAppFeedLayout.Columns, @@ -1091,34 +1163,6 @@ describe('CustomFeedDialog', () => { }); }); - it('creates a profile-only Tagged-as feed as Wot plus domain tags', async () => { - mockCommitCreate.mockResolvedValue( - createMockFeed({ id: 'profile-feed', reach: PubkyAppFeedReach.Wot, tags: [], domain_tags: ['🔥'] }), - ); - - render( - - - , - ); - - fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'Emoji Network' } }); - changeSelectValue('reach-select', TAGGED_AS_FILTER_KEY); - fireEvent.change(screen.getByTestId('profile-tag-input-field'), { target: { value: '🔥' } }); - fireEvent.click(screen.getByTestId('save-feed-button')); - - await waitFor(() => { - expect(mockCommitCreate).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'Emoji Network', - reach: PubkyAppFeedReach.Wot, - tags: [], - domain_tags: ['🔥'], - }), - ); - }); - }); - it('shows success toast and navigates after successful create', async () => { const mockCreatedFeed = createMockFeed({ id: 'new-feed-123', name: 'My Feed' }); mockCommitCreate.mockResolvedValue(mockCreatedFeed); @@ -1131,6 +1175,8 @@ describe('CustomFeedDialog', () => { fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'My Feed' } }); fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'bitcoin' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1152,6 +1198,8 @@ describe('CustomFeedDialog', () => { fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'My Feed' } }); fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'bitcoin' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1174,6 +1222,8 @@ describe('CustomFeedDialog', () => { fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'My Feed' } }); fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'bitcoin' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1208,6 +1258,8 @@ describe('CustomFeedDialog', () => { expect(screen.getByTestId('layout-select')).toHaveAttribute('data-value', String(PubkyAppFeedLayout.Visual)); }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1224,19 +1276,20 @@ describe('CustomFeedDialog', () => { it('calls FeedController.commitUpdate with correct params on save in edit mode', async () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); const mockUpdatedFeed = createMockFeed({ id: 'feed-abc123', name: 'Bitcoin News' }); mockCommitUpdate.mockResolvedValue(mockUpdatedFeed); render( - + , ); // Add a new tag (existing tags are populated from customFeed) fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'crypto' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1244,6 +1297,7 @@ describe('CustomFeedDialog', () => { feedId: 'feed-abc123', changes: { name: 'Bitcoin News', + icon: 'activity', reach: PubkyAppFeedReach.Following, sort: PubkyAppFeedSort.Popularity, layout: PubkyAppFeedLayout.Wide, @@ -1255,101 +1309,112 @@ describe('CustomFeedDialog', () => { }); }); - it('preserves a legacy Me domain feed during a rename-only edit', async () => { - const mockFeed = createMockFeed({ - reach: PubkyAppFeedReach.Me, - tags: ['bitcoin'], - domain_tags: ['developer'], - }); - mockUseCustomFeed.mockReturnValue(mockFeed); - mockCommitUpdate.mockResolvedValue({ ...mockFeed, name: 'Renamed legacy feed' }); + it('persists a newly selected icon when editing a feed', async () => { + const mockFeed = createMockFeed({ icon: 'activity' }); + mockCommitUpdate.mockResolvedValue(createMockFeed({ icon: 'mountain' })); render( - + , ); - fireEvent.change(screen.getByTestId('feed-name-input'), { target: { value: 'Renamed legacy feed' } }); + fireEvent.click(screen.getByTestId('choose-mountain-icon')); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { - expect(mockCommitUpdate).toHaveBeenCalledWith({ - feedId: mockFeed.id, - changes: expect.objectContaining({ - name: 'Renamed legacy feed', - reach: PubkyAppFeedReach.Me, - domain_tags: ['developer'], + expect(mockCommitUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + changes: expect.objectContaining({ + icon: 'mountain', + }), }), - }); + ); }); }); - it('clears a legacy domain tag list on an explicit Following to Friends change', async () => { - const mockFeed = createMockFeed({ - reach: PubkyAppFeedReach.Following, - tags: ['bitcoin'], - domain_tags: ['developer'], - }); - mockUseCustomFeed.mockReturnValue(mockFeed); - mockCommitUpdate.mockResolvedValue({ ...mockFeed, reach: PubkyAppFeedReach.Friends, domain_tags: [] }); + it('shows success toast without navigating when the feed id is unchanged', async () => { + const mockFeed = createMockFeed(); + + const mockUpdatedFeed = createMockFeed({ id: 'feed-abc123', name: 'Bitcoin News' }); + mockCommitUpdate.mockResolvedValue(mockUpdatedFeed); render( - + , ); - changeSelectValue('reach-select', PubkyAppFeedReach.Friends); + fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'crypto' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { - expect(mockCommitUpdate).toHaveBeenCalledWith({ - feedId: mockFeed.id, - changes: expect.objectContaining({ - reach: PubkyAppFeedReach.Friends, - domain_tags: [], - }), + expect(mockToast).toHaveBeenCalledWith({ + title: 'Feed updated: Bitcoin News', }); + expect(mockPush).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); }); }); - it('shows success toast and navigates after successful edit', async () => { + it('replaces the stale active route when an edit changes the feed id', async () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); + mockCommitUpdate.mockResolvedValue(createMockFeed({ id: 'feed-updated' })); - const mockUpdatedFeed = createMockFeed({ id: 'feed-abc123', name: 'Bitcoin News' }); - mockCommitUpdate.mockResolvedValue(mockUpdatedFeed); + render( + + + , + ); + + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + + fireEvent.click(screen.getByTestId('save-feed-button')); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith('/feed/feed-updated'); + }); + }); + + it('stays on the current route when editing an inactive explicitly supplied feed', async () => { + const mockFeed = createMockFeed({ id: 'feed-inactive' }); + mockUsePathname.mockReturnValue('/home'); + mockCommitUpdate.mockResolvedValue(createMockFeed({ id: 'feed-updated' })); render( - + , ); - fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'crypto' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { - expect(mockToast).toHaveBeenCalledWith({ - title: 'Feed updated: Bitcoin News', - }); - expect(mockPush).toHaveBeenCalledWith('/feed/feed-abc123'); + expect(mockCommitUpdate).toHaveBeenCalledWith(expect.objectContaining({ feedId: 'feed-inactive' })); + expect(mockPush).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); }); }); it('shows error toast when edit fails', async () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); mockCommitUpdate.mockRejectedValue(new Error('Network error')); render( - + , ); fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'crypto' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1362,16 +1427,17 @@ describe('CustomFeedDialog', () => { it('sends content kind value when content is not ALL on edit', async () => { const mockFeed = createMockFeed({ content: PubkyAppPostKind.Image }); - mockUseCustomFeed.mockReturnValue(mockFeed); mockCommitUpdate.mockResolvedValue(createMockFeed()); render( - + , ); fireEvent.change(screen.getByTestId('tag-input-field'), { target: { value: 'photo' } }); + await waitFor(() => expect(screen.getByTestId('save-feed-button')).toBeEnabled()); + fireEvent.click(screen.getByTestId('save-feed-button')); await waitFor(() => { @@ -1389,11 +1455,10 @@ describe('CustomFeedDialog', () => { it('calls FeedController.commitDelete when delete button is clicked', async () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); mockCommitDelete.mockResolvedValue(undefined); render( - + , ); @@ -1405,13 +1470,12 @@ describe('CustomFeedDialog', () => { }); }); - it('shows success toast and navigates to home after successful delete', async () => { + it('shows success toast and replaces the active route with home after successful delete', async () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); mockCommitDelete.mockResolvedValue(undefined); render( - + , ); @@ -1422,17 +1486,17 @@ describe('CustomFeedDialog', () => { expect(mockToast).toHaveBeenCalledWith({ title: 'Feed deleted: Bitcoin News', }); - expect(mockPush).toHaveBeenCalledWith('/home'); + expect(mockReplace).toHaveBeenCalledWith('/home'); }); }); - it('shows error toast when delete fails', async () => { - const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); - mockCommitDelete.mockRejectedValue(new Error('Delete failed')); + it('stays on the current route after deleting an inactive explicitly supplied feed', async () => { + const mockFeed = createMockFeed({ id: 'feed-inactive' }); + mockUsePathname.mockReturnValue('/home'); + mockCommitDelete.mockResolvedValue(undefined); render( - + , ); @@ -1440,32 +1504,45 @@ describe('CustomFeedDialog', () => { fireEvent.click(screen.getByTestId('delete-feed-button')); await waitFor(() => { - expect(mockToast).toHaveBeenCalledWith({ - variant: 'error', - description: 'Could not delete feed. Try again.', - }); + expect(mockCommitDelete).toHaveBeenCalledWith({ feedId: 'feed-inactive' }); + expect(mockPush).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); }); }); - it('disables delete button when customFeed is undefined', () => { - mockUseCustomFeed.mockReturnValue(undefined); + it('shows error toast when delete fails', async () => { + const mockFeed = createMockFeed(); + mockCommitDelete.mockRejectedValue(new Error('Delete failed')); render( - + , ); - expect(screen.getByTestId('delete-feed-button')).toBeDisabled(); + fireEvent.click(screen.getByTestId('delete-feed-button')); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + variant: 'error', + description: 'Could not delete feed. Try again.', + }); + }); }); }); // --- Snapshot Tests --- describe('CustomFeedDialog - Snapshots', () => { + // Warm the dialog's icons so DynamicLucideIcon renders them synchronously + // and snapshots capture the resolved svg regardless of test order. + beforeAll(async () => { + await loadLucideIconNode('activity'); + }); + beforeEach(() => { vi.clearAllMocks(); - mockUseCustomFeed.mockReturnValue(undefined); + mockUsePathname.mockReturnValue('/feed/feed-abc123'); }); it('matches snapshot for create mode default state', () => { @@ -1479,21 +1556,9 @@ describe('CustomFeedDialog - Snapshots', () => { it('matches snapshot for edit mode with custom feed', () => { const mockFeed = createMockFeed(); - mockUseCustomFeed.mockReturnValue(mockFeed); - - const { container } = render( - - - , - ); - expect(container.firstChild).toMatchSnapshot(); - }); - - it('matches snapshot for edit mode without custom feed (disabled)', () => { - mockUseCustomFeed.mockReturnValue(undefined); const { container } = render( - + , ); @@ -1502,10 +1567,9 @@ describe('CustomFeedDialog - Snapshots', () => { it('matches snapshot for edit mode with null content feed', () => { const mockFeed = createMockFeed({ content: null, tags: ['bitcoin'] }); - mockUseCustomFeed.mockReturnValue(mockFeed); const { container } = render( - + , ); diff --git a/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx.snap b/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx.snap index dbd771e0a..74777b4f4 100644 --- a/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx.snap +++ b/src/components/organisms/CustomFeedDialog/CustomFeedDialog.test.tsx.snap @@ -7,7 +7,6 @@ exports[`CustomFeedDialog - Snapshots > matches snapshot for create mode default >
+
+ +
+ + +
+
matches snapshot for create mode default r="2" /> + My network
matches snapshot for create mode default r=".5" /> + Tagged as
matches snapshot for create mode default stroke-width="1.5" /> + Following
matches snapshot for create mode default d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762" /> + Friends
matches snapshot for create mode default d="M20 21a8 8 0 0 0-16 0" /> + Me
matches snapshot for create mode default r="2" /> + All
@@ -899,7 +954,7 @@ exports[`CustomFeedDialog - Snapshots > matches snapshot for create mode default >
matches snapshot for create mode with di value="" />
+
+ +
+ + +
+
matches snapshot for create mode with di r="2" /> + My network
matches snapshot for create mode with di r=".5" /> + Tagged as
matches snapshot for create mode with di stroke-width="1.5" /> + Following
matches snapshot for create mode with di d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762" /> + Friends
matches snapshot for create mode with di d="M20 21a8 8 0 0 0-16 0" /> + Me
matches snapshot for create mode with di r="2" /> + All
@@ -1822,7 +1932,7 @@ exports[`CustomFeedDialog - Snapshots > matches snapshot for create mode with di >
+
+ +
+ + +
+
matches snapshot for edit mode with cust r="2" /> + My network
matches snapshot for edit mode with cust r=".5" /> + Tagged as
matches snapshot for edit mode with cust stroke-width="1.5" /> + Following
matches snapshot for edit mode with cust d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762" /> + Friends
matches snapshot for edit mode with cust d="M20 21a8 8 0 0 0-16 0" /> + Me
matches snapshot for edit mode with cust r="2" /> + All
@@ -2776,10 +2941,11 @@ exports[`CustomFeedDialog - Snapshots > matches snapshot for edit mode with cust data-size="lg" data-testid="save-feed-button" data-variant="secondary" + disabled="" >
+
+ +
+ + +
+
matches snapshot for edit mode with null r="2" /> + My network
matches snapshot for edit mode with null r=".5" /> + Tagged as
matches snapshot for edit mode with null stroke-width="1.5" /> + Following
matches snapshot for edit mode with null d="M19.414 14.414C21 12.828 22 11.5 22 9.5a5.5 5.5 0 0 0-9.591-3.676.6.6 0 0 1-.818.001A5.5 5.5 0 0 0 2 9.5c0 2.3 1.5 4 3 5.5l5.535 5.362a2 2 0 0 0 2.879.052 2.12 2.12 0 0 0-.004-3 2.124 2.124 0 1 0 3-3 2.124 2.124 0 0 0 3.004 0 2 2 0 0 0 0-2.828l-1.881-1.882a2.41 2.41 0 0 0-3.409 0l-1.71 1.71a2 2 0 0 1-2.828 0 2 2 0 0 1 0-2.828l2.823-2.762" /> + Friends
matches snapshot for edit mode with null d="M20 21a8 8 0 0 0-16 0" /> + Me
matches snapshot for edit mode with null r="2" /> + All
@@ -3748,10 +3969,11 @@ exports[`CustomFeedDialog - Snapshots > matches snapshot for edit mode with null data-size="lg" data-testid="save-feed-button" data-variant="secondary" + disabled="" >
-
- -
-
-
-

- Edit Feed -

-
-
- - -
-
-
- -
-
- - Select a reach - -
-
-
- - My network -
-
- - Tagged as -
-
- - - - Following -
-
- - Friends -
-
- - Me -
-
- - All -
-
- -
-
-
- -
-
- - Select a sort - -
-
-
- - - Recent -
-
- - - Popularity -
-
- -
-
-
- -
-
- - Select a layout - -
-
-
- - - Columns -
-
- - - Wide -
-
- - - Visual -
-
- - - List -
-
- -
-
-
- -
-
- - Select content - -
-
-
- - - All -
-
- - - Posts -
-
- - - Articles -
-
- - - Collections -
-
- - - Images -
-
- - - Videos -
-
- - - Links -
-
- - - Files -
-
- -
-
-
-
- - - Filter by what posts are about. - -
- -
-
-
- - -
-
-
-`; diff --git a/src/components/organisms/CustomFeedDialog/CustomFeedDialog.tsx b/src/components/organisms/CustomFeedDialog/CustomFeedDialog.tsx index a9afdf14b..b0fa13503 100644 --- a/src/components/organisms/CustomFeedDialog/CustomFeedDialog.tsx +++ b/src/components/organisms/CustomFeedDialog/CustomFeedDialog.tsx @@ -1,9 +1,7 @@ 'use client'; import { type ComponentType, type ReactNode, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; import { - Activity, CirclePlay, Columns3, Delete, @@ -27,78 +25,74 @@ import { } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort, PubkyAppPostKind } from 'pubky-app-specs'; -import { APP_ROUTES } from '@/app/routes'; +import { Controller, useWatch } from 'react-hook-form'; import { Button } from '@/atoms/Button/Button'; import { Container } from '@/atoms/Container/Container'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog'; +import { DynamicLucideIcon } from '@/atoms/DynamicLucideIcon/DynamicLucideIcon'; import { Input } from '@/atoms/Input/Input'; import { Label } from '@/atoms/Label/Label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/atoms/Select/Select'; import { Typography } from '@/atoms/Typography/Typography'; -import { useCustomFeed } from '@/hooks/useCustomFeed/useCustomFeed'; -import { useCustomFeedMutation } from '@/hooks/useCustomFeedMutation/useCustomFeedMutation'; +import { useCustomFeedForm } from '@/hooks/useCustomFeedForm/useCustomFeedForm'; +import { + CUSTOM_FEED_CONTENT_ALL, + CUSTOM_FEED_FORM_FIELDS, + type CustomFeedFormContent, + type CustomFeedFormReach, +} from '@/hooks/useCustomFeedForm/useCustomFeedForm.types'; import { UsersRound2 } from '@/icons'; import { getMaxStreamTags } from '@/libs/runtime-config/runtime-config'; +import type { FeedModelSchema } from '@/models/feed/feed.schema'; import { TAGGED_AS_FILTER_KEY } from '@/molecules/Filters/FilterReach/FilterReach'; import { PostTag } from '@/molecules/PostTag/PostTag'; import { TagInput } from '@/molecules/TagInput/TagInput'; -import { useToast } from '@/molecules/Toaster/use-toast'; +import { IconPickerDialog } from '@/organisms/IconPickerDialog/IconPickerDialog'; import { HOME_PROFILE_TAGS_MAX_SELECTED } from '@/stores/home/home.types'; -type CustomFeedDialogProps = { - mode: 'create' | 'edit'; - children: ReactNode; -}; -type CustomFeedDialogContent = PubkyAppPostKind | 'ALL'; -type CustomFeedReachValue = PubkyAppFeedReach | typeof TAGGED_AS_FILTER_KEY; +type CustomFeedDialogProps = + | { + mode: 'create'; + children: ReactNode; + feed?: never; + } + | { + mode: 'edit'; + children: ReactNode; + feed: FeedModelSchema; + }; -function isVisualCustomFeedContentSupported(content?: CustomFeedDialogContent): boolean { - return content === 'ALL' || content === PubkyAppPostKind.Image || content === PubkyAppPostKind.Video; +function isVisualCustomFeedContentSupported(content?: CustomFeedFormContent): boolean { + return ( + content === CUSTOM_FEED_CONTENT_ALL || content === PubkyAppPostKind.Image || content === PubkyAppPostKind.Video + ); } -export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { - const router = useRouter(); - const { toast } = useToast(); - const customFeed = useCustomFeed(); - const { commitCreate, commitUpdate, commitDelete, loading } = useCustomFeedMutation(); - const tFilter = useTranslations('filters'); - const tDialog = useTranslations('dialogs.customFeed'); + +function parseReachValue(value: string): CustomFeedFormReach { + return value === TAGGED_AS_FILTER_KEY ? TAGGED_AS_FILTER_KEY : (Number(value) as PubkyAppFeedReach); +} + +export const CustomFeedDialog = (props: CustomFeedDialogProps) => { + const { mode, children } = props; const [open, setOpen] = useState(false); - const [name, setName] = useState(''); - const [reach, setReach] = useState( - mode === 'create' ? PubkyAppFeedReach.All : undefined, - ); - const [sort, setSort] = useState( - mode === 'create' ? PubkyAppFeedSort.Recent : undefined, - ); - const [layout, setLayout] = useState( - mode === 'create' ? PubkyAppFeedLayout.Columns : undefined, + // Read `feed` off `props` rather than destructuring it: the props union ties + // `feed` to `mode`, and destructuring erases that link for TS. + const { form, loading, submit, deleteFeed } = useCustomFeedForm( + props.mode === 'edit' ? { mode: 'edit', feed: props.feed, open } : { mode: 'create', open }, ); - const [content, setContent] = useState(mode === 'create' ? 'ALL' : undefined); - const [tags, setTags] = useState([]); - const [domainTags, setDomainTags] = useState([]); - const disabled = loading || (mode === 'edit' && !customFeed); - useEffect(() => { - if (open) return; - if (mode === 'create') { - setName(''); - setReach(PubkyAppFeedReach.All); - setSort(PubkyAppFeedSort.Recent); - setLayout(PubkyAppFeedLayout.Columns); - setContent('ALL'); - setTags([]); - setDomainTags([]); - } else if (mode === 'edit') { - const domainTags = customFeed?.domain_tags ?? []; - const isTaggedAsFeed = customFeed?.reach === PubkyAppFeedReach.Wot && domainTags.length > 0; - setName(customFeed?.name ?? ''); - setReach(isTaggedAsFeed ? TAGGED_AS_FILTER_KEY : customFeed?.reach); - setSort(customFeed?.sort); - setLayout(customFeed?.layout); - setContent(customFeed?.content === null ? 'ALL' : customFeed?.content); - setTags(customFeed?.tags ?? []); - setDomainTags(domainTags); - } - }, [open, mode, customFeed]); + const tFilter = useTranslations('filters'); + const tDialog = useTranslations('dialogs.customFeed'); + + const { control } = form; + const layout = useWatch({ control, name: CUSTOM_FEED_FORM_FIELDS.LAYOUT }); + const content = useWatch({ control, name: CUSTOM_FEED_FORM_FIELDS.CONTENT }); + const icon = useWatch({ control, name: CUSTOM_FEED_FORM_FIELDS.ICON }); + const reach = useWatch({ control, name: CUSTOM_FEED_FORM_FIELDS.REACH }); + const domainTags = useWatch({ control, name: CUSTOM_FEED_FORM_FIELDS.DOMAIN_TAGS }) ?? []; + + const isTaggedAsReach = reach === TAGGED_AS_FILTER_KEY; + const isAtProfileTagLimit = domainTags.length >= HOME_PROFILE_TAGS_MAX_SELECTED; + const reachFilters = [ { value: PubkyAppFeedReach.Wot, @@ -166,12 +160,12 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { }, ]; const allContentFilters: Array<{ - value: CustomFeedDialogContent; + value: CustomFeedFormContent; label: string; icon: ComponentType; }> = [ { - value: 'ALL', + value: CUSTOM_FEED_CONTENT_ALL, label: tFilter('content.all'), icon: Layers, }, @@ -215,132 +209,82 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { layout === PubkyAppFeedLayout.Visual ? allContentFilters.filter((filter) => isVisualCustomFeedContentSupported(filter.value)) : allContentFilters; + + // Catches a stored feed whose layout/content pair another client left in a + // combination this dialog cannot represent; user-driven layout changes are + // corrected up-front in `handleLayoutChange`. useEffect(() => { if (layout !== PubkyAppFeedLayout.Visual) return; - if (content === undefined || isVisualCustomFeedContentSupported(content)) return; - setContent('ALL'); - }, [content, layout]); - const handleLayoutChange = (value: string) => { + if (isVisualCustomFeedContentSupported(content)) return; + form.setValue(CUSTOM_FEED_FORM_FIELDS.CONTENT, CUSTOM_FEED_CONTENT_ALL, { shouldValidate: true }); + }, [content, layout, form]); + + const handleLayoutChange = (value: string, onChange: (next: PubkyAppFeedLayout) => void) => { const nextLayout = Number(value) as PubkyAppFeedLayout; - setLayout(nextLayout); - if ( - nextLayout === PubkyAppFeedLayout.Visual && - content !== undefined && - !isVisualCustomFeedContentSupported(content) - ) { - setContent('ALL'); + onChange(nextLayout); + if (nextLayout === PubkyAppFeedLayout.Visual && !isVisualCustomFeedContentSupported(content)) { + form.setValue(CUSTOM_FEED_FORM_FIELDS.CONTENT, CUSTOM_FEED_CONTENT_ALL, { shouldValidate: true }); } }; + const handleReachChange = (value: string) => { - const nextReach: CustomFeedReachValue = - value === TAGGED_AS_FILTER_KEY ? TAGGED_AS_FILTER_KEY : (Number(value) as PubkyAppFeedReach); - setReach(nextReach); + const nextReach = parseReachValue(value); + // setValue (not only Controller.onChange) so useWatch subscribers reliably + // re-render — needed to reveal the profile-tags section for Tagged as. + form.setValue(CUSTOM_FEED_FORM_FIELDS.REACH, nextReach, { + shouldValidate: true, + shouldDirty: true, + }); + // Profile tags are authored only via Tagged as. Leaving that surface (or + // any other explicit reach pick) drops legacy domain tags so they cannot + // silently persist on an unsupported reach after save. if (nextReach !== TAGGED_AS_FILTER_KEY) { - setDomainTags([]); + form.setValue(CUSTOM_FEED_FORM_FIELDS.DOMAIN_TAGS, [], { shouldValidate: true }); } }; + const handleDomainTagAdd = (tag: string) => { const normalizedTag = tag.trim().toLowerCase(); if ( - reach !== TAGGED_AS_FILTER_KEY || + !isTaggedAsReach || !normalizedTag || domainTags.length >= HOME_PROFILE_TAGS_MAX_SELECTED || domainTags.some((existingTag) => existingTag.toLowerCase() === normalizedTag) ) { return; } - setDomainTags([...domainTags, normalizedTag]); + form.setValue(CUSTOM_FEED_FORM_FIELDS.DOMAIN_TAGS, [...domainTags, normalizedTag], { + shouldValidate: true, + shouldDirty: true, + }); }; - const isTaggedAsReach = reach === TAGGED_AS_FILTER_KEY; - const isAtProfileTagLimit = domainTags.length >= HOME_PROFILE_TAGS_MAX_SELECTED; - const canSave = - name.trim().length > 0 && (tags.length > 0 || domainTags.length > 0) && (!isTaggedAsReach || domainTags.length > 0); + const handleSaveFeed = async () => { - if (reach === undefined || sort === undefined || layout === undefined || content === undefined) return; - const persistedReach = reach === TAGGED_AS_FILTER_KEY ? PubkyAppFeedReach.Wot : reach; - if (mode === 'create') { - try { - const feed = await commitCreate({ - name, - reach: persistedReach, - sort, - layout, - content: content === 'ALL' ? null : content, - tags, - domain_tags: domainTags, - }); - setOpen(false); - toast({ - title: tDialog('feedCreated', { - name: feed.name, - }), - }); - router.push(`${APP_ROUTES.FEED}/${feed.id}`); - } catch { - toast({ - variant: 'error', - description: tDialog('feedCreateError'), - }); - } - } else if (mode === 'edit') { - if (!customFeed) return; - try { - const feed = await commitUpdate({ - feedId: customFeed.id, - changes: { - name, - reach: persistedReach, - sort, - layout, - content: content === 'ALL' ? null : content, - tags, - domain_tags: domainTags, - }, - }); - setOpen(false); - toast({ - title: tDialog('feedEdited', { - name: feed.name, - }), - }); - router.push(`${APP_ROUTES.FEED}/${feed.id}`); - } catch { - toast({ - variant: 'error', - description: tDialog('feedEditError'), - }); - } - } + const saved = await submit(); + + if (saved) setOpen(false); }; const handleDeleteFeed = async () => { - if (!customFeed) return; - try { - await commitDelete({ - feedId: customFeed.id, - }); - setOpen(false); - toast({ - title: tDialog('feedDeleted', { - name: customFeed.name, - }), - }); - router.push(APP_ROUTES.HOME); - } catch { - toast({ - variant: 'error', - description: tDialog('feedDeleteError'), - }); - } + const deleted = await deleteFeed(); + + if (deleted) setOpen(false); }; + return ( - + {children} { - if (mode === 'edit') e.preventDefault(); + // Edit mode skips the name input's auto-focus, but focus must still + // enter the modal — leaving it on the now-obscured trigger strands + // keyboard and screen-reader users outside the dialog. + if (mode === 'edit') { + e.preventDefault(); + (e.currentTarget as HTMLElement | null)?.focus(); + } }} onCloseAutoFocus={(e) => e.preventDefault()} className="w-3xl" @@ -353,14 +297,52 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { - setName(e.target.value)} - disabled={disabled} - className="h-14 border-dashed" - data-testid="feed-name-input" + ( + + )} + /> + + + + + + ( + + + + )} /> @@ -368,94 +350,117 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { - + ( + + )} + /> - + ( + + )} + /> - + ( + + )} + /> - + ( + + )} + /> @@ -466,34 +471,42 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { {tDialog('postTagsDescription')} - setTags([...tags, tag])} - existingTags={tags.map((tag) => ({ - label: tag, - }))} - showCloseButton={false} - disabled={disabled} - maxTags={getMaxStreamTags()} - currentTagsCount={tags.length} - enableApiSuggestions - excludeFromApiSuggestions={tags} - addOnSuggestionClick - className="w-48" - data-testid="feed-tag-input" - /> - - {tags.length > 0 && ( - - {tags.map((tag, index) => ( - setTags((prevTags) => prevTags.filter((_, i) => i !== index))} + ( + <> + field.onChange([...field.value, tag])} + existingTags={field.value.map((tag) => ({ + label: tag, + }))} + showCloseButton={false} + disabled={loading} + maxTags={getMaxStreamTags()} + currentTagsCount={field.value.length} + enableApiSuggestions + excludeFromApiSuggestions={field.value} + addOnSuggestionClick + className="w-48" + data-testid="feed-tag-input" /> - ))} - - )} + + {field.value.length > 0 && ( + + {field.value.map((tag, index) => ( + field.onChange(field.value.filter((_, i) => i !== index))} + /> + ))} + + )} + + )} + /> {(isTaggedAsReach || domainTags.length > 0) && ( @@ -510,7 +523,7 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { placeholder={tFilter('reach.profileTag')} existingTags={domainTags.map((label) => ({ label }))} viewerTags={domainTags.map((label) => ({ label }))} - disabled={disabled} + disabled={loading} maxTags={HOME_PROFILE_TAGS_MAX_SELECTED} currentTagsCount={domainTags.length} limitReachedPlaceholder={tFilter('reach.profileTagLimitReached', { @@ -531,8 +544,14 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { setDomainTags((currentTags) => currentTags.filter((_, i) => i !== index))} + showClose={isTaggedAsReach && !loading} + onClose={() => + form.setValue( + CUSTOM_FEED_FORM_FIELDS.DOMAIN_TAGS, + domainTags.filter((_, i) => i !== index), + { shouldValidate: true, shouldDirty: true }, + ) + } /> ))} @@ -545,11 +564,11 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { variant="secondary" size="lg" onClick={handleSaveFeed} - disabled={disabled || !canSave} + disabled={loading || !form.formState.isValid} className="h-15 w-full" data-testid="save-feed-button" > - + {tDialog('saveFeed')} @@ -558,7 +577,7 @@ export const CustomFeedDialog = ({ mode, children }: CustomFeedDialogProps) => { variant="destructive" size="lg" onClick={handleDeleteFeed} - disabled={disabled} + disabled={loading} className="h-15 w-full" data-testid="delete-feed-button" > diff --git a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx index 2b0a5c5bf..a959932f9 100644 --- a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx +++ b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx @@ -1,7 +1,9 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { PubkyAppFeedLayout, PubkyAppFeedReach, PubkyAppFeedSort } from 'pubky-app-specs'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadLucideIconNode } from '@/libs/utils/lucideIcons'; import type { FeedModelSchema } from '@/models/feed/feed.schema'; +import { resetViewport, setMobileViewport } from '@/test-utils/viewport'; import { FeedNavigation } from './FeedNavigation'; // Mock next/navigation @@ -13,6 +15,7 @@ vi.mock('next/navigation', () => ({ // Mock dexie-react-hooks — allow controlling useLiveQuery return value per test let mockCustomFeeds: FeedModelSchema[]; let mockIsAuthenticated = true; +let mockViewport: 'small' | 'medium' | 'large' = 'large'; const mockRequireAuth = vi.fn((action: () => unknown) => action()); vi.mock('dexie-react-hooks', () => ({ useLiveQuery: vi.fn( @@ -42,21 +45,29 @@ vi.mock('@/atoms/Button/Button', () => { className, overrideDefaults, onClick, + type, + 'aria-label': ariaLabel, + 'data-testid': dataTestId, }: { children: React.ReactNode; variant?: string; size?: string; className?: string; overrideDefaults?: boolean; - onClick?: () => void; + onClick?: React.MouseEventHandler; + type?: 'button' | 'submit' | 'reset'; + 'aria-label'?: string; + 'data-testid'?: string; }) => ( @@ -66,8 +77,18 @@ vi.mock('@/atoms/Button/Button', () => { vi.mock('@/atoms/Container/Container', () => { return { - Container: ({ children, className }: { children: React.ReactNode; className?: string }) => ( -
+ Container: ({ + children, + className, + overrideDefaults: _overrideDefaults, + 'data-testid': dataTestId, + }: { + children: React.ReactNode; + className?: string; + overrideDefaults?: boolean; + 'data-testid'?: string; + }) => ( +
{children}
), @@ -101,13 +122,24 @@ vi.mock('@/atoms/Link/Link', () => { href, className, overrideDefaults, + onClick, + 'aria-current': ariaCurrent, }: { children: React.ReactNode; href?: string; className?: string; overrideDefaults?: boolean; + onClick?: React.MouseEventHandler; + 'aria-current'?: React.AriaAttributes['aria-current']; }) => ( - + {children} ), @@ -135,8 +167,18 @@ vi.mock('@/atoms/Typography/Typography', () => { // Mock @/organisms — CustomFeedDialog is a complex component; mock it as a transparent wrapper vi.mock('@/organisms/CustomFeedDialog/CustomFeedDialog', () => { return { - CustomFeedDialog: ({ children, mode }: { children: React.ReactNode; mode: string }) => ( -
{children}
+ CustomFeedDialog: ({ + children, + mode, + feed, + }: { + children: React.ReactNode; + mode: string; + feed?: FeedModelSchema; + }) => ( +
+ {children} +
), }; }); @@ -156,6 +198,18 @@ vi.mock('@/hooks/useRequireAuth/useRequireAuth', () => ({ }), })); +vi.mock('@/hooks/useIsMobile/useIsMobile', () => ({ + useIsMobile: ({ breakpoint }: { breakpoint?: string } = {}) => { + if (breakpoint === 'lg') return mockViewport === 'small'; + if (breakpoint === 'xl') return mockViewport !== 'large'; + return mockViewport !== 'large'; + }, +})); + +vi.mock('@/hooks/useIsTouchDevice/useIsTouchDevice', () => ({ + useIsTouchDevice: () => false, +})); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -163,6 +217,7 @@ vi.mock('@/hooks/useRequireAuth/useRequireAuth', () => ({ const createMockFeed = (overrides: Partial = {}): FeedModelSchema => ({ id: 'feed-abc123', name: 'Bitcoin News', + icon: 'activity', tags: ['bitcoin', 'lightning'], domain_tags: [], reach: PubkyAppFeedReach.All, @@ -183,6 +238,7 @@ describe('FeedNavigation', () => { vi.clearAllMocks(); mockCustomFeeds = []; mockIsAuthenticated = true; + mockViewport = 'large'; mockRequireAuth.mockImplementation((action: () => unknown) => action()); mockUsePathname.mockReturnValue('/home'); mockGetList.mockResolvedValue([]); @@ -285,7 +341,7 @@ describe('FeedNavigation', () => { const links = screen.getAllByTestId('link'); const activeLink = links.find((link) => link.getAttribute('href') === '/feed/feed-active'); - expect(activeLink).toHaveClass('border-white'); + expect(activeLink?.parentElement).toHaveClass('border-white'); expect(activeLink).toHaveClass('text-white'); }); @@ -297,13 +353,13 @@ describe('FeedNavigation', () => { const links = screen.getAllByTestId('link'); const inactiveLink = links.find((link) => link.getAttribute('href') === '/feed/feed-inactive'); - expect(inactiveLink).toHaveClass('border-border'); + expect(inactiveLink?.parentElement).toHaveClass('border-border'); expect(inactiveLink).toHaveClass('text-muted-foreground'); }); - // ── Edit dialog for active custom feed ────────────────────────────────── + // ── Edit dialog for custom feeds ──────────────────────────────────────── - it('wraps custom feed icon in edit dialog when feed is active', () => { + it('renders a separate edit action for an active custom feed', () => { mockCustomFeeds = [createMockFeed({ id: 'feed-edit', name: 'Editable Feed' })]; mockUsePathname.mockReturnValue('/feed/feed-edit'); @@ -311,20 +367,54 @@ describe('FeedNavigation', () => { const editDialog = screen.getByTestId('custom-feed-dialog-edit'); expect(editDialog).toBeInTheDocument(); + expect(editDialog).toHaveAttribute('data-feed-id', 'feed-edit'); - // The edit dialog should contain a button with the pencil icon - const editButton = editDialog.querySelector('[data-testid="button"]'); + const editButton = screen.getByTestId('edit-feed-feed-edit'); expect(editButton).toBeInTheDocument(); + expect(editButton).toHaveAttribute('aria-label', 'Edit Editable Feed'); + expect(editButton).toHaveClass('shrink-0'); + expect(editButton).not.toHaveClass('absolute'); }); - it('does not show edit dialog for inactive custom feed', () => { + it('renders an edit action for an inactive custom feed without nesting it in the link', () => { mockCustomFeeds = [createMockFeed({ id: 'feed-noedit', name: 'No Edit Feed' })]; mockUsePathname.mockReturnValue('/home'); render(); - // There should be a create dialog but no edit dialog - expect(screen.queryByTestId('custom-feed-dialog-edit')).not.toBeInTheDocument(); + const editDialog = screen.getByTestId('custom-feed-dialog-edit'); + const editButton = screen.getByTestId('edit-feed-feed-noedit'); + const feedLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/feed/feed-noedit'); + + expect(editDialog).toHaveAttribute('data-feed-id', 'feed-noedit'); + expect(editButton.closest('a')).toBeNull(); + expect(editDialog.parentElement).toBe(feedLink?.parentElement); + }); + + it('shows the edit action on mobile and reveals it on desktop hover or focus', () => { + mockCustomFeeds = [createMockFeed({ id: 'feed-edit', name: 'Editable Feed' })]; + + render(); + + const editButton = screen.getByTestId('edit-feed-feed-edit'); + const feedLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/feed/feed-edit'); + const feedTab = feedLink?.parentElement; + expect(editButton).toHaveClass('opacity-100'); + expect(editButton).toHaveClass('lg:opacity-0'); + expect(editButton).toHaveClass('lg:group-hover:opacity-100'); + expect(editButton).toHaveClass('lg:group-focus-within:opacity-100'); + expect(editButton).toHaveClass('rounded-none'); + expect(editButton).toHaveClass('bg-transparent'); + expect(editButton).toHaveClass('hover:bg-transparent'); + expect(editButton).toHaveClass('transition-opacity'); + expect(editButton).toHaveClass('duration-200'); + expect(editButton.parentElement?.parentElement).toHaveClass('gap-x-2'); + expect(editButton).not.toHaveClass('mr-2'); + expect(editButton.querySelector('svg')).toHaveClass('size-2.5'); + expect(feedLink).toHaveClass('flex-1'); + expect(feedLink).toHaveClass('lg:flex-none'); + expect(feedTab).not.toHaveClass('justify-center'); + expect(feedTab).toHaveClass('lg:justify-center'); }); it('does not show edit dialog for Home feed even when active', () => { @@ -335,6 +425,29 @@ describe('FeedNavigation', () => { expect(screen.queryByTestId('custom-feed-dialog-edit')).not.toBeInTheDocument(); }); + it('renders a fallback icon for a legacy custom feed without an icon', () => { + mockCustomFeeds = [createMockFeed({ id: 'legacy-feed', icon: undefined })]; + + render(); + + const legacyLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/feed/legacy-feed'); + expect(legacyLink?.querySelector('svg')).toHaveClass('lucide-activity'); + }); + + it('never shows the default icon while a feed icon is still loading', () => { + // 'wine' is never loaded by other tests in this file, so this render hits + // the loading path: an empty svg, not the Activity fallback. + mockCustomFeeds = [createMockFeed({ id: 'wine-feed', icon: 'wine' })]; + + render(); + + const wineLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/feed/wine-feed'); + const icon = wineLink?.querySelector('svg'); + expect(icon).toBeTruthy(); + expect(icon).not.toHaveClass('lucide-activity'); + expect(icon?.childElementCount).toBe(0); + }); + // ── Create Feed button ────────────────────────────────────────────────── it('renders Create Feed button inside a create dialog', () => { @@ -345,6 +458,98 @@ describe('FeedNavigation', () => { expect(createDialog).toHaveTextContent('Create Feed'); }); + it('shows Create Feed label on mobile and keeps it screen-reader only on desktop', () => { + render(); + + const createLabel = screen.getByText('Create Feed'); + expect(createLabel).toHaveClass('font-medium'); + expect(createLabel).toHaveClass('lg:sr-only'); + }); + + it('keeps Create Feed button outside the feed tabs', () => { + render(); + + const createDialog = screen.getByTestId('custom-feed-dialog-create'); + const tabs = screen.getByTestId('feed-navigation-tabs'); + const createButton = createDialog.querySelector('button'); + + expect(createButton).toHaveClass('shrink-0'); + expect(tabs.contains(createDialog)).toBe(false); + }); + + it('shows at most five feeds on large screens and puts the rest in a popover', () => { + mockCustomFeeds = Array.from({ length: 6 }, (_, index) => + createMockFeed({ id: `feed-${index + 1}`, name: `Feed ${index + 1}` }), + ); + + render(); + + expect(screen.getAllByTestId('custom-feed-tab')).toHaveLength(4); + expect(screen.getByText('Feed 4')).toBeInTheDocument(); + expect(screen.queryByText('Feed 5')).not.toBeInTheDocument(); + const overflowTrigger = screen.getByTestId('feed-navigation-overflow-trigger'); + expect(overflowTrigger).toHaveAttribute('aria-label', 'More feeds'); + expect(overflowTrigger.querySelector('svg')).toHaveClass('lucide-chevrons-right'); + + fireEvent.click(overflowTrigger); + + const overflowItems = screen.getAllByTestId('overflow-feed-item'); + expect(screen.getByTestId('popover-content')).toHaveClass('w-42'); + expect(screen.getByTestId('feed-navigation-overflow-list')).toHaveClass('gap-2.5'); + expect(overflowItems).toHaveLength(2); + overflowItems.forEach((item) => { + expect(item).not.toHaveClass('border-b'); + expect(item).not.toHaveClass('hover:bg-accent'); + }); + overflowItems.forEach((item) => { + expect(item.querySelector('a')).toHaveClass('flex-1'); + expect(item.querySelector('a')).not.toHaveClass('min-h-12'); + }); + expect(screen.getByTestId('edit-feed-feed-5')).toBeInTheDocument(); + expect(screen.getByTestId('edit-feed-feed-6')).toBeInTheDocument(); + expect(overflowItems[0].querySelector('a svg')).toHaveClass('size-4'); + expect(screen.getByTestId('edit-feed-feed-5').querySelector('svg')).toHaveClass('size-2.5'); + expect(screen.getByTestId('edit-feed-feed-5').closest('a')).toBeNull(); + expect(screen.getByText('Feed 5')).toBeInTheDocument(); + expect(screen.getByText('Feed 6')).toBeInTheDocument(); + }); + + it('limits desktop rows but renders every feed in the mobile drawer', () => { + mockCustomFeeds = Array.from({ length: 5 }, (_, index) => + createMockFeed({ id: `feed-${index + 1}`, name: `Feed ${index + 1}` }), + ); + mockViewport = 'medium'; + + const { rerender } = render(); + + expect(screen.getAllByTestId('custom-feed-tab')).toHaveLength(3); + + mockViewport = 'small'; + rerender(); + + expect(screen.getAllByTestId('custom-feed-tab')).toHaveLength(5); + expect(screen.queryByTestId('feed-navigation-overflow-trigger')).not.toBeInTheDocument(); + }); + + it('keeps a selected overflow feed visible with its edit action', () => { + mockCustomFeeds = Array.from({ length: 5 }, (_, index) => + createMockFeed({ id: `feed-${index + 1}`, name: `Feed ${index + 1}` }), + ); + mockUsePathname.mockReturnValue('/feed/feed-5'); + + render(); + + const activeLink = screen.getAllByTestId('link').find((link) => link.getAttribute('href') === '/feed/feed-5'); + expect(activeLink).toHaveAttribute('aria-current', 'page'); + expect(activeLink?.parentElement).toHaveClass('border-white'); + expect(screen.getByTestId('edit-feed-feed-5')).toBeInTheDocument(); + expect(screen.queryByText('Feed 4')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('feed-navigation-overflow-trigger')); + + expect(screen.getByText('Feed 4')).toBeInTheDocument(); + }); + it('renders Create Feed button with PlusCircle icon', () => { render(); @@ -391,21 +596,27 @@ describe('FeedNavigation', () => { // ── Container and layout ──────────────────────────────────────────────── - it('renders container with flex-row and overflow-x-auto classes', () => { + it('renders a clipped tab region without scrolling or a scroll fade', () => { render(); const container = screen.getByTestId('container'); + const tabs = screen.getByTestId('feed-navigation-tabs'); expect(container).toHaveClass('lg:flex-row'); - expect(container).toHaveClass('overflow-x-auto'); + expect(container).toHaveClass('overflow-hidden'); + expect(tabs).toHaveClass('overflow-hidden'); + expect(tabs).not.toHaveClass('overflow-x-auto'); + expect(tabs).not.toHaveClass('scroll-fade-s'); + expect(tabs).toHaveClass('min-w-0'); + expect(tabs).toHaveClass('flex-1'); }); - it('renders all links with min-w-40 and h-12 classes', () => { + it('renders all links with min-w-0 and h-12 classes', () => { mockCustomFeeds = [createMockFeed({ id: 'feed-1', name: 'Test Feed' })]; render(); const links = screen.getAllByTestId('link'); links.forEach((link) => { - expect(link).toHaveClass('min-w-40'); + expect(link).toHaveClass('min-w-0'); expect(link).toHaveClass('min-h-12'); }); }); @@ -416,10 +627,17 @@ describe('FeedNavigation', () => { // --------------------------------------------------------------------------- describe('FeedNavigation - Snapshots', () => { + // Warm the mock feeds' icon so DynamicLucideIcon renders it synchronously + // and snapshots capture the resolved svg regardless of test order. + beforeAll(async () => { + await loadLucideIconNode('activity'); + }); + beforeEach(() => { vi.clearAllMocks(); mockCustomFeeds = []; mockIsAuthenticated = true; + mockViewport = 'large'; mockRequireAuth.mockImplementation((action: () => unknown) => action()); mockUsePathname.mockReturnValue('/home'); mockGetList.mockResolvedValue([]); @@ -430,6 +648,15 @@ describe('FeedNavigation - Snapshots', () => { expect(container.firstChild).toMatchSnapshot(); }); + it('matches snapshot with the large-screen feed limit', () => { + mockCustomFeeds = Array.from({ length: 4 }, (_, index) => + createMockFeed({ id: `feed-${index + 1}`, name: `Feed ${index + 1}` }), + ); + + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + it('matches snapshot with custom feeds and Home active', () => { mockCustomFeeds = [ createMockFeed({ id: 'feed-1', name: 'Bitcoin News' }), @@ -460,3 +687,31 @@ describe('FeedNavigation - Snapshots', () => { expect(container.firstChild).toMatchSnapshot(); }); }); + +describe('FeedNavigation - Mobile Snapshots', () => { + beforeAll(async () => { + await loadLucideIconNode('activity'); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockCustomFeeds = Array.from({ length: 4 }, (_, index) => + createMockFeed({ id: `feed-${index + 1}`, name: `Feed ${index + 1}` }), + ); + mockIsAuthenticated = true; + mockViewport = 'small'; + mockRequireAuth.mockImplementation((action: () => unknown) => action()); + mockUsePathname.mockReturnValue('/home'); + mockGetList.mockResolvedValue([]); + setMobileViewport(); + }); + + afterEach(() => { + resetViewport(); + }); + + it('matches snapshot with all feeds in the mobile drawer', () => { + const { container } = render(); + expect(container.firstChild).toMatchSnapshot(); + }); +}); diff --git a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap index f4a693a17..c0dc3a866 100644 --- a/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap +++ b/src/components/organisms/FeedNavigation/FeedNavigation.test.tsx.snap @@ -1,8 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`FeedNavigation - Snapshots > matches snapshot with custom feed active (showing edit dialog) 1`] = ` +exports[`FeedNavigation - Mobile Snapshots > matches snapshot with all feeds in the mobile drawer 1`] = `
matches snapshot with custom feed active ( > Feed
- - - - Home - - - + + + Home + +
- +
+
+
+ + - + + Feed 2 + + +
+ +
- + + + + Feed 3 + + +
+ +
+
+
- Active Feed - - + + + + Feed 4 + + +
+ +
+
+
`; -exports[`FeedNavigation - Snapshots > matches snapshot with custom feeds and Home active 1`] = ` +exports[`FeedNavigation - Snapshots > matches snapshot with custom feed active (showing edit dialog) 1`] = `
matches snapshot with custom feeds and Hom > Feed
- - - - Home - - - - - - Bitcoin News - - - - - +
- Lightning Network - - + + + + Active Feed + + +
+ +
+
+
`; -exports[`FeedNavigation - Snapshots > matches snapshot with multiple custom feeds and one active 1`] = ` +exports[`FeedNavigation - Snapshots > matches snapshot with custom feeds and Home active 1`] = `
matches snapshot with multiple custom feed > Feed
- - - - Home - - - - - - Bitcoin - - - + + + Home + +
- +
+
+ - - Lightning - - - - - - Nostr - - +
`; -exports[`FeedNavigation - Snapshots > matches snapshot with no custom feeds and Home active 1`] = ` +exports[`FeedNavigation - Snapshots > matches snapshot with multiple custom feeds and one active 1`] = `
matches snapshot with no custom feeds and > Feed
- - - - Home - -
- +
+
+
+ + + + Lightning + + +
+ +
+
+
+ + + + Nostr + + +
+ +
+
+ +
+ +
+ +`; + +exports[`FeedNavigation - Snapshots > matches snapshot with no custom feeds and Home active 1`] = ` +
+
+ Feed +
+ +
+ +
+
+`; + +exports[`FeedNavigation - Snapshots > matches snapshot with the large-screen feed limit 1`] = ` +
+
+ Feed +
+
+ + + + Home + + +
+ + + + Feed 1 + + +
+ +
+
+
+ + + + Feed 2 + + +
+ +
+
+
+ + + + Feed 3 + + +
+ +
+
+
+ + + + Feed 4 + + +
+ +
+
+
+
+ + + + ); +}; + export const FeedNavigation = ({ className }: FeedNavigationProps) => { const pathname = usePathname(); const tHeader = useTranslations('header'); const tDialog = useTranslations('dialogs.customFeed'); const { isAuthenticated, requireAuth } = useRequireAuth(); + const isBelowDesktop = useIsMobile({ breakpoint: 'lg' }); + const isMediumScreen = useIsMobile({ breakpoint: 'xl' }); + const [isOverflowOpen, setIsOverflowOpen] = useState(false); const customFeeds = useLiveQuery( async () => { try { @@ -49,79 +138,125 @@ export const FeedNavigation = ({ className }: FeedNavigationProps) => { [isAuthenticated], isAuthenticated ? cachedFeeds : [], ); - const customFeedsMapped = customFeeds.map((f) => ({ - name: f.name, - icon: , - href: APP_ROUTES.FEED + '/' + f.id, - })); - const feeds = [ - { - name: tHeader('home'), - icon: , - href: APP_ROUTES.HOME, - }, - ...customFeedsMapped, - ]; + // Warm every feed icon as soon as feed data is known, so tabs, the overflow + // popover, and the edit dialogs render icons synchronously from cache + // instead of flashing the loading placeholder. + useEffect(() => { + preloadLucideIcons(customFeeds.map((feed) => feed.icon)); + }, [customFeeds]); + + const visibleCustomFeedLimit = isBelowDesktop + ? customFeeds.length + : (isMediumScreen ? MEDIUM_SCREEN_FEED_LIMIT : LARGE_SCREEN_FEED_LIMIT) - 1; + const initiallyVisibleCustomFeeds = customFeeds.slice(0, visibleCustomFeedLimit); + const activeOverflowFeed = customFeeds + .slice(visibleCustomFeedLimit) + .find((feed) => pathname === `${APP_ROUTES.FEED}/${feed.id}`); + const visibleCustomFeeds = activeOverflowFeed + ? [...initiallyVisibleCustomFeeds.slice(0, -1), activeOverflowFeed] + : initiallyVisibleCustomFeeds; + const visibleCustomFeedIds = new Set(visibleCustomFeeds.map((feed) => feed.id)); + const overflowFeeds = customFeeds.filter((feed) => !visibleCustomFeedIds.has(feed.id)); + const actionButtonClassName = + 'flex min-h-12 w-full shrink-0 cursor-pointer items-center gap-x-2 border-b border-border text-muted-foreground transition-colors hover:text-white lg:w-9 lg:justify-center'; + return ( - + {tHeader('feed')} - {feeds.map((f) => ( + handleFeedNavClick(event, { - isActive: pathname === f.href, - smoothScrollWhenActive: f.href === APP_ROUTES.HOME, + isActive: pathname === APP_ROUTES.HOME, + smoothScrollWhenActive: true, }) } className={cn( - 'flex min-h-12 w-full min-w-40 items-center gap-x-2 border-b transition-colors hover:text-white lg:justify-center', - pathname === f.href ? 'border-white text-white' : 'border-border text-muted-foreground', + 'flex min-h-12 w-full min-w-0 items-center gap-x-2 border-b transition-colors hover:text-white lg:flex-1 lg:justify-center', + pathname === APP_ROUTES.HOME ? 'border-white text-white' : 'border-border text-muted-foreground', )} > - {f.href !== APP_ROUTES.HOME && f.href === pathname ? ( - - - - ) : ( - f.icon - )} + - {f.name} + {tHeader('home')} - ))} + + {visibleCustomFeeds.map((feed) => ( + + ))} + + + {overflowFeeds.length > 0 && ( + + + + + + + + {overflowFeeds.map((feed) => ( + setIsOverflowOpen(false)} + /> + ))} + + + + )} {isAuthenticated ? ( - ) : ( - diff --git a/src/components/organisms/IconPickerDialog/IconPickerDialog.test.tsx b/src/components/organisms/IconPickerDialog/IconPickerDialog.test.tsx new file mode 100644 index 000000000..13a6f70fb --- /dev/null +++ b/src/components/organisms/IconPickerDialog/IconPickerDialog.test.tsx @@ -0,0 +1,227 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LUCIDE_ICON_NAMES } from '@/libs/utils/lucideIcons'; +import { resetViewport, setMobileViewport } from '@/test-utils/viewport'; +import { IconPickerDialog } from './IconPickerDialog'; + +const TEST_ICONS = ['activity', 'airplay', 'mountain'] as const; +const VIRTUAL_GRID_SNAPSHOT_ICONS = LUCIDE_ICON_NAMES.slice(0, 8); + +async function finishOpeningAnimation() { + fireEvent.animationEnd(screen.getByTestId('icon-picker-dialog-content')); + await waitFor(() => { + if (screen.queryByTestId('icon-picker-loading')) { + throw new Error('Icon grid is still waiting for the opening animation'); + } + }); +} + +// DynamicLucideIcon renders an empty svg while its chunk loads, so gate on the +// svg having children (via childElementCount — jsdom's querySelector misses +// svg descendants). Snapshots must wait for every icon they capture — the +// module-level cache makes a partial wait nondeterministic across test order. +async function waitForResolvedIcons(names: readonly string[]) { + await waitFor(() => { + for (const iconName of names) { + const button = screen.getByRole('button', { name: iconName.replaceAll('-', ' ') }); + if (!(button.querySelector('svg')?.childElementCount ?? 0)) { + throw new Error(`Icon ${iconName} is still loading`); + } + } + }); +} + +describe('IconPickerDialog', () => { + it('renders a searchable icon grid with visible SVGs when open', async () => { + render( {}} icons={TEST_ICONS} />); + + expect(screen.getByRole('searchbox', { name: 'Search for icon' })).toBeInTheDocument(); + expect(screen.getByTestId('icon-picker-scroll-area')).toHaveAttribute('aria-busy', 'true'); + expect(screen.getByTestId('icon-picker-loading')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'activity' })).not.toBeInTheDocument(); + + await finishOpeningAnimation(); + + expect(screen.getByRole('button', { name: 'activity' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'airplay' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'mountain' })).toBeInTheDocument(); + expect(screen.getByTestId('icon-picker-scroll-area')).not.toHaveAttribute('aria-busy'); + expect(screen.getByTestId('icon-picker-dialog-content')).toHaveClass('flex-col', 'gap-6'); + expect(screen.getByTestId('icon-picker-dialog-content')).toHaveClass('h-110'); + expect(screen.getByRole('searchbox', { name: 'Search for icon' })).toHaveClass('border-dashed'); + expect(screen.getByRole('searchbox', { name: 'Search for icon' })).toHaveClass('rounded-md'); + expect(screen.getByRole('searchbox', { name: 'Search for icon' })).not.toHaveClass('mt-6'); + expect(screen.getByTestId('icon-picker-scroll-area')).not.toHaveClass('mt-6'); + + await waitForResolvedIcons(TEST_ICONS); + }); + + it('shows a themed clear button only while a query is present', async () => { + render( {}} icons={TEST_ICONS} />); + await finishOpeningAnimation(); + + expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); + + const searchbox = screen.getByRole('searchbox', { name: 'Search for icon' }); + fireEvent.change(searchbox, { target: { value: 'mount' } }); + + const clearButton = screen.getByRole('button', { name: 'Clear search' }); + expect(clearButton).toHaveClass('cursor-pointer'); + expect(clearButton).toHaveClass('text-muted-foreground'); + + fireEvent.click(clearButton); + + expect(searchbox).toHaveValue(''); + expect(searchbox).toHaveFocus(); + expect(screen.queryByRole('button', { name: 'Clear search' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'activity' })).toBeInTheDocument(); + }); + + it('filters icons by their kebab-case names', async () => { + render( {}} icons={TEST_ICONS} />); + await finishOpeningAnimation(); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search for icon' }), { + target: { value: 'mount' }, + }); + + expect(screen.getByRole('button', { name: 'mountain' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'activity' })).not.toBeInTheDocument(); + }); + + it('normalizes spaces in search queries', async () => { + render( {}} icons={['circle-alert', 'activity']} />); + await finishOpeningAnimation(); + + fireEvent.change(screen.getByRole('searchbox', { name: 'Search for icon' }), { + target: { value: 'circle alert' }, + }); + + expect(screen.getByRole('button', { name: 'circle alert' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'activity' })).not.toBeInTheDocument(); + }); + + it('returns the selected icon and closes the dialog', async () => { + const onSelect = vi.fn(); + const onOpenChange = vi.fn(); + render( + , + ); + await finishOpeningAnimation(); + + expect(screen.getByRole('button', { name: 'activity' })).toHaveAttribute('aria-pressed', 'true'); + fireEvent.click(screen.getByRole('button', { name: 'mountain' })); + + expect(onSelect).toHaveBeenCalledWith('mountain'); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('filters invalid names supplied by a consumer', () => { + render( {}} icons={['not-a-real-icon']} />); + + expect(screen.getByText('No icons found')).toBeInTheDocument(); + }); + + it('only mounts the virtualized rows around the scroll viewport', async () => { + const icons = LUCIDE_ICON_NAMES.slice(0, 110); + const initiallyHiddenIcon = icons[100]; + const initiallyVisibleIcon = icons[0]; + render( {}} icons={icons} />); + await finishOpeningAnimation(); + + expect(screen.queryByTestId(`icon-picker-option-${initiallyHiddenIcon}`)).not.toBeInTheDocument(); + expect(screen.getByTestId(`icon-picker-option-${initiallyVisibleIcon}`)).toBeInTheDocument(); + + const scrollArea = screen.getByTestId('icon-picker-scroll-area'); + Object.defineProperties(scrollArea, { + clientHeight: { configurable: true, value: 208 }, + scrollTop: { configurable: true, value: 400 }, + }); + fireEvent.scroll(scrollArea); + + expect(screen.getByTestId(`icon-picker-option-${initiallyHiddenIcon}`)).toBeInTheDocument(); + expect(screen.queryByTestId(`icon-picker-option-${initiallyVisibleIcon}`)).not.toBeInTheDocument(); + expect(screen.getByTestId('icon-picker-virtual-space')).toHaveStyle({ height: '504px' }); + }); + + it('supports context-specific accessible copy', () => { + render( + {}} + icons={[]} + title="Choose a collection icon" + description="Choose a custom icon for your collection." + searchPlaceholder="Search collection icons" + emptyMessage="No collection icons found" + />, + ); + + expect(screen.getByRole('dialog', { name: 'Choose a collection icon' })).toBeInTheDocument(); + expect(screen.getByText('Choose a custom icon for your collection.')).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: 'Search collection icons' })).toBeInTheDocument(); + expect(screen.getByText('No collection icons found')).toBeInTheDocument(); + }); + + it('can manage its open state through a trigger', () => { + render( + {}} icons={TEST_ICONS}> + + , + ); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Choose icon' })); + expect(screen.getByRole('dialog', { name: 'Choose icon' })).toBeInTheDocument(); + }); + + it('stops selection clicks from reaching a clickable ancestor', async () => { + const ancestorClick = vi.fn(); + render( +
+ {}} icons={TEST_ICONS} /> +
, + ); + await finishOpeningAnimation(); + + fireEvent.click(screen.getByRole('button', { name: 'activity' })); + + expect(ancestorClick).not.toHaveBeenCalled(); + }); +}); + +describe('IconPickerDialog - Snapshots', () => { + it('matches snapshot for the empty state', () => { + const { baseElement } = render( {}} icons={[]} />); + + expect(baseElement).toMatchSnapshot(); + }); + + it('matches snapshot for a virtualized desktop grid', async () => { + const { baseElement } = render( {}} icons={VIRTUAL_GRID_SNAPSHOT_ICONS} />); + await finishOpeningAnimation(); + + await waitForResolvedIcons(VIRTUAL_GRID_SNAPSHOT_ICONS); + + expect(baseElement).toMatchSnapshot(); + }); +}); + +describe('IconPickerDialog - Mobile Snapshots', () => { + beforeEach(() => { + setMobileViewport(); + }); + + afterEach(() => { + resetViewport(); + }); + + it('matches snapshot for a virtualized mobile grid', async () => { + const { baseElement } = render( {}} icons={VIRTUAL_GRID_SNAPSHOT_ICONS} />); + await finishOpeningAnimation(); + + await waitForResolvedIcons(VIRTUAL_GRID_SNAPSHOT_ICONS); + + expect(baseElement).toMatchSnapshot(); + }); +}); diff --git a/src/components/organisms/IconPickerDialog/IconPickerDialog.test.tsx.snap b/src/components/organisms/IconPickerDialog/IconPickerDialog.test.tsx.snap new file mode 100644 index 000000000..bbd8e02b0 --- /dev/null +++ b/src/components/organisms/IconPickerDialog/IconPickerDialog.test.tsx.snap @@ -0,0 +1,1001 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`IconPickerDialog - Mobile Snapshots > matches snapshot for a virtualized mobile grid 1`] = ` + +