mirror of
https://github.com/docmost/docmost.git
synced 2026-05-19 07:54:05 +08:00
filter/sort, file, person
This commit is contained in:
@@ -28,14 +28,6 @@ type BaseTableProps = {
|
||||
export function BaseTable({ baseId }: BaseTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
|
||||
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBaseRowsQuery(baseId);
|
||||
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createPropertyMutation = useCreatePropertyMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
|
||||
const [activeViewId, setActiveViewId] = useAtom(activeViewIdAtom) as unknown as [string | null, (val: string | null) => void];
|
||||
|
||||
@@ -45,6 +37,17 @@ export function BaseTable({ baseId }: BaseTableProps) {
|
||||
return views.find((v) => v.id === activeViewId) ?? views[0];
|
||||
}, [views, activeViewId]);
|
||||
|
||||
const activeFilters = activeView?.config?.filters;
|
||||
const activeSorts = activeView?.config?.sorts;
|
||||
const { data: rowsData, isLoading: rowsLoading, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useBaseRowsQuery(baseId, activeFilters, activeSorts);
|
||||
|
||||
const updateRowMutation = useUpdateRowMutation();
|
||||
const createRowMutation = useCreateRowMutation();
|
||||
const reorderRowMutation = useReorderRowMutation();
|
||||
const createPropertyMutation = useCreatePropertyMutation();
|
||||
const createViewMutation = useCreateViewMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView && activeViewId !== activeView.id) {
|
||||
setActiveViewId(activeView.id);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
IBaseProperty,
|
||||
SelectTypeOptions,
|
||||
ViewFilterConfig,
|
||||
ViewFilterOperator,
|
||||
} from "@/features/base/types/base.types";
|
||||
@@ -32,6 +33,114 @@ const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [
|
||||
|
||||
const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"];
|
||||
|
||||
function getOperatorsForType(type: string): ViewFilterOperator[] {
|
||||
switch (type) {
|
||||
case "text":
|
||||
case "email":
|
||||
case "url":
|
||||
return ["equals", "notEquals", "contains", "notContains", "isEmpty", "isNotEmpty"];
|
||||
case "number":
|
||||
return ["equals", "notEquals", "greaterThan", "lessThan", "isEmpty", "isNotEmpty"];
|
||||
case "date":
|
||||
case "createdAt":
|
||||
case "lastEditedAt":
|
||||
return ["equals", "notEquals", "before", "after", "isEmpty", "isNotEmpty"];
|
||||
case "select":
|
||||
case "status":
|
||||
case "multiSelect":
|
||||
return ["equals", "notEquals", "isEmpty", "isNotEmpty"];
|
||||
case "checkbox":
|
||||
return ["equals", "isEmpty", "isNotEmpty"];
|
||||
case "person":
|
||||
case "lastEditedBy":
|
||||
return ["equals", "notEquals", "isEmpty", "isNotEmpty"];
|
||||
case "file":
|
||||
return ["isEmpty", "isNotEmpty"];
|
||||
default:
|
||||
return ["equals", "notEquals", "isEmpty", "isNotEmpty"];
|
||||
}
|
||||
}
|
||||
|
||||
function FilterValueInput({
|
||||
filter,
|
||||
property,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
filter: ViewFilterConfig;
|
||||
property: IBaseProperty | undefined;
|
||||
onChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (!property) {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const type = property.type;
|
||||
|
||||
if (type === "select" || type === "status" || type === "multiSelect") {
|
||||
const typeOptions = property.typeOptions as SelectTypeOptions | undefined;
|
||||
const choices = typeOptions?.choices ?? [];
|
||||
const choiceOptions = choices.map((c) => ({ value: c.id, label: c.name }));
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
data={choiceOptions}
|
||||
value={(filter.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={120}
|
||||
placeholder={t("Select")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "number") {
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "checkbox") {
|
||||
return (
|
||||
<Select
|
||||
size="xs"
|
||||
data={[
|
||||
{ value: "true", label: t("True") },
|
||||
{ value: "false", label: t("False") },
|
||||
]}
|
||||
value={(filter.value as string) ?? null}
|
||||
onChange={(val) => onChange(val ?? "")}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
onChange={(e) => onChange(e.currentTarget.value)}
|
||||
w={100}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type ViewFilterConfigProps = {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -56,17 +165,14 @@ export function ViewFilterConfigPopover({
|
||||
label: p.name,
|
||||
}));
|
||||
|
||||
const operatorOptions = OPERATORS.map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const firstProperty = properties[0];
|
||||
if (!firstProperty) return;
|
||||
const validOperators = getOperatorsForType(firstProperty.type);
|
||||
const defaultOperator = validOperators.includes("contains") ? "contains" : validOperators[0];
|
||||
onChange([
|
||||
...filters,
|
||||
{ propertyId: firstProperty.id, operator: "contains" },
|
||||
{ propertyId: firstProperty.id, operator: defaultOperator },
|
||||
]);
|
||||
}, [filters, properties, onChange]);
|
||||
|
||||
@@ -80,11 +186,25 @@ export function ViewFilterConfigPopover({
|
||||
const handlePropertyChange = useCallback(
|
||||
(index: number, propertyId: string | null) => {
|
||||
if (!propertyId) return;
|
||||
const newProperty = properties.find((p) => p.id === propertyId);
|
||||
onChange(
|
||||
filters.map((f, i) => (i === index ? { ...f, propertyId } : f)),
|
||||
filters.map((f, i) => {
|
||||
if (i !== index) return f;
|
||||
if (newProperty) {
|
||||
const validOperators = getOperatorsForType(newProperty.type);
|
||||
const currentOperatorValid = validOperators.includes(f.operator);
|
||||
return {
|
||||
...f,
|
||||
propertyId,
|
||||
operator: currentOperatorValid ? f.operator : validOperators[0],
|
||||
value: currentOperatorValid ? f.value : undefined,
|
||||
};
|
||||
}
|
||||
return { ...f, propertyId };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[filters, onChange],
|
||||
[filters, properties, onChange],
|
||||
);
|
||||
|
||||
const handleOperatorChange = useCallback(
|
||||
@@ -143,6 +263,16 @@ export function ViewFilterConfigPopover({
|
||||
|
||||
{filters.map((filter, index) => {
|
||||
const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
|
||||
const property = properties.find((p) => p.id === filter.propertyId);
|
||||
const validOperators = property
|
||||
? getOperatorsForType(property.type)
|
||||
: OPERATORS.map((op) => op.value);
|
||||
const operatorOptions = OPERATORS
|
||||
.filter((op) => validOperators.includes(op.value))
|
||||
.map((op) => ({
|
||||
value: op.value,
|
||||
label: t(op.labelKey),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Group key={index} gap="xs" wrap="nowrap">
|
||||
@@ -161,14 +291,11 @@ export function ViewFilterConfigPopover({
|
||||
w={130}
|
||||
/>
|
||||
{needsValue && (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder={t("Value")}
|
||||
value={(filter.value as string) ?? ""}
|
||||
onChange={(e) =>
|
||||
handleValueChange(index, e.currentTarget.value)
|
||||
}
|
||||
w={100}
|
||||
<FilterValueInput
|
||||
filter={filter}
|
||||
property={property}
|
||||
onChange={(val) => handleValueChange(index, val)}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
<ActionIcon
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
UpdateRowInput,
|
||||
DeleteRowInput,
|
||||
ReorderRowInput,
|
||||
ViewFilterConfig,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { queryClient } from "@/main";
|
||||
@@ -26,11 +28,15 @@ type RowCacheContext = {
|
||||
previous: InfiniteData<IPagination<IBaseRow>> | undefined;
|
||||
};
|
||||
|
||||
export function useBaseRowsQuery(baseId: string | undefined) {
|
||||
export function useBaseRowsQuery(
|
||||
baseId: string | undefined,
|
||||
filters?: ViewFilterConfig[],
|
||||
sorts?: ViewSortConfig[],
|
||||
) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: ["base-rows", baseId],
|
||||
queryKey: ["base-rows", baseId, filters, sorts],
|
||||
queryFn: ({ pageParam }) =>
|
||||
listRows(baseId!, { cursor: pageParam, limit: 100 }),
|
||||
listRows(baseId!, { cursor: pageParam, limit: 100, filters, sorts }),
|
||||
enabled: !!baseId,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
UpdateViewInput,
|
||||
DeleteViewInput,
|
||||
UpdatePropertyResult,
|
||||
ViewFilterConfig,
|
||||
ViewSortConfig,
|
||||
} from "@/features/base/types/base.types";
|
||||
import { IPagination } from "@/lib/types";
|
||||
|
||||
@@ -105,7 +107,13 @@ export async function deleteRow(data: DeleteRowInput): Promise<void> {
|
||||
|
||||
export async function listRows(
|
||||
baseId: string,
|
||||
params?: { viewId?: string; cursor?: string; limit?: number },
|
||||
params?: {
|
||||
viewId?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
filters?: ViewFilterConfig[];
|
||||
sorts?: ViewSortConfig[];
|
||||
},
|
||||
): Promise<IPagination<IBaseRow>> {
|
||||
const req = await api.post("/bases/rows/list", { baseId, ...params });
|
||||
return req.data;
|
||||
|
||||
Reference in New Issue
Block a user