Add document classification filter

Add classification as a filter-only field on documents, resolved from
the latest document version. Expose in GraphQL, MCP, and document list
UI with a selector alongside the document type filter.

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-03-31 16:11:49 +02:00
parent 4a6cbdc97b
commit 2d11038232
6 changed files with 93 additions and 24 deletions

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers"; import { documentClassifications, documentTypes, getDocumentClassificationLabel, getDocumentTypeLabel, sprintf } from "@probo/helpers";
import { useList } from "@probo/hooks"; import { useList } from "@probo/hooks";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui"; import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
@@ -23,7 +23,7 @@ import { ConnectionHandler, graphql } from "relay-runtime";
import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql"; import type { DocumentListBulkArchiveMutation } from "#/__generated__/core/DocumentListBulkArchiveMutation.graphql";
import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql"; import type { DocumentListBulkUnarchiveMutation } from "#/__generated__/core/DocumentListBulkUnarchiveMutation.graphql";
import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql"; import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql";
import type { DocumentOrderField, DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql"; import type { DocumentClassification, DocumentOrderField, DocumentsListQuery, DocumentType } from "#/__generated__/core/DocumentsListQuery.graphql";
import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog"; import { BulkExportDialog, type BulkExportDialogRef } from "#/components/documents/BulkExportDialog";
import { type Order, SortableTable, SortableTh } from "#/components/SortableTable"; import { type Order, SortableTable, SortableTh } from "#/components/SortableTable";
import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph"; import { useBulkDeleteDocumentsMutation, useBulkExportDocumentsMutation } from "#/hooks/graph/DocumentGraph";
@@ -49,6 +49,7 @@ const fragment = graphql`
last: { type: "Int", defaultValue: null } last: { type: "Int", defaultValue: null }
status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] } status: { type: "[DocumentStatus!]", defaultValue: [ACTIVE] }
documentTypes: { type: "[DocumentType!]", defaultValue: null } documentTypes: { type: "[DocumentType!]", defaultValue: null }
classifications: { type: "[DocumentClassification!]", defaultValue: null }
) { ) {
documents( documents(
first: $first first: $first
@@ -56,7 +57,7 @@ const fragment = graphql`
last: $last last: $last
before: $before before: $before
orderBy: $order orderBy: $order
filter: { status: $status documentTypes: $documentTypes } filter: { status: $status documentTypes: $documentTypes classifications: $classifications }
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) { ) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
__id __id
edges { edges {
@@ -128,17 +129,22 @@ export function DocumentList(props: {
); );
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null); const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
const [classificationFilter, setClassificationFilter] = useState<DocumentClassification | null>(null);
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const refetch = pagination.refetch; const refetch = pagination.refetch;
useEffect(() => { useEffect(() => {
startTransition(() => { startTransition(() => {
refetch( refetch(
{ status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null }, {
status: [tab],
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
classifications: classificationFilter ? [classificationFilter] : null,
},
{ fetchPolicy: "store-and-network" }, { fetchPolicy: "store-and-network" },
); );
}); });
}, [tab, refetch, documentTypeFilter]); }, [tab, refetch, documentTypeFilter, classificationFilter]);
const documents = pagination.data.documents.edges.map(({ node }) => node); const documents = pagination.data.documents.edges.map(({ node }) => node);
const connectionId = pagination.data.documents.__id; const connectionId = pagination.data.documents.__id;
@@ -184,7 +190,31 @@ export function DocumentList(props: {
"DocumentsListQuery_documents", "DocumentsListQuery_documents",
{ {
orderBy: { direction: "ASC", field: "TITLE" }, orderBy: { direction: "ASC", field: "TITLE" },
filter: { status: [tab], documentTypes: newType ? [newType] : null }, filter: {
status: [tab],
documentTypes: newType ? [newType] : null,
classifications: classificationFilter ? [classificationFilter] : null,
},
},
),
);
};
const handleClassificationFilterChange = (value: string) => {
const newClassification = value === "ALL" ? null : (value as DocumentClassification);
clear();
setClassificationFilter(newClassification);
onConnectionIdChange(
ConnectionHandler.getConnectionID(
organizationId,
"DocumentsListQuery_documents",
{
orderBy: { direction: "ASC", field: "TITLE" },
filter: {
status: [tab],
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
classifications: newClassification ? [newClassification] : null,
},
}, },
), ),
); );
@@ -263,7 +293,11 @@ export function DocumentList(props: {
"DocumentsListQuery_documents", "DocumentsListQuery_documents",
{ {
orderBy: order, orderBy: order,
filter: { status: [tab], documentTypes: documentTypeFilter ? [documentTypeFilter] : null }, filter: {
status: [tab],
documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
classifications: classificationFilter ? [classificationFilter] : null,
},
}, },
), ),
); );
@@ -274,6 +308,7 @@ export function DocumentList(props: {
order: { direction: order.direction, field: order.field as DocumentOrderField }, order: { direction: order.direction, field: order.field as DocumentOrderField },
status: [tab], status: [tab],
documentTypes: documentTypeFilter ? [documentTypeFilter] : null, documentTypes: documentTypeFilter ? [documentTypeFilter] : null,
classifications: classificationFilter ? [classificationFilter] : null,
}); });
}; };
@@ -291,6 +326,17 @@ export function DocumentList(props: {
</Option> </Option>
))} ))}
</Select> </Select>
<Select
value={classificationFilter ?? "ALL"}
onValueChange={handleClassificationFilterChange}
>
<Option value="ALL">{__("All classifications")}</Option>
{documentClassifications.map(classification => (
<Option key={classification} value={classification}>
{getDocumentClassificationLabel(__, classification) ?? classification}
</Option>
))}
</Select>
</div> </div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}> <div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
{documents.length > 0 {documents.length > 0

View File

@@ -28,6 +28,7 @@ type (
userEmail *mail.Addr userEmail *mail.Addr
approverIdentityID *gid.GID approverIdentityID *gid.GID
documentTypes []DocumentType documentTypes []DocumentType
classifications []DocumentClassification
status []DocumentStatus status []DocumentStatus
} }
) )
@@ -70,6 +71,11 @@ func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *Docume
return f return f
} }
func (f *DocumentFilter) WithClassifications(classifications []DocumentClassification) *DocumentFilter {
f.classifications = classifications
return f
}
func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter { func (f *DocumentFilter) WithStatus(status []DocumentStatus) *DocumentFilter {
f.status = status f.status = status
return f return f
@@ -92,6 +98,14 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
} }
} }
var classifications []string
if f.classifications != nil {
classifications = make([]string, len(f.classifications))
for i, c := range f.classifications {
classifications[i] = c.String()
}
}
var status []string var status []string
if f.status != nil { if f.status != nil {
status = make([]string, len(f.status)) status = make([]string, len(f.status))
@@ -107,6 +121,7 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
"user_email": f.userEmail, "user_email": f.userEmail,
"approver_identity_id": f.approverIdentityID, "approver_identity_id": f.approverIdentityID,
"document_types": documentTypes, "document_types": documentTypes,
"classifications": classifications,
"document_status": status, "document_status": status,
} }
} }
@@ -169,6 +184,18 @@ func (f *DocumentFilter) SQLFragment() string {
ELSE TRUE ELSE TRUE
END END
AND AND
CASE
WHEN @classifications::document_classification[] IS NOT NULL THEN
(
SELECT dv.classification
FROM document_versions dv
WHERE dv.document_id = documents.id
ORDER BY dv.major DESC, dv.minor DESC
LIMIT 1
) = ANY(@classifications::document_classification[])
ELSE TRUE
END
AND
CASE CASE
WHEN @document_status::text[] IS NULL THEN TRUE WHEN @document_status::text[] IS NULL THEN TRUE
ELSE status::text = ANY(@document_status::text[]) ELSE status::text = ANY(@document_status::text[])

View File

@@ -1634,6 +1634,7 @@ input ControlFilter {
input DocumentFilter { input DocumentFilter {
query: String query: String
documentTypes: [DocumentType!] documentTypes: [DocumentType!]
classifications: [DocumentClassification!]
status: [DocumentStatus!] status: [DocumentStatus!]
} }

View File

@@ -1,17 +1,3 @@
// Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package console_v1 package console_v1
// This file will be automatically regenerated based on the schema, any resolver // This file will be automatically regenerated based on the schema, any resolver
@@ -625,7 +611,8 @@ func (r *controlResolver) Documents(ctx context.Context, obj *types.Control, fir
var documentFilter = coredata.NewDocumentFilter(nil) var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil { if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query). documentFilter = coredata.NewDocumentFilter(filter.Query).
WithDocumentTypes(filter.DocumentTypes) WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications)
} }
page, err := prb.Documents.ListForControlID(ctx, obj.ID, cursor, documentFilter) page, err := prb.Documents.ListForControlID(ctx, obj.ID, cursor, documentFilter)
@@ -7262,6 +7249,7 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
if filter != nil { if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query). documentFilter = coredata.NewDocumentFilter(filter.Query).
WithDocumentTypes(filter.DocumentTypes). WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications).
WithStatus(filter.Status) WithStatus(filter.Status)
} }
@@ -8674,7 +8662,8 @@ func (r *riskResolver) Documents(ctx context.Context, obj *types.Risk, first *in
var documentFilter = coredata.NewDocumentFilter(nil) var documentFilter = coredata.NewDocumentFilter(nil)
if filter != nil { if filter != nil {
documentFilter = coredata.NewDocumentFilter(filter.Query). documentFilter = coredata.NewDocumentFilter(filter.Query).
WithDocumentTypes(filter.DocumentTypes) WithDocumentTypes(filter.DocumentTypes).
WithClassifications(filter.Classifications)
} }
page, err := prb.Documents.ListForRiskID(ctx, obj.ID, cursor, documentFilter) page, err := prb.Documents.ListForRiskID(ctx, obj.ID, cursor, documentFilter)

View File

@@ -2042,7 +2042,8 @@ func (r *Resolver) ListDocumentsTool(ctx context.Context, req *mcp.CallToolReque
} }
documentFilter = coredata.NewDocumentFilter(query). documentFilter = coredata.NewDocumentFilter(query).
WithDocumentTypes(input.Filter.DocumentTypes) WithDocumentTypes(input.Filter.DocumentTypes).
WithClassifications(input.Filter.Classifications)
} }
docPage, err := prb.Documents.ListByOrganizationID(ctx, input.OrganizationID, cursor, documentFilter) docPage, err := prb.Documents.ListByOrganizationID(ctx, input.OrganizationID, cursor, documentFilter)

View File

@@ -5374,6 +5374,11 @@ components:
items: items:
$ref: "#/components/schemas/DocumentType" $ref: "#/components/schemas/DocumentType"
description: Document types description: Document types
classifications:
type: array
items:
$ref: "#/components/schemas/DocumentClassification"
description: Document classifications
ListDocumentsOutput: ListDocumentsOutput:
type: object type: object