import { Temporal } from "@js-temporal/polyfill"; import { isLink, serializeInputElement } from "@maykin-ui/client-common"; import React, { ChangeEventHandler, ComponentProps, FocusEventHandler, MouseEventHandler, useCallback, useEffect, useState, } from "react"; import { TypedField, date2DateString, getFormFieldTypeByFieldType, gettextFirst, isBool, isNull, isNumber, isPrimitive, isString, isUndefined, useIntl, } from "../../../lib"; import { Badge, BadgeProps } from "../../badge"; import { Bool, BoolProps } from "../../boolean"; import { Button, ButtonProps } from "../../button"; import { FormControl, FormControlProps, Option } from "../../form"; import { A, AProps, P, PProps } from "../../typography"; import { TRANSLATIONS } from "./translations"; export type ValueProps = Omit< React.ComponentProps<"a" | "p" | "span">, "onBlur" | "onChange" | "onClick" | "value" > & { /** Value to render. */ value: unknown; /** Whether to use a "decorative" component instead of `

` if applicable. */ decorate?: boolean; /** Is set, renders an `` with `href` set. */ href?: string; aProps?: AProps; boolProps?: Omit; badgeProps?: BadgeProps; pProps?: PProps; onClick?: MouseEventHandler; /** @private indicates that the is used internally. */ nested?: boolean; } & ValueEditableUnion; export type ValueEditableUnion = ( | { /** Whether the value should be editable (requires field). */ editable: true; /** The form field to show when editing. */ field: TypedField; } | { /** Whether the value should be editable (requires field). */ editable?: false; /** The form field to show when editing. */ field?: TypedField; } ) & { formControlProps?: Partial; buttonProps?: Partial; /** Whether the value is currently being edited. */ editing?: boolean; /** An error message to show. */ error?: string; labelEdit?: string; /** Gets called when the input is blurred. */ onBlur?: React.FocusEventHandler; /** Gets called when the value changes. */ onChange?: React.ChangeEventHandler; /** Gets called when the edit button is clicked. */ onEdit?: React.MouseEventHandler; }; /** * Generic wrapper rendering the appropriate component for `value` based on its type. * Type can be: * * - A primitive, rendered using appropriate subcomponent (see props). * - A React.ReactNode: rendered directly. * - Any other complex type is ignored. */ export const Value = (rawProps: ValueProps) => { const { aProps, badgeProps, boolProps, buttonProps, error, formControlProps, pProps, decorate = false, editing, field, editable = field?.editable, href = "", labelEdit, nested = false, value: valueProp, onBlur, // Only supported when rendering ``. onClick, // Only supported when rendering ``. onChange, onEdit, ...props } = rawProps; const _labelEdit = gettextFirst(labelEdit, TRANSLATIONS.LABEL_EDIT, { ...field, label: field?.name || "", }); const [editingState, setEditingState] = useState( Boolean(editable && editing), ); useEffect(() => { setEditingState(Boolean(editable && editing)); }, [editable, editing]); const [valueState, setValueStateState] = useState(); useEffect(() => { setValueStateState(valueProp); }, [valueProp]); /** * Gets called when the value changes. */ const handleChange: ChangeEventHandler = useCallback( (e) => { const serializedValue = serializeInputElement(e.target, { typed: true }); setValueStateState(serializedValue); onChange?.(e); }, [valueProp, onChange], ); /** * Gets called when the value representation is clicked. */ const handleClick: MouseEventHandler = useCallback( (e) => { setEditingState(Boolean(editable && true)); onEdit?.(e); onClick?.(e); }, [editable, editingState, onEdit], ); const handleBlur = useCallback( (e) => { if (typeof editing === "undefined") setEditingState(false); onBlur?.(e); }, [editing, onBlur], ); const intl = useIntl(); if (editable && !editingState) { return ( ); } // Returns when editing if (editable && editingState) { // @ts-expect-error - Fields is set here. const type = getFormFieldTypeByFieldType(field.type); return ( } onChange={handleChange} onBlur={handleBlur} {...formControlProps} /> ); } if (React.isValidElement(valueState)) { return valueState; } if (isNull(valueState) || isUndefined(valueState) || field?.type === "null") { return nested ? ( "-" ) : (

)}> -

); } /** * Renders a Date when the type is date, whilst decorate is true. */ if (decorate && field?.type === "date") { return (

)} {...pProps}> {valueState ? date2DateString(new Date(valueState.toString()), "DDMMYYYY") : "-"}

); } if (decorate && field?.multiple && Array.isArray(valueState)) { return valueState.map((value, index) => { const label = !isPrimitive(value) && "label" in value ? value.label : value; return {label}; }); } /** * Renders a duration using Temporal.Duration the type is duration, whilst decorate is true. */ if (decorate && field?.type === "duration") { const duration = Temporal.Duration.from(valueState); return (

)} {...pProps}> {duration.toLocaleString(intl.locale, { style: "long", })}

); } /** * Renders primitive string or number values as text or links. * * Conditions: * - If the value is a string, or a number without `decorate` mode, or the field type is "string" / "number". * - Converts the value to a string and renders it as: * * Notes: * - Returns "-" if the string is empty. * - When nested, only returns the plain string instead of wrapping it in a paragraph. */ if ( isString(valueState) || (isNumber(valueState) && !decorate) || field?.type === "string" || (field?.type === "number" && !decorate) ) { const string = String(valueState); if (!nested && (href || (isLink(string) && !editingState))) { return (

)}> } > {string || href}

); } return nested ? ( string || "-" ) : (

)} {...pProps}> {string || "-"}

); } /** * Renders boolean values using a `` component. * * Conditions: * - If the value is a boolean or the field type is explicitly "boolean". * - Supports decoration via the `decorate` flag. */ if (isBool(valueState) || field?.type === "boolean") { return ( ); } /** * Renders numeric values as a stylized badge. * * Conditions: * - If the value is a number or the field type is explicitly "number". * - Always wraps the value in a `` component for visual emphasis. * * Notes: * - This block only runs when `decorate` is true, since undecorated numbers * are handled by the earlier string/number condition. */ if (isNumber(valueState) || field?.type === "number") { return ( {valueState as number} ); } console.warn("Refusing to render complex value:", valueState); };