New channel config (#807)

* Channel config rework

Add staged channel config with tabbed UI, import/export workflow, and global form state refactor

* Improve import dialog config comparison and UI labels

* Review fixes

* Improve state handling

* Fix default filter behaviour

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
This commit is contained in:
Jeremy Gallant
2025-09-01 09:07:34 -04:00
committed by GitHub
co-authored by philon-
parent 01fa030ef9
commit 1946000d14
42 changed files with 597 additions and 456 deletions
-86
View File
@@ -1,86 +0,0 @@
import { Channel } from "@components/PageComponents/Channel.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import { Types } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
ns: "channels",
index: channel.index,
});
};
const ChannelsPage = () => {
const { t } = useTranslation("channels");
const { channels, setDialogOpen } = useDevice();
const [activeChannel] = useState<Types.ChannelNumber>(
Types.ChannelNumber.Primary,
);
const currentChannel = channels.get(activeChannel);
const allChannels = Array.from(channels.values());
return (
<PageLayout
contentClassName="overflow-auto"
leftBar={<Sidebar />}
label={
currentChannel
? getChannelName(currentChannel)
: t("loading", { ns: "common" })
}
actions={[
{
key: "import",
icon: UploadIcon,
onClick() {
setDialogOpen("import", true);
},
},
{
key: "qr",
icon: QrCodeIcon,
onClick() {
setDialogOpen("QR", true);
},
},
]}
>
<Tabs defaultValue="0">
<TabsList className="dark:bg-slate-800">
{allChannels.map((channel) => (
<TabsTrigger
key={channel.index}
value={channel.index.toString()}
className="dark:text-white"
>
{getChannelName(channel)}
</TabsTrigger>
))}
</TabsList>
{allChannels.map((channel) => (
<TabsContent key={channel.index} value={channel.index.toString()}>
<Channel key={channel.index} channel={channel} />
</TabsContent>
))}
</Tabs>
</PageLayout>
);
};
export default ChannelsPage;
@@ -0,0 +1,95 @@
import { Channel } from "@app/components/PageComponents/ChannelConfig/Channel";
import { Button } from "@components/UI/Button.tsx";
import { Spinner } from "@components/UI/Spinner.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@components/UI/Tabs.tsx";
import { useDevice } from "@core/stores";
import type { Protobuf } from "@meshtastic/core";
import i18next from "i18next";
import { QrCodeIcon, UploadIcon } from "lucide-react";
import { Suspense, useMemo } from "react";
import type { UseFormReturn } from "react-hook-form";
import { useTranslation } from "react-i18next";
interface ConfigProps {
onFormInit: <T extends object>(methods: UseFormReturn<T>) => void;
}
export const getChannelName = (channel: Protobuf.Channel.Channel) => {
return channel.settings?.name.length
? channel.settings?.name
: channel.index === 0
? i18next.t("page.broadcastLabel")
: i18next.t("page.channelIndex", {
ns: "channels",
index: channel.index,
});
};
export const ChannelConfig = ({ onFormInit }: ConfigProps) => {
const { channels, getWorkingChannelConfig, setDialogOpen } = useDevice();
const { t } = useTranslation("channels");
const allChannels = Array.from(channels.values());
const flags = useMemo(
() =>
new Map(
allChannels.map((channel) => [
channel.index,
getWorkingChannelConfig(channel.index),
]),
),
[allChannels, getWorkingChannelConfig],
);
return (
<Tabs defaultValue="channel_0">
<TabsList className="w-full dark:bg-slate-700">
{allChannels.map((channel) => (
<TabsTrigger
key={`channel_${channel.index}`}
value={`channel_${channel.index}`}
className="dark:text-white relative"
>
{getChannelName(channel)}
{flags.get(channel.index) && (
<span className="absolute -top-0.5 -right-0.5 z-50 flex size-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-500 opacity-25" />
<span className="relative inline-flex size-3 rounded-full bg-sky-500" />
</span>
)}
</TabsTrigger>
))}
<Button
className="ml-auto mr-1 h-8"
onClick={() => setDialogOpen("import", true)}
>
<UploadIcon className="mr-2" size={14} />
{t("page.import")}
</Button>
<Button className=" h-8" onClick={() => setDialogOpen("QR", true)}>
<QrCodeIcon className="mr-2" size={14} />
{t("page.export")}
</Button>
</TabsList>
{allChannels.map((channel) => (
<TabsContent
key={`channel_${channel.index}`}
value={`channel_${channel.index}`}
>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<Channel
key={channel.index}
onFormInit={onFormInit}
channel={channel}
/>
</Suspense>
</TabsContent>
))}
</Tabs>
);
};
@@ -83,7 +83,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
return (
<Tabs defaultValue={t("page.tabDevice")}>
<TabsList className="dark:bg-slate-700">
<TabsList className="w-full dark:bg-slate-700">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
@@ -103,9 +103,7 @@ export const DeviceConfig = ({ onFormInit }: ConfigProps) => {
{tabs.map((tab) => (
<TabsContent key={tab.label} value={tab.label}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<Suspense fallback={<Spinner size="lg" className="my-5" />}>
<tab.element onFormInit={onFormInit} />
</Suspense>
<tab.element onFormInit={onFormInit} />
</Suspense>
</TabsContent>
))}
@@ -103,7 +103,7 @@ export const ModuleConfig = ({ onFormInit }: ConfigProps) => {
return (
<Tabs defaultValue={t("page.tabMqtt")}>
<TabsList className="dark:bg-slate-800">
<TabsList className="w-full dark:bg-slate-800">
{tabs.map((tab) => (
<TabsTrigger
key={tab.label}
+121 -67
View File
@@ -3,18 +3,20 @@ 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";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
import {
BoxesIcon,
LayersIcon,
RefreshCwIcon,
SaveIcon,
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";
@@ -22,18 +24,22 @@ const ConfigPage = () => {
const {
workingConfig,
workingModuleConfig,
workingChannelConfig,
connection,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
setConfig,
setModuleConfig,
addChannel,
} = useDevice();
const { hasErrors } = useAppStore();
const [activeConfigSection, setActiveConfigSection] = useState<
"device" | "module"
"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");
@@ -41,21 +47,51 @@ 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,
});
},
});
},
[],
);
const handleSave = useCallback(async () => {
if (hasErrors()) {
return toast({
title: t("toast.validationError.title"),
description: t("toast.validationError.description"),
});
}
// Cleanup subscription on unmount
useEffect(() => {
return () => unsubRef.current?.();
}, []);
const handleSave = useCallback(async () => {
setIsSaving(true);
try {
// Save all working channel configs first, doesn't require a commit/reboot
await Promise.all(
workingChannelConfig.map((channel) =>
connection?.setChannel(channel).then(() => {
toast({
title: t("toast.savedChannel.title", {
ns: "ui",
channelName: channel.settings?.name,
}),
});
}),
),
);
await Promise.all(
workingConfig.map((newConfig) =>
connection?.setConfig(newConfig).then(() => {
@@ -82,24 +118,31 @@ const ConfigPage = () => {
),
);
await connection?.commitEditSettings().then(() => {
if (formMethods) {
formMethods.reset(
{},
{
keepValues: true,
},
);
}
if (workingConfig.length > 0 || workingModuleConfig.length > 0) {
await connection?.commitEditSettings();
}
workingConfig.map((newConfig) => setConfig(newConfig));
workingModuleConfig.map((newModuleConfig) =>
setModuleConfig(newModuleConfig),
);
workingChannelConfig.forEach((newChannel) => addChannel(newChannel));
workingConfig.forEach((newConfig) => setConfig(newConfig));
workingModuleConfig.forEach((newModuleConfig) =>
setModuleConfig(newModuleConfig),
);
removeWorkingConfig();
removeWorkingModuleConfig();
});
removeWorkingChannelConfig();
removeWorkingConfig();
removeWorkingModuleConfig();
if (formMethods) {
formMethods.reset(formMethods.getValues(), {
keepDirty: false,
keepErrors: false,
keepTouched: false,
keepValues: true,
});
// Force RHF to re-validate and emit state
formMethods.trigger();
}
} catch (_error) {
toast({
title: t("toast.configSaveError.title"),
@@ -107,29 +150,40 @@ const ConfigPage = () => {
});
} finally {
setIsSaving(false);
toast({
title: t("toast.saveAllSuccess.title"),
description: t("toast.saveAllSuccess.description"),
});
}
}, [
hasErrors,
toast,
t,
workingConfig,
connection,
workingModuleConfig,
workingChannelConfig,
formMethods,
addChannel,
setConfig,
setModuleConfig,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
]);
const handleReset = useCallback(() => {
if (formMethods) {
formMethods.reset();
}
removeWorkingChannelConfig();
removeWorkingConfig();
removeWorkingModuleConfig();
}, [formMethods, removeWorkingConfig, removeWorkingModuleConfig]);
}, [
formMethods,
removeWorkingConfig,
removeWorkingModuleConfig,
removeWorkingChannelConfig,
]);
const leftSidebar = useMemo(
() => (
@@ -151,35 +205,33 @@ const ConfigPage = () => {
isDirty={workingModuleConfig.length > 0}
count={workingModuleConfig.length}
/>
<SidebarButton
label={t("navigation.channelConfig")}
active={activeConfigSection === "channel"}
onClick={() => setActiveConfigSection("channel")}
Icon={LayersIcon}
isDirty={workingChannelConfig.length > 0}
count={workingChannelConfig.length}
/>
</SidebarSection>
</Sidebar>
),
[activeConfigSection, workingConfig, workingModuleConfig, t],
[
activeConfigSection,
workingConfig,
workingModuleConfig,
workingChannelConfig,
t,
],
);
const buttonOpacity = useMemo(() => {
const isFormDirty = formMethods?.formState.isDirty ?? false;
const hasDirtyFields =
(Object.keys(formMethods?.formState.dirtyFields ?? {}).length ?? 0) > 0;
const hasWorkingConfig = workingConfig.length > 0;
const hasWorkingModuleConfig = workingModuleConfig.length > 0;
const shouldShowButton =
(isFormDirty && hasDirtyFields) ||
hasWorkingConfig ||
hasWorkingModuleConfig;
return shouldShowButton ? "opacity-100" : "opacity-0";
}, [
formMethods?.formState.isDirty,
formMethods?.formState.dirtyFields,
workingConfig,
workingModuleConfig,
]);
const isValid = useMemo(() => {
return Object.keys(formMethods?.formState.errors ?? {}).length === 0;
}, [formMethods?.formState.errors]);
const hasDrafts =
workingConfig.length > 0 ||
workingModuleConfig.length > 0 ||
workingChannelConfig.length > 0;
const hasPending = hasDrafts || rhfState.isDirty;
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
const actions = useMemo(
() => [
@@ -207,15 +259,13 @@ const ConfigPage = () => {
},
{
key: "save",
icon: !isValid ? SaveOff : SaveIcon,
icon: !hasPending ? SaveOff : SaveIcon,
isLoading: isSaving,
disabled:
isSaving ||
!isValid ||
(workingConfig.length === 0 && workingModuleConfig.length === 0),
iconClasses: !isValid
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
disabled: saveDisabled,
iconClasses:
!rhfState.isValid && hasPending
? "text-red-400 cursor-not-allowed"
: "cursor-pointer",
className: cn([
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
"hover:dark:bg-slate-300 hover:dark:text-black",
@@ -227,10 +277,10 @@ const ConfigPage = () => {
],
[
isSaving,
isValid,
hasPending,
rhfState.isValid,
saveDisabled,
buttonOpacity,
workingConfig,
workingModuleConfig,
handleReset,
handleSave,
t,
@@ -244,14 +294,18 @@ const ConfigPage = () => {
label={
activeConfigSection === "device"
? t("navigation.radioConfig")
: t("navigation.moduleConfig")
: activeConfigSection === "module"
? t("navigation.moduleConfig")
: t("navigation.channelConfig")
}
actions={actions}
>
{activeConfigSection === "device" ? (
<DeviceConfig onFormInit={onFormInit} />
) : (
) : activeConfigSection === "module" ? (
<ModuleConfig onFormInit={onFormInit} />
) : (
<ChannelConfig onFormInit={onFormInit} />
)}
</PageLayout>
);
+1 -1
View File
@@ -19,7 +19,7 @@ import {
import { cn } from "@core/utils/cn.ts";
import { randId } from "@core/utils/randId.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx";
import { getChannelName } from "@pages/Config/ChannelConfig.tsx";
import { useNavigate, useParams } from "@tanstack/react-router";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import {