filter/sort, file, person

This commit is contained in:
Philipinho
2026-03-08 03:15:49 +00:00
parent ac03a54ae6
commit 674b0ec64a
12 changed files with 982 additions and 59 deletions
@@ -1,12 +1,21 @@
import { IconPaperclip } from "@tabler/icons-react";
import { useState, useRef, useCallback } from "react";
import { Popover, ActionIcon, Text, UnstyledButton } from "@mantine/core";
import {
IconPaperclip,
IconUpload,
IconFile,
IconX,
} from "@tabler/icons-react";
import { IBaseProperty } from "@/features/base/types/base.types";
import cellClasses from "@/features/base/styles/cells.module.css";
import api from "@/lib/api-client";
type FileValue = {
export type FileValue = {
id: string;
name: string;
url?: string;
size?: number;
fileName: string;
mimeType?: string;
fileSize?: number;
filePath?: string;
};
type CellFileProps = {
@@ -18,25 +27,210 @@ type CellFileProps = {
onCancel: () => void;
};
function formatFileSize(bytes?: number): string {
if (!bytes) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function parseFiles(value: unknown): FileValue[] {
if (!Array.isArray(value)) return [];
return value.filter(
(f): f is FileValue =>
f && typeof f === "object" && "id" in f && "fileName" in f,
);
}
export function CellFile({
value,
property,
isEditing,
onCommit,
onCancel,
}: CellFileProps) {
const files = Array.isArray(value) ? (value as FileValue[]) : [];
const files = parseFiles(value);
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleRemove = useCallback(
(fileId: string) => {
const updated = files.filter((f) => f.id !== fileId);
onCommit(updated.length > 0 ? updated : null);
},
[files, onCommit],
);
const handleUpload = useCallback(
async (fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return;
setUploading(true);
const newFiles: FileValue[] = [...files];
for (const file of Array.from(fileList)) {
try {
const formData = new FormData();
formData.append("file", file);
formData.append("baseId", property.baseId);
const res = await api.post<FileValue>(
"/bases/files/upload",
formData,
{
headers: { "Content-Type": "multipart/form-data" },
},
);
const attachment = res as unknown as FileValue;
newFiles.push({
id: attachment.id,
fileName: attachment.fileName,
mimeType: attachment.mimeType,
fileSize: attachment.fileSize,
filePath: attachment.filePath,
});
} catch (err) {
console.error("File upload failed:", err);
}
}
setUploading(false);
onCommit(newFiles.length > 0 ? newFiles : null);
},
[files, property.baseId, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 2;
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={280}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<FileList files={files} maxVisible={MAX_VISIBLE} />
</div>
</Popover.Target>
<Popover.Dropdown p={8} onKeyDown={handleKeyDown}>
{files.length === 0 && !uploading && (
<Text size="xs" c="dimmed" mb={8}>
No files attached
</Text>
)}
{files.map((file) => (
<div
key={file.id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 0",
borderBottom:
"1px solid var(--mantine-color-default-border)",
}}
>
<IconFile
size={14}
style={{
flexShrink: 0,
color: "var(--mantine-color-gray-6)",
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" truncate="end" fw={500}>
{file.fileName}
</Text>
{file.fileSize != null && (
<Text size="xs" c="dimmed">
{formatFileSize(file.fileSize)}
</Text>
)}
</div>
<ActionIcon
variant="subtle"
color="gray"
size="xs"
onClick={() => handleRemove(file.id)}
>
<IconX size={12} />
</ActionIcon>
</div>
))}
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
handleUpload(e.target.files);
e.target.value = "";
}}
/>
<UnstyledButton
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 0",
marginTop: 4,
fontSize: "var(--mantine-font-size-xs)",
color: uploading
? "var(--mantine-color-gray-5)"
: "var(--mantine-color-blue-6)",
}}
>
<IconUpload size={14} />
{uploading ? "Uploading..." : "Add file"}
</UnstyledButton>
</Popover.Dropdown>
</Popover>
);
}
if (files.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
const MAX_VISIBLE = 2;
const visible = files.slice(0, MAX_VISIBLE);
const overflow = files.length - MAX_VISIBLE;
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
}
function FileList({
files,
maxVisible,
}: {
files: FileValue[];
maxVisible: number;
}) {
const visible = files.slice(0, maxVisible);
const overflow = files.length - maxVisible;
return (
<div className={cellClasses.fileGroup}>
{visible.map((file) => (
<span key={file.id} className={cellClasses.fileBadge}>
<IconPaperclip size={12} />
{file.name}
{file.fileName}
</span>
))}
{overflow > 0 && (
@@ -1,4 +1,8 @@
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { Popover, TextInput } from "@mantine/core";
import { IBaseProperty } from "@/features/base/types/base.types";
import { useWorkspaceMembersQuery } from "@/features/workspace/queries/workspace-query";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import cellClasses from "@/features/base/styles/cells.module.css";
type CellPersonProps = {
@@ -10,34 +14,206 @@ type CellPersonProps = {
onCancel: () => void;
};
function getInitials(id: string): string {
return id.substring(0, 2).toUpperCase();
}
export function CellPerson({
value,
isEditing,
onCommit,
onCancel,
}: CellPersonProps) {
const personIds = Array.isArray(value)
? (value as string[])
: typeof value === "string"
? [value]
: [];
const selectedSet = new Set(personIds);
const [search, setSearch] = useState("");
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
setSearch("");
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isEditing]);
// Fetch members for display (always) and search (when editing)
const { data: membersData } = useWorkspaceMembersQuery({ limit: 100 });
const members = membersData?.items ?? [];
const memberMap = useMemo(() => {
const map = new Map<string, (typeof members)[0]>();
for (const m of members) map.set(m.id, m);
return map;
}, [members]);
// Filtered members for editing
const filteredMembers = search
? members.filter(
(m) =>
m.name.toLowerCase().includes(search.toLowerCase()) ||
(m.email && m.email.toLowerCase().includes(search.toLowerCase())),
)
: members;
const handleToggle = useCallback(
(memberId: string) => {
const newIds = selectedSet.has(memberId)
? personIds.filter((id) => id !== memberId)
: [...personIds, memberId];
onCommit(newIds.length > 0 ? newIds : null);
},
[personIds, selectedSet, onCommit],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
},
[onCancel],
);
const MAX_VISIBLE = 4;
if (isEditing) {
return (
<Popover
opened
onClose={onCancel}
position="bottom-start"
width={260}
trapFocus
>
<Popover.Target>
<div style={{ width: "100%", height: "100%" }}>
<PersonAvatarList
personIds={personIds}
memberMap={memberMap}
maxVisible={MAX_VISIBLE}
/>
</div>
</Popover.Target>
<Popover.Dropdown p={4}>
<TextInput
ref={searchRef}
size="xs"
placeholder="Search members..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
onKeyDown={handleKeyDown}
mb={4}
/>
<div className={cellClasses.selectDropdown}>
{filteredMembers.map((member) => (
<div
key={member.id}
className={`${cellClasses.selectOption} ${
selectedSet.has(member.id)
? cellClasses.selectOptionActive
: ""
}`}
onClick={() => handleToggle(member.id)}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<CustomAvatar
avatarUrl={member.avatarUrl}
name={member.name}
size={22}
radius="xl"
/>
<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>
))}
{filteredMembers.length === 0 && (
<div
style={{
padding: "8px 12px",
fontSize: 12,
color: "var(--mantine-color-dimmed)",
}}
>
No members found
</div>
)}
</div>
</Popover.Dropdown>
</Popover>
);
}
if (personIds.length === 0) {
return <span className={cellClasses.emptyValue} />;
}
const MAX_VISIBLE = 4;
const visible = personIds.slice(0, MAX_VISIBLE);
const overflow = personIds.length - MAX_VISIBLE;
return (
<PersonAvatarList
personIds={personIds}
memberMap={memberMap}
maxVisible={MAX_VISIBLE}
/>
);
}
function PersonAvatarList({
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 (
<div className={cellClasses.personGroup}>
{visible.map((id) => (
<div key={id} className={cellClasses.personAvatar}>
{getInitials(id)}
</div>
))}
{visible.map((id) => {
const member = memberMap.get(id);
const name = member?.name ?? id.substring(0, 2);
return (
<CustomAvatar
key={id}
avatarUrl={member?.avatarUrl ?? ""}
name={name}
size={22}
radius="xl"
/>
);
})}
{overflow > 0 && (
<span className={cellClasses.overflowCount}>+{overflow}</span>
)}