From 674b0ec64a7f45937a654d3b5e7c035d398b8ab3 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 8 Mar 2026 03:15:49 +0000 Subject: [PATCH] filter/sort, file, person --- .../features/base/components/base-table.tsx | 19 +- .../base/components/cells/cell-file.tsx | 214 +++++++++++++- .../base/components/cells/cell-person.tsx | 200 ++++++++++++- .../components/views/view-filter-config.tsx | 159 ++++++++-- .../features/base/queries/base-row-query.ts | 12 +- .../features/base/services/base-service.ts | 10 +- .../core/attachment/attachment.controller.ts | 83 ++++++ .../attachment/services/attachment.service.ts | 2 +- apps/server/src/core/base/base.schemas.ts | 16 +- .../src/core/base/dto/update-row.dto.ts | 36 ++- .../core/base/services/base-row.service.ts | 18 +- .../src/database/repos/base/base-row.repo.ts | 272 +++++++++++++++++- 12 files changed, 982 insertions(+), 59 deletions(-) diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx index bfd2b439..a465cd62 100644 --- a/apps/client/src/features/base/components/base-table.tsx +++ b/apps/client/src/features/base/components/base-table.tsx @@ -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); diff --git a/apps/client/src/features/base/components/cells/cell-file.tsx b/apps/client/src/features/base/components/cells/cell-file.tsx index 878758e6..72a8f793 100644 --- a/apps/client/src/features/base/components/cells/cell-file.tsx +++ b/apps/client/src/features/base/components/cells/cell-file.tsx @@ -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(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( + "/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 ( + + +
+ +
+
+ + {files.length === 0 && !uploading && ( + + No files attached + + )} + + {files.map((file) => ( +
+ +
+ + {file.fileName} + + {file.fileSize != null && ( + + {formatFileSize(file.fileSize)} + + )} +
+ handleRemove(file.id)} + > + + +
+ ))} + + { + handleUpload(e.target.files); + e.target.value = ""; + }} + /> + + 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)", + }} + > + + {uploading ? "Uploading..." : "Add file"} + +
+
+ ); + } if (files.length === 0) { return ; } - const MAX_VISIBLE = 2; - const visible = files.slice(0, MAX_VISIBLE); - const overflow = files.length - MAX_VISIBLE; + return ; +} + +function FileList({ + files, + maxVisible, +}: { + files: FileValue[]; + maxVisible: number; +}) { + const visible = files.slice(0, maxVisible); + const overflow = files.length - maxVisible; return (
{visible.map((file) => ( - {file.name} + {file.fileName} ))} {overflow > 0 && ( diff --git a/apps/client/src/features/base/components/cells/cell-person.tsx b/apps/client/src/features/base/components/cells/cell-person.tsx index b53b0c9b..1769b1b8 100644 --- a/apps/client/src/features/base/components/cells/cell-person.tsx +++ b/apps/client/src/features/base/components/cells/cell-person.tsx @@ -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(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(); + 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 ( + + +
+ +
+
+ + setSearch(e.currentTarget.value)} + onKeyDown={handleKeyDown} + mb={4} + /> +
+ {filteredMembers.map((member) => ( +
handleToggle(member.id)} + > +
+ +
+
+ {member.name} +
+ {member.email && ( +
+ {member.email} +
+ )} +
+
+
+ ))} + {filteredMembers.length === 0 && ( +
+ No members found +
+ )} +
+
+
+ ); + } if (personIds.length === 0) { return ; } - const MAX_VISIBLE = 4; - const visible = personIds.slice(0, MAX_VISIBLE); - const overflow = personIds.length - MAX_VISIBLE; + return ( + + ); +} + +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 (
- {visible.map((id) => ( -
- {getInitials(id)} -
- ))} + {visible.map((id) => { + const member = memberMap.get(id); + const name = member?.name ?? id.substring(0, 2); + return ( + + ); + })} {overflow > 0 && ( +{overflow} )} diff --git a/apps/client/src/features/base/components/views/view-filter-config.tsx b/apps/client/src/features/base/components/views/view-filter-config.tsx index 28b91e52..44d211a1 100644 --- a/apps/client/src/features/base/components/views/view-filter-config.tsx +++ b/apps/client/src/features/base/components/views/view-filter-config.tsx @@ -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 ( + 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 ( + onChange(val ?? "")} + w={100} + /> + ); + } + + return ( + 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 ( @@ -161,14 +291,11 @@ export function ViewFilterConfigPopover({ w={130} /> {needsValue && ( - - handleValueChange(index, e.currentTarget.value) - } - w={100} + handleValueChange(index, val)} + t={t} /> )} > | 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) => diff --git a/apps/client/src/features/base/services/base-service.ts b/apps/client/src/features/base/services/base-service.ts index b00ad7f9..414ef768 100644 --- a/apps/client/src/features/base/services/base-service.ts +++ b/apps/client/src/features/base/services/base-service.ts @@ -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 { 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> { const req = await api.post("/bases/rows/list", { baseId, ...params }); return req.data; diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index d70f0034..5ce0c854 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -47,6 +47,7 @@ import { } from '../casl/interfaces/workspace-ability.type'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; 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 { validate as isValidUUID } from 'uuid'; import { EnvironmentService } from '../../integrations/environment/environment.service'; @@ -71,6 +72,7 @@ export class AttachmentController { private readonly workspaceAbility: WorkspaceAbilityFactory, private readonly spaceAbility: SpaceAbilityFactory, private readonly pageRepo: PageRepo, + private readonly baseRepo: BaseRepo, private readonly attachmentRepo: AttachmentRepo, private readonly environmentService: EnvironmentService, 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) @Get('/files/:fileId/:fileName') async getFile( diff --git a/apps/server/src/core/attachment/services/attachment.service.ts b/apps/server/src/core/attachment/services/attachment.service.ts index bc6a1e36..7f5d3678 100644 --- a/apps/server/src/core/attachment/services/attachment.service.ts +++ b/apps/server/src/core/attachment/services/attachment.service.ts @@ -43,7 +43,7 @@ export class AttachmentService { async uploadFile(opts: { filePromise: Promise; - pageId: string; + pageId?: string; userId: string; spaceId: string; workspaceId: string; diff --git a/apps/server/src/core/base/base.schemas.ts b/apps/server/src/core/base/base.schemas.ts index 9ecff634..eb9e82e6 100644 --- a/apps/server/src/core/base/base.schemas.ts +++ b/apps/server/src/core/base/base.schemas.ts @@ -119,10 +119,10 @@ const typeOptionsSchemaMap: Record = { export function validateTypeOptions( type: BasePropertyTypeValue, typeOptions: unknown, -): z.SafeParseReturnType { +): z.ZodSafeParseResult { const schema = typeOptionsSchemaMap[type]; if (!schema) { - return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.SafeParseError; + return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: ['type'] }]) } as z.ZodSafeParseError; } return schema.safeParse(typeOptions ?? {}); } @@ -146,7 +146,13 @@ const cellValueSchemaMap: Partial> = { [BasePropertyType.MULTI_SELECT]: z.array(z.string().uuid()), [BasePropertyType.DATE]: z.string(), [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.URL]: z.string().url(), [BasePropertyType.EMAIL]: z.string().email(), @@ -161,10 +167,10 @@ export function getCellValueSchema( export function validateCellValue( type: BasePropertyTypeValue, value: unknown, -): z.SafeParseReturnType { +): z.ZodSafeParseResult { const schema = cellValueSchemaMap[type]; if (!schema) { - return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.SafeParseError; + return { success: false, error: new z.ZodError([{ code: 'custom', message: `Unknown property type: ${type}`, path: [] }]) } as z.ZodSafeParseError; } return schema.safeParse(value); } diff --git a/apps/server/src/core/base/dto/update-row.dto.ts b/apps/server/src/core/base/dto/update-row.dto.ts index 1f8aa1be..095fe7a3 100644 --- a/apps/server/src/core/base/dto/update-row.dto.ts +++ b/apps/server/src/core/base/dto/update-row.dto.ts @@ -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 { @IsUUID() @@ -27,6 +28,27 @@ export class RowIdDto { 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 { @IsUUID() baseId: string; @@ -34,6 +56,18 @@ export class ListRowsDto { @IsOptional() @IsUUID() viewId?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FilterDto) + filters?: FilterDto[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => SortDto) + sorts?: SortDto[]; } export class ReorderRowDto { diff --git a/apps/server/src/core/base/services/base-row.service.ts b/apps/server/src/core/base/services/base-row.service.ts index 5dbec2a4..398bc586 100644 --- a/apps/server/src/core/base/services/base-row.service.ts +++ b/apps/server/src/core/base/services/base-row.service.ts @@ -93,7 +93,23 @@ export class BaseRowService { } async list(dto: ListRowsDto, pagination: PaginationOptions) { - return this.baseRowRepo.findByBaseId(dto.baseId, pagination); + 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); + } + + 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) { diff --git a/apps/server/src/database/repos/base/base-row.repo.ts b/apps/server/src/database/repos/base/base-row.repo.ts index 2f5d59a8..b0d906f1 100644 --- a/apps/server/src/database/repos/base/base-row.repo.ts +++ b/apps/server/src/database/repos/base/base-row.repo.ts @@ -8,7 +8,20 @@ import { } from '@docmost/db/types/entity.types'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; 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 = { + createdAt: 'createdAt', + lastEditedAt: 'updatedAt', + lastEditedBy: 'lastUpdatedById', +}; + +const ARRAY_TYPES = new Set(['multiSelect', 'person', 'file']); + +function escapeIlike(value: string): string { + return value.replace(/[%_\\]/g, '\\$&'); +} @Injectable() export class BaseRowRepo { @@ -169,4 +182,261 @@ export class BaseRowRepo { .execute(); } } + + async findByBaseIdFiltered( + baseId: string, + filters: Array<{ propertyId: string; operator: string; value?: unknown }>, + sorts: Array<{ propertyId: string; direction: string }>, + propertyTypeMap: Map, + 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; + + // 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, + filter: { propertyId: string; operator: string; value?: unknown }, + propertyTypeMap: Map, + ): SelectQueryBuilder { + 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`cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb`, + ); + } + if (propertyType === 'number') { + return query.where( + sql`(cells->>'${sql.raw(propertyId)}')::numeric = ${Number(value)}`, + ); + } + if (propertyType === 'checkbox') { + return query.where( + sql`(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`NOT (cells->'${sql.raw(propertyId)}' @> ${JSON.stringify([value])}::jsonb)`, + ]), + ); + } + if (propertyType === 'number') { + return query.where( + sql`(cells->>'${sql.raw(propertyId)}')::numeric != ${Number(value)}`, + ); + } + if (propertyType === 'checkbox') { + return query.where( + sql`(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`(cells->>'${sql.raw(propertyId)}')::numeric > ${Number(value)}`, + ); + } + if (operator === 'lessThan') { + return query.where( + sql`(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, + column: string, + operator: string, + value: unknown, + propertyType: string, + ): SelectQueryBuilder { + 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, + sort: { propertyId: string; direction: string }, + propertyTypeMap: Map, + ): SelectQueryBuilder { + 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`, + ); + } }