From 544d148fec2d702734a2e064aa515a936d2c6de5 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:30:40 +0800 Subject: [PATCH] fix(projects,squads): projects multi-select + squads FAB clearance/toast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-list consistency audit fixes: - projects: add multi-select (checkbox column + select-all header + page-anchored batch toolbar) — it's a dozens-scale full-page list like skills/autopilots/agents but was the only one missing it. Batch ops: Pin all (any member) + Delete (workspace admin). Table view only (cards have no checkboxes). GRID template + min-width updated for the checkbox track. - squads: add the FAB bottom clearance the other full-page lists have (last row/kebab was sliding under the chat FAB). - squads: archive success toast was showing the dialog's question title ("Archive this squad?"); use a proper "Squad archived" key. Intentional and left as-is (documented): squads/runtimes have no multi-select/virtualization (1-5 rows); projects table isn't virtualized yet (dual-view + card grid; tracked as low-risk debt). Co-Authored-By: Claude Fable 5 --- packages/views/locales/en/projects.json | 5 +- packages/views/locales/en/squads.json | 3 +- packages/views/locales/ja/projects.json | 5 +- packages/views/locales/ja/squads.json | 3 +- packages/views/locales/ko/projects.json | 5 +- packages/views/locales/ko/squads.json | 3 +- packages/views/locales/zh-Hans/projects.json | 5 +- packages/views/locales/zh-Hans/squads.json | 3 +- .../projects/components/projects-page.tsx | 201 ++++++++++++++++-- .../views/squads/components/squads-page.tsx | 8 +- 10 files changed, 219 insertions(+), 22 deletions(-) diff --git a/packages/views/locales/en/projects.json b/packages/views/locales/en/projects.json index 49a263b45..a2f608640 100644 --- a/packages/views/locales/en/projects.json +++ b/packages/views/locales/en/projects.json @@ -14,7 +14,10 @@ "pin": "Pin to sidebar", "unpin": "Unpin", "delete": "Delete", - "no_matches": "No projects match" + "no_matches": "No projects match", + "selected_one": "{{count}} selected", + "selected_other": "{{count}} selected", + "clear_selection": "Clear selection" }, "table": { "name": "Name", diff --git a/packages/views/locales/en/squads.json b/packages/views/locales/en/squads.json index 41d7bd65f..a6bcf3671 100644 --- a/packages/views/locales/en/squads.json +++ b/packages/views/locales/en/squads.json @@ -24,7 +24,8 @@ "description": "\"{{name}}\" will be archived. Issues currently assigned to this squad will be transferred to its leader. This can't be undone — create a new squad if you need the routing back.", "cancel": "Cancel", "confirm": "Archive", - "archiving": "Archiving…" + "archiving": "Archiving…", + "success": "Squad archived" }, "name_editor": { "cancel": "Cancel" diff --git a/packages/views/locales/ja/projects.json b/packages/views/locales/ja/projects.json index ccaa1ab56..5e10de688 100644 --- a/packages/views/locales/ja/projects.json +++ b/packages/views/locales/ja/projects.json @@ -14,7 +14,10 @@ "pin": "サイドバーにピン留め", "unpin": "ピン留め解除", "delete": "削除", - "no_matches": "該当するプロジェクトはありません" + "no_matches": "該当するプロジェクトはありません", + "selected_one": "{{count}} 件選択中", + "selected_other": "{{count}} 件選択中", + "clear_selection": "選択を解除" }, "table": { "name": "名前", diff --git a/packages/views/locales/ja/squads.json b/packages/views/locales/ja/squads.json index 2012a29a8..452301815 100644 --- a/packages/views/locales/ja/squads.json +++ b/packages/views/locales/ja/squads.json @@ -24,7 +24,8 @@ "description": "\"{{name}}\" がアーカイブされます。現在このスクワッドに割り当てられているイシューはリーダーに引き継がれます。この操作は取り消せません。ルーティングを元に戻すには、新しいスクワッドを作成してください。", "cancel": "キャンセル", "confirm": "アーカイブ", - "archiving": "アーカイブ中…" + "archiving": "アーカイブ中…", + "success": "スカッドをアーカイブしました" }, "name_editor": { "cancel": "キャンセル" diff --git a/packages/views/locales/ko/projects.json b/packages/views/locales/ko/projects.json index 2d6ddcbeb..7735bb222 100644 --- a/packages/views/locales/ko/projects.json +++ b/packages/views/locales/ko/projects.json @@ -14,7 +14,10 @@ "pin": "사이드바에 고정", "unpin": "고정 해제", "delete": "삭제", - "no_matches": "일치하는 프로젝트가 없습니다" + "no_matches": "일치하는 프로젝트가 없습니다", + "selected_one": "{{count}}개 선택됨", + "selected_other": "{{count}}개 선택됨", + "clear_selection": "선택 해제" }, "table": { "name": "이름", diff --git a/packages/views/locales/ko/squads.json b/packages/views/locales/ko/squads.json index effa556ac..2e45cca63 100644 --- a/packages/views/locales/ko/squads.json +++ b/packages/views/locales/ko/squads.json @@ -24,7 +24,8 @@ "description": "\"{{name}}\"이(가) 보관됩니다. 현재 이 스쿼드에 할당된 이슈는 리더에게 이전됩니다. 이 작업은 되돌릴 수 없습니다. 라우팅을 다시 쓰려면 새 스쿼드를 만드세요.", "cancel": "취소", "confirm": "보관", - "archiving": "보관하는 중..." + "archiving": "보관하는 중...", + "success": "스쿼드를 보관했습니다" }, "name_editor": { "cancel": "취소" diff --git a/packages/views/locales/zh-Hans/projects.json b/packages/views/locales/zh-Hans/projects.json index 2b5285346..a346136ac 100644 --- a/packages/views/locales/zh-Hans/projects.json +++ b/packages/views/locales/zh-Hans/projects.json @@ -14,7 +14,10 @@ "pin": "钉到侧边栏", "unpin": "取消钉选", "delete": "删除", - "no_matches": "没有匹配的项目" + "no_matches": "没有匹配的项目", + "selected_one": "已选 {{count}} 项", + "selected_other": "已选 {{count}} 项", + "clear_selection": "清除选择" }, "table": { "name": "名称", diff --git a/packages/views/locales/zh-Hans/squads.json b/packages/views/locales/zh-Hans/squads.json index dd0cd3c04..c1283aa79 100644 --- a/packages/views/locales/zh-Hans/squads.json +++ b/packages/views/locales/zh-Hans/squads.json @@ -24,7 +24,8 @@ "description": "“{{name}}” 将被归档,该小队当前承接的 issue 会转交给小队负责人。此操作无法撤销,如需恢复路由请新建小队。", "cancel": "取消", "confirm": "归档", - "archiving": "归档中…" + "archiving": "归档中…", + "success": "已归档小队" }, "name_editor": { "cancel": "取消" diff --git a/packages/views/projects/components/projects-page.tsx b/packages/views/projects/components/projects-page.tsx index b1f0718cc..f43e35c87 100644 --- a/packages/views/projects/components/projects-page.tsx +++ b/packages/views/projects/components/projects-page.tsx @@ -44,6 +44,7 @@ import { ActorAvatar } from "../../common/actor-avatar"; import { FILTER_ITEM_CLASS, HoverCheck } from "../../common/hover-check"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { Button } from "@multica/ui/components/ui/button"; +import { Checkbox } from "@multica/ui/components/ui/checkbox"; import { Input } from "@multica/ui/components/ui/input"; import { Dialog, @@ -143,18 +144,19 @@ const COLUMN_WIDTHS: Record = { created: 104, }; -// Fixed tracks: edges 12+12, name min 200, status 116, kebab 28 = 368, plus -// the 9 gap-x-3 gaps between the wide template's 10 tracks. -const FIXED_TRACKS_WIDTH = 368 + 9 * 12; +// Fixed tracks: edges 12+12, checkbox 16, name min 200, status 116, +// kebab 28 = 384, plus the 10 gap-x-3 gaps between the wide template's +// 11 tracks. +const FIXED_TRACKS_WIDTH = 384 + 10 * 12; -// Render/track order: name, status (core, fixed 116px), priority, progress, -// lead, issues, created, kebab. MUST be a literal string — Tailwind can't -// see interpolated `grid-cols-[...]` arbitrary values, so an interpolated -// width silently drops the whole template and the grid collapses to one -// column. +// Render/track order: checkbox, name, status (core, fixed 116px), priority, +// progress, lead, issues, created, kebab. MUST be a literal string — +// Tailwind can't see interpolated `grid-cols-[...]` arbitrary values, so an +// interpolated width silently drops the whole template and the grid +// collapses to one column. const GRID_COLS = - "grid-cols-[0.75rem_minmax(120px,1fr)_116px_1.75rem_0.75rem] " + - "@2xl:grid-cols-[0.75rem_minmax(200px,1fr)_116px_var(--pjc-priority)_var(--pjc-progress)_var(--pjc-lead)_var(--pjc-issues)_var(--pjc-created)_1.75rem_0.75rem]"; + "grid-cols-[0.75rem_1rem_minmax(120px,1fr)_116px_1.75rem_0.75rem] " + + "@2xl:grid-cols-[0.75rem_1rem_minmax(200px,1fr)_116px_var(--pjc-priority)_var(--pjc-progress)_var(--pjc-lead)_var(--pjc-issues)_var(--pjc-created)_1.75rem_0.75rem]"; function columnTrackVars( isVisible: (key: ProjectColumnKey) => boolean, @@ -307,16 +309,43 @@ function ProjectRowActions({ ); } +function CheckboxCell({ + checked, + onToggle, +}: { + checked: boolean; + onToggle: () => void; +}) { + return ( + + + + ); +} + function ProjectTableRow({ project, pinned, canDelete, isColVisible, + selected, + onToggleSelect, }: { project: Project; pinned: boolean; canDelete: boolean; isColVisible: (key: ProjectColumnKey) => boolean; + selected: boolean; + onToggleSelect: () => void; }) { const wsPaths = useWorkspacePaths(); const formatRelativeDate = useFormatRelativeDate(); @@ -327,7 +356,8 @@ function ProjectTableRow({ ); return ( - + + void; isColVisible: (key: ProjectColumnKey) => boolean; + allSelected: boolean; + someSelected: boolean; + onToggleAll: () => void; }) { const { t } = useT("projects"); const sorted = (field: ProjectSortField) => sortField === field ? sortDirection : false; + const anySelected = allSelected || someSelected; return ( +
+ +
onSort("name")}> {t(($) => $.table.name)} @@ -605,6 +659,102 @@ function countActiveFilters(f: ProjectListFilters): number { return c; } +// Batch toolbar — page-anchored (not viewport). Pin all selected (any +// member) + Delete (workspace admin). Mirrors the other lists. +function ProjectBatchToolbar({ + rows, + pinnedIds, + canDelete, + onClear, +}: { + rows: Project[]; + pinnedIds: Set; + canDelete: boolean; + onClear: () => void; +}) { + const { t } = useT("projects"); + const createPin = useCreatePin(); + const deleteProject = useDeleteProject(); + const [confirmDelete, setConfirmDelete] = useState(false); + + if (rows.length === 0) return null; + const anyUnpinned = rows.some((p) => !pinnedIds.has(p.id)); + + return ( + <> +
+
+ + {t(($) => $.page.selected, { count: rows.length })} + + +
+ {anyUnpinned && ( + + )} + {canDelete && ( + + )} +
+ + + + + {t(($) => $.delete_dialog.title)} + {t(($) => $.delete_dialog.description)} + + + + + + + + + ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -651,6 +801,14 @@ export function ProjectsPage() { }, [pins]); const [search, setSearch] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const toggleSelected = (id: string) => + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); const activeFilterCount = countActiveFilters(filters); const hasActiveFilters = activeFilterCount > 0; @@ -709,6 +867,12 @@ export function ProjectsPage() { return sorted; }, [projects, search, filters, sortField, sortDirection]); + const selectedProjects = visible.filter((p) => selectedIds.has(p.id)); + const allSelected = visible.length > 0 && selectedProjects.length === visible.length; + const someSelected = selectedProjects.length > 0 && !allSelected; + const handleToggleAll = () => + setSelectedIds(allSelected ? new Set() : new Set(visible.map((p) => p.id))); + const sortLabel = (f: ProjectSortField) => f === "name" ? t(($) => $.table.name) @@ -736,7 +900,8 @@ export function ProjectsPage() { ); return ( -
+ // relative: positioning anchor for the page-centered batch toolbar. +
@@ -1027,6 +1192,9 @@ export function ProjectsPage() { sortDirection={sortDirection} onSort={toggleSort} isColVisible={isColVisible} + allSelected={allSelected} + someSelected={someSelected} + onToggleAll={handleToggleAll} /> {visible.map((project) => ( toggleSelected(project.id)} /> ))} @@ -1056,6 +1226,13 @@ export function ProjectsPage() {
)} + + setSelectedIds(new Set())} + /> )}
diff --git a/packages/views/squads/components/squads-page.tsx b/packages/views/squads/components/squads-page.tsx index 12468f9e2..f60925241 100644 --- a/packages/views/squads/components/squads-page.tsx +++ b/packages/views/squads/components/squads-page.tsx @@ -56,6 +56,7 @@ import { ListGridHeader, ListGridHeaderCell, ListGridRow, + LIST_GRID_BOTTOM_CLEARANCE, type ListGridSortDirection, } from "@multica/ui/components/ui/list-grid"; import { @@ -254,7 +255,7 @@ function ArchiveSquadDialog({ onSuccess: () => { qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) }); onOpenChange(false); - toast.success(t(($) => $.archive_dialog.title)); + toast.success(t(($) => $.archive_dialog.success)); }, onError: (err) => toast.error(err instanceof Error ? err.message : String(err)), @@ -744,7 +745,10 @@ export function SquadsPage() {