From 1876782285f9d44e02a7cc7ed914952a69b56504 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 21 Sep 2024 00:23:51 +0200 Subject: [PATCH] Setup framework for new form fields --- web/src/ui/i18n/resources/de.tsx | 5 + web/src/ui/i18n/resources/en.tsx | 5 + web/src/ui/i18n/resources/es.tsx | 7 ++ web/src/ui/i18n/resources/fi.tsx | 5 + web/src/ui/i18n/resources/fr.tsx | 5 + web/src/ui/i18n/resources/it.tsx | 5 + web/src/ui/i18n/resources/nl.tsx | 5 + web/src/ui/i18n/resources/no.tsx | 5 + web/src/ui/i18n/resources/zh-CN.tsx | 5 + web/src/ui/i18n/types.ts | 1 + .../launcher/formFields/FormFieldWrapper.tsx | 37 +++++++ .../formFields/YamlCodeBlock.stories.tsx | 25 +++++ .../formFields/YamlCodeBlockFormField.tsx | 99 +++++++++++++++++++ .../pages/launcher/formFields/useFormField.ts | 70 +++++++++++++ 14 files changed, 279 insertions(+) create mode 100644 web/src/ui/pages/launcher/formFields/FormFieldWrapper.tsx create mode 100644 web/src/ui/pages/launcher/formFields/YamlCodeBlock.stories.tsx create mode 100644 web/src/ui/pages/launcher/formFields/YamlCodeBlockFormField.tsx create mode 100644 web/src/ui/pages/launcher/formFields/useFormField.ts diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index 0e2f6c8c6..bc7f1597a 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -672,6 +672,11 @@ Fühlen Sie sich frei, Ihre Kubernetes-Bereitstellungen zu erkunden und die Kont ) }, + "YamlCodeBlockFormField": { + "not an array": "Ein Array wird erwartet", + "not an object": "Ein Objekt wird erwartet", + "not valid yaml": "Ungültiges YAML/JSON" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Nicht gespeicherte Änderungen", "no longer bookmarked dialog body": diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index fcc6e7216..288d524d8 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -660,6 +660,11 @@ Feel free to explore and take charge of your Kubernetes deployments! "Click on the bookmark icon again to update your saved configuration", "ok": "Ok" }, + "YamlCodeBlockFormField": { + "not an array": "An array is expected", + "not an object": "An object is expected", + "not valid yaml": "Invalid YAML/JSON" + }, "MyService": { "page title": ({ helmReleaseFriendlyName }) => `${helmReleaseFriendlyName} Monitoring` diff --git a/web/src/ui/i18n/resources/es.tsx b/web/src/ui/i18n/resources/es.tsx index 2b424f9c0..09f607af4 100644 --- a/web/src/ui/i18n/resources/es.tsx +++ b/web/src/ui/i18n/resources/es.tsx @@ -8,6 +8,7 @@ import { capitalize } from "tsafe/capitalize"; import { MaybeLink } from "ui/shared/MaybeLink"; export const translations: Translations<"en"> = { + /* spell-checker: disable */ "Account": { "infos": "Información de la cuenta", "git": "Git", @@ -666,6 +667,11 @@ export const translations: Translations<"en"> = { ), "ok": "Ok" }, + "YamlCodeBlockFormField": { + "not an array": "Se espera un arreglo", + "not an object": "Se espera un objeto", + "not valid yaml": "YAML/JSON no válido" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Tus cambios no se guardarán", "no longer bookmarked dialog body": @@ -1007,4 +1013,5 @@ export const translations: Translations<"en"> = { "copied to clipboard": "¡Copiado!", "copy to clipboard": "Copiar al portapapeles" } + /* spell-checker: enable */ }; diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 05f4ceca8..f76abe4a7 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -657,6 +657,11 @@ Tutustu vapaasti ja ota hallintaan Kubernetes-julkaisusi! ), "ok": "Ok" }, + "YamlCodeBlockFormField": { + "not an array": "Taulukkoa odotetaan", + "not an object": "Oliota odotetaan", + "not valid yaml": "Virheellinen YAML/JSON" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Muutokset eivät tallennu", "no longer bookmarked dialog body": diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 817a6ab62..da24a5a08 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -672,6 +672,11 @@ N'hésitez pas à explorer et à prendre en main vos déploiements Kubernetes ! ), "ok": "Ok" }, + "YamlCodeBlockFormField": { + "not an array": "Un tableau est attendu", + "not an object": "Un objet est attendu", + "not valid yaml": "YAML/JSON invalide" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Changements non enregistrés", "no longer bookmarked dialog body": diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index eda55261b..a652f3001 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -666,6 +666,11 @@ Sentiti libero di esplorare e prendere il controllo dei tuoi deployment Kubernet ) }, + "YamlCodeBlockFormField": { + "not an array": "È previsto un array", + "not an object": "È previsto un oggetto", + "not valid yaml": "YAML/JSON non valido" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Modifiche non salvate", "no longer bookmarked dialog body": diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 73672f064..180275dbc 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -668,6 +668,11 @@ Voel je vrij om te verkennen en de controle over je Kubernetes-implementaties te ) }, + "YamlCodeBlockFormField": { + "not an array": "Een array wordt verwacht", + "not an object": "Een object wordt verwacht", + "not valid yaml": "Ongeldige YAML/JSON" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Niet opgeslagen wijzigingen", "no longer bookmarked dialog body": diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index 4dfddb1bc..17cdcd24d 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -667,6 +667,11 @@ Utforsk gjerne og ta kontroll over tjenestene du kjører på Kubernetes! ) }, + "YamlCodeBlockFormField": { + "not an array": "En matrise forventes", + "not an object": "Et objekt forventes", + "not valid yaml": "Ugyldig YAML/JSON" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "Endringene dine vil ikke bli lagret", "no longer bookmarked dialog body": diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index c320e653b..818394918 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -618,6 +618,11 @@ ${ ) }, + "YamlCodeBlockFormField": { + "not an array": "需要是数组", + "not an object": "需要是对象", + "not valid yaml": "无效的 YAML/JSON" + }, "NoLongerBookmarkedDialog": { "no longer bookmarked dialog title": "更改未保存", "no longer bookmarked dialog body": "再次单击书签符号以更新您保存的配置.", diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index d319514ba..8fc6efeb0 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -55,6 +55,7 @@ export type ComponentKey = | import("ui/pages/launcher/LauncherDialogs/AcknowledgeSharingOfConfigConfirmDialog").I18n | import("ui/pages/launcher/LauncherDialogs/AutoLaunchDisabledDialog").I18n | import("ui/pages/launcher/LauncherDialogs/NoLongerBookmarkedDialog").I18n + | import("ui/pages/launcher/formFields/YamlCodeBlockFormField").I18n | import("ui/pages/myService/MyService").I18n | import("ui/pages/myService/PodLogsTab").I18n | import("ui/pages/myService/MyServiceButtonBar").I18n diff --git a/web/src/ui/pages/launcher/formFields/FormFieldWrapper.tsx b/web/src/ui/pages/launcher/formFields/FormFieldWrapper.tsx new file mode 100644 index 000000000..d90e37264 --- /dev/null +++ b/web/src/ui/pages/launcher/formFields/FormFieldWrapper.tsx @@ -0,0 +1,37 @@ +import { tss } from "tss"; + +type Props = { + className?: string; + title: string; + description: string | undefined; + onResetToDefault: () => void; + error: JSX.Element | string | undefined; + children: JSX.Element; +}; + +export function FormFieldWrapper(props: Props) { + const { className, title, description, onResetToDefault, error, children } = props; + + const { cx, classes } = useStyles({ "isErrored": error !== undefined }); + + return ( +
+

{title}

+ + {description !== undefined &&

{description}

} + {children} + {error !== undefined && error} +
+ ); +} + +const useStyles = tss + .withName({ FormFieldWrapper }) + .withParams<{ isErrored: boolean }>() + .create(({ theme, isErrored }) => ({ + "root": { + "color": !isErrored + ? undefined + : theme.colors.useCases.alertSeverity.error.main + } + })); diff --git a/web/src/ui/pages/launcher/formFields/YamlCodeBlock.stories.tsx b/web/src/ui/pages/launcher/formFields/YamlCodeBlock.stories.tsx new file mode 100644 index 000000000..96ac2584e --- /dev/null +++ b/web/src/ui/pages/launcher/formFields/YamlCodeBlock.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { YamlCodeBlockFormField } from "./YamlCodeBlockFormField"; +import { action } from "@storybook/addon-actions"; + +const meta = { + title: "pages/Launcher/formFields/YamlCodeBlock", + component: YamlCodeBlockFormField +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + "title": "This is the title", + "description": "This is the description", + "expectedDataType": "object", + "value": { + "key1": "value1", + "key2": "value2" + }, + "onChange": action("Value changed") + } +}; diff --git a/web/src/ui/pages/launcher/formFields/YamlCodeBlockFormField.tsx b/web/src/ui/pages/launcher/formFields/YamlCodeBlockFormField.tsx new file mode 100644 index 000000000..fb0647bd6 --- /dev/null +++ b/web/src/ui/pages/launcher/formFields/YamlCodeBlockFormField.tsx @@ -0,0 +1,99 @@ +import { memo, Suspense } from "react"; +import type { Stringifyable } from "core/tools/Stringifyable"; +import { FormFieldWrapper } from "./FormFieldWrapper"; +import { tss } from "tss"; +import { useFormField } from "./useFormField"; +import YAML from "yaml"; +import { declareComponentKeys, useTranslation } from "ui/i18n"; + +type Props = { + className?: string; + title: string; + description: string | undefined; + expectedDataType: "object" | "array"; + value: Record | Stringifyable[]; + onChange: (newValue: Record | Stringifyable[]) => void; +}; + +export const YamlCodeBlockFormField = memo((props: Props) => { + const { className, title, description, expectedDataType, value, onChange } = props; + + const { t } = useTranslation({ YamlCodeBlockFormField }); + + const { cx, classes } = useStyles(); + + const { serializedValue, setSerializedValue, errorMessageKey, resetToDefault } = + useFormField< + Record | Stringifyable[], + string, + "not valid yaml" | "not an array" | "not an object" + >({ + "serializedValue": JSON.stringify(value), + onChange, + "parse": serializedValue => { + let value: Record | Stringifyable[]; + + try { + value = YAML.parse(serializedValue); + } catch { + return { + "isValid": false, + "errorMessageKey": "not valid yaml" + }; + } + + switch (expectedDataType) { + case "array": + if (!(value instanceof Array)) { + return { + "isValid": false, + "errorMessageKey": "not an array" + }; + } + break; + case "object": + if (!(value instanceof Object) || value instanceof Array) { + return { + "isValid": false, + "errorMessageKey": "not an object" + }; + } + break; + } + + return { + "isValid": true, + value + }; + } + }); + + return ( + + + setSerializedValue(e.target.value)} + /> + + + ); +}); + +const useStyles = tss.withName({ YamlCodeBlockFormField }).create({ + "root": {}, + "input": {} +}); + +const { i18n } = declareComponentKeys< + "not valid yaml" | "not an array" | "not an object" +>()({ YamlCodeBlockFormField }); + +export type I18n = typeof i18n; diff --git a/web/src/ui/pages/launcher/formFields/useFormField.ts b/web/src/ui/pages/launcher/formFields/useFormField.ts new file mode 100644 index 000000000..00f25c444 --- /dev/null +++ b/web/src/ui/pages/launcher/formFields/useFormField.ts @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import { useConst } from "powerhooks/useConst"; +import { waitForDebounceFactory } from "powerhooks/tools/waitForDebounce"; + +export function useFormField< + TValue, + TSerializedValue extends string | number | boolean, + ErrorMessageKey extends string +>(params: { + serializedValue: TSerializedValue; + onChange: (newValue: TValue) => void; + parse: ( + serializedValue: TSerializedValue + ) => + | { isValid: true; value: TValue } + | { isValid: false; errorMessageKey: ErrorMessageKey }; +}): { + serializedValue: TSerializedValue; + setSerializedValue: (newValue: TSerializedValue) => void; + errorMessageKey: ErrorMessageKey | undefined; + resetToDefault: () => void; +} { + const { serializedValue: serializedValue_params, onChange, parse } = params; + + const serializedValue_default = useConst(() => serializedValue_params); + + const [serializedValue, setSerializedValue] = useState(serializedValue_params); + const [errorMessageKey, setErrorMessageKey] = useState( + undefined + ); + + useEffect(() => { + setSerializedValue(serializedValue_params); + }, [serializedValue_params]); + + const onChange_const = useConstCallback(onChange); + + const onChangeWithDebounce = useConst(() => { + const { waitForDebounce } = waitForDebounceFactory({ "delay": 500 }); + + async function onChangeWithDebounce(newValue: TValue) { + await waitForDebounce(); + + onChange_const(newValue); + } + + return onChangeWithDebounce; + }); + + useEffect(() => { + const resultOfParse = parse(serializedValue); + + if (!resultOfParse.isValid) { + setErrorMessageKey(resultOfParse.errorMessageKey); + return; + } + + setErrorMessageKey(undefined); + + onChangeWithDebounce(resultOfParse.value); + }, [serializedValue]); + + return { + serializedValue, + setSerializedValue, + errorMessageKey, + "resetToDefault": () => setSerializedValue(serializedValue_default) + }; +}