diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 93178e9..f7d3b70 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, + ) => { + const next = new URLSearchParams(searchParams); + if (!value) next.delete(key); + else next.set(key, value); + setSearchParams(next, { replace: true }); + }; + const updateParams = ( + updates: Partial< + Record< + | 'q' + | 'sort' + | 'sortField' + | 'sortDir' + | 'filter' + | 'access' + | 'lead' + | 'members' + | 'myProjects' + | 'createdDate', + string | undefined + > + >, + ) => { + 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); + }; + 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 +1008,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 && ( +

+ {hasActiveFiltersOrSearch + ? searchQuery + ? 'No results match your search' + : 'No projects match the selected filters.' + : 'No projects yet.'} +

+ )} ); }