From 6cf374de7ba354b421ee7b6086933609f88d711b Mon Sep 17 00:00:00 2001 From: Henry Burgess Date: Wed, 24 Jun 2026 13:06:00 -0500 Subject: [PATCH 1/6] MARS-1167 Create `CompareAttributeDialog` dialog --- .../CompareAttributeDialog/index.tsx | 273 ++++++++++++ client/src/components/Icon/index.tsx | 4 + .../components/ViewAttributeDialog/index.tsx | 396 ++++++++++-------- types/index.d.ts | 12 + 4 files changed, 504 insertions(+), 181 deletions(-) create mode 100644 client/src/components/CompareAttributeDialog/index.tsx diff --git a/client/src/components/CompareAttributeDialog/index.tsx b/client/src/components/CompareAttributeDialog/index.tsx new file mode 100644 index 00000000..f2653802 --- /dev/null +++ b/client/src/components/CompareAttributeDialog/index.tsx @@ -0,0 +1,273 @@ +// React +import React, { useEffect, useState } from "react"; + +// Existing and custom components +import { Flex, Dialog, Text, CloseButton, Spinner } from "@chakra-ui/react"; +import Icon from "@components/Icon"; + +// Existing and custom types +import { AttributeModel, CompareAttributeDialogProps, IValue } from "@types"; + +// GraphQL imports +import { useLazyQuery } from "@apollo/client/react"; +import { gql } from "@apollo/client"; + +// Utility functions and libraries +import _ from "lodash"; + +// Variables +import { GLOBAL_STYLES } from "@variables"; + +const CompareAttributeDialog = (props: CompareAttributeDialogProps) => { + // State storing Attribute information + const [templateAttribute, setTemplateAttribute] = useState(); + const [loadingComparison, setLoadingComparison] = useState(false); + + // GraphQL operations + const GET_TEMPLATE = gql` + query GetTemplate($_id: String) { + template(_id: $_id) { + _id + name + timestamp + owner + archived + description + values { + _id + name + type + data + } + history { + author + message + timestamp + version + _id + name + owner + archived + description + values { + _id + name + type + data + } + } + } + } + `; + const [getTemplate] = useLazyQuery<{ + template: AttributeModel; + }>(GET_TEMPLATE); + + const prepareComparison = async () => { + setLoadingComparison(true); + const templateAttributeResult = await getTemplate({ + variables: { + _id: props.templateAttributeId, + }, + }); + + if (templateAttributeResult.data) { + setTemplateAttribute(templateAttributeResult.data.template); + } + setLoadingComparison(false); + }; + + useEffect(() => { + prepareComparison(); + }, [props.open]); + + /** + * Generate a side-by-side "diff"-like comparison between an Attribute and the Template + * Attribute it was created from + * @param attributeLeft Existing Attribute that is based on the Template Attribute + * @param attributeRight Template Attribute + * @return + */ + const createSideBySide = (attributeLeft: AttributeModel, attributeRight: AttributeModel | undefined) => { + const leftValues = new Set(attributeLeft.values.map((value) => value._id)); + const rightValues = new Set(attributeRight?.values.map((value) => value._id)); + + // Get collection of Values that are conserved + const conservedValueIds = leftValues.intersection(rightValues); + const conservedValues = attributeLeft.values.filter((value) => [...conservedValueIds].includes(value._id)); + + // Get collection of Values that are removed + const removedValueIds = rightValues.difference(leftValues); + const removedValues = attributeRight?.values.filter((value) => [...removedValueIds].includes(value._id)) || []; + + // Get collection of Values that are added + const addedValueIds = leftValues.difference(rightValues); + const addedValues = attributeLeft.values.filter((value) => [...addedValueIds].includes(value._id)); + + return ( + + {/* Left Column */} + + + + Attribute: + + + {attributeLeft.name} + + + + + + Values: + + + {[...conservedValues].map((value) => { + return ( + + + + {value.name} + + + ); + })} + + {[...removedValues].map((value) => { + return ( + + + + {value.name} + + + ); + })} + + {[...addedValues].map((value) => { + return ( + + + + {value.name} + + + ); + })} + + + + + {/* Right Column - Fallback */} + {_.isUndefined(attributeRight) && ( + + + Unable to load Template + + + )} + + {/* Right Column - Default */} + {attributeRight && ( + + + Template: {attributeRight.name} + + + Values: + + + {[...attributeRight.values].map((value) => { + return ( + + + + {value.name} + + + ); + })} + + + )} + + ); + }; + + return ( + props.setOpen(event.open)} + size={"xl"} + closeOnEscape + closeOnInteractOutside + > + + + + + + + + + Compare Attributes: {props.modifiedAttribute.name} | {templateAttribute?.name} + + + + + props.setOpen(false)} /> + + + + + {loadingComparison ? ( + + + + + Preparing Comparison + + + + ) : ( + createSideBySide(props.modifiedAttribute, templateAttribute) + )} + + + + + ); +}; + +export default CompareAttributeDialog; diff --git a/client/src/components/Icon/index.tsx b/client/src/components/Icon/index.tsx index 3852e41d..578fa0cd 100644 --- a/client/src/components/Icon/index.tsx +++ b/client/src/components/Icon/index.tsx @@ -33,6 +33,7 @@ import { BsCloudDownloadFill, BsCollectionFill, BsCopy, + BsDashCircleFill, BsDatabaseFill, BsDiagram2Fill, BsEnvelope, @@ -40,6 +41,7 @@ import { BsEye, BsEyeSlash, BsFileCodeFill, + BsFileDiffFill, BsFileTextFill, BsFillBookFill, BsFillBookmarkFill, @@ -109,11 +111,13 @@ export const SYSTEM_ICONS: Record = { check: BsCheckCircleFill, counter: BsCalculator, close: BsXLg, + diff: BsFileDiffFill, info: BsInfoCircleFill, search: BsSearch, search_query: BsBraces, bell: BsBellFill, add: BsPlusCircleFill, + remove: BsDashCircleFill, copy: BsCopy, edit: BsPencilFill, expand: BsArrowsAngleExpand, diff --git a/client/src/components/ViewAttributeDialog/index.tsx b/client/src/components/ViewAttributeDialog/index.tsx index 3461c988..b174b937 100644 --- a/client/src/components/ViewAttributeDialog/index.tsx +++ b/client/src/components/ViewAttributeDialog/index.tsx @@ -4,6 +4,7 @@ import React, { useState } from "react"; // Existing and custom components import { Button, Flex, Input, Dialog, Text, CloseButton, EmptyState, Textarea } from "@chakra-ui/react"; import ActorTag from "@components/ActorTag"; +import CompareAttributeDialog from "@components/CompareAttributeDialog"; import Icon from "@components/Icon"; import Linky from "@components/Linky"; import Values from "@components/Values"; @@ -25,209 +26,242 @@ const ViewAttributeDialog = (props: ViewAttributeDialogProps) => { const [name, setName] = useState(props.attribute.name); const [description, setDescription] = useState(props.attribute.description); const [values, setValues] = useState(props.attribute.values); + const [compareAttributesOpen, setCompareAttributesOpen] = useState(false); return ( - props.setOpen(event.open)} - size={"xl"} - closeOnEscape - closeOnInteractOutside - > - - - - - - - - - {props.attribute.name} - - - - - props.setOpen(false)} /> - - - - - - {props.isTemplate && ( - - - Base Template: + + props.setOpen(event.open)} + size={"xl"} + closeOnEscape + closeOnInteractOutside + > + + + + + + + + + Attribute: {props.attribute.name} - - {/* Ensure actual ID is passed to Linky, remove appended Template unique identifier */} - - - )} - - - + + props.setOpen(false)} /> + + + + + + {props.isTemplate && ( + - Name - - + + Using: + + {/* Ensure actual ID is passed to Linky, remove appended Template unique identifier */} + + + + + + + + )} + + + setName(event.target.value)} - readOnly={!isEditing} - /> - + > - Owner + Name - - + setName(event.target.value)} + readOnly={!isEditing} + /> + + + Owner + + + + - - - - Description - -