From e2802e7543a1f423741777e5b91d3b07b876c018 Mon Sep 17 00:00:00 2001 From: Joseph Kavanagh Date: Wed, 3 Apr 2024 17:11:52 +0100 Subject: [PATCH] refactor(web): use defaultvalues in react-hook-form --- .../generic/boolean-with-default.tsx | 1 + .../components/modals/action-release/item.tsx | 8 +- .../modals/service-edit/loading.tsx | 4 +- .../components/modals/service-edit/notify.tsx | 3 +- .../modals/service-edit/service.tsx | 54 +---- .../service-edit/util/api-ui-conversions.tsx | 163 +++++++------ .../service-edit/version-with-refresh.tsx | 5 +- .../modals/service-edit/webhook.tsx | 3 +- .../src/components/notification/index.tsx | 8 +- .../react-app/src/modals/delete-confirm.tsx | 5 +- web/ui/react-app/src/modals/service-edit.tsx | 227 +++++++++++++++--- web/ui/react-app/src/types/config.tsx | 2 +- 12 files changed, 312 insertions(+), 171 deletions(-) diff --git a/web/ui/react-app/src/components/generic/boolean-with-default.tsx b/web/ui/react-app/src/components/generic/boolean-with-default.tsx index 1f6379fc..17b8baca 100644 --- a/web/ui/react-app/src/components/generic/boolean-with-default.tsx +++ b/web/ui/react-app/src/components/generic/boolean-with-default.tsx @@ -61,6 +61,7 @@ const BooleanWithDefault: FC = ({ return ( diff --git a/web/ui/react-app/src/components/modals/action-release/item.tsx b/web/ui/react-app/src/components/modals/action-release/item.tsx index 5813354e..fb7c443d 100644 --- a/web/ui/react-app/src/components/modals/action-release/item.tsx +++ b/web/ui/react-app/src/components/modals/action-release/item.tsx @@ -53,12 +53,8 @@ const sendableTimeout = ( let timeout = differenceInMilliseconds(nextRunnable, now); // if we're already after nextRunnable, just wait a second if (now > nextRunnable) timeout = 1000; - const timer = setTimeout(function () { - setSendable(true); - }, timeout); - return () => { - clearTimeout(timer); - }; + const timer = setTimeout(() => setSendable(true), timeout); + return () => clearTimeout(timer); } }; diff --git a/web/ui/react-app/src/components/modals/service-edit/loading.tsx b/web/ui/react-app/src/components/modals/service-edit/loading.tsx index 3bf936fe..9a8da4a3 100644 --- a/web/ui/react-app/src/components/modals/service-edit/loading.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/loading.tsx @@ -28,8 +28,8 @@ export const Loading: FC = ({ name }) => { "Options:", "Latest Version:", "Deployed Version:", - "Commands:", - "WebHooks:", + "Command:", + "WebHook:", "Notify:", "Dashboard:", ]; diff --git a/web/ui/react-app/src/components/modals/service-edit/notify.tsx b/web/ui/react-app/src/components/modals/service-edit/notify.tsx index b11c536a..1b30e10d 100644 --- a/web/ui/react-app/src/components/modals/service-edit/notify.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/notify.tsx @@ -49,10 +49,11 @@ const Notify: FC = ({ else if (itemType && (NotifyTypesConst as string[]).includes(itemName)) setValue(`${name}.type`, itemName); // Trigger validation on name/type - setTimeout(() => { + const timeout = setTimeout(() => { if (itemName !== "") trigger(`${name}.name`); trigger(`${name}.type`); }, 25); + return () => clearTimeout(timeout); }, [itemName]); const header = useMemo( () => `${name.split(".").slice(-1)}: (${itemType}) ${itemName}`, diff --git a/web/ui/react-app/src/components/modals/service-edit/service.tsx b/web/ui/react-app/src/components/modals/service-edit/service.tsx index fdf0ab8a..4e7e3509 100644 --- a/web/ui/react-app/src/components/modals/service-edit/service.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/service.tsx @@ -1,10 +1,5 @@ -import { FC, useEffect, useMemo, useState } from "react"; import { FormGroup, Stack } from "react-bootstrap"; -import { - ServiceEditAPIType, - ServiceEditOtherData, - ServiceEditType, -} from "types/service-edit"; +import { ServiceEditOtherData, ServiceEditType } from "types/service-edit"; import EditServiceCommands from "components/modals/service-edit/commands"; import EditServiceDashboard from "components/modals/service-edit/dashboard"; @@ -13,17 +8,15 @@ import EditServiceLatestVersion from "components/modals/service-edit/latest-vers import EditServiceNotifys from "components/modals/service-edit/notifys"; import EditServiceOptions from "components/modals/service-edit/options"; import EditServiceWebHooks from "components/modals/service-edit/webhooks"; +import { FC } from "react"; import { FormItem } from "components/generic/form"; -import { Loading } from "./loading"; import { WebHookType } from "types/config"; -import { convertAPIServiceDataEditToUI } from "components/modals/service-edit/util"; -import { fetchJSON } from "utils"; -import { useFormContext } from "react-hook-form"; -import { useQuery } from "@tanstack/react-query"; import { useWebSocket } from "contexts/websocket"; interface Props { name: string; + defaultData: ServiceEditType; + otherOptionsData: ServiceEditOtherData; } /** @@ -32,45 +25,10 @@ interface Props { * @param name - The name of the service * @returns The form fields for creating/editing a service */ -const EditService: FC = ({ name }) => { - const { reset } = useFormContext(); - const [loading, setLoading] = useState(true); - - const { data: otherOptionsData, isFetched: isFetchedOtherOptionsData } = - useQuery({ - queryKey: ["service/edit", "detail"], - queryFn: () => - fetchJSON({ url: "api/v1/service/edit" }), - }); - const { data: serviceData, isSuccess: isSuccessServiceData } = useQuery({ - queryKey: ["service/edit", { id: name }], - queryFn: () => - fetchJSON({ url: `api/v1/service/edit/${name}` }), - enabled: !!name, - refetchOnMount: "always", - }); - - const defaultData: ServiceEditType = useMemo( - () => convertAPIServiceDataEditToUI(name, serviceData, otherOptionsData), - [serviceData, otherOptionsData] - ); +const EditService: FC = ({ name, defaultData, otherOptionsData }) => { const { monitorData } = useWebSocket(); - useEffect(() => { - // If we're loading and have finished fetching the service data - // (or don't have name = resetting for close) - if ( - (loading && isSuccessServiceData && isFetchedOtherOptionsData) || - !name - ) { - reset(defaultData); - setTimeout(() => setLoading(false), 100); - } - }, [defaultData]); - - return loading ? ( - - ) : ( + return ( ({ args: args.map((arg) => ({ arg })), })), - webhook: serviceData?.webhook?.map((item) => { - // Determine webhook name and type - const whName = item.name ?? ""; - const whType = item.type ?? ""; - - // Construct custom headers - const customHeaders = item.custom_headers - ? item.custom_headers.map((header, index) => ({ - ...header, - oldIndex: index, - })) - : firstNonEmpty( - otherOptionsData?.webhook?.[whName]?.custom_headers, - ( - otherOptionsData?.defaults?.webhook?.[whType] as - | WebHookType - | undefined - )?.custom_headers, - ( - otherOptionsData?.hard_defaults?.webhook?.[whType] as - | WebHookType - | undefined - )?.custom_headers - ).map(() => ({ key: "", value: "" })); - - // Return modified item - return { - ...item, - custom_headers: customHeaders, - oldIndex: item.name, - }; - }), - notify: serviceData?.notify?.map((item) => ({ - ...item, - oldIndex: item.name, - url_fields: { - ...convertNotifyURLFields( - item.name ?? "", - item.type, - item.url_fields, - otherOptionsData - ), - }, - params: { - avatar: "", // controlled param - color: "", // ^ - icon: "", // ^ - ...convertNotifyParams( - item.name ?? "", - item.type, - item.params, - otherOptionsData - ), - }, - })), + webhook: serviceData.webhook + ? Object.entries(serviceData.webhook).reduce( + (acc: WebHookEditType[], [_key, value]) => { + // Determine webhook name and type + const whName = value.name ?? ""; + const whType = value.type ?? ""; + + // Construct custom headers + const customHeaders = !isEmptyArray(value.custom_headers) + ? value.custom_headers?.map((header, index) => ({ + ...header, + oldIndex: index, + })) + : firstNonEmpty( + otherOptionsData?.webhook?.[whName]?.custom_headers, + ( + otherOptionsData?.defaults?.webhook?.[whType] as + | WebHookType + | undefined + )?.custom_headers, + ( + otherOptionsData?.hard_defaults?.webhook?.[whType] as + | WebHookType + | undefined + )?.custom_headers + ).map(() => ({ key: "", value: "" })); + + const transformedWebhook = { + ...value, + custom_headers: customHeaders, + oldIndex: whName, + }; + return [...acc, transformedWebhook]; + }, + [] + ) + : [], + notify: serviceData.notify + ? Object.entries(serviceData.notify).reduce( + (acc: NotifyEditType[], [_key, value]) => { + // Determine notify name and type + const notifyName = value.name ?? ""; + const notifyType = value.type ?? ""; + + const transformedNotify = { + ...value, + id: notifyName, + url_fields: convertNotifyURLFields( + notifyName, + notifyType, + value.url_fields, + otherOptionsData + ), + params: { + avatar: "", // controlled param + color: "", // ^ + icon: "", // ^ + ...convertNotifyParams( + notifyName, + notifyType, + value.params, + otherOptionsData + ), + }, + }; + return [...acc, transformedNotify]; + }, + [] + ) + : [], dashboard: { auto_approve: undefined, icon: "", @@ -213,11 +235,14 @@ export const convertHeadersFromString = ( // convert from a JSON string try { - return Object.entries(JSON.parse(s)).map(([key, value], i) => ({ - id: usingStr ? i : undefined, - key: usingStr ? key : "", - value: usingStr ? value : "", - })) as HeaderType[]; + return Object.entries(JSON.parse(s)).map(([key, value], i) => { + const id = usingStr ? { id: i } : {}; + return { + ...id, + key: usingStr ? key : "", + value: usingStr ? value : "", + }; + }) as HeaderType[]; } catch (error) { return []; } @@ -253,11 +278,11 @@ export const convertOpsGenieTargetFromString = ( obj: { id: string; type: string; name: string; username: string }, i: number ) => { - const id = usingStr ? i : undefined; + const id = usingStr ? { id: i } : {}; // team/user - id if (obj.id) { return { - id: id, + ...id, type: obj.type, sub_type: "id", value: usingStr ? obj.id : "", @@ -265,7 +290,7 @@ export const convertOpsGenieTargetFromString = ( } else { // team/user - username/name return { - id: id, + ...id, type: obj.type, sub_type: obj.type === "user" ? "username" : "name", value: usingStr ? obj.name || obj.username : "", @@ -304,12 +329,12 @@ export const convertNtfyActionsFromString = ( // convert from a JSON string try { return JSON.parse(s).map((obj: NotifyNtfyAction, i: number) => { - const id = usingStr ? i : undefined; + const id = usingStr ? { id: i } : {}; // View if (obj.action === "view") return { - id: id, + ...id, action: obj.action, label: usingStr ? obj.label : "", url: usingStr ? obj.url : "", @@ -318,7 +343,7 @@ export const convertNtfyActionsFromString = ( // HTTP if (obj.action === "http") return { - id: id, + ...id, action: obj.action, label: usingStr ? obj.label : "", url: usingStr ? obj.url : "", @@ -333,7 +358,7 @@ export const convertNtfyActionsFromString = ( // Broadcast if (obj.action === "broadcast") return { - id: id, + ...id, action: obj.action, label: usingStr ? obj.label : "", intent: usingStr ? obj.intent : "", @@ -345,7 +370,7 @@ export const convertNtfyActionsFromString = ( // Unknown action return { - id: id, + ...id, ...obj, }; }) as NotifyNtfyAction[]; diff --git a/web/ui/react-app/src/components/modals/service-edit/version-with-refresh.tsx b/web/ui/react-app/src/components/modals/service-edit/version-with-refresh.tsx index 3b6e5b07..0ad169a1 100644 --- a/web/ui/react-app/src/components/modals/service-edit/version-with-refresh.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/version-with-refresh.tsx @@ -92,10 +92,9 @@ const VersionWithRefresh: FC = ({ vType, serviceName, original }) => { refetchSemanticVersioning(); refetchData(); // setTimeout to allow time for refetches ^ - setTimeout(() => { - refetchVersion(); - }); + const timeout = setTimeout(() => refetchVersion()); setLastFetched(currentTime); + return () => clearTimeout(timeout); } }; diff --git a/web/ui/react-app/src/components/modals/service-edit/webhook.tsx b/web/ui/react-app/src/components/modals/service-edit/webhook.tsx index cb70f348..23e75021 100644 --- a/web/ui/react-app/src/components/modals/service-edit/webhook.tsx +++ b/web/ui/react-app/src/components/modals/service-edit/webhook.tsx @@ -63,10 +63,11 @@ const EditServiceWebHook: FC = ({ useEffect(() => { if (mains?.[itemName]?.type !== undefined) setValue(`${name}.type`, mains[itemName].type); - setTimeout(() => { + const timeout = setTimeout(() => { if (itemName !== "") trigger(`${name}.name`); trigger(`${name}.type`); }, 25); + return () => clearTimeout(timeout); }, [itemName]); const header = useMemo( diff --git a/web/ui/react-app/src/components/notification/index.tsx b/web/ui/react-app/src/components/notification/index.tsx index fe83bed7..7c6a22a0 100644 --- a/web/ui/react-app/src/components/notification/index.tsx +++ b/web/ui/react-app/src/components/notification/index.tsx @@ -38,15 +38,11 @@ const Notification: FC = ({ useEffect(() => { if (delay !== 0) { const timer = setTimeout( - () => { - removeNotification(id); - }, + () => removeNotification(id), delay ? delay : 10000 ); - return () => { - clearTimeout(timer); - }; + return () => clearTimeout(timer); } }, [delay, id, removeNotification]); diff --git a/web/ui/react-app/src/modals/delete-confirm.tsx b/web/ui/react-app/src/modals/delete-confirm.tsx index 1d1b6fa9..19106371 100644 --- a/web/ui/react-app/src/modals/delete-confirm.tsx +++ b/web/ui/react-app/src/modals/delete-confirm.tsx @@ -47,8 +47,9 @@ export const DeleteModal: FC = ({ onDelete, disabled }) => { Confirm Delete - Are you sure you want to delete this item? This action cannot be - undone. + Are you sure you want to delete this item? +
+ This action cannot be undone. {deleting && ( { return removeEmptyValues(convertUIServiceDataEditToAPI(data)); }; - /** - * @returns The modal for editing a service + * @returns The service edit modal */ const ServiceEditModal = () => { - // modal.actionType: - // EDIT const { handleModal, modal } = useContext(ModalContext); - const form = useForm({ mode: "onBlur" }); + if (modal.actionType !== "EDIT") { + return null; + } + return ( + handleModal("", { id: "", loading: true })} + /> + ); +}; + +interface ServiceEditModalGetDataProps { + serviceID: string; + hideModal: () => void; +} +/** + * Gets the data for and returns the service edit modal + * + * @param serviceID - The ID of the service to edit + * @param hideModal - The function to hide the modal + * @returns The service edit modal with the data fetched and a loading modal while fetching + */ +const ServiceEditModalGetData: FC = ({ + serviceID, + hideModal, +}) => { + const [loadingModal, setLoadingModal] = useState(true); + useEffect(() => { + const timeout = setTimeout(() => setLoadingModal(false), 200); + return () => clearTimeout(timeout); + }, []); + const { data: otherOptionsData, isFetched: isFetchedOtherOptionsData } = + useQuery({ + queryKey: ["service/edit", "detail"], + queryFn: () => + fetchJSON({ url: "api/v1/service/edit" }), + }); + const { + data: serviceData, + isSuccess: isSuccessServiceData, + isRefetching, + } = useQuery({ + queryKey: ["service/edit", { id: serviceID }], + queryFn: () => + fetchJSON({ + url: `api/v1/service/edit/${serviceID}`, + }), + enabled: !!serviceID, + refetchOnMount: "always", + }); + + const defaultData: ServiceEditType = useMemo( + () => + convertAPIServiceDataEditToUI(serviceID, serviceData, otherOptionsData), + [serviceData, otherOptionsData, isRefetching] + ); + + // Not fetchedZ yet + if ( + loadingModal || + !isFetchedOtherOptionsData || + (!isSuccessServiceData && serviceID) || + !otherOptionsData + ) { + return ( + + + + + + + + + + {serviceID && ( + {}} disabled={!loadingModal} /> + )} + + + + + + + + ); + } + + // Service edit modal + return ( + + ); +}; + +/** + * @returns The header for the service edit modal + */ +const ServiceEditModalHeader = () => ( + + + Edit Service + + + +); + +interface ServiceEditModalWithDataProps { + serviceID: string; + defaultData: ServiceEditType; + otherOptionsData: ServiceEditOtherData; + hideModal: () => void; +} +/** + * Returns a modal for editing the service + * + * @param serviceID - The ID of the service to edit + * @param defaultData - The default data for the service + * @param otherOptionsData - The mains/defaults/hardDefaults for the service + * @param hideModal - The function to hide the modal + * @returns The modal for editing a service + */ +const ServiceEditModalWithData: FC = ({ + serviceID, + defaultData, + otherOptionsData, + hideModal, +}) => { + const form = useForm({ + mode: "onBlur", + defaultValues: defaultData ?? {}, + }); + useEffect(() => { + if (defaultData) form.reset(defaultData); + }, [defaultData]); // null if submitting const [err, setErr] = useState(""); - const hideModal = useCallback(() => { + const resetAndHideModal = useCallback(() => { form.reset({}); setErr(""); - handleModal("", { id: "", loading: true }); + hideModal(); }, []); const onSubmit = async (data: ServiceEditType) => { setErr(null); const payload = getPayload(data); - const serviceName = modal.service.id; await fetch( - serviceName ? `api/v1/service/edit/${serviceName}` : "api/v1/service/new", + serviceID ? `api/v1/service/edit/${serviceID}` : "api/v1/service/new", { - method: serviceName ? "PUT" : "POST", + method: serviceID ? "PUT" : "POST", body: JSON.stringify(payload), } ) @@ -88,8 +259,8 @@ const ServiceEditModal = () => { }; const onDelete = async () => { - console.log(`Deleting ${modal.service.id}`); - await fetch(`api/v1/service/delete/${modal.service.id}`, { + console.log(`Deleting ${serviceID}`); + await fetch(`api/v1/service/delete/${serviceID}`, { method: "DELETE", }).then(() => { hideModal(); @@ -99,34 +270,26 @@ const ServiceEditModal = () => { return (
- hideModal()} - > - - - Edit Service - - - + + - + - {modal.service.id !== "" && ( + {serviceID && ( onDelete()} disabled={err === null} diff --git a/web/ui/react-app/src/types/config.tsx b/web/ui/react-app/src/types/config.tsx index 3b533658..8f331c1c 100644 --- a/web/ui/react-app/src/types/config.tsx +++ b/web/ui/react-app/src/types/config.tsx @@ -621,7 +621,7 @@ export interface NotifyOptionsType { } export interface NotifyURLFieldsType { - [key: string]: undefined | string | number | boolean; + [key: string]: undefined | string | number | boolean | HeaderType[]; } export interface WebHookType {