@evoke-platform/ui-components 1.10.0-dev.34 → 1.10.0-dev.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  import { DateTimeFormatter } from '@js-joda/core';
2
2
  import { omit } from 'lodash';
3
- import React, { useEffect, useState } from 'react';
3
+ import React, { useCallback, useEffect, useState } from 'react';
4
4
  import { useFormContext } from '../../../../theme/hooks';
5
5
  import { InvalidDate, LocalDate, nativeJs } from '../../../../util';
6
6
  import { DatePicker, LocalizationProvider, TextField } from '../../../core';
7
+ import { obfuscateValue } from '../../FormV2/components/utils';
7
8
  import InputFieldComponent from '../InputFieldComponent/InputFieldComponent';
8
9
  function asCalendarDate(value) {
9
10
  if (!value) {
@@ -28,12 +29,16 @@ const asMonthDayYearFormat = (date) => {
28
29
  }
29
30
  };
30
31
  const DatePickerSelect = (props) => {
31
- const { id, property, defaultValue, error, errorMessage, readOnly, required, size, onBlur, onChange, additionalProps, } = props;
32
- const [value, setValue] = useState(asCalendarDate(defaultValue));
32
+ const { id, property, defaultValue, error, errorMessage, readOnly, required, size, onBlur, onChange, additionalProps, endAdornment, protection, } = props;
33
33
  const { onAutosave } = useFormContext();
34
+ const processValue = useCallback((val) => {
35
+ const isProtected = protection?.maskChar && val === obfuscateValue(val, { protection });
36
+ return isProtected && typeof val === 'string' ? val : asCalendarDate(val);
37
+ }, [protection]);
38
+ const [value, setValue] = useState(() => processValue(defaultValue));
34
39
  useEffect(() => {
35
- setValue(asCalendarDate(defaultValue));
36
- }, [defaultValue]);
40
+ setValue(processValue(defaultValue));
41
+ }, [defaultValue, processValue]);
37
42
  const handleChange = (date) => {
38
43
  setValue(date);
39
44
  onChange && onChange(property.id, date, property);
@@ -49,12 +54,23 @@ const DatePickerSelect = (props) => {
49
54
  }
50
55
  }
51
56
  };
52
- return readOnly ? (React.createElement(InputFieldComponent, { ...{ ...props, defaultValue: asMonthDayYearFormat(value) } })) : (React.createElement(LocalizationProvider, null,
57
+ return readOnly ? (React.createElement(InputFieldComponent, { ...{
58
+ ...props,
59
+ defaultValue: protection?.maskChar && value === obfuscateValue(value, { protection })
60
+ ? value
61
+ : asMonthDayYearFormat(value),
62
+ endAdornment,
63
+ } })) : (React.createElement(LocalizationProvider, null,
53
64
  React.createElement(DatePicker, { value: value, onChange: handleChange, onAccept: handleAccept, inputFormat: "MM/dd/yyyy", renderInput: (params) => (React.createElement(TextField, { ...params, id: id, error: error, errorMessage: errorMessage, onBlur: onBlur, fullWidth: true, required: required, sx: { background: 'white', borderRadius: '8px' }, size: size ?? 'medium',
54
65
  // merges MUI inputProps with additionalProps.inputProps in a way that still shows the value
55
66
  inputProps: {
56
67
  ...params.inputProps,
57
68
  ...(additionalProps?.inputProps ?? {}),
69
+ }, InputProps: {
70
+ ...params.InputProps,
71
+ endAdornment: (React.createElement(React.Fragment, null,
72
+ params.InputProps?.endAdornment,
73
+ endAdornment)),
58
74
  }, ...omit(additionalProps, ['inputProps']) })) })));
59
75
  };
60
76
  export default DatePickerSelect;
@@ -1,4 +1,4 @@
1
- import { SelectOption } from '@evoke-platform/context';
1
+ import { PropertyProtection as PropertyProtectionType, SelectOption } from '@evoke-platform/context';
2
2
  import React, { FocusEventHandler, ReactNode } from 'react';
3
3
  import { ObjectProperty } from '../../../types';
4
4
  import { AutocompleteOption } from '../../core';
@@ -36,6 +36,8 @@ export type FormFieldProps = {
36
36
  description?: string;
37
37
  tooltip?: string;
38
38
  isCombobox?: boolean;
39
+ endAdornment?: ReactNode;
40
+ protection?: PropertyProtectionType;
39
41
  };
40
42
  declare const FormField: (props: FormFieldProps) => React.JSX.Element;
41
43
  export default FormField;
@@ -1,4 +1,5 @@
1
- import React from 'react';
1
+ import React, { useEffect, useState } from 'react';
2
+ import PropertyProtection from '../FormV2/components/PropertyProtection';
2
3
  import AddressFieldComponent from './AddressFieldComponent/addressFieldComponent';
3
4
  import BooleanSelect from './BooleanSelect/BooleanSelect';
4
5
  import DatePickerSelect from './DatePickerSelect/DatePickerSelect';
@@ -8,8 +9,16 @@ import InputFieldComponent from './InputFieldComponent/InputFieldComponent';
8
9
  import Select from './Select/Select';
9
10
  import TimePickerSelect from './TimePickerSelect/TimePickerSelect';
10
11
  const FormField = (props) => {
11
- const { id, defaultValue, error, onChange, property, readOnly, selectOptions, required, strictlyTrue, size, placeholder, errorMessage, onBlur, mask, max, min, isMultiLineText, rows, inputMaskPlaceholderChar, queryAddresses, isOptionEqualToValue, renderOption, disableCloseOnSelect, getOptionLabel, additionalProps, displayOption, sortBy, label, description, tooltip, isCombobox, } = props;
12
- let control;
12
+ const { id, defaultValue, error, onChange, property, readOnly, selectOptions, required, strictlyTrue, size, placeholder, errorMessage, onBlur, mask, max, min, isMultiLineText, rows, inputMaskPlaceholderChar, queryAddresses, isOptionEqualToValue, renderOption, disableCloseOnSelect, getOptionLabel, additionalProps, displayOption, sortBy, label, description, tooltip, isCombobox, protection, } = props;
13
+ const [currentDisplayValue, setCurrentDisplayValue] = useState(defaultValue);
14
+ const isProtectedProperty = !!protection?.maskChar;
15
+ const [protectionMode, setProtectionMode] = useState(isProtectedProperty ? (!currentDisplayValue ? 'edit' : 'mask') : 'full');
16
+ useEffect(() => {
17
+ if (isProtectedProperty && protectionMode === 'edit') {
18
+ setCurrentDisplayValue(defaultValue);
19
+ }
20
+ }, [defaultValue]);
21
+ const protectionComponent = isProtectedProperty && !!defaultValue ? (React.createElement(PropertyProtection, { parameter: property, protection: protection, mask: mask, canEdit: !readOnly, value: defaultValue, handleChange: (value) => onChange?.(property.id, value, property), setCurrentDisplayValue: setCurrentDisplayValue, mode: protectionMode, setMode: setProtectionMode })) : null;
13
22
  const commonProps = {
14
23
  id: id ?? property.id,
15
24
  property,
@@ -17,8 +26,8 @@ const FormField = (props) => {
17
26
  onBlur,
18
27
  error,
19
28
  errorMessage,
20
- readOnly,
21
- defaultValue,
29
+ readOnly: readOnly || (!!isProtectedProperty && protectionMode !== 'edit'),
30
+ defaultValue: isProtectedProperty ? currentDisplayValue : defaultValue,
22
31
  selectOptions,
23
32
  required,
24
33
  strictlyTrue,
@@ -37,7 +46,10 @@ const FormField = (props) => {
37
46
  description,
38
47
  tooltip,
39
48
  isCombobox,
49
+ endAdornment: protectionComponent,
50
+ protection,
40
51
  };
52
+ let control;
41
53
  if (queryAddresses) {
42
54
  control = (React.createElement(AddressFieldComponent, { ...commonProps, mask: mask, inputMaskPlaceholderChar: inputMaskPlaceholderChar, isMultiLineText: isMultiLineText, rows: rows, queryAddresses: queryAddresses }));
43
55
  return control;
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react';
3
3
  import InputMask from 'react-input-mask';
4
4
  import NumberFormat from 'react-number-format';
5
5
  import { Autocomplete, TextField } from '../../../core';
6
+ import { obfuscateValue } from '../../FormV2/components/utils';
6
7
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
8
  export const NumericFormat = (props) => {
8
9
  const { inputRef, onChange, defaultValue, ...other } = props;
@@ -15,7 +16,7 @@ export const NumericFormat = (props) => {
15
16
  }, isNumericString: true, fixedDecimalScale: true, allowNegative: true }));
16
17
  };
17
18
  const InputFieldComponent = (props) => {
18
- const { id, property, defaultValue, error, errorMessage, onBlur, readOnly, required, size, placeholder, mask, min, max, isMultiLineText, rows, inputMaskPlaceholderChar, additionalProps, } = props;
19
+ const { id, property, defaultValue, error, errorMessage, onBlur, readOnly, required, size, placeholder, mask, min, max, isMultiLineText, rows, inputMaskPlaceholderChar, additionalProps, endAdornment, protection, } = props;
19
20
  const [value, setValue] = useState(defaultValue ?? '');
20
21
  const [inputValue, setInputValue] = useState('');
21
22
  useEffect(() => {
@@ -43,6 +44,7 @@ const InputFieldComponent = (props) => {
43
44
  : property.type === 'integer'
44
45
  ? { inputProps: { min, max, ...(additionalProps?.inputProps ?? {}) } }
45
46
  : null;
47
+ const isValueProtected = protection?.maskChar && defaultValue === obfuscateValue(defaultValue, { protection, mask });
46
48
  return property.enum && !readOnly ? (React.createElement(Autocomplete, { id: id,
47
49
  // note: this is different between widgets and builder
48
50
  // builder had select options being {label, value}
@@ -53,7 +55,7 @@ const InputFieldComponent = (props) => {
53
55
  ? [...property.enum, defaultValue]
54
56
  : property.enum, onChange: handleSelectChange, renderInput: (params) => (React.createElement(TextField, { ...params, value: value, error: error, errorMessage: errorMessage, fullWidth: true, onBlur: onBlur, size: size ?? 'medium', placeholder: placeholder })), disableClearable: true, value: value, isOptionEqualToValue: (option, value) => {
55
57
  return option.value === value;
56
- }, error: error, required: required, inputValue: inputValue ?? '', onInputChange: handleInputValueChange, ...(additionalProps ?? {}) })) : !mask ? (React.createElement(TextField, { id: id, sx: {
58
+ }, error: error, required: required, inputValue: inputValue ?? '', onInputChange: handleInputValueChange, ...(additionalProps ?? {}) })) : !mask || isValueProtected ? (React.createElement(TextField, { id: id, sx: {
57
59
  background: 'white',
58
60
  borderRadius: '8px',
59
61
  ...(readOnly && {
@@ -65,7 +67,7 @@ const InputFieldComponent = (props) => {
65
67
  backgroundColor: '#f4f6f8',
66
68
  },
67
69
  }),
68
- }, error: error, errorMessage: errorMessage, value: value, onChange: !readOnly ? handleChange : undefined, InputProps: { ...InputProps, readOnly: readOnly }, required: required, fullWidth: true, onBlur: onBlur, placeholder: readOnly ? undefined : placeholder, size: size ?? 'medium', type: property.type === 'integer' ? 'number' : 'text', multiline: property.type === 'string' && !readOnly && isMultiLineText, rows: isMultiLineText ? (rows ? rows : 3) : undefined, ...(additionalProps ?? {}) })) : (React.createElement(InputMask, { mask: mask, maskChar: inputMaskPlaceholderChar ?? '_', value: value, onChange: !readOnly ? handleChange : undefined, onBlur: onBlur, alwaysShowMask: true }, (() => (React.createElement(TextField, { id: id, sx: readOnly
70
+ }, error: error, errorMessage: errorMessage, value: value, onChange: !readOnly ? handleChange : undefined, InputProps: { ...InputProps, endAdornment, readOnly: readOnly }, required: required, fullWidth: true, onBlur: onBlur, placeholder: readOnly ? undefined : placeholder, size: size ?? 'medium', type: property.type === 'integer' ? 'number' : 'text', multiline: property.type === 'string' && !readOnly && isMultiLineText, rows: isMultiLineText ? (rows ? rows : 3) : undefined, ...(additionalProps ?? {}) })) : (React.createElement(InputMask, { mask: mask, maskChar: inputMaskPlaceholderChar ?? '_', value: value, onChange: !readOnly ? handleChange : undefined, onBlur: onBlur, alwaysShowMask: true }, (() => (React.createElement(TextField, { id: id, sx: readOnly
69
71
  ? {
70
72
  '& .MuiOutlinedInput-notchedOutline': {
71
73
  border: 'none',
@@ -75,7 +77,7 @@ const InputFieldComponent = (props) => {
75
77
  backgroundColor: '#f4f6f8',
76
78
  },
77
79
  }
78
- : undefined, required: required, error: error, errorMessage: errorMessage, InputProps: { ...InputProps, readOnly: readOnly }, fullWidth: true, size: size ?? 'medium', type: property.type === 'integer' ? 'number' : 'text', multiline: property.type === 'string' && !readOnly && isMultiLineText, rows: isMultiLineText ? (rows ? rows : 3) : undefined, ...(additionalProps ?? {}) })
80
+ : undefined, required: required, error: error, errorMessage: errorMessage, InputProps: { ...InputProps, endAdornment, readOnly: readOnly }, fullWidth: true, size: size ?? 'medium', type: property.type === 'integer' ? 'number' : 'text', multiline: property.type === 'string' && !readOnly && isMultiLineText, rows: isMultiLineText ? (rows ? rows : 3) : undefined, ...(additionalProps ?? {}) })
79
81
  // Casting to `React.ReactNode` is necessary to resolve TypeScript errors
80
82
  // due to compatibility issues with the outdated `react-input-mask` version
81
83
  // and the newer `@types/react` package.
@@ -8,7 +8,7 @@ import { Body } from './components/Body';
8
8
  import { Footer, FooterActions } from './components/Footer';
9
9
  import { FormContext } from './components/FormContext';
10
10
  import Header, { AccordionActions, Title } from './components/Header';
11
- import { assignIdsToSectionsAndRichText, convertDocToParameters, convertPropertiesToParams, entryIsVisible, getEntryId, getNestedParameterIds, isAddressProperty, } from './components/utils';
11
+ import { assignIdsToSectionsAndRichText, convertDocToParameters, convertPropertiesToParams, entryIsVisible, getEntryId, getNestedParameterIds, isAddressProperty, obfuscateValue, } from './components/utils';
12
12
  import { handleValidation } from './components/ValidationFiles/Validation';
13
13
  import ValidationErrors from './components/ValidationFiles/ValidationErrors';
14
14
  const FormRendererInternal = (props) => {
@@ -79,6 +79,15 @@ const FormRendererInternal = (props) => {
79
79
  if (value) {
80
80
  for (const key of Object.keys(currentValues)) {
81
81
  if (!isEqual(currentValues[key], value[key])) {
82
+ // For protected properties, don't validate initial obfuscated value
83
+ const property = object?.properties?.find((prop) => prop.id === key);
84
+ const isProtectedProperty = property?.protection?.maskChar;
85
+ if (isProtectedProperty) {
86
+ if (value[key] === obfuscateValue(value[key], property)) {
87
+ setValue(key, value[key], { shouldValidate: false });
88
+ continue;
89
+ }
90
+ }
82
91
  setValue(key, value[key], { shouldValidate: true });
83
92
  }
84
93
  }
@@ -122,6 +131,22 @@ const FormRendererInternal = (props) => {
122
131
  }
123
132
  });
124
133
  };
134
+ const removeUneditedProtectedValues = () => {
135
+ const protectedProperties = object?.properties?.filter((prop) => prop.protection?.maskChar);
136
+ if (!protectedProperties || protectedProperties.length === 0) {
137
+ return;
138
+ }
139
+ protectedProperties.forEach((property) => {
140
+ const fieldId = property.id;
141
+ const originalValue = instance?.[fieldId];
142
+ const value = getValues(fieldId);
143
+ // When protected value hasn't been edited or viewed, unregister to
144
+ // avoid saving the obfuscated value.
145
+ if (value === originalValue) {
146
+ processFieldUnregister(fieldId);
147
+ }
148
+ });
149
+ };
125
150
  const processFieldUnregister = (fieldId) => {
126
151
  if (isAddressProperty(fieldId)) {
127
152
  // Unregister entire addressObject to clear hidden field errors, then restore existing values since unregistering address.line1 etc is not working
@@ -145,6 +170,7 @@ const FormRendererInternal = (props) => {
145
170
  };
146
171
  async function unregisterHiddenFieldsAndSubmit() {
147
172
  unregisterHiddenFields(entries ?? []);
173
+ removeUneditedProtectedValues();
148
174
  await handleSubmit((data) => onSubmit && onSubmit(action?.type === 'delete' ? {} : data), (errors) => onSubmitError(errors))();
149
175
  }
150
176
  const headerProps = {
@@ -0,0 +1,16 @@
1
+ import { PropertyProtection as PropertyProtectionType } from '@evoke-platform/context';
2
+ import React from 'react';
3
+ import { ObjectProperty } from '../../../../types';
4
+ type PropertyProtectionProps = {
5
+ parameter: ObjectProperty;
6
+ protection?: PropertyProtectionType;
7
+ mask?: string;
8
+ canEdit: boolean;
9
+ value: unknown;
10
+ handleChange?: (value: unknown) => void;
11
+ setCurrentDisplayValue: (value: unknown) => void;
12
+ mode: 'mask' | 'full' | 'edit';
13
+ setMode: (mode: 'mask' | 'full' | 'edit') => void;
14
+ };
15
+ declare const PropertyProtection: React.FC<PropertyProtectionProps>;
16
+ export default PropertyProtection;
@@ -0,0 +1,113 @@
1
+ import { useApiServices } from '@evoke-platform/context';
2
+ import React, { useEffect, useState } from 'react';
3
+ import { CheckRounded, ClearRounded, EditRounded, VisibilityOffRounded, VisibilityRounded } from '../../../../icons';
4
+ import { useFormContext } from '../../../../theme/hooks';
5
+ import { Divider, IconButton, InputAdornment, Snackbar, Tooltip } from '../../../core';
6
+ import { getPrefixedUrl } from '../../Form/utils';
7
+ import { obfuscateValue } from './utils';
8
+ const PropertyProtection = (props) => {
9
+ const { parameter, mask, protection, canEdit, value, mode, setMode, setCurrentDisplayValue, handleChange } = props;
10
+ const apiServices = useApiServices();
11
+ const { object, instance, fetchedOptions, setFetchedOptions } = useFormContext();
12
+ const [hasViewPermission, setHasViewPermission] = useState(fetchedOptions[`${parameter.id}-hasViewPermission`] || undefined);
13
+ const [fullValue, setFullValue] = useState(fetchedOptions[`${parameter.id}-fullValue`]);
14
+ const [isLoading, setIsLoading] = useState(hasViewPermission === undefined);
15
+ const [error, setError] = useState(null);
16
+ useEffect(() => {
17
+ if (hasViewPermission === undefined && instance) {
18
+ apiServices
19
+ .get(getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/checkAccess?action=readProtected&fieldId=${parameter.id}`))
20
+ .then((viewPermissionCheck) => {
21
+ setHasViewPermission(viewPermissionCheck.result);
22
+ setFetchedOptions({ [`${parameter.id}-hasViewPermission`]: viewPermissionCheck.result });
23
+ })
24
+ .catch(() => {
25
+ setError('Failed to check view permission.');
26
+ setHasViewPermission(false);
27
+ })
28
+ .finally(() => setIsLoading(false));
29
+ }
30
+ }, []);
31
+ const hasValueChangedOrViewed = value !== obfuscateValue(value, { protection, mask });
32
+ const canViewFull = hasViewPermission || hasValueChangedOrViewed;
33
+ const fetchFullValue = async () => {
34
+ // if instance doesn't exist, cannot fetch full value
35
+ if (!instance) {
36
+ return undefined;
37
+ }
38
+ try {
39
+ const value = await apiServices.get(getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/properties/${parameter.id}?showProtectedValue=true`));
40
+ setFullValue(value);
41
+ setFetchedOptions({ [`${parameter.id}-fullValue`]: value });
42
+ return value;
43
+ }
44
+ catch (error) {
45
+ setError(`Failed to retrieve full ${parameter.name} value`);
46
+ return '';
47
+ }
48
+ };
49
+ const handleModeChange = async (newMode) => {
50
+ if (newMode === 'edit') {
51
+ if (!canEdit) {
52
+ // sanity check that user can edit
53
+ return;
54
+ }
55
+ if (!hasViewPermission && !hasValueChangedOrViewed) {
56
+ handleChange?.(undefined);
57
+ setCurrentDisplayValue(undefined);
58
+ }
59
+ }
60
+ else if (newMode === 'full') {
61
+ let valueToDisplay;
62
+ if (hasValueChangedOrViewed) {
63
+ // Use the changed value if available
64
+ valueToDisplay = fullValue;
65
+ }
66
+ else {
67
+ valueToDisplay = fullValue ?? (await fetchFullValue());
68
+ }
69
+ setCurrentDisplayValue(valueToDisplay);
70
+ handleChange?.(valueToDisplay);
71
+ }
72
+ else {
73
+ const maskedValue = obfuscateValue(value, { protection, mask });
74
+ setCurrentDisplayValue(maskedValue);
75
+ // save current value as full value, so value can be reverted as needed
76
+ if (hasValueChangedOrViewed) {
77
+ setFullValue(value);
78
+ setFetchedOptions({ [`${parameter.id}-fullValue`]: value });
79
+ }
80
+ }
81
+ setMode(newMode);
82
+ };
83
+ const handleRevert = async () => {
84
+ const revertedValue = fullValue ?? (await fetchFullValue());
85
+ handleChange?.(revertedValue);
86
+ setCurrentDisplayValue(obfuscateValue(revertedValue, { protection, mask }));
87
+ setMode('mask');
88
+ };
89
+ if (isLoading || (!canEdit && !hasViewPermission)) {
90
+ return null;
91
+ }
92
+ return (React.createElement(InputAdornment, { position: "end", sx: { paddingLeft: parameter?.type === 'date' ? '12px' : undefined } },
93
+ mode === 'edit' && (React.createElement(React.Fragment, null,
94
+ React.createElement(IconButton, { onClick: () => handleModeChange('mask'), "aria-label": "Done Editing" },
95
+ React.createElement(Tooltip, { title: "Done Editing" },
96
+ React.createElement(CheckRounded, { fontSize: "small" }))),
97
+ React.createElement(Divider, { orientation: "vertical", sx: { mx: 0.5, height: 24 } }),
98
+ React.createElement(IconButton, { size: "small", onClick: handleRevert, "aria-label": "Revert value" },
99
+ React.createElement(Tooltip, { title: "Revert value" },
100
+ React.createElement(ClearRounded, { fontSize: "small" }))))),
101
+ canEdit && (mode === 'full' || (!hasViewPermission && !hasValueChangedOrViewed && mode === 'mask')) && (React.createElement(IconButton, { onClick: () => handleModeChange('edit'), "aria-label": canViewFull ? 'Edit value' : 'Enter value' },
102
+ React.createElement(Tooltip, { title: "Edit value" },
103
+ React.createElement(EditRounded, { fontSize: "small" })))),
104
+ canEdit && canViewFull && mode === 'full' && (React.createElement(Divider, { orientation: "vertical", sx: { mx: 0.5, height: 24 } })),
105
+ canViewFull && mode === 'mask' && (React.createElement(IconButton, { onClick: () => handleModeChange('full'), "aria-label": "Show full value" },
106
+ React.createElement(Tooltip, { title: "Show full value" },
107
+ React.createElement(VisibilityOffRounded, { fontSize: "small" })))),
108
+ canViewFull && mode === 'full' && (React.createElement(IconButton, { onClick: () => handleModeChange('mask'), "aria-label": "Hide value" },
109
+ React.createElement(Tooltip, { title: "Hide value" },
110
+ React.createElement(VisibilityRounded, { fontSize: "small" })))),
111
+ React.createElement(Snackbar, { open: !!error, handleClose: () => setError(null), error: true, message: error })));
112
+ };
113
+ export default PropertyProtection;
@@ -153,6 +153,7 @@ export function RecursiveEntryRenderer(props) {
153
153
  'aria-describedby': `${entryId}-description`,
154
154
  };
155
155
  }
156
+ const objectProperty = object?.properties?.find((p) => p.id === entryId);
156
157
  return (React.createElement(FieldWrapper
157
158
  /*
158
159
  * Key remounts the field if a value is changed.
@@ -178,7 +179,7 @@ export function RecursiveEntryRenderer(props) {
178
179
  });
179
180
  }, readOnly: entry.type === 'readonlyField', placeholder: display?.placeholder, mask: validation?.mask, isOptionEqualToValue: isOptionEqualToValue, error: !!errors?.[entryId], errorMessage: errors?.[entryId]?.message, isMultiLineText: !!display?.rowCount, rows: display?.rowCount, required: entry.display?.required || false, size: fieldHeight, sortBy: display?.choicesDisplay?.sortBy && display.choicesDisplay.sortBy, displayOption: fieldDefinition.type === 'boolean'
180
181
  ? display?.booleanDisplay
181
- : display?.choicesDisplay?.type && display.choicesDisplay.type, label: display?.label, description: display?.description, tooltip: display?.tooltip, selectOptions: fieldDefinition?.enum, additionalProps: additionalProps, isCombobox: fieldDefinition.nonStrictEnum, strictlyTrue: fieldDefinition.strictlyTrue })));
182
+ : display?.choicesDisplay?.type && display.choicesDisplay.type, label: display?.label, description: display?.description, tooltip: display?.tooltip, selectOptions: fieldDefinition?.enum, additionalProps: additionalProps, isCombobox: fieldDefinition.nonStrictEnum, strictlyTrue: fieldDefinition.strictlyTrue, protection: objectProperty?.protection })));
182
183
  }
183
184
  }
184
185
  else if (entry.type === 'columns') {
@@ -2,6 +2,7 @@
2
2
  import { Action, ApiServices, Column, Columns, EvokeForm, FormEntry, InputField, InputParameter, InputParameterReference, Obj, ObjectInstance, Property, Section, Sections, UserAccount } from '@evoke-platform/context';
3
3
  import { LocalDateTime } from '@js-joda/core';
4
4
  import { FieldErrors, FieldValues } from 'react-hook-form';
5
+ import { ObjectProperty } from '../../../../types';
5
6
  import { AutocompleteOption } from '../../../core';
6
7
  import { Document, DocumentData, SavedDocumentReference } from './types';
7
8
  export declare const scrollIntoViewWithOffset: (el: HTMLElement, offset: number, container?: HTMLElement) => void;
@@ -102,3 +103,4 @@ export declare function assignIdsToSectionsAndRichText(entries: FormEntry[], obj
102
103
  */
103
104
  export declare function plainTextToRtf(plainText: string): string;
104
105
  export declare function getFieldDefinition(entry: FormEntry, object: Obj, parameters?: InputParameter[], isDocument?: boolean): InputParameter | Property | undefined;
106
+ export declare function obfuscateValue(value: unknown, property?: Partial<Property> | Partial<ObjectProperty>): unknown;
@@ -833,3 +833,65 @@ export function getFieldDefinition(entry, object, parameters, isDocument) {
833
833
  }
834
834
  return def;
835
835
  }
836
+ export function obfuscateValue(value, property) {
837
+ const { mask, protection } = property ?? {};
838
+ if (!protection?.maskChar) {
839
+ return value;
840
+ }
841
+ const maskChar = protection.maskChar;
842
+ const stringValue = String(value ?? '');
843
+ const simpleValue = mask && mask.length === stringValue.length ? stripValueOfMask(stringValue, mask) : stringValue;
844
+ let { preserveFirst = 0, preserveLast = 0 } = protection;
845
+ // Preserved character count should never exceed simpleValue length.
846
+ // Also, when using preserve characters, always hide at least 1 character.
847
+ preserveFirst = Math.min(preserveFirst, simpleValue.length - 1);
848
+ preserveLast = Math.min(preserveLast, simpleValue.length - preserveFirst - 1);
849
+ let obfuscatedValue = '';
850
+ if (simpleValue.length) {
851
+ const prefix = simpleValue.slice(0, preserveFirst);
852
+ const suffix = preserveLast ? simpleValue.slice(-preserveLast) : '';
853
+ const maskedSection = maskChar[0].repeat(simpleValue.length - preserveFirst - preserveLast);
854
+ obfuscatedValue = prefix + maskedSection + suffix;
855
+ }
856
+ // Reapply mask to the obfuscated value
857
+ if (mask && mask.length === stringValue.length) {
858
+ obfuscatedValue = applyMaskToObfuscatedValue(obfuscatedValue, mask);
859
+ }
860
+ return obfuscatedValue;
861
+ }
862
+ function stripValueOfMask(value, mask) {
863
+ const valueChars = [...value];
864
+ let simpleValue = '';
865
+ for (const [index, maskChar] of [...mask].entries()) {
866
+ const isPlaceholder = ['9', 'a', '*'].includes(maskChar);
867
+ if (!valueChars[index]) {
868
+ break;
869
+ }
870
+ else if (isPlaceholder) {
871
+ simpleValue += valueChars[index];
872
+ }
873
+ }
874
+ return simpleValue;
875
+ }
876
+ function applyMaskToObfuscatedValue(value, mask) {
877
+ const valueChars = [...value];
878
+ let maskedValue = '';
879
+ let valueIndex = 0;
880
+ for (const maskChar of mask) {
881
+ const isPlaceholder = ['9', 'a', '*'].includes(maskChar);
882
+ if (isPlaceholder) {
883
+ if (valueIndex < valueChars.length) {
884
+ maskedValue += valueChars[valueIndex];
885
+ valueIndex++;
886
+ }
887
+ else {
888
+ // Stop if value is shorter than mask
889
+ break;
890
+ }
891
+ }
892
+ else {
893
+ maskedValue += maskChar;
894
+ }
895
+ }
896
+ return maskedValue;
897
+ }
@@ -15,6 +15,7 @@ import RepeatableField from '../FormV2/components/FormFieldTypes/CollectionFiles
15
15
  import Criteria from '../FormV2/components/FormFieldTypes/Criteria';
16
16
  import { Document } from '../FormV2/components/FormFieldTypes/DocumentFiles/Document';
17
17
  import { Image } from '../FormV2/components/FormFieldTypes/Image';
18
+ import PropertyProtection from '../FormV2/components/PropertyProtection';
18
19
  import { entryIsVisible, fetchCollectionData, filterEmptySections, getDefaultPages, isAddressProperty, } from '../FormV2/components/utils';
19
20
  function ViewOnlyEntryRenderer(props) {
20
21
  const { entry } = props;
@@ -23,6 +24,8 @@ function ViewOnlyEntryRenderer(props) {
23
24
  const apiServices = useApiServices();
24
25
  const { defaultPages, findDefaultPageSlugFor } = useApp();
25
26
  const [navigationSlug, setNavigationSlug] = useState(fetchedOptions[`${entryId}NavigationSlug`]);
27
+ const [currentDisplayValue, setCurrentDisplayValue] = useState(instance?.[entryId]);
28
+ const [protectionMode, setProtectionMode] = useState('mask');
26
29
  const initialMiddleObjectInstances = fetchedOptions[`${entryId}InitialMiddleObjectInstances`];
27
30
  const middleObject = fetchedOptions[`${entryId}MiddleObject`];
28
31
  const display = 'display' in entry ? entry.display : undefined;
@@ -40,6 +43,8 @@ function ViewOnlyEntryRenderer(props) {
40
43
  }
41
44
  return def;
42
45
  }, [entry, object]);
46
+ const isProtectedProperty = fieldDefinition?.protection?.maskChar;
47
+ const protectionComponent = isProtectedProperty ? (React.createElement(PropertyProtection, { parameter: fieldDefinition, protection: fieldDefinition?.protection, mask: fieldDefinition?.mask, value: currentDisplayValue, canEdit: false, setCurrentDisplayValue: setCurrentDisplayValue, mode: protectionMode, setMode: setProtectionMode })) : null;
43
48
  useEffect(() => {
44
49
  if (fieldDefinition?.type === 'collection' && fieldDefinition?.manyToManyPropertyId && instance) {
45
50
  fetchCollectionData(apiServices, fieldDefinition, setFetchedOptions, instance.id, fetchedOptions, initialMiddleObjectInstances);
@@ -72,7 +77,7 @@ function ViewOnlyEntryRenderer(props) {
72
77
  return (React.createElement(AddressFields, { entry: entry, viewOnly: true, entryId: entryId, fieldDefinition: fieldDefinition }));
73
78
  }
74
79
  else {
75
- let fieldValue = instance?.[entryId] || '';
80
+ let fieldValue = currentDisplayValue ?? (instance?.[entryId] || '');
76
81
  switch (fieldDefinition?.type) {
77
82
  case 'object':
78
83
  if (navigationSlug && fieldDefinition?.objectId) {
@@ -93,7 +98,10 @@ function ViewOnlyEntryRenderer(props) {
93
98
  fieldValue = fieldValue && fieldValue.name;
94
99
  break;
95
100
  case 'date':
96
- fieldValue = fieldValue && DateTime.fromISO(fieldValue).toFormat('MM/dd/yyyy');
101
+ fieldValue =
102
+ isProtectedProperty && protectionMode === 'mask'
103
+ ? fieldValue
104
+ : fieldValue && DateTime.fromISO(fieldValue).toFormat('MM/dd/yyyy');
97
105
  break;
98
106
  case 'date-time':
99
107
  fieldValue =
@@ -136,7 +144,9 @@ function ViewOnlyEntryRenderer(props) {
136
144
  React.createElement(Criteria, { value: fieldValue, fieldDefinition: fieldDefinition, canUpdateProperty: false })));
137
145
  }
138
146
  else {
139
- return (React.createElement(FieldWrapper, { inputId: entryId, inputType: fieldDefinition?.type || 'string', label: display?.label || fieldDefinition?.name || 'default', value: fieldValue, required: display?.required || false, prefix: display?.prefix, suffix: display?.suffix, viewOnly: true }, fieldDefinition?.type === 'boolean' && fieldValue ? (React.createElement(CheckCircleRounded, { "aria-label": "Checked", color: "success" })) : fieldDefinition?.type === 'boolean' ? (React.createElement(CancelRounded, { "aria-label": "Unchecked", color: "error" })) : (React.createElement(Typography, { variant: "body1", key: entryId, sx: { height: '24px' } }, fieldValue))));
147
+ return (React.createElement(FieldWrapper, { inputId: entryId, inputType: fieldDefinition?.type || 'string', label: display?.label || fieldDefinition?.name || 'default', value: fieldValue, required: display?.required || false, prefix: display?.prefix, suffix: display?.suffix, viewOnly: true }, fieldDefinition?.type === 'boolean' && fieldValue ? (React.createElement(CheckCircleRounded, { "aria-label": "Checked", color: "success" })) : fieldDefinition?.type === 'boolean' ? (React.createElement(CancelRounded, { "aria-label": "Unchecked", color: "error" })) : (React.createElement(Box, { sx: { display: 'flex', alignItems: 'center', gap: 1 } },
148
+ React.createElement(Typography, { variant: "body1", key: entryId, sx: { height: '24px' } }, fieldValue),
149
+ fieldDefinition?.protection?.maskChar && protectionComponent))));
140
150
  }
141
151
  }
142
152
  }
@@ -1,3 +1,4 @@
1
+ import { PropertyProtection } from '@evoke-platform/context';
1
2
  export type EvokeObject = {
2
3
  id: string;
3
4
  name: string;
@@ -13,6 +14,8 @@ export type ObjectProperty = {
13
14
  searchable?: boolean;
14
15
  objectId?: string;
15
16
  formula?: string;
17
+ mask?: string;
18
+ protection?: PropertyProtection;
16
19
  };
17
20
  export type Obj = {
18
21
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoke-platform/ui-components",
3
- "version": "1.10.0-dev.34",
3
+ "version": "1.10.0-dev.36",
4
4
  "description": "",
5
5
  "main": "dist/published/index.js",
6
6
  "module": "dist/published/index.js",