Skip to content

Commit

Permalink
feat: key value component
Browse files Browse the repository at this point in the history
  • Loading branch information
tonai committed Feb 13, 2025
1 parent f319bf5 commit 649d3b5
Show file tree
Hide file tree
Showing 7 changed files with 400 additions and 103 deletions.
118 changes: 118 additions & 0 deletions assets/components/Input/KeyValueItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { useDragItem } from "@/hooks/useDrag";
import { fr } from "@codegouvfr/react-dsfr";
import Button from "@codegouvfr/react-dsfr/Button";
import Input from "@codegouvfr/react-dsfr/Input";
import { tss } from "tss-react";
import { useFormContext } from "react-hook-form";
import { InputHTMLAttributes } from "react";
import { get } from "@/utils";
import Select, { SelectProps } from "@codegouvfr/react-dsfr/SelectNext";

export interface KeyValue {
key?: string;
value: string | null;
}

export type KeyValues = KeyValue[];

interface KeyValueListProps {
index: number;
name: string;
onRemove: (index: number) => void;
valueInputProps?: InputHTMLAttributes<HTMLInputElement>;
valueOptions?: SelectProps.Option[];
valueType?: "textInput" | "select";
}

function KeyValueItem(props: KeyValueListProps) {
const { index, name, onRemove, valueInputProps, valueOptions = [], valueType = "textInput" } = props;
const { dragIndex, ref, startDrag } = useDragItem();
const { classes, cx } = useStyles({ active: dragIndex === index });
const {
formState: { errors },
register,
watch,
} = useFormContext();
const useKeys = watch(`${name}.useKeys`);
const keyError = get(errors, `${name}.values.${index}.key.message`);
const valueError = get(errors, `${name}.values.${index}.value.message`);

function handleMove(index: number) {
return (event) => startDrag(event, index);
}

function handleRemove(index: number) {
return () => onRemove(index);
}

return (
<div className={cx(fr.cx("fr-pb-1w"), classes.row, classes.drag)} ref={ref}>
<div>
<Button
title="Ordonner"
priority={"tertiary no outline"}
iconId={"ri-draggable"}
nativeButtonProps={{ onMouseDown: handleMove(index) }}
type="button"
/>
</div>
{useKeys && (
<div className={classes.cell}>
<Input
label="Clé"
nativeInputProps={register(`${name}.values.${index}.key`)}
state={keyError ? "error" : "default"}
stateRelatedMessage={String(keyError ?? "")}
/>
</div>
)}
<div className={classes.cell}>
{valueType === "textInput" && (
<Input
label="Valeur"
nativeInputProps={{
...valueInputProps,
...register(`${name}.values.${index}.value`),
}}
state={valueError ? "error" : "default"}
stateRelatedMessage={String(valueError ?? "")}
/>
)}
{valueType === "select" && (
<Select
label="Valeur"
placeholder="Select an option"
nativeSelectProps={register(`${name}.values.${index}.value`)}
options={valueOptions}
state={valueError ? "error" : "default"}
stateRelatedMessage={String(valueError ?? "")}
/>
)}
</div>
<div>
<Button title="Supprimer" priority={"tertiary no outline"} iconId={"fr-icon-delete-line"} onClick={handleRemove(index)} />
</div>
</div>
);
}

export default KeyValueItem;

const useStyles = tss.withParams<{ active: boolean }>().create(({ active }) => ({
drag: {
position: "relative",
zIndex: active ? 1 : 0,
scale: active ? 1.01 : 1,
},
row: {
display: "flex",
gap: "1.5rem",
alignItems: "center",
},
cell: {
display: "flex",
flexDirection: "column",
flex: 1,
height: "104px",
},
}));
198 changes: 95 additions & 103 deletions assets/components/Input/KeyValueList.tsx
Original file line number Diff line number Diff line change
@@ -1,124 +1,116 @@
import * as yup from "yup";
import { Controller, useFieldArray, useFormContext } from "react-hook-form";

import { fr } from "@codegouvfr/react-dsfr";
import Button from "@codegouvfr/react-dsfr/Button";
import Input from "@codegouvfr/react-dsfr/Input";
import { FC, useEffect, useState } from "react";
import { v4 as uuidv4 } from "uuid";

interface KeyValueListProps {
id?: string;
label: string;
hintText: string;
state?: "default" | "error" | "success";
stateRelatedMessage?: string;
defaultValue?: Record<string, string>;
onChange?: (value: Record<string, string>) => void;
}
import ToggleSwitch from "@codegouvfr/react-dsfr/ToggleSwitch";
import { useDrag } from "@/hooks/useDrag";
import { DragProvider } from "@/contexts/drag";

type HeaderDatas = Record<string, { name: string; value: string }>;
import KeyValueItem, { KeyValues } from "./KeyValueItem";
import { InputHTMLAttributes } from "react";
import { SelectProps } from "@codegouvfr/react-dsfr/SelectNext";
import { get } from "@/utils";

const KeyValueList: FC<KeyValueListProps> = (props: KeyValueListProps) => {
const { label, hintText, state, stateRelatedMessage, defaultValue = {}, onChange } = props;
export interface KeyValuesForm {
values: KeyValues;
useKeys: boolean;
}

const [datas, setDatas] = useState<HeaderDatas>(() => {
const d: HeaderDatas = {};
Object.keys(defaultValue).forEach((keyname) => {
const uuid = uuidv4();
d[uuid] = { name: keyname, value: defaultValue[keyname] };
});
return d;
});
function checkDuplicates({ useKeys, values }: KeyValuesForm) {
const attribute = useKeys ? "key" : "value";
const items = values.map((item) => item[attribute]);
return new Set(items).size === items.length;
}

useEffect(() => {
const result = {};
Object.keys(datas).forEach((uuid) => {
if (datas[uuid].name && datas[uuid].value) {
result[datas[uuid].name] = datas[uuid].value;
}
export function getKeyValueSchema(testConfig?: yup.TestConfig<string | null> | yup.TestConfig<string | null>[]) {
let valueSchema = yup.string().nullable().defined().strict(true);
if (testConfig) {
const tests: yup.TestConfig<string | null>[] = testConfig instanceof Array ? testConfig : [testConfig];
tests.forEach((test) => {
valueSchema = valueSchema.test(test);
});
onChange?.(result);
}, [datas, onChange]);

// Ajout d'une ligne
const handleAdd = () => {
const uuid = uuidv4();
const d = { ...datas };
d[uuid] = { name: "", value: "" };
setDatas(d);
};
}
return yup
.object({
useKeys: yup.boolean().required(),
values: yup
.array()
.of(
yup.object({
key: yup.string(),
value: valueSchema,
})
)
.required(),
})
.test("hasDuplicates", "remove or fix duplicates keys / values", checkDuplicates);
}

// Suppression d'une ligne
const handleRemove = (key: string) => {
const d = { ...datas };
delete d[key];
setDatas(d);
};
interface KeyValueListProps {
label: string;
hintText?: string;
name: string;
valueInputProps?: InputHTMLAttributes<HTMLInputElement>;
valueOptions?: SelectProps.Option[];
valueType?: "textInput" | "select";
}

const handleChangeName = (key: string, name: string) => {
const d = { ...datas };
d[key].name = name;
setDatas(d);
};
function KeyValueList(props: KeyValueListProps) {
const { label, hintText, name, valueInputProps, valueOptions, valueType } = props;
const {
control,
formState: { errors },
} = useFormContext();
const { fields, append, remove, move } = useFieldArray({
name: `${name}.values`,
});
const keyValueError = get(errors, `${name}.root.message`);
const hasError = Boolean(keyValueError);
const drag = useDrag(move);

const handleChangeValue = (key: string, value: string) => {
const d = { ...datas };
d[key].value = value;
setDatas(d);
};
function handleAdd() {
append({ key: "", value: "" });
}

return (
<div>
<div className={fr.cx("fr-input-group", "fr-mb-1v")}>
<label className={fr.cx("fr-label")}>{label}</label>
<span className={fr.cx("fr-hint-text")}>{hintText}</span>
<div className={fr.cx("fr-input-group", { "fr-input-group--error": hasError })}>
<label className={fr.cx("fr-label")}>
{label}
{hintText && <span className={fr.cx("fr-hint-text")}>{hintText}</span>}
</label>
<div className={fr.cx("fr-mt-1w", "fr-mb-1w")}>
<Controller
control={control}
name={`${name}.useKeys`}
render={({ field: { value, onChange } }) => (
<ToggleSwitch checked={value} label="Définir les clés" inputTitle="Définir les clés" onChange={onChange} showCheckedHint={false} />
)}
/>
</div>
<DragProvider value={drag}>
{fields.map((field, i) => {
return (
<KeyValueItem
key={field.id}
index={i}
name={name}
onRemove={remove}
valueInputProps={valueInputProps}
valueOptions={valueOptions}
valueType={valueType}
/>
);
})}
</DragProvider>
<div>
<Button className={fr.cx("fr-mr-1v")} iconId={"fr-icon-add-circle-line"} priority="tertiary" onClick={handleAdd}>
<Button className={fr.cx("fr-mr-1v")} iconId={"fr-icon-add-circle-line"} priority="tertiary" onClick={handleAdd} type="button">
Ajouter
</Button>
</div>
{Object.keys(datas).map((key) => {
return (
<div key={key} className={fr.cx("fr-grid-row", "fr-grid-row--gutters", "fr-grid-row--middle")}>
<div className={fr.cx("fr-col-3")}>
<Input
label={null}
nativeInputProps={{
defaultValue: datas[key].name,
onChange: (e) => handleChangeName(key, e.currentTarget.value),
}}
/>
</div>
<div className={fr.cx("fr-col-3")}>
<Input
label={null}
nativeInputProps={{
defaultValue: datas[key].value,
onChange: (e) => handleChangeValue(key, e.currentTarget.value),
}}
/>
</div>
<Button title={""} priority={"tertiary no outline"} iconId={"fr-icon-delete-line"} onClick={() => handleRemove(key)} />
</div>
);
})}
{state !== "default" && (
<p
className={fr.cx(
(() => {
switch (state) {
case "error":
return "fr-error-text";
case "success":
return "fr-valid-text";
}
})()
)}
>
{stateRelatedMessage}
</p>
)}
{hasError && <p className={fr.cx("fr-error-text")}>{String(keyValueError)}</p>}
</div>
);
};
}

export default KeyValueList;
21 changes: 21 additions & 0 deletions assets/contexts/drag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createContext, PropsWithChildren, useContext } from "react";

export interface IDragContext {
dragIndex: number;
register: (ref: HTMLElement) => void;
startDrag: (event: MouseEvent, index: number) => void;
}

const dragContext = createContext<IDragContext | null>(null);

export function DragProvider({ children, value }: PropsWithChildren<{ value: IDragContext }>) {
return <dragContext.Provider value={value}>{children}</dragContext.Provider>;
}

export function useDragContext() {
const context = useContext(dragContext);
if (!context) {
throw new Error("useDragContext must be used within a DragProvider");
}
return context;
}
Loading

0 comments on commit 649d3b5

Please sign in to comment.