Browse Source

Improve state handling

pull/807/head
philon- 11 months ago
parent
commit
9e520eb551
  1. 8
      packages/web/public/i18n/locales/en/dialog.json
  2. 4
      packages/web/public/i18n/locales/en/ui.json
  3. 11
      packages/web/src/components/Dialog/ImportDialog.tsx
  4. 16
      packages/web/src/components/Form/DynamicForm.tsx
  5. 35
      packages/web/src/core/stores/appStore/index.ts
  6. 55
      packages/web/src/pages/Config/index.tsx

8
packages/web/public/i18n/locales/en/dialog.json

@ -16,7 +16,7 @@
}
},
"import": {
"description": "Import a Channel Set from a Meshtastic URL.",
"description": "Import a Channel Set from a Meshtastic URL. <br />Valid Meshtasic URLs start with \"<italic>https://meshtastic.org/e/...</italic>\"",
"error": {
"invalidUrl": "Invalid Meshtastic URL"
},
@ -26,9 +26,9 @@
"channelName": "Name",
"channelSlot": "Slot",
"channelSetUrl": "Channel Set/QR Code URL",
"usePreset": "Import LoRa Preset",
"presetDescription": "The current LoRa configuration will be overridden.",
"title": "Import Channel Set"
"useLoraConfig": "Import LoRa Config",
"presetDescription": "The current LoRa Config will be replaced.",
"title": "Import Channels"
},
"locationResponse": {
"title": "Location: {{identifier}}",

4
packages/web/public/i18n/locales/en/ui.json

@ -69,8 +69,8 @@
"description": "The configuration change {{case}} has been saved."
},
"saveAllSuccess": {
"title": "Save complete",
"description": "The configuration changes have been saved."
"title": "Saved",
"description": "All configuration changes have been saved."
},
"favoriteNode": {
"title": "{{action}} {{nodeName}} {{direction}} favorites.",

11
packages/web/src/components/Dialog/ImportDialog.tsx

@ -24,7 +24,7 @@ import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
import { Protobuf } from "@meshtastic/core";
import { toByteArray } from "base64-js";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
export interface ImportDialogProps {
open: boolean;
@ -150,7 +150,12 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
<DialogClose />
<DialogHeader>
<DialogTitle>{t("import.title")}</DialogTitle>
<DialogDescription>{t("import.description")}</DialogDescription>
<DialogDescription>
<Trans
i18nKey={"import.description"}
components={{ italic: <i />, br: <br /> }}
/>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<Label>{t("import.channelSetUrl")}</Label>
@ -177,7 +182,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
onCheckedChange={(next) => setUpdateConfig(next)}
/>
<Label className="">
{t("import.usePreset")}
{t("import.useLoraConfig")}
<span className="block pt-2 font-normal text-s">
{t("import.presetDescription")}
</span>

16
packages/web/src/components/Form/DynamicForm.tsx

@ -7,7 +7,6 @@ import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { Heading } from "@components/UI/Typography/Heading.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useAppStore } from "@core/stores";
import { useEffect } from "react";
import {
type Control,
@ -83,7 +82,6 @@ export function DynamicForm<T extends FieldValues>({
validationSchema,
}: DynamicFormProps<T>) {
const { t } = useTranslation();
const { setDirtyForm, setValidForm } = useAppStore();
const internalMethods = useForm<T>({
mode: "onChange",
@ -92,6 +90,7 @@ export function DynamicForm<T extends FieldValues>({
? createZodResolver(validationSchema)
: undefined,
shouldFocusError: false,
shouldUnregister: true,
resetOptions: { keepDefaultValues: true },
values,
});
@ -107,19 +106,6 @@ export function DynamicForm<T extends FieldValues>({
}
}, [onFormInit, propMethods, internalMethods]);
useEffect(() => {
setValidForm(formState.isValid);
setDirtyForm(formState.isDirty);
}, [formState.isDirty, formState.isValid, setValidForm, setDirtyForm]);
useEffect(
() => () => {
setValidForm(true);
setDirtyForm(false);
},
[setValidForm, setDirtyForm],
);
const isDisabled = (
disabledBy?: DisabledBy<T>[],
disabled?: boolean,

35
packages/web/src/core/stores/appStore/index.ts

@ -19,8 +19,6 @@ interface AppState {
nodeNumToBeRemoved: number;
connectDialogOpen: boolean;
nodeNumDetails: number;
validForm: boolean;
dirtyForm: boolean;
setRasterSources: (sources: RasterSource[]) => void;
addRasterSource: (source: RasterSource) => void;
@ -32,13 +30,6 @@ interface AppState {
setNodeNumToBeRemoved: (nodeNum: number) => void;
setConnectDialogOpen: (open: boolean) => void;
setNodeNumDetails: (nodeNum: number) => void;
// Error management
isValidForm: () => boolean;
setValidForm: (valid: boolean) => void;
isDirtyForm: () => boolean;
setDirtyForm: (unsaved: boolean) => void;
}
export const useAppStore = create<AppState>()((set, get) => ({
@ -50,8 +41,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
connectDialogOpen: false,
nodeNumToBeRemoved: 0,
nodeNumDetails: 0,
validForm: true,
dirtyForm: true,
setRasterSources: (sources: RasterSource[]) => {
set(
@ -109,28 +98,4 @@ export const useAppStore = create<AppState>()((set, get) => ({
set(() => ({
nodeNumDetails: nodeNum,
})),
isValidForm: () => {
const state = get();
return state.validForm;
},
setValidForm: (valid: boolean) => {
set(
produce<AppState>((draft) => {
draft.validForm = valid;
}),
);
},
isDirtyForm: () => {
const state = get();
return state.dirtyForm;
},
setDirtyForm: (saved: boolean) => {
set(
produce<AppState>((draft) => {
draft.dirtyForm = saved;
}),
);
},
}));

55
packages/web/src/pages/Config/index.tsx

@ -3,7 +3,7 @@ import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { useToast } from "@core/hooks/useToast.ts";
import { useAppStore, useDevice } from "@core/stores";
import { useDevice } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { ChannelConfig } from "@pages/Config/ChannelConfig.tsx";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
@ -16,7 +16,7 @@ import {
SaveOff,
SettingsIcon,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FieldValues, UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -33,13 +33,13 @@ const ConfigPage = () => {
setModuleConfig,
addChannel,
} = useDevice();
const { isDirtyForm, isValidForm, setDirtyForm, setValidForm } =
useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module" | "channel"
>("device");
const [isSaving, setIsSaving] = useState(false);
const [rhfState, setRhfState] = useState({ isDirty: false, isValid: true });
const unsubRef = useRef<(() => void) | null>(null);
const [formMethods, setFormMethods] = useState<UseFormReturn | null>(null);
const { toast } = useToast();
const { t } = useTranslation("deviceConfig");
@ -47,10 +47,33 @@ const ConfigPage = () => {
const onFormInit = useCallback(
<T extends FieldValues>(methods: UseFormReturn<T>) => {
setFormMethods(methods as UseFormReturn);
setRhfState({
// Assume defailt on init, changes will be caught by subscription
isDirty: false,
isValid: true,
});
// Unsubscribe from previous subscriptions & subscribe to form changes
unsubRef.current?.();
unsubRef.current = methods.subscribe({
formState: { isDirty: true, isValid: true },
callback: ({ isValid, isDirty }) => {
setRhfState({
isDirty: isDirty ?? false,
isValid: isValid ?? true,
});
},
});
},
[],
);
// Cleanup subscription on unmount
useEffect(() => {
return () => unsubRef.current?.();
}, []);
const handleSave = useCallback(async () => {
setIsSaving(true);
@ -114,7 +137,7 @@ const ConfigPage = () => {
keepDirty: false,
keepErrors: false,
keepTouched: false,
keepValues: false,
keepValues: true,
});
// Force RHF to re-validate and emit state
@ -127,8 +150,6 @@ const ConfigPage = () => {
});
} finally {
setIsSaving(false);
setDirtyForm(false);
setValidForm(true);
toast({
title: t("toast.saveAllSuccess.title"),
description: t("toast.saveAllSuccess.description"),
@ -148,16 +169,12 @@ const ConfigPage = () => {
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
setDirtyForm,
setValidForm,
]);
const handleReset = useCallback(() => {
if (formMethods) {
formMethods.reset();
}
setDirtyForm(false);
setValidForm(true);
removeWorkingChannelConfig();
removeWorkingConfig();
removeWorkingModuleConfig();
@ -166,8 +183,6 @@ const ConfigPage = () => {
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
setDirtyForm,
setValidForm,
]);
const leftSidebar = useMemo(
@ -214,11 +229,9 @@ const ConfigPage = () => {
workingConfig.length > 0 ||
workingModuleConfig.length > 0 ||
workingChannelConfig.length > 0;
const isValid = isValidForm();
const isDirty = isDirtyForm();
const hasPending = formMethods?.formState.isReady && (hasDrafts || isDirty);
const hasPending = hasDrafts || rhfState.isDirty;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !isValid || !hasPending;
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
const actions = useMemo(
() => [
@ -246,11 +259,11 @@ const ConfigPage = () => {
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
icon: !hasPending ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled: saveDisabled,
iconClasses:
isDirty && !isValid
!rhfState.isValid && hasPending
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
className: cn([
@ -264,8 +277,8 @@ const ConfigPage = () => {
],
[
isSaving,
isValid,
isDirty,
hasPending,
rhfState.isValid,
saveDisabled,
buttonOpacity,
handleReset,

Loading…
Cancel
Save