Skip to content

Commit

Permalink
Editable entity data (#2875)
Browse files Browse the repository at this point in the history
* feat(*) added a cell that can render and edit context dynamically

* refactor(*) updated editable details v2 api, added disabled states, added sorting

* chore(*) updated packages

* feat(*): ran format
  • Loading branch information
Omri-Levy authored Dec 9, 2024
1 parent 1f9a3b1 commit fe55bfa
Show file tree
Hide file tree
Showing 49 changed files with 1,300 additions and 167 deletions.
9 changes: 9 additions & 0 deletions apps/backoffice-v2/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# @ballerine/backoffice-v2

## 0.7.79

### Patch Changes

- Updated dependencies
- @ballerine/common@0.9.57
- @ballerine/workflow-browser-sdk@0.6.75
- @ballerine/workflow-node-sdk@0.6.75

## 0.7.78

### Patch Changes
Expand Down
10 changes: 6 additions & 4 deletions apps/backoffice-v2/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ballerine/backoffice-v2",
"version": "0.7.78",
"version": "0.7.79",
"description": "Ballerine - Backoffice",
"homepage": "https://github.com/ballerine-io/ballerine",
"type": "module",
Expand Down Expand Up @@ -52,11 +52,11 @@
},
"dependencies": {
"@ballerine/blocks": "0.2.28",
"@ballerine/common": "0.9.56",
"@ballerine/common": "0.9.57",
"@ballerine/react-pdf-toolkit": "^1.2.48",
"@ballerine/ui": "^0.5.48",
"@ballerine/workflow-browser-sdk": "0.6.74",
"@ballerine/workflow-node-sdk": "0.6.74",
"@ballerine/workflow-browser-sdk": "0.6.75",
"@ballerine/workflow-node-sdk": "0.6.75",
"@botpress/webchat": "^2.1.10",
"@botpress/webchat-generator": "^0.2.9",
"@fontsource/inter": "^4.5.15",
Expand Down Expand Up @@ -117,6 +117,7 @@
"i18next-http-backend": "^2.1.1",
"leaflet": "^1.9.4",
"libphonenumber-js": "^1.10.49",
"lodash-es": "^4.17.21",
"lowlight": "^3.1.0",
"lucide-react": "0.445.0",
"match-sorter": "^6.3.1",
Expand Down Expand Up @@ -167,6 +168,7 @@
"@types/d3-hierarchy": "^3.1.7",
"@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.3",
"@types/lodash-es": "^4.17.12",
"@types/node": "^18.11.13",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.14",
Expand Down
4 changes: 4 additions & 0 deletions apps/backoffice-v2/public/locales/en/toast.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,5 +100,9 @@
"note_created": {
"success": "Note added successfully.",
"error": "Error occurred while adding note."
},
"update_details": {
"success": "Details updated successfully.",
"error": "Error occurred while updating details."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Button, TextWithNAFallback } from '@ballerine/ui';

import { FormField } from '../Form/Form.Field';
import { titleCase } from 'string-ts';
import { Form } from '../Form/Form';
import { FunctionComponent } from 'react';
import { FormItem } from '../Form/Form.Item';
import { FormLabel } from '../Form/Form.Label';
import { FormMessage } from '../Form/Form.Message';
import { useNewEditableDetailsLogic } from './hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic';
import { EditableDetailsV2Options } from './components/EditableDetailsV2Options';
import { EditableDetailV2 } from './components/EditableDetailV2';
import { IEditableDetailsV2Props } from './types';

export const EditableDetailsV2: FunctionComponent<IEditableDetailsV2Props> = ({
title,
fields,
onSubmit,
onEnableIsEditable,
onCancel,
config,
}) => {
if (config.blacklist && config.whitelist) {
throw new Error('Cannot provide both blacklist and whitelist');
}

const { form, handleSubmit, filteredFields } = useNewEditableDetailsLogic({
fields,
onSubmit,
config,
});

return (
<div className={'px-3.5'}>
<div className={'my-4 flex justify-between'}>
<h2 className={'text-xl font-bold'}>{title}</h2>
<EditableDetailsV2Options
actions={{
options: {
disabled: config.actions.options.disabled,
},
enableEditing: {
disabled: config.actions.enableEditing.disabled,
},
}}
onEnableIsEditable={onEnableIsEditable}
/>
</div>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className={'grid grid-cols-3 gap-x-4 gap-y-6'}>
<legend className={'sr-only'}>{title}</legend>
{filteredFields.map(({ title, path, props }) => {
const originalValue = form.watch(path);

return (
<FormField
key={path}
control={form.control}
name={path}
render={({ field }) => (
<FormItem>
<TextWithNAFallback as={FormLabel} className={`block`}>
{titleCase(title ?? '')}
</TextWithNAFallback>
<EditableDetailV2
type={props.type}
format={props.format}
minimum={props.minimum}
maximum={props.maximum}
pattern={props.pattern}
options={props.options}
isEditable={!config.actions.editing.disabled && props.isEditable}
valueAlias={props.valueAlias}
originalValue={originalValue}
form={form}
field={field}
parse={config.parse}
/>
<FormMessage />
</FormItem>
)}
/>
);
})}
</div>
<div className={'min-h-12 mt-3 flex justify-end gap-x-3'}>
{!config.actions.editing.disabled &&
filteredFields?.some(({ props }) => props.isEditable) && (
<Button
type="button"
className={`aria-disabled:pointer-events-none aria-disabled:opacity-50`}
aria-disabled={config.actions.cancel.disabled}
onClick={onCancel}
>
Cancel
</Button>
)}
{!config.actions.editing.disabled &&
filteredFields?.some(({ props }) => props.isEditable) && (
<Button
type="submit"
className={`aria-disabled:pointer-events-none aria-disabled:opacity-50`}
aria-disabled={config.actions.save.disabled}
>
Save
</Button>
)}
</div>
</form>
</Form>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { FunctionComponent, ComponentProps, useCallback, ChangeEvent } from 'react';
import { FieldValues, UseFormReturn } from 'react-hook-form';
import { ExtendedJson } from '@/common/types';
import { isValidDatetime } from '@/common/utils/is-valid-datetime';
import { FileJson2 } from 'lucide-react';
import { JsonDialog, ctw, BallerineLink, checkIsDate } from '@ballerine/ui';
import { isObject, isNullish, checkIsIsoDate, checkIsUrl } from '@ballerine/common';
import { Input } from '@ballerine/ui';
import { Select } from '../../../atoms/Select/Select';
import { SelectTrigger } from '../../../atoms/Select/Select.Trigger';
import { SelectValue } from '../../../atoms/Select/Select.Value';
import { SelectContent } from '../../../atoms/Select/Select.Content';
import { SelectItem } from '../../../atoms/Select/Select.Item';
import { keyFactory } from '@/common/utils/key-factory/key-factory';
import { Checkbox_ } from '../../../atoms/Checkbox_/Checkbox_';
import dayjs from 'dayjs';
import { ReadOnlyDetailV2 } from './ReadOnlyDetailV2';
import { getDisplayValue } from '../utils/get-display-value';
import { FormField } from '../../Form/Form.Field';
import { FormControl } from '../../Form/Form.Control';
import { getInputType } from '../utils/get-input-type';

export const EditableDetailV2: FunctionComponent<{
isEditable: boolean;
className?: string;
options?: Array<{
label: string;
value: string;
}>;
form: UseFormReturn<FieldValues>;
field: Parameters<ComponentProps<typeof FormField>['render']>[0]['field'];
valueAlias?: string;
originalValue: ExtendedJson;
type: string | undefined;
format: string | undefined;
minimum?: number;
maximum?: number;
pattern?: string;
parse?: {
date?: boolean;
isoDate?: boolean;
datetime?: boolean;
boolean?: boolean;
url?: boolean;
nullish?: boolean;
};
}> = ({
isEditable,
className,
options,
originalValue,
form,
field,
valueAlias,
type,
format,
minimum,
maximum,
pattern,
parse,
}) => {
const displayValue = getDisplayValue({ value: field.value, originalValue, isEditable });
const onInputChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value === 'N/A' ? '' : event.target.value;

form.setValue(field.name, value);
},
[field.name, form],
);

if (Array.isArray(field.value) || isObject(field.value)) {
return (
<div className={ctw(`flex items-end justify-start`, className)}>
<JsonDialog
buttonProps={{
variant: 'link',
className: 'p-0 text-blue-500',
}}
rightIcon={<FileJson2 size={`16`} />}
dialogButtonText={`View Information`}
json={JSON.stringify(field.value)}
/>
</div>
);
}

if (isEditable && options) {
return (
<Select disabled={!isEditable} onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger className="h-9 w-full border-input p-1 shadow-sm">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{options?.map(({ label, value }, index) => {
return (
<SelectItem key={keyFactory(label, index?.toString(), `select-item`)} value={value}>
{label}
</SelectItem>
);
})}
</SelectContent>
</Select>
);
}

if (parse?.boolean && (typeof field.value === 'boolean' || type === 'boolean')) {
return (
<FormControl>
<Checkbox_
disabled={!isEditable}
checked={field.value}
onCheckedChange={field.onChange}
className={ctw('border-[#E5E7EB]', className)}
/>
</FormControl>
);
}

if (isEditable) {
const inputType = getInputType({ format, type, value: originalValue });

return (
<FormControl>
<Input
{...field}
{...(typeof minimum === 'number' && { min: minimum })}
{...(typeof maximum === 'number' && { max: maximum })}
{...(pattern && { pattern })}
{...(inputType === 'datetime-local' && { step: '1' })}
type={inputType}
value={displayValue}
onChange={onInputChange}
autoComplete={'off'}
className={ctw(`p-1`, {
'text-slate-400': isNullish(field.value) || field.value === '',
})}
/>
</FormControl>
);
}

if (typeof field.value === 'boolean' || type === 'boolean') {
return <ReadOnlyDetailV2 className={className}>{`${field.value}`}</ReadOnlyDetailV2>;
}

if (parse?.url && checkIsUrl(field.value)) {
return (
<BallerineLink href={field.value} className={className}>
{valueAlias ?? field.value}
</BallerineLink>
);
}

if (parse?.datetime && (isValidDatetime(field.value) || type === 'date-time')) {
const value = field.value.endsWith(':00') ? field.value : `${field.value}:00`;

return (
<ReadOnlyDetailV2 className={className}>
{dayjs(value).utc().format('DD/MM/YYYY HH:mm')}
</ReadOnlyDetailV2>
);
}

if (
(parse?.date && checkIsDate(field.value, { isStrict: false })) ||
(parse?.isoDate && checkIsIsoDate(field.value)) ||
(type === 'date' && (parse?.date || parse?.isoDate))
) {
return (
<ReadOnlyDetailV2 className={className}>
{dayjs(field.value).format('DD/MM/YYYY')}
</ReadOnlyDetailV2>
);
}

if (parse?.nullish && isNullish(field.value)) {
return <ReadOnlyDetailV2 className={className}>{field.value}</ReadOnlyDetailV2>;
}

if (isNullish(field.value)) {
return <ReadOnlyDetailV2 className={className}>{`${field.value}`}</ReadOnlyDetailV2>;
}

return <ReadOnlyDetailV2 className={className}>{field.value}</ReadOnlyDetailV2>;
};
Loading

0 comments on commit fe55bfa

Please sign in to comment.