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:
@@ -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
|
||||||
|
|||||||
@@ -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[])
|
||||||
|
|||||||
@@ -1634,6 +1634,7 @@ input ControlFilter {
|
|||||||
input DocumentFilter {
|
input DocumentFilter {
|
||||||
query: String
|
query: String
|
||||||
documentTypes: [DocumentType!]
|
documentTypes: [DocumentType!]
|
||||||
|
classifications: [DocumentClassification!]
|
||||||
status: [DocumentStatus!]
|
status: [DocumentStatus!]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user