From 26fa6e3474b634626aacf3a6a8c95f80dfe75262 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 12:54:26 +0400 Subject: [PATCH 1/7] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 481 +++++++++++++++++++++++- ui/src/pages/ProjectsListPage.tsx | 136 ++++++- 2 files changed, 591 insertions(+), 26 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 93178e9..e574fae 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Button, Tooltip } from '../ui'; +import { Avatar, Button, Tooltip } from '../ui'; import { Dropdown } from '../work-item'; import { useModulesFilter } from '../../contexts/ModulesFilterContext'; import { useWorkspaceViewsState } from '../../contexts/WorkspaceViewsStateContext'; @@ -11,6 +11,7 @@ import { CreateViewModal, ModuleFiltersPanel, } from '../workspace-views'; +import { CollapsibleSection } from '../workspace-views/WorkspaceViewsFiltersShared'; import { ProjectSavedViewDisplayDropdown } from '../project-saved-view/ProjectSavedViewDisplayDropdown'; import { ProjectSavedViewMoreMenu } from '../project-saved-view/ProjectSavedViewMoreMenu'; import { DateRangeModal } from '../workspace-views/DateRangeModal'; @@ -285,6 +286,40 @@ const IconFilter = () => ( ); +const IconLock = () => ( + + + + +); +const IconGlobe = () => ( + + + + + + +); const IconPlus = () => ( + (searchParams.get(key) ?? '') + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + const selectedAccess = parseCsvParam('access').filter( + (value): value is 'private' | 'public' => value === 'private' || value === 'public', + ); + const selectedLeadIds = parseCsvParam('lead'); + const selectedMemberIds = parseCsvParam('members'); + const myProjectsOnly = searchParams.get('myProjects') === '1'; + const createdDateFilter = + searchParams.get('createdDate') === 'today' || + searchParams.get('createdDate') === 'last7' || + searchParams.get('createdDate') === 'last30' + ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30') + : ''; + const [projectsDropdownOpen, setProjectsDropdownOpen] = useState(null); const [searchOpen, setSearchOpen] = useState(!!searchQuery); + const [projectsFiltersSearch, setProjectsFiltersSearch] = useState(''); + const [workspaceMembers, setWorkspaceMembers] = useState([]); + const [showAllLeads, setShowAllLeads] = useState(false); + const [showAllMembers, setShowAllMembers] = useState(false); + const [projectsFilterSectionOpen, setProjectsFilterSectionOpen] = useState({ + createdDate: true, + access: true, + lead: true, + members: true, + }); const baseUrl = `/${workspaceSlug}`; + const sortFieldLabelMap: Record = { + manual: 'Manual', + name: 'Name', + created_date: 'Created date', + member_count: 'Number of members', + }; + const activeFilterCount = + (myProjectsOnly ? 1 : 0) + + (createdDateFilter ? 1 : 0) + + selectedAccess.length + + selectedLeadIds.length + + selectedMemberIds.length; + + useEffect(() => { + if (!workspaceSlug) return; + let cancelled = false; + workspaceService + .listMembers(workspaceSlug) + .then((members) => { + if (!cancelled) setWorkspaceMembers(members ?? []); + }) + .catch(() => { + if (!cancelled) setWorkspaceMembers([]); + }); + return () => { + cancelled = true; + }; + }, [workspaceSlug]); + + const updateParam = ( + key: + | 'q' + | 'sort' + | 'sortField' + | 'sortDir' + | 'filter' + | 'access' + | 'lead' + | 'members' + | 'myProjects' + | 'createdDate', + value?: string, + ) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (!value) next.delete(key); + else next.set(key, value); + return next; + }, + { replace: true }, + ); + }; + const updateParams = ( + updates: Partial< + Record< + | 'q' + | 'sort' + | 'sortField' + | 'sortDir' + | 'filter' + | 'access' + | 'lead' + | 'members' + | 'myProjects' + | 'createdDate', + string | undefined + > + >, + ) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + Object.entries(updates).forEach(([key, value]) => { + if (!value) next.delete(key); + else next.set(key, value); + }); + return next; + }, + { replace: true }, + ); + }; + const setCsvParam = (key: 'access' | 'lead' | 'members', values: string[]) => { + updateParam(key, values.length ? values.join(',') : undefined); + }; + const toggleCsvParam = (key: 'access' | 'lead' | 'members', value: string) => { + const current = parseCsvParam(key); + setCsvParam( + key, + current.includes(value) ? current.filter((v) => v !== value) : [...current, value], + ); + }; + + const memberOptions = [ + ...(authUser + ? [{ id: authUser.id, label: 'You', avatarUrl: authUser.avatarUrl, sortLabel: 'You' }] + : []), + ...workspaceMembers + .filter((member) => member.member_id !== authUser?.id) + .map((member) => ({ + id: member.member_id, + label: + member.member_display_name?.trim() || + member.member_email?.trim() || + member.member_id.slice(0, 8), + avatarUrl: member.member_avatar ?? null, + sortLabel: + member.member_display_name?.trim() || member.member_email?.trim() || member.member_id, + })), + ].sort((a, b) => a.sortLabel.localeCompare(b.sortLabel)); + const normalizedFilterSearch = projectsFiltersSearch.trim().toLowerCase(); + const includeBySearch = (label: string) => + !normalizedFilterSearch || label.toLowerCase().includes(normalizedFilterSearch); + const visibleLeadOptions = memberOptions.filter((opt) => includeBySearch(opt.label)); + const visibleMemberOptions = memberOptions.filter((opt) => includeBySearch(opt.label)); + const leadOptionsToRender = showAllLeads ? visibleLeadOptions : visibleLeadOptions.slice(0, 5); + const memberOptionsToRender = showAllMembers + ? visibleMemberOptions + : visibleMemberOptions.slice(0, 5); return ( <> @@ -817,7 +1018,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { + + + {sortFieldLabelMap[sortField]} + {projectsDropdownOpen === 'projects-sort' ? : } + + } + triggerClassName="flex items-center gap-1.5 rounded-md border border-(--border-subtle) bg-(--bg-layer-2) px-2.5 py-1.5 text-[13px] font-medium text-(--txt-secondary) hover:bg-(--bg-layer-2-hover)" > - - Created date - - - + ); + })} +
+ {[ + { value: 'asc', label: 'Ascending' }, + { value: 'desc', label: 'Descending' }, + ].map((opt) => { + const active = sortDir === opt.value; + return ( + + ); + })} + + } + displayValue={activeFilterCount > 0 ? `Filters (${activeFilterCount})` : 'Filters'} + panelClassName="w-80 rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-1 shadow-(--shadow-raised)" + triggerContent={ + <> + + + + + {activeFilterCount > 0 ? `Filters (${activeFilterCount})` : 'Filters'} + + {projectsDropdownOpen === 'projects-filters' ? ( + + ) : ( + + )} + + } + triggerClassName="flex items-center gap-1.5 rounded-md border border-(--border-subtle) bg-(--bg-layer-2) px-2.5 py-1.5 text-[13px] font-medium text-(--txt-secondary) hover:bg-(--bg-layer-2-hover)" > - - Filters - - +
+
+ + + + setProjectsFiltersSearch(e.target.value)} + placeholder="Search" + className="min-w-0 flex-1 bg-transparent text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + aria-label="Search project filters" + /> +
+
+
+ + + setProjectsFilterSectionOpen((prev) => ({ + ...prev, + createdDate: !prev.createdDate, + })) + } + > + {[ + { value: 'today', label: 'Today' }, + { value: 'last7', label: 'Last 7 days' }, + { value: 'last30', label: 'Last 30 days' }, + ] + .filter((opt) => includeBySearch(opt.label)) + .map((opt) => ( + + ))} + + + + setProjectsFilterSectionOpen((prev) => ({ ...prev, access: !prev.access })) + } + > + {[ + { value: 'private' as const, label: 'Private', icon: }, + { value: 'public' as const, label: 'Public', icon: }, + ] + .filter((opt) => includeBySearch(opt.label)) + .map((opt) => ( + + ))} + + + setProjectsFilterSectionOpen((prev) => ({ ...prev, lead: !prev.lead })) + } + > + {leadOptionsToRender.map((opt) => ( + + ))} + {visibleLeadOptions.length > 5 && ( + + )} + + + setProjectsFilterSectionOpen((prev) => ({ ...prev, members: !prev.members })) + } + > + {memberOptionsToRender.map((opt) => ( + + ))} + {visibleMemberOptions.length > 5 && ( + + )} + +
+
- {projects.length === 0 &&

No projects yet.

} + {projects.length === 0 && ( +

+ {favoritesOnly || + accessFilters.length > 0 || + leadFilters.length > 0 || + memberFilters.length > 0 || + !!createdDateFilter || + myProjectsOnly + ? 'No projects match the selected filters.' + : 'No projects yet.'} +

+ )} ); } From 50a58070107142c724480156ae1ebc23c1af99d3 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 13:10:00 +0400 Subject: [PATCH 2/7] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 30 +++++++++---------------- ui/src/pages/ProjectsListPage.tsx | 11 +++------ 2 files changed, 13 insertions(+), 28 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index e574fae..653e811 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -922,15 +922,10 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { | 'createdDate', value?: string, ) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - if (!value) next.delete(key); - else next.set(key, value); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + if (!value) next.delete(key); + else next.set(key, value); + setSearchParams(next, { replace: true }); }; const updateParams = ( updates: Partial< @@ -949,17 +944,12 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { > >, ) => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - Object.entries(updates).forEach(([key, value]) => { - if (!value) next.delete(key); - else next.set(key, value); - }); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + Object.entries(updates).forEach(([key, value]) => { + if (!value) next.delete(key); + else next.set(key, value); + }); + setSearchParams(next, { replace: true }); }; const setCsvParam = (key: 'access' | 'lead' | 'members', values: string[]) => { updateParam(key, values.length ? values.join(',') : undefined); diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index d881dd2..00a408a 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -106,14 +106,9 @@ export function ProjectsListPage() { const createProjectOpen = searchParams.get('createProject') === '1'; const closeCreateModal = () => { - setSearchParams( - (prev) => { - const next = new URLSearchParams(prev); - next.delete('createProject'); - return next; - }, - { replace: true }, - ); + const next = new URLSearchParams(searchParams); + next.delete('createProject'); + setSearchParams(next, { replace: true }); }; useEffect(() => { From 5862741482be77628bfe56b407058c52e7c24bdd Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 13:33:16 +0400 Subject: [PATCH 3/7] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 2 +- ui/src/pages/ProjectsListPage.tsx | 44 ++++++++++++++----------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 653e811..f7d3b70 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -847,7 +847,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { ? sortDirParam : legacySortParam === 'created_asc' || legacySortParam === 'name_asc' ? 'asc' - : 'desc'; + : 'asc'; const parseCsvParam = (key: string) => (searchParams.get(key) ?? '') .split(',') diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 00a408a..5ebac94 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -87,7 +87,7 @@ export function ProjectsListPage() { ? sortDirParam : sortParam === 'created_asc' || sortParam === 'name_asc' ? 'asc' - : 'desc'; + : 'asc'; const createdDateFilter = searchParams.get('createdDate') === 'today' || searchParams.get('createdDate') === 'last7' || @@ -215,7 +215,7 @@ export function ProjectsListPage() { const projectOrderById = new Map(allProjects.map((p, index) => [p.id, index])); - const projects = allProjects + const filteredProjects = allProjects .filter((p) => { if (!searchQuery) return true; return ( @@ -259,37 +259,38 @@ export function ProjectsListPage() { const days = createdDateFilter === 'last7' ? 7 : 30; const threshold = now.getTime() - days * 24 * 60 * 60 * 1000; return createdAtMs >= threshold; - }) - .slice() + }); + + const projects = filteredProjects + .map((project) => ({ + project, + createdAtMs: Date.parse(project.created_at ?? '') || 0, + membersCount: new Set([ + ...(membersByProject[project.id] ?? []), + ...(project.project_lead_id ? [project.project_lead_id] : []), + ]).size, + })) .sort((a, b) => { - const createdA = Date.parse(a.created_at ?? '') || 0; - const createdB = Date.parse(b.created_at ?? '') || 0; - const membersA = new Set([ - ...(membersByProject[a.id] ?? []), - ...(a.project_lead_id ? [a.project_lead_id] : []), - ]).size; - const membersB = new Set([ - ...(membersByProject[b.id] ?? []), - ...(b.project_lead_id ? [b.project_lead_id] : []), - ]).size; let result = 0; switch (sortField) { case 'name': - result = a.name.localeCompare(b.name); + result = a.project.name.localeCompare(b.project.name); break; case 'member_count': - result = membersA - membersB; + result = a.membersCount - b.membersCount; break; case 'manual': - result = (projectOrderById.get(a.id) ?? 0) - (projectOrderById.get(b.id) ?? 0); + result = + (projectOrderById.get(a.project.id) ?? 0) - (projectOrderById.get(b.project.id) ?? 0); break; case 'created_date': default: - result = createdA - createdB; + result = a.createdAtMs - b.createdAtMs; break; } return sortDir === 'desc' ? -result : result; - }); + }) + .map(({ project }) => project); if (loading) { return ( @@ -432,12 +433,15 @@ export function ProjectsListPage() { {projects.length === 0 && (

{favoritesOnly || + !!searchQuery || accessFilters.length > 0 || leadFilters.length > 0 || memberFilters.length > 0 || !!createdDateFilter || myProjectsOnly - ? 'No projects match the selected filters.' + ? searchQuery + ? 'No results match your search' + : 'No projects match the selected filters.' : 'No projects yet.'}

)} From 6174b308373037d73e10e9ea65e6bef091b251fc Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Mon, 20 Apr 2026 13:59:30 +0400 Subject: [PATCH 4/7] refactor(ui): update pages for ui --- ui/src/pages/ProjectsListPage.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 5ebac94..21c5359 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -292,6 +292,15 @@ export function ProjectsListPage() { }) .map(({ project }) => project); + const hasActiveFiltersOrSearch = + !!searchQuery || + favoritesOnly || + accessFilters.length > 0 || + leadFilters.length > 0 || + memberFilters.length > 0 || + !!createdDateFilter || + myProjectsOnly; + if (loading) { return (
@@ -432,13 +441,7 @@ export function ProjectsListPage() {
{projects.length === 0 && (

- {favoritesOnly || - !!searchQuery || - accessFilters.length > 0 || - leadFilters.length > 0 || - memberFilters.length > 0 || - !!createdDateFilter || - myProjectsOnly + {hasActiveFiltersOrSearch ? searchQuery ? 'No results match your search' : 'No projects match the selected filters.' From 30836dfb1a56a66472894894c893a7c81dbb19b3 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Sat, 25 Apr 2026 18:22:59 +0400 Subject: [PATCH 5/7] refactor(ui): update layout, pages for ui --- ui/src/components/layout/PageHeader.tsx | 134 ++++++++++++++++-------- ui/src/pages/ProjectsListPage.tsx | 16 ++- 2 files changed, 102 insertions(+), 48 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index f7d3b70..036fa9e 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -862,10 +862,14 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { const createdDateFilter = searchParams.get('createdDate') === 'today' || searchParams.get('createdDate') === 'last7' || - searchParams.get('createdDate') === 'last30' - ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30') + searchParams.get('createdDate') === 'last30' || + searchParams.get('createdDate') === 'custom' + ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30' | 'custom') : ''; + const createdAfter = searchParams.get('createdAfter'); + const createdBefore = searchParams.get('createdBefore'); const [projectsDropdownOpen, setProjectsDropdownOpen] = useState(null); + const [projectsDateRangeModalOpen, setProjectsDateRangeModalOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(!!searchQuery); const [projectsFiltersSearch, setProjectsFiltersSearch] = useState(''); const [workspaceMembers, setWorkspaceMembers] = useState([]); @@ -919,7 +923,9 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { | 'lead' | 'members' | 'myProjects' - | 'createdDate', + | 'createdDate' + | 'createdAfter' + | 'createdBefore', value?: string, ) => { const next = new URLSearchParams(searchParams); @@ -939,7 +945,9 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { | 'lead' | 'members' | 'myProjects' - | 'createdDate', + | 'createdDate' + | 'createdAfter' + | 'createdBefore', string | undefined > >, @@ -1072,7 +1080,11 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { : 'text-(--txt-secondary) hover:bg-(--bg-layer-1-hover)' }`} onClick={() => { - updateParams({ sortField: opt.value, sort: undefined }); + updateParams({ + sortField: opt.value, + sort: undefined, + ...(opt.value === 'manual' ? { sortDir: undefined } : {}), + }); }} > {opt.label} @@ -1086,6 +1098,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { { value: 'desc', label: 'Descending' }, ].map((opt) => { const active = sortDir === opt.value; + const disabled = sortField === 'manual'; return ( - )} + + setProjectsFilterSectionOpen((prev) => ({ + ...prev, + createdDate: !prev.createdDate, + })) + } + > + {[ + { value: 'today', label: 'Today' }, + { value: 'last7', label: 'Last 7 days' }, + { value: 'last30', label: 'Last 30 days' }, + { value: 'custom', label: 'Custom' }, + ] + .filter((opt) => includeBySearch(opt.label)) + .map((opt) => { + const active = createdDateFilter === opt.value; + return ( + + ); + })} + @@ -1305,6 +1334,21 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { + setProjectsDateRangeModalOpen(false)} + title="Created date range" + after={createdAfter} + before={createdBefore} + onApply={(after, before) => { + updateParams({ + createdDate: 'custom', + createdAfter: after, + createdBefore: before, + }); + setProjectsDateRangeModalOpen(false); + }} + /> ); } diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 21c5359..8eac11a 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -91,9 +91,12 @@ export function ProjectsListPage() { const createdDateFilter = searchParams.get('createdDate') === 'today' || searchParams.get('createdDate') === 'last7' || - searchParams.get('createdDate') === 'last30' - ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30') + searchParams.get('createdDate') === 'last30' || + searchParams.get('createdDate') === 'custom' + ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30' | 'custom') : ''; + const createdAfter = searchParams.get('createdAfter'); + const createdBefore = searchParams.get('createdBefore'); const favoritesOnly = searchParams.get('filter') === 'favorites'; const [workspace, setWorkspace] = useState(null); const [allProjects, setAllProjects] = useState([]); @@ -247,15 +250,21 @@ export function ProjectsListPage() { return memberIds.includes(authUser.id) || p.project_lead_id === authUser.id; }) .filter((p) => { - if (!createdDateFilter) return true; const createdAtMs = Date.parse(p.created_at ?? ''); if (!Number.isFinite(createdAtMs)) return false; + if (!createdDateFilter) return true; const now = new Date(); if (createdDateFilter === 'today') { const startOfDay = new Date(now); startOfDay.setHours(0, 0, 0, 0); return createdAtMs >= startOfDay.getTime(); } + if (createdDateFilter === 'custom') { + const afterMs = createdAfter ? Date.parse(createdAfter) : NaN; + const beforeMs = createdBefore ? Date.parse(createdBefore) : NaN; + if (!Number.isFinite(afterMs) || !Number.isFinite(beforeMs)) return true; + return createdAtMs >= afterMs && createdAtMs <= beforeMs + (24 * 60 * 60 * 1000 - 1); + } const days = createdDateFilter === 'last7' ? 7 : 30; const threshold = now.getTime() - days * 24 * 60 * 60 * 1000; return createdAtMs >= threshold; @@ -288,6 +297,7 @@ export function ProjectsListPage() { result = a.createdAtMs - b.createdAtMs; break; } + if (sortField === 'manual') return result; return sortDir === 'desc' ? -result : result; }) .map(({ project }) => project); From 47f6ffeb10612b4ecd2eb5e3f6a4f10d3c21c3e5 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Sat, 25 Apr 2026 18:35:25 +0400 Subject: [PATCH 6/7] feat(projects): extract search parameter parsing to utility function --- ui/src/components/layout/PageHeader.tsx | 53 +++++------------- ui/src/lib/projectsListSearchParams.ts | 74 +++++++++++++++++++++++++ ui/src/pages/ProjectsListPage.tsx | 54 +++++------------- 3 files changed, 102 insertions(+), 79 deletions(-) create mode 100644 ui/src/lib/projectsListSearchParams.ts diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 036fa9e..c191d79 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -59,6 +59,7 @@ import { import { PROJECT_VIEWS_FILTER_EVENT } from '../../lib/projectViewsEvents'; import { slugify } from '../../lib/slug'; import { MODULE_WORK_ITEMS_COUNT_EVENT } from '../../lib/moduleWorkItemsPrefs'; +import { parseProjectsListSearchParams } from '../../lib/projectsListSearchParams'; import { ModuleDetailHeader } from './ModuleDetailHeader'; export type ProjectSection = 'issues' | 'cycles' | 'modules' | 'views' | 'pages'; @@ -830,44 +831,17 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { const { user: authUser } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); const searchQuery = searchParams.get('q') ?? ''; - const sortFieldParam = searchParams.get('sortField'); - const sortDirParam = searchParams.get('sortDir'); - const legacySortParam = searchParams.get('sort'); - const sortField = - sortFieldParam === 'manual' || - sortFieldParam === 'name' || - sortFieldParam === 'created_date' || - sortFieldParam === 'member_count' - ? sortFieldParam - : legacySortParam === 'name_asc' || legacySortParam === 'name_desc' - ? 'name' - : 'created_date'; - const sortDir = - sortDirParam === 'asc' || sortDirParam === 'desc' - ? sortDirParam - : legacySortParam === 'created_asc' || legacySortParam === 'name_asc' - ? 'asc' - : 'asc'; - const parseCsvParam = (key: string) => - (searchParams.get(key) ?? '') - .split(',') - .map((v) => v.trim()) - .filter(Boolean); - const selectedAccess = parseCsvParam('access').filter( - (value): value is 'private' | 'public' => value === 'private' || value === 'public', - ); - const selectedLeadIds = parseCsvParam('lead'); - const selectedMemberIds = parseCsvParam('members'); - const myProjectsOnly = searchParams.get('myProjects') === '1'; - const createdDateFilter = - searchParams.get('createdDate') === 'today' || - searchParams.get('createdDate') === 'last7' || - searchParams.get('createdDate') === 'last30' || - searchParams.get('createdDate') === 'custom' - ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30' | 'custom') - : ''; - const createdAfter = searchParams.get('createdAfter'); - const createdBefore = searchParams.get('createdBefore'); + const { + sortField, + sortDir, + accessFilters: selectedAccess, + leadFilters: selectedLeadIds, + memberFilters: selectedMemberIds, + myProjectsOnly, + createdDateFilter, + createdAfter, + createdBefore, + } = parseProjectsListSearchParams(searchParams); const [projectsDropdownOpen, setProjectsDropdownOpen] = useState(null); const [projectsDateRangeModalOpen, setProjectsDateRangeModalOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(!!searchQuery); @@ -963,7 +937,8 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { updateParam(key, values.length ? values.join(',') : undefined); }; const toggleCsvParam = (key: 'access' | 'lead' | 'members', value: string) => { - const current = parseCsvParam(key); + const current = + key === 'access' ? selectedAccess : key === 'lead' ? selectedLeadIds : selectedMemberIds; setCsvParam( key, current.includes(value) ? current.filter((v) => v !== value) : [...current, value], diff --git a/ui/src/lib/projectsListSearchParams.ts b/ui/src/lib/projectsListSearchParams.ts new file mode 100644 index 0000000..c322232 --- /dev/null +++ b/ui/src/lib/projectsListSearchParams.ts @@ -0,0 +1,74 @@ +export type ProjectsSortField = 'manual' | 'name' | 'created_date' | 'member_count'; +export type ProjectsSortDir = 'asc' | 'desc'; +export type ProjectsCreatedDateFilter = '' | 'today' | 'last7' | 'last30' | 'custom'; + +export interface ProjectsListSearchParamsState { + searchQuery: string; + sortField: ProjectsSortField; + sortDir: ProjectsSortDir; + accessFilters: Array<'private' | 'public'>; + leadFilters: string[]; + memberFilters: string[]; + myProjectsOnly: boolean; + createdDateFilter: ProjectsCreatedDateFilter; + createdAfter: string | null; + createdBefore: string | null; + favoritesOnly: boolean; +} + +function parseCsvParam(searchParams: URLSearchParams, key: string): string[] { + return (searchParams.get(key) ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); +} + +export function parseProjectsListSearchParams( + searchParams: URLSearchParams, +): ProjectsListSearchParamsState { + const sortFieldParam = searchParams.get('sortField'); + const sortDirParam = searchParams.get('sortDir'); + const legacySortParam = searchParams.get('sort'); + const createdDateParam = searchParams.get('createdDate'); + + const sortField: ProjectsSortField = + sortFieldParam === 'manual' || + sortFieldParam === 'name' || + sortFieldParam === 'created_date' || + sortFieldParam === 'member_count' + ? sortFieldParam + : legacySortParam === 'name_asc' || legacySortParam === 'name_desc' + ? 'name' + : 'created_date'; + + const sortDir: ProjectsSortDir = + sortDirParam === 'asc' || sortDirParam === 'desc' + ? sortDirParam + : legacySortParam === 'created_asc' || legacySortParam === 'name_asc' + ? 'asc' + : 'asc'; + + const createdDateFilter: ProjectsCreatedDateFilter = + createdDateParam === 'today' || + createdDateParam === 'last7' || + createdDateParam === 'last30' || + createdDateParam === 'custom' + ? createdDateParam + : ''; + + return { + searchQuery: (searchParams.get('q') ?? '').toLowerCase().trim(), + sortField, + sortDir, + accessFilters: parseCsvParam(searchParams, 'access').filter( + (value): value is 'private' | 'public' => value === 'private' || value === 'public', + ), + leadFilters: parseCsvParam(searchParams, 'lead'), + memberFilters: parseCsvParam(searchParams, 'members'), + myProjectsOnly: searchParams.get('myProjects') === '1', + createdDateFilter, + createdAfter: searchParams.get('createdAfter'), + createdBefore: searchParams.get('createdBefore'), + favoritesOnly: searchParams.get('filter') === 'favorites', + }; +} diff --git a/ui/src/pages/ProjectsListPage.tsx b/ui/src/pages/ProjectsListPage.tsx index 8eac11a..2985ed9 100644 --- a/ui/src/pages/ProjectsListPage.tsx +++ b/ui/src/pages/ProjectsListPage.tsx @@ -9,6 +9,7 @@ import { projectService } from '../services/projectService'; import { favoriteService } from '../services/favoriteService'; import { useFavorites } from '../contexts/FavoritesContext'; import { useAuth } from '../contexts/AuthContext'; +import { parseProjectsListSearchParams } from '../lib/projectsListSearchParams'; import type { WorkspaceApiResponse, ProjectApiResponse } from '../api/types'; const MAX_AVATARS = 3; @@ -58,46 +59,19 @@ export function ProjectsListPage() { const { workspaceSlug } = useParams<{ workspaceSlug: string }>(); const { user: authUser } = useAuth(); const [searchParams, setSearchParams] = useSearchParams(); - const searchQuery = (searchParams.get('q') ?? '').toLowerCase().trim(); - const parseCsvParam = (key: string) => - (searchParams.get(key) ?? '') - .split(',') - .map((v) => v.trim()) - .filter(Boolean); - const accessFilters = parseCsvParam('access').filter( - (value): value is 'private' | 'public' => value === 'private' || value === 'public', - ); - const leadFilters = parseCsvParam('lead'); - const memberFilters = parseCsvParam('members'); - const myProjectsOnly = searchParams.get('myProjects') === '1'; - const sortFieldParam = searchParams.get('sortField'); - const sortDirParam = searchParams.get('sortDir'); - const sortParam = searchParams.get('sort'); - const sortField = - sortFieldParam === 'manual' || - sortFieldParam === 'name' || - sortFieldParam === 'created_date' || - sortFieldParam === 'member_count' - ? sortFieldParam - : sortParam === 'name_asc' || sortParam === 'name_desc' - ? 'name' - : 'created_date'; - const sortDir = - sortDirParam === 'asc' || sortDirParam === 'desc' - ? sortDirParam - : sortParam === 'created_asc' || sortParam === 'name_asc' - ? 'asc' - : 'asc'; - const createdDateFilter = - searchParams.get('createdDate') === 'today' || - searchParams.get('createdDate') === 'last7' || - searchParams.get('createdDate') === 'last30' || - searchParams.get('createdDate') === 'custom' - ? (searchParams.get('createdDate') as 'today' | 'last7' | 'last30' | 'custom') - : ''; - const createdAfter = searchParams.get('createdAfter'); - const createdBefore = searchParams.get('createdBefore'); - const favoritesOnly = searchParams.get('filter') === 'favorites'; + const { + searchQuery, + accessFilters, + leadFilters, + memberFilters, + myProjectsOnly, + sortField, + sortDir, + createdDateFilter, + createdAfter, + createdBefore, + favoritesOnly, + } = parseProjectsListSearchParams(searchParams); const [workspace, setWorkspace] = useState(null); const [allProjects, setAllProjects] = useState([]); const [membersByProject, setMembersByProject] = useState>({}); From cfd75806e248b5e89205bdddaf82eb3f720884e0 Mon Sep 17 00:00:00 2001 From: Rafetikus Date: Sat, 25 Apr 2026 19:51:20 +0400 Subject: [PATCH 7/7] refactor(ui): update layout, lib, pages for ui --- ui/src/components/layout/PageHeader.tsx | 11 +++++++++++ ui/src/lib/projectsListSearchParams.ts | 4 +++- ui/src/pages/ProjectsListPage.tsx | 20 ++++++++++++++++---- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index c191d79..43d65cb 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -841,6 +841,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { createdDateFilter, createdAfter, createdBefore, + favoritesOnly, } = parseProjectsListSearchParams(searchParams); const [projectsDropdownOpen, setProjectsDropdownOpen] = useState(null); const [projectsDateRangeModalOpen, setProjectsDateRangeModalOpen] = useState(false); @@ -864,6 +865,7 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) { member_count: 'Number of members', }; const activeFilterCount = + (favoritesOnly ? 1 : 0) + (myProjectsOnly ? 1 : 0) + (createdDateFilter ? 1 : 0) + selectedAccess.length + @@ -1136,6 +1138,15 @@ function ProjectsHeader({ workspaceSlug }: { workspaceSlug: string }) {

+