Refactor MeasuresPage to use Relay fragments
Replace the client-side grouped-by-category view (fetching 500 items) with a flat table using server-side filtering and cursor-based pagination. Colocate GraphQL queries, fragments, and mutations in the component file per console CLAUDE.md conventions. Backend changes add a category filter to the measure list endpoints (GraphQL, MCP) and a new measureCategories field on Organization. Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -326,6 +326,41 @@ WHERE
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *Measures) LoadDistinctCategoriesByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope Scoper,
|
||||
organizationID gid.GID,
|
||||
) ([]string, error) {
|
||||
q := `
|
||||
SELECT DISTINCT
|
||||
category
|
||||
FROM
|
||||
measures
|
||||
WHERE
|
||||
%s
|
||||
AND organization_id = @organization_id
|
||||
ORDER BY
|
||||
category ASC
|
||||
`
|
||||
q = fmt.Sprintf(q, scope.SQLFragment())
|
||||
|
||||
args := pgx.NamedArgs{"organization_id": organizationID}
|
||||
maps.Copy(args, scope.SQLArguments())
|
||||
|
||||
rows, err := conn.Query(ctx, q, args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot query measure categories: %w", err)
|
||||
}
|
||||
|
||||
categories, err := pgx.CollectRows(rows, pgx.RowTo[string])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot collect measure categories: %w", err)
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (m *Measures) LoadByOrganizationID(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
|
||||
@@ -20,22 +20,25 @@ import (
|
||||
|
||||
type (
|
||||
MeasureFilter struct {
|
||||
query *string
|
||||
state *MeasureState
|
||||
query *string
|
||||
state *MeasureState
|
||||
category *string
|
||||
}
|
||||
)
|
||||
|
||||
func NewMeasureFilter(query *string, state *MeasureState) *MeasureFilter {
|
||||
func NewMeasureFilter(query *string, state *MeasureState, category *string) *MeasureFilter {
|
||||
return &MeasureFilter{
|
||||
query: query,
|
||||
state: state,
|
||||
query: query,
|
||||
state: state,
|
||||
category: category,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *MeasureFilter) SQLArguments() pgx.NamedArgs {
|
||||
return pgx.NamedArgs{
|
||||
"query": f.query,
|
||||
"state": f.state,
|
||||
"query": f.query,
|
||||
"state": f.state,
|
||||
"category": f.category,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +63,15 @@ AND
|
||||
ELSE
|
||||
state = @state::mitigation_state
|
||||
END
|
||||
)
|
||||
)
|
||||
AND
|
||||
(
|
||||
CASE
|
||||
WHEN @category::text IS NULL OR @category::text = '' THEN
|
||||
TRUE
|
||||
ELSE
|
||||
category = @category::text
|
||||
END
|
||||
)
|
||||
`
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ func (s FrameworkService) Export(
|
||||
Direction: page.OrderDirectionAsc,
|
||||
},
|
||||
),
|
||||
coredata.NewMeasureFilter(nil, nil),
|
||||
coredata.NewMeasureFilter(nil, nil, nil),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load measures: %w", err)
|
||||
|
||||
@@ -237,6 +237,43 @@ func (s MeasureService) CountForOrganizationID(
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (s MeasureService) ListDistinctCategoriesForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
) ([]string, error) {
|
||||
var categories []string
|
||||
|
||||
err := s.svc.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
organization := &coredata.Organization{}
|
||||
if err := organization.LoadByID(ctx, conn, s.svc.scope, organizationID); err != nil {
|
||||
return fmt.Errorf("cannot load organization: %w", err)
|
||||
}
|
||||
|
||||
var measures coredata.Measures
|
||||
var err error
|
||||
categories, err = measures.LoadDistinctCategoriesByOrganizationID(
|
||||
ctx,
|
||||
conn,
|
||||
s.svc.scope,
|
||||
organization.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load measure categories: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func (s MeasureService) ListForOrganizationID(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
|
||||
@@ -1572,6 +1572,7 @@ input DocumentFilter {
|
||||
input MeasureFilter {
|
||||
query: String
|
||||
state: MeasureState
|
||||
category: String
|
||||
}
|
||||
|
||||
input RiskFilter {
|
||||
@@ -1857,6 +1858,8 @@ type Organization implements Node {
|
||||
filter: StateOfApplicabilityFilter = { snapshotId: null }
|
||||
): StateOfApplicabilityConnection! @goField(forceResolver: true)
|
||||
|
||||
measureCategories: [String!]! @goField(forceResolver: true)
|
||||
|
||||
measures(
|
||||
first: Int
|
||||
after: CursorKey
|
||||
|
||||
@@ -530,9 +530,9 @@ func (r *controlResolver) Measures(ctx context.Context, obj *types.Control, firs
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||
if filter != nil {
|
||||
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State)
|
||||
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State, filter.Category)
|
||||
}
|
||||
|
||||
page, err := prb.Measures.ListForControlID(ctx, obj.ID, cursor, measureFilter)
|
||||
@@ -6554,6 +6554,23 @@ func (r *organizationResolver) StatesOfApplicability(ctx context.Context, obj *t
|
||||
return types.NewStateOfApplicabilityConnection(page, r, obj.ID, stateOfApplicabilityFilter), nil
|
||||
}
|
||||
|
||||
// MeasureCategories is the resolver for the measureCategories field.
|
||||
func (r *organizationResolver) MeasureCategories(ctx context.Context, obj *types.Organization) ([]string, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionMeasureList); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prb := r.ProboService(ctx, obj.ID.TenantID())
|
||||
|
||||
categories, err := prb.Measures.ListDistinctCategoriesForOrganizationID(ctx, obj.ID)
|
||||
if err != nil {
|
||||
r.logger.ErrorCtx(ctx, "cannot list measure categories", log.Error(err))
|
||||
return nil, gqlutils.Internal(ctx)
|
||||
}
|
||||
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
// Measures is the resolver for the measures field.
|
||||
func (r *organizationResolver) Measures(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.MeasureOrderBy, filter *types.MeasureFilter) (*types.MeasureConnection, error) {
|
||||
if err := r.authorize(ctx, obj.ID, probo.ActionMeasureList); err != nil {
|
||||
@@ -6575,9 +6592,9 @@ func (r *organizationResolver) Measures(ctx context.Context, obj *types.Organiza
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||
if filter != nil {
|
||||
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State)
|
||||
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State, filter.Category)
|
||||
}
|
||||
|
||||
page, err := prb.Measures.ListForOrganizationID(ctx, obj.ID, cursor, measureFilter)
|
||||
@@ -7790,9 +7807,9 @@ func (r *riskResolver) Measures(ctx context.Context, obj *types.Risk, first *int
|
||||
|
||||
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||
if filter != nil {
|
||||
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State)
|
||||
measureFilter = coredata.NewMeasureFilter(filter.Query, filter.State, filter.Category)
|
||||
}
|
||||
|
||||
page, err := prb.Measures.ListForRiskID(ctx, obj.ID, cursor, measureFilter)
|
||||
|
||||
@@ -380,9 +380,9 @@ func (r *Resolver) ListMeasuresTool(ctx context.Context, req *mcp.CallToolReques
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
||||
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||
if input.Filter != nil {
|
||||
measureFilter = coredata.NewMeasureFilter(input.Filter.Query, input.Filter.State)
|
||||
measureFilter = coredata.NewMeasureFilter(input.Filter.Query, input.Filter.State, input.Filter.Category)
|
||||
}
|
||||
|
||||
page, err := prb.Measures.ListForOrganizationID(ctx, input.OrganizationID, cursor, measureFilter)
|
||||
@@ -1624,7 +1624,7 @@ func (r *Resolver) ListControlMeasuresTool(ctx context.Context, req *mcp.CallToo
|
||||
|
||||
cursor := types.NewCursor(input.Size, input.Cursor, pageOrderBy)
|
||||
|
||||
measurePage, err := prb.Measures.ListForControlID(ctx, input.ControlID, cursor, coredata.NewMeasureFilter(nil, nil))
|
||||
measurePage, err := prb.Measures.ListForControlID(ctx, input.ControlID, cursor, coredata.NewMeasureFilter(nil, nil, nil))
|
||||
if err != nil {
|
||||
return nil, types.ListControlMeasuresOutput{}, fmt.Errorf("failed to list control measures: %w", err)
|
||||
}
|
||||
|
||||
@@ -1434,6 +1434,9 @@ components:
|
||||
state:
|
||||
$ref: "#/components/schemas/MeasureState"
|
||||
description: Measure state filter
|
||||
category:
|
||||
type: string
|
||||
description: Filter by measure category
|
||||
|
||||
ListMeasuresOutput:
|
||||
type: object
|
||||
|
||||
Reference in New Issue
Block a user