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:
@@ -7,18 +7,7 @@ import { useMutationWithToasts } from "../useMutationWithToasts";
|
|||||||
|
|
||||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||||
|
|
||||||
export const measuresQuery = graphql`
|
export const MeasureConnectionKey = "MeasuresPage_measures";
|
||||||
query MeasureGraphListQuery($organizationId: ID!) {
|
|
||||||
organization: node(id: $organizationId) @required(action: THROW) {
|
|
||||||
__typename
|
|
||||||
... on Organization {
|
|
||||||
id
|
|
||||||
canCreateMeasure: permission(action: "core:measure:create")
|
|
||||||
...MeasuresPageFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const deleteMeasureMutation = graphql`
|
const deleteMeasureMutation = graphql`
|
||||||
mutation MeasureGraphDeleteMutation(
|
mutation MeasureGraphDeleteMutation(
|
||||||
@@ -31,8 +20,6 @@ const deleteMeasureMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const MeasureConnectionKey = "MeasuresGraphListQuery__measures";
|
|
||||||
|
|
||||||
export function useDeleteMeasureMutation() {
|
export function useDeleteMeasureMutation() {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { groupBy, objectKeys, slugify, sprintf } from "@probo/helpers";
|
import {
|
||||||
|
formatError,
|
||||||
|
getMeasureStateLabel,
|
||||||
|
sprintf,
|
||||||
|
type GraphQLError,
|
||||||
|
} from "@probo/helpers";
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -7,14 +12,13 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
FileButton,
|
FileButton,
|
||||||
IconChevronDown,
|
|
||||||
IconChevronUp,
|
|
||||||
IconFolderUpload,
|
IconFolderUpload,
|
||||||
IconPencil,
|
IconPencil,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
IconTrashCan,
|
||||||
MeasureImplementation,
|
Option,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
|
Select,
|
||||||
Table,
|
Table,
|
||||||
Tbody,
|
Tbody,
|
||||||
Td,
|
Td,
|
||||||
@@ -23,52 +27,103 @@ import {
|
|||||||
Tr,
|
Tr,
|
||||||
useConfirm,
|
useConfirm,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { MeasureBadge } from "@probo/ui/src/Molecules/Badge/MeasureBadge";
|
import { MeasureBadge } from "@probo/ui/src/Molecules/Badge/MeasureBadge";
|
||||||
import { type ChangeEventHandler, useMemo, useRef, useState } from "react";
|
import { type ChangeEventHandler, useRef, useState, useTransition } from "react";
|
||||||
import {
|
import {
|
||||||
|
ConnectionHandler,
|
||||||
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
useFragment,
|
useFragment,
|
||||||
|
useMutation,
|
||||||
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { Link, useParams } from "react-router";
|
|
||||||
import { graphql } from "relay-runtime";
|
|
||||||
|
|
||||||
import type { MeasureGraphListQuery } from "#/__generated__/core/MeasureGraphListQuery.graphql";
|
import type { MeasuresPageDeleteMutation } from "#/__generated__/core/MeasuresPageDeleteMutation.graphql";
|
||||||
import type {
|
import type { MeasuresPageFragment$key } from "#/__generated__/core/MeasuresPageFragment.graphql";
|
||||||
MeasuresPageFragment$data,
|
|
||||||
MeasuresPageFragment$key,
|
|
||||||
} from "#/__generated__/core/MeasuresPageFragment.graphql";
|
|
||||||
import type { MeasuresPageImportMutation } from "#/__generated__/core/MeasuresPageImportMutation.graphql";
|
import type { MeasuresPageImportMutation } from "#/__generated__/core/MeasuresPageImportMutation.graphql";
|
||||||
import {
|
import type { MeasuresPageListQuery } from "#/__generated__/core/MeasuresPageListQuery.graphql";
|
||||||
measuresQuery,
|
import type {
|
||||||
useDeleteMeasureMutation,
|
MeasuresPageRefetchQuery,
|
||||||
} from "#/hooks/graph/MeasureGraph";
|
MeasureState,
|
||||||
|
} from "#/__generated__/core/MeasuresPageRefetchQuery.graphql";
|
||||||
|
import type { MeasuresPageRowFragment$key } from "#/__generated__/core/MeasuresPageRowFragment.graphql";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
import type { NodeOf } from "#/types";
|
|
||||||
|
|
||||||
import MeasureFormDialog from "./dialog/MeasureFormDialog";
|
import MeasureFormDialog from "./dialog/MeasureFormDialog";
|
||||||
|
|
||||||
type Props = {
|
export const MeasuresConnectionKey = "MeasuresPage_measures";
|
||||||
queryRef: PreloadedQuery<MeasureGraphListQuery>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const measuresFragment = graphql`
|
export const measuresPageQuery = graphql`
|
||||||
fragment MeasuresPageFragment on Organization {
|
query MeasuresPageListQuery($organizationId: ID!) {
|
||||||
measures(first: 500) @connection(key: "MeasuresGraphListQuery__measures") {
|
organization: node(id: $organizationId) @required(action: THROW) {
|
||||||
__id
|
__typename
|
||||||
|
... on Organization {
|
||||||
|
canCreateMeasure: permission(action: "core:measure:create")
|
||||||
|
measureCategories
|
||||||
|
...MeasuresPageFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const measureRowFragment = graphql`
|
||||||
|
fragment MeasuresPageRowFragment on Measure {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
category
|
||||||
|
state
|
||||||
|
canUpdate: permission(action: "core:measure:update")
|
||||||
|
canDelete: permission(action: "core:measure:delete")
|
||||||
|
...MeasureFormDialogMeasureFragment
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const deleteMeasureMutation = graphql`
|
||||||
|
mutation MeasuresPageDeleteMutation(
|
||||||
|
$input: DeleteMeasureInput!
|
||||||
|
$connections: [ID!]!
|
||||||
|
) {
|
||||||
|
deleteMeasure(input: $input) {
|
||||||
|
deletedMeasureId @deleteEdge(connections: $connections)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const measuresPageFragment = graphql`
|
||||||
|
fragment MeasuresPageFragment on Organization
|
||||||
|
@refetchable(queryName: "MeasuresPageRefetchQuery")
|
||||||
|
@argumentDefinitions(
|
||||||
|
first: { type: "Int", defaultValue: 20 }
|
||||||
|
after: { type: "CursorKey" }
|
||||||
|
state: { type: "MeasureState", defaultValue: null }
|
||||||
|
category: { type: "String", defaultValue: null }
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
measures(
|
||||||
|
first: $first
|
||||||
|
after: $after
|
||||||
|
filter: { state: $state, category: $category }
|
||||||
|
)
|
||||||
|
@connection(
|
||||||
|
key: "MeasuresPage_measures"
|
||||||
|
filters: ["filter"]
|
||||||
|
) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
id
|
id
|
||||||
name
|
|
||||||
category
|
|
||||||
state
|
|
||||||
canUpdate: permission(action: "core:measure:update")
|
canUpdate: permission(action: "core:measure:update")
|
||||||
canDelete: permission(action: "core:measure:delete")
|
canDelete: permission(action: "core:measure:delete")
|
||||||
...MeasureFormDialogMeasureFragment
|
...MeasuresPageRowFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pageInfo {
|
||||||
|
hasNextPage
|
||||||
|
endCursor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
@@ -91,24 +146,79 @@ const importMeasuresMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function MeasuresPage(props: Props) {
|
interface MeasuresPageProps {
|
||||||
|
queryRef: PreloadedQuery<MeasuresPageListQuery>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeasuresPage({ queryRef }: MeasuresPageProps) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organization = usePreloadedQuery(
|
const organizationId = useOrganizationId();
|
||||||
measuresQuery,
|
|
||||||
props.queryRef,
|
usePageTitle(__("Measures"));
|
||||||
).organization;
|
|
||||||
|
const { organization } = usePreloadedQuery(measuresPageQuery, queryRef);
|
||||||
if (organization.__typename !== "Organization") {
|
if (organization.__typename !== "Organization") {
|
||||||
throw new Error("invalid node type");
|
throw new Error("invalid node type");
|
||||||
}
|
}
|
||||||
const data = useFragment<MeasuresPageFragment$key>(
|
|
||||||
measuresFragment,
|
const [isPending, startTransition] = useTransition();
|
||||||
organization,
|
const [stateFilter, setStateFilter] = useState<MeasureState | null>(null);
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, loadNext, hasNext, isLoadingNext, refetch }
|
||||||
|
= usePaginationFragment<MeasuresPageRefetchQuery, MeasuresPageFragment$key>(
|
||||||
|
measuresPageFragment,
|
||||||
|
organization,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refetchFilters = (overrides: Record<string, unknown> = {}) => {
|
||||||
|
startTransition(() => {
|
||||||
|
refetch(
|
||||||
|
{
|
||||||
|
state: stateFilter,
|
||||||
|
category: categoryFilter,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
{ fetchPolicy: "network-only" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStateFilterChange = (value: string) => {
|
||||||
|
const newState = value === "ALL" ? null : (value as MeasureState);
|
||||||
|
setStateFilter(newState);
|
||||||
|
refetchFilters({ state: newState });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCategoryFilterChange = (value: string) => {
|
||||||
|
const newCategory = value === "ALL" ? null : value;
|
||||||
|
setCategoryFilter(newCategory);
|
||||||
|
refetchFilters({ category: newCategory });
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentFilter = {
|
||||||
|
state: stateFilter,
|
||||||
|
category: categoryFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const connectionId = ConnectionHandler.getConnectionID(
|
||||||
|
organizationId,
|
||||||
|
MeasuresConnectionKey,
|
||||||
|
{ filter: currentFilter },
|
||||||
);
|
);
|
||||||
const connectionId = data.measures.__id;
|
const allFiltersNullConnectionId = ConnectionHandler.getConnectionID(
|
||||||
const measures = data.measures.edges.map(edge => edge.node);
|
organizationId,
|
||||||
const measuresPerCategory = useMemo(() => {
|
MeasuresConnectionKey,
|
||||||
return groupBy(measures, measure => measure.category);
|
{ filter: { state: null, category: null } },
|
||||||
}, [measures]);
|
);
|
||||||
|
const hasActiveFilter = stateFilter || categoryFilter;
|
||||||
|
const createConnectionIds = hasActiveFilter
|
||||||
|
? [allFiltersNullConnectionId, connectionId]
|
||||||
|
: [connectionId];
|
||||||
|
|
||||||
|
const measures = data?.measures?.edges?.map(edge => edge.node) ?? [];
|
||||||
|
const categories = organization.measureCategories ?? [];
|
||||||
|
|
||||||
const [importMeasures] = useMutationWithToasts<MeasuresPageImportMutation>(
|
const [importMeasures] = useMutationWithToasts<MeasuresPageImportMutation>(
|
||||||
importMeasuresMutation,
|
importMeasuresMutation,
|
||||||
{
|
{
|
||||||
@@ -117,7 +227,6 @@ export default function MeasuresPage(props: Props) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const importFileRef = useRef<HTMLInputElement>(null);
|
const importFileRef = useRef<HTMLInputElement>(null);
|
||||||
usePageTitle(__("Measures"));
|
|
||||||
|
|
||||||
const handleImport: ChangeEventHandler<HTMLInputElement> = (event) => {
|
const handleImport: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -127,10 +236,10 @@ export default function MeasuresPage(props: Props) {
|
|||||||
void importMeasures({
|
void importMeasures({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
organizationId: organization.id,
|
organizationId,
|
||||||
file: null,
|
file: null,
|
||||||
},
|
},
|
||||||
connections: [connectionId],
|
connections: createConnectionIds,
|
||||||
},
|
},
|
||||||
uploadables: {
|
uploadables: {
|
||||||
"input.file": file,
|
"input.file": file,
|
||||||
@@ -141,6 +250,10 @@ export default function MeasuresPage(props: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasAnyAction = measures.some(
|
||||||
|
({ canUpdate, canDelete }) => canUpdate || canDelete,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -167,126 +280,140 @@ export default function MeasuresPage(props: Props) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<MeasureImplementation measures={measures} className="my-10" />
|
|
||||||
{objectKeys(measuresPerCategory)
|
<div className="flex items-center gap-4">
|
||||||
.sort((a, b) => a.localeCompare(b))
|
<Select
|
||||||
.map(category => (
|
value={stateFilter ?? "ALL"}
|
||||||
<Category
|
onValueChange={handleStateFilterChange}
|
||||||
key={category}
|
>
|
||||||
category={category}
|
<Option value="ALL">{__("All states")}</Option>
|
||||||
measures={measuresPerCategory[category]}
|
<Option value="NOT_STARTED">{getMeasureStateLabel(__, "NOT_STARTED")}</Option>
|
||||||
connectionId={connectionId}
|
<Option value="IN_PROGRESS">{getMeasureStateLabel(__, "IN_PROGRESS")}</Option>
|
||||||
/>
|
<Option value="IMPLEMENTED">{getMeasureStateLabel(__, "IMPLEMENTED")}</Option>
|
||||||
))}
|
<Option value="NOT_APPLICABLE">{getMeasureStateLabel(__, "NOT_APPLICABLE")}</Option>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={categoryFilter ?? "ALL"}
|
||||||
|
onValueChange={handleCategoryFilterChange}
|
||||||
|
>
|
||||||
|
<Option value="ALL">{__("All categories")}</Option>
|
||||||
|
{categories.map(category => (
|
||||||
|
<Option key={category} value={category}>
|
||||||
|
{category}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
|
||||||
|
{measures.length > 0
|
||||||
|
? (
|
||||||
|
<Card>
|
||||||
|
<Table>
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>{__("Measure")}</Th>
|
||||||
|
<Th>{__("Category")}</Th>
|
||||||
|
<Th>{__("State")}</Th>
|
||||||
|
{hasAnyAction && <Th />}
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{measures.map(measure => (
|
||||||
|
<MeasureRow
|
||||||
|
key={measure.id}
|
||||||
|
measureKey={measure}
|
||||||
|
connectionId={connectionId}
|
||||||
|
hasAnyAction={hasAnyAction}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{hasNext && (
|
||||||
|
<div className="p-4 border-t">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => loadNext(20)}
|
||||||
|
disabled={isLoadingNext}
|
||||||
|
>
|
||||||
|
{isLoadingNext ? __("Loading...") : __("Load more")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Card padded>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
{__("No measures yet")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-txt-tertiary mb-4">
|
||||||
|
{__("Create your first measure to get started.")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryProps = {
|
|
||||||
category: string;
|
|
||||||
measures: NodeOf<MeasuresPageFragment$data["measures"]>[];
|
|
||||||
connectionId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function Category(props: CategoryProps) {
|
|
||||||
const params = useParams<{ categoryId?: string }>();
|
|
||||||
const { __ } = useTranslate();
|
|
||||||
const organizationId = useOrganizationId();
|
|
||||||
const categoryId = slugify(props.category);
|
|
||||||
const [limit, setLimit] = useState<number | null>(4);
|
|
||||||
const measures = useMemo(() => {
|
|
||||||
return limit ? props.measures.slice(0, limit) : props.measures;
|
|
||||||
}, [props.measures, limit]);
|
|
||||||
const showMoreButton = limit !== null && props.measures.length > limit;
|
|
||||||
const isExpanded = categoryId === params.categoryId;
|
|
||||||
const ExpandComponent = isExpanded ? IconChevronUp : IconChevronDown;
|
|
||||||
const completedMeasures = props.measures.filter(
|
|
||||||
m => m.state === "IMPLEMENTED",
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="py-3 px-5">
|
|
||||||
<Link
|
|
||||||
to={`/organizations/${organizationId}/measures/category/${categoryId}`}
|
|
||||||
className="flex items-center justify-between cursor-pointer"
|
|
||||||
>
|
|
||||||
<h2 className="text-base font-medium">{props.category}</h2>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-txt-secondary">
|
|
||||||
<span>
|
|
||||||
{__("Completion")}
|
|
||||||
:
|
|
||||||
{" "}
|
|
||||||
<span className="text-txt-primary font-medium">
|
|
||||||
{completedMeasures.length}
|
|
||||||
/
|
|
||||||
{props.measures.length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-border-low">|</span>
|
|
||||||
<ExpandComponent size={16} className="text-txt-secondary" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<Table className="bg-invert">
|
|
||||||
<Thead>
|
|
||||||
<Tr>
|
|
||||||
<Th>{__("Measure")}</Th>
|
|
||||||
<Th>{__("State")}</Th>
|
|
||||||
{measures.some(
|
|
||||||
({ canUpdate, canDelete }) => canUpdate || canDelete,
|
|
||||||
) && <Th></Th>}
|
|
||||||
</Tr>
|
|
||||||
</Thead>
|
|
||||||
<Tbody>
|
|
||||||
{measures.map(measure => (
|
|
||||||
<MeasureRow
|
|
||||||
key={measure.id}
|
|
||||||
measure={measure}
|
|
||||||
connectionId={props.connectionId}
|
|
||||||
hasAnyAction={measure.canUpdate || measure.canDelete}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Tbody>
|
|
||||||
</Table>
|
|
||||||
{showMoreButton && (
|
|
||||||
<Button
|
|
||||||
variant="tertiary"
|
|
||||||
onClick={() => setLimit(null)}
|
|
||||||
className="mt-3 mx-auto"
|
|
||||||
icon={IconChevronDown}
|
|
||||||
>
|
|
||||||
{sprintf(__("Show %s more"), props.measures.length - limit)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type MeasureRowProps = {
|
type MeasureRowProps = {
|
||||||
measure: NodeOf<MeasuresPageFragment$data["measures"]>;
|
measureKey: MeasuresPageRowFragment$key;
|
||||||
connectionId: string;
|
connectionId: string;
|
||||||
hasAnyAction: boolean;
|
hasAnyAction: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function MeasureRow(props: MeasureRowProps) {
|
function MeasureRow(props: MeasureRowProps) {
|
||||||
const { __ } = useTranslate();
|
const measure = useFragment(measureRowFragment, props.measureKey);
|
||||||
const [deleteMeasure, isDeleting] = useDeleteMeasureMutation();
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
|
const { __ } = useTranslate();
|
||||||
|
const [deleteMeasure] = useMutation<MeasuresPageDeleteMutation>(deleteMeasureMutation);
|
||||||
|
const { toast } = useToast();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const dialogRef = useDialogRef();
|
||||||
|
|
||||||
const onDelete = () => {
|
const handleDelete = () => {
|
||||||
confirm(
|
confirm(
|
||||||
() =>
|
() =>
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
void deleteMeasure({
|
deleteMeasure({
|
||||||
variables: {
|
variables: {
|
||||||
input: { measureId: props.measure.id },
|
input: { measureId: measure.id },
|
||||||
connections: [props.connectionId],
|
connections: [props.connectionId],
|
||||||
},
|
},
|
||||||
onCompleted: () => resolve(),
|
onCompleted(_, error) {
|
||||||
|
if (error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to delete measure"),
|
||||||
|
error as GraphQLError[],
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: __("Success"),
|
||||||
|
description: __("Measure deleted successfully"),
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to delete measure"),
|
||||||
|
error as GraphQLError,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
@@ -294,44 +421,44 @@ function MeasureRow(props: MeasureRowProps) {
|
|||||||
__(
|
__(
|
||||||
"This will permanently delete the measure \"%s\". This action cannot be undone.",
|
"This will permanently delete the measure \"%s\". This action cannot be undone.",
|
||||||
),
|
),
|
||||||
props.measure.name,
|
measure.name,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const dialogRef = useDialogRef();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<MeasureFormDialog measure={props.measure} ref={dialogRef} />
|
<MeasureFormDialog measure={measure} ref={dialogRef} />
|
||||||
<Tr to={`/organizations/${organizationId}/measures/${props.measure.id}`}>
|
<Tr to={`/organizations/${organizationId}/measures/${measure.id}`}>
|
||||||
<Td>{props.measure.name}</Td>
|
<Td>{measure.name}</Td>
|
||||||
|
<Td>{measure.category}</Td>
|
||||||
<Td width={120}>
|
<Td width={120}>
|
||||||
<MeasureBadge state={props.measure.state} />
|
<MeasureBadge state={measure.state} />
|
||||||
</Td>
|
</Td>
|
||||||
{(props.measure.canUpdate || props.measure.canDelete) && (
|
{props.hasAnyAction && (
|
||||||
<Td noLink width={50} className="text-end">
|
<Td noLink width={50} className="text-end">
|
||||||
<ActionDropdown>
|
{(measure.canUpdate || measure.canDelete) && (
|
||||||
{props.measure.canUpdate && (
|
<ActionDropdown>
|
||||||
<DropdownItem
|
{measure.canUpdate && (
|
||||||
icon={IconPencil}
|
<DropdownItem
|
||||||
onClick={() => dialogRef.current?.open()}
|
icon={IconPencil}
|
||||||
>
|
onClick={() => dialogRef.current?.open()}
|
||||||
{__("Edit")}
|
>
|
||||||
</DropdownItem>
|
{__("Edit")}
|
||||||
)}
|
</DropdownItem>
|
||||||
{props.measure.canDelete && (
|
)}
|
||||||
<DropdownItem
|
{measure.canDelete && (
|
||||||
onClick={onDelete}
|
<DropdownItem
|
||||||
disabled={isDeleting}
|
onClick={handleDelete}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon={IconTrashCan}
|
icon={IconTrashCan}
|
||||||
>
|
>
|
||||||
{__("Delete")}
|
{__("Delete")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
)}
|
)}
|
||||||
</ActionDropdown>
|
</ActionDropdown>
|
||||||
|
)}
|
||||||
</Td>
|
</Td>
|
||||||
)}
|
)}
|
||||||
</Tr>
|
</Tr>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Suspense, useEffect } from "react";
|
||||||
|
import { useQueryLoader } from "react-relay";
|
||||||
|
|
||||||
|
import type { MeasuresPageListQuery } from "#/__generated__/core/MeasuresPageListQuery.graphql";
|
||||||
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
|
import MeasuresPage, { measuresPageQuery } from "./MeasuresPage";
|
||||||
|
|
||||||
|
export default function MeasuresPageLoader() {
|
||||||
|
const organizationId = useOrganizationId();
|
||||||
|
const [queryRef, loadQuery]
|
||||||
|
= useQueryLoader<MeasuresPageListQuery>(measuresPageQuery);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadQuery({ organizationId });
|
||||||
|
}, [loadQuery, organizationId]);
|
||||||
|
|
||||||
|
if (!queryRef) {
|
||||||
|
return <PageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<PageSkeleton />}>
|
||||||
|
<MeasuresPage queryRef={queryRef} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,24 +8,19 @@ import { Fragment } from "react";
|
|||||||
import { loadQuery } from "react-relay";
|
import { loadQuery } from "react-relay";
|
||||||
import { redirect } from "react-router";
|
import { redirect } from "react-router";
|
||||||
|
|
||||||
import type { MeasureGraphListQuery } from "#/__generated__/core/MeasureGraphListQuery.graphql";
|
|
||||||
import type { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql";
|
import type { MeasureGraphNodeQuery } from "#/__generated__/core/MeasureGraphNodeQuery.graphql";
|
||||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||||
import { coreEnvironment } from "#/environments";
|
import { coreEnvironment } from "#/environments";
|
||||||
import { measureNodeQuery, measuresQuery } from "#/hooks/graph/MeasureGraph";
|
import { measureNodeQuery } from "#/hooks/graph/MeasureGraph";
|
||||||
|
|
||||||
export const measureRoutes = [
|
export const measureRoutes = [
|
||||||
{
|
{
|
||||||
path: "measures",
|
path: "measures",
|
||||||
Fallback: PageSkeleton,
|
Fallback: PageSkeleton,
|
||||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
Component: lazy(
|
||||||
loadQuery<MeasureGraphListQuery>(coreEnvironment, measuresQuery, {
|
() =>
|
||||||
organizationId: organizationId,
|
import("#/pages/organizations/measures/MeasuresPageLoader"),
|
||||||
}),
|
|
||||||
),
|
|
||||||
Component: withQueryRef(
|
|
||||||
lazy(() => import("#/pages/organizations/measures/MeasuresPage")),
|
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -326,6 +326,41 @@ WHERE
|
|||||||
return count, nil
|
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(
|
func (m *Measures) LoadByOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
conn pg.Conn,
|
conn pg.Conn,
|
||||||
|
|||||||
@@ -20,22 +20,25 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
MeasureFilter struct {
|
MeasureFilter struct {
|
||||||
query *string
|
query *string
|
||||||
state *MeasureState
|
state *MeasureState
|
||||||
|
category *string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewMeasureFilter(query *string, state *MeasureState) *MeasureFilter {
|
func NewMeasureFilter(query *string, state *MeasureState, category *string) *MeasureFilter {
|
||||||
return &MeasureFilter{
|
return &MeasureFilter{
|
||||||
query: query,
|
query: query,
|
||||||
state: state,
|
state: state,
|
||||||
|
category: category,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *MeasureFilter) SQLArguments() pgx.NamedArgs {
|
func (f *MeasureFilter) SQLArguments() pgx.NamedArgs {
|
||||||
return pgx.NamedArgs{
|
return pgx.NamedArgs{
|
||||||
"query": f.query,
|
"query": f.query,
|
||||||
"state": f.state,
|
"state": f.state,
|
||||||
|
"category": f.category,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +63,15 @@ AND
|
|||||||
ELSE
|
ELSE
|
||||||
state = @state::mitigation_state
|
state = @state::mitigation_state
|
||||||
END
|
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,
|
Direction: page.OrderDirectionAsc,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
coredata.NewMeasureFilter(nil, nil),
|
coredata.NewMeasureFilter(nil, nil, nil),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cannot load measures: %w", err)
|
return fmt.Errorf("cannot load measures: %w", err)
|
||||||
|
|||||||
@@ -237,6 +237,43 @@ func (s MeasureService) CountForOrganizationID(
|
|||||||
return count, nil
|
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(
|
func (s MeasureService) ListForOrganizationID(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
organizationID gid.GID,
|
organizationID gid.GID,
|
||||||
|
|||||||
@@ -1572,6 +1572,7 @@ input DocumentFilter {
|
|||||||
input MeasureFilter {
|
input MeasureFilter {
|
||||||
query: String
|
query: String
|
||||||
state: MeasureState
|
state: MeasureState
|
||||||
|
category: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input RiskFilter {
|
input RiskFilter {
|
||||||
@@ -1857,6 +1858,8 @@ type Organization implements Node {
|
|||||||
filter: StateOfApplicabilityFilter = { snapshotId: null }
|
filter: StateOfApplicabilityFilter = { snapshotId: null }
|
||||||
): StateOfApplicabilityConnection! @goField(forceResolver: true)
|
): StateOfApplicabilityConnection! @goField(forceResolver: true)
|
||||||
|
|
||||||
|
measureCategories: [String!]! @goField(forceResolver: true)
|
||||||
|
|
||||||
measures(
|
measures(
|
||||||
first: Int
|
first: Int
|
||||||
after: CursorKey
|
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)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||||
if filter != 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)
|
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
|
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.
|
// 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) {
|
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 {
|
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)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||||
if filter != 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)
|
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)
|
cursor := types.NewCursor(first, after, last, before, pageOrderBy)
|
||||||
|
|
||||||
var measureFilter = coredata.NewMeasureFilter(nil, nil)
|
var measureFilter = coredata.NewMeasureFilter(nil, nil, nil)
|
||||||
if filter != 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)
|
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)
|
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 {
|
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)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, types.ListControlMeasuresOutput{}, fmt.Errorf("failed to list control measures: %w", err)
|
return nil, types.ListControlMeasuresOutput{}, fmt.Errorf("failed to list control measures: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1434,6 +1434,9 @@ components:
|
|||||||
state:
|
state:
|
||||||
$ref: "#/components/schemas/MeasureState"
|
$ref: "#/components/schemas/MeasureState"
|
||||||
description: Measure state filter
|
description: Measure state filter
|
||||||
|
category:
|
||||||
|
type: string
|
||||||
|
description: Filter by measure category
|
||||||
|
|
||||||
ListMeasuresOutput:
|
ListMeasuresOutput:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in New Issue
Block a user