mirror of
https://github.com/meshtastic/web.git
synced 2026-07-31 23:16:37 -04:00
feat(sdk,web): migrate User + Channel + ImportDialog off changeRegistry; ConfigEditor gains owner support
Phase 3c-1 of PR #9. The last writers/readers of changeRegistry are moved to the SDK ConfigEditor. (One sweep commit drops the changeRegistry plumbing from the deviceStore.) ConfigEditor (sdk/features/config/domain/ConfigEditor.ts): - New owner state: baselineOwner / workingOwner signals, isOwnerDirty, setBaselineOwner(user), setOwner(user). The editor doesn't subscribe to a SDK signal for the user (owner arrives via NodeInfo, not its own packet), so the web seeds baseline from useMyNodeAsProto via setter. - commit() now also runs setOwner (admin message) when isOwnerDirty, inside the same beginEdit/commitEdit window as radio/module/channels. - isDirty aggregates owner dirty too. reset() reverts working owner. - Disconnect clears baseline + working owner. User page (Settings/User.tsx): - Aligned to Android: single "User Config" card, fields in order: Node ID (read-only), Long Name, Short Name, Hardware model (read-only enum name), Unmessageable, Licensed amateur radio (Ham). - onSubmit calls editor.setOwner instead of connection.setOwner — edits queue with the rest of the editor's pending changes and ship on the global Save. - Preserves non-edited User fields (id, hwModel, role, publicKey, macaddr) by spreading the baseline before applying form deltas. - isUnmessageable now reads from the proto's isUnmessagable instead of defaulting to false (longstanding bug — the existing value was ignored). - shortName min loosened to 1 (was 2) per Android rule. Channel page (PageComponents/Channels/Channel.tsx): - Reads working channel from editor.channels via useSignal projection; drops getChange / setChange / removeChange / deepCompareConfig. - onSubmit -> editor.setChannel(payload). Channels page (PageComponents/Channels/Channels.tsx): - Reads channel list from useChannels() (SDK), maps each into a real proto Channel via create(ChannelSchema, ...) for the per-tab Channel component which still expects the proto shape. - Per-channel dirty indicator now reads editor.dirtyChannels instead of hasChannelChange. ImportDialog (Dialog/ImportDialog.tsx): - Apply path -> editor.setChannel + editor.setRadioSection("lora",...) instead of setChange. Channel reads via SDK. i18n: config.json user.* gains userConfig (card label), nodeId, hardwareModel keys; field labels reworded to match Android (e.g. "Licensed amateur radio (Ham)", "Unmessageable" with "Unmonitored or Infrastructure" description). UserValidationSchema gains optional nodeId / hardwareModel display fields.
This commit is contained in:
@@ -7,6 +7,7 @@ import type { MeshClient } from "../../../core/client/MeshClient.ts";
|
||||
import { type ReadonlySignal, toReadonly } from "../../../core/signals/createStore.ts";
|
||||
import { setChannel } from "../../channels/application/ChannelUseCases.ts";
|
||||
import { DeviceStatusEnum } from "../../device/domain/DeviceStatus.ts";
|
||||
import { setOwner } from "../../nodes/application/SetOwnerUseCase.ts";
|
||||
import {
|
||||
beginEditSettings,
|
||||
commitEditSettings,
|
||||
@@ -45,9 +46,12 @@ export class ConfigEditor {
|
||||
private readonly workingChannels = signal<ReadonlyMap<number, Protobuf.Channel.Channel>>(
|
||||
new Map(),
|
||||
);
|
||||
private readonly baselineOwner = signal<Protobuf.Mesh.User | undefined>(undefined);
|
||||
private readonly workingOwner = signal<Protobuf.Mesh.User | undefined>(undefined);
|
||||
private readonly _dirtyRadioSections = signal<readonly RadioConfigSection[]>([]);
|
||||
private readonly _dirtyModuleSections = signal<readonly ModuleConfigSection[]>([]);
|
||||
private readonly _dirtyChannels = signal<readonly number[]>([]);
|
||||
private readonly _isOwnerDirty = signal<boolean>(false);
|
||||
private readonly _isDirty = signal<boolean>(false);
|
||||
|
||||
public readonly radio: ReadonlySignal<RadioConfig> = toReadonly(this.workingRadio);
|
||||
@@ -63,6 +67,10 @@ export class ConfigEditor {
|
||||
public readonly dirtyChannels: ReadonlySignal<readonly number[]> = toReadonly(
|
||||
this._dirtyChannels,
|
||||
);
|
||||
public readonly owner: ReadonlySignal<Protobuf.Mesh.User | undefined> = toReadonly(
|
||||
this.workingOwner,
|
||||
);
|
||||
public readonly isOwnerDirty: ReadonlySignal<boolean> = toReadonly(this._isOwnerDirty);
|
||||
public readonly isDirty: ReadonlySignal<boolean> = toReadonly(this._isDirty);
|
||||
|
||||
constructor(client: MeshClient) {
|
||||
@@ -118,17 +126,38 @@ export class ConfigEditor {
|
||||
this.baselineRadio.value = {};
|
||||
this.baselineModules.value = {};
|
||||
this.baselineChannels.value = new Map();
|
||||
this.baselineOwner.value = undefined;
|
||||
this.workingRadio.value = {};
|
||||
this.workingModules.value = {};
|
||||
this.workingChannels.value = new Map();
|
||||
this.workingOwner.value = undefined;
|
||||
this._dirtyRadioSections.value = [];
|
||||
this._dirtyModuleSections.value = [];
|
||||
this._dirtyChannels.value = [];
|
||||
this._isOwnerDirty.value = false;
|
||||
this._isDirty.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the baseline owner (the device's current user). Web pulls this
|
||||
* from `useMyNode().user` after the NodeInfo packet arrives, so we accept
|
||||
* it through a setter rather than subscribing to a SDK signal.
|
||||
*/
|
||||
public setBaselineOwner(owner: Protobuf.Mesh.User | undefined): void {
|
||||
this.baselineOwner.value = owner;
|
||||
if (!this._isOwnerDirty.peek()) {
|
||||
this.workingOwner.value = owner;
|
||||
}
|
||||
this.recomputeDirty();
|
||||
}
|
||||
|
||||
public setOwner(owner: Protobuf.Mesh.User): void {
|
||||
this.workingOwner.value = owner;
|
||||
this.recomputeDirty();
|
||||
}
|
||||
|
||||
public setRadioSection<K extends RadioConfigSection & string>(
|
||||
key: K,
|
||||
value: RadioConfig[K],
|
||||
@@ -156,6 +185,7 @@ export class ConfigEditor {
|
||||
this.workingRadio.value = this.baselineRadio.peek();
|
||||
this.workingModules.value = this.baselineModules.peek();
|
||||
this.workingChannels.value = new Map(this.baselineChannels.peek());
|
||||
this.workingOwner.value = this.baselineOwner.peek();
|
||||
this.recomputeDirty();
|
||||
}
|
||||
|
||||
@@ -196,15 +226,25 @@ export class ConfigEditor {
|
||||
if (Result.isError(result)) return Result.err(result.error);
|
||||
}
|
||||
|
||||
if (this._isOwnerDirty.peek()) {
|
||||
const owner = this.workingOwner.peek();
|
||||
if (owner) {
|
||||
const result = await setOwner(this.client, owner);
|
||||
if (Result.isError(result)) return Result.err(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
const commitResult = await commitEditSettings(this.client);
|
||||
if (Result.isError(commitResult)) return Result.err(commitResult.error);
|
||||
|
||||
this.baselineRadio.value = this.workingRadio.peek();
|
||||
this.baselineModules.value = this.workingModules.peek();
|
||||
this.baselineChannels.value = new Map(this.workingChannels.peek());
|
||||
this.baselineOwner.value = this.workingOwner.peek();
|
||||
this._dirtyRadioSections.value = [];
|
||||
this._dirtyModuleSections.value = [];
|
||||
this._dirtyChannels.value = [];
|
||||
this._isOwnerDirty.value = false;
|
||||
this._isDirty.value = false;
|
||||
|
||||
return Result.ok(undefined);
|
||||
@@ -248,11 +288,14 @@ export class ConfigEditor {
|
||||
}
|
||||
}
|
||||
|
||||
const ownerDirty = !shallowEqual(this.baselineOwner.peek(), this.workingOwner.peek());
|
||||
|
||||
this._dirtyRadioSections.value = radioDirty;
|
||||
this._dirtyModuleSections.value = moduleDirty;
|
||||
this._dirtyChannels.value = channelDirty;
|
||||
this._isOwnerDirty.value = ownerDirty;
|
||||
this._isDirty.value =
|
||||
radioDirty.length > 0 || moduleDirty.length > 0 || channelDirty.length > 0;
|
||||
radioDirty.length > 0 || moduleDirty.length > 0 || channelDirty.length > 0 || ownerDirty;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -528,7 +528,7 @@
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"title": "User Settings",
|
||||
"title": "User",
|
||||
"description": "Configure your device name and identity settings",
|
||||
"longName": {
|
||||
"label": "Long Name",
|
||||
@@ -548,11 +548,23 @@
|
||||
},
|
||||
"isUnmessageable": {
|
||||
"label": "Unmessageable",
|
||||
"description": "Used to identify unmonitored or infrastructure nodes so that messaging is not available to nodes that will never respond."
|
||||
"description": "Unmonitored or Infrastructure"
|
||||
},
|
||||
"isLicensed": {
|
||||
"label": "Licensed amateur radio (HAM)",
|
||||
"description": "Enable if you are a licensed amateur radio operator, enabling this option disables encryption and is not compatible with the default Meshtastic network."
|
||||
"label": "Licensed amateur radio (Ham)",
|
||||
"description": "Enabling this option disables encryption and is not compatible with the default Meshtastic network."
|
||||
},
|
||||
"userConfig": {
|
||||
"label": "User Config",
|
||||
"description": "Identity and licensing"
|
||||
},
|
||||
"nodeId": {
|
||||
"label": "Node ID",
|
||||
"description": "Read-only — assigned by the device"
|
||||
},
|
||||
"hardwareModel": {
|
||||
"label": "Hardware model",
|
||||
"description": "Read-only — reported by the device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
} from "@components/UI/Select.tsx";
|
||||
import { Switch } from "@components/UI/Switch.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useConfigEditor } from "@meshtastic/sdk-react";
|
||||
import { toByteArray } from "base64-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -33,7 +33,8 @@ export interface ImportDialogProps {
|
||||
}
|
||||
|
||||
export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
const { setChange, channels, config } = useDevice();
|
||||
const { config } = useDevice();
|
||||
const editor = useConfigEditor();
|
||||
const { t } = useTranslation("dialog");
|
||||
const [importDialogInput, setImportDialogInput] = useState<string>("");
|
||||
const [channelSet, setChannelSet] = useState<Protobuf.AppOnly.ChannelSet>();
|
||||
@@ -77,6 +78,7 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
}, [importDialogInput, t]);
|
||||
|
||||
const apply = () => {
|
||||
if (!editor) return;
|
||||
channelSet?.settings.forEach((ch: Protobuf.Channel.ChannelSettings, index: number) => {
|
||||
if (importIndex[index] === -1) {
|
||||
return;
|
||||
@@ -91,24 +93,15 @@ export const ImportDialog = ({ open, onOpenChange }: ImportDialogProps) => {
|
||||
settings: ch,
|
||||
});
|
||||
|
||||
if (!deepCompareConfig(channels.get(importIndex[index] ?? 0), payload, true)) {
|
||||
setChange(
|
||||
{ type: "channel", index: importIndex[index] ?? 0 },
|
||||
payload,
|
||||
channels.get(importIndex[index] ?? 0),
|
||||
);
|
||||
}
|
||||
editor.setChannel(payload);
|
||||
});
|
||||
|
||||
if (channelSet?.loraConfig && updateConfig) {
|
||||
const payload = {
|
||||
...config.lora,
|
||||
...channelSet.loraConfig,
|
||||
};
|
||||
|
||||
if (!deepCompareConfig(config.lora, payload, true)) {
|
||||
setChange({ type: "config", variant: "lora" }, payload, config.lora);
|
||||
}
|
||||
} as Protobuf.Config.Config_LoRaConfig;
|
||||
editor.setRadioSection("lora", payload);
|
||||
}
|
||||
// Reset state after import
|
||||
setImportDialogInput("");
|
||||
|
||||
@@ -4,8 +4,8 @@ import { PkiRegenerateDialog } from "@components/Dialog/PkiRegenerateDialog.tsx"
|
||||
import { createZodResolver } from "@components/Form/createZodResolver.ts";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { deepCompareConfig } from "@core/utils/deepCompareConfig.ts";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import { fromByteArray, toByteArray } from "base64-js";
|
||||
import cryptoRandomString from "crypto-random-string";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -17,8 +17,20 @@ export interface SettingsPanelProps {
|
||||
channel: Protobuf.Channel.Channel;
|
||||
}
|
||||
|
||||
const EMPTY_CHANNELS_SIGNAL = {
|
||||
value: new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<
|
||||
number,
|
||||
Protobuf.Channel.Channel
|
||||
>,
|
||||
peek: () =>
|
||||
new Map<number, Protobuf.Channel.Channel>() as ReadonlyMap<number, Protobuf.Channel.Channel>,
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
const { config, setChange, getChange, removeChange } = useDevice();
|
||||
const { config } = useDevice();
|
||||
const editor = useConfigEditor();
|
||||
const editorChannels = useSignal(editor?.channels ?? EMPTY_CHANNELS_SIGNAL);
|
||||
const { t } = useTranslation(["channels", "ui", "dialog"]);
|
||||
|
||||
const defaultConfig = channel;
|
||||
@@ -39,10 +51,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
},
|
||||
};
|
||||
|
||||
const workingChannel = getChange({
|
||||
type: "channel",
|
||||
index: channel.index,
|
||||
}) as Protobuf.Channel.Channel | undefined;
|
||||
const workingChannel = editorChannels.get(channel.index);
|
||||
const effectiveConfig = workingChannel ?? channel;
|
||||
const formValues = {
|
||||
...effectiveConfig,
|
||||
@@ -95,7 +104,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
}, [effectiveByteCount, trigger]);
|
||||
|
||||
const onSubmit = (data: ChannelValidation) => {
|
||||
if (!formState.isReady) {
|
||||
if (!formState.isReady || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,12 +120,7 @@ export const Channel = ({ onFormInit, channel }: SettingsPanelProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
if (deepCompareConfig(channel, payload, true)) {
|
||||
removeChange({ type: "channel", index: channel.index });
|
||||
return;
|
||||
}
|
||||
|
||||
setChange({ type: "channel", index: channel.index }, payload, channel);
|
||||
editor.setChannel(payload);
|
||||
};
|
||||
|
||||
const preSharedKeyRegenerate = async () => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Channel } from "@app/components/PageComponents/Channels/Channel";
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
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 { Protobuf } from "@meshtastic/sdk";
|
||||
import { useChannels, useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import i18next from "i18next";
|
||||
import { QrCodeIcon, UploadIcon } from "lucide-react";
|
||||
import { Suspense, useMemo } from "react";
|
||||
@@ -24,14 +27,34 @@ export const getChannelName = (channel: { index: number; settings?: { name?: str
|
||||
});
|
||||
};
|
||||
|
||||
const EMPTY_DIRTY_CHANNELS_SIGNAL = {
|
||||
value: [] as readonly number[],
|
||||
peek: () => [] as readonly number[],
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
export const Channels = ({ onFormInit }: ConfigProps) => {
|
||||
const { channels, hasChannelChange, setDialogOpen } = useDevice();
|
||||
const { setDialogOpen } = useDevice();
|
||||
const editor = useConfigEditor();
|
||||
const channels = useChannels();
|
||||
const dirtyChannels = useSignal(editor?.dirtyChannels ?? EMPTY_DIRTY_CHANNELS_SIGNAL);
|
||||
const { t } = useTranslation("channels");
|
||||
|
||||
const allChannels = Array.from(channels.values());
|
||||
const allChannels = useMemo(
|
||||
() =>
|
||||
channels.map((c) =>
|
||||
create(Protobuf.Channel.ChannelSchema, {
|
||||
index: c.index,
|
||||
role: c.role,
|
||||
settings: c.settings,
|
||||
}),
|
||||
),
|
||||
[channels],
|
||||
);
|
||||
const flags = useMemo(
|
||||
() => new Map(allChannels.map((channel) => [channel.index, hasChannelChange(channel.index)])),
|
||||
[allChannels, hasChannelChange],
|
||||
() =>
|
||||
new Map(allChannels.map((channel) => [channel.index, dirtyChannels.includes(channel.index)])),
|
||||
[allChannels, dirtyChannels],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,72 +2,104 @@ import { type UserValidation, UserValidationSchema } from "@app/validation/confi
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import { DynamicForm, type DynamicFormFormInit } from "@components/Form/DynamicForm.tsx";
|
||||
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useDevice } from "@core/stores";
|
||||
import { Protobuf } from "@meshtastic/sdk";
|
||||
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface UserConfigProps {
|
||||
onFormInit: DynamicFormFormInit<UserValidation>;
|
||||
}
|
||||
|
||||
const EMPTY_OWNER_SIGNAL = {
|
||||
value: undefined as Protobuf.Mesh.User | undefined,
|
||||
peek: () => undefined as Protobuf.Mesh.User | undefined,
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
export const User = ({ onFormInit }: UserConfigProps) => {
|
||||
const { getChange, connection } = useDevice();
|
||||
const { t } = useTranslation("config");
|
||||
|
||||
const editor = useConfigEditor();
|
||||
const myNode = useMyNodeAsProto();
|
||||
const defaultUser = myNode?.user ?? {
|
||||
id: "",
|
||||
longName: "",
|
||||
shortName: "",
|
||||
isLicensed: false,
|
||||
};
|
||||
const owner = useSignal(editor?.owner ?? EMPTY_OWNER_SIGNAL);
|
||||
|
||||
// Get working copy from change registry
|
||||
const workingUser = getChange({ type: "user" }) as Protobuf.Mesh.User | undefined;
|
||||
// Seed the editor's baseline when the device's user info first arrives.
|
||||
// setBaselineOwner is a no-op when the working copy is dirty, so user
|
||||
// edits in flight are preserved across re-renders.
|
||||
useEffect(() => {
|
||||
if (editor && myNode?.user) {
|
||||
editor.setBaselineOwner(myNode.user);
|
||||
}
|
||||
}, [editor, myNode?.user]);
|
||||
|
||||
const effectiveUser = workingUser ?? defaultUser;
|
||||
const baselineUser =
|
||||
myNode?.user ??
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
id: "",
|
||||
longName: "",
|
||||
shortName: "",
|
||||
isLicensed: false,
|
||||
});
|
||||
const effectiveUser = owner ?? baselineUser;
|
||||
|
||||
const onSubmit = (data: UserValidation) => {
|
||||
connection?.setOwner(
|
||||
create(Protobuf.Mesh.UserSchema, {
|
||||
...data,
|
||||
}),
|
||||
);
|
||||
if (!editor) return;
|
||||
// Preserve fields the form doesn't edit (id, hwModel, role, publicKey, etc.).
|
||||
const merged = create(Protobuf.Mesh.UserSchema, {
|
||||
...baselineUser,
|
||||
longName: data.longName,
|
||||
shortName: data.shortName,
|
||||
isUnmessagable: data.isUnmessageable,
|
||||
isLicensed: data.isLicensed,
|
||||
});
|
||||
editor.setOwner(merged);
|
||||
};
|
||||
|
||||
const hwName = Protobuf.Mesh.HardwareModel[baselineUser.hwModel] ?? String(baselineUser.hwModel);
|
||||
|
||||
return (
|
||||
<DynamicForm<UserValidation>
|
||||
onSubmit={onSubmit}
|
||||
onFormInit={onFormInit}
|
||||
validationSchema={UserValidationSchema}
|
||||
defaultValues={{
|
||||
longName: defaultUser.longName,
|
||||
shortName: defaultUser.shortName,
|
||||
isLicensed: defaultUser.isLicensed,
|
||||
isUnmessageable: false,
|
||||
nodeId: baselineUser.id,
|
||||
hardwareModel: hwName,
|
||||
longName: baselineUser.longName,
|
||||
shortName: baselineUser.shortName,
|
||||
isLicensed: baselineUser.isLicensed,
|
||||
isUnmessageable: baselineUser.isUnmessagable ?? false,
|
||||
}}
|
||||
values={{
|
||||
nodeId: effectiveUser.id,
|
||||
hardwareModel: hwName,
|
||||
longName: effectiveUser.longName,
|
||||
shortName: effectiveUser.shortName,
|
||||
isLicensed: effectiveUser.isLicensed,
|
||||
isUnmessageable: false,
|
||||
isUnmessageable: effectiveUser.isUnmessagable ?? false,
|
||||
}}
|
||||
fieldGroups={[
|
||||
{
|
||||
label: t("user.title"),
|
||||
description: t("user.description"),
|
||||
label: t("user.userConfig.label"),
|
||||
description: t("user.userConfig.description"),
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
name: "nodeId",
|
||||
label: t("user.nodeId.label"),
|
||||
description: t("user.nodeId.description"),
|
||||
disabled: true,
|
||||
properties: {
|
||||
showCopyButton: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "longName",
|
||||
label: t("user.longName.label"),
|
||||
description: t("user.longName.description"),
|
||||
properties: {
|
||||
fieldLength: {
|
||||
min: 1,
|
||||
max: 40,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
fieldLength: { min: 1, max: 39, showCharacterCount: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -76,13 +108,16 @@ export const User = ({ onFormInit }: UserConfigProps) => {
|
||||
label: t("user.shortName.label"),
|
||||
description: t("user.shortName.description"),
|
||||
properties: {
|
||||
fieldLength: {
|
||||
min: 2,
|
||||
max: 4,
|
||||
showCharacterCount: true,
|
||||
},
|
||||
fieldLength: { min: 1, max: 4, showCharacterCount: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
name: "hardwareModel",
|
||||
label: t("user.hardwareModel.label"),
|
||||
description: t("user.hardwareModel.description"),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
name: "isUnmessageable",
|
||||
|
||||
@@ -2,13 +2,16 @@ import { t } from "i18next";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const UserValidationSchema = z.object({
|
||||
// Read-only display fields (preserved on submit).
|
||||
nodeId: z.string().optional(),
|
||||
hardwareModel: z.string().optional(),
|
||||
longName: z
|
||||
.string()
|
||||
.min(1, t("user.longName.validation.min"))
|
||||
.max(40, t("user.longName.validation.max")),
|
||||
shortName: z
|
||||
.string()
|
||||
.min(2, t("user.shortName.validation.min"))
|
||||
.min(1, t("user.shortName.validation.min"))
|
||||
.max(4, t("user.shortName.validation.max")),
|
||||
isUnmessageable: z.boolean().default(false),
|
||||
isLicensed: z.boolean().default(false),
|
||||
|
||||
Reference in New Issue
Block a user