From 2a6e604bf84b0abc2312401f0f9027756729b883 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 8 Mar 2026 03:36:57 +0000 Subject: [PATCH] person cell --- .../base/components/cells/cell-person.tsx | 197 +++++++++--------- .../features/base/queries/base-row-query.ts | 67 +++--- .../src/features/base/styles/cells.module.css | 106 +++++++++- .../src/features/base/styles/grid.module.css | 2 +- 4 files changed, 227 insertions(+), 145 deletions(-) diff --git a/apps/client/src/features/base/components/cells/cell-person.tsx b/apps/client/src/features/base/components/cells/cell-person.tsx index 1769b1b8..dcdafcca 100644 --- a/apps/client/src/features/base/components/cells/cell-person.tsx +++ b/apps/client/src/features/base/components/cells/cell-person.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react"; -import { Popover, TextInput } from "@mantine/core"; +import { Popover } from "@mantine/core"; +import { IconX } from "@tabler/icons-react"; import { IBaseProperty } from "@/features/base/types/base.types"; import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query"; import { CustomAvatar } from "@/components/ui/custom-avatar"; @@ -25,7 +26,6 @@ export function CellPerson({ : typeof value === "string" ? [value] : []; - const selectedSet = new Set(personIds); const [search, setSearch] = useState(""); const searchRef = useRef(null); @@ -37,7 +37,6 @@ export function CellPerson({ } }, [isEditing]); - // Fetch members for display (always) and search (when editing) const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 }); const members = membersData?.items ?? []; const memberMap = useMemo(() => { @@ -46,7 +45,6 @@ export function CellPerson({ return map; }, [members]); - // Filtered members for editing const filteredMembers = search ? members.filter( (m) => @@ -55,14 +53,31 @@ export function CellPerson({ ) : members; - const handleToggle = useCallback( + const handleAdd = useCallback( (memberId: string) => { - const newIds = selectedSet.has(memberId) - ? personIds.filter((id) => id !== memberId) - : [...personIds, memberId]; + if (personIds.includes(memberId)) return; + onCommit([...personIds, memberId]); + }, + [personIds, onCommit], + ); + + const handleRemove = useCallback( + (memberId: string) => { + const newIds = personIds.filter((id) => id !== memberId); onCommit(newIds.length > 0 ? newIds : null); }, - [personIds, selectedSet, onCommit], + [personIds, onCommit], + ); + + const handleToggle = useCallback( + (memberId: string) => { + if (personIds.includes(memberId)) { + handleRemove(memberId); + } else { + handleAdd(memberId); + } + }, + [personIds, handleAdd, handleRemove], ); const handleKeyDown = useCallback( @@ -71,11 +86,15 @@ export function CellPerson({ e.preventDefault(); onCancel(); } + if (e.key === "Backspace" && search === "" && personIds.length > 0) { + e.preventDefault(); + handleRemove(personIds[personIds.length - 1]); + } }, - [onCancel], + [onCancel, search, personIds, handleRemove], ); - const MAX_VISIBLE = 4; + const selectedSet = new Set(personIds); if (isEditing) { return ( @@ -83,85 +102,79 @@ export function CellPerson({ opened onClose={onCancel} position="bottom-start" - width={260} + width={300} trapFocus >
- +
- - setSearch(e.currentTarget.value)} - onKeyDown={handleKeyDown} - mb={4} - /> + + {/* Tag input area */} +
+ {personIds.map((id) => { + const member = memberMap.get(id); + const name = member?.name ?? id.substring(0, 8); + return ( + + + {name} + + + ); + })} + setSearch(e.currentTarget.value)} + onKeyDown={handleKeyDown} + /> +
+ + {/* Dropdown */} +
+
+ Select as many as you like +
{filteredMembers.map((member) => (
handleToggle(member.id)} > -
- -
-
- {member.name} -
- {member.email && ( -
- {member.email} -
- )} -
-
+ + + {member.name} +
))} {filteredMembers.length === 0 && ( -
+
No members found
)} @@ -175,48 +188,36 @@ export function CellPerson({ return ; } - return ( - - ); + return ; } -function PersonAvatarList({ +function PersonReadList({ personIds, memberMap, - maxVisible, }: { personIds: string[]; memberMap: Map< string, { id: string; name: string; email?: string; avatarUrl?: string } >; - maxVisible: number; }) { - const visible = personIds.slice(0, maxVisible); - const overflow = personIds.length - maxVisible; - return (
- {visible.map((id) => { + {personIds.map((id) => { const member = memberMap.get(id); - const name = member?.name ?? id.substring(0, 2); + const name = member?.name ?? id.substring(0, 8); return ( - +
+ + {name} +
); })} - {overflow > 0 && ( - +{overflow} - )}
); } diff --git a/apps/client/src/features/base/queries/base-row-query.ts b/apps/client/src/features/base/queries/base-row-query.ts index 5ce45c3d..7caaec79 100644 --- a/apps/client/src/features/base/queries/base-row-query.ts +++ b/apps/client/src/features/base/queries/base-row-query.ts @@ -25,7 +25,7 @@ import { useTranslation } from "react-i18next"; import { IPagination } from "@/lib/types"; type RowCacheContext = { - previous: InfiniteData> | undefined; + snapshots: [readonly unknown[], InfiniteData> | undefined][]; }; export function useBaseRowsQuery( @@ -57,8 +57,8 @@ export function useCreateRowMutation() { return useMutation({ mutationFn: (data) => createRow(data), onSuccess: (newRow) => { - queryClient.setQueryData>>( - ["base-rows", newRow.baseId], + queryClient.setQueriesData>>( + { queryKey: ["base-rows", newRow.baseId] }, (old) => { if (!old) return old; const lastPageIndex = old.pages.length - 1; @@ -92,12 +92,12 @@ export function useUpdateRowMutation() { queryKey: ["base-rows", variables.baseId], }); - const previous = queryClient.getQueryData< + const snapshots = queryClient.getQueriesData< InfiniteData> - >(["base-rows", variables.baseId]); + >({ queryKey: ["base-rows", variables.baseId] }); - queryClient.setQueryData>>( - ["base-rows", variables.baseId], + queryClient.setQueriesData>>( + { queryKey: ["base-rows", variables.baseId] }, (old) => { if (!old) return old; return { @@ -117,14 +117,13 @@ export function useUpdateRowMutation() { }, ); - return { previous }; + return { snapshots }; }, onError: (_, variables, context) => { - if (context?.previous) { - queryClient.setQueryData( - ["base-rows", variables.baseId], - context.previous, - ); + if (context?.snapshots) { + for (const [key, data] of context.snapshots) { + queryClient.setQueryData(key, data); + } } notifications.show({ message: t("Failed to update row"), @@ -132,8 +131,8 @@ export function useUpdateRowMutation() { }); }, onSuccess: (updatedRow) => { - queryClient.setQueryData>>( - ["base-rows", updatedRow.baseId], + queryClient.setQueriesData>>( + { queryKey: ["base-rows", updatedRow.baseId] }, (old) => { if (!old) return old; return { @@ -162,12 +161,12 @@ export function useDeleteRowMutation() { queryKey: ["base-rows", variables.baseId], }); - const previous = queryClient.getQueryData< + const snapshots = queryClient.getQueriesData< InfiniteData> - >(["base-rows", variables.baseId]); + >({ queryKey: ["base-rows", variables.baseId] }); - queryClient.setQueryData>>( - ["base-rows", variables.baseId], + queryClient.setQueriesData>>( + { queryKey: ["base-rows", variables.baseId] }, (old) => { if (!old) return old; return { @@ -180,14 +179,13 @@ export function useDeleteRowMutation() { }, ); - return { previous }; + return { snapshots }; }, onError: (_, variables, context) => { - if (context?.previous) { - queryClient.setQueryData( - ["base-rows", variables.baseId], - context.previous, - ); + if (context?.snapshots) { + for (const [key, data] of context.snapshots) { + queryClient.setQueryData(key, data); + } } notifications.show({ message: t("Failed to delete row"), @@ -206,12 +204,12 @@ export function useReorderRowMutation() { queryKey: ["base-rows", variables.baseId], }); - const previous = queryClient.getQueryData< + const snapshots = queryClient.getQueriesData< InfiniteData> - >(["base-rows", variables.baseId]); + >({ queryKey: ["base-rows", variables.baseId] }); - queryClient.setQueryData>>( - ["base-rows", variables.baseId], + queryClient.setQueriesData>>( + { queryKey: ["base-rows", variables.baseId] }, (old) => { if (!old) return old; return { @@ -228,14 +226,13 @@ export function useReorderRowMutation() { }, ); - return { previous }; + return { snapshots }; }, onError: (_, variables, context) => { - if (context?.previous) { - queryClient.setQueryData( - ["base-rows", variables.baseId], - context.previous, - ); + if (context?.snapshots) { + for (const [key, data] of context.snapshots) { + queryClient.setQueryData(key, data); + } } notifications.show({ message: t("Failed to reorder row"), diff --git a/apps/client/src/features/base/styles/cells.module.css b/apps/client/src/features/base/styles/cells.module.css index 6212a6d0..1c21f858 100644 --- a/apps/client/src/features/base/styles/cells.module.css +++ b/apps/client/src/features/base/styles/cells.module.css @@ -89,27 +89,111 @@ color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4)); } +/* Person cell — read mode (vertical list like Notion) */ + .personGroup { display: flex; - align-items: center; - gap: 4px; - overflow: hidden; + flex-direction: column; + gap: 2px; + padding: 4px 0; } -.personAvatar { - width: 22px; - height: 22px; - border-radius: 50%; - flex-shrink: 0; - background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)); +.personRow { display: flex; align-items: center; + gap: 6px; +} + +.personName { + font-size: var(--mantine-font-size-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Person cell — edit mode (tag input + dropdown) */ + +.personTagArea { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 6px 8px; + min-height: 34px; +} + +.personTag { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 4px 2px 2px; + border-radius: 3px; + background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); + font-size: var(--mantine-font-size-xs); + white-space: nowrap; + max-width: 160px; +} + +.personTagName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.personTagRemove { + display: inline-flex; + align-items: center; justify-content: center; - font-size: 10px; - font-weight: 600; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 2px; + background: transparent; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2)); + cursor: pointer; + flex-shrink: 0; +} + +.personTagRemove:hover { + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0)); } +.personTagInput { + flex: 1; + min-width: 60px; + border: none; + outline: none; + background: transparent; + font-size: var(--mantine-font-size-xs); + font-family: inherit; + color: inherit; + padding: 2px 0; +} + +.personTagInput::placeholder { + color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3)); +} + +.personDropdownDivider { + height: 1px; + background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4)); +} + +.personDropdownHint { + padding: 6px 8px; + font-size: 11px; + color: light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-3)); +} + +.personOptionName { + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .fileGroup { display: flex; align-items: center; diff --git a/apps/client/src/features/base/styles/grid.module.css b/apps/client/src/features/base/styles/grid.module.css index 3ca1aa66..a7572889 100644 --- a/apps/client/src/features/base/styles/grid.module.css +++ b/apps/client/src/features/base/styles/grid.module.css @@ -126,7 +126,7 @@ .cell { display: flex; align-items: center; - height: 36px; + min-height: 36px; padding: 0 8px; font-size: var(--mantine-font-size-sm); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));