From 091eec8b0e693dcb6f297a69b0b9f16519157afe Mon Sep 17 00:00:00 2001 From: aldertnl <81586998+aldertnl@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:55:55 +0100 Subject: [PATCH 1/3] feat(ui): add Reach and WoT feed controls --- cypress/e2e/feed.cy.ts | 3 +- cypress/e2e/posts.cy.ts | 1 + cypress/support/collections.ts | 3 +- messages/ar.json | 24 +- messages/de.json | 24 +- messages/en.json | 24 +- messages/es.json | 24 +- messages/fr.json | 24 +- messages/it.json | 24 +- messages/ja.json | 24 +- messages/pt-BR.json | 24 +- messages/zh.json | 24 +- .../FilterContent/FilterContent.test.tsx | 128 +- .../FilterContent/FilterContent.test.tsx.snap | 4150 +++-------------- .../Filters/FilterContent/FilterContent.tsx | 118 +- .../Filters/FilterDropdown/FilterDropdown.tsx | 89 + .../FilterLayout/FilterLayout.test.tsx | 106 +- .../FilterLayout/FilterLayout.test.tsx.snap | 1055 ++--- .../Filters/FilterLayout/FilterLayout.tsx | 74 +- .../FilterProfileTags.test.tsx | 140 + .../FilterProfileTags/FilterProfileTags.tsx | 117 + .../FilterProfileTags.types.ts | 13 + .../Filters/FilterReach/FilterReach.test.tsx | 193 +- .../FilterReach/FilterReach.test.tsx.snap | 1453 +++--- .../Filters/FilterReach/FilterReach.tsx | 212 +- .../Filters/FilterSort/FilterSort.test.tsx | 122 +- .../FilterSort/FilterSort.test.tsx.snap | 621 ++- .../Filters/FilterSort/FilterSort.tsx | 38 +- .../molecules/TagInput/TagInput.tsx | 1 - .../FeedNavigation/FeedNavigation.test.tsx | 49 +- .../FeedNavigation.test.tsx.snap | 80 +- .../FeedNavigation/FeedNavigation.tsx | 33 +- .../HomeFeedSidebar/HomeFeedSidebar.test.tsx | 141 +- .../HomeFeedSidebar.test.tsx.snap | 57 +- .../HomeFeedSidebar/HomeFeedSidebar.tsx | 47 +- .../HotFeedFilters/HotFeedFilters.tsx | 8 +- .../organisms/PostHeader/PostHeader.test.tsx | 18 + .../organisms/PostHeader/PostHeader.tsx | 14 + .../organisms/PostHeader/PostHeader.types.ts | 1 + .../organisms/PostInput/PostInput.test.tsx | 24 +- .../PostInput/PostInput.test.tsx.snap | 37 +- .../organisms/PostInput/PostInput.tsx | 14 +- .../SinglePostLeftSidebar.test.tsx | 21 +- .../SinglePostLeftSidebar.test.tsx.snap | 18 - .../SinglePostLeftSidebar.tsx | 4 +- .../Feed/TimelineFeed/TimelineFeed.test.tsx | 88 +- .../Feed/TimelineFeed/TimelineFeed.tsx | 29 + .../TimelineFeedContent.tsx | 8 +- .../models/stream/post/postStream.types.ts | 15 + .../nexus/stream/posts/postStream.api.ts | 3 + .../nexus/stream/posts/postStream.test.ts | 54 + .../services/nexus/stream/posts/postStream.ts | 20 +- .../nexus/stream/posts/postStream.types.ts | 6 + .../nexus/stream/posts/postStream.utils.ts | 30 +- src/core/stores/home/home.actions.ts | 70 +- src/core/stores/home/home.store.test.ts | 46 +- src/core/stores/home/home.store.ts | 2 + src/core/stores/home/home.types.ts | 34 +- src/core/stores/home/home.utils.test.ts | 76 +- src/core/stores/home/home.utils.ts | 83 +- .../useHotStreamId/useHotStreamId.test.ts | 24 +- src/hooks/useHotStreamId/useHotStreamId.ts | 15 +- src/hooks/usePostInput/usePostInput.test.ts | 16 + src/hooks/usePostInput/usePostInput.ts | 6 + .../useStreamIdFromFilters.test.tsx | 92 +- .../useStreamIdFromFilters.ts | 21 +- 66 files changed, 4317 insertions(+), 5840 deletions(-) create mode 100644 src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx create mode 100644 src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx create mode 100644 src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.tsx create mode 100644 src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.types.ts diff --git a/cypress/e2e/feed.cy.ts b/cypress/e2e/feed.cy.ts index 1d81efc43f..a943d835e9 100644 --- a/cypress/e2e/feed.cy.ts +++ b/cypress/e2e/feed.cy.ts @@ -297,7 +297,8 @@ describe('feed and filters', () => { cy.signInWithEncryptedFile(backupDownloadFilePath(profile1.username)); cy.get('[data-cy="filter-reach-radiogroup"]').find('[aria-label="Following"]').click(); waitForFeedToLoad(); - cy.get('[data-cy="filter-sort-radiogroup"]').find('[aria-label="Popularity"]').click(); + cy.get('[data-cy="filter-sort-radiogroup"]').click(); + cy.get('[role="option"][aria-label="Popularity"]').click(); waitForFeedToLoad(); // * check the posts are in the correct order diff --git a/cypress/e2e/posts.cy.ts b/cypress/e2e/posts.cy.ts index d0cec9354c..e339aff08d 100644 --- a/cypress/e2e/posts.cy.ts +++ b/cypress/e2e/posts.cy.ts @@ -392,6 +392,7 @@ describe('posts', () => { cy.location('pathname').should('contain', '/post/'); // switch to wide layout + cy.get('[data-cy="filter-layout-dropdown"]').filter(':visible').click(); cy.get('[data-cy="wide-layout-toggle"]').filter(':visible').click(); // add three tags to post diff --git a/cypress/support/collections.ts b/cypress/support/collections.ts index 1e301acc59..ff0bfa6f78 100644 --- a/cypress/support/collections.ts +++ b/cypress/support/collections.ts @@ -182,7 +182,8 @@ export const deleteCollectionFromHero = () => { // apply a content filter (e.g. 'All', 'Collections') from the home feed sidebar export const applyContentFilter = (label: string) => { - cy.get('[data-testid="filter-content-radiogroup"]').find(`[aria-label="${label}"]`).click(); + cy.get('[data-testid="filter-content-dropdown"]').click(); + cy.get(`[role="option"][aria-label="${label}"]`).click(); waitForFeedToLoad(); }; diff --git a/messages/ar.json b/messages/ar.json index 524e0c3410..611a1a5247 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -1158,10 +1158,30 @@ }, "filters": { "reach": { - "title": "النطاق", + "title": "منشورات من", "all": "الكل", + "network": "الشبكة", + "myNetwork": "شبكتي", "following": "المتابَعون", - "friends": "الاصدقاء" + "friends": "الاصدقاء", + "me": "انا", + "profileTag": "وسم الملف الشخصي", + "profileTagLimitReached": "{max} وسوم كحد أقصى", + "taggedBy": "بواسطة {reach}", + "taggedByLabel": "اختر من قام بوسم هذه الملفات الشخصية", + "profileTagSummary": { + "reach": { + "all": "منشورات من أي شخص وسمه {curator} كـ {tags}", + "friends": "منشورات من الأصدقاء الذين وسمهم {curator} كـ {tags}", + "following": "منشورات من الأشخاص الذين أتابعهم ووسمهم {curator} كـ {tags}", + "network": "منشورات من أشخاص في شبكتي وسمهم {curator} كـ {tags}" + }, + "curator": { + "me": "أنا", + "following": "الأشخاص الذين أتابعهم", + "network": "شبكتي" + } + } }, "sort": { "title": "الترتيب", diff --git a/messages/de.json b/messages/de.json index c3c4330309..ea18cb1dbd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1164,10 +1164,30 @@ }, "filters": { "reach": { - "title": "Reichweite", + "title": "Beiträge von", "all": "Alle", + "network": "Netzwerk", + "myNetwork": "Mein Netzwerk", "following": "Folge ich", - "friends": "Freunde" + "friends": "Freunde", + "me": "Ich", + "profileTag": "Profil-Tag", + "profileTagLimitReached": "max. {max} Tags", + "taggedBy": "von {reach}", + "taggedByLabel": "Auswählen, wer diese Profile markiert hat", + "profileTagSummary": { + "reach": { + "all": "Beiträge von allen, die von {curator} als {tags} markiert wurden", + "friends": "Beiträge von Freunden, die von {curator} als {tags} markiert wurden", + "following": "Beiträge von Personen, denen ich folge und die von {curator} als {tags} markiert wurden", + "network": "Beiträge von Personen in meinem Netzwerk, die von {curator} als {tags} markiert wurden" + }, + "curator": { + "me": "mir", + "following": "Personen, denen ich folge", + "network": "meinem Netzwerk" + } + } }, "sort": { "title": "Sortieren", diff --git a/messages/en.json b/messages/en.json index cf97c76502..7c9549797b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1162,10 +1162,30 @@ }, "filters": { "reach": { - "title": "Reach", + "title": "Posts from", "all": "All", + "network": "Network", + "myNetwork": "My Network", "following": "Following", - "friends": "Friends" + "friends": "Friends", + "me": "Me", + "profileTag": "profile tag", + "profileTagLimitReached": "{max} tags max", + "taggedBy": "by {reach}", + "taggedByLabel": "Choose who tagged these profiles", + "profileTagSummary": { + "reach": { + "all": "Posts from anyone tagged as {tags} by {curator}", + "friends": "Posts from friends tagged as {tags} by {curator}", + "following": "Posts from people I follow tagged as {tags} by {curator}", + "network": "Posts from people in my network tagged as {tags} by {curator}" + }, + "curator": { + "me": "me", + "following": "people I follow", + "network": "my network" + } + } }, "sort": { "title": "Sort", diff --git a/messages/es.json b/messages/es.json index 8c5b66db12..8ecdc33181 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1164,10 +1164,30 @@ }, "filters": { "reach": { - "title": "Alcance", + "title": "Publicaciones de", "all": "Todos", + "network": "Red", + "myNetwork": "Mi red", "following": "Siguiendo", - "friends": "Amigos" + "friends": "Amigos", + "me": "Yo", + "profileTag": "etiqueta de perfil", + "profileTagLimitReached": "máximo {max} etiquetas", + "taggedBy": "por {reach}", + "taggedByLabel": "Elige quién etiquetó estos perfiles", + "profileTagSummary": { + "reach": { + "all": "Publicaciones de cualquiera etiquetado como {tags} por {curator}", + "friends": "Publicaciones de amigos etiquetados como {tags} por {curator}", + "following": "Publicaciones de personas que sigo etiquetadas como {tags} por {curator}", + "network": "Publicaciones de personas de mi red etiquetadas como {tags} por {curator}" + }, + "curator": { + "me": "mí", + "following": "personas que sigo", + "network": "mi red" + } + } }, "sort": { "title": "Ordenar", diff --git a/messages/fr.json b/messages/fr.json index 85fbe4d61c..f91de3652f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1162,10 +1162,30 @@ }, "filters": { "reach": { - "title": "Portee", + "title": "Publications de", "all": "Tous", + "network": "Réseau", + "myNetwork": "Mon réseau", "following": "Abonnements", - "friends": "Amis" + "friends": "Amis", + "me": "Moi", + "profileTag": "tag de profil", + "profileTagLimitReached": "{max} tags maximum", + "taggedBy": "par {reach}", + "taggedByLabel": "Choisissez qui a tagué ces profils", + "profileTagSummary": { + "reach": { + "all": "Publications de toute personne taguée comme {tags} par {curator}", + "friends": "Publications d’amis tagués comme {tags} par {curator}", + "following": "Publications de personnes que je suis taguées comme {tags} par {curator}", + "network": "Publications de personnes de mon réseau taguées comme {tags} par {curator}" + }, + "curator": { + "me": "moi", + "following": "les personnes que je suis", + "network": "mon réseau" + } + } }, "sort": { "title": "Trier", diff --git a/messages/it.json b/messages/it.json index ec283f425d..1b4059d040 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1162,10 +1162,30 @@ }, "filters": { "reach": { - "title": "Portata", + "title": "Post di", "all": "Tutti", + "network": "Rete", + "myNetwork": "La mia rete", "following": "Seguiti", - "friends": "Amici" + "friends": "Amici", + "me": "Io", + "profileTag": "tag profilo", + "profileTagLimitReached": "massimo {max} tag", + "taggedBy": "da {reach}", + "taggedByLabel": "Scegli chi ha taggato questi profili", + "profileTagSummary": { + "reach": { + "all": "Post di chiunque sia stato taggato come {tags} da {curator}", + "friends": "Post di amici taggati come {tags} da {curator}", + "following": "Post di persone che seguo taggate come {tags} da {curator}", + "network": "Post di persone nella mia rete taggate come {tags} da {curator}" + }, + "curator": { + "me": "me", + "following": "persone che seguo", + "network": "la mia rete" + } + } }, "sort": { "title": "Ordina", diff --git a/messages/ja.json b/messages/ja.json index 8b0ffe1827..71b317c7f4 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -1162,10 +1162,30 @@ }, "filters": { "reach": { - "title": "範囲", + "title": "投稿元", "all": "すべて", + "network": "ネットワーク", + "myNetwork": "自分のネットワーク", "following": "フォロー中", - "friends": "友達" + "friends": "友達", + "me": "自分", + "profileTag": "プロフィールタグ", + "profileTagLimitReached": "最大{max}個のタグ", + "taggedBy": "{reach}による", + "taggedByLabel": "これらのプロフィールをタグ付けした範囲を選択", + "profileTagSummary": { + "reach": { + "all": "{curator}が {tags} とタグ付けした人の投稿", + "friends": "{curator}が {tags} とタグ付けした友達の投稿", + "following": "{curator}が {tags} とタグ付けしたフォロー中の人の投稿", + "network": "{curator}が {tags} とタグ付けした自分のネットワーク内の人の投稿" + }, + "curator": { + "me": "私", + "following": "フォロー中の人", + "network": "自分のネットワーク" + } + } }, "sort": { "title": "並べ替え", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 496790bd3b..e15b7d958a 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -1162,10 +1162,30 @@ }, "filters": { "reach": { - "title": "Alcance", + "title": "Posts de", "all": "Todos", + "network": "Rede", + "myNetwork": "Minha rede", "following": "Seguindo", - "friends": "Amigos" + "friends": "Amigos", + "me": "Eu", + "profileTag": "tag de perfil", + "profileTagLimitReached": "máximo de {max} tags", + "taggedBy": "por {reach}", + "taggedByLabel": "Escolha quem marcou estes perfis", + "profileTagSummary": { + "reach": { + "all": "Posts de qualquer pessoa marcada como {tags} por {curator}", + "friends": "Posts de amigos marcados como {tags} por {curator}", + "following": "Posts de pessoas que sigo marcadas como {tags} por {curator}", + "network": "Posts de pessoas da minha rede marcadas como {tags} por {curator}" + }, + "curator": { + "me": "mim", + "following": "pessoas que sigo", + "network": "minha rede" + } + } }, "sort": { "title": "Ordenar", diff --git a/messages/zh.json b/messages/zh.json index 9deec89071..ef13de7f9c 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1156,10 +1156,30 @@ }, "filters": { "reach": { - "title": "范围", + "title": "帖子来源", "all": "全部", + "network": "网络", + "myNetwork": "我的网络", "following": "关注", - "friends": "好友" + "friends": "好友", + "me": "我", + "profileTag": "个人资料标签", + "profileTagLimitReached": "最多 {max} 个标签", + "taggedBy": "由{reach}标记", + "taggedByLabel": "选择谁标记了这些个人资料", + "profileTagSummary": { + "reach": { + "all": "{curator}标记为 {tags} 的任何人的帖子", + "friends": "{curator}标记为 {tags} 的好友帖子", + "following": "{curator}标记为 {tags} 的我关注的人的帖子", + "network": "{curator}标记为 {tags} 的我网络中的人的帖子" + }, + "curator": { + "me": "我", + "following": "我关注的人", + "network": "我的网络" + } + } }, "sort": { "title": "排序", diff --git a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx index 21875839aa..6d1c1a10d8 100644 --- a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx +++ b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx @@ -1,31 +1,45 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { VISUAL_DISABLED_CONTENT } from '@/organisms/Timeline/Feed/TimelineFeed/TimelineFeedVisual.helpers'; -import { CONTENT, type ContentType } from '@/stores/home/home.types'; +import { CONTENT } from '@/stores/home/home.types'; import { FilterContent } from './FilterContent'; +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.setPointerCapture = vi.fn(); +}); + +async function selectContentOption(label: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole('combobox', { name: 'Content' })); + await user.click(screen.getByRole('option', { name: label })); +} + describe('FilterContent', () => { - it('renders with default selected tab', () => { + it('renders the current option in a dropdown trigger', () => { render(); expect(screen.getByText('Content')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Content' })).toHaveTextContent('All'); }); - it('calls onTabChange when tab is clicked', () => { + it('calls onTabChange when an option is selected', async () => { const onTabChange = vi.fn(); render(); - fireEvent.click(screen.getByText('Images')); - expect(onTabChange).toHaveBeenCalledWith('images'); + await selectContentOption('Images'); + + expect(onTabChange).toHaveBeenCalledWith(CONTENT.IMAGES); }); - it('handles all tab types correctly', () => { + it('supports all content options', async () => { const onTabChange = vi.fn(); render(); - // Map of tab values to their display labels - const tabsToTest: Array<{ value: ContentType; label: string }> = [ - { value: CONTENT.ALL, label: 'All' }, + const options = [ { value: CONTENT.SHORT, label: 'Posts' }, { value: CONTENT.LONG, label: 'Articles' }, { value: CONTENT.COLLECTIONS, label: 'Collections' }, @@ -33,78 +47,78 @@ describe('FilterContent', () => { { value: CONTENT.VIDEOS, label: 'Videos' }, { value: CONTENT.LINKS, label: 'Links' }, { value: CONTENT.FILES, label: 'Files' }, + { value: CONTENT.ALL, label: 'All' }, ]; - tabsToTest.forEach(({ value, label }) => { - fireEvent.click(screen.getByText(label)); - expect(onTabChange).toHaveBeenCalledWith(value); + for (const { label } of options) { + await selectContentOption(label); + } + + options.forEach(({ value }, index) => { + expect(onTabChange).toHaveBeenNthCalledWith(index + 1, value); }); - }); + }, 10_000); - it('handles tab switching correctly', () => { - const onTabChange = vi.fn(); - render(); + it('updates the trigger when the controlled selection changes', () => { + const { rerender } = render(); + + expect(screen.getByRole('combobox', { name: 'Content' })).toHaveTextContent('All'); - // Click on articles tab - fireEvent.click(screen.getByText('Articles')); - expect(onTabChange).toHaveBeenCalledWith(CONTENT.LONG); // 'long' is the value - expect(onTabChange).toHaveBeenCalledTimes(1); + rerender(); + + expect(screen.getByRole('combobox', { name: 'Content' })).toHaveTextContent('Articles'); }); - it('handles multiple tab clicks', () => { + it('handles multiple option selections', async () => { const onTabChange = vi.fn(); render(); - fireEvent.click(screen.getByText('Images')); - fireEvent.click(screen.getByText('Videos')); - fireEvent.click(screen.getByText('Files')); - - expect(onTabChange).toHaveBeenCalledTimes(3); - expect(onTabChange).toHaveBeenNthCalledWith(1, 'images'); - expect(onTabChange).toHaveBeenNthCalledWith(2, 'videos'); - expect(onTabChange).toHaveBeenNthCalledWith(3, 'files'); - }); + await selectContentOption('Images'); + await selectContentOption('Videos'); + await selectContentOption('Files'); - it('renders all items as disabled when disabled prop is true', () => { - render(); - - const labels = ['All', 'Posts', 'Articles', 'Collections', 'Images', 'Videos', 'Links', 'Files']; - labels.forEach((label) => { - expect(screen.getByLabelText(label)).toHaveAttribute('aria-disabled', 'true'); - }); + expect(onTabChange).toHaveBeenNthCalledWith(1, CONTENT.IMAGES); + expect(onTabChange).toHaveBeenNthCalledWith(2, CONTENT.VIDEOS); + expect(onTabChange).toHaveBeenNthCalledWith(3, CONTENT.FILES); }); - it('does not call onTabChange when disabled', () => { + it('disables the dropdown when the filter is disabled', async () => { const onTabChange = vi.fn(); + const user = userEvent.setup(); render(); - fireEvent.click(screen.getByText('Images')); - fireEvent.click(screen.getByText('Videos')); - fireEvent.click(screen.getByText('Files')); + const trigger = screen.getByRole('combobox', { name: 'Content' }); + expect(trigger).toBeDisabled(); + await user.click(trigger); + + expect(screen.queryByRole('option')).not.toBeInTheDocument(); expect(onTabChange).not.toHaveBeenCalled(); }); - it('items are not disabled by default', () => { + it('renders enabled options by default', async () => { + const user = userEvent.setup(); render(); - const labels = ['All', 'Posts', 'Articles', 'Collections', 'Images', 'Videos', 'Links', 'Files']; - labels.forEach((label) => { - expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-disabled', 'true'); - }); + await user.click(screen.getByRole('combobox', { name: 'Content' })); + + for (const label of ['All', 'Posts', 'Articles', 'Collections', 'Images', 'Videos', 'Links', 'Files']) { + expect(screen.getByRole('option', { name: label })).not.toHaveAttribute('aria-disabled', 'true'); + } }); - it('disables only the requested tabs', () => { + it('disables only the requested options', async () => { + const user = userEvent.setup(); render(); - expect(screen.getByLabelText('Posts')).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('Articles')).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('Collections')).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('Links')).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('Files')).toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('All')).not.toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('Images')).not.toHaveAttribute('aria-disabled', 'true'); - expect(screen.getByLabelText('Videos')).not.toHaveAttribute('aria-disabled', 'true'); + await user.click(screen.getByRole('combobox', { name: 'Content' })); + + for (const label of ['Posts', 'Articles', 'Collections', 'Links', 'Files']) { + expect(screen.getByRole('option', { name: label })).toHaveAttribute('aria-disabled', 'true'); + } + for (const label of ['All', 'Images', 'Videos']) { + expect(screen.getByRole('option', { name: label })).not.toHaveAttribute('aria-disabled', 'true'); + } }); }); diff --git a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap index 6e7bd9515c..b766fa6ec6 100644 --- a/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap +++ b/src/components/molecules/Filters/FilterContent/FilterContent.test.tsx.snap @@ -19,357 +19,78 @@ exports[`FilterContent - Snapshots > matches snapshot with All content selected Content - + + + + `; @@ -392,357 +113,85 @@ exports[`FilterContent - Snapshots > matches snapshot with Articles content sele Content - + + + + `; @@ -765,357 +214,81 @@ exports[`FilterContent - Snapshots > matches snapshot with Collections content s Content - + + + + `; @@ -1138,357 +311,78 @@ exports[`FilterContent - Snapshots > matches snapshot with Files content selecte Content - + + + + `; @@ -1511,357 +405,85 @@ exports[`FilterContent - Snapshots > matches snapshot with Images content select Content - + + + + `; @@ -1884,357 +506,75 @@ exports[`FilterContent - Snapshots > matches snapshot with Links content selecte Content - + + + + `; @@ -2257,357 +597,75 @@ exports[`FilterContent - Snapshots > matches snapshot with Posts content selecte Content - + + + + `; @@ -2630,357 +688,77 @@ exports[`FilterContent - Snapshots > matches snapshot with Videos content select Content - + + + + `; @@ -3003,357 +781,78 @@ exports[`FilterContent - Snapshots > matches snapshot with default props 1`] = ` Content - + + + + `; @@ -3376,364 +875,79 @@ exports[`FilterContent - Snapshots > matches snapshot with disabled state 1`] = Content - + + + + `; diff --git a/src/components/molecules/Filters/FilterContent/FilterContent.tsx b/src/components/molecules/Filters/FilterContent/FilterContent.tsx index aa7b9ff596..aecd4537e1 100644 --- a/src/components/molecules/Filters/FilterContent/FilterContent.tsx +++ b/src/components/molecules/Filters/FilterContent/FilterContent.tsx @@ -1,10 +1,9 @@ 'use client'; -import * as React from 'react'; import { CirclePlay, Download, Image, Layers, Library, Link, Newspaper, StickyNote } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { CONTENT, type ContentType } from '@/stores/home/home.types'; -import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup'; +import { FilterDropdown } from '../FilterDropdown/FilterDropdown'; import { BaseFilterProps } from '../Filters.types'; interface FilterContentProps extends BaseFilterProps { @@ -18,73 +17,68 @@ export function FilterContent({ disabledTabs = [], }: FilterContentProps) { const t = useTranslations('filters.content'); - const disabledSet = React.useMemo(() => new Set(disabledTabs), [disabledTabs]); - const isDisabled = React.useCallback( - (contentType: ContentType) => { - return disabled || disabledSet.has(contentType) ? true : undefined; + const disabledSet = new Set(disabledTabs); + const isDisabled = (contentType: ContentType) => disabled || disabledSet.has(contentType) || undefined; + const items = [ + { + key: CONTENT.ALL, + label: t('all'), + icon: Layers, + disabled: isDisabled(CONTENT.ALL), }, - [disabled, disabledSet], - ); - const items = React.useMemo( - () => [ - { - key: CONTENT.ALL, - label: t('all'), - icon: Layers, - disabled: isDisabled(CONTENT.ALL), - }, - { - key: CONTENT.SHORT, - label: t('posts'), - icon: StickyNote, - disabled: isDisabled(CONTENT.SHORT), - }, - { - key: CONTENT.LONG, - label: t('articles'), - icon: Newspaper, - disabled: isDisabled(CONTENT.LONG), - }, - { - key: CONTENT.COLLECTIONS, - label: t('collections'), - icon: Library, - disabled: isDisabled(CONTENT.COLLECTIONS), - }, - { - key: CONTENT.IMAGES, - label: t('images'), - icon: Image, - disabled: isDisabled(CONTENT.IMAGES), - }, - { - key: CONTENT.VIDEOS, - label: t('videos'), - icon: CirclePlay, - disabled: isDisabled(CONTENT.VIDEOS), - }, - { - key: CONTENT.LINKS, - label: t('links'), - icon: Link, - disabled: isDisabled(CONTENT.LINKS), - }, - { - key: CONTENT.FILES, - label: t('files'), - icon: Download, - disabled: isDisabled(CONTENT.FILES), - }, - ], - [t, isDisabled], - ); + { + key: CONTENT.SHORT, + label: t('posts'), + icon: StickyNote, + disabled: isDisabled(CONTENT.SHORT), + }, + { + key: CONTENT.LONG, + label: t('articles'), + icon: Newspaper, + disabled: isDisabled(CONTENT.LONG), + }, + { + key: CONTENT.COLLECTIONS, + label: t('collections'), + icon: Library, + disabled: isDisabled(CONTENT.COLLECTIONS), + }, + { + key: CONTENT.IMAGES, + label: t('images'), + icon: Image, + disabled: isDisabled(CONTENT.IMAGES), + }, + { + key: CONTENT.VIDEOS, + label: t('videos'), + icon: CirclePlay, + disabled: isDisabled(CONTENT.VIDEOS), + }, + { + key: CONTENT.LINKS, + label: t('links'), + icon: Link, + disabled: isDisabled(CONTENT.LINKS), + }, + { + key: CONTENT.FILES, + label: t('files'), + icon: Download, + disabled: isDisabled(CONTENT.FILES), + }, + ]; + return ( - ); } diff --git a/src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx b/src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx new file mode 100644 index 0000000000..b5691a81ad --- /dev/null +++ b/src/components/molecules/Filters/FilterDropdown/FilterDropdown.tsx @@ -0,0 +1,89 @@ +'use client'; + +import * as React from 'react'; +import { FilterHeader, FilterRoot } from '@/atoms/Filter/Filter'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/atoms/Select/Select'; +import { useControlledState } from '@/hooks/useControlledState/useControlledState'; +import type { FilterListItem } from '../Filters.types'; + +interface FilterDropdownProps { + title?: string; + ariaLabel?: string; + items: FilterListItem[]; + selectedValue?: T; + defaultValue?: T; + onChange?: (value: T) => void; + dataCy?: string; + testId?: string; +} + +export function FilterDropdown({ + title, + ariaLabel, + items, + selectedValue: controlledValue, + defaultValue, + onChange, + dataCy, + testId, +}: FilterDropdownProps) { + const headerId = React.useId(); + const { value: selectedValue, setValue: setSelectedValue } = useControlledState({ + value: controlledValue, + defaultValue: defaultValue ?? items[0]?.key, + onChange, + }); + const selectedItem = items.find((item) => item.key === selectedValue) ?? items[0]; + const isDisabled = items.every((item) => item.disabled); + + if (!selectedItem) { + return null; + } + + const SelectedIcon = selectedItem.icon; + + return ( + + {title && } + + + + ); +} diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx index 2703992c90..dc9f4f8387 100644 --- a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx +++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx @@ -1,88 +1,106 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import { LAYOUT, type LayoutType } from '@/stores/home/home.types'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { LAYOUT } from '@/stores/home/home.types'; import { FilterLayout } from './FilterLayout'; +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.setPointerCapture = vi.fn(); +}); + +async function selectLayoutOption(label: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole('combobox', { name: 'Layout' })); + await user.click(screen.getByRole('option', { name: label })); +} + describe('FilterLayout', () => { - it('renders with default selected tab', () => { + it('renders the current option in a dropdown trigger', () => { render(); expect(screen.getByText('Layout')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Layout' })).toHaveTextContent('Columns'); }); - it('calls onTabChange when tab is clicked', () => { + it('calls onTabChange when an option is selected', async () => { const onTabChange = vi.fn(); render(); - fireEvent.click(screen.getByText('Wide')); - expect(onTabChange).toHaveBeenCalledWith('wide'); + + await selectLayoutOption('Wide'); + + expect(onTabChange).toHaveBeenCalledWith(LAYOUT.WIDE); }); - it('handles all tab types correctly', () => { + it('supports all layout options', async () => { const onTabChange = vi.fn(); render(); - ([LAYOUT.COLUMNS, LAYOUT.WIDE, LAYOUT.LIST] as LayoutType[]).forEach((tab) => { - const label = tab === LAYOUT.COLUMNS ? 'Columns' : tab === LAYOUT.WIDE ? 'Wide' : 'List'; - fireEvent.click(screen.getByText(label)); - expect(onTabChange).toHaveBeenCalledWith(tab); - }); + await selectLayoutOption('Wide'); + await selectLayoutOption('List'); + await selectLayoutOption('Columns'); + + expect(onTabChange).toHaveBeenNthCalledWith(1, LAYOUT.WIDE); + expect(onTabChange).toHaveBeenNthCalledWith(2, LAYOUT.LIST); + expect(onTabChange).toHaveBeenNthCalledWith(3, LAYOUT.COLUMNS); }); - it('rerenders with different selected tabs', () => { + it('updates the trigger when the controlled selection changes', () => { const { rerender } = render(); - let columnsItem = screen.getByText('Columns').closest('[data-testid="filter-item"]'); - const wideItem = screen.getByText('Wide').closest('[data-testid="filter-item"]'); - expect(columnsItem).toBeInTheDocument(); - expect(wideItem).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Layout' })).toHaveTextContent('Columns'); rerender(); - columnsItem = screen.getByText('Columns').closest('[data-testid="filter-item"]'); - const wideItem2 = screen.getByText('Wide').closest('[data-testid="filter-item"]'); - expect(columnsItem).toBeInTheDocument(); - expect(wideItem2).toBeInTheDocument(); - }); - it('renders all items as disabled when disabled prop is true', () => { - render(); - - const labels = ['Columns', 'Wide', 'List']; - labels.forEach((label) => { - expect(screen.getByLabelText(label)).toHaveAttribute('aria-disabled', 'true'); - }); + expect(screen.getByRole('combobox', { name: 'Layout' })).toHaveTextContent('Wide'); }); - it('does not call onTabChange when disabled', () => { + it('disables the dropdown when the filter is disabled', async () => { const onTabChange = vi.fn(); + const user = userEvent.setup(); render(); - fireEvent.click(screen.getByText('Columns')); - fireEvent.click(screen.getByText('Wide')); - fireEvent.click(screen.getByText('List')); + const trigger = screen.getByRole('combobox', { name: 'Layout' }); + expect(trigger).toBeDisabled(); + + await user.click(trigger); + expect(screen.queryByRole('option')).not.toBeInTheDocument(); expect(onTabChange).not.toHaveBeenCalled(); }); - it('items are not disabled by default', () => { + it('renders enabled options by default', async () => { + const user = userEvent.setup(); render(); - const labels = ['Columns', 'Wide', 'List']; - labels.forEach((label) => { - expect(screen.getByLabelText(label)).not.toHaveAttribute('aria-disabled', 'true'); - }); + await user.click(screen.getByRole('combobox', { name: 'Layout' })); + + for (const label of ['Columns', 'Wide', 'List']) { + expect(screen.getByRole('option', { name: label })).not.toHaveAttribute('aria-disabled', 'true'); + } }); - it('renders visual layout when enabled', () => { + it('renders visual layout when enabled', async () => { + const user = userEvent.setup(); render(); - expect(screen.getByText('Visual')).toBeInTheDocument(); + await user.click(screen.getByRole('combobox', { name: 'Layout' })); + + expect(screen.getByRole('option', { name: 'Visual' })).toBeInTheDocument(); }); - it('falls back to columns for display when visual is selected but hidden', () => { + it('falls back to columns for display when visual is selected but hidden', async () => { + const user = userEvent.setup(); render(); - expect(screen.getByLabelText('Columns')).toHaveAttribute('aria-checked', 'true'); - expect(screen.queryByText('Visual')).not.toBeInTheDocument(); + const trigger = screen.getByRole('combobox', { name: 'Layout' }); + expect(trigger).toHaveTextContent('Columns'); + + await user.click(trigger); + + expect(screen.queryByRole('option', { name: 'Visual' })).not.toBeInTheDocument(); }); }); diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap index c23fc29788..0945749caf 100644 --- a/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap +++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.test.tsx.snap @@ -19,157 +19,82 @@ exports[`FilterLayout - Snapshots > matches snapshot with Columns selected tab 1 Layout - + + + + `; @@ -192,157 +117,79 @@ exports[`FilterLayout - Snapshots > matches snapshot with Wide selected tab 1`] Layout - + + + + `; @@ -365,157 +212,82 @@ exports[`FilterLayout - Snapshots > matches snapshot with default props 1`] = ` Layout - + + + + `; @@ -538,160 +310,84 @@ exports[`FilterLayout - Snapshots > matches snapshot with disabled state 1`] = ` Layout - + + + + `; @@ -714,156 +410,81 @@ exports[`FilterLayout - Snapshots > matches snapshot with hidden visual selectio Layout - + + + + `; diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx index 7caaf1af1a..f4b61386c1 100644 --- a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx +++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx @@ -1,10 +1,9 @@ 'use client'; -import * as React from 'react'; import { Columns3, LayoutGrid, Rows2, Rows4 } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { LAYOUT, type LayoutType } from '@/stores/home/home.types'; -import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup'; +import { FilterDropdown } from '../FilterDropdown/FilterDropdown'; import { BaseFilterProps, FilterListItem } from '../Filters.types'; interface FilterLayoutProps extends BaseFilterProps { @@ -19,49 +18,48 @@ export function FilterLayout({ }: FilterLayoutProps) { const t = useTranslations('filters.layout'); const displaySelectedTab = !showVisual && selectedTab === LAYOUT.VISUAL ? LAYOUT.COLUMNS : selectedTab; - const items = React.useMemo( - () => - [ - { - key: LAYOUT.COLUMNS, - label: t('columns'), - icon: Columns3, + const items = [ + { + key: LAYOUT.COLUMNS, + label: t('columns'), + icon: Columns3, + disabled, + dataCy: 'columns-layout-toggle', + }, + { + key: LAYOUT.WIDE, + label: t('wide'), + icon: Rows2, + disabled, + dataCy: 'wide-layout-toggle', + }, + { + key: LAYOUT.LIST, + label: t('list'), + icon: Rows4, + disabled, + dataCy: 'list-layout-toggle', + }, + showVisual + ? { + key: LAYOUT.VISUAL, + label: t('visual'), + icon: LayoutGrid, disabled, - dataCy: 'columns-layout-toggle', - }, - { - key: LAYOUT.WIDE, - label: t('wide'), - icon: Rows2, - disabled, - dataCy: 'wide-layout-toggle', - }, - { - key: LAYOUT.LIST, - label: t('list'), - icon: Rows4, - disabled, - dataCy: 'list-layout-toggle', - }, - showVisual - ? { - key: LAYOUT.VISUAL, - label: t('visual'), - icon: LayoutGrid, - disabled, - dataCy: 'visual-layout-toggle', - } - : null, - ].filter(Boolean) as FilterListItem[], - [t, disabled, showVisual], - ); + dataCy: 'visual-layout-toggle', + } + : null, + ].filter(Boolean) as FilterListItem[]; + return ( - ); } diff --git a/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx new file mode 100644 index 0000000000..4655f4e985 --- /dev/null +++ b/src/components/molecules/Filters/FilterProfileTags/FilterProfileTags.test.tsx @@ -0,0 +1,140 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { PROFILE_TAG_SCOPE } from '@/stores/home/home.types'; +import { FilterProfileTags } from './FilterProfileTags'; + +beforeAll(() => { + Element.prototype.scrollIntoView = vi.fn(); + Element.prototype.hasPointerCapture = vi.fn(); + Element.prototype.releasePointerCapture = vi.fn(); + Element.prototype.setPointerCapture = vi.fn(); +}); + +const defaultProps = { + selectedTags: [] as string[], + onTagAdd: vi.fn(), + onTagRemove: vi.fn(), + scope: PROFILE_TAG_SCOPE.NETWORK, + onScopeChange: vi.fn(), +}; + +describe('FilterProfileTags', () => { + it('renders the Tagged as controls and current reach scope', () => { + const { container } = render(); + + expect(screen.getByRole('heading', { name: 'Tagged as' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('profile tag')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })).toHaveTextContent( + 'by my network', + ); + expect(container.querySelector('.lucide-waypoints')).toHaveClass('size-5'); + }); + + it('uses the UsersRound icon and lowercase label for Following', () => { + const { container } = render(); + + expect(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })).toHaveTextContent( + 'by following', + ); + expect(container.querySelector('.lucide-users-round')).toHaveClass('size-5'); + }); + + it('orders WoT scopes as Network, Following, then Me', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })); + + expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([ + 'by my network', + 'by following', + 'by me', + ]); + }); + + it('adds a normalized profile tag from the input', async () => { + const onTagAdd = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('profile tag'); + fireEvent.change(input, { target: { value: 'Bitcoin' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => expect(onTagAdd).toHaveBeenCalledWith('bitcoin')); + }); + + it('renders selected tags in a removable stack', async () => { + const onTagRemove = vi.fn(); + const user = userEvent.setup(); + render(); + + expect(screen.getByText('bitcoin')).toBeInTheDocument(); + expect(screen.getByText('nostr')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Remove bitcoin tag')); + expect(onTagRemove).toHaveBeenCalledWith('bitcoin'); + }); + + it('changes only the independent WoT scope through the compact dropdown', async () => { + const onScopeChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })); + await user.click(screen.getByRole('option', { name: 'by following' })); + + expect(onScopeChange).toHaveBeenCalledWith(PROFILE_TAG_SCOPE.FOLLOWING); + }); + + it('offers Me as the Nexus depth-zero scope', async () => { + const onScopeChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('combobox', { name: 'Choose who tagged these profiles' })); + await user.click(screen.getByRole('option', { name: 'by me' })); + + expect(onScopeChange).toHaveBeenCalledWith(PROFILE_TAG_SCOPE.ME); + }); + + it('uses a 100ms collapse transition when hidden', () => { + render(