diff --git a/client/src/components/CompareAttributeDialog/index.tsx b/client/src/components/CompareAttributeDialog/index.tsx new file mode 100644 index 00000000..939155b4 --- /dev/null +++ b/client/src/components/CompareAttributeDialog/index.tsx @@ -0,0 +1,904 @@ +// React +import React, { useEffect, useState } from "react"; + +// Existing and custom components +import { Button, Flex, Text, CloseButton, Spinner, Checkbox, Collapsible, Dialog, Spacer } from "@chakra-ui/react"; +import AlertDialog from "@components/AlertDialog"; +import Icon from "@components/Icon"; +import Tooltip from "@components/Tooltip"; + +// Existing and custom types +import { + AttributeModel, + CompareAttributeDialogCollapsibleProps, + CompareAttributeDialogProps, + CompareAttributeFieldDiffProps, + IValue, +} from "@types"; + +// GraphQL imports +import { useLazyQuery } from "@apollo/client/react"; +import { gql } from "@apollo/client"; + +// Utility functions +import { getValueTypeIconProps, isValueEqual } from "@lib/util"; +import _ from "lodash"; + +// Variables +import { GLOBAL_STYLES } from "@variables"; + +const CompareAttributeDialog = (props: CompareAttributeDialogProps) => { + const [templateAttribute, setTemplateAttribute] = useState(); + const [loadingComparison, setLoadingComparison] = useState(false); + const [warningOpen, setWarningOpen] = useState(false); + + // Collapsible sections state (all expanded by default) + const [expandedSections, setExpandedSections] = useState>(new Set()); + + // Selection state + const [useTemplateName, setUseTemplateName] = useState(false); + const [useTemplateDescription, setUseTemplateDescription] = useState(false); + const [adoptModifiedValueIds, setAdoptModifiedValueIds] = useState>(new Set()); + const [pullTemplateValueIds, setPullTemplateValueIds] = useState>(new Set()); + const [removeEntityValueIds, setRemoveEntityValueIds] = useState>(new Set()); + + 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); + + /** + * Utility function to configure state prior to displaying comparison view + */ + const prepareComparison = async () => { + // Reset state + setLoadingComparison(true); + setUseTemplateName(false); + setUseTemplateDescription(false); + setAdoptModifiedValueIds(new Set()); + setPullTemplateValueIds(new Set()); + setRemoveEntityValueIds(new Set()); + + // Retreive the latest version of the Template for comparison + const templateAttributeResult = await getTemplate({ + variables: { + _id: props.templateAttributeId, + }, + }); + + if (templateAttributeResult.data?.template) { + const template = templateAttributeResult.data.template; + setTemplateAttribute(template); + + // Apply operations and selections if `defaultApplyAll` is `true` + if (props.defaultApplyAll) { + if (props.modifiedAttribute.name !== template.name) { + setUseTemplateName(true); + } + if (props.modifiedAttribute.description !== template.description) { + setUseTemplateDescription(true); + } + + const entityValueMap = new Map(props.modifiedAttribute.values.map((value) => [value._id, value])); + const templateValueMap = new Map(template.values.map((value) => [value._id, value])); + + // Create Sets and determine exact differences between Template and current Entity Attribute + const adoptModifiedSet = new Set(); + const pullTemplateSet = new Set(); + const removeEntitySet = new Set(); + + for (const [id, templateValue] of templateValueMap) { + const entityValue = entityValueMap.get(id); + if (entityValue && !isValueEqual(entityValue, templateValue)) { + // Modified Value from Template + adoptModifiedSet.add(id); + } else if (!entityValue) { + // Added Value from Template + pullTemplateSet.add(id); + } + } + + // Removed Values from Template + for (const [id] of entityValueMap) { + if (!templateValueMap.has(id)) { + removeEntitySet.add(id); + } + } + + setAdoptModifiedValueIds(adoptModifiedSet); + setPullTemplateValueIds(pullTemplateSet); + setRemoveEntityValueIds(removeEntitySet); + } + } + setLoadingComparison(false); + }; + + useEffect(() => { + if (props.open) { + prepareComparison(); + } + }, [props.open]); + + /** + * Helper function to toggle the selected items under `CompareAttributeDialogCollapsible` sections + * @param {React.Dispatch>>} setSelected Function to dispatch and update React state + * @param {string} selection Selection to add or remove + */ + const toggleSet = (setSelected: React.Dispatch>>, selection: string) => { + setSelected((previous) => { + const next = new Set(previous); + if (next.has(selection)) { + next.delete(selection); + } else { + next.add(selection); + } + return next; + }); + }; + + /** + * Helper function to toggle the `CompareAttributeDialogCollapsible` sections that are expanded + * @param {string} section Selected section of the Template Values diff + */ + const toggleSection = (section: string) => { + setExpandedSections((previous) => { + const next = new Set(previous); + if (next.has(section)) { + next.delete(section); + } else { + next.add(section); + } + return next; + }); + }; + + /** + * Helper component to represent the collapsible sections for each type of modification to the Template + * @param props Component props + * @return + */ + const CompareAttributeDialogCollapsible = (props: CompareAttributeDialogCollapsibleProps) => ( + toggleSection(props.sectionKey)} + bg={`${props.color}.50`} + p={"1"} + rounded={"md"} + disabled={props.disabled} + > + + + + + + {props.label} ({props.count}) + + + + + + {props.children} + + + + ); + + /** + * Helper component to represent the direct comparison of a single modification to the Template + * @param props Component props + * @return + */ + const CompareAttributeFieldDiff = (props: CompareAttributeFieldDiffProps) => ( + + + + {props.label} + + {props.isDifferent && ( + props.setUseOriginal(!!e.checked)} + > + + + Reset to Template + + )} + + + + {props.isDifferent && ( + + + Current Entity: + + + + {_.truncate(props.currentValue, { length: 24 })} + + + + + + + + )} + + + Template: + + + + {_.truncate(props.originalValue, { length: 24 })} + + + + + + {!props.isDifferent && } + + + + ); + + // Generate the differences between the Template Values and the Entity Attribute Values + const entityValueMap = new Map(props.modifiedAttribute.values.map((v) => [v._id, v])); + const templateValueMap = new Map(templateAttribute?.values.map((v) => [v._id, v]) || []); + + // Generate the collection of shared Values + const sharedValues = [...entityValueMap.keys()].filter((id) => templateValueMap.has(id)); + + // Generate the collections of differing Values + const unchangedValues: { entity: IValue; template: IValue }[] = []; + const modifiedValues: { entity: IValue; template: IValue }[] = []; + const templateOnlyValues: IValue[] = []; + const entityOnlyValues: IValue[] = []; + + for (const value of sharedValues) { + const entityValue = entityValueMap.get(value)!; + const templateValue = templateValueMap.get(value)!; + if (isValueEqual(entityValue, templateValue)) { + unchangedValues.push({ entity: entityValue, template: templateValue }); + } else { + modifiedValues.push({ entity: entityValue, template: templateValue }); + } + } + + // Values only contained in the Template + for (const [id, value] of templateValueMap) { + if (!entityValueMap.has(id)) { + templateOnlyValues.push(value); + } + } + + // Values only contained in the Entity Attribute + for (const [id, value] of entityValueMap) { + if (!templateValueMap.has(id)) entityOnlyValues.push(value); + } + + const nameIsDifferent = props.modifiedAttribute.name !== templateAttribute?.name; + const descriptionIsDifferent = props.modifiedAttribute.description !== templateAttribute?.description; + + const hasSelection = + useTemplateName || + useTemplateDescription || + adoptModifiedValueIds.size > 0 || + pullTemplateValueIds.size > 0 || + removeEntityValueIds.size > 0; + + const onUpdate = () => { + // If no `onUpdate` function is specified or the `AttributeModel` is undefined, ignore + if (!props.onUpdate || !templateAttribute) { + return; + } + + // Create updated Set of merged Values, applying changes based on user selections + let mergedValues: IValue[] = props.modifiedAttribute.values.map((value) => ({ ...value })); + mergedValues = mergedValues.filter((value) => !removeEntityValueIds.has(value._id)); + const mergedMap = new Map(mergedValues.map((v) => [v._id, v])); + + // Apply Values that are to be updated from the Template + for (const value of templateAttribute.values) { + if (mergedMap.has(value._id) && adoptModifiedValueIds.has(value._id)) { + const valueId = mergedValues.findIndex((mergedValue) => mergedValue._id === value._id); + if (valueId !== -1) { + mergedValues[valueId] = { ...value }; + } + } + } + + // Add Values to be pulled from the Template + for (const value of templateAttribute.values) { + if (!mergedMap.has(value._id) && pullTemplateValueIds.has(value._id)) { + mergedValues.push({ ...value }); + } + } + + // Call the `onUpdate` function to apply changes + props.onUpdate({ + ...props.modifiedAttribute, + name: useTemplateName && nameIsDifferent ? templateAttribute.name : props.modifiedAttribute.name, + description: + useTemplateDescription && descriptionIsDifferent + ? templateAttribute.description + : props.modifiedAttribute.description, + values: mergedValues, + }); + props.setOpen(false); + }; + + return ( + + props.setOpen(event.open)} + size={"lg"} + closeOnEscape + closeOnInteractOutside + > + + + + + + + + + Compare: {props.modifiedAttribute.name} + + + + {templateAttribute?.name} + + + + + props.setOpen(false)} /> + + + + + {loadingComparison ? ( + + + + + Preparing Comparison + + + + ) : !templateAttribute ? ( + + + Unable to load Template + + + ) : ( + + + {/* Template Name */} + + {/* Template Description */} + + + + {/* Template Values */} + + + Values + + + + {/* Left Column: Entity Attribute */} + + + + Current Entity + + + {props.entityName}: {props.modifiedAttribute.name} + + + + + {unchangedValues.length === 0 && ( + + No Unmodified Values + + )} + {unchangedValues.map(({ entity }) => ( + + + + Value: + + + + {entity.name} + + + + ))} + + + + {modifiedValues.length === 0 && ( + + No Modified Values + + )} + {modifiedValues.map(({ entity }) => ( + + + + Value: + + + + {entity.name} + + + + + Data: + + + + {_.truncate(entity.data, { length: 24 })} + + + + + ))} + + + + {entityOnlyValues.length === 0 && templateOnlyValues.length === 0 && ( + + No Values to Add or Remove + + )} + {entityOnlyValues.length > 0 && ( + + Added to Current Entity + + )} + {entityOnlyValues.map((value) => ( + + + + Value: + + + + {value.name} + + + toggleSet(setRemoveEntityValueIds, value._id)} + > + + + Remove + + + ))} + + + + + + {/* Right Column: Template Attribute */} + + + + Template + + + {templateAttribute.name} + + + + + {unchangedValues.length === 0 && ( + + No Unmodified Values + + )} + {unchangedValues.map(({ template }) => ( + + + + Value: + + + + {template.name} + + + + ))} + + + + {modifiedValues.length === 0 && ( + + No Modified Values + + )} + {modifiedValues.map(({ template }) => ( + + + + + Value: + + + + {template.name} + + + toggleSet(setAdoptModifiedValueIds, template._id)} + > + + + Reset to Template + + + + + Data: + + + + {_.truncate(template.data, { length: 24 })} + + + + + ))} + + + + {entityOnlyValues.length === 0 && templateOnlyValues.length === 0 && ( + + No Values to Add or Remove + + )} + {templateOnlyValues.length > 0 && ( + + Available in Template + + )} + {templateOnlyValues.map((value) => ( + + + + Value: + + + + {value.name} + + + toggleSet(setPullTemplateValueIds, value._id)} + > + + + Add + + + ))} + + + + + + )} + + + + + + + + + + + + + setWarningOpen(false)} + rightButtonLabel={"Apply"} + rightButtonAction={() => { + setWarningOpen(false); + onUpdate(); + }} + > + + + These operations are potentially destructive! + + + Ensure you review all changes to {props.modifiedAttribute.name} before saving. You will be able to preview + changes. + + + + + ); +}; + +export default CompareAttributeDialog; diff --git a/client/src/components/Icon/index.tsx b/client/src/components/Icon/index.tsx index 956366f2..afb1bc49 100644 --- a/client/src/components/Icon/index.tsx +++ b/client/src/components/Icon/index.tsx @@ -8,6 +8,7 @@ import { BsArchiveFill, BsArrowCounterclockwise, BsArrowLeftCircleFill, + BsArrowLeftRight, BsArrowRight, BsArrowRightCircleFill, BsArrowUpRight, @@ -33,6 +34,7 @@ import { BsCloudDownloadFill, BsCollectionFill, BsCopy, + BsDashCircleFill, BsDatabaseFill, BsDiagram2Fill, BsEnvelope, @@ -40,6 +42,7 @@ import { BsEye, BsEyeSlash, BsFileCodeFill, + BsFileDiffFill, BsFileEarmarkRichtextFill, BsFileTextFill, BsFillBookFill, @@ -110,12 +113,14 @@ export const SYSTEM_ICONS: Record = { check: BsCheckCircleFill, counter: BsCalculator, close: BsXLg, + diff: BsFileDiffFill, info: BsInfoCircleFill, file: BsFileEarmarkRichtextFill, search: BsSearch, search_query: BsBraces, bell: BsBellFill, add: BsPlusCircleFill, + remove: BsDashCircleFill, copy: BsCopy, edit: BsPencilFill, expand: BsArrowsAngleExpand, @@ -172,6 +177,7 @@ export const SYSTEM_ICONS: Record = { a_right: BsArrowRight, a_right_fill: BsArrowRightCircleFill, a_left_fill: BsArrowLeftCircleFill, + a_both: BsArrowLeftRight, a_both_fill: BsFillDashCircleFill, // Chevrons diff --git a/client/src/components/Values/index.tsx b/client/src/components/Values/index.tsx index 43ee6f87..52f6858b 100644 --- a/client/src/components/Values/index.tsx +++ b/client/src/components/Values/index.tsx @@ -1202,6 +1202,25 @@ const ValueRow = (props: { props.onValueChange(props.value._id, valueName, valueType, valueData, props.permittedValues ? source : undefined); }, [valueName, valueType, valueData, source]); + // Sync local state when props change from an external source + useEffect(() => { + setValueName(props.value.name); + setValueType(props.value.type as IValueType); + setValueData(props.value.data); + + // Adjust for Value sources + const valueSource = props.value.source ?? "column"; + setSource(valueSource); + const columnMode = props.permittedValues !== undefined && valueSource === "column"; + + // Update Value types + const typeOptions: ValueTypeOption[] = columnMode + ? baseTypeOptions + : [...baseTypeOptions, { label: "Entity", value: "entity" }, { label: "Select", value: "select" }]; + const valueType = typeOptions.find((option) => option.value === props.value.type) ?? baseTypeOptions[1]; + setValueTypeOption(valueType); + }, [props.value.name, props.value.type, props.value.data, props.value.source, props.permittedValues]); + /** * Utility function to generate default data when the `type` changes * @param valueType The new `IValueType` that has been selected diff --git a/client/src/components/ViewAttributeDialog/index.tsx b/client/src/components/ViewAttributeDialog/index.tsx index 3461c988..c1f53524 100644 --- a/client/src/components/ViewAttributeDialog/index.tsx +++ b/client/src/components/ViewAttributeDialog/index.tsx @@ -4,12 +4,13 @@ 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"; // Existing and custom types -import { ViewAttributeDialogProps } from "@types"; +import { AttributeModel, ViewAttributeDialogProps } from "@types"; // Utility functions and libraries import _ from "lodash"; @@ -21,213 +22,281 @@ const ViewAttributeDialog = (props: ViewAttributeDialogProps) => { const isEditing = _.isBoolean(props.editing) ? props.editing : false; // State to be updated - const [attribute] = useState(props.attribute); const [name, setName] = useState(props.attribute.name); const [description, setDescription] = useState(props.attribute.description); const [values, setValues] = useState(props.attribute.values); + const [compareDialogOpen, setCompareDialogOpen] = useState(false); + const [compareDefaultApplyAll, setCompareDefaultApplyAll] = useState(false); + + // Current working state, used to inform `CompareAttributeDialog` state + const currentAttribute: AttributeModel = { + ...props.attribute, + name, + description, + values, + }; + + /** + * Helper function to apply changes to the `AttributeModel` after the `CompareAttributeDialog` has been closed + * @param updated Update `AttributeModel` after comparison + */ + const onUpdateAttribute = (updated: AttributeModel) => { + setName(updated.name); + setDescription(updated.description); + setValues(updated.values); + }; 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 - -