Skip to content

Commit

Permalink
feat(*) added a cell that can render and edit context dynamically
Browse files Browse the repository at this point in the history
  • Loading branch information
Omri-Levy committed Dec 4, 2024
1 parent 9c303f1 commit 944f8cb
Show file tree
Hide file tree
Showing 27 changed files with 2,897 additions and 216 deletions.
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,101 @@
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 { TEditableDetailsV2Props } from './types';
import { useNewEditableDetailsLogic } from './hooks/useEditableDetailsV2Logic/useEditableDetailsV2Logic';
import { EditableDetailsV2Options } from './components/EditableDetailsV2Options';
import { EditableDetailV2 } from './components/EditableDetailV2';

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

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

return (
<div className={'px-3.5'}>
<div className={'my-4 flex justify-between'}>
<h2 className={'text-xl font-bold'}>{title}</h2>
<EditableDetailsV2Options 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={isEditable && props.isEditable}
originalValue={originalValue}
form={form}
field={field}
parse={parse}
/>
<FormMessage />
</FormItem>
)}
/>
);
})}
</div>
<div className={'min-h-12 mt-3 flex justify-end gap-x-3'}>
{isEditable && filteredFields?.some(({ props }) => props.isEditable) && (
<Button type="button" onClick={onCancel}>
Cancel
</Button>
)}
{isEditable && filteredFields?.some(({ props }) => props.isEditable) && (
<Button
type="submit"
className={`aria-disabled:pointer-events-none aria-disabled:opacity-50`}
aria-disabled={isSaveDisabled}
>
Save
</Button>
)}
</div>
</form>
</Form>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
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'];
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,
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}>
{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,34 @@
import {
DropdownMenuContent,
Button,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuItem,
} from '@ballerine/ui';
import { Edit } from 'lucide-react';
import { FunctionComponent } from 'react';

export const EditableDetailsV2Options: FunctionComponent<{
onEnableIsEditable: () => void;
}> = ({ onEnableIsEditable }) => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className={'px-2 py-0 text-xs'}>
Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className={`h-6 w-full`} asChild>
<Button
variant={'ghost'}
className="justify-start text-xs leading-tight"
onClick={onEnableIsEditable}
>
<Edit size={16} className="me-2" /> Edit
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { TextWithNAFallback, ctw } from '@ballerine/ui';
import { FunctionComponent, ComponentProps } from 'react';

export const ReadOnlyDetailV2: FunctionComponent<ComponentProps<typeof TextWithNAFallback>> = ({
children,
className,
...props
}) => {
return (
<TextWithNAFallback
as={'div'}
tabIndex={0}
role={'textbox'}
aria-readonly
{...props}
className={ctw(
'flex h-9 w-full max-w-[30ch] items-center break-all rounded-md border border-transparent p-1 pt-1.5 text-sm',
className,
)}
>
{children}
</TextWithNAFallback>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const __ROOT__ = '__ROOT__';
Loading

0 comments on commit 944f8cb

Please sign in to comment.