Skip to content

Commit

Permalink
feat: add validation to widget edit modal inputs (#879)
Browse files Browse the repository at this point in the history
* feat: add validation to widget edit modal inputs

* chore: remove unused console.log statements
  • Loading branch information
Meierschlumpf authored Jul 28, 2024
1 parent 851f5e4 commit 9cb6200
Show file tree
Hide file tree
Showing 5 changed files with 74 additions and 43 deletions.
10 changes: 9 additions & 1 deletion packages/validation/src/form/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {}),
};
Expand Down Expand Up @@ -103,6 +104,7 @@ const handleZodError = (issue: z.ZodIssueOptionalMessage, ctx: ErrorMapCtx) => {
params: {},
} as const;
}

if (issue.code === ZodIssueCode.invalid_string) {
return handleStringError(issue);
}
Expand All @@ -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 {
Expand Down
15 changes: 12 additions & 3 deletions packages/validation/src/location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
54 changes: 16 additions & 38 deletions packages/widgets/src/_inputs/widget-location-input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
"use client";

import type { ChangeEvent } from "react";
import { useCallback } from "react";
import {
ActionIcon,
Expand Down Expand Up @@ -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],
);
Expand All @@ -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
Expand All @@ -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>
Expand Down
30 changes: 30 additions & 0 deletions packages/widgets/src/modals/widget-edit-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);

Expand Down
8 changes: 7 additions & 1 deletion packages/widgets/src/options.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 9cb6200

Please sign in to comment.