diff options
author | crupest <crupest@outlook.com> | 2023-07-24 21:48:48 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2023-07-24 21:48:48 +0800 |
commit | 4f8d933994c576dc180fae23a3dca477d2354939 (patch) | |
tree | 654763f143b3508af2d2c263ace6469268107899 /FrontEnd/src/views/common/input | |
parent | ae48ac24c5533653f1e1f1e6d2a33e0238222297 (diff) | |
download | timeline-4f8d933994c576dc180fae23a3dca477d2354939.tar.gz timeline-4f8d933994c576dc180fae23a3dca477d2354939.tar.bz2 timeline-4f8d933994c576dc180fae23a3dca477d2354939.zip |
...
Diffstat (limited to 'FrontEnd/src/views/common/input')
-rw-r--r-- | FrontEnd/src/views/common/input/InputGroup.css (renamed from FrontEnd/src/views/common/input/InputPanel.css) | 0 | ||||
-rw-r--r-- | FrontEnd/src/views/common/input/InputGroup.tsx | 362 | ||||
-rw-r--r-- | FrontEnd/src/views/common/input/InputPanel.tsx | 246 |
3 files changed, 362 insertions, 246 deletions
diff --git a/FrontEnd/src/views/common/input/InputPanel.css b/FrontEnd/src/views/common/input/InputGroup.css index f9d6ac8b..f9d6ac8b 100644 --- a/FrontEnd/src/views/common/input/InputPanel.css +++ b/FrontEnd/src/views/common/input/InputGroup.css diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx new file mode 100644 index 00000000..1b137fd8 --- /dev/null +++ b/FrontEnd/src/views/common/input/InputGroup.tsx @@ -0,0 +1,362 @@ +import { + useState, + useEffect, + ReactNode, + ComponentPropsWithoutRef, + Ref, +} from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; + +import "./InputGroup.css"; + +export interface TextInput { + type: "text"; + label?: Text; + password?: boolean; + textFieldProps?: Omit< + ComponentPropsWithoutRef<"input">, + "type" | "value" | "onChange" + >; + helperText?: Text; +} + +export interface BoolInput { + type: "bool"; + label: Text; + helperText?: Text; +} + +export interface SelectInputOption { + value: string; + label: Text; + icon?: ReactNode; +} + +export interface SelectInput { + type: "select"; + label: Text; + options: SelectInputOption[]; +} + +export interface DateTimeInput { + type: "datetime"; + label?: Text; + helperText?: string; +} + +export type Input = TextInput | BoolInput | SelectInput | DateTimeInput; + +export type InputType = Input["type"]; + +export type InputTypeToInputMap = { + [I in Input as I["type"]]: I; +}; + +export interface InputTypeToValueMap { + text: string; + bool: boolean; + select: string; + datetime: Date; +} + +export type InputValue = InputTypeToValueMap[keyof InputTypeToValueMap]; + +export type MapInputToValue<I extends Input> = InputTypeToValueMap[I["type"]]; + +export type MapInputListToValueList<Tuple extends Input[]> = { + [Index in keyof Tuple]: MapInputToValue<Tuple[Index]>; +}; + +export type MapInputListTo<Tuple extends Input[], T> = { + [Index in keyof Tuple]: T; +}; + +export interface InputTypeToInitialValueMap { + text: string | null | undefined; + bool: boolean | null | undefined; + select: string | null | undefined; + datetime: Date | string | null | undefined; +} + +export type MapInputToInitialValue<I extends Input> = + InputTypeToInitialValueMap[I["type"]]; + +export type InputValueTransformers = { + [I in Input as I["type"]]: ( + input: I, + value: MapInputToInitialValue<I>, + ) => MapInputToValue<I>; +}; + +const initialValueTransformers: InputValueTransformers = { + text: (input, value) => value ?? "", + bool: (input, value) => value ?? false, + select: (input, value) => value ?? input.options[0].value, + datetime: (input, value) => { + if (value == null) return new Date(); + if (typeof value === "string") { + return new Date(value); + } + return value; + }, +}; + +// No use currently +// +// export type ValueValueTransformers = { +// [I in Input as I["type"]]: (input: MapInputToValue<I>) => MapInputToValue<I>; +// }; +// +// const finalValueMapperMap: ValueValueMapper = { +// bool: (value) => value, +// datetime: (value) => new Date(value).toISOString(), +// select: (value) => value, +// text: (value) => value, +// }; + +export type InputErrors = { + index: number; + message: Text; +}[]; + +export type InputList = Input[]; +export type Validator<Inputs extends InputList> = ( + inputs: MapInputListToValueList<Inputs>, +) => InputErrors; +export type Values<Inputs extends InputList> = MapInputListToValueList<Inputs>; +export type Dirties<Inputs extends InputList> = MapInputListTo<Inputs, boolean>; + +export function useInputs<Inputs extends InputList>( + inputs: Inputs, + validator?: Validator<Inputs>, +): { + inputs: Inputs; + validator?: Validator<Inputs>; + dirties: Dirties<Inputs> | undefined; + setDirties: (dirties: Dirties<Inputs>) => void; + dirtyAll: () => void; +} { + const [dirties, setDirties] = useState<Dirties<Inputs>>(); + + return { + inputs, + validator, + values, + dirties, + setDirties, + dirtyAll: () => { + if (dirties != null) { + setDirties(new Array(dirties.length).fill(true) as Dirties<Inputs>); + } + }, + }; +} + +export interface InputGroupProps<Inputs extends InputList> { + inputs: Inputs; + validator?: Validator<Inputs>; + + values?: Values<Inputs>; + onChange: ( + values: Values<Inputs>, + errors: InputErrors, + trigger: number, // May be -1, which means I don't know who trigger this change. + ) => void; + errors: InputErrors; + dirties: Dirties<Inputs>; + onDirty: (dirties: Dirties<Inputs>) => void; + disabled?: boolean; + + color?: ThemeColor; + className?: string; + containerRef?: Ref<HTMLDivElement>; +} + +export default function InputGroup<Inputs extends Input[]>({ + color, + inputs, + validator, + values, + errors, + disabled, + onChange, + dirties, + onDirty, + containerRef, + className, +}: InputGroupProps<Inputs>) { + const c = useC(); + + type Values = MapInputListToValueList<Inputs>; + type Dirties = MapInputListTo<Inputs, boolean>; + + useEffect(() => { + if (values == null) { + const values = inputs.map((input) => { + return initialValueTransformers[input.type](input as never); + }) as Values; + const errors = validator?.(values) ?? []; + onChange(values, errors, -1); + onDirty?.(inputs.map(() => false) as Dirties); + } + }, [values, inputs, validator, onChange, onDirty]); + + if (values == null) { + return null; + } + + const updateValue = (index: number, newValue: InputValue): void => { + const oldValues = values; + const newValues = oldValues.slice() as Values; + newValues[index] = newValue; + const error = validator?.(newValues) ?? []; + onChange(newValues, error, index); + if (dirties != null && onDirty != null && dirties[index] === false) { + const newDirties = dirties.slice() as Dirties; + newDirties[index] = true; + onDirty(newDirties); + } + }; + + return ( + <div + ref={containerRef} + className={classNames( + "cru-input-group", + `cru-${color ?? "primary"}`, + className, + )} + > + {inputs.map((item: Input, index: number) => { + const value = values[index]; + const error = + dirties && + dirties[index] && + errors && + errors.find((e) => e.index === index)?.message; + + if (item.type === "text") { + return ( + <div + key={index} + className={classNames( + "cru-input-container cru-input-text", + item.password && "password", + error && "error", + )} + > + {item.label && ( + <label className="cru-input-label">{c(item.label)}</label> + )} + <input + type={item.password === true ? "password" : "text"} + value={value as string} + onChange={(event) => { + const v = event.target.value; + updateValue(index, v); + }} + disabled={disabled} + /> + {error && <div className="cru-input-error-text">{c(error)}</div>} + {item.helperText && ( + <div className="cru-input-helper-text"> + {c(item.helperText)} + </div> + )} + </div> + ); + } else if (item.type === "bool") { + return ( + <div + key={index} + className={classNames( + "cru-input-container cru-input-bool", + error && "error", + )} + > + <input + type="checkbox" + checked={value as boolean} + onChange={(event) => { + const v = event.currentTarget.checked; + updateValue(index, v); + }} + disabled={disabled} + /> + <label className="cru-input-label-inline">{c(item.label)}</label> + {error && <div className="cru-input-error-text">{c(error)}</div>} + {item.helperText && ( + <div className="cru-operation-dialog-helper-text"> + {c(item.helperText)} + </div> + )} + </div> + ); + } else if (item.type === "select") { + return ( + <div + key={index} + className={classNames( + "cru-input-container cru-input-select", + error && "error", + )} + > + <label className="cru-input-label">{c(item.label)}</label> + <select + value={value as string} + onChange={(event) => { + const e = event.target.value; + updateValue(index, e); + }} + disabled={disabled} + > + {item.options.map((option, i) => { + return ( + <option value={option.value} key={i}> + {option.icon} + {c(option.label)} + </option> + ); + })} + </select> + </div> + ); + } else if (item.type === "datetime") { + return ( + <div + key={index} + className={classNames( + "cru-input-container cru-input-datetime", + error && "error", + )} + > + {item.label && ( + <label className="cru-input-label">{c(item.label)}</label> + )} + <input + type="datetime-local" + value={(value as Date).toLocaleString()} + onChange={(event) => { + const v = event.target.valueAsDate; + if (v == null) { + if (process.env.NODE_ENV === "development") { + console.log( + "Looks like user input date is null. We do nothing. But you might want to check why.", + ); + } + return; + } + updateValue(index, v); + }} + disabled={disabled} + /> + {error && <div className="cru-input-error-text">{c(error)}</div>} + </div> + ); + } + })} + </div> + ); +} diff --git a/FrontEnd/src/views/common/input/InputPanel.tsx b/FrontEnd/src/views/common/input/InputPanel.tsx deleted file mode 100644 index e0db3787..00000000 --- a/FrontEnd/src/views/common/input/InputPanel.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import * as React from "react"; -import classNames from "classnames"; -import { useTranslation } from "react-i18next"; - -import { convertI18nText, I18nText } from "@/common"; - -import "./InputPanel.css"; - -export interface TextInput { - type: "text"; - label?: I18nText; - helper?: I18nText; - password?: boolean; -} - -export interface BoolInput { - type: "bool"; - label: I18nText; - helper?: I18nText; -} - -export interface SelectInputOption { - value: string; - label: I18nText; - icon?: React.ReactElement; -} - -export interface SelectInput { - type: "select"; - label: I18nText; - options: SelectInputOption[]; -} - -export interface DateTimeInput { - type: "datetime"; - label?: I18nText; - helper?: I18nText; -} - -export type Input = TextInput | BoolInput | SelectInput | DateTimeInput; - -interface InputTypeToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string; - datetime: string; -} - -type ValueTypes = InputTypeToValueTypeMap[keyof InputTypeToValueTypeMap]; - -type MapInputTypeToValueType<Type> = Type extends keyof InputTypeToValueTypeMap - ? InputTypeToValueTypeMap[Type] - : never; - -type MapInputToValueType<T> = T extends Input - ? MapInputTypeToValueType<T["type"]> - : T; - -type MapInputListToValueTypeList<Tuple extends readonly Input[]> = { - [Index in keyof Tuple]: MapInputToValueType<Tuple[Index]>; -} & { length: Tuple["length"] }; - -export type InputPanelError = { - [index: number]: I18nText | null | undefined; -}; - -export function hasError(e: InputPanelError | null | undefined): boolean { - if (e == null) return false; - for (const key of Object.keys(e)) { - if (e[key as unknown as number] != null) return true; - } - return false; -} - -export interface InputPanelProps<InputList extends readonly Input[]> { - scheme: InputList; - values: MapInputListToValueTypeList<InputList>; - onChange: ( - values: MapInputListToValueTypeList<InputList>, - index: number, - ) => void; - error?: InputPanelError; - disable?: boolean; -} - -const InputPanel = <InputList extends readonly Input[]>( - props: InputPanelProps<InputList>, -): React.ReactElement => { - const { values, onChange, scheme, error, disable } = props; - - const { t } = useTranslation(); - - const updateValue = (index: number, newValue: ValueTypes): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - onChange( - newValues as unknown as MapInputListToValueTypeList<InputList>, - index, - ); - }; - - return ( - <div> - {scheme.map((item, index) => { - const v = values[index]; - const e: string | null = convertI18nText(error?.[index], t); - - if (item.type === "text") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - {item.label && ( - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type={item.password === true ? "password" : "text"} - value={v as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={disable} - /> - {e && <div className="cru-input-panel-error-text">{e}</div>} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } else if (item.type === "bool") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <input - type="checkbox" - checked={v as boolean} - onChange={(event) => { - const value = event.currentTarget.checked; - updateValue(index, value); - }} - disabled={disable} - /> - <label className="cru-input-panel-inline-label"> - {convertI18nText(item.label, t)} - </label> - {e != null && ( - <div className="cru-input-panel-error-text">{e}</div> - )} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } else if (item.type === "select") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - <select - value={v as string} - onChange={(event) => { - const value = event.target.value; - updateValue(index, value); - }} - disabled={disable} - > - {item.options.map((option, i) => { - return ( - <option value={option.value} key={i}> - {option.icon} - {convertI18nText(option.label, t)} - </option> - ); - })} - </select> - </div> - ); - } else if (item.type === "color") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - <label className="cru-input-panel-inline-label"> - {convertI18nText(item.label, t)} - </label> - <TwitterPicker - color={v as string} - triangle="hide" - onChange={(result) => updateValue(index, result.hex)} - /> - </div> - ); - } else if (item.type === "datetime") { - return ( - <div - key={index} - className={classNames("cru-input-panel-group", e && "error")} - > - {item.label && ( - <label className="cru-input-panel-label"> - {convertI18nText(item.label, t)} - </label> - )} - <input - type="datetime-local" - value={v as string} - onChange={(e) => { - const v = e.target.value; - updateValue(index, v); - }} - disabled={disable} - /> - {e != null && ( - <div className="cru-input-panel-error-text">{e}</div> - )} - {item.helper && ( - <div className="cru-input-panel-helper-text"> - {convertI18nText(item.helper, t)} - </div> - )} - </div> - ); - } - })} - </div> - ); -}; - -export default InputPanel; |