From 712394a4ca3445e1de0b1adcc15e3e3cff3837a1 Mon Sep 17 00:00:00 2001 From: Adrian Galvan Date: Mon, 23 Oct 2023 12:39:46 -0700 Subject: [PATCH] Add custom fields to system history (#4294) Co-authored-by: Kelsey Thomas <101993653+Kelsey-Ethyca@users.noreply.github.com> --- CHANGELOG.md | 1 + .../features/common/custom-fields/hooks.ts | 101 +++++----- .../admin-ui/src/features/plus/plus.slice.ts | 10 +- .../src/features/system/SystemFormTabs.tsx | 4 +- .../features/system/SystemInformationForm.tsx | 10 +- .../system/history/SystemHistoryTable.tsx | 5 + .../src/features/system/history/helpers.tsx | 190 ++++++++++++++---- .../system/history/modal/SystemDataForm.tsx | 17 +- .../history/modal/SystemHistoryModal.tsx | 10 +- .../modal/fields/SystemCustomFieldGroup.tsx | 60 ++++++ .../modal/fields/SystemDataTextField.tsx | 2 +- .../src/features/system/system.slice.ts | 2 +- tests/ctl/core/test_system_history.py | 2 +- 13 files changed, 309 insertions(+), 105 deletions(-) create mode 100644 clients/admin-ui/src/features/system/history/modal/fields/SystemCustomFieldGroup.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index bb4f60f213d..f777a5e6e28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The types of changes are: ### Added - Added a `FidesPreferenceToggled` event to Fides.js to track when user preferences change without being saved [#4253](https://github.com/ethyca/fides/pull/4253) - Add AC Systems to the TCF Overlay under Vendor Consents section [#4266](https://github.com/ethyca/fides/pull/4266/) +- Custom fields are now included in system history change tracking [#4294](https://github.com/ethyca/fides/pull/4294) ### Changed - Derive cookie storage info, privacy policy and legitimate interest disclosure URLs, and data retention data from the data map instead of directly from gvl.json [#4286](https://github.com/ethyca/fides/pull/4286) diff --git a/clients/admin-ui/src/features/common/custom-fields/hooks.ts b/clients/admin-ui/src/features/common/custom-fields/hooks.ts index fd6874f8844..3365148905a 100644 --- a/clients/admin-ui/src/features/common/custom-fields/hooks.ts +++ b/clients/admin-ui/src/features/common/custom-fields/hooks.ts @@ -3,11 +3,10 @@ import { useCallback, useMemo } from "react"; import { useFeatures } from "~/features/common/features"; import { useAlert } from "~/features/common/hooks"; import { - useDeleteCustomFieldMutation, + useBulkUpdateCustomFieldsMutation, useGetAllAllowListQuery, useGetCustomFieldDefinitionsByResourceTypeQuery, useGetCustomFieldsForResourceQuery, - useUpsertCustomFieldMutation, } from "~/features/plus/plus.slice"; import { CustomFieldWithId, ResourceTypes } from "~/types/api"; @@ -47,20 +46,13 @@ export const useCustomFields = ({ skip: queryFidesKey !== "" && !(isEnabled && queryFidesKey), }); - // The `fixedCacheKey` options will ensure that components referencing the same resource will - // share mutation info. That won't be too useful, though, because `upsertCustomField` can issue - // multiple requests: one for each field associated with the resource. - const [upsertCustomFieldMutationTrigger, upsertCustomFieldMutationResult] = - useUpsertCustomFieldMutation({ fixedCacheKey: resourceFidesKey }); - const [deleteCustomFieldMutationTrigger, deleteCustomFieldMutationResult] = - useDeleteCustomFieldMutation({ fixedCacheKey: resourceFidesKey }); + const [bulkUpdateCustomFieldsMutationTrigger] = + useBulkUpdateCustomFieldsMutation(); const isLoading = allAllowListQuery.isLoading || customFieldDefinitionsQuery.isLoading || - isCustomFieldIsLoading || - upsertCustomFieldMutationResult.isLoading || - deleteCustomFieldMutationResult.isLoading; + isCustomFieldIsLoading; const idToAllowListWithOptions = useMemo( () => @@ -116,13 +108,21 @@ export const useCustomFields = ({ */ const customFieldValues = useMemo(() => { const values: CustomFieldValues = {}; - if (definitionIdToCustomField) { - definitionIdToCustomField.forEach((value, key) => { - values[key] = value.value.toString(); + if (activeCustomFieldDefinition && definitionIdToCustomField) { + activeCustomFieldDefinition.forEach((value) => { + const customField = definitionIdToCustomField.get(value.id || ""); + if (customField) { + if (!!value.allow_list_id && value.field_type === "string[]") { + values[customField.custom_field_definition_id] = customField.value; + } else { + values[customField.custom_field_definition_id] = + customField.value.toString(); + } + } }); } return values; - }, [definitionIdToCustomField]); + }, [activeCustomFieldDefinition, definitionIdToCustomField]); /** * Issue a batch of upsert and delete requests that will sync the form selections to the @@ -134,8 +134,8 @@ export const useCustomFields = ({ return; } - // When creating an resource, the fides key may have initially been blank. But by the time the - // form is submitted it must not be blank (not undefined, not an empty string). + // When creating a resource, the fides key may have initially been blank. + // But by the time the form is submitted it must not be blank (not undefined, not an empty string). const fidesKey = "fides_key" in formValues && formValues.fides_key !== "" ? formValues.fides_key @@ -156,37 +156,38 @@ export const useCustomFields = ({ return; } - try { - // This would be a lot simpler (and more efficient) if the API had an endpoint for updating - // all the metadata associated with a field, including deleting options that weren't passed. - await Promise.allSettled( - sortedCustomFieldDefinitionIds.map((definitionId) => { - const customField = definitionIdToCustomField.get(definitionId); - const value = customFieldValuesFromForm[definitionId]; - - if ( - value === undefined || - value === "" || - (Array.isArray(value) && value.length === 0) - ) { - if (!customField?.id) { - return undefined; - } - const { id } = customField; - - return deleteCustomFieldMutationTrigger({ id }); - } - - const body = { - custom_field_definition_id: definitionId, - resource_id: fidesKey, - id: customField?.id, - value, - }; + const upsertList: Array = []; + const deleteList: Array = []; + + sortedCustomFieldDefinitionIds.forEach((definitionId) => { + const customField = definitionIdToCustomField.get(definitionId); + const value = customFieldValuesFromForm[definitionId]; + + if ( + value === undefined || + value === "" || + (Array.isArray(value) && value.length === 0) + ) { + if (customField?.id) { + deleteList.push(customField.id); + } + } else { + upsertList.push({ + custom_field_definition_id: definitionId, + resource_id: fidesKey, + id: customField?.id, + value, + }); + } + }); - return upsertCustomFieldMutationTrigger(body); - }) - ); + try { + await bulkUpdateCustomFieldsMutationTrigger({ + resource_type: resourceType, + resource_id: fidesKey, + upsert: upsertList, + delete: deleteList, + }); } catch (e) { errorAlert( `One or more custom fields have failed to save, please try again.` @@ -198,11 +199,11 @@ export const useCustomFields = ({ [ isEnabled, definitionIdToCustomField, - deleteCustomFieldMutationTrigger, errorAlert, resourceFidesKey, sortedCustomFieldDefinitionIds, - upsertCustomFieldMutationTrigger, + bulkUpdateCustomFieldsMutationTrigger, + resourceType, ] ); diff --git a/clients/admin-ui/src/features/plus/plus.slice.ts b/clients/admin-ui/src/features/plus/plus.slice.ts index dc76d591117..cfd7c7b4abc 100644 --- a/clients/admin-ui/src/features/plus/plus.slice.ts +++ b/clients/admin-ui/src/features/plus/plus.slice.ts @@ -196,7 +196,14 @@ const plusApi = baseApi.injectEndpoints({ }), invalidatesTags: ["Custom Fields", "Datamap"], }), - + bulkUpdateCustomFields: build.mutation({ + query: (params) => ({ + url: `plus/custom-metadata/custom-field/bulk`, + method: "POST", + body: params, + }), + invalidatesTags: ["Custom Fields", "Datamap"], + }), getAllCustomFieldDefinitions: build.query< CustomFieldDefinitionWithId[], void @@ -321,6 +328,7 @@ export const { useUpdateScanMutation, useUpsertAllowListMutation, useUpsertCustomFieldMutation, + useBulkUpdateCustomFieldsMutation, useGetAllCustomFieldDefinitionsQuery, useGetAllowListQuery, useGetAllDictionaryEntriesQuery, diff --git a/clients/admin-ui/src/features/system/SystemFormTabs.tsx b/clients/admin-ui/src/features/system/SystemFormTabs.tsx index 30a2c45e116..d58820993d5 100644 --- a/clients/admin-ui/src/features/system/SystemFormTabs.tsx +++ b/clients/admin-ui/src/features/system/SystemFormTabs.tsx @@ -15,7 +15,7 @@ import { useSystemOrDatamapRoute } from "~/features/common/hooks/useSystemOrData import { DEFAULT_TOAST_PARAMS } from "~/features/common/toast"; import ConnectionForm from "~/features/datastore-connections/system_portal_config/ConnectionForm"; import PrivacyDeclarationStep from "~/features/system/privacy-declarations/PrivacyDeclarationStep"; -import { System, SystemResponse } from "~/types/api"; +import { SystemResponse } from "~/types/api"; import SystemHistoryTable from "./history/SystemHistoryTable"; import { @@ -109,7 +109,7 @@ const SystemFormTabs = ({ } }, [activeSystem]); - const handleSuccess = (system: System) => { + const handleSuccess = (system: SystemResponse) => { // show a save message if this is the first time the system was saved if (activeSystem === undefined) { setShowSaveMessage(true); diff --git a/clients/admin-ui/src/features/system/SystemInformationForm.tsx b/clients/admin-ui/src/features/system/SystemInformationForm.tsx index 1e317109672..75513451ec8 100644 --- a/clients/admin-ui/src/features/system/SystemInformationForm.tsx +++ b/clients/admin-ui/src/features/system/SystemInformationForm.tsx @@ -51,7 +51,7 @@ import { useUpdateSystemMutation, } from "~/features/system/system.slice"; import SystemFormInputGroup from "~/features/system/SystemFormInputGroup"; -import { ResourceTypes, System, SystemResponse } from "~/types/api"; +import { ResourceTypes, SystemResponse } from "~/types/api"; import { DictSuggestionToggle } from "./dictionary-form/ToggleDictSuggestions"; import { usePrivacyDeclarationData } from "./privacy-declarations/hooks"; @@ -80,7 +80,7 @@ const SystemHeading = ({ system }: { system?: SystemResponse }) => { }; interface Props { - onSuccess: (system: System) => void; + onSuccess: (system: SystemResponse) => void; system?: SystemResponse; withHeader?: boolean; children?: React.ReactNode; @@ -156,7 +156,9 @@ const SystemInformationForm = ({ const systemBody = transformFormValuesToSystem(values); const handleResult = ( - result: { data: {} } | { error: FetchBaseQueryError | SerializedError } + result: + | { data: SystemResponse } + | { error: FetchBaseQueryError | SerializedError } ) => { if (isErrorResult(result)) { const attemptedAction = isEditing ? "editing" : "creating"; @@ -172,7 +174,7 @@ const SystemInformationForm = ({ toast.closeAll(); // Reset state such that isDirty will be checked again before next save formikHelpers.resetForm({ values }); - onSuccess(systemBody); + onSuccess(result.data); dispatch(setSuggestions("hiding")); } }; diff --git a/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx b/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx index a497c42ef37..d8759a09c0d 100644 --- a/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx +++ b/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx @@ -23,7 +23,9 @@ import { SystemHistoryResponse } from "~/types/api"; import { SystemResponse } from "~/types/api/models/SystemResponse"; import { + alignPrivacyDeclarationCustomFields, alignPrivacyDeclarations, + alignSystemCustomFields, assignSystemNames, assignVendorLabels, describeSystemChange, @@ -59,6 +61,9 @@ const SystemHistoryTable = ({ system }: Props) => { history = assignVendorLabels(history, dictionaryOptions); // Look up the system names for the source and destination fides_keys history = assignSystemNames(history, systems); + // Align custom fields + history = alignSystemCustomFields(history); + history = alignPrivacyDeclarationCustomFields(history); setSelectedHistory(history); setModalOpen(true); diff --git a/clients/admin-ui/src/features/system/history/helpers.tsx b/clients/admin-ui/src/features/system/history/helpers.tsx index 1125a1b87a0..f876bfeb15f 100644 --- a/clients/admin-ui/src/features/system/history/helpers.tsx +++ b/clients/admin-ui/src/features/system/history/helpers.tsx @@ -6,7 +6,7 @@ import { PrivacyDeclaration } from "~/types/api/models/PrivacyDeclaration"; import { SystemHistoryResponse } from "~/types/api/models/SystemHistoryResponse"; import { SystemResponse } from "~/types/api/models/SystemResponse"; -// Helper function to format date and time +/** Helper function to format date and time based on the user's locale */ export const formatDateAndTime = (dateString: string) => { const date = new Date(dateString); const userLocale = navigator.language; @@ -30,33 +30,7 @@ export const formatDateAndTime = (dateString: string) => { return { formattedTime, formattedDate }; }; -export function alignArrays( - before: PrivacyDeclaration[], - after: PrivacyDeclaration[] -) { - const allNames = new Set([...before, ...after].map((item) => item.data_use)); - const alignedBefore: PrivacyDeclaration[] = []; - const alignedAfter: PrivacyDeclaration[] = []; - - allNames.forEach((data_use) => { - const firstItem = before.find((item) => item.data_use === data_use) || { - data_use: "", - data_categories: [], - }; - const secondItem = after.find((item) => item.data_use === data_use) || { - data_use: "", - data_categories: [], - }; - alignedBefore.push(firstItem); - alignedAfter.push(secondItem); - }); - - return [alignedBefore, alignedAfter]; -} - -const lookupVendorLabel = (vendor_id: string, options: DictOption[]) => - options.find((option) => option.value === vendor_id)?.label ?? vendor_id; - +/** A collection of mappings between backend identifiers an UI labels */ export const getUiLabel = (key: string): string => { const keyMapping: Record = { privacy_declarations: "data uses", @@ -67,10 +41,11 @@ export const getUiLabel = (key: string): string => { return keyMapping[key] || key; }; -export const describeSystemChange = (history: SystemHistoryResponse) => { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { edited_by, before, after, created_at } = history; - +/** Determines if a field was added, modified, or removed as part of modification */ +const categorizeFieldModifications = ( + before: Record, + after: Record +) => { const uniqueKeys = new Set([...Object.keys(before), ...Object.keys(after)]); const addedFields: string[] = []; @@ -120,6 +95,29 @@ export const describeSystemChange = (history: SystemHistoryResponse) => { } }); + return { addedFields, removedFields, changedFields }; +}; + +/** Creates a description of the given system history entry in the style of a commit message */ +export const describeSystemChange = (history: SystemHistoryResponse) => { + // eslint-disable-next-line @typescript-eslint/naming-convention + const { edited_by, before, after, created_at } = history; + + let addedFields: string[] = []; + let removedFields: string[] = []; + let changedFields: string[] = []; + + // if the history contains custom fields, it won't have the main fields + // so we can just process the custom_fields object + if (before.custom_fields || after.custom_fields) { + ({ addedFields, removedFields, changedFields } = + categorizeFieldModifications(before.custom_fields, after.custom_fields)); + } else { + // process the main fields + ({ addedFields, removedFields, changedFields } = + categorizeFieldModifications(before, after)); + } + const changeDescriptions: Array<[string, JSX.Element]> = []; if (addedFields.length > 0) { @@ -169,6 +167,7 @@ export const describeSystemChange = (history: SystemHistoryResponse) => { ); }; +/** Replaces system keys with system names (if available) */ export const assignSystemNames = ( history: SystemHistoryResponse, systems: SystemResponse[] @@ -201,6 +200,7 @@ export const assignSystemNames = ( return { ...history, before: modifiedBefore, after: modifiedAfter }; }; +/** Replaces vendor IDs with vendor names */ export const assignVendorLabels = ( history: SystemHistoryResponse, dictionaryOptions: DictOption[] @@ -210,6 +210,9 @@ export const assignVendorLabels = ( return history; } + const lookupVendorLabel = (vendor_id: string, options: DictOption[]) => + options.find((option) => option.value === vendor_id)?.label ?? vendor_id; + return { ...history, before: { @@ -223,15 +226,33 @@ export const assignVendorLabels = ( }; }; +/** Modifies the privacy_declaration lists in the before and after objects to match in length */ export const alignPrivacyDeclarations = ( history: SystemHistoryResponse ): SystemHistoryResponse => { - const beforePrivacyDeclarations = history.before.privacy_declarations || []; - const afterPrivacyDeclarations = history.after.privacy_declarations || []; - const [alignedBefore, alignedAfter] = alignArrays( - beforePrivacyDeclarations, - afterPrivacyDeclarations - ); + const before = history.before.privacy_declarations || []; + const after = history.after.privacy_declarations || []; + + const allNames = new Set([...before, ...after].map((item) => item.data_use)); + const alignedBefore: PrivacyDeclaration[] = []; + const alignedAfter: PrivacyDeclaration[] = []; + + allNames.forEach((data_use) => { + const firstItem = before.find( + (item: PrivacyDeclaration) => item.data_use === data_use + ) || { + data_use: "", + data_categories: [], + }; + const secondItem = after.find( + (item: PrivacyDeclaration) => item.data_use === data_use + ) || { + data_use: "", + data_categories: [], + }; + alignedBefore.push(firstItem); + alignedAfter.push(secondItem); + }); return { ...history, @@ -245,3 +266,96 @@ export const alignPrivacyDeclarations = ( }, }; }; + +/** Makes sure the before and after custom_field objects have the same keys + * to align the rendering of the fields in the diff modal */ +export const alignSystemCustomFields = ( + history: SystemHistoryResponse +): SystemHistoryResponse => { + const beforeCustomFields = { ...history.before.custom_fields }; + const afterCustomFields = { ...history.after.custom_fields }; + + const allKeys = new Set([ + ...Object.keys(beforeCustomFields), + ...Object.keys(afterCustomFields), + ]); + + allKeys.forEach((key) => { + if (!(key in beforeCustomFields)) { + beforeCustomFields[key] = null; + } + if (!(key in afterCustomFields)) { + afterCustomFields[key] = null; + } + }); + + return { + ...history, + before: { + ...history.before, + custom_fields: beforeCustomFields, + }, + after: { + ...history.after, + custom_fields: afterCustomFields, + }, + }; +}; + +/** Makes sure the custom fields object on privacy declarations have the same keys */ +export const alignPrivacyDeclarationCustomFields = ( + history: SystemHistoryResponse +): SystemHistoryResponse => { + // If privacy_declarations[0].custom_fields isn't defined, return the unmodified history object + if ( + !history.before.privacy_declarations || + !history.before.privacy_declarations[0] || + !history.before.privacy_declarations[0].custom_fields + ) { + return history; + } + + const beforeCustomFields = { + ...history.before.privacy_declarations[0].custom_fields, + }; + const afterCustomFields = + history.after.privacy_declarations && history.after.privacy_declarations[0] + ? { ...history.after.privacy_declarations[0].custom_fields } + : {}; + + const allKeys = new Set([ + ...Object.keys(beforeCustomFields), + ...Object.keys(afterCustomFields), + ]); + + allKeys.forEach((key) => { + if (!(key in beforeCustomFields)) { + beforeCustomFields[key] = null; + } + if (!(key in afterCustomFields)) { + afterCustomFields[key] = null; + } + }); + + return { + ...history, + before: { + ...history.before, + privacy_declarations: [ + { + ...history.before.privacy_declarations[0], + custom_fields: beforeCustomFields, + }, + ], + }, + after: { + ...history.after, + privacy_declarations: [ + { + ...history.after.privacy_declarations[0], + custom_fields: afterCustomFields, + }, + ], + }, + }; +}; diff --git a/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx b/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx index f670cf6f527..929069a0c50 100644 --- a/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx +++ b/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx @@ -3,8 +3,9 @@ import { Form, Formik } from "formik"; import React from "react"; import { useFeatures } from "~/features/common/features/features.slice"; -import { PrivacyDeclaration } from "~/types/api"; +import { PrivacyDeclaration, ResourceTypes } from "~/types/api"; +import SystemCustomFieldGroup from "./fields/SystemCustomFieldGroup"; import SystemDataSwitch from "./fields/SystemDataSwitch"; import SystemDataTags from "./fields/SystemDataTags"; import SystemDataTextField from "./fields/SystemDataTextField"; @@ -158,6 +159,10 @@ const SystemDataForm: React.FC = ({ initialValues }) => { tooltip="Which data security practices are employed to keep the data safe?" /> + {/* Data uses */} {initialValues.privacy_declarations && initialValues.privacy_declarations.map( @@ -171,8 +176,8 @@ const SystemDataForm: React.FC = ({ initialValues }) => { /> = ({ initialValues }) => { tooltip="Which categories of personal data does this system share with third parties?" /> + ) )} diff --git a/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx b/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx index e344529b01f..856a3f26e48 100644 --- a/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx +++ b/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx @@ -29,17 +29,19 @@ const getBadges = (before: Record, after: Record) => { badges.push("Data Flow"); } - if ( + const hasPrivacyDeclarations = (before.privacy_declarations && before.privacy_declarations.length > 0) || - (after.privacy_declarations && after.privacy_declarations.length > 0) - ) { + (after.privacy_declarations && after.privacy_declarations.length > 0); + + if (hasPrivacyDeclarations) { badges.push("Data Uses"); } const hasOtherFields = [...Object.keys(before), ...Object.keys(after)].some( (key) => !specialFields.has(key) ); - if (hasOtherFields) { + + if (!hasPrivacyDeclarations && hasOtherFields) { badges.unshift("System Information"); } diff --git a/clients/admin-ui/src/features/system/history/modal/fields/SystemCustomFieldGroup.tsx b/clients/admin-ui/src/features/system/history/modal/fields/SystemCustomFieldGroup.tsx new file mode 100644 index 00000000000..4c21a454ed8 --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/fields/SystemCustomFieldGroup.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +import { useCustomFields } from "~/features/common/custom-fields/hooks"; +import { ResourceTypes } from "~/types/api"; + +import SystemDataGroup from "../SystemDataGroup"; +import SystemDataTags from "./SystemDataTags"; +import SystemDataTextField from "./SystemDataTextField"; + +interface SystemCustomFieldGroupProps { + customFields?: Record; + resourceType: ResourceTypes; +} + +const SystemCustomFieldGroup: React.FC = ({ + customFields = {}, + resourceType, +}) => { + const { idToCustomFieldDefinition } = useCustomFields({ + resourceType, + }); + + /** Used to determine if a custom field should be rendered as a text field or data tags. + * The presence of an allow_list_id indicates either a single or multi-value select + */ + const isMultivalued = (name: string): boolean => + Array.from(idToCustomFieldDefinition.values()).some( + (value) => value.name === name && !!value.allow_list_id + ); + + const prefix = + resourceType === ResourceTypes.SYSTEM + ? "custom_fields" + : "privacy_declarations[0].custom_fields"; + + // to ensure the order in the diff lists is the same + const sortedFieldNames = Object.keys(customFields).sort(); + + return ( + + {sortedFieldNames.map((fieldName) => + isMultivalued(fieldName) ? ( + + ) : ( + + ) + )} + + ); +}; + +export default SystemCustomFieldGroup; diff --git a/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx index e6f819240bb..2ed9897f39a 100644 --- a/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx +++ b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx @@ -30,7 +30,7 @@ const SystemDataTextField = ({ const afterValue = _.get(selectedHistory?.after, props.name) || ""; // Determine whether to highlight - setShouldHighlight(beforeValue !== afterValue); + setShouldHighlight(!_.isEqual(beforeValue, afterValue)); const longestValue = beforeValue.length > afterValue.length ? beforeValue : afterValue; diff --git a/clients/admin-ui/src/features/system/system.slice.ts b/clients/admin-ui/src/features/system/system.slice.ts index bf82f2468ff..0bfccc78691 100644 --- a/clients/admin-ui/src/features/system/system.slice.ts +++ b/clients/admin-ui/src/features/system/system.slice.ts @@ -48,7 +48,7 @@ const systemApi = baseApi.injectEndpoints({ }), // we accept 'unknown' as well since the user can paste anything in, and we rely // on the backend to do the validation for us - createSystem: build.mutation({ + createSystem: build.mutation({ query: (body) => ({ url: `system/`, method: "POST", diff --git a/tests/ctl/core/test_system_history.py b/tests/ctl/core/test_system_history.py index 9373ca8c07f..52bea44a001 100644 --- a/tests/ctl/core/test_system_history.py +++ b/tests/ctl/core/test_system_history.py @@ -109,4 +109,4 @@ async def test_multiple_changes(self, db, async_session_temp, system: System): ).all() assert len(system_histories) == 3 for system_history in system_histories: - system_history.edited_by == CONFIG.security.root_username + assert system_history.edited_by == CONFIG.security.root_username