mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
* feat(server): custom issue properties — definitions, typed values, CLI (MUL-4463)
Workspace-level property definitions (issue_property table; 7 types:
text/number/select/multi_select/date/checkbox/url) plus a typed value bag
on each issue (issue.properties JSONB keyed by definition UUID, mirroring
the metadata machinery: single-key atomic writes, 16KB cap, GIN index).
- Definitions: owner/admin only; agent actors rejected (agents propose,
humans confirm). 20 active per workspace, 50 options per select,
reserved built-in names blocked, archive instead of delete.
- Values: any member or agent; per-type validation with self-correcting
error messages that enumerate legal option ids.
- API: /api/properties CRUD + PUT/DELETE /api/issues/{id}/properties/{propertyId};
issue responses always emit the properties bag.
- CLI: multica property list/get/create/update/archive/unarchive and
multica issue property list/set/unset with name→id translation.
- Events: property:created/updated, issue_properties:changed.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom properties settings tab + issue sidebar editors (MUL-4463)
- Settings → Properties: definition management mirroring the Labels tab
(list with type badges/option chips/usage counts, create/edit dialog
with option editor, archive/restore, 20-cap indicator). Admin-gated;
members see a read-only catalog.
- Issue detail sidebar: custom properties join the built-in optional
props' progressive disclosure — set values render as rows with
type-appropriate editors (select/multi-select pickers, calendar,
yes/no, inline input for text/number/url), unset ones live in the
same '+ Add property' menu behind a separator. Archived definitions
render read-only until cleared.
- Core: property types, zod schemas (lenient type strings for forward
compat), api client methods, React Query hooks with optimistic
single-key value writes, ws-updaters + realtime wiring for
property:created/updated and issue_properties:changed.
- Locales: en/zh-Hans/ja/ko strings; Issue fixtures gain properties: {}.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 1 — mobile CI, option guard, mutation safety, schema tolerance
- mobile: EMPTY_ISSUE_FALLBACK gains the required properties field (mobile
typecheck was the red CI check).
- server: PATCH /api/properties/{id} rejects config updates that remove
select options still referenced by issues (409 with a per-option usage
census via jsonb ?); renames keep ids and pass. Integration test included.
- core: property value mutations are serialized per workspace via mutation
scope, snapshot the bag from detail OR list caches (board surfaces have no
detail cache — the old path overwrote whole bags with one key), roll back
to the snapshot or invalidate on error, and the last settled mutation does
an authoritative detail+catalog invalidate (usage counts reconcile).
- schemas: unknown-shaped property values (future server types) are dropped
per-entry in a preprocess step instead of failing the whole IssueSchema
and blanking lists through parseWithFallback; test updated to lock the
tolerant behavior.
- realtime: reconnect invalidation covers the property catalog; every
issue_properties:changed event also refreshes catalog usage counts.
- ui: number editor accepts decimals (step=any); settings usage count
pluralizes (issue/issues) with CJK-safe plural keys.
Co-authored-by: multica-agent <github@multica.ai>
* fix(migrations): renumber issue properties to 179 and build the GIN index concurrently
main's migration sequence advanced twice under this PR (167 collision, then
an upstream renumber wave that claimed 178), so issue properties now sits at
179 — verified against main's current tip by the prefix-uniqueness lint.
The properties GIN index moves to its own single-statement migration (180)
using CREATE INDEX CONCURRENTLY — a plain CREATE INDEX on the hot issue
table would block writes for the duration of the build. Mirrors the
119_user_created_at_index pattern; full-chain dry-run on a fresh database
passes through 180.
Co-authored-by: multica-agent <github@multica.ai>
* feat(web): custom-property list surfaces — filter, cards, sort, board grouping (MUL-4463 M2)
Brings custom properties to the issue list surfaces on top of the M1
definitions/values core:
- Filter: per-definition sections in the Filter dropdown (select /
multi_select options with color dots and counts; checkbox as Yes/No
pseudo-options). OR within a definition, AND across definitions;
client-side in applyIssueFilters, mirrored into filterAssigneeGroups
for the assignee-grouped board. Included in active-filter count and
Clear all.
- Cards: per-property Display toggles (cardPropertyIds) render value
chips on board cards and list rows via CustomPropertyValueDisplay.
- Sort: SortField gains property:<id> for number/date definitions.
Server keeps position order (fixed sort enum); the surface controller
re-sorts client-side, swimlane/gantt reuse the same comparator.
Date-only strings compare lexically; missing values sort last.
- Board grouping: IssueGrouping gains property:<id> for select
definitions — one column per option (definition order) plus a
trailing No-value column, option-colored headings. Drag-drop moves
position via UpdateIssue and applies the value through
useSetIssueProperty/useUnsetIssueProperty (properties are not part
of UpdateIssueRequest). Stale persisted property groupings fall back
to status columns.
View-store: propertyFilters + cardPropertyIds persisted via the
partialize allowlist; clearFilters resets property filters; new fields
deep-merge cleanly into pre-existing persisted snapshots.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address MUL-4463 review round 2 — desc sort, option bucketing, archived-state reconciliation
- sort: direction now applies to value comparison only; issues without a
value sort last in BOTH directions (the whole-array reverse flipped them
to the front on desc). Test covers the desc+missing case.
- board: values referencing an option removed from the definition bucket
into the No-value column instead of vanishing (unmatched column ids
dropped the issue entirely). Defense-in-depth behind the new server-side
in-use guard; drag-utils test locks both behaviors.
- controller: persisted propertyFilters keyed by archived/deleted
definitions are stripped before reaching the filter predicates, and a
persisted property sort on a non-active definition degrades to manual
order — previously both kept silently applying while the header claimed
otherwise. The filter badge counts only active-definition filters.
Co-authored-by: multica-agent <github@multica.ai>
* feat(properties): server-side property filtering and sorting on list endpoints
Property filter/sort now execute in the database, so results are correct
across the full issue set — not just the loaded 50-per-status window
(closes MUL-4493 item 1's filter/sort half; requested on MUL-4463).
- New `properties` query param on ListIssues and ListGroupedIssues:
JSON {definitionId: [values]} compiled to an AND-of-ORs containment
check (double NOT EXISTS over jsonb_array_elements). One value expands
to every storage shape it could match — string (select), array element
(multi_select), boolean (checkbox) — so the handler stays type-agnostic.
Guarded at 20 definitions / 50 values.
- `sort=property:<definitionId>` resolves the definition and orders by a
typed expression (numeric CASE cast for number, NULLIF text for
date/text/url); missing values sort last in both directions. Malformed
ids 400; unknown/archived definitions degrade to position order instead
of breaking stale clients.
- Frontend: the property filter and property sort ride the IssueSortParam
window bag, so every surface (workspace + my-issues variants), query
key, and per-status load-more page carries them automatically. The
client-side re-sort layer is gone; applyIssueFilters keeps its property
predicate as an optimistic-update backstop.
- Regression test seeds 55 issues and proves a match at position 55 is
returned by a filtered 50-row page, plus sort order/missing-last,
AND-across-definitions, and the 400/fallback sort paths.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): address review round 3 — cache reconcile, merged-scope order, GIN-indexable filter, pool loader
- Cache reconciliation: property value writes (mutation settle + WS event)
now invalidate every issue window whose server-side shape depends on
property values — queries filtered by `properties` or sorted by
`property:<id>` (detected via query-key predicate), covering flat lists,
assignee groups, and my-issues variants. Windows without property params
keep the cheap in-place patch. Fixes stale ordering/membership/counts
under staleTime:Infinity.
- My Issues "All" scope: merged assigned/created/involves results are
re-sorted with a comparator mirroring the server ORDER BY semantics
(including property sorts and missing-last, created_at DESC tiebreak) in
both the flat and assignee-grouped merge paths — relation concatenation
no longer overrides the user's sort.
- Filter predicate rebuilt as plain bind-parameter containment ORs
(AND across definitions): EXPLAIN now shows BitmapOr over
idx_issue_properties_gin (the correlated jsonb_array_elements form
defeated the index). Alternatives capped at 256 bind params.
- Property-grouped board gains a pool loader strip: one sentinel per
status that still has server rows, keeping every issue reachable until
per-column pagination lands (MUL-4493).
- Windowing regression test hardened: explicit positions + an assertion
that the unfiltered first page excludes the target (the old fixture tied
at position 0 and the created_at DESC tiebreak put the target on page
one, proving nothing).
- Rollback safety: /api/properties 404 (old server) degrades to an empty
catalog instead of a query error, which also keeps property params from
ever being sent to pre-property servers; migration 179's CHECK
constraints switch to NOT VALID + VALIDATE so the exclusive lock is
instantaneous.
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): harden concurrency and cache coordination from clean-room review
Backend (MUL-4762 F1/F4/F5):
- withPropertyLock: pg_advisory_xact_lock helper; definition create/update
and value writes now serialize config-vs-value and cap-vs-insert races
(workspace-level 'props:' lock + per-definition 'prop:' lock, ordered).
- propertySortExpr degrades archived definitions to position sort.
Frontend (F2/F3/F6):
- onIssuePropertiesChanged invalidates plain assignee-group caches too.
- Property value mutations cancel list refetches in onMutate and roll back
only the touched key against the current bag (concurrent WS writes to
other keys survive a failed write).
- useUpdateIssue reconcile drops the stale properties bag from the server
snapshot; the property pipeline owns that field.
- Surface controller passes persisted property filters/sorts through
until the catalog query settles (cold cache no longer strips them).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): open_only branch honors the properties filter
ListOpenIssues takes the parsed AND-of-ORs containment groups as a single
jsonb properties_filter param and unrolls them with a static double
NOT EXISTS; previously the open_only path parsed the properties param and
silently dropped it (clean-room review F7a).
Co-authored-by: multica-agent <github@multica.ai>
* fix(properties): toast on failed board drag to a property column
Property-column drags rolled the card back silently on failure; mirror
the status/assignee drag path (use-issue-surface-actions) so the
snap-back is explained (clean-room review F3, drag half).
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
1541 lines
58 KiB
TypeScript
1541 lines
58 KiB
TypeScript
"use client";
|
|
|
|
import { useMemo, useState } from "react";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
CalendarDays,
|
|
ChartGantt,
|
|
ChevronDown,
|
|
CircleDot,
|
|
Columns3,
|
|
Filter,
|
|
FolderKanban,
|
|
FolderMinus,
|
|
List,
|
|
SignalHigh,
|
|
SlidersHorizontal,
|
|
X,
|
|
Tag,
|
|
User,
|
|
UserMinus,
|
|
UserPen,
|
|
Waves,
|
|
} from "lucide-react";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Spinner } from "@multica/ui/components/ui/spinner";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuRadioGroup,
|
|
DropdownMenuRadioItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuSub,
|
|
DropdownMenuSubTrigger,
|
|
DropdownMenuSubContent,
|
|
} from "@multica/ui/components/ui/dropdown-menu";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "@multica/ui/components/ui/popover";
|
|
import { Calendar } from "@multica/ui/components/ui/calendar";
|
|
import { Switch } from "@multica/ui/components/ui/switch";
|
|
import {
|
|
ALL_STATUSES,
|
|
PRIORITY_ORDER,
|
|
} from "@multica/core/issues/config";
|
|
import { StatusIcon, PriorityIcon } from ".";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { memberListOptions, agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
|
import { projectListOptions } from "@multica/core/projects/queries";
|
|
import { labelListOptions } from "@multica/core/labels/queries";
|
|
import { propertyListOptions } from "@multica/core/properties";
|
|
import { propertyIdFromViewKey } from "@multica/core/issues/stores/view-store";
|
|
import type { IssueProperty } from "@multica/core/types";
|
|
import { ProjectIcon } from "../../projects/components/project-icon";
|
|
import { ActorAvatar } from "../../common/actor-avatar";
|
|
import { LabelChip } from "../../labels/label-chip";
|
|
import {
|
|
SORT_OPTIONS,
|
|
GROUPING_OPTIONS,
|
|
SWIMLANE_GROUPINGS,
|
|
CARD_PROPERTY_OPTIONS,
|
|
type ActorFilterValue,
|
|
type IssueDateField,
|
|
type IssueDateFilter,
|
|
type SortField,
|
|
type IssueGrouping,
|
|
type SwimlaneGrouping,
|
|
type ViewMode,
|
|
} from "@multica/core/issues/stores/view-store";
|
|
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
|
|
import { addDaysDateOnly, dateOnlyToLocalDate, formatDateOnly, toDateOnly, todayDateOnly } from "@multica/core/issues/date";
|
|
import {
|
|
useIssuesScopeStore,
|
|
type IssuesScope,
|
|
} from "@multica/core/issues/stores/issues-scope-store";
|
|
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
|
import type { Issue } from "@multica/core/types";
|
|
import { useT } from "../../i18n";
|
|
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
|
|
import { FILTER_ITEM_CLASS, HoverCheck } from "../../common/hover-check";
|
|
import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip";
|
|
|
|
type LocalDateRange = {
|
|
from: Date | undefined;
|
|
to?: Date;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function getActiveFilterCount(state: {
|
|
statusFilters: string[];
|
|
priorityFilters: string[];
|
|
assigneeFilters: ActorFilterValue[];
|
|
includeNoAssignee: boolean;
|
|
creatorFilters: ActorFilterValue[];
|
|
projectFilters: string[];
|
|
includeNoProject: boolean;
|
|
labelFilters: string[];
|
|
propertyFilters?: Record<string, string[]>;
|
|
dateFilter?: IssueDateFilter | null;
|
|
}) {
|
|
let count = 0;
|
|
if (state.statusFilters.length > 0) count++;
|
|
if (state.priorityFilters.length > 0) count++;
|
|
if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++;
|
|
if (state.creatorFilters.length > 0) count++;
|
|
if (state.projectFilters.length > 0 || state.includeNoProject) count++;
|
|
if (state.labelFilters.length > 0) count++;
|
|
for (const selected of Object.values(state.propertyFilters ?? {})) {
|
|
if (selected.length > 0) count++;
|
|
}
|
|
if (state.dateFilter) count++;
|
|
return count;
|
|
}
|
|
|
|
function shortDateLabel(dateOnly: string) {
|
|
return formatDateOnly(dateOnly, { month: "short", day: "numeric" }) || dateOnly;
|
|
}
|
|
|
|
function normalizeDateRange(from: Date, to: Date) {
|
|
return from <= to ? [from, to] as const : [to, from] as const;
|
|
}
|
|
|
|
const DATE_FIELD_LABEL_KEY: Record<IssueDateField, "date_field_created" | "date_field_updated"> = {
|
|
created_at: "date_field_created",
|
|
updated_at: "date_field_updated",
|
|
};
|
|
|
|
function useIssueCounts(allIssues: Issue[]) {
|
|
return useMemo(() => {
|
|
const status = new Map<string, number>();
|
|
const priority = new Map<string, number>();
|
|
const assignee = new Map<string, number>();
|
|
const creator = new Map<string, number>();
|
|
const project = new Map<string, number>();
|
|
const label = new Map<string, number>();
|
|
// property definition id → option key → count. Checkbox values count
|
|
// under the "true"/"false" pseudo-option keys the filter store uses.
|
|
const property = new Map<string, Map<string, number>>();
|
|
let noAssignee = 0;
|
|
let noProject = 0;
|
|
|
|
for (const issue of allIssues) {
|
|
status.set(issue.status, (status.get(issue.status) ?? 0) + 1);
|
|
priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1);
|
|
|
|
if (!issue.assignee_id) {
|
|
noAssignee++;
|
|
} else {
|
|
const aKey = `${issue.assignee_type}:${issue.assignee_id}`;
|
|
assignee.set(aKey, (assignee.get(aKey) ?? 0) + 1);
|
|
}
|
|
|
|
const cKey = `${issue.creator_type}:${issue.creator_id}`;
|
|
creator.set(cKey, (creator.get(cKey) ?? 0) + 1);
|
|
|
|
if (!issue.project_id) {
|
|
noProject++;
|
|
} else {
|
|
project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1);
|
|
}
|
|
|
|
if (issue.labels) {
|
|
for (const l of issue.labels) {
|
|
label.set(l.id, (label.get(l.id) ?? 0) + 1);
|
|
}
|
|
}
|
|
|
|
for (const [propertyId, value] of Object.entries(issue.properties ?? {})) {
|
|
const optionKeys =
|
|
typeof value === "string"
|
|
? [value]
|
|
: Array.isArray(value)
|
|
? value
|
|
: typeof value === "boolean"
|
|
? [String(value)]
|
|
: [];
|
|
if (optionKeys.length === 0) continue;
|
|
let perOption = property.get(propertyId);
|
|
if (!perOption) {
|
|
perOption = new Map<string, number>();
|
|
property.set(propertyId, perOption);
|
|
}
|
|
for (const key of optionKeys) {
|
|
perOption.set(key, (perOption.get(key) ?? 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { status, priority, assignee, creator, noAssignee, project, noProject, label, property };
|
|
}, [allIssues]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scope config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SCOPE_VALUES: IssuesScope[] = ["all", "members", "agents"];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actor sub-menu content (shared between Assignee and Creator)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ActorSubContent({
|
|
counts,
|
|
selected,
|
|
onToggle,
|
|
showNoAssignee,
|
|
includeNoAssignee,
|
|
onToggleNoAssignee,
|
|
noAssigneeCount,
|
|
showSquads = true,
|
|
}: {
|
|
counts: Map<string, number>;
|
|
selected: ActorFilterValue[];
|
|
onToggle: (value: ActorFilterValue) => void;
|
|
showNoAssignee?: boolean;
|
|
includeNoAssignee?: boolean;
|
|
onToggleNoAssignee?: () => void;
|
|
noAssigneeCount?: number;
|
|
showSquads?: boolean;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [search, setSearch] = useState("");
|
|
const wsId = useWorkspaceId();
|
|
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
|
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
|
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
|
const query = search.trim().toLowerCase();
|
|
const filteredMembers = members.filter((m) =>
|
|
m.name.toLowerCase().includes(query) || matchesPinyin(m.name, query),
|
|
);
|
|
const filteredAgents = agents.filter((a) =>
|
|
!a.archived_at && (a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query)),
|
|
);
|
|
const filteredSquads = squads.filter((s) =>
|
|
!s.archived_at && (s.name.toLowerCase().includes(query) || matchesPinyin(s.name, query)),
|
|
);
|
|
|
|
const isSelected = (type: "member" | "agent" | "squad", id: string) =>
|
|
selected.some((f) => f.type === type && f.id === id);
|
|
|
|
return (
|
|
<>
|
|
<div className="px-2 py-1.5 border-b border-foreground/5">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t(($) => $.filters.placeholder)}
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-64 overflow-y-auto p-1">
|
|
{showNoAssignee &&
|
|
(!query || "no assignee".includes(query) || "unassigned".includes(query)) && (
|
|
<DropdownMenuCheckboxItem
|
|
checked={includeNoAssignee ?? false}
|
|
onCheckedChange={() => onToggleNoAssignee?.()}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={includeNoAssignee ?? false} />
|
|
<UserMinus className="size-3.5 text-muted-foreground" />
|
|
{t(($) => $.filters.no_assignee)}
|
|
{(noAssigneeCount ?? 0) > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{noAssigneeCount}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
)}
|
|
|
|
{filteredMembers.length > 0 && (
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t(($) => $.filters.members_group)}</DropdownMenuLabel>
|
|
{filteredMembers.map((m) => {
|
|
const checked = isSelected("member", m.user_id);
|
|
const count = counts.get(`member:${m.user_id}`) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={m.user_id}
|
|
checked={checked}
|
|
onCheckedChange={() =>
|
|
onToggle({ type: "member", id: m.user_id })
|
|
}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<ActorAvatar actorType="member" actorId={m.user_id} size="sm" />
|
|
<span className="truncate">{m.name}</span>
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuGroup>
|
|
)}
|
|
|
|
{filteredAgents.length > 0 && (
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t(($) => $.filters.agents_group)}</DropdownMenuLabel>
|
|
{filteredAgents.map((a) => {
|
|
const checked = isSelected("agent", a.id);
|
|
const count = counts.get(`agent:${a.id}`) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={a.id}
|
|
checked={checked}
|
|
onCheckedChange={() =>
|
|
onToggle({ type: "agent", id: a.id })
|
|
}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<ActorAvatar actorType="agent" actorId={a.id} size="sm" showStatusDot />
|
|
<span className="truncate">{a.name}</span>
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuGroup>
|
|
)}
|
|
|
|
{showSquads && filteredSquads.length > 0 && (
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t(($) => $.filters.squads_group)}</DropdownMenuLabel>
|
|
{filteredSquads.map((s) => {
|
|
const checked = isSelected("squad", s.id);
|
|
const count = counts.get(`squad:${s.id}`) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={s.id}
|
|
checked={checked}
|
|
onCheckedChange={() =>
|
|
onToggle({ type: "squad", id: s.id })
|
|
}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<ActorAvatar actorType="squad" actorId={s.id} size="sm" />
|
|
<span className="truncate">{s.name}</span>
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuGroup>
|
|
)}
|
|
|
|
{filteredMembers.length === 0 && filteredAgents.length === 0 && (!showSquads || filteredSquads.length === 0) && search && (
|
|
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
|
{t(($) => $.filters.no_results)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Project sub-menu content
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ProjectSubContent({
|
|
counts,
|
|
selected,
|
|
onToggle,
|
|
includeNoProject,
|
|
onToggleNoProject,
|
|
noProjectCount,
|
|
}: {
|
|
counts: Map<string, number>;
|
|
selected: string[];
|
|
onToggle: (projectId: string) => void;
|
|
includeNoProject: boolean;
|
|
onToggleNoProject: () => void;
|
|
noProjectCount: number;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [search, setSearch] = useState("");
|
|
const wsId = useWorkspaceId();
|
|
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
|
const query = search.trim().toLowerCase();
|
|
const filtered = projects.filter((p) =>
|
|
p.title.toLowerCase().includes(query),
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="px-2 py-1.5 border-b border-foreground/5">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t(($) => $.filters.placeholder)}
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-64 overflow-y-auto p-1">
|
|
{(!query || "no project".includes(query) || "unassigned".includes(query)) && (
|
|
<DropdownMenuCheckboxItem
|
|
checked={includeNoProject}
|
|
onCheckedChange={() => onToggleNoProject()}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={includeNoProject} />
|
|
<FolderMinus className="size-3.5 text-muted-foreground" />
|
|
{t(($) => $.filters.no_project)}
|
|
{noProjectCount > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{noProjectCount}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
)}
|
|
|
|
{filtered.map((p) => {
|
|
const checked = selected.includes(p.id);
|
|
const count = counts.get(p.id) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={p.id}
|
|
checked={checked}
|
|
onCheckedChange={() => onToggle(p.id)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<ProjectIcon project={p} size="sm" />
|
|
<span className="truncate">{p.title}</span>
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
|
|
{filtered.length === 0 && search && (
|
|
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
|
{t(($) => $.filters.no_results)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Label sub-menu content
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function LabelSubContent({
|
|
counts,
|
|
selected,
|
|
onToggle,
|
|
}: {
|
|
counts: Map<string, number>;
|
|
selected: string[];
|
|
onToggle: (labelId: string) => void;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [search, setSearch] = useState("");
|
|
const wsId = useWorkspaceId();
|
|
const { data: labels = [] } = useQuery(labelListOptions(wsId));
|
|
const query = search.trim().toLowerCase();
|
|
const filtered = labels.filter((l) => l.name.toLowerCase().includes(query));
|
|
|
|
return (
|
|
<>
|
|
<div className="px-2 py-1.5 border-b border-foreground/5">
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t(($) => $.filters.placeholder)}
|
|
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="max-h-64 overflow-y-auto p-1">
|
|
{filtered.map((l) => {
|
|
const checked = selected.includes(l.id);
|
|
const count = counts.get(l.id) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={l.id}
|
|
checked={checked}
|
|
onCheckedChange={() => onToggle(l.id)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<LabelChip label={l} />
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
|
|
{filtered.length === 0 && (
|
|
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
|
{search ? t(($) => $.filters.no_results) : t(($) => $.filters.no_labels)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Option checkboxes for one custom property inside the Filter dropdown.
|
|
* Select/multi_select list the definition's options (dot + name); checkbox
|
|
* definitions expose the "true"/"false" pseudo-options with Yes/No labels.
|
|
*/
|
|
function PropertyFilterOptions({
|
|
property,
|
|
counts,
|
|
selected,
|
|
onToggle,
|
|
}: {
|
|
property: IssueProperty;
|
|
counts: Map<string, number> | undefined;
|
|
selected: string[];
|
|
onToggle: (optionId: string) => void;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const options =
|
|
property.type === "checkbox"
|
|
? [
|
|
{ id: "true", name: t(($) => $.pickers.custom_property.true_label), color: undefined },
|
|
{ id: "false", name: t(($) => $.pickers.custom_property.false_label), color: undefined },
|
|
]
|
|
: (property.config.options ?? []).map((option) => ({
|
|
id: option.id,
|
|
name: option.name,
|
|
color: option.color as string | undefined,
|
|
}));
|
|
|
|
return (
|
|
<>
|
|
{options.map((option) => {
|
|
const checked = selected.includes(option.id);
|
|
const count = counts?.get(option.id) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={option.id}
|
|
checked={checked}
|
|
onCheckedChange={() => onToggle(option.id)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
{option.color && (
|
|
<span
|
|
className="size-2.5 shrink-0 rounded-full"
|
|
style={{ backgroundColor: option.color }}
|
|
/>
|
|
)}
|
|
<span className="truncate">{option.name}</span>
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{count}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Date sub-menu content
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DateSubContent({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: IssueDateFilter | null;
|
|
onChange: (filter: IssueDateFilter | null) => void;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const [field, setField] = useState<IssueDateField>(value?.field ?? "created_at");
|
|
const [range, setRange] = useState<LocalDateRange | undefined>(() => {
|
|
if (!value) return undefined;
|
|
const from = dateOnlyToLocalDate(value.from);
|
|
if (!from) return undefined;
|
|
return { from, to: dateOnlyToLocalDate(value.to) };
|
|
});
|
|
|
|
const setFieldValue = (next: IssueDateField) => {
|
|
setField(next);
|
|
if (value) onChange({ ...value, field: next });
|
|
};
|
|
|
|
const applyPreset = (days: 1 | 3 | 7) => {
|
|
onChange({
|
|
field,
|
|
from: addDaysDateOnly(1 - days),
|
|
to: todayDateOnly(),
|
|
});
|
|
};
|
|
|
|
const applyCustom = () => {
|
|
if (!range?.from) return;
|
|
const [from, to] = normalizeDateRange(range.from, range.to ?? range.from);
|
|
onChange({
|
|
field,
|
|
from: toDateOnly(from),
|
|
to: toDateOnly(to),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t(($) => $.filters.date_field)}</DropdownMenuLabel>
|
|
<DropdownMenuRadioGroup value={field} onValueChange={(next) => setFieldValue(next as IssueDateField)}>
|
|
{(["created_at", "updated_at"] as const).map((option) => (
|
|
<DropdownMenuRadioItem key={option} value={option}>
|
|
{t(($) => $.filters[DATE_FIELD_LABEL_KEY[option]])}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuGroup>
|
|
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => applyPreset(1)}>
|
|
{t(($) => $.filters.date_today)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => applyPreset(3)}>
|
|
{t(($) => $.filters.date_last_3_days)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => applyPreset(7)}>
|
|
{t(($) => $.filters.date_last_7_days)}
|
|
</DropdownMenuItem>
|
|
|
|
<div className="px-1.5 py-1">
|
|
<Popover>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 w-full justify-start px-0 text-sm font-normal"
|
|
>
|
|
{t(($) => $.filters.date_custom_range)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" side="right" className="w-auto gap-0 p-0">
|
|
<Calendar
|
|
mode="range"
|
|
selected={range}
|
|
onSelect={(next) => setRange(next)}
|
|
captionLayout="dropdown"
|
|
/>
|
|
<div className="flex justify-end border-t p-2">
|
|
<Button size="sm" onClick={applyCustom} disabled={!range?.from}>
|
|
{t(($) => $.filters.date_apply)}
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
|
|
{value && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
setRange(undefined);
|
|
onChange(null);
|
|
}}
|
|
>
|
|
{t(($) => $.filters.date_clear)}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ViewRefreshIndicator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Neutral "view is revalidating" slot for the header controls cluster.
|
|
* Driven by the surface's isRefreshing (placeholder-backed revalidation:
|
|
* sort/date change, grouped-board filter change) — trigger-agnostic on
|
|
* purpose, so any view change that puts a request in flight lights it.
|
|
*
|
|
* The slot is fixed-width so appearing/disappearing never shifts the
|
|
* controls; the 300ms appear delay keeps fast networks indicator-free
|
|
* (NN/g: sub-second responses need no feedback) while slow ones get a
|
|
* "working on it" signal instead of a dead click.
|
|
*/
|
|
export function ViewRefreshIndicator({ active }: { active: boolean }) {
|
|
return (
|
|
<span className="flex w-4 shrink-0 items-center justify-center">
|
|
{active && (
|
|
<span className="animate-in fade-in fill-mode-backwards [animation-delay:300ms]">
|
|
<Spinner className="size-3.5 text-muted-foreground" />
|
|
</span>
|
|
)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// IssuesHeader
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function IssuesHeader({
|
|
scopedIssues,
|
|
allowGantt = false,
|
|
dateFilter = null,
|
|
onDateFilterChange,
|
|
isRefreshing = false,
|
|
}: {
|
|
scopedIssues: Issue[];
|
|
allowGantt?: boolean;
|
|
dateFilter?: IssueDateFilter | null;
|
|
onDateFilterChange?: (filter: IssueDateFilter | null) => void;
|
|
isRefreshing?: boolean;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const scope = useIssuesScopeStore((s) => s.scope);
|
|
const setScope = useIssuesScopeStore((s) => s.setScope);
|
|
// Bind the workspace agents-working chip to the active view store so
|
|
// shared IssuesHeader consumers (/issues and project detail) toggle the
|
|
// same filter state as the rest of the display controls. /my-issues keeps
|
|
// its own sibling header and passes chip state explicitly.
|
|
const agentRunningFilter = useViewStore((s) => s.agentRunningFilter);
|
|
const toggleAgentRunningFilter = useViewStore(
|
|
(s) => s.toggleAgentRunningFilter,
|
|
);
|
|
// Scope the chip to whatever issues this page is currently showing.
|
|
// /issues uses the full workspace minus Members/Agents pill filtering;
|
|
// passing the visible-issue id set lets the chip count match the list
|
|
// length when the filter is on.
|
|
const scopedIssueIds = useMemo(
|
|
() => new Set(scopedIssues.map((i) => i.id)),
|
|
[scopedIssues],
|
|
);
|
|
const SCOPE_LABEL_KEY: Record<IssuesScope, "all_label" | "members_label" | "agents_label"> = {
|
|
all: "all_label",
|
|
members: "members_label",
|
|
agents: "agents_label",
|
|
};
|
|
const SCOPE_DESC_KEY: Record<IssuesScope, "all_description" | "members_description" | "agents_description"> = {
|
|
all: "all_description",
|
|
members: "members_description",
|
|
agents: "agents_description",
|
|
};
|
|
|
|
const scopeLabel = t(($) => $.scope[SCOPE_LABEL_KEY[scope]]);
|
|
|
|
return (
|
|
<div className="h-12 shrink-0 overflow-x-auto px-4 [-webkit-overflow-scrolling:touch]">
|
|
<div className="flex h-full w-max min-w-full items-center justify-between gap-2">
|
|
{/* Left: scope buttons */}
|
|
<div className="hidden shrink-0 items-center gap-1 md:flex">
|
|
{SCOPE_VALUES.map((s) => (
|
|
<Tooltip key={s}>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className={
|
|
scope === s
|
|
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
|
: "text-muted-foreground"
|
|
}
|
|
onClick={() => setScope(s)}
|
|
>
|
|
{t(($) => $.scope[SCOPE_LABEL_KEY[s]])}
|
|
</Button>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{t(($) => $.scope[SCOPE_DESC_KEY[s]])}</TooltipContent>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="shrink-0 gap-1 text-muted-foreground md:hidden"
|
|
>
|
|
<span className="truncate">{scopeLabel}</span>
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-auto">
|
|
<DropdownMenuRadioGroup value={scope} onValueChange={(value) => setScope(value as IssuesScope)}>
|
|
{SCOPE_VALUES.map((s) => (
|
|
<DropdownMenuRadioItem key={s} value={s}>
|
|
{t(($) => $.scope[SCOPE_LABEL_KEY[s]])}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{agentRunningFilter && (
|
|
<span className="mr-1 hidden text-xs text-muted-foreground md:inline">
|
|
{t(($) => $.agent_activity.filter_active_label)}
|
|
</span>
|
|
)}
|
|
<WorkspaceAgentWorkingChip
|
|
value={agentRunningFilter}
|
|
onToggle={toggleAgentRunningFilter}
|
|
scopedIssueIds={scopedIssueIds}
|
|
/>
|
|
<IssueDisplayControls
|
|
scopedIssues={scopedIssues}
|
|
allowGantt={allowGantt}
|
|
dateFilter={dateFilter}
|
|
onDateFilterChange={onDateFilterChange}
|
|
/>
|
|
<ViewRefreshIndicator active={isRefreshing} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function IssueDisplayControls({
|
|
scopedIssues,
|
|
hideViewToggle = false,
|
|
allowGantt = false,
|
|
dateFilter = null,
|
|
onDateFilterChange,
|
|
}: {
|
|
scopedIssues: Issue[];
|
|
hideViewToggle?: boolean;
|
|
dateFilter?: IssueDateFilter | null;
|
|
onDateFilterChange?: (filter: IssueDateFilter | null) => void;
|
|
// Only Project Detail renders <GanttView>; other surfaces (global /issues,
|
|
// /my-issues, actor panel) ignore viewMode === "gantt" and would silently
|
|
// fall back to List if the option were exposed there. Keep Gantt opt-in.
|
|
allowGantt?: boolean;
|
|
}) {
|
|
const { t } = useT("issues");
|
|
const viewMode = useViewStore((s) => s.viewMode);
|
|
const statusFilters = useViewStore((s) => s.statusFilters);
|
|
const priorityFilters = useViewStore((s) => s.priorityFilters);
|
|
const assigneeFilters = useViewStore((s) => s.assigneeFilters);
|
|
const includeNoAssignee = useViewStore((s) => s.includeNoAssignee);
|
|
const creatorFilters = useViewStore((s) => s.creatorFilters);
|
|
const projectFilters = useViewStore((s) => s.projectFilters);
|
|
const includeNoProject = useViewStore((s) => s.includeNoProject);
|
|
const labelFilters = useViewStore((s) => s.labelFilters);
|
|
const propertyFilters = useViewStore((s) => s.propertyFilters);
|
|
const cardPropertyIds = useViewStore((s) => s.cardPropertyIds);
|
|
const sortBy = useViewStore((s) => s.sortBy);
|
|
const sortDirection = useViewStore((s) => s.sortDirection);
|
|
const grouping = useViewStore((s) => s.grouping);
|
|
const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping);
|
|
const cardProperties = useViewStore((s) => s.cardProperties);
|
|
const showSubIssues = useViewStore((s) => s.showSubIssues);
|
|
const act = useViewStoreApi().getState();
|
|
const headerWsId = useWorkspaceId();
|
|
// Active custom-property catalog: drives the filter sections, dynamic
|
|
// sort/grouping options, and the card-property toggles below.
|
|
const { data: workspaceProperties = [] } = useQuery(propertyListOptions(headerWsId));
|
|
const propertyById = useMemo(
|
|
() => new Map(workspaceProperties.map((p) => [p.id, p])),
|
|
[workspaceProperties],
|
|
);
|
|
const filterableProperties = useMemo(
|
|
() =>
|
|
workspaceProperties.filter((p) =>
|
|
p.type === "select" || p.type === "multi_select" || p.type === "checkbox",
|
|
),
|
|
[workspaceProperties],
|
|
);
|
|
const sortableProperties = useMemo(
|
|
() => workspaceProperties.filter((p) => p.type === "number" || p.type === "date"),
|
|
[workspaceProperties],
|
|
);
|
|
const groupableProperties = useMemo(
|
|
() => workspaceProperties.filter((p) => p.type === "select"),
|
|
[workspaceProperties],
|
|
);
|
|
|
|
const counts = useIssueCounts(scopedIssues);
|
|
const showDateFilter = !!onDateFilterChange;
|
|
|
|
// Only count filters whose definition is still active — a filter pinned to
|
|
// an archived definition is stripped by the surface controller and must
|
|
// not light the badge for an effect that no longer exists.
|
|
const effectivePropertyFilters = useMemo(() => {
|
|
const activeIds = new Set(filterableProperties.map((p) => p.id));
|
|
return Object.fromEntries(
|
|
Object.entries(propertyFilters).filter(([id, selected]) => selected.length > 0 && activeIds.has(id)),
|
|
);
|
|
}, [filterableProperties, propertyFilters]);
|
|
|
|
const activeFilterCount = getActiveFilterCount({
|
|
statusFilters,
|
|
priorityFilters,
|
|
propertyFilters: effectivePropertyFilters,
|
|
assigneeFilters,
|
|
includeNoAssignee,
|
|
creatorFilters,
|
|
projectFilters,
|
|
includeNoProject,
|
|
labelFilters,
|
|
dateFilter: showDateFilter ? dateFilter : null,
|
|
});
|
|
const hasActiveFilters = activeFilterCount > 0;
|
|
|
|
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_start_date" | "sort_due_date" | "sort_created" | "sort_title"> = {
|
|
position: "sort_manual",
|
|
priority: "sort_priority",
|
|
start_date: "sort_start_date",
|
|
due_date: "sort_due_date",
|
|
created_at: "sort_created",
|
|
title: "sort_title",
|
|
};
|
|
const GROUPING_LABEL_KEY: Record<typeof GROUPING_OPTIONS[number]["value"], "group_status" | "group_assignee"> = {
|
|
status: "group_status",
|
|
assignee: "group_assignee",
|
|
};
|
|
const SWIMLANE_GROUPING_LABEL_KEY: Record<SwimlaneGrouping, "group_parent" | "group_project" | "group_assignee"> = {
|
|
parent: "group_parent",
|
|
project: "group_project",
|
|
assignee: "group_assignee",
|
|
};
|
|
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_start_date" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
|
|
priority: "card_priority",
|
|
description: "card_description",
|
|
assignee: "card_assignee",
|
|
startDate: "card_start_date",
|
|
dueDate: "card_due_date",
|
|
project: "card_project",
|
|
labels: "card_labels",
|
|
childProgress: "card_child_progress",
|
|
};
|
|
const dateFilterLabel = showDateFilter && dateFilter
|
|
? `${t(($) => $.filters[DATE_FIELD_LABEL_KEY[dateFilter.field]])}: ${
|
|
dateFilter.from === dateFilter.to
|
|
? shortDateLabel(dateFilter.from)
|
|
: `${shortDateLabel(dateFilter.from)} - ${shortDateLabel(dateFilter.to)}`
|
|
}`
|
|
: null;
|
|
const sortPropertyId = propertyIdFromViewKey(sortBy);
|
|
const groupingPropertyId = propertyIdFromViewKey(grouping);
|
|
// Property-backed sort/grouping label straight from the definition name;
|
|
// a stale persisted id (archived/deleted definition) falls back to the
|
|
// manual/status default label.
|
|
const sortLabel = sortPropertyId
|
|
? propertyById.get(sortPropertyId)?.name ?? t(($) => $.display.sort_manual)
|
|
: t(($) => $.display[SORT_LABEL_KEY[sortBy as keyof typeof SORT_LABEL_KEY]]);
|
|
const groupingLabel = groupingPropertyId
|
|
? propertyById.get(groupingPropertyId)?.name ?? t(($) => $.display.group_status)
|
|
: t(($) => $.display[GROUPING_LABEL_KEY[grouping as keyof typeof GROUPING_LABEL_KEY]]);
|
|
const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]);
|
|
const controlButtonClass = "h-8 w-8 gap-1 px-0 text-muted-foreground md:h-7 md:w-auto md:px-2.5";
|
|
|
|
return (
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{/* Filter */}
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
variant={hasActiveFilters ? "default" : "outline"}
|
|
size="sm"
|
|
className={
|
|
hasActiveFilters
|
|
? "h-8 w-8 gap-1 bg-brand px-0 text-white hover:bg-brand/90 md:h-7 md:w-auto md:px-2.5"
|
|
: controlButtonClass
|
|
}
|
|
>
|
|
<Filter className="size-3.5" />
|
|
<span className="hidden md:inline">
|
|
{hasActiveFilters
|
|
? t(($) => $.filters.active_count, { count: activeFilterCount })
|
|
: t(($) => $.filters.tooltip)}
|
|
</span>
|
|
{hasActiveFilters && (
|
|
<span className="tabular-nums md:hidden">{activeFilterCount}</span>
|
|
)}
|
|
{hasActiveFilters && (
|
|
<span
|
|
role="button"
|
|
tabIndex={-1}
|
|
className="-mr-1 ml-0.5 hidden rounded-sm p-0.5 hover:bg-white/20 md:inline-flex"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
act.clearFilters();
|
|
onDateFilterChange?.(null);
|
|
}}
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
>
|
|
<X className="size-3" />
|
|
</span>
|
|
)}
|
|
</Button>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{t(($) => $.filters.tooltip)}</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end" className="w-auto">
|
|
{/* Status */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<CircleDot className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_status)}</span>
|
|
{statusFilters.length > 0 && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{statusFilters.length}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-48">
|
|
{ALL_STATUSES.map((s) => {
|
|
const checked = statusFilters.includes(s);
|
|
const count = counts.status.get(s) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={s}
|
|
checked={checked}
|
|
onCheckedChange={() => act.toggleStatusFilter(s)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
|
{t(($) => $.status[s])}
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{t(($) => $.filters.issue_count, { count })}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
{/* Priority */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<SignalHigh className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_priority)}</span>
|
|
{priorityFilters.length > 0 && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{priorityFilters.length}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-44">
|
|
{PRIORITY_ORDER.map((p) => {
|
|
const checked = priorityFilters.includes(p);
|
|
const count = counts.priority.get(p) ?? 0;
|
|
return (
|
|
<DropdownMenuCheckboxItem
|
|
key={p}
|
|
checked={checked}
|
|
onCheckedChange={() => act.togglePriorityFilter(p)}
|
|
className={FILTER_ITEM_CLASS}
|
|
>
|
|
<HoverCheck checked={checked} />
|
|
<PriorityIcon priority={p} />
|
|
{t(($) => $.priority[p])}
|
|
{count > 0 && (
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{t(($) => $.filters.issue_count, { count })}
|
|
</span>
|
|
)}
|
|
</DropdownMenuCheckboxItem>
|
|
);
|
|
})}
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
{showDateFilter && onDateFilterChange && (
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<CalendarDays className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_date)}</span>
|
|
{dateFilterLabel && (
|
|
<span className="max-w-36 truncate text-xs text-primary font-medium">
|
|
{dateFilterLabel}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-56">
|
|
<DateSubContent
|
|
value={dateFilter}
|
|
onChange={onDateFilterChange}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
)}
|
|
|
|
{/* Assignee */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<User className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_assignee)}</span>
|
|
{(assigneeFilters.length > 0 || includeNoAssignee) && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{assigneeFilters.length + (includeNoAssignee ? 1 : 0)}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
|
<ActorSubContent
|
|
counts={counts.assignee}
|
|
selected={assigneeFilters}
|
|
onToggle={act.toggleAssigneeFilter}
|
|
showNoAssignee
|
|
includeNoAssignee={includeNoAssignee}
|
|
onToggleNoAssignee={act.toggleNoAssignee}
|
|
noAssigneeCount={counts.noAssignee}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
{/* Creator */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<UserPen className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_creator)}</span>
|
|
{creatorFilters.length > 0 && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{creatorFilters.length}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
|
<ActorSubContent
|
|
counts={counts.creator}
|
|
selected={creatorFilters}
|
|
onToggle={act.toggleCreatorFilter}
|
|
showSquads={false}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
{/* Project */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<FolderKanban className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_project)}</span>
|
|
{(projectFilters.length > 0 || includeNoProject) && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{projectFilters.length + (includeNoProject ? 1 : 0)}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
|
<ProjectSubContent
|
|
counts={counts.project}
|
|
selected={projectFilters}
|
|
onToggle={act.toggleProjectFilter}
|
|
includeNoProject={includeNoProject}
|
|
onToggleNoProject={act.toggleNoProject}
|
|
noProjectCount={counts.noProject}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
{/* Label */}
|
|
<DropdownMenuSub>
|
|
<DropdownMenuSubTrigger>
|
|
<Tag className="size-3.5" />
|
|
<span className="flex-1">{t(($) => $.filters.section_label)}</span>
|
|
{labelFilters.length > 0 && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{labelFilters.length}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-52 p-0">
|
|
<LabelSubContent
|
|
counts={counts.label}
|
|
selected={labelFilters}
|
|
onToggle={act.toggleLabelFilter}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
|
|
{/* Custom properties — one sub per filterable definition */}
|
|
{filterableProperties.length > 0 && <DropdownMenuSeparator />}
|
|
{filterableProperties.map((property) => {
|
|
const selected = propertyFilters[property.id] ?? [];
|
|
return (
|
|
<DropdownMenuSub key={property.id}>
|
|
<DropdownMenuSubTrigger>
|
|
<SlidersHorizontal className="size-3.5" />
|
|
<span className="flex-1 truncate">{property.name}</span>
|
|
{selected.length > 0 && (
|
|
<span className="text-xs text-primary font-medium">
|
|
{selected.length}
|
|
</span>
|
|
)}
|
|
</DropdownMenuSubTrigger>
|
|
<DropdownMenuSubContent className="w-auto min-w-44 p-1">
|
|
<PropertyFilterOptions
|
|
property={property}
|
|
counts={counts.property.get(property.id)}
|
|
selected={selected}
|
|
onToggle={(optionId) => act.togglePropertyFilter(property.id, optionId)}
|
|
/>
|
|
</DropdownMenuSubContent>
|
|
</DropdownMenuSub>
|
|
);
|
|
})}
|
|
|
|
{/* Reset */}
|
|
{hasActiveFilters && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
act.clearFilters();
|
|
onDateFilterChange?.(null);
|
|
}}
|
|
>
|
|
{t(($) => $.filters.reset)}
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Display settings */}
|
|
<Popover>
|
|
<Tooltip>
|
|
<PopoverTrigger
|
|
render={
|
|
<TooltipTrigger
|
|
render={
|
|
<Button variant="outline" size="sm" className={controlButtonClass}>
|
|
{sortBy === "position" ? (
|
|
<SlidersHorizontal className="size-3.5" />
|
|
) : sortDirection === "asc" ? (
|
|
<ArrowUp className="size-3.5" />
|
|
) : (
|
|
<ArrowDown className="size-3.5" />
|
|
)}
|
|
<span className="hidden md:inline">{sortLabel}</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">{t(($) => $.display.tooltip)}</TooltipContent>
|
|
</Tooltip>
|
|
<PopoverContent align="end" className="w-64 p-0">
|
|
{viewMode === "board" && (
|
|
<div className="border-b px-3 py-2.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.display.grouping_section)}
|
|
</span>
|
|
<div className="mt-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-between text-xs"
|
|
>
|
|
{groupingLabel}
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-auto">
|
|
<DropdownMenuRadioGroup value={grouping} onValueChange={(v) => act.setGrouping(v as IssueGrouping)}>
|
|
{GROUPING_OPTIONS.map((opt) => (
|
|
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
|
{t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
{groupableProperties.map((property) => (
|
|
<DropdownMenuRadioItem key={property.id} value={`property:${property.id}`}>
|
|
{property.name}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{viewMode === "swimlane" && (
|
|
<div className="border-b px-3 py-2.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.display.grouping_section)}
|
|
</span>
|
|
<div className="mt-2">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-between text-xs"
|
|
>
|
|
{swimlaneGroupingLabel}
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-auto">
|
|
<DropdownMenuRadioGroup
|
|
value={swimlaneGrouping}
|
|
onValueChange={(v) => act.setSwimlaneGrouping(v as SwimlaneGrouping)}
|
|
>
|
|
{SWIMLANE_GROUPINGS.map((value) => (
|
|
<DropdownMenuRadioItem key={value} value={value}>
|
|
{t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[value]])}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="border-b px-3 py-2.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.display.ordering_section)}
|
|
</span>
|
|
<div className="mt-2 flex items-center gap-1.5">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex-1 justify-between text-xs"
|
|
>
|
|
{sortLabel}
|
|
<ChevronDown className="size-3 text-muted-foreground" />
|
|
</Button>
|
|
}
|
|
/>
|
|
<DropdownMenuContent align="start" className="w-auto">
|
|
<DropdownMenuRadioGroup value={sortBy} onValueChange={(v) => act.setSortBy(v as SortField)}>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
|
|
{t(($) => $.display[SORT_LABEL_KEY[opt.value as keyof typeof SORT_LABEL_KEY]])}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
{sortableProperties.map((property) => (
|
|
<DropdownMenuRadioItem key={property.id} value={`property:${property.id}`}>
|
|
{property.name}
|
|
</DropdownMenuRadioItem>
|
|
))}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{sortBy !== "position" && (
|
|
<Button
|
|
variant="outline"
|
|
size="icon-sm"
|
|
onClick={() =>
|
|
act.setSortDirection(sortDirection === "asc" ? "desc" : "asc")
|
|
}
|
|
title={sortDirection === "asc" ? t(($) => $.display.ascending_title) : t(($) => $.display.descending_title)}
|
|
>
|
|
{sortDirection === "asc" ? (
|
|
<ArrowUp className="size-3.5" />
|
|
) : (
|
|
<ArrowDown className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<label className="flex cursor-pointer items-center justify-between border-b px-3 py-2.5">
|
|
<span className="text-sm">{t(($) => $.display.show_sub_issues)}</span>
|
|
<Switch
|
|
size="sm"
|
|
checked={showSubIssues}
|
|
onCheckedChange={() => act.toggleShowSubIssues()}
|
|
/>
|
|
</label>
|
|
|
|
<div className="px-3 py-2.5">
|
|
<span className="text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.display.card_properties_section)}
|
|
</span>
|
|
<div className="mt-2 space-y-2">
|
|
{CARD_PROPERTY_OPTIONS.map((opt) => (
|
|
<label
|
|
key={opt.key}
|
|
className="flex cursor-pointer items-center justify-between"
|
|
>
|
|
<span className="text-sm">{t(($) => $.display[CARD_PROPERTY_LABEL_KEY[opt.key]])}</span>
|
|
<Switch
|
|
size="sm"
|
|
checked={cardProperties[opt.key]}
|
|
onCheckedChange={() => act.toggleCardProperty(opt.key)}
|
|
/>
|
|
</label>
|
|
))}
|
|
{workspaceProperties.map((property) => (
|
|
<label
|
|
key={property.id}
|
|
className="flex cursor-pointer items-center justify-between"
|
|
>
|
|
<span className="truncate text-sm">{property.name}</span>
|
|
<Switch
|
|
size="sm"
|
|
checked={cardPropertyIds.includes(property.id)}
|
|
onCheckedChange={() => act.toggleCardPropertyId(property.id)}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
|
|
{/* View toggle. If a store has `viewMode === "gantt"` persisted but
|
|
this surface doesn't render Gantt, fall back to "list" so the
|
|
trigger icon matches what's actually on screen. */}
|
|
{!hideViewToggle && (
|
|
<DropdownMenu>
|
|
<Tooltip>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<TooltipTrigger
|
|
render={
|
|
<Button variant="outline" size="sm" className={controlButtonClass}>
|
|
{viewMode === "board" ? (
|
|
<Columns3 className="size-3.5" />
|
|
) : viewMode === "swimlane" ? (
|
|
<Waves className="size-3.5" />
|
|
) : viewMode === "gantt" && allowGantt ? (
|
|
<ChartGantt className="size-3.5" />
|
|
) : (
|
|
<List className="size-3.5" />
|
|
)}
|
|
<span className="hidden md:inline">
|
|
{viewMode === "board"
|
|
? t(($) => $.view.board)
|
|
: viewMode === "swimlane"
|
|
? t(($) => $.view.swimlane)
|
|
: viewMode === "gantt" && allowGantt
|
|
? t(($) => $.view.gantt)
|
|
: t(($) => $.view.list)}
|
|
</span>
|
|
</Button>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<TooltipContent side="bottom">
|
|
{viewMode === "board"
|
|
? t(($) => $.view.tooltip_board)
|
|
: viewMode === "swimlane"
|
|
? t(($) => $.view.tooltip_swimlane)
|
|
: viewMode === "gantt" && allowGantt
|
|
? t(($) => $.view.tooltip_gantt)
|
|
: t(($) => $.view.tooltip_list)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<DropdownMenuContent align="end" className="w-auto">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{t(($) => $.view.section)}</DropdownMenuLabel>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuRadioGroup value={viewMode} onValueChange={(v) => act.setViewMode(v as ViewMode)}>
|
|
<DropdownMenuRadioItem value="board">
|
|
<Columns3 />
|
|
{t(($) => $.view.board)}
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="list">
|
|
<List />
|
|
{t(($) => $.view.list)}
|
|
</DropdownMenuRadioItem>
|
|
<DropdownMenuRadioItem value="swimlane">
|
|
<Waves />
|
|
{t(($) => $.view.swimlane)}
|
|
</DropdownMenuRadioItem>
|
|
{allowGantt && (
|
|
<DropdownMenuRadioItem value="gantt">
|
|
<ChartGantt />
|
|
{t(($) => $.view.gantt)}
|
|
</DropdownMenuRadioItem>
|
|
)}
|
|
</DropdownMenuRadioGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|