Skip to content

Commit

Permalink
feat(documents): add document filtering by tags (#146)
Browse files Browse the repository at this point in the history
  • Loading branch information
CorentinTh authored Mar 6, 2025
1 parent ae69eb2 commit 24b80eb
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 35 deletions.
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { Tag } from '@/modules/tags/tags.types';
import type { TooltipTriggerProps } from '@kobalte/core/tooltip';
import type { ColumnDef } from '@tanstack/solid-table';
import type { Document } from '../documents.types';
import { timeAgo } from '@/modules/shared/date/time-ago';
import { cn } from '@/modules/shared/style/cn';
import { Tag } from '@/modules/tags/components/tag.component';
import { TagLink } from '@/modules/tags/components/tag.component';
import { Button } from '@/modules/ui/components/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/modules/ui/components/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/modules/ui/components/table';
Expand Down Expand Up @@ -47,9 +48,9 @@ export const tagsColumn: ColumnDef<Document> = {
accessorKey: 'tags',
cell: data => (
<div class="text-muted-foreground hidden sm:flex flex-wrap gap-1">
<For each={data.getValue<any[]>()}>
<For each={data.getValue<Tag[]>()}>
{tag => (
<Tag {...tag} class="text-xs" />
<TagLink {...tag} class="text-xs" />
)}
</For>
</div>
Expand Down Expand Up @@ -146,9 +147,6 @@ export const DocumentsPaginatedList: Component<{
return (
<div>
<Switch>
<Match when={props.documentsCount === 0}>
<p>No documents found</p>
</Match>
<Match when={props.documentsCount > 0}>
<Table>

Expand Down
28 changes: 15 additions & 13 deletions apps/papra-client/src/modules/documents/documents.composables.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Document } from './documents.types';
import { safely } from '@corentinth/chisels';
import { throttle } from 'lodash-es';
import { createSignal } from 'solid-js';
import { useConfirmModal } from '../shared/confirm';
import { promptUploadFiles } from '../shared/files/upload';
Expand All @@ -8,6 +9,12 @@ import { queryClient } from '../shared/query/query-client';
import { createToast } from '../ui/components/sonner';
import { deleteDocument, restoreDocument, uploadDocument } from './documents.services';

function invalidateOrganizationDocumentsQuery({ organizationId }: { organizationId: string }) {
return queryClient.invalidateQueries({
queryKey: ['organizations', organizationId],
});
}

export function useDeleteDocument() {
const { confirm } = useConfirmModal();

Expand Down Expand Up @@ -41,9 +48,7 @@ export function useDeleteDocument() {
organizationId,
});

await queryClient.invalidateQueries({
queryKey: ['organizations', organizationId],
});
await invalidateOrganizationDocumentsQuery({ organizationId });
createToast({ type: 'success', message: 'Document deleted' });

return { hasDeleted: true };
Expand All @@ -64,9 +69,7 @@ export function useRestoreDocument() {
organizationId: document.organizationId,
});

await queryClient.invalidateQueries({
queryKey: ['organizations', document.organizationId],
});
await invalidateOrganizationDocumentsQuery({ organizationId: document.organizationId });

createToast({ type: 'success', message: 'Document restored' });
setIsRestoring(false);
Expand All @@ -76,7 +79,9 @@ export function useRestoreDocument() {

export function useUploadDocuments({ organizationId }: { organizationId: string }) {
const uploadDocuments = async ({ files }: { files: File[] }) => {
for (const file of files) {
const throttledInvalidateOrganizationDocumentsQuery = throttle(invalidateOrganizationDocumentsQuery, 500);

await Promise.all(files.map(async (file) => {
const [, error] = await safely(uploadDocument({ file, organizationId }));

if (isHttpErrorWithCode({ error, code: 'document.already_exists' })) {
Expand All @@ -86,13 +91,10 @@ export function useUploadDocuments({ organizationId }: { organizationId: string
description: `The document ${file.name} already exists, it has not been uploaded.`,
});
}
}

queryClient.invalidateQueries({
// Invalidate the whole organization as the documents count and stats might have changed
queryKey: ['organizations', organizationId],
refetchType: 'all',
});
await throttledInvalidateOrganizationDocumentsQuery({ organizationId });
}),
);
};

return {
Expand Down
5 changes: 5 additions & 0 deletions apps/papra-client/src/modules/documents/documents.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ export async function fetchOrganizationDocuments({
organizationId,
pageIndex,
pageSize,
filters,
}: {
organizationId: string;
pageIndex: number;
pageSize: number;
filters?: {
tags?: string[];
};
}) {
const {
documents,
Expand All @@ -42,6 +46,7 @@ export async function fetchOrganizationDocuments({
query: {
pageIndex,
pageSize,
...filters,
},
});

Expand Down
43 changes: 39 additions & 4 deletions apps/papra-client/src/modules/documents/pages/documents.page.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,52 @@
import { fetchOrganization } from '@/modules/organizations/organizations.services';
import { useParams } from '@solidjs/router';
import { Tag } from '@/modules/tags/components/tag.component';
import { fetchTags } from '@/modules/tags/tags.services';
import { useParams, useSearchParams } from '@solidjs/router';
import { createQueries, keepPreviousData } from '@tanstack/solid-query';
import { type Component, createSignal, Suspense } from 'solid-js';
import { castArray } from 'lodash-es';
import { type Component, createSignal, For, Show, Suspense } from 'solid-js';
import { DocumentUploadArea } from '../components/document-upload-area.component';
import { createdAtColumn, DocumentsPaginatedList, standardActionsColumn, tagsColumn } from '../components/documents-list.component';
import { fetchOrganizationDocuments } from '../documents.services';

export const DocumentsPage: Component = () => {
const params = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const [getPagination, setPagination] = createSignal({ pageIndex: 0, pageSize: 100 });

const getFiltererTagIds = () => searchParams.tags ? castArray(searchParams.tags) : [];

const query = createQueries(() => ({
queries: [
{
queryKey: ['organizations', params.organizationId, 'documents', getPagination()],
queryKey: ['organizations', params.organizationId, 'documents', getPagination(), getFiltererTagIds()],
queryFn: () => fetchOrganizationDocuments({
organizationId: params.organizationId,
...getPagination(),
filters: {
tags: getFiltererTagIds(),
},
}),
placeholderData: keepPreviousData,
},
{
queryKey: ['organizations', params.organizationId],
queryFn: () => fetchOrganization({ organizationId: params.organizationId }),
},
{
queryKey: ['organizations', params.organizationId, 'tags'],
queryFn: () => fetchTags({ organizationId: params.organizationId }),
},
],
}));

const getFilteredTags = () => query[2].data?.tags.filter(tag => getFiltererTagIds().includes(tag.id)) ?? [];
const hasFilters = () => getFiltererTagIds().length > 0;

return (
<div class="p-6 mt-4 pb-32 max-w-5xl mx-auto">
<Suspense>
{query[0].data?.documents?.length === 0
{query[0].data?.documents?.length === 0 && !hasFilters()
? (
<>
<h2 class="text-xl font-bold ">
Expand All @@ -50,6 +66,25 @@ export const DocumentsPage: Component = () => {
<h2 class="text-lg font-semibold mb-4">
Documents
</h2>
<Show when={hasFilters()}>
<div class="flex flex-wrap gap-2 mb-4">
<For each={getFilteredTags()}>
{tag => (
<Tag
{...tag}
closable
onClose={() => setSearchParams({ tags: getFiltererTagIds().filter(id => id !== tag.id) })}
/>
)}
</For>
</div>
</Show>

<Show when={hasFilters() && query[0].data?.documentsCount === 0}>
<p class="text-muted-foreground mt-1 mb-6">
No documents found
</p>
</Show>

<DocumentsPaginatedList
documents={query[0].data?.documents ?? []}
Expand Down
38 changes: 35 additions & 3 deletions apps/papra-client/src/modules/tags/components/tag.component.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,25 @@
import { cn } from '@/modules/shared/style/cn';
import { A } from '@solidjs/router';
import { type Component, type ComponentProps, splitProps } from 'solid-js';

export const Tag: Component<{
type TagProps = {
name?: string;
description?: string | null;
color?: string;
closable?: boolean;
onClose?: () => void;
} & ComponentProps<'span'>> = (props) => {
};

export const Tag: Component<TagProps & ComponentProps<'span'>> = (props) => {
const [local, rest] = splitProps(props, ['name', 'description', 'color', 'class']);

return (
<span
class={cn('inline-flex gap-2 px-2.5 py-1 leading-none rounded-lg text-sm items-center bg-muted group', local.class)}
class={cn(
'inline-flex gap-2 px-2.5 py-1 leading-none rounded-lg text-sm items-center bg-muted group',
{ 'cursor-pointer': props.closable },
local.class,
)}
{...rest}
{...(props.closable && {
onClick: (e) => {
Expand All @@ -27,3 +34,28 @@ export const Tag: Component<{
</span>
);
};

type TagLinkProps = {
name?: string;
description?: string | null;
color?: string;
organizationId: string;
};

export const TagLink: Component<TagLinkProps & Omit<ComponentProps<typeof A>, 'href'> & { href?: string }> = (props) => {
const [local, rest] = splitProps(props, ['name', 'description', 'color', 'class', 'href']);

return (
<A
href={props.href ?? `/organizations/${props.organizationId}/documents?tags=${props.id}`}
class={cn(
'inline-flex gap-2 px-2.5 py-1 leading-none rounded-lg text-sm items-center bg-muted group hover:underline',
local.class,
)}
{...rest}
>
<span class="size-1.5 rounded-full" style={{ 'background-color': props.color }} />
{props.name}
</A>
);
};
10 changes: 6 additions & 4 deletions apps/papra-client/src/modules/tags/pages/tags.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { TextArea } from '@/modules/ui/components/textarea';
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
import { getValues } from '@modular-forms/solid';
import { useParams } from '@solidjs/router';
import { A, useParams } from '@solidjs/router';
import { createQuery } from '@tanstack/solid-query';
import { type Component, createSignal, For, type JSX, Show, Suspense } from 'solid-js';
import * as v from 'valibot';
Expand Down Expand Up @@ -302,9 +302,11 @@ export const TagsPage: Component = () => {
</div>
</TableCell>
<TableCell>{tag.description || <span class="text-muted-foreground">No description</span>}</TableCell>
<TableCell class="flex items-center gap-1">
<div class="i-tabler-file-text size-5 text-muted-foreground" />
{tag.documentsCount}
<TableCell>
<A href={`/organizations/${params.organizationId}/documents?tags=${tag.id}`} class="inline-flex items-center gap-1 hover:underline">
<div class="i-tabler-file-text size-5 text-muted-foreground" />
{tag.documentsCount}
</A>
</TableCell>
<TableCell class="text-muted-foreground" title={tag.createdAt.toLocaleString()}>
{timeAgo({ date: tag.createdAt })}
Expand Down
19 changes: 17 additions & 2 deletions apps/papra-server/src/modules/documents/documents.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,18 @@ async function saveOrganizationDocument({ db, ...documentToInsert }: { db: Datab
return { document };
}

async function getOrganizationDocumentsCount({ organizationId, db }: { organizationId: string; db: Database }) {
async function getOrganizationDocumentsCount({ organizationId, filters, db }: { organizationId: string; filters?: { tags?: string[] }; db: Database }) {
const [{ documentsCount }] = await db
.select({
documentsCount: count(documentsTable.id),
})
.from(documentsTable)
.leftJoin(documentsTagsTable, eq(documentsTable.id, documentsTagsTable.documentId))
.where(
and(
eq(documentsTable.organizationId, organizationId),
eq(documentsTable.isDeleted, false),
...(filters?.tags ? filters.tags.map(tag => eq(documentsTagsTable.tagId, tag)) : []),
),
);

Expand All @@ -91,7 +93,19 @@ async function getOrganizationDeletedDocumentsCount({ organizationId, db }: { or
return { documentsCount };
}

async function getOrganizationDocuments({ organizationId, pageIndex, pageSize, db }: { organizationId: string; pageIndex: number; pageSize: number; db: Database }) {
async function getOrganizationDocuments({
organizationId,
pageIndex,
pageSize,
filters,
db,
}: {
organizationId: string;
pageIndex: number;
pageSize: number;
filters?: { tags?: string[] };
db: Database;
}) {
const query = db
.select({
document: omit(getTableColumns(documentsTable), ['content']),
Expand All @@ -104,6 +118,7 @@ async function getOrganizationDocuments({ organizationId, pageIndex, pageSize, d
and(
eq(documentsTable.organizationId, organizationId),
eq(documentsTable.isDeleted, false),
...(filters?.tags ? filters.tags.map(tag => eq(documentsTagsTable.tagId, tag)) : []),
),
);

Expand Down
10 changes: 7 additions & 3 deletions apps/papra-server/src/modules/documents/documents.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,14 +105,18 @@ function setupGetDocumentsRoute({ app }: { app: ServerInstance }) {
z.object({
pageIndex: z.coerce.number().min(0).int().optional().default(0),
pageSize: z.coerce.number().min(1).max(100).int().optional().default(100),
tags: z.union([
z.array(z.string()),
z.string().transform(value => [value]),
]).optional(),
}),
),
async (context) => {
const { userId } = getUser({ context });
const { db } = getDb({ context });

const { organizationId } = context.req.valid('param');
const { pageIndex, pageSize } = context.req.valid('query');
const { pageIndex, pageSize, tags } = context.req.valid('query');

const documentsRepository = createDocumentsRepository({ db });
const organizationsRepository = createOrganizationsRepository({ db });
Expand All @@ -123,8 +127,8 @@ function setupGetDocumentsRoute({ app }: { app: ServerInstance }) {
{ documents },
{ documentsCount },
] = await Promise.all([
documentsRepository.getOrganizationDocuments({ organizationId, pageIndex, pageSize }),
documentsRepository.getOrganizationDocumentsCount({ organizationId }),
documentsRepository.getOrganizationDocuments({ organizationId, pageIndex, pageSize, filters: { tags } }),
documentsRepository.getOrganizationDocumentsCount({ organizationId, filters: { tags } }),
]);

return context.json({
Expand Down

0 comments on commit 24b80eb

Please sign in to comment.