mirror of
https://github.com/docmost/docmost.git
synced 2026-06-15 22:48:42 +08:00
filter/sort, file, person
This commit is contained in:
@@ -28,14 +28,6 @@ type BaseTableProps = {
|
|||||||
export function BaseTable({ baseId }: BaseTableProps) {
|
export function BaseTable({ baseId }: BaseTableProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: base, isLoading: baseLoading, error: baseError } = useBaseQuery(baseId);
|
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];
|
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];
|
return views.find((v) => v.id === activeViewId) ?? views[0];
|
||||||
}, [views, activeViewId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (activeView && activeViewId !== activeView.id) {
|
if (activeView && activeViewId !== activeView.id) {
|
||||||
setActiveViewId(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 { IBaseProperty } from "@/features/base/types/base.types";
|
||||||
import cellClasses from "@/features/base/styles/cells.module.css";
|
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||||
|
import api from "@/lib/api-client";
|
||||||
|
|
||||||
type FileValue = {
|
export type FileValue = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
fileName: string;
|
||||||
url?: string;
|
mimeType?: string;
|
||||||
size?: number;
|
fileSize?: number;
|
||||||
|
filePath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CellFileProps = {
|
type CellFileProps = {
|
||||||
@@ -18,25 +27,210 @@ type CellFileProps = {
|
|||||||
onCancel: () => void;
|
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({
|
export function CellFile({
|
||||||
value,
|
value,
|
||||||
|
property,
|
||||||
|
isEditing,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
}: CellFileProps) {
|
}: 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) {
|
if (files.length === 0) {
|
||||||
return <span className={cellClasses.emptyValue} />;
|
return <span className={cellClasses.emptyValue} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE = 2;
|
return <FileList files={files} maxVisible={MAX_VISIBLE} />;
|
||||||
const visible = files.slice(0, MAX_VISIBLE);
|
}
|
||||||
const overflow = files.length - MAX_VISIBLE;
|
|
||||||
|
function FileList({
|
||||||
|
files,
|
||||||
|
maxVisible,
|
||||||
|
}: {
|
||||||
|
files: FileValue[];
|
||||||
|
maxVisible: number;
|
||||||
|
}) {
|
||||||
|
const visible = files.slice(0, maxVisible);
|
||||||
|
const overflow = files.length - maxVisible;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cellClasses.fileGroup}>
|
<div className={cellClasses.fileGroup}>
|
||||||
{visible.map((file) => (
|
{visible.map((file) => (
|
||||||
<span key={file.id} className={cellClasses.fileBadge}>
|
<span key={file.id} className={cellClasses.fileBadge}>
|
||||||
<IconPaperclip size={12} />
|
<IconPaperclip size={12} />
|
||||||
{file.name}
|
{file.fileName}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{overflow > 0 && (
|
{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 { 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";
|
import cellClasses from "@/features/base/styles/cells.module.css";
|
||||||
|
|
||||||
type CellPersonProps = {
|
type CellPersonProps = {
|
||||||
@@ -10,34 +14,206 @@ type CellPersonProps = {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getInitials(id: string): string {
|
|
||||||
return id.substring(0, 2).toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CellPerson({
|
export function CellPerson({
|
||||||
value,
|
value,
|
||||||
|
isEditing,
|
||||||
|
onCommit,
|
||||||
|
onCancel,
|
||||||
}: CellPersonProps) {
|
}: CellPersonProps) {
|
||||||
const personIds = Array.isArray(value)
|
const personIds = Array.isArray(value)
|
||||||
? (value as string[])
|
? (value as string[])
|
||||||
: typeof value === "string"
|
: typeof value === "string"
|
||||||
? [value]
|
? [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) {
|
if (personIds.length === 0) {
|
||||||
return <span className={cellClasses.emptyValue} />;
|
return <span className={cellClasses.emptyValue} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_VISIBLE = 4;
|
return (
|
||||||
const visible = personIds.slice(0, MAX_VISIBLE);
|
<PersonAvatarList
|
||||||
const overflow = personIds.length - MAX_VISIBLE;
|
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 (
|
return (
|
||||||
<div className={cellClasses.personGroup}>
|
<div className={cellClasses.personGroup}>
|
||||||
{visible.map((id) => (
|
{visible.map((id) => {
|
||||||
<div key={id} className={cellClasses.personAvatar}>
|
const member = memberMap.get(id);
|
||||||
{getInitials(id)}
|
const name = member?.name ?? id.substring(0, 2);
|
||||||
</div>
|
return (
|
||||||
))}
|
<CustomAvatar
|
||||||
|
key={id}
|
||||||
|
avatarUrl={member?.avatarUrl ?? ""}
|
||||||
|
name={name}
|
||||||
|
size={22}
|
||||||
|
radius="xl"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{overflow > 0 && (
|
{overflow > 0 && (
|
||||||
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
<span className={cellClasses.overflowCount}>+{overflow}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
IBaseProperty,
|
IBaseProperty,
|
||||||
|
SelectTypeOptions,
|
||||||
ViewFilterConfig,
|
ViewFilterConfig,
|
||||||
ViewFilterOperator,
|
ViewFilterOperator,
|
||||||
} from "@/features/base/types/base.types";
|
} from "@/features/base/types/base.types";
|
||||||
@@ -32,6 +33,114 @@ const OPERATORS: { value: ViewFilterOperator; labelKey: string }[] = [
|
|||||||
|
|
||||||
const NO_VALUE_OPERATORS: ViewFilterOperator[] = ["isEmpty", "isNotEmpty"];
|
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 = {
|
type ViewFilterConfigProps = {
|
||||||
opened: boolean;
|
opened: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -56,17 +165,14 @@ export function ViewFilterConfigPopover({
|
|||||||
label: p.name,
|
label: p.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const operatorOptions = OPERATORS.map((op) => ({
|
|
||||||
value: op.value,
|
|
||||||
label: t(op.labelKey),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleAdd = useCallback(() => {
|
const handleAdd = useCallback(() => {
|
||||||
const firstProperty = properties[0];
|
const firstProperty = properties[0];
|
||||||
if (!firstProperty) return;
|
if (!firstProperty) return;
|
||||||
|
const validOperators = getOperatorsForType(firstProperty.type);
|
||||||
|
const defaultOperator = validOperators.includes("contains") ? "contains" : validOperators[0];
|
||||||
onChange([
|
onChange([
|
||||||
...filters,
|
...filters,
|
||||||
{ propertyId: firstProperty.id, operator: "contains" },
|
{ propertyId: firstProperty.id, operator: defaultOperator },
|
||||||
]);
|
]);
|
||||||
}, [filters, properties, onChange]);
|
}, [filters, properties, onChange]);
|
||||||
|
|
||||||
@@ -80,11 +186,25 @@ export function ViewFilterConfigPopover({
|
|||||||
const handlePropertyChange = useCallback(
|
const handlePropertyChange = useCallback(
|
||||||
(index: number, propertyId: string | null) => {
|
(index: number, propertyId: string | null) => {
|
||||||
if (!propertyId) return;
|
if (!propertyId) return;
|
||||||
|
const newProperty = properties.find((p) => p.id === propertyId);
|
||||||
onChange(
|
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(
|
const handleOperatorChange = useCallback(
|
||||||
@@ -143,6 +263,16 @@ export function ViewFilterConfigPopover({
|
|||||||
|
|
||||||
{filters.map((filter, index) => {
|
{filters.map((filter, index) => {
|
||||||
const needsValue = !NO_VALUE_OPERATORS.includes(filter.operator);
|
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 (
|
return (
|
||||||
<Group key={index} gap="xs" wrap="nowrap">
|
<Group key={index} gap="xs" wrap="nowrap">
|
||||||
@@ -161,14 +291,11 @@ export function ViewFilterConfigPopover({
|
|||||||
w={130}
|
w={130}
|
||||||
/>
|
/>
|
||||||
{needsValue && (
|
{needsValue && (
|
||||||
<TextInput
|
<FilterValueInput
|
||||||
size="xs"
|
filter={filter}
|
||||||
placeholder={t("Value")}
|
property={property}
|
||||||
value={(filter.value as string) ?? ""}
|
onChange={(val) => handleValueChange(index, val)}
|
||||||
onChange={(e) =>
|
t={t}
|
||||||
handleValueChange(index, e.currentTarget.value)
|
|
||||||
}
|
|
||||||
w={100}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
UpdateRowInput,
|
UpdateRowInput,
|
||||||
DeleteRowInput,
|
DeleteRowInput,
|
||||||
ReorderRowInput,
|
ReorderRowInput,
|
||||||
|
ViewFilterConfig,
|
||||||
|
ViewSortConfig,
|
||||||
} from "@/features/base/types/base.types";
|
} from "@/features/base/types/base.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { queryClient } from "@/main";
|
import { queryClient } from "@/main";
|
||||||
@@ -26,11 +28,15 @@ type RowCacheContext = {
|
|||||||
previous: InfiniteData<IPagination<IBaseRow>> | undefined;
|
previous: InfiniteData<IPagination<IBaseRow>> | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useBaseRowsQuery(baseId: string | undefined) {
|
export function useBaseRowsQuery(
|
||||||
|
baseId: string | undefined,
|
||||||
|
filters?: ViewFilterConfig[],
|
||||||
|
sorts?: ViewSortConfig[],
|
||||||
|
) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["base-rows", baseId],
|
queryKey: ["base-rows", baseId, filters, sorts],
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
listRows(baseId!, { cursor: pageParam, limit: 100 }),
|
listRows(baseId!, { cursor: pageParam, limit: 100, filters, sorts }),
|
||||||
enabled: !!baseId,
|
enabled: !!baseId,
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
getNextPageParam: (lastPage: IPagination<IBaseRow>) =>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
UpdateViewInput,
|
UpdateViewInput,
|
||||||
DeleteViewInput,
|
DeleteViewInput,
|
||||||
UpdatePropertyResult,
|
UpdatePropertyResult,
|
||||||
|
ViewFilterConfig,
|
||||||
|
ViewSortConfig,
|
||||||
} from "@/features/base/types/base.types";
|
} from "@/features/base/types/base.types";
|
||||||
import { IPagination } from "@/lib/types";
|
import { IPagination } from "@/lib/types";
|
||||||
|
|
||||||
@@ -105,7 +107,13 @@ export async function deleteRow(data: DeleteRowInput): Promise<void> {
|
|||||||
|
|
||||||
export async function listRows(
|
export async function listRows(
|
||||||
baseId: string,
|
baseId: string,
|
||||||
params?: { viewId?: string; cursor?: string; limit?: number },
|
params?: {
|
||||||
|
viewId?: string;
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
filters?: ViewFilterConfig[];
|
||||||
|
sorts?: ViewSortConfig[];
|
||||||
|
},
|
||||||
): Promise<IPagination<IBaseRow>> {
|
): Promise<IPagination<IBaseRow>> {
|
||||||
const req = await api.post("/bases/rows/list", { baseId, ...params });
|
const req = await api.post("/bases/rows/list", { baseId, ...params });
|
||||||
return req.data;
|
return req.data;
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
} from '../casl/interfaces/workspace-ability.type';
|
} from '../casl/interfaces/workspace-ability.type';
|
||||||
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { BaseRepo } from '@docmost/db/repos/base/base.repo';
|
||||||
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
import { validate as isValidUUID } from 'uuid';
|
import { validate as isValidUUID } from 'uuid';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
@@ -71,6 +72,7 @@ export class AttachmentController {
|
|||||||
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
private readonly workspaceAbility: WorkspaceAbilityFactory,
|
||||||
private readonly spaceAbility: SpaceAbilityFactory,
|
private readonly spaceAbility: SpaceAbilityFactory,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly baseRepo: BaseRepo,
|
||||||
private readonly attachmentRepo: AttachmentRepo,
|
private readonly attachmentRepo: AttachmentRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
@@ -163,6 +165,87 @@ export class AttachmentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('bases/files/upload')
|
||||||
|
@UseInterceptors(FileInterceptor)
|
||||||
|
async uploadBaseFile(
|
||||||
|
@Req() req: any,
|
||||||
|
@Res() res: FastifyReply,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
|
||||||
|
|
||||||
|
let file = null;
|
||||||
|
try {
|
||||||
|
file = await req.file({
|
||||||
|
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(err.message);
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Failed to upload file');
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseId = file.fields?.baseId?.value;
|
||||||
|
|
||||||
|
if (!baseId) {
|
||||||
|
throw new BadRequestException('baseId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidUUID(baseId)) {
|
||||||
|
throw new BadRequestException('Invalid baseId');
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = await this.baseRepo.findById(baseId);
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
throw new NotFoundException('Base not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaceId = base.spaceId;
|
||||||
|
|
||||||
|
const spaceAbilityCheck = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
spaceAbilityCheck.cannot(
|
||||||
|
SpaceCaslAction.Edit,
|
||||||
|
SpaceCaslSubject.Base,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileResponse = await this.attachmentService.uploadFile({
|
||||||
|
filePromise: file,
|
||||||
|
spaceId: spaceId,
|
||||||
|
userId: user.id,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.send(fileResponse);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.statusCode === 413) {
|
||||||
|
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
|
||||||
|
this.logger.error(errMessage);
|
||||||
|
throw new BadRequestException(errMessage);
|
||||||
|
}
|
||||||
|
this.logger.error(err);
|
||||||
|
throw new BadRequestException('Error processing file upload.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('/files/:fileId/:fileName')
|
@Get('/files/:fileId/:fileName')
|
||||||
async getFile(
|
async getFile(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class AttachmentService {
|
|||||||
|
|
||||||
async uploadFile(opts: {
|
async uploadFile(opts: {
|
||||||
filePromise: Promise<MultipartFile>;
|
filePromise: Promise<MultipartFile>;
|
||||||
pageId: string;
|
pageId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
|||||||
@@ -119,10 +119,10 @@ const typeOptionsSchemaMap: Record<BasePropertyTypeValue, z.ZodType> = {
|
|||||||
export function validateTypeOptions(
|
export function validateTypeOptions(
|
||||||
type: BasePropertyTypeValue,
|
type: BasePropertyTypeValue,
|
||||||
typeOptions: unknown,
|
typeOptions: unknown,
|
||||||
): z.SafeParseReturnType<unknown, unknown> {
|
): z.ZodSafeParseResult<unknown> {
|
||||||
const schema = typeOptionsSchemaMap[type];
|
const schema = typeOptionsSchemaMap[type];
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.SafeParseError<unknown>;
|
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.ZodSafeParseError<unknown>;
|
||||||
}
|
}
|
||||||
return schema.safeParse(typeOptions ?? {});
|
return schema.safeParse(typeOptions ?? {});
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,13 @@ const cellValueSchemaMap: Partial<Record<BasePropertyTypeValue, z.ZodType>> = {
|
|||||||
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
|
[BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()),
|
||||||
[BasePropertyType.DATE]: z.string(),
|
[BasePropertyType.DATE]: z.string(),
|
||||||
[BasePropertyType.PERSON]: z.array(z.string().uuid()),
|
[BasePropertyType.PERSON]: z.array(z.string().uuid()),
|
||||||
[BasePropertyType.FILE]: z.array(z.string().uuid()),
|
[BasePropertyType.FILE]: z.array(z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
fileName: z.string(),
|
||||||
|
mimeType: z.string().optional(),
|
||||||
|
fileSize: z.number().optional(),
|
||||||
|
filePath: z.string().optional(),
|
||||||
|
})),
|
||||||
[BasePropertyType.CHECKBOX]: z.boolean(),
|
[BasePropertyType.CHECKBOX]: z.boolean(),
|
||||||
[BasePropertyType.URL]: z.string().url(),
|
[BasePropertyType.URL]: z.string().url(),
|
||||||
[BasePropertyType.EMAIL]: z.string().email(),
|
[BasePropertyType.EMAIL]: z.string().email(),
|
||||||
@@ -161,10 +167,10 @@ export function getCellValueSchema(
|
|||||||
export function validateCellValue(
|
export function validateCellValue(
|
||||||
type: BasePropertyTypeValue,
|
type: BasePropertyTypeValue,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): z.SafeParseReturnType<unknown, unknown> {
|
): z.ZodSafeParseResult<unknown> {
|
||||||
const schema = cellValueSchemaMap[type];
|
const schema = cellValueSchemaMap[type];
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError<unknown>;
|
return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.ZodSafeParseError<unknown>;
|
||||||
}
|
}
|
||||||
return schema.safeParse(value);
|
return schema.safeParse(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
import { IsNotEmpty, IsObject, IsOptional, IsString, IsUUID, IsArray, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
export class UpdateRowDto {
|
export class UpdateRowDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
@@ -27,6 +28,27 @@ export class RowIdDto {
|
|||||||
baseId: string;
|
baseId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FilterDto {
|
||||||
|
@IsUUID()
|
||||||
|
propertyId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
operator: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
value?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
class SortDto {
|
||||||
|
@IsUUID()
|
||||||
|
propertyId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
direction: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class ListRowsDto {
|
export class ListRowsDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
baseId: string;
|
baseId: string;
|
||||||
@@ -34,6 +56,18 @@ export class ListRowsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
viewId?: string;
|
viewId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => FilterDto)
|
||||||
|
filters?: FilterDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SortDto)
|
||||||
|
sorts?: SortDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ReorderRowDto {
|
export class ReorderRowDto {
|
||||||
|
|||||||
@@ -93,9 +93,25 @@ export class BaseRowService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async list(dto: ListRowsDto, pagination: PaginationOptions) {
|
async list(dto: ListRowsDto, pagination: PaginationOptions) {
|
||||||
|
const hasFilters = dto.filters && dto.filters.length > 0;
|
||||||
|
const hasSorts = dto.sorts && dto.sorts.length > 0;
|
||||||
|
|
||||||
|
if (!hasFilters && !hasSorts) {
|
||||||
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
|
return this.baseRowRepo.findByBaseId(dto.baseId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const properties = await this.basePropertyRepo.findByBaseId(dto.baseId);
|
||||||
|
const propertyTypeMap = new Map(properties.map((p) => [p.id, p.type]));
|
||||||
|
|
||||||
|
return this.baseRowRepo.findByBaseIdFiltered(
|
||||||
|
dto.baseId,
|
||||||
|
dto.filters ?? [],
|
||||||
|
dto.sorts ?? [],
|
||||||
|
propertyTypeMap,
|
||||||
|
pagination,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async reorder(dto: ReorderRowDto) {
|
async reorder(dto: ReorderRowDto) {
|
||||||
const row = await this.baseRowRepo.findById(dto.rowId);
|
const row = await this.baseRowRepo.findById(dto.rowId);
|
||||||
if (!row || row.baseId !== dto.baseId) {
|
if (!row || row.baseId !== dto.baseId) {
|
||||||
|
|||||||
@@ -8,7 +8,20 @@ import {
|
|||||||
} from '@docmost/db/types/entity.types';
|
} from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||||
import { sql } from 'kysely';
|
import { sql, SelectQueryBuilder, SqlBool } from 'kysely';
|
||||||
|
import { DB } from '@docmost/db/types/db';
|
||||||
|
|
||||||
|
const SYSTEM_COLUMN_MAP: Record<string, string> = {
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
lastEditedAt: 'updatedAt',
|
||||||
|
lastEditedBy: 'lastUpdatedById',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ARRAY_TYPES = new Set(['multiSelect', 'person', 'file']);
|
||||||
|
|
||||||
|
function escapeIlike(value: string): string {
|
||||||
|
return value.replace(/[%_\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BaseRowRepo {
|
export class BaseRowRepo {
|
||||||
@@ -169,4 +182,261 @@ export class BaseRowRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findByBaseIdFiltered(
|
||||||
|
baseId: string,
|
||||||
|
filters: Array<{ propertyId: string; operator: string; value?: unknown }>,
|
||||||
|
sorts: Array<{ propertyId: string; direction: string }>,
|
||||||
|
propertyTypeMap: Map<string, string>,
|
||||||
|
pagination: PaginationOptions,
|
||||||
|
opts?: { trx?: KyselyTransaction },
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, opts?.trx);
|
||||||
|
|
||||||
|
let query = db
|
||||||
|
.selectFrom('baseRows')
|
||||||
|
.selectAll()
|
||||||
|
.where('baseId', '=', baseId)
|
||||||
|
.where('deletedAt', 'is', null) as SelectQueryBuilder<DB, 'baseRows', any>;
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
for (const filter of filters) {
|
||||||
|
query = this.applyFilter(query, filter, propertyTypeMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorts
|
||||||
|
for (const sort of sorts) {
|
||||||
|
query = this.applySort(query, sort, propertyTypeMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add position, id as tiebreaker
|
||||||
|
query = query.orderBy('position', 'asc').orderBy('id', 'asc');
|
||||||
|
|
||||||
|
// Simple limit-based pagination (cursor pagination is not used when filters/sorts are active
|
||||||
|
// because JSONB-based cursor expressions are complex)
|
||||||
|
const limit = pagination.limit ?? 20;
|
||||||
|
const rows = await query.limit(limit + 1).execute();
|
||||||
|
|
||||||
|
const hasNextPage = rows.length > limit;
|
||||||
|
if (hasNextPage) rows.pop();
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows,
|
||||||
|
meta: {
|
||||||
|
limit,
|
||||||
|
hasNextPage,
|
||||||
|
hasPrevPage: false,
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyFilter(
|
||||||
|
query: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||||
|
filter: { propertyId: string; operator: string; value?: unknown },
|
||||||
|
propertyTypeMap: Map<string, string>,
|
||||||
|
): SelectQueryBuilder<DB, 'baseRows', any> {
|
||||||
|
const { propertyId, operator, value } = filter;
|
||||||
|
const propertyType = propertyTypeMap.get(propertyId);
|
||||||
|
if (!propertyType) return query;
|
||||||
|
|
||||||
|
// System property -> use actual column
|
||||||
|
const systemCol = SYSTEM_COLUMN_MAP[propertyType];
|
||||||
|
if (systemCol) {
|
||||||
|
return this.applyColumnFilter(query, systemCol, operator, value, propertyType);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isArray = ARRAY_TYPES.has(propertyType);
|
||||||
|
|
||||||
|
// isEmpty / isNotEmpty don't need a value
|
||||||
|
if (operator === 'isEmpty') {
|
||||||
|
if (isArray) {
|
||||||
|
return query.where(({ or, eb }) =>
|
||||||
|
or([
|
||||||
|
eb(sql.raw(`cells->'${propertyId}'`), 'is', null),
|
||||||
|
eb(sql`jsonb_array_length(cells->'${sql.raw(propertyId)}')`, '=', 0),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return query.where(({ or, eb }) =>
|
||||||
|
or([
|
||||||
|
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
|
||||||
|
eb(sql.raw(`cells->>'${propertyId}'`), '=', ''),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'isNotEmpty') {
|
||||||
|
if (isArray) {
|
||||||
|
return query
|
||||||
|
.where(sql.raw(`cells->'${propertyId}'`), 'is not', null)
|
||||||
|
.where(sql`jsonb_array_length(cells->'${sql.raw(propertyId)}')`, '>', 0);
|
||||||
|
}
|
||||||
|
return query
|
||||||
|
.where(sql.raw(`cells->>'${propertyId}'`), 'is not', null)
|
||||||
|
.where(sql.raw(`cells->>'${propertyId}'`), '!=', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || value === null) return query;
|
||||||
|
|
||||||
|
// contains / notContains - text search
|
||||||
|
if (operator === 'contains') {
|
||||||
|
return query.where(
|
||||||
|
sql.raw(`cells->>'${propertyId}'`),
|
||||||
|
'ilike',
|
||||||
|
`%${escapeIlike(String(value))}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operator === 'notContains') {
|
||||||
|
return query.where(({ or, eb }) =>
|
||||||
|
or([
|
||||||
|
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
|
||||||
|
eb(
|
||||||
|
sql.raw(`cells->>'${propertyId}'`),
|
||||||
|
'not ilike',
|
||||||
|
`%${escapeIlike(String(value))}%`,
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// equals / notEquals
|
||||||
|
if (operator === 'equals') {
|
||||||
|
if (isArray) {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (propertyType === 'number') {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric = ${Number(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (propertyType === 'checkbox') {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::boolean = ${Boolean(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return query.where(sql.raw(`cells->>'${propertyId}'`), '=', String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator === 'notEquals') {
|
||||||
|
if (isArray) {
|
||||||
|
return query.where(({ or, eb }) =>
|
||||||
|
or([
|
||||||
|
eb(sql.raw(`cells->'${propertyId}'`), 'is', null),
|
||||||
|
sql<SqlBool>`NOT (cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb)`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (propertyType === 'number') {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric != ${Number(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (propertyType === 'checkbox') {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::boolean != ${Boolean(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return query.where(({ or, eb }) =>
|
||||||
|
or([
|
||||||
|
eb(sql.raw(`cells->>'${propertyId}'`), 'is', null),
|
||||||
|
eb(sql.raw(`cells->>'${propertyId}'`), '!=', String(value)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// greaterThan / lessThan - number
|
||||||
|
if (operator === 'greaterThan') {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric > ${Number(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operator === 'lessThan') {
|
||||||
|
return query.where(
|
||||||
|
sql<SqlBool>`(cells->>'${sql.raw(propertyId)}')::numeric < ${Number(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// before / after - date
|
||||||
|
if (operator === 'before') {
|
||||||
|
return query.where(sql.raw(`cells->>'${propertyId}'`), '<', String(value));
|
||||||
|
}
|
||||||
|
if (operator === 'after') {
|
||||||
|
return query.where(sql.raw(`cells->>'${propertyId}'`), '>', String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyColumnFilter(
|
||||||
|
query: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||||
|
column: string,
|
||||||
|
operator: string,
|
||||||
|
value: unknown,
|
||||||
|
propertyType: string,
|
||||||
|
): SelectQueryBuilder<DB, 'baseRows', any> {
|
||||||
|
if (operator === 'isEmpty') {
|
||||||
|
return query.where(sql.raw(`"${column}"`), 'is', null);
|
||||||
|
}
|
||||||
|
if (operator === 'isNotEmpty') {
|
||||||
|
return query.where(sql.raw(`"${column}"`), 'is not', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || value === null) return query;
|
||||||
|
|
||||||
|
if (operator === 'equals') {
|
||||||
|
return query.where(sql.raw(`"${column}"`), '=', value);
|
||||||
|
}
|
||||||
|
if (operator === 'notEquals') {
|
||||||
|
return query.where(({ or, eb }) =>
|
||||||
|
or([
|
||||||
|
eb(sql.raw(`"${column}"`), 'is', null),
|
||||||
|
eb(sql.raw(`"${column}"`), '!=', value),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (operator === 'before') {
|
||||||
|
return query.where(sql.raw(`"${column}"`), '<', value);
|
||||||
|
}
|
||||||
|
if (operator === 'after') {
|
||||||
|
return query.where(sql.raw(`"${column}"`), '>', value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySort(
|
||||||
|
query: SelectQueryBuilder<DB, 'baseRows', any>,
|
||||||
|
sort: { propertyId: string; direction: string },
|
||||||
|
propertyTypeMap: Map<string, string>,
|
||||||
|
): SelectQueryBuilder<DB, 'baseRows', any> {
|
||||||
|
const { propertyId, direction } = sort;
|
||||||
|
const propertyType = propertyTypeMap.get(propertyId);
|
||||||
|
if (!propertyType) return query;
|
||||||
|
|
||||||
|
const dir = direction === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
// System property -> use actual column
|
||||||
|
const systemCol = SYSTEM_COLUMN_MAP[propertyType];
|
||||||
|
if (systemCol) {
|
||||||
|
return query.orderBy(sql.raw(`"${systemCol}"`), sql`${sql.raw(dir)} NULLS LAST`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number properties: cast to numeric for proper numeric ordering
|
||||||
|
if (propertyType === 'number') {
|
||||||
|
return query.orderBy(
|
||||||
|
sql`(cells->>'${sql.raw(propertyId)}')::numeric`,
|
||||||
|
sql`${sql.raw(dir)} NULLS LAST`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All other properties: use text extraction
|
||||||
|
return query.orderBy(
|
||||||
|
sql.raw(`cells->>'${propertyId}'`),
|
||||||
|
sql`${sql.raw(dir)} NULLS LAST`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user