Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editable entity data #2875

Merged
merged 6 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backoffice-v2/package.json
Original file line number Diff line number Diff line change
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>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
DropdownMenuContent,
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
} from '@ballerine/ui';
import { Edit } from 'lucide-react';
import { FunctionComponent } from 'react';

export const EditableDetailsV2Options: FunctionComponent<{
actions: {
options: {
disabled: boolean;
};
enableEditing: {
disabled: boolean;
};
};
onEnableIsEditable: () => void;
}> = ({ actions, onEnableIsEditable }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className={'px-2 py-0 text-xs aria-disabled:pointer-events-none aria-disabled:opacity-50'}
aria-disabled={actions.options.disabled}
>
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className={`h-6 w-full`} asChild>
<Button
variant={'ghost'}
className="justify-start text-xs leading-tight aria-disabled:pointer-events-none aria-disabled:opacity-50"
aria-disabled={actions.enableEditing.disabled}
onClick={onEnableIsEditable}
>
<Edit size={16} className="me-2" /> Edit
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
Loading
Loading