Feat(config): Align settings menu to match android/ios (#906)
* feat: aligned settings menu to match android/ios * updated sidebar text size. * Update packages/web/public/i18n/locales/en/config.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/public/i18n/locales/en/config.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/PageComponents/Settings/User.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update packages/web/src/components/PageComponents/ModuleConfig/Telemetry.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * linting/formatting fixes * fixed formatting issue --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import { deviceRoute, moduleRoute, radioRoute } from "@app/routes";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
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 { useDevice } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { DeviceConfig } from "@pages/Settings/DeviceConfig.tsx";
|
||||
import { ModuleConfig } from "@pages/Settings/ModuleConfig.tsx";
|
||||
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import {
|
||||
LayersIcon,
|
||||
RadioTowerIcon,
|
||||
RefreshCwIcon,
|
||||
RouterIcon,
|
||||
SaveIcon,
|
||||
SaveOff,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { FieldValues, UseFormReturn } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RadioConfig } from "./RadioConfig.tsx";
|
||||
|
||||
const ConfigPage = () => {
|
||||
const {
|
||||
getAllConfigChanges,
|
||||
getAllModuleConfigChanges,
|
||||
getAllChannelChanges,
|
||||
connection,
|
||||
clearAllChanges,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
addChannel,
|
||||
getConfigChangeCount,
|
||||
getModuleConfigChangeCount,
|
||||
getChannelChangeCount,
|
||||
} = useDevice();
|
||||
|
||||
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 navigate = useNavigate();
|
||||
const routerState = useRouterState();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const configChangeCount = getConfigChangeCount();
|
||||
const moduleConfigChangeCount = getModuleConfigChangeCount();
|
||||
const channelChangeCount = getChannelChangeCount();
|
||||
|
||||
const sections = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "radio",
|
||||
route: radioRoute,
|
||||
label: t("navigation.radioConfig"),
|
||||
icon: RadioTowerIcon,
|
||||
changeCount: configChangeCount,
|
||||
component: RadioConfig,
|
||||
},
|
||||
{
|
||||
key: "device",
|
||||
route: deviceRoute,
|
||||
label: t("navigation.deviceConfig"),
|
||||
icon: RouterIcon,
|
||||
changeCount: moduleConfigChangeCount,
|
||||
component: DeviceConfig,
|
||||
},
|
||||
{
|
||||
key: "module",
|
||||
route: moduleRoute,
|
||||
label: t("navigation.moduleConfig"),
|
||||
icon: LayersIcon,
|
||||
changeCount: channelChangeCount,
|
||||
component: ModuleConfig,
|
||||
},
|
||||
],
|
||||
[t, configChangeCount, moduleConfigChangeCount, channelChangeCount],
|
||||
);
|
||||
|
||||
const activeSection =
|
||||
sections.find((section) =>
|
||||
routerState.location.pathname.includes(`/settings/${section.key}`),
|
||||
) ?? sections[0];
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => unsubRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
const channelChanges = getAllChannelChanges();
|
||||
const configChanges = getAllConfigChanges();
|
||||
const moduleConfigChanges = getAllModuleConfigChanges();
|
||||
|
||||
await Promise.all(
|
||||
channelChanges.map((channel) =>
|
||||
connection?.setChannel(channel).then(() => {
|
||||
toast({
|
||||
title: t("toast.savedChannel.title", {
|
||||
ns: "ui",
|
||||
channelName: channel.settings?.name,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
configChanges.map((newConfig) =>
|
||||
connection?.setConfig(newConfig).then(() => {
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newConfig.payloadVariant.case,
|
||||
}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
moduleConfigChanges.map((newModuleConfig) =>
|
||||
connection?.setModuleConfig(newModuleConfig).then(() =>
|
||||
toast({
|
||||
title: t("toast.saveSuccess.title"),
|
||||
description: t("toast.saveSuccess.description", {
|
||||
case: newModuleConfig.payloadVariant.case,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (configChanges.length > 0 || moduleConfigChanges.length > 0) {
|
||||
await connection?.commitEditSettings();
|
||||
}
|
||||
|
||||
channelChanges.forEach((newChannel) => {
|
||||
addChannel(newChannel);
|
||||
});
|
||||
configChanges.forEach((newConfig) => {
|
||||
setConfig(newConfig);
|
||||
});
|
||||
moduleConfigChanges.forEach((newModuleConfig) => {
|
||||
setModuleConfig(newModuleConfig);
|
||||
});
|
||||
|
||||
clearAllChanges();
|
||||
|
||||
if (formMethods) {
|
||||
formMethods.reset(formMethods.getValues(), {
|
||||
keepDirty: false,
|
||||
keepErrors: false,
|
||||
keepTouched: false,
|
||||
keepValues: true,
|
||||
});
|
||||
|
||||
formMethods.trigger();
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
title: t("toast.configSaveError.title"),
|
||||
description: t("toast.configSaveError.description"),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
toast({
|
||||
title: t("toast.saveAllSuccess.title"),
|
||||
description: t("toast.saveAllSuccess.description"),
|
||||
});
|
||||
}
|
||||
}, [
|
||||
toast,
|
||||
t,
|
||||
getAllConfigChanges,
|
||||
connection,
|
||||
getAllModuleConfigChanges,
|
||||
getAllChannelChanges,
|
||||
formMethods,
|
||||
addChannel,
|
||||
setConfig,
|
||||
setModuleConfig,
|
||||
clearAllChanges,
|
||||
]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (formMethods) {
|
||||
formMethods.reset();
|
||||
}
|
||||
clearAllChanges();
|
||||
}, [formMethods, clearAllChanges]);
|
||||
|
||||
const leftSidebar = useMemo(
|
||||
() => (
|
||||
<Sidebar>
|
||||
<SidebarSection label={t("sidebar.label")} className="py-2 px-0">
|
||||
{sections.map((section) => (
|
||||
<SidebarButton
|
||||
key={section.key}
|
||||
label={section.label}
|
||||
active={activeSection?.key === section.key}
|
||||
onClick={() => navigate({ to: section.route.to })}
|
||||
Icon={section.icon}
|
||||
isDirty={section.changeCount > 0}
|
||||
count={section.changeCount}
|
||||
/>
|
||||
))}
|
||||
</SidebarSection>
|
||||
</Sidebar>
|
||||
),
|
||||
[sections, activeSection?.key, navigate, t],
|
||||
);
|
||||
|
||||
const hasDrafts =
|
||||
getConfigChangeCount() > 0 ||
|
||||
getModuleConfigChangeCount() > 0 ||
|
||||
getChannelChangeCount() > 0;
|
||||
const hasPending = hasDrafts || rhfState.isDirty;
|
||||
const buttonOpacity = hasPending ? "opacity-100" : "opacity-0";
|
||||
const saveDisabled = isSaving || !rhfState.isValid || !hasPending;
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "unsavedChanges",
|
||||
label: t("common:formValidation.unsavedChanges"),
|
||||
onClick: () => {},
|
||||
className: cn([
|
||||
"bg-blue-500 text-slate-900 hover:bg-initial",
|
||||
"transition-colors duration-200",
|
||||
buttonOpacity,
|
||||
"transition-opacity",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "reset",
|
||||
icon: RefreshCwIcon,
|
||||
label: t("common:button.reset"),
|
||||
onClick: handleReset,
|
||||
className: cn([
|
||||
buttonOpacity,
|
||||
"transition-opacity hover:bg-slate-200 disabled:hover:bg-white",
|
||||
"hover:dark:bg-slate-300 hover:dark:text-black cursor-pointer",
|
||||
]),
|
||||
},
|
||||
{
|
||||
key: "save",
|
||||
icon: !hasPending ? SaveOff : SaveIcon,
|
||||
isLoading: isSaving,
|
||||
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",
|
||||
"disabled:hover:cursor-not-allowed cursor-pointer",
|
||||
]),
|
||||
onClick: handleSave,
|
||||
label: t("common:button.save"),
|
||||
},
|
||||
],
|
||||
[
|
||||
isSaving,
|
||||
hasPending,
|
||||
rhfState.isValid,
|
||||
saveDisabled,
|
||||
buttonOpacity,
|
||||
handleReset,
|
||||
handleSave,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const ActiveComponent = activeSection?.component;
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
contentClassName="overflow-auto"
|
||||
leftBar={leftSidebar}
|
||||
label={activeSection?.label ?? ""}
|
||||
actions={actions}
|
||||
>
|
||||
<ActiveComponent onFormInit={onFormInit} />
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
||||
Reference in New Issue
Block a user