mirror of
https://github.com/ajnart/homarr.git
synced 2026-01-29 10:49:14 +01:00
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) => {
|
||||
const error = handleZodError(issue, ctx);
|
||||
if ("message" in error && error.message)
|
||||
if ("message" in error && error.message) {
|
||||
return {
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
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: {},
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (issue.code === ZodIssueCode.invalid_string) {
|
||||
return handleStringError(issue);
|
||||
}
|
||||
@@ -112,6 +114,12 @@ const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
|
||||
if (issue.code === ZodIssueCode.too_big) {
|
||||
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) {
|
||||
const { i18n } = issue.params as CustomErrorParams;
|
||||
return {
|
||||
|
||||
@@ -14,9 +14,18 @@ const searchCityInput = z.object({
|
||||
query: z.string(),
|
||||
});
|
||||
|
||||
const searchCityOutput = z.object({
|
||||
results: z.array(citySchema),
|
||||
});
|
||||
const searchCityOutput = z
|
||||
.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 = {
|
||||
searchCity: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -33,23 +32,18 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
||||
const tLocation = useScopedI18n("widget.common.location");
|
||||
const form = useFormContext();
|
||||
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 handleChange = form.getInputProps(`options.${property}`).onChange as LocationOnChange;
|
||||
const handleChange = inputProps.onChange as LocationOnChange;
|
||||
const unknownLocation = tLocation("unknownLocation");
|
||||
|
||||
const onQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
handleChange({
|
||||
name: event.currentTarget.value,
|
||||
longitude: "",
|
||||
latitude: "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const onLocationSelect = useCallback(
|
||||
(location: OptionLocation) => {
|
||||
handleChange(location);
|
||||
form.clearFieldError(`options.${property}.latitude`);
|
||||
form.clearFieldError(`options.${property}.longitude`);
|
||||
},
|
||||
[handleChange],
|
||||
);
|
||||
@@ -63,35 +57,21 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
||||
});
|
||||
}, [selectionEnabled, value.name, onLocationSelect, openModal]);
|
||||
|
||||
const onLatitudeChange = useCallback(
|
||||
(inputValue: number | string) => {
|
||||
if (typeof inputValue !== "number") return;
|
||||
handleChange({
|
||||
...value,
|
||||
name: unknownLocation,
|
||||
latitude: inputValue,
|
||||
});
|
||||
},
|
||||
[value],
|
||||
);
|
||||
form.watch(`options.${property}.latitude`, ({ value }) => {
|
||||
if (typeof value !== "number") return;
|
||||
form.setFieldValue(`options.${property}.name`, unknownLocation);
|
||||
});
|
||||
|
||||
const onLongitudeChange = useCallback(
|
||||
(inputValue: number | string) => {
|
||||
if (typeof inputValue !== "number") return;
|
||||
handleChange({
|
||||
...value,
|
||||
name: unknownLocation,
|
||||
longitude: inputValue,
|
||||
});
|
||||
},
|
||||
[value],
|
||||
);
|
||||
form.watch(`options.${property}.longitude`, ({ value }) => {
|
||||
if (typeof value !== "number") return;
|
||||
form.setFieldValue(`options.${property}.name`, unknownLocation);
|
||||
});
|
||||
|
||||
return (
|
||||
<Fieldset legend={t("label")}>
|
||||
<Stack gap="xs">
|
||||
<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")}>
|
||||
<div>
|
||||
<Button
|
||||
@@ -108,18 +88,16 @@ export const WidgetLocationInput = ({ property, kind }: CommonWidgetInputProps<"
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
value={value.latitude}
|
||||
onChange={onLatitudeChange}
|
||||
decimalScale={5}
|
||||
label={tLocation("latitude")}
|
||||
hideControls
|
||||
{...form.getInputProps(`options.${property}.latitude`)}
|
||||
/>
|
||||
<NumberInput
|
||||
value={value.longitude}
|
||||
onChange={onLongitudeChange}
|
||||
decimalScale={5}
|
||||
label={tLocation("longitude")}
|
||||
hideControls
|
||||
{...form.getInputProps(`options.${property}.longitude`)}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Group, Stack } from "@mantine/core";
|
||||
|
||||
import { objectEntries } from "@homarr/common";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import { zodResolver } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { z } from "@homarr/validation";
|
||||
import { zodErrorMap } from "@homarr/validation/form";
|
||||
|
||||
import { widgetImports } from "..";
|
||||
import { getInputForType } from "../_inputs";
|
||||
@@ -33,8 +37,34 @@ interface ModalProps<TSort extends WidgetKind> {
|
||||
export const WidgetEditModal = createModal<ModalProps<WidgetKind>>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const [advancedOptions, setAdvancedOptions] = useState<BoardItemAdvancedOptions>(innerProps.value.advancedOptions);
|
||||
|
||||
// Translate the error messages
|
||||
z.setErrorMap(zodErrorMap(t));
|
||||
const form = useForm({
|
||||
mode: "controlled",
|
||||
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);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { objectEntries } from "@homarr/common";
|
||||
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 type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input";
|
||||
@@ -90,6 +91,11 @@ const optionsFactory = {
|
||||
longitude: 0,
|
||||
},
|
||||
withDescription: input?.withDescription ?? false,
|
||||
validate: z.object({
|
||||
name: z.string().min(1),
|
||||
latitude: z.number(),
|
||||
longitude: z.number(),
|
||||
}),
|
||||
}),
|
||||
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
||||
type: "multiText" as const,
|
||||
|
||||
Reference in New Issue
Block a user