From 7903bd3a774138212f2f5528be14ddd159126756 Mon Sep 17 00:00:00 2001 From: Jonathan Date: Mon, 26 May 2025 00:20:26 +0200 Subject: [PATCH] Add measure listing page Signed-off-by: Bryan Frimin Signed-off-by: Sacha Al Himdani --- apps/console2/src/hooks/graph/MeasureGraph.ts | 36 +++ .../MeasureGraphDeleteMutation.graphql.ts | 132 +++++++++ .../MeasureGraphListQuery.graphql.ts | 241 +++++++++++++++ .../organizations/measures/MeasuresPage.tsx | 280 ++++++++++++++++++ .../MeasuresPageFragment.graphql.ts | 171 +++++++++++ .../MeasuresPageImportMutation.graphql.ts | 184 ++++++++++++ apps/console2/src/routes.tsx | 2 + apps/console2/src/routes/measureRoutes.ts | 16 + packages/helpers/src/index.ts | 1 + packages/helpers/src/measure.ts | 23 ++ packages/ui/.storybook/preview.tsx | 4 + packages/ui/src/Atoms/Button/Button.tsx | 2 +- packages/ui/src/Atoms/Table/Table.tsx | 20 +- .../Molecules/Badge/MeasureBadge.stories.tsx | 12 + .../ui/src/Molecules/Badge/MeasureBadge.tsx | 28 ++ .../FileButton/FileButton.stories.tsx | 16 + .../src/Molecules/FileButton/FileButton.tsx | 32 ++ .../MeasureImplementation.stories.tsx | 30 ++ .../MeasureImplementation.tsx | 72 +++++ .../src/Molecules/PageHeader/PageHeader.tsx | 2 +- packages/ui/src/index.ts | 3 + 21 files changed, 1301 insertions(+), 6 deletions(-) create mode 100644 apps/console2/src/hooks/graph/MeasureGraph.ts create mode 100644 apps/console2/src/hooks/graph/__generated__/MeasureGraphDeleteMutation.graphql.ts create mode 100644 apps/console2/src/hooks/graph/__generated__/MeasureGraphListQuery.graphql.ts create mode 100644 apps/console2/src/pages/organizations/measures/MeasuresPage.tsx create mode 100644 apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageFragment.graphql.ts create mode 100644 apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageImportMutation.graphql.ts create mode 100644 apps/console2/src/routes/measureRoutes.ts create mode 100644 packages/helpers/src/measure.ts create mode 100644 packages/ui/src/Molecules/Badge/MeasureBadge.stories.tsx create mode 100644 packages/ui/src/Molecules/Badge/MeasureBadge.tsx create mode 100644 packages/ui/src/Molecules/FileButton/FileButton.stories.tsx create mode 100644 packages/ui/src/Molecules/FileButton/FileButton.tsx create mode 100644 packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.stories.tsx create mode 100644 packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.tsx diff --git a/apps/console2/src/hooks/graph/MeasureGraph.ts b/apps/console2/src/hooks/graph/MeasureGraph.ts new file mode 100644 index 000000000..2b4e71011 --- /dev/null +++ b/apps/console2/src/hooks/graph/MeasureGraph.ts @@ -0,0 +1,36 @@ +import { useTranslate } from "@probo/i18n"; +import { graphql } from "relay-runtime"; +import { useMutationWithToasts } from "../useMutationWithToasts"; +import type { MeasureGraphDeleteMutation } from "./__generated__/MeasureGraphDeleteMutation.graphql"; + +export const measuresQuery = graphql` + query MeasureGraphListQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ...MeasuresPageFragment + } + } +`; + +const deleteMeasureMutation = graphql` + mutation MeasureGraphDeleteMutation( + $input: DeleteMeasureInput! + $connections: [ID!]! + ) { + deleteMeasure(input: $input) { + deletedMeasureId @deleteEdge(connections: $connections) + } + } +`; + +export function useDeleteMeasureMutation() { + const { __ } = useTranslate(); + + return useMutationWithToasts( + deleteMeasureMutation, + { + successMessage: __("Measure deleted successfully."), + errorMessage: __("Failed to delete measure. Please try again."), + } + ); +} diff --git a/apps/console2/src/hooks/graph/__generated__/MeasureGraphDeleteMutation.graphql.ts b/apps/console2/src/hooks/graph/__generated__/MeasureGraphDeleteMutation.graphql.ts new file mode 100644 index 000000000..869c4fcb3 --- /dev/null +++ b/apps/console2/src/hooks/graph/__generated__/MeasureGraphDeleteMutation.graphql.ts @@ -0,0 +1,132 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type DeleteMeasureInput = { + measureId: string; +}; +export type MeasureGraphDeleteMutation$variables = { + connections: ReadonlyArray; + input: DeleteMeasureInput; +}; +export type MeasureGraphDeleteMutation$data = { + readonly deleteMeasure: { + readonly deletedMeasureId: string; + }; +}; +export type MeasureGraphDeleteMutation = { + response: MeasureGraphDeleteMutation$data; + variables: MeasureGraphDeleteMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "connections" +}, +v1 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" +}, +v2 = [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } +], +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "deletedMeasureId", + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "MeasureGraphDeleteMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "DeleteMeasurePayload", + "kind": "LinkedField", + "name": "deleteMeasure", + "plural": false, + "selections": [ + (v3/*: any*/) + ], + "storageKey": null + } + ], + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "kind": "Operation", + "name": "MeasureGraphDeleteMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "DeleteMeasurePayload", + "kind": "LinkedField", + "name": "deleteMeasure", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "filters": null, + "handle": "deleteEdge", + "key": "", + "kind": "ScalarHandle", + "name": "deletedMeasureId", + "handleArgs": [ + { + "kind": "Variable", + "name": "connections", + "variableName": "connections" + } + ] + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "415f19e622fe431330deca8d3d10f121", + "id": null, + "metadata": {}, + "name": "MeasureGraphDeleteMutation", + "operationKind": "mutation", + "text": "mutation MeasureGraphDeleteMutation(\n $input: DeleteMeasureInput!\n) {\n deleteMeasure(input: $input) {\n deletedMeasureId\n }\n}\n" + } +}; +})(); + +(node as any).hash = "867f5fe6142e2900147cb2f7492b22d3"; + +export default node; diff --git a/apps/console2/src/hooks/graph/__generated__/MeasureGraphListQuery.graphql.ts b/apps/console2/src/hooks/graph/__generated__/MeasureGraphListQuery.graphql.ts new file mode 100644 index 000000000..e6abc0ff7 --- /dev/null +++ b/apps/console2/src/hooks/graph/__generated__/MeasureGraphListQuery.graphql.ts @@ -0,0 +1,241 @@ +/** + * @generated SignedSource<<4d7510366af238f19e3bff7949776f0a>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type MeasureGraphListQuery$variables = { + organizationId: string; +}; +export type MeasureGraphListQuery$data = { + readonly organization: { + readonly id: string; + readonly " $fragmentSpreads": FragmentRefs<"MeasuresPageFragment">; + }; +}; +export type MeasureGraphListQuery = { + response: MeasureGraphListQuery$data; + variables: MeasureGraphListQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "organizationId" + } +], +v1 = [ + { + "kind": "Variable", + "name": "id", + "variableName": "organizationId" + } +], +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v3 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null +}, +v4 = [ + { + "kind": "Literal", + "name": "first", + "value": 100 + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "MeasureGraphListQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: any*/), + { + "args": null, + "kind": "FragmentSpread", + "name": "MeasuresPageFragment" + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "MeasureGraphListQuery", + "selections": [ + { + "alias": "organization", + "args": (v1/*: any*/), + "concreteType": null, + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v3/*: any*/), + (v2/*: any*/), + { + "kind": "InlineFragment", + "selections": [ + { + "alias": null, + "args": (v4/*: any*/), + "concreteType": "MeasureConnection", + "kind": "LinkedField", + "name": "measures", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "MeasureEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Measure", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v2/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "category", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + }, + (v3/*: any*/) + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + }, + { + "kind": "ClientExtension", + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__id", + "storageKey": null + } + ] + } + ], + "storageKey": "measures(first:100)" + }, + { + "alias": null, + "args": (v4/*: any*/), + "filters": null, + "handle": "connection", + "key": "MeasuresGraphListQuery__measures", + "kind": "LinkedHandle", + "name": "measures" + } + ], + "type": "Organization", + "abstractKey": null + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "876c92c41e08247a4900883b56ac6762", + "id": null, + "metadata": {}, + "name": "MeasureGraphListQuery", + "operationKind": "query", + "text": "query MeasureGraphListQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n id\n ...MeasuresPageFragment\n }\n}\n\nfragment MeasuresPageFragment on Organization {\n measures(first: 100) {\n edges {\n node {\n id\n name\n category\n state\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "3014f5724257c0819ba6ef15d2b72d07"; + +export default node; diff --git a/apps/console2/src/pages/organizations/measures/MeasuresPage.tsx b/apps/console2/src/pages/organizations/measures/MeasuresPage.tsx new file mode 100644 index 000000000..57093b6db --- /dev/null +++ b/apps/console2/src/pages/organizations/measures/MeasuresPage.tsx @@ -0,0 +1,280 @@ +import type { MeasureGraphListQuery } from "/hooks/graph/__generated__/MeasureGraphListQuery.graphql"; +import { + useFragment, + usePreloadedQuery, + type PreloadedQuery, +} from "react-relay"; +import { useTranslate } from "@probo/i18n"; +import { + ActionDropdown, + Button, + Card, + ConfirmDialog, + DropdownItem, + FileButton, + IconChevronDown, + IconChevronUp, + IconFolderUpload, + IconPencil, + IconPlusLarge, + IconTrashCan, + MeasureBadge, + MeasureImplementation, + PageHeader, + Table, + Tbody, + Td, + Th, + Thead, + Tr, +} from "@probo/ui"; +import { + measuresQuery, + useDeleteMeasureMutation, +} from "/hooks/graph/MeasureGraph"; +import { graphql } from "relay-runtime"; +import type { + MeasuresPageFragment$data, + MeasuresPageFragment$key, +} from "./__generated__/MeasuresPageFragment.graphql"; +import { groupBy, objectKeys, sprintf } from "@probo/helpers"; +import { useMemo, useRef, useState, type ChangeEventHandler } from "react"; +import type { NodeOf } from "/types"; +import type { MeasuresPageImportMutation } from "./__generated__/MeasuresPageImportMutation.graphql"; +import { useMutationWithToasts } from "/hooks/useMutationWithToasts"; +import { useToggle } from "@probo/hooks"; + +type Props = { + queryRef: PreloadedQuery; +}; + +const measuresFragment = graphql` + fragment MeasuresPageFragment on Organization { + measures(first: 100) @connection(key: "MeasuresGraphListQuery__measures") { + __id + edges { + node { + id + name + category + state + } + } + } + } +`; + +const importMeasuresMutation = graphql` + mutation MeasuresPageImportMutation( + $input: ImportMeasureInput! + $connections: [ID!]! + ) { + importMeasure(input: $input) { + measureEdges @appendEdge(connections: $connections) { + node { + id + name + category + state + } + } + } + } +`; + +export default function MeasuresPage(props: Props) { + const { __ } = useTranslate(); + const organization = usePreloadedQuery( + measuresQuery, + props.queryRef + ).organization; + const data = useFragment( + measuresFragment, + organization + ); + const connectionId = data.measures.__id; + const measures = data.measures.edges.map((edge) => edge.node); + const measuresPerCategory = useMemo(() => { + return groupBy(measures, (measure) => measure.category); + }, [measures]); + const [importMeasures] = useMutationWithToasts( + importMeasuresMutation, + { + successMessage: __("Measures imported successfully."), + errorMessage: __("Failed to import measures. Please try again."), + } + ); + const importFileRef = useRef(null); + + const handleImport: ChangeEventHandler = (event) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + importMeasures({ + variables: { + input: { + organizationId: organization.id, + file: null, + }, + connections: [connectionId], + }, + uploadables: { + "input.file": file, + }, + onCompleted() { + importFileRef.current!.value = ""; + }, + }); + }; + + return ( +
+ + + {__("Import")} + + + + + {objectKeys(measuresPerCategory) + .sort((a, b) => a.localeCompare(b)) + .map((category) => ( + + ))} +
+ ); +} + +type CategoryProps = { + category: string; + measures: NodeOf[]; +}; + +function Category(props: CategoryProps) { + const { __ } = useTranslate(); + const [limit, setLimit] = useState(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, toggleExpanded] = useToggle(false); + const ExpandComponent = isExpanded ? IconChevronUp : IconChevronDown; + const completedMeasures = props.measures.filter( + (m) => m.state === "IMPLEMENTED" + ); + + return ( + +
+

{props.category}

+
+ + {__("Completion")}:{" "} + + {completedMeasures.length}/{props.measures.length} + + + | + +
+
+ {isExpanded && ( +
+ + + + + + + + + + {measures.map((measure) => ( + + ))} + +
{__("Measure")}{__("State")}
+ {showMoreButton && ( + + )} +
+ )} +
+ ); +} + +type MeasureRowProps = { + measure: NodeOf; +}; + +function MeasureRow(props: MeasureRowProps) { + const { __ } = useTranslate(); + const [deleteMeasure, isDeleting] = useDeleteMeasureMutation(); + + const onDelete = () => { + deleteMeasure({ + variables: { + input: { measureId: props.measure.id }, + connections: ["MeasuresListQuery_measures"], + }, + }); + }; + + return ( + + {props.measure.name} + + + + + + {__("Edit")} + onDelete()} + > + + {__("Delete")} + + + + + + ); +} diff --git a/apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageFragment.graphql.ts b/apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageFragment.graphql.ts new file mode 100644 index 000000000..62945a657 --- /dev/null +++ b/apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageFragment.graphql.ts @@ -0,0 +1,171 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment } from "relay-runtime"; +export type MeasureState = + | "IMPLEMENTED" + | "IN_PROGRESS" + | "NOT_APPLICABLE" + | "NOT_STARTED"; +import { FragmentRefs } from "relay-runtime"; +export type MeasuresPageFragment$data = { + readonly measures: { + readonly __id: string; + readonly edges: ReadonlyArray<{ + readonly node: { + readonly category: string; + readonly id: string; + readonly name: string; + readonly state: MeasureState; + }; + }>; + }; + readonly " $fragmentType": "MeasuresPageFragment"; +}; +export type MeasuresPageFragment$key = { + readonly " $data"?: MeasuresPageFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"MeasuresPageFragment">; +}; + +const node: ReaderFragment = { + argumentDefinitions: [], + kind: "Fragment", + metadata: { + connection: [ + { + count: null, + cursor: null, + direction: "forward", + path: ["measures"], + }, + ], + }, + name: "MeasuresPageFragment", + selections: [ + { + alias: "measures", + args: null, + concreteType: "MeasureConnection", + kind: "LinkedField", + name: "__MeasuresGraphListQuery__measures_connection", + plural: false, + selections: [ + { + alias: null, + args: null, + concreteType: "MeasureEdge", + kind: "LinkedField", + name: "edges", + plural: true, + selections: [ + { + alias: null, + args: null, + concreteType: "Measure", + kind: "LinkedField", + name: "node", + plural: false, + selections: [ + { + alias: null, + args: null, + kind: "ScalarField", + name: "id", + storageKey: null, + }, + { + alias: null, + args: null, + kind: "ScalarField", + name: "name", + storageKey: null, + }, + { + alias: null, + args: null, + kind: "ScalarField", + name: "category", + storageKey: null, + }, + { + alias: null, + args: null, + kind: "ScalarField", + name: "state", + storageKey: null, + }, + { + alias: null, + args: null, + kind: "ScalarField", + name: "__typename", + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + kind: "ScalarField", + name: "cursor", + storageKey: null, + }, + ], + storageKey: null, + }, + { + alias: null, + args: null, + concreteType: "PageInfo", + kind: "LinkedField", + name: "pageInfo", + plural: false, + selections: [ + { + alias: null, + args: null, + kind: "ScalarField", + name: "endCursor", + storageKey: null, + }, + { + alias: null, + args: null, + kind: "ScalarField", + name: "hasNextPage", + storageKey: null, + }, + ], + storageKey: null, + }, + { + kind: "ClientExtension", + selections: [ + { + alias: null, + args: null, + kind: "ScalarField", + name: "__id", + storageKey: null, + }, + ], + }, + ], + storageKey: null, + }, + ], + type: "Organization", + abstractKey: null, +}; + +(node as any).hash = "6a45bee844bf79dc4540e20edf10ad78"; + +export default node; diff --git a/apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageImportMutation.graphql.ts b/apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageImportMutation.graphql.ts new file mode 100644 index 000000000..06bf10ad8 --- /dev/null +++ b/apps/console2/src/pages/organizations/measures/__generated__/MeasuresPageImportMutation.graphql.ts @@ -0,0 +1,184 @@ +/** + * @generated SignedSource<<5a6e0e680ef636cf776a56f490733aee>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type MeasureState = "IMPLEMENTED" | "IN_PROGRESS" | "NOT_APPLICABLE" | "NOT_STARTED" | "%future added value"; +export type ImportMeasureInput = { + file: any; + organizationId: string; +}; +export type MeasuresPageImportMutation$variables = { + connections: ReadonlyArray; + input: ImportMeasureInput; +}; +export type MeasuresPageImportMutation$data = { + readonly importMeasure: { + readonly measureEdges: ReadonlyArray<{ + readonly node: { + readonly category: string; + readonly id: string; + readonly name: string; + readonly state: MeasureState; + }; + }>; + }; +}; +export type MeasuresPageImportMutation = { + response: MeasuresPageImportMutation$data; + variables: MeasuresPageImportMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "connections" +}, +v1 = { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" +}, +v2 = [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } +], +v3 = { + "alias": null, + "args": null, + "concreteType": "MeasureEdge", + "kind": "LinkedField", + "name": "measureEdges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "Measure", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "name", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "category", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "state", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null +}; +return { + "fragment": { + "argumentDefinitions": [ + (v0/*: any*/), + (v1/*: any*/) + ], + "kind": "Fragment", + "metadata": null, + "name": "MeasuresPageImportMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "ImportMeasurePayload", + "kind": "LinkedField", + "name": "importMeasure", + "plural": false, + "selections": [ + (v3/*: any*/) + ], + "storageKey": null + } + ], + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": [ + (v1/*: any*/), + (v0/*: any*/) + ], + "kind": "Operation", + "name": "MeasuresPageImportMutation", + "selections": [ + { + "alias": null, + "args": (v2/*: any*/), + "concreteType": "ImportMeasurePayload", + "kind": "LinkedField", + "name": "importMeasure", + "plural": false, + "selections": [ + (v3/*: any*/), + { + "alias": null, + "args": null, + "filters": null, + "handle": "appendEdge", + "key": "", + "kind": "LinkedHandle", + "name": "measureEdges", + "handleArgs": [ + { + "kind": "Variable", + "name": "connections", + "variableName": "connections" + } + ] + } + ], + "storageKey": null + } + ] + }, + "params": { + "cacheID": "13a5799ca6febcfb85294f75456deffb", + "id": null, + "metadata": {}, + "name": "MeasuresPageImportMutation", + "operationKind": "mutation", + "text": "mutation MeasuresPageImportMutation(\n $input: ImportMeasureInput!\n) {\n importMeasure(input: $input) {\n measureEdges {\n node {\n id\n name\n category\n state\n }\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "6419f51d00f273c824038e7f2d4c65ba"; + +export default node; diff --git a/apps/console2/src/routes.tsx b/apps/console2/src/routes.tsx index 6f082b6dc..44cc9cee6 100644 --- a/apps/console2/src/routes.tsx +++ b/apps/console2/src/routes.tsx @@ -12,6 +12,7 @@ import { PageSkeleton } from "./components/skeletons/PageSkeleton.tsx"; import { type PreloadedQuery } from "react-relay"; import { useCleanup } from "./hooks/useDelayedEffect.ts"; import { riskRoutes } from "./routes/riskRoutes.ts"; +import { measureRoutes } from "./routes/measureRoutes.ts"; function ErrorBoundary() { const error = useRouteError(); @@ -70,6 +71,7 @@ const routes = [ ), }, ...riskRoutes, + ...measureRoutes, ], }, ] satisfies AppRoute[]; diff --git a/apps/console2/src/routes/measureRoutes.ts b/apps/console2/src/routes/measureRoutes.ts new file mode 100644 index 000000000..43b302c1a --- /dev/null +++ b/apps/console2/src/routes/measureRoutes.ts @@ -0,0 +1,16 @@ +import { lazy } from "react"; +import { loadQuery } from "react-relay"; +import { RisksPageSkeleton } from "/components/skeletons/RisksPageSkeleton.tsx"; +import type { AppRoute } from "/routes.tsx"; +import { relayEnvironment } from "/providers/RelayProviders"; +import { measuresQuery } from "/hooks/graph/MeasureGraph"; + +export const measureRoutes = [ + { + path: "measures", + fallback: RisksPageSkeleton, + queryLoader: ({ organizationId }) => + loadQuery(relayEnvironment, measuresQuery, { organizationId }), + Component: lazy(() => import("/pages/organizations/measures/MeasuresPage")), + }, +] satisfies AppRoute[]; diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 1d62bdfc0..0d30773a0 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -8,3 +8,4 @@ export { } from "./risk"; export { times, groupBy, isEmpty } from "./array"; export { randomInt } from "./number"; +export { getMeasureStateLabel, measureStates } from "./measure"; diff --git a/packages/helpers/src/measure.ts b/packages/helpers/src/measure.ts new file mode 100644 index 000000000..b3eb8f478 --- /dev/null +++ b/packages/helpers/src/measure.ts @@ -0,0 +1,23 @@ +type Translator = (s: string) => string; + +export const measureStates = [ + "IMPLEMENTED", + "IN_PROGRESS", + "NOT_APPLICABLE", + "NOT_STARTED", +] as const; + +export function getMeasureStateLabel(__: Translator, state: string) { + switch (state) { + case "IMPLEMENTED": + return __("Implemented"); + case "IN_PROGRESS": + return __("In Progress"); + case "NOT_APPLICABLE": + return __("Not Applicable"); + case "NOT_STARTED": + return __("Not Started"); + default: + return __("Unknown"); + } +} diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index aff699567..e32edabae 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -3,6 +3,7 @@ import type { Preview } from "@storybook/react"; import "../src/theme.css"; import "./preview.css"; import { BrowserRouter } from "react-router"; +import { useEffect } from "react"; const preview: Preview = { parameters: { @@ -15,6 +16,9 @@ const preview: Preview = { }, decorators: [ (Story, { parameters }) => { + useEffect(() => { + document.body.classList.add("bg-level-0"); + }, []); return (
diff --git a/packages/ui/src/Atoms/Button/Button.tsx b/packages/ui/src/Atoms/Button/Button.tsx index 9303dcd04..be4d4c58b 100644 --- a/packages/ui/src/Atoms/Button/Button.tsx +++ b/packages/ui/src/Atoms/Button/Button.tsx @@ -7,7 +7,7 @@ import type { import { Link } from "react-router"; import { tv, type VariantProps } from "tailwind-variants"; -const button = tv({ +export const button = tv({ base: "flex items-center justify-center gap-[6px] px-3 py-2 rounded-full cursor-pointer text-sm font-medium h-8 focus:outline-none", variants: { variant: { diff --git a/packages/ui/src/Atoms/Table/Table.tsx b/packages/ui/src/Atoms/Table/Table.tsx index fcd45d58f..8a30aa6aa 100644 --- a/packages/ui/src/Atoms/Table/Table.tsx +++ b/packages/ui/src/Atoms/Table/Table.tsx @@ -3,9 +3,12 @@ import { Card } from "../Card/Card"; import { Link } from "react-router"; import clsx from "clsx"; -export function Table({ children }: PropsWithChildren) { +export function Table({ + children, + className, +}: PropsWithChildren<{ className?: string }>) { return ( - + {children}
); @@ -59,17 +62,26 @@ export function Td({ children, noLink, className, -}: PropsWithChildren<{ noLink?: boolean; className?: string }>) { + width, +}: PropsWithChildren<{ + noLink?: boolean; + className?: string; + width?: number; +}>) { const { to } = useContext(TrContext); if (!to || noLink) { return ( - + {children} ); } return ( ; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/ui/src/Molecules/Badge/MeasureBadge.tsx b/packages/ui/src/Molecules/Badge/MeasureBadge.tsx new file mode 100644 index 000000000..5c5f8d508 --- /dev/null +++ b/packages/ui/src/Molecules/Badge/MeasureBadge.tsx @@ -0,0 +1,28 @@ +import { getMeasureStateLabel } from "@probo/helpers"; +import { Badge } from "@probo/ui"; +import { useTranslate } from "@probo/i18n"; +import type { ComponentProps } from "react"; +import type { measureStates } from "@probo/helpers"; + +type Props = { + state: (typeof measureStates)[number]; +}; + +const stateToVariant: Record< + Props["state"], + ComponentProps["variant"] +> = { + IMPLEMENTED: "success", + IN_PROGRESS: "info", + NOT_APPLICABLE: "neutral", + NOT_STARTED: "neutral", +}; + +export function MeasureBadge({ state }: Props) { + const { __ } = useTranslate(); + return ( + + {getMeasureStateLabel(__, state)} + + ); +} diff --git a/packages/ui/src/Molecules/FileButton/FileButton.stories.tsx b/packages/ui/src/Molecules/FileButton/FileButton.stories.tsx new file mode 100644 index 000000000..040aa36e0 --- /dev/null +++ b/packages/ui/src/Molecules/FileButton/FileButton.stories.tsx @@ -0,0 +1,16 @@ +import { FileButton } from "./FileButton"; +import type { Meta, StoryObj } from "@storybook/react"; + +export default { + title: "Molecules/FileButton", + component: FileButton, + argTypes: {}, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + children: "Upload", + }, +}; diff --git a/packages/ui/src/Molecules/FileButton/FileButton.tsx b/packages/ui/src/Molecules/FileButton/FileButton.tsx new file mode 100644 index 000000000..1f10a03b7 --- /dev/null +++ b/packages/ui/src/Molecules/FileButton/FileButton.tsx @@ -0,0 +1,32 @@ +import type { + ChangeEventHandler, + ComponentProps, + PropsWithChildren, + RefObject, +} from "react"; +import { button, Button } from "../../Atoms/Button/Button.tsx"; + +type Props = PropsWithChildren<{ + onChange: ChangeEventHandler; + accept?: string; + disabled?: boolean; + className?: string; + ref?: RefObject; +}> & + Pick, "disabled" | "variant" | "icon">; + +export function FileButton({ + onChange, + children, + icon: IconComponent, + ref, + ...props +}: Props) { + return ( + + ); +} diff --git a/packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.stories.tsx b/packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.stories.tsx new file mode 100644 index 000000000..0529c0b14 --- /dev/null +++ b/packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.stories.tsx @@ -0,0 +1,30 @@ +import { MeasureImplementation } from "./MeasureImplementation"; +import type { Meta, StoryObj } from "@storybook/react"; +import { times } from "@probo/helpers"; + +export default { + title: "Molecules/MeasureImplementation", + component: MeasureImplementation, + argTypes: {}, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + measures: [ + ...times(15, () => ({ + state: "IMPLEMENTED", + })), + ...times(10, () => ({ + state: "IN_PROGRESS", + })), + ...times(5, () => ({ + state: "NOT_APPLICABLE", + })), + ...times(5, () => ({ + state: "NOT_STARTED", + })), + ] as any, + }, +}; diff --git a/packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.tsx b/packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.tsx new file mode 100644 index 000000000..caccf3a75 --- /dev/null +++ b/packages/ui/src/Molecules/MeasureImplementation/MeasureImplementation.tsx @@ -0,0 +1,72 @@ +import type { ComponentProps } from "react"; +import type { MeasureBadge } from "../Badge/MeasureBadge.tsx"; +import { useTranslate } from "@probo/i18n"; +import { getMeasureStateLabel, measureStates } from "@probo/helpers"; +import clsx from "clsx"; + +type MeasureState = ComponentProps["state"]; + +type Props = { + measures: { state: MeasureState }[]; + className?: string; +}; + +const stateToColor: Record = { + IMPLEMENTED: "bg-border-success", + IN_PROGRESS: "bg-border-warning", + NOT_APPLICABLE: "bg-border-info", + NOT_STARTED: "bg-highlight", +}; + +export function MeasureImplementation({ measures, className }: Props) { + const { __ } = useTranslate(); + const counts = measures.reduce( + (acc, measure) => { + acc[measure.state] = (acc[measure.state] ?? 0) + 1; + return acc; + }, + {} as Record, + ); + const percent = Math.round( + (100 * + ((counts["IMPLEMENTED"] ?? 0) + (counts["NOT_APPLICABLE"] ?? 0))) / + measures.length, + ); + return ( +
+

+ {__("Measure implementation")} +

+
+ {measureStates.map((state) => ( +
+ ))} +
+
+
+ {percent}% {__("Complete")} +
+ {measureStates.map((state) => ( +
+
+ {getMeasureStateLabel(__, state)} +
+ ))} +
+
+ ); +} diff --git a/packages/ui/src/Molecules/PageHeader/PageHeader.tsx b/packages/ui/src/Molecules/PageHeader/PageHeader.tsx index b04036552..e940115e7 100644 --- a/packages/ui/src/Molecules/PageHeader/PageHeader.tsx +++ b/packages/ui/src/Molecules/PageHeader/PageHeader.tsx @@ -14,7 +14,7 @@ export function PageHeader({ title, description, children }: Props) {

{description}

)}
-
{children}
+
{children}
); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 07f9ad883..4fdf581cc 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -46,6 +46,9 @@ export { ConfirmDialog } from "./Molecules/Dialog/ConfirmDialog.tsx"; export { RisksChart } from "./Molecules/Risks/RisksChart"; export { RiskOverview } from "./Molecules/Risks/RiskOverview"; export { Combobox, ComboboxItem } from "./Molecules/Combobox/Combobox"; +export { FileButton } from "./Molecules/FileButton/FileButton"; +export { MeasureBadge } from "./Molecules/Badge/MeasureBadge"; +export { MeasureImplementation } from "./Molecules/MeasureImplementation/MeasureImplementation"; // Hooks export { useToast, Toasts } from "./Atoms/Toasts/Toasts";