feat: add validation to widget edit modal inputs (#879)
* feat: add validation to widget edit modal inputs * chore: remove unused console.log statements
This commit is contained in:
@@ -11,10 +11,11 @@ export const zodErrorMap = <
|
|||||||
) => {
|
) => {
|
||||||
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
return (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||||
const error = handleZodError(issue, ctx);
|
const error = handleZodError(issue, ctx);
|
||||||
if ("message" in error && error.message)
|
if ("message" in error && error.message) {
|
||||||
return {
|
return {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
message: t(error.key ? `common.zod.${error.key}` : "common.zod.errors.default", error.params ?? {}),
|
message: t(error.key ? `common.zod.${error.key}` : "common.zod.errors.default", error.params ?? {}),
|
||||||
};
|
};
|
||||||
@@ -103,6 +104,7 @@ const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
|||||||
params: {},
|
params: {},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (issue.code === ZodIssueCode.invalid_string) {
|
if (issue.code === ZodIssueCode.invalid_string) {
|
||||||
return handleStringError(issue);
|
return handleStringError(issue);
|
||||||
}
|
}
|
||||||
@@ -112,6 +114,12 @@ const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
|||||||
if (issue.code === ZodIssueCode.too_big) {
|
if (issue.code === ZodIssueCode.too_big) {
|
||||||
return handleTooBigError(issue);
|
return handleTooBigError(issue);
|
||||||
}
|
}
|
||||||
|
if (issue.code === ZodIssueCode.invalid_type && ctx.data === "") {
|
||||||
|
return {
|
||||||
|
key: "errors.required",
|
||||||
|
params: {},
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
|
if (issue.code === ZodIssueCode.custom && issue.params?.i18n) {
|
||||||
const { i18n } = issue.params as CustomErrorParams;
|
const { i18n } = issue.params as CustomErrorParams;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,9 +14,18 @@ const searchCityInput = z.object({
|
|||||||
query: z.string(),
|
query: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const searchCityOutput = z.object({
|
const searchCityOutput = z
|
||||||
results: z.array(citySchema),
|
.object({
|
||||||
});
|
results: z.array(citySchema),
|
||||||
|
})
|
||||||
|
.or(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
generationtime_ms: z.number(),
|
||||||
|
})
|
||||||
|
.refine((data) => Object.keys(data).length === 1, { message: "Invalid response" })
|
||||||
|
.transform(() => ({ results: [] })), // We fallback to empty array if no results
|
||||||
|
);
|
||||||
|
|
||||||
export const locationSchemas = {
|
export const locationSchemas = {
|
||||||
searchCity: {
|
searchCity: {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ChangeEvent } from "react";
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
@@ -33,23 +32,18 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
|||||||
const tLocation = useScopedI18n("widget.common.location");
|
const tLocation = useScopedI18n("widget.common.location");
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
const { openModal } = useModalAction(LocationSearchModal);
|
const { openModal } = useModalAction(LocationSearchModal);
|
||||||
const value = form.values.options[property] as OptionLocation;
|
const inputProps = form.getInputProps(`options.${property}`);
|
||||||
|
const value = inputProps.value as OptionLocation;
|
||||||
const selectionEnabled = value.name.length > 1;
|
const selectionEnabled = value.name.length > 1;
|
||||||
|
|
||||||
const handleChange = form.getInputProps(`options.${property}`).onChange as LocationOnChange;
|
const handleChange = inputProps.onChange as LocationOnChange;
|
||||||
const unknownLocation = tLocation("unknownLocation");
|
const unknownLocation = tLocation("unknownLocation");
|
||||||
|
|
||||||
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
||||||
handleChange({
|
|
||||||
name: event.currentTarget.value,
|
|
||||||
longitude: "",
|
|
||||||
latitude: "",
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onLocationSelect = useCallback(
|
const onLocationSelect = useCallback(
|
||||||
(location: OptionLocation) => {
|
(location: OptionLocation) => {
|
||||||
handleChange(location);
|
handleChange(location);
|
||||||
|
form.clearFieldError(`options.${property}.latitude`);
|
||||||
|
form.clearFieldError(`options.${property}.longitude`);
|
||||||
},
|
},
|
||||||
[handleChange],
|
[handleChange],
|
||||||
);
|
);
|
||||||
@@ -63,35 +57,21 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
|||||||
});
|
});
|
||||||
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
|
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
|
||||||
|
|
||||||
const onLatitudeChange = useCallback(
|
form.watch(`options.${property}.latitude`, ({ value }) => {
|
||||||
(inputValue: number | string) => {
|
if (typeof value !== "number") return;
|
||||||
if (typeof inputValue !== "number") return;
|
form.setFieldValue(`options.${property}.name`, unknownLocation);
|
||||||
handleChange({
|
});
|
||||||
...value,
|
|
||||||
name: unknownLocation,
|
|
||||||
latitude: inputValue,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[value],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onLongitudeChange = useCallback(
|
form.watch(`options.${property}.longitude`, ({ value }) => {
|
||||||
(inputValue: number | string) => {
|
if (typeof value !== "number") return;
|
||||||
if (typeof inputValue !== "number") return;
|
form.setFieldValue(`options.${property}.name`, unknownLocation);
|
||||||
handleChange({
|
});
|
||||||
...value,
|
|
||||||
name: unknownLocation,
|
|
||||||
longitude: inputValue,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[value],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fieldset legend={t("label")}>
|
<Fieldset legend={t("label")}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Group wrap="nowrap" align="end">
|
<Group wrap="nowrap" align="end">
|
||||||
<TextInput w="100%" label={tLocation("query")} value={value.name} onChange={onQueryChange} />
|
<TextInput w="100%" label={tLocation("query")} {...form.getInputProps(`options.${property}.name`)} />
|
||||||
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
|
<Tooltip hidden={selectionEnabled} label={tLocation("disabledTooltip")}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
@@ -108,18 +88,16 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
|||||||
|
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={value.latitude}
|
|
||||||
onChange={onLatitudeChange}
|
|
||||||
decimalScale={5}
|
decimalScale={5}
|
||||||
label={tLocation("latitude")}
|
label={tLocation("latitude")}
|
||||||
hideControls
|
hideControls
|
||||||
|
{...form.getInputProps(`options.${property}.latitude`)}
|
||||||
/>
|
/>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
value={value.longitude}
|
|
||||||
onChange={onLongitudeChange}
|
|
||||||
decimalScale={5}
|
decimalScale={5}
|
||||||
label={tLocation("longitude")}
|
label={tLocation("longitude")}
|
||||||
hideControls
|
hideControls
|
||||||
|
{...form.getInputProps(`options.${property}.longitude`)}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button, Group, Stack } from "@mantine/core";
|
import { Button, Group, Stack } from "@mantine/core";
|
||||||
|
|
||||||
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
|
import { zodResolver } from "@homarr/form";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
import { zodErrorMap } from "@homarr/validation/form";
|
||||||
|
|
||||||
import { widgetImports } from "..";
|
import { widgetImports } from "..";
|
||||||
import { getInputForType } from "../_inputs";
|
import { getInputForType } from "../_inputs";
|
||||||
@@ -33,8 +37,34 @@ interface ModalProps<TSort extends WidgetKind> {
|
|||||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||||
|
|
||||||
|
// Translate the error messages
|
||||||
|
z.setErrorMap(zodErrorMap(t));
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
|
mode: "controlled",
|
||||||
initialValues: innerProps.value,
|
initialValues: innerProps.value,
|
||||||
|
validate: zodResolver(
|
||||||
|
z.object({
|
||||||
|
options: z.object(
|
||||||
|
objectEntries(widgetImports[innerProps.kind].definition.options).reduce(
|
||||||
|
(acc, [key, value]: [string, { validate?: z.ZodType<unknown> }]) => {
|
||||||
|
if (value.validate) {
|
||||||
|
acc[key] = value.validate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, z.ZodType<unknown>>,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
integrationIds: z.array(z.string()),
|
||||||
|
advancedOptions: z.object({
|
||||||
|
customCssClasses: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
validateInputOnBlur: true,
|
||||||
|
validateInputOnChange: true,
|
||||||
});
|
});
|
||||||
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
|
const { openModal } = useModalAction(WidgetAdvancedOptionsModal);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { objectEntries } from "@homarr/common";
|
import { objectEntries } from "@homarr/common";
|
||||||
import type { WidgetKind } from "@homarr/definitions";
|
import type { WidgetKind } from "@homarr/definitions";
|
||||||
import type { z, ZodType } from "@homarr/validation";
|
import type { ZodType } from "@homarr/validation";
|
||||||
|
import { z } from "@homarr/validation";
|
||||||
|
|
||||||
import { widgetImports } from ".";
|
import { widgetImports } from ".";
|
||||||
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
||||||
@@ -90,6 +91,11 @@ const optionsFactory = {
|
|||||||
longitude: 0,
|
longitude: 0,
|
||||||
},
|
},
|
||||||
withDescription: input?.withDescription ?? false,
|
withDescription: input?.withDescription ?? false,
|
||||||
|
validate: z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
latitude: z.number(),
|
||||||
|
longitude: z.number(),
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
||||||
type: "multiText" as const,
|
type: "multiText" as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user