person cell

This commit is contained in:
Philipinho
2026-03-08 03:36:57 +00:00
parent 674b0ec64a
commit 2a6e604bf8
4 changed files with 227 additions and 145 deletions
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react"; 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 { IBaseProperty } from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query"; import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar"; import { CustomAvatar } from "@/components/ui/custom-avatar";
@@ -25,7 +26,6 @@ export function CellPerson({
: typeof value === "string" : typeof value === "string"
? [value] ? [value]
: []; : [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
@@ -37,7 +37,6 @@ export function CellPerson({
} }
}, [isEditing]); }, [isEditing]);
// Fetch members for display (always) and search (when editing)
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 }); const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const members = membersData?.items ?? []; const members = membersData?.items ?? [];
const memberMap = useMemo(() => { const memberMap = useMemo(() => {
@@ -46,7 +45,6 @@ export function CellPerson({
return map; return map;
}, [members]); }, [members]);
// Filtered members for editing
const filteredMembers = search const filteredMembers = search
? members.filter( ? members.filter(
(m) => (m) =>
@@ -55,14 +53,31 @@ export function CellPerson({
) )
: members; : members;
const handleToggle = useCallback( const handleAdd = useCallback(
(memberId: string) => { (memberId: string) => {
const newIds = selectedSet.has(memberId) if (personIds.includes(memberId)) return;
? personIds.filter((id) => id !== memberId) onCommit([...personIds, memberId]);
: [...personIds, memberId]; },
[personIds, onCommit],
);
const handleRemove = useCallback(
(memberId: string) => {
const newIds = personIds.filter((id) => id !== memberId);
onCommit(newIds.length > 0 ? newIds : null); 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( const handleKeyDown = useCallback(
@@ -71,11 +86,15 @@ export function CellPerson({
e.preventDefault(); e.preventDefault();
onCancel(); 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) { if (isEditing) {
return ( return (
@@ -83,85 +102,79 @@ export function CellPerson({
opened opened
onClose={onCancel} onClose={onCancel}
position="bottom-start" position="bottom-start"
width={260} width={300}
trapFocus trapFocus
> >
<Popover.Target> <Popover.Target>
<div style={{ width: "100%", height: "100%" }}> <div style={{ width: "100%", height: "100%" }}>
<PersonAvatarList <PersonReadList personIds={personIds} memberMap={memberMap} />
personIds={personIds}
memberMap={memberMap}
maxVisible={MAX_VISIBLE}
/>
</div> </div>
</Popover.Target> </Popover.Target>
<Popover.Dropdown p={4}> <Popover.Dropdown p={0}>
<TextInput {/* Tag input area */}
ref={searchRef} <div className={cellClasses.personTagArea}>
size="xs" {personIds.map((id) => {
placeholder="Search members..." const member = memberMap.get(id);
value={search} const name = member?.name ?? id.substring(0, 8);
onChange={(e) => setSearch(e.currentTarget.value)} return (
onKeyDown={handleKeyDown} <span key={id} className={cellClasses.personTag}>
mb={4} <CustomAvatar
/> avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={18}
radius="xl"
/>
<span className={cellClasses.personTagName}>{name}</span>
<button
type="button"
className={cellClasses.personTagRemove}
onClick={(e) => {
e.stopPropagation();
handleRemove(id);
}}
>
<IconX size={10} />
</button>
</span>
);
})}
<input
ref={searchRef}
className={cellClasses.personTagInput}
placeholder={personIds.length === 0 ? "Search for a person..." : ""}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
/>
</div>
{/* Dropdown */}
<div className={cellClasses.personDropdownDivider} />
<div className={cellClasses.personDropdownHint}>
Select as many as you like
</div>
<div className={cellClasses.selectDropdown}> <div className={cellClasses.selectDropdown}>
{filteredMembers.map((member) => ( {filteredMembers.map((member) => (
<div <div
key={member.id} key={member.id}
className={`${cellClasses.selectOption} ${ className={`${cellClasses.selectOption} ${
selectedSet.has(member.id) selectedSet.has(member.id) ? cellClasses.selectOptionActive : ""
? cellClasses.selectOptionActive
: ""
}`} }`}
onClick={() => handleToggle(member.id)} onClick={() => handleToggle(member.id)}
> >
<div <CustomAvatar
style={{ display: "flex", alignItems: "center", gap: 8 }} avatarUrl={member.avatarUrl}
> name={member.name}
<CustomAvatar size={24}
avatarUrl={member.avatarUrl} radius="xl"
name={member.name} />
size={22} <span className={cellClasses.personOptionName}>
radius="xl" {member.name}
/> </span>
<div style={{ overflow: "hidden" }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{member.name}
</div>
{member.email && (
<div
style={{
fontSize: 11,
color: "var(--mantine-color-dimmed)",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{member.email}
</div>
)}
</div>
</div>
</div> </div>
))} ))}
{filteredMembers.length === 0 && ( {filteredMembers.length === 0 && (
<div <div className={cellClasses.personDropdownHint}>
style={{
padding: "8px 12px",
fontSize: 12,
color: "var(--mantine-color-dimmed)",
}}
>
No members found No members found
</div> </div>
)} )}
@@ -175,48 +188,36 @@ export function CellPerson({
return <span className={cellClasses.emptyValue} />; return <span className={cellClasses.emptyValue} />;
} }
return ( return <PersonReadList personIds={personIds} memberMap={memberMap} />;
<PersonAvatarList
personIds={personIds}
memberMap={memberMap}
maxVisible={MAX_VISIBLE}
/>
);
} }
function PersonAvatarList({ function PersonReadList({
personIds, personIds,
memberMap, memberMap,
maxVisible,
}: { }: {
personIds: string[]; personIds: string[];
memberMap: Map< memberMap: Map<
string, string,
{ id: string; name: string; email?: string; avatarUrl?: string } { id: string; name: string; email?: string; avatarUrl?: string }
>; >;
maxVisible: number;
}) { }) {
const visible = personIds.slice(0, maxVisible);
const overflow = personIds.length - maxVisible;
return ( return (
<div className={cellClasses.personGroup}> <div className={cellClasses.personGroup}>
{visible.map((id) => { {personIds.map((id) => {
const member = memberMap.get(id); const member = memberMap.get(id);
const name = member?.name ?? id.substring(0, 2); const name = member?.name ?? id.substring(0, 8);
return ( return (
<CustomAvatar <div key={id} className={cellClasses.personRow}>
key={id} <CustomAvatar
avatarUrl={member?.avatarUrl ?? ""} avatarUrl={member?.avatarUrl ?? ""}
name={name} name={name}
size={22} size={20}
radius="xl" radius="xl"
/> />
<span className={cellClasses.personName}>{name}</span>
</div>
); );
})} })}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}
</div> </div>
); );
} }
@@ -25,7 +25,7 @@ import { useTranslation } from "react-i18next";
import { IPagination } from "@/lib/types"; import { IPagination } from "@/lib/types";
type RowCacheContext = { type RowCacheContext = {
previous: InfiniteData<IPagination<IBaseRow>> | undefined; snapshots: [readonly unknown[], InfiniteData<IPagination<IBaseRow>> | undefined][];
}; };
export function useBaseRowsQuery( export function useBaseRowsQuery(
@@ -57,8 +57,8 @@ export function useCreateRowMutation() {
return useMutation<IBaseRow, Error, CreateRowInput>({ return useMutation<IBaseRow, Error, CreateRowInput>({
mutationFn: (data) => createRow(data), mutationFn: (data) => createRow(data),
onSuccess: (newRow) => { onSuccess: (newRow) => {
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>( queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
["base-rows", newRow.baseId], { queryKey: ["base-rows", newRow.baseId] },
(old) => { (old) => {
if (!old) return old; if (!old) return old;
const lastPageIndex = old.pages.length - 1; const lastPageIndex = old.pages.length - 1;
@@ -92,12 +92,12 @@ export function useUpdateRowMutation() {
queryKey: ["base-rows", variables.baseId], queryKey: ["base-rows", variables.baseId],
}); });
const previous = queryClient.getQueryData< const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>> InfiniteData<IPagination<IBaseRow>>
>(["base-rows", variables.baseId]); >({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>( queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
["base-rows", variables.baseId], { queryKey: ["base-rows", variables.baseId] },
(old) => { (old) => {
if (!old) return old; if (!old) return old;
return { return {
@@ -117,14 +117,13 @@ export function useUpdateRowMutation() {
}, },
); );
return { previous }; return { snapshots };
}, },
onError: (_, variables, context) => { onError: (_, variables, context) => {
if (context?.previous) { if (context?.snapshots) {
queryClient.setQueryData( for (const [key, data] of context.snapshots) {
["base-rows", variables.baseId], queryClient.setQueryData(key, data);
context.previous, }
);
} }
notifications.show({ notifications.show({
message: t("Failed to update row"), message: t("Failed to update row"),
@@ -132,8 +131,8 @@ export function useUpdateRowMutation() {
}); });
}, },
onSuccess: (updatedRow) => { onSuccess: (updatedRow) => {
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>( queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
["base-rows", updatedRow.baseId], { queryKey: ["base-rows", updatedRow.baseId] },
(old) => { (old) => {
if (!old) return old; if (!old) return old;
return { return {
@@ -162,12 +161,12 @@ export function useDeleteRowMutation() {
queryKey: ["base-rows", variables.baseId], queryKey: ["base-rows", variables.baseId],
}); });
const previous = queryClient.getQueryData< const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>> InfiniteData<IPagination<IBaseRow>>
>(["base-rows", variables.baseId]); >({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>( queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
["base-rows", variables.baseId], { queryKey: ["base-rows", variables.baseId] },
(old) => { (old) => {
if (!old) return old; if (!old) return old;
return { return {
@@ -180,14 +179,13 @@ export function useDeleteRowMutation() {
}, },
); );
return { previous }; return { snapshots };
}, },
onError: (_, variables, context) => { onError: (_, variables, context) => {
if (context?.previous) { if (context?.snapshots) {
queryClient.setQueryData( for (const [key, data] of context.snapshots) {
["base-rows", variables.baseId], queryClient.setQueryData(key, data);
context.previous, }
);
} }
notifications.show({ notifications.show({
message: t("Failed to delete row"), message: t("Failed to delete row"),
@@ -206,12 +204,12 @@ export function useReorderRowMutation() {
queryKey: ["base-rows", variables.baseId], queryKey: ["base-rows", variables.baseId],
}); });
const previous = queryClient.getQueryData< const snapshots = queryClient.getQueriesData<
InfiniteData<IPagination<IBaseRow>> InfiniteData<IPagination<IBaseRow>>
>(["base-rows", variables.baseId]); >({ queryKey: ["base-rows", variables.baseId] });
queryClient.setQueryData<InfiniteData<IPagination<IBaseRow>>>( queryClient.setQueriesData<InfiniteData<IPagination<IBaseRow>>>(
["base-rows", variables.baseId], { queryKey: ["base-rows", variables.baseId] },
(old) => { (old) => {
if (!old) return old; if (!old) return old;
return { return {
@@ -228,14 +226,13 @@ export function useReorderRowMutation() {
}, },
); );
return { previous }; return { snapshots };
}, },
onError: (_, variables, context) => { onError: (_, variables, context) => {
if (context?.previous) { if (context?.snapshots) {
queryClient.setQueryData( for (const [key, data] of context.snapshots) {
["base-rows", variables.baseId], queryClient.setQueryData(key, data);
context.previous, }
);
} }
notifications.show({ notifications.show({
message: t("Failed to reorder row"), message: t("Failed to reorder row"),
@@ -89,27 +89,111 @@
color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4)); color: light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4));
} }
/* Person cell — read mode (vertical list like Notion) */
.personGroup { .personGroup {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 4px; gap: 2px;
overflow: hidden; padding: 4px 0;
} }
.personAvatar { .personRow {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
display: flex; display: flex;
align-items: center; 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; justify-content: center;
font-size: 10px; width: 16px;
font-weight: 600; 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)); 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 { .fileGroup {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -126,7 +126,7 @@
.cell { .cell {
display: flex; display: flex;
align-items: center; align-items: center;
height: 36px; min-height: 36px;
padding: 0 8px; padding: 0 8px;
font-size: var(--mantine-font-size-sm); font-size: var(--mantine-font-size-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0)); color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));