Add document types filtering and rename ISMS to GOVERNANCE

Adds 5 new document types (PLAN, REGISTER, RECORD, REPORT, TEMPLATE), renames ISMS to GOVERNANCE, and implements type-based filtering across GraphQL, MCP, and frontend. Includes migration, enum updates, filter implementation with SQL array support, and frontend dropdown UI with Relay refetch pattern.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-19 11:24:45 +01:00
parent 2b08dea600
commit 4f54241382
15 changed files with 178 additions and 37 deletions

View File

@@ -6,7 +6,7 @@ export const documentSchema = z.object({
title: z.string().min(1, "Title is required"), title: z.string().min(1, "Title is required"),
content: z.string().min(1, "Content is required"), content: z.string().min(1, "Content is required"),
approverIds: z.array(z.string()).min(1, "At least one approver is required"), approverIds: z.array(z.string()).min(1, "At least one approver is required"),
documentType: z.enum(["OTHER", "ISMS", "POLICY", "PROCEDURE"]), documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]), classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
}); });

View File

@@ -29,7 +29,7 @@ export const documentsPageQuery = graphql`
... on Organization { ... on Organization {
canCreateDocument: permission(action: "core:document:create") canCreateDocument: permission(action: "core:document:create")
...DocumentListFragment @arguments(first: 50, order: { field: TITLE, direction: ASC }) ...DocumentListFragment @arguments(first: 50, order: { field: TITLE, direction: ASC })
documents(first: 50, orderBy: { field: TITLE, direction: ASC }) { allDocuments: documents(first: 50, orderBy: { field: TITLE, direction: ASC }) {
edges { edges {
node { node {
canSendSigningNotifications: permission( canSendSigningNotifications: permission(
@@ -63,7 +63,7 @@ export default function DocumentsPage(props: {
usePageTitle(__("Documents")); usePageTitle(__("Documents"));
const canSendAnySignatureNotifications = organization.documents.edges.some( const canSendAnySignatureNotifications = organization.allDocuments.edges.some(
({ node: { canSendSigningNotifications } }) => canSendSigningNotifications, ({ node: { canSendSigningNotifications } }) => canSendSigningNotifications,
); );
@@ -71,7 +71,10 @@ export default function DocumentsPage(props: {
ConnectionHandler.getConnectionID( ConnectionHandler.getConnectionID(
organizationId, organizationId,
"DocumentsListQuery_documents", "DocumentsListQuery_documents",
{ orderBy: { direction: "ASC", field: "TITLE" } }, {
orderBy: { direction: "ASC", field: "TITLE" },
filter: { documentTypes: null },
},
), ),
); );

View File

@@ -1,13 +1,13 @@
import { sprintf } from "@probo/helpers"; import { documentTypes, 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, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui"; import { Button, Card, Checkbox, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
import { type ComponentProps, use, useRef } from "react"; import { type ComponentProps, use, useRef, useState, useTransition } from "react";
import { usePaginationFragment } from "react-relay"; import { usePaginationFragment } from "react-relay";
import { ConnectionHandler, graphql } from "relay-runtime"; import { ConnectionHandler, graphql } from "relay-runtime";
import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql"; import type { DocumentListFragment$key } from "#/__generated__/core/DocumentListFragment.graphql";
import type { DocumentsListQuery } from "#/__generated__/core/DocumentsListQuery.graphql"; import type { 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";
@@ -30,6 +30,7 @@ const fragment = graphql`
after: { type: "CursorKey", defaultValue: null } after: { type: "CursorKey", defaultValue: null }
before: { type: "CursorKey", defaultValue: null } before: { type: "CursorKey", defaultValue: null }
last: { type: "Int", defaultValue: null } last: { type: "Int", defaultValue: null }
documentTypes: { type: "[DocumentType!]", defaultValue: null }
) { ) {
documents( documents(
first: $first first: $first
@@ -37,7 +38,8 @@ const fragment = graphql`
last: $last last: $last
before: $before before: $before
orderBy: $order orderBy: $order
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy"]) { filter: { documentTypes: $documentTypes }
) @connection(key: "DocumentsListQuery_documents" filters: ["orderBy", "filter"]) {
__id __id
edges { edges {
node { node {
@@ -79,6 +81,19 @@ export function DocumentList(props: {
= useBulkExportDocumentsMutation(); = useBulkExportDocumentsMutation();
const { list: selection, toggle, clear, reset } = useList<string>([]); const { list: selection, toggle, clear, reset } = useList<string>([]);
const confirm = useConfirm(); const confirm = useConfirm();
const [isPending, startTransition] = useTransition();
const [documentTypeFilter, setDocumentTypeFilter] = useState<DocumentType | null>(null);
const handleDocumentTypeFilterChange = (value: string) => {
const newType = value === "ALL" ? null : (value as DocumentType);
setDocumentTypeFilter(newType);
startTransition(() => {
pagination.refetch(
{ documentTypes: newType ? [newType] : null },
{ fetchPolicy: "network-only" },
);
});
};
const canDeleteAny = documents.some(({ canDelete }) => canDelete); const canDeleteAny = documents.some(({ canDelete }) => canDelete);
const canUpdateAny = documents.some(({ canUpdate }) => canUpdate); const canUpdateAny = documents.some(({ canUpdate }) => canUpdate);
@@ -134,12 +149,31 @@ export function DocumentList(props: {
ConnectionHandler.getConnectionID( ConnectionHandler.getConnectionID(
organizationId, organizationId,
"DocumentsListQuery_documents", "DocumentsListQuery_documents",
{ orderBy: order }, {
orderBy: order,
filter: { documentTypes: documentTypeFilter ? [documentTypeFilter] : null },
},
), ),
); );
}; };
return documents.length > 0 return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<Select
value={documentTypeFilter ?? "ALL"}
onValueChange={handleDocumentTypeFilterChange}
>
<Option value="ALL">{__("All types")}</Option>
{documentTypes.map((type) => (
<Option key={type} value={type}>
{getDocumentTypeLabel(__, type) ?? type}
</Option>
))}
</Select>
</div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
{documents.length > 0
? ( ? (
<SortableTable <SortableTable
{...pagination} {...pagination}
@@ -273,5 +307,8 @@ export function DocumentList(props: {
</p> </p>
</div> </div>
</Card> </Card>
); )}
</div>
</div>
);
} }

View File

@@ -2,8 +2,20 @@ export function documentTypeLabel(type: string, __: (s: string) => string) {
switch (type) { switch (type) {
case "POLICY": case "POLICY":
return __("Policy"); return __("Policy");
case "ISMS": case "GOVERNANCE":
return __("Security"); return __("Governance");
case "PROCEDURE":
return __("Procedure");
case "PLAN":
return __("Plan");
case "REGISTER":
return __("Register");
case "RECORD":
return __("Record");
case "REPORT":
return __("Report");
case "TEMPLATE":
return __("Template");
default: default:
return __("Other"); return __("Other");
} }

View File

@@ -72,15 +72,15 @@ func TestDocument_Create(t *testing.T) {
assertValue: "PROCEDURE", assertValue: "PROCEDURE",
}, },
{ {
name: "with ISMS type", name: "with GOVERNANCE type",
input: map[string]any{ input: map[string]any{
"title": "ISMS Document", "title": "Governance Document",
"content": "ISMS content", "content": "Governance content",
"documentType": "ISMS", "documentType": "GOVERNANCE",
"classification": "INTERNAL", "classification": "INTERNAL",
}, },
assertField: "documentType", assertField: "documentType",
assertValue: "ISMS", assertValue: "GOVERNANCE",
}, },
{ {
name: "with OTHER type", name: "with OTHER type",

View File

@@ -1,17 +1,27 @@
type Translator = (s: string) => string; type Translator = (s: string) => string;
export const documentTypes = ["OTHER", "ISMS", "POLICY", "PROCEDURE"] as const; export const documentTypes = ["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"] as const;
export function getDocumentTypeLabel(__: Translator, type: string) { export function getDocumentTypeLabel(__: Translator, type: string) {
switch (type) { switch (type) {
case "OTHER": case "OTHER":
return __("Other"); return __("Other");
case "ISMS": case "GOVERNANCE":
return __("ISMS"); return __("Governance");
case "POLICY": case "POLICY":
return __("Policy"); return __("Policy");
case "PROCEDURE": case "PROCEDURE":
return __("Procedure"); return __("Procedure");
case "PLAN":
return __("Plan");
case "REGISTER":
return __("Register");
case "RECORD":
return __("Record");
case "REPORT":
return __("Report");
case "TEMPLATE":
return __("Template");
} }
} }

View File

@@ -25,6 +25,7 @@ type (
trustCenterVisibilities []TrustCenterVisibility trustCenterVisibilities []TrustCenterVisibility
published *bool published *bool
userEmail *mail.Addr userEmail *mail.Addr
documentTypes []DocumentType
} }
) )
@@ -55,6 +56,11 @@ func (f *DocumentFilter) WithUserEmail(userEmail *mail.Addr) *DocumentFilter {
return f return f
} }
func (f *DocumentFilter) WithDocumentTypes(documentTypes []DocumentType) *DocumentFilter {
f.documentTypes = documentTypes
return f
}
func (f *DocumentFilter) SQLArguments() pgx.NamedArgs { func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
var visibilities []string var visibilities []string
if f.trustCenterVisibilities != nil { if f.trustCenterVisibilities != nil {
@@ -63,11 +69,21 @@ func (f *DocumentFilter) SQLArguments() pgx.NamedArgs {
visibilities[i] = v.String() visibilities[i] = v.String()
} }
} }
var documentTypes []string
if f.documentTypes != nil {
documentTypes = make([]string, len(f.documentTypes))
for i, dt := range f.documentTypes {
documentTypes[i] = dt.String()
}
}
return pgx.NamedArgs{ return pgx.NamedArgs{
"query": f.query, "query": f.query,
"trust_center_visibilities": visibilities, "trust_center_visibilities": visibilities,
"published": f.published, "published": f.published,
"user_email": f.userEmail, "user_email": f.userEmail,
"document_types": documentTypes,
} }
} }
@@ -109,5 +125,11 @@ func (f *DocumentFilter) SQLFragment() string {
AND dvs.state IN ('REQUESTED', 'SIGNED') AND dvs.state IN ('REQUESTED', 'SIGNED')
) )
END END
AND
CASE
WHEN @document_types::document_type[] IS NOT NULL THEN
document_type = ANY(@document_types::document_type[])
ELSE TRUE
END
)` )`
} }

View File

@@ -24,18 +24,28 @@ type (
) )
const ( const (
DocumentTypeOther DocumentType = "OTHER" DocumentTypeOther DocumentType = "OTHER"
DocumentTypeISMS DocumentType = "ISMS" DocumentTypeGovernance DocumentType = "GOVERNANCE"
DocumentTypePolicy DocumentType = "POLICY" DocumentTypePolicy DocumentType = "POLICY"
DocumentTypeProcedure DocumentType = "PROCEDURE" DocumentTypeProcedure DocumentType = "PROCEDURE"
DocumentTypePlan DocumentType = "PLAN"
DocumentTypeRegister DocumentType = "REGISTER"
DocumentTypeRecord DocumentType = "RECORD"
DocumentTypeReport DocumentType = "REPORT"
DocumentTypeTemplate DocumentType = "TEMPLATE"
) )
func DocumentTypes() []DocumentType { func DocumentTypes() []DocumentType {
return []DocumentType{ return []DocumentType{
DocumentTypeOther, DocumentTypeOther,
DocumentTypeISMS, DocumentTypeGovernance,
DocumentTypePolicy, DocumentTypePolicy,
DocumentTypeProcedure, DocumentTypeProcedure,
DocumentTypePlan,
DocumentTypeRegister,
DocumentTypeRecord,
DocumentTypeReport,
DocumentTypeTemplate,
} }
} }
@@ -49,12 +59,22 @@ func (dt *DocumentType) UnmarshalText(data []byte) error {
switch val { switch val {
case DocumentTypeOther.String(): case DocumentTypeOther.String():
*dt = DocumentTypeOther *dt = DocumentTypeOther
case DocumentTypeISMS.String(): case DocumentTypeGovernance.String():
*dt = DocumentTypeISMS *dt = DocumentTypeGovernance
case DocumentTypePolicy.String(): case DocumentTypePolicy.String():
*dt = DocumentTypePolicy *dt = DocumentTypePolicy
case DocumentTypeProcedure.String(): case DocumentTypeProcedure.String():
*dt = DocumentTypeProcedure *dt = DocumentTypeProcedure
case DocumentTypePlan.String():
*dt = DocumentTypePlan
case DocumentTypeRegister.String():
*dt = DocumentTypeRegister
case DocumentTypeRecord.String():
*dt = DocumentTypeRecord
case DocumentTypeReport.String():
*dt = DocumentTypeReport
case DocumentTypeTemplate.String():
*dt = DocumentTypeTemplate
default: default:
return fmt.Errorf("invalid DocumentType value: %q", val) return fmt.Errorf("invalid DocumentType value: %q", val)
} }

View File

@@ -0,0 +1,6 @@
ALTER TYPE document_type RENAME VALUE 'ISMS' TO 'GOVERNANCE';
ALTER TYPE document_type ADD VALUE 'PLAN';
ALTER TYPE document_type ADD VALUE 'REGISTER';
ALTER TYPE document_type ADD VALUE 'RECORD';
ALTER TYPE document_type ADD VALUE 'REPORT';
ALTER TYPE document_type ADD VALUE 'TEMPLATE';

View File

@@ -971,10 +971,18 @@ enum VendorCategory
enum DocumentType enum DocumentType
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentType") { @goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentType") {
OTHER @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeOther") OTHER @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeOther")
ISMS @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeISMS") GOVERNANCE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeGovernance")
POLICY @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypePolicy") POLICY @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypePolicy")
PROCEDURE PROCEDURE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeProcedure") @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeProcedure")
PLAN @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypePlan")
REGISTER
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeRegister")
RECORD @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeRecord")
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
TEMPLATE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
} }
enum DocumentClassification enum DocumentClassification
@@ -1571,6 +1579,7 @@ input ControlFilter {
input DocumentFilter { input DocumentFilter {
query: String query: String
documentTypes: [DocumentType!]
} }
input MeasureFilter { input MeasureFilter {

View File

@@ -567,7 +567,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)
} }
page, err := prb.Documents.ListForControlID(ctx, obj.ID, cursor, documentFilter) page, err := prb.Documents.ListForControlID(ctx, obj.ID, cursor, documentFilter)
@@ -6477,7 +6478,8 @@ func (r *organizationResolver) Documents(ctx context.Context, obj *types.Organiz
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)
} }
page, err := prb.Documents.ListByOrganizationID(ctx, obj.ID, cursor, documentFilter) page, err := prb.Documents.ListByOrganizationID(ctx, obj.ID, cursor, documentFilter)
@@ -7844,7 +7846,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)
} }
page, err := prb.Documents.ListForRiskID(ctx, obj.ID, cursor, documentFilter) page, err := prb.Documents.ListForRiskID(ctx, obj.ID, cursor, documentFilter)

View File

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

View File

@@ -5038,9 +5038,14 @@ components:
type: string type: string
enum: enum:
- OTHER - OTHER
- ISMS - GOVERNANCE
- POLICY - POLICY
- PROCEDURE - PROCEDURE
- PLAN
- REGISTER
- RECORD
- REPORT
- TEMPLATE
go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentType go.probo.inc/mcpgen/type: go.probo.inc/probo/pkg/coredata.DocumentType
DocumentClassification: DocumentClassification:
@@ -5314,6 +5319,11 @@ components:
items: items:
$ref: "#/components/schemas/TrustCenterVisibility" $ref: "#/components/schemas/TrustCenterVisibility"
description: Trust center visibilities description: Trust center visibilities
document_types:
type: array
items:
$ref: "#/components/schemas/DocumentType"
description: Document types
ListDocumentsOutput: ListDocumentsOutput:
type: object type: object

View File

@@ -52,10 +52,18 @@ type Organization implements Node {
enum DocumentType enum DocumentType
@goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentType") { @goModel(model: "go.probo.inc/probo/pkg/coredata.DocumentType") {
OTHER @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeOther") OTHER @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeOther")
ISMS @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeISMS") GOVERNANCE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeGovernance")
POLICY @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypePolicy") POLICY @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypePolicy")
PROCEDURE PROCEDURE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeProcedure") @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeProcedure")
PLAN @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypePlan")
REGISTER
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeRegister")
RECORD @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeRecord")
REPORT @goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeReport")
TEMPLATE
@goEnum(value: "go.probo.inc/probo/pkg/coredata.DocumentTypeTemplate")
} }
type Document implements Node @nda { type Document implements Node @nda {

View File

@@ -177,7 +177,7 @@ func (s *DocumentService) exportPDFData(
switch document.DocumentType { switch document.DocumentType {
case coredata.DocumentTypePolicy: case coredata.DocumentTypePolicy:
classification = docgen.ClassificationConfidential classification = docgen.ClassificationConfidential
case coredata.DocumentTypeISMS: case coredata.DocumentTypeGovernance:
classification = docgen.ClassificationSecret classification = docgen.ClassificationSecret
} }