From 80d2d485b862f6c95fa1eb81ff96cb45d9b147b1 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sat, 13 Apr 2024 11:34:55 +0200 Subject: [PATCH] feat: add weather widget (#286) * feat: add nestjs replacement, remove nestjs * feat: add weather widget * fix: lock issue * fix: format issue * fix: deepsource issues * fix: change timezone to auto --- .../src/app/[locale]/widgets/[kind]/page.tsx | 3 +- apps/nextjs/src/env.mjs | 4 + packages/api/src/root.ts | 4 + packages/api/src/router/location.ts | 18 ++ packages/api/src/router/widgets/index.ts | 6 + packages/api/src/router/widgets/weather.ts | 15 + packages/translation/src/lang/en.ts | 53 +++- packages/validation/src/index.ts | 4 + packages/validation/src/location.ts | 26 ++ packages/validation/src/widgets/index.ts | 5 + packages/validation/src/widgets/weather.ts | 24 ++ packages/widgets/src/_inputs/index.ts | 3 +- .../src/_inputs/widget-location-input.tsx | 280 ++++++++++++++++++ .../src/_inputs/widget-slider-input.tsx | 3 +- .../widgets/src/modals/widget-edit-modal.tsx | 4 +- packages/widgets/src/options.ts | 4 +- packages/widgets/src/weather/component.tsx | 206 ++++++++++++- packages/widgets/src/weather/icon.tsx | 81 +++++ packages/widgets/src/weather/index.ts | 32 +- 19 files changed, 762 insertions(+), 13 deletions(-) create mode 100644 packages/api/src/router/location.ts create mode 100644 packages/api/src/router/widgets/index.ts create mode 100644 packages/api/src/router/widgets/weather.ts create mode 100644 packages/validation/src/location.ts create mode 100644 packages/validation/src/widgets/index.ts create mode 100644 packages/validation/src/widgets/weather.ts create mode 100644 packages/widgets/src/_inputs/widget-location-input.tsx create mode 100644 packages/widgets/src/weather/icon.tsx diff --git a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx index 14ea3693a..d00dbc8fe 100644 --- a/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx +++ b/apps/nextjs/src/app/[locale]/widgets/[kind]/page.tsx @@ -5,6 +5,7 @@ import type { WidgetKind } from "@homarr/definitions"; import { Center } from "@homarr/ui"; import { widgetImports } from "@homarr/widgets"; +import { env } from "~/env.mjs"; import { WidgetPreviewPageContent } from "./_content"; interface Props { @@ -12,7 +13,7 @@ interface Props { } export default async function WidgetPreview(props: Props) { - if (!(props.params.kind in widgetImports)) { + if (!(props.params.kind in widgetImports || env.NODE_ENV !== "development")) { notFound(); } diff --git a/apps/nextjs/src/env.mjs b/apps/nextjs/src/env.mjs index 3663323fa..14e02551c 100644 --- a/apps/nextjs/src/env.mjs +++ b/apps/nextjs/src/env.mjs @@ -12,6 +12,9 @@ export const env = createEnv({ .optional() .transform((url) => (url ? `https://${url}` : undefined)), PORT: z.coerce.number().default(3000), + NODE_ENV: z + .enum(["development", "production", "test"]) + .default("development"), }, /** * Specify your server-side environment variables schema here. This way you can ensure the app isn't @@ -49,6 +52,7 @@ export const env = createEnv({ DB_NAME: process.env.DB_NAME, DB_PORT: process.env.DB_PORT, DB_DRIVER: process.env.DB_DRIVER, + NODE_ENV: process.env.NODE_ENV, // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, }, skipValidation: diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index 30ea84eb0..a2ac124d0 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -1,8 +1,10 @@ import { appRouter as innerAppRouter } from "./router/app"; import { boardRouter } from "./router/board"; import { integrationRouter } from "./router/integration"; +import { locationRouter } from "./router/location"; import { logRouter } from "./router/log"; import { userRouter } from "./router/user"; +import { widgetRouter } from "./router/widgets"; import { createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ @@ -10,6 +12,8 @@ export const appRouter = createTRPCRouter({ integration: integrationRouter, board: boardRouter, app: innerAppRouter, + widget: widgetRouter, + location: locationRouter, log: logRouter, }); diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts new file mode 100644 index 000000000..faeb82e42 --- /dev/null +++ b/packages/api/src/router/location.ts @@ -0,0 +1,18 @@ +import type { z } from "@homarr/validation"; +import { validation } from "@homarr/validation"; + +import { createTRPCRouter, publicProcedure } from "../trpc"; + +export const locationRouter = createTRPCRouter({ + searchCity: publicProcedure + .input(validation.location.searchCity.input) + .output(validation.location.searchCity.output) + .query(async ({ input }) => { + const res = await fetch( + `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`, + ); + return (await res.json()) as z.infer< + typeof validation.location.searchCity.output + >; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts new file mode 100644 index 000000000..7b8595fdb --- /dev/null +++ b/packages/api/src/router/widgets/index.ts @@ -0,0 +1,6 @@ +import { createTRPCRouter } from "../../trpc"; +import { weatherRouter } from "./weather"; + +export const widgetRouter = createTRPCRouter({ + weather: weatherRouter, +}); diff --git a/packages/api/src/router/widgets/weather.ts b/packages/api/src/router/widgets/weather.ts new file mode 100644 index 000000000..a5440fd73 --- /dev/null +++ b/packages/api/src/router/widgets/weather.ts @@ -0,0 +1,15 @@ +import { validation } from "@homarr/validation"; + +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +export const weatherRouter = createTRPCRouter({ + atLocation: publicProcedure + .input(validation.widget.weather.atLocationInput) + .output(validation.widget.weather.atLocationOutput) + .query(async ({ input }) => { + const res = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min¤t_weather=true&timezone=auto`, + ); + return res.json(); + }), +}); diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 316192669..7766b1646 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -376,12 +376,63 @@ export default { description: "Displays the current weather information of a set location.", option: { + isFormatFahrenheit: { + label: "Temperature in Fahrenheit", + }, location: { - label: "Location", + label: "Weather location", }, showCity: { label: "Show city", }, + hasForecast: { + label: "Show forecast", + }, + forecastDayCount: { + label: "Amount of forecast days", + description: + "When the widget is not wide enough, less days are shown", + }, + }, + kind: { + clear: "Clear", + mainlyClear: "Mainly clear", + fog: "Fog", + drizzle: "Drizzle", + freezingDrizzle: "Freezing drizzle", + rain: "Rain", + freezingRain: "Freezing rain", + snowFall: "Snow fall", + snowGrains: "Snow grains", + rainShowers: "Rain showers", + snowShowers: "Snow showers", + thunderstorm: "Thunderstorm", + thunderstormWithHail: "Thunderstorm with hail", + unknown: "Unknown", + }, + }, + common: { + location: { + query: "City / Postal code", + latitude: "Latitude", + longitude: "Longitude", + disabledTooltip: "Please enter a city or postal code", + unknownLocation: "Unknown location", + search: "Search", + table: { + header: { + city: "City", + country: "Country", + coordinates: "Coordinates", + population: "Population", + }, + action: { + select: "Select {city}, {countryCode}", + }, + population: { + fallback: "Unknown", + }, + }, }, }, }, diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index ba1f7daec..4d72a8625 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -1,13 +1,17 @@ import { appSchemas } from "./app"; import { boardSchemas } from "./board"; import { integrationSchemas } from "./integration"; +import { locationSchemas } from "./location"; import { userSchemas } from "./user"; +import { widgetSchemas } from "./widgets"; export const validation = { user: userSchemas, integration: integrationSchemas, board: boardSchemas, app: appSchemas, + widget: widgetSchemas, + location: locationSchemas, }; export { createSectionSchema, sharedItemSchema } from "./shared"; diff --git a/packages/validation/src/location.ts b/packages/validation/src/location.ts new file mode 100644 index 000000000..2b9b12f2f --- /dev/null +++ b/packages/validation/src/location.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +const citySchema = z.object({ + id: z.number(), + name: z.string(), + country: z.string().optional(), + country_code: z.string().optional(), + latitude: z.number(), + longitude: z.number(), + population: z.number().optional(), +}); + +const searchCityInput = z.object({ + query: z.string(), +}); + +const searchCityOutput = z.object({ + results: z.array(citySchema), +}); + +export const locationSchemas = { + searchCity: { + input: searchCityInput, + output: searchCityOutput, + }, +}; diff --git a/packages/validation/src/widgets/index.ts b/packages/validation/src/widgets/index.ts new file mode 100644 index 000000000..bf007766d --- /dev/null +++ b/packages/validation/src/widgets/index.ts @@ -0,0 +1,5 @@ +import { weatherWidgetSchemas } from "./weather"; + +export const widgetSchemas = { + weather: weatherWidgetSchemas, +}; diff --git a/packages/validation/src/widgets/weather.ts b/packages/validation/src/widgets/weather.ts new file mode 100644 index 000000000..29b93e578 --- /dev/null +++ b/packages/validation/src/widgets/weather.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +export const atLocationInput = z.object({ + longitude: z.number(), + latitude: z.number(), +}); + +export const atLocationOutput = z.object({ + current_weather: z.object({ + weathercode: z.number(), + temperature: z.number(), + }), + daily: z.object({ + time: z.array(z.string()), + weathercode: z.array(z.number()), + temperature_2m_max: z.array(z.number()), + temperature_2m_min: z.array(z.number()), + }), +}); + +export const weatherWidgetSchemas = { + atLocationInput, + atLocationOutput, +}; diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts index 4da6a74bd..558872aee 100644 --- a/packages/widgets/src/_inputs/index.ts +++ b/packages/widgets/src/_inputs/index.ts @@ -1,5 +1,6 @@ import type { WidgetOptionType } from "../options"; import { WidgetAppInput } from "./widget-app-input"; +import { WidgetLocationInput } from "./widget-location-input"; import { WidgetMultiSelectInput } from "./widget-multiselect-input"; import { WidgetNumberInput } from "./widget-number-input"; import { WidgetSelectInput } from "./widget-select-input"; @@ -9,7 +10,7 @@ import { WidgetTextInput } from "./widget-text-input"; const mapping = { text: WidgetTextInput, - location: () => null, + location: WidgetLocationInput, multiSelect: WidgetMultiSelectInput, multiText: () => null, number: WidgetNumberInput, diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx new file mode 100644 index 000000000..61dd4961a --- /dev/null +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -0,0 +1,280 @@ +"use client"; + +import type { ChangeEvent } from "react"; +import { useCallback } from "react"; + +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { createModal, useModalAction } from "@homarr/modals"; +import { useScopedI18n } from "@homarr/translation/client"; +import { + ActionIcon, + Anchor, + Button, + Fieldset, + Group, + IconClick, + IconListSearch, + Loader, + NumberInput, + Stack, + Table, + Text, + TextInput, + Tooltip, +} from "@homarr/ui"; + +import type { OptionLocation } from "../options"; +import type { CommonWidgetInputProps } from "./common"; +import { useWidgetInputTranslation } from "./common"; +import { useFormContext } from "./form"; + +export const WidgetLocationInput = ({ + property, + kind, +}: CommonWidgetInputProps<"location">) => { + const t = useWidgetInputTranslation(kind, property); + const tLocation = useScopedI18n("widget.common.location"); + const form = useFormContext(); + const { openModal } = useModalAction(LocationSearchModal); + const value = form.values.options[property] as OptionLocation; + const selectionEnabled = value.name.length > 1; + + const handleChange = form.getInputProps(`options.${property}`) + .onChange as LocationOnChange; + const unknownLocation = tLocation("unknownLocation"); + + const onQueryChange = useCallback((event: ChangeEvent) => { + handleChange({ + name: event.currentTarget.value, + longitude: "", + latitude: "", + }); + }, []); + + const onLocationSelect = useCallback( + (location: OptionLocation) => { + handleChange(location); + }, + [handleChange], + ); + + const onSearch = useCallback(() => { + if (!selectionEnabled) return; + + openModal({ + query: value.name, + onLocationSelect, + }); + }, [selectionEnabled, value.name, onLocationSelect, openModal]); + + const onLatitudeChange = useCallback( + (inputValue: number | string) => { + if (typeof inputValue !== "number") return; + handleChange({ + ...value, + name: unknownLocation, + latitude: inputValue, + }); + }, + [value], + ); + + const onLongitudeChange = useCallback( + (inputValue: number | string) => { + if (typeof inputValue !== "number") return; + handleChange({ + ...value, + name: unknownLocation, + longitude: inputValue, + }); + }, + [value], + ); + + return ( +
+ + + + + + + + + + + +
+ ); +}; + +type LocationOnChange = ( + location: Pick & { + latitude: OptionLocation["latitude"] | ""; + longitude: OptionLocation["longitude"] | ""; + }, +) => void; + +interface LocationSearchInnerProps { + query: string; + onLocationSelect: (location: OptionLocation) => void; +} + +const LocationSearchModal = createModal( + ({ actions, innerProps }) => { + const t = useScopedI18n("widget.common.location.table"); + const tCommon = useScopedI18n("common"); + const { data, isPending, error } = clientApi.location.searchCity.useQuery({ + query: innerProps.query, + }); + + if (error) { + throw error; + } + + return ( + + + + + {t("header.city")} + + {t("header.country")} + + {t("header.coordinates")} + {t("header.population")} + + + + + {isPending && ( + + + + + + + + )} + {data?.results.map((city) => ( + + ))} + +
+ + + +
+ ); + }, +).withOptions({ + defaultTitle(t) { + return t("widget.common.location.search"); + }, + size: "xl", +}); + +interface LocationSearchTableRowProps { + city: RouterOutputs["location"]["searchCity"]["results"][number]; + onLocationSelect: (location: OptionLocation) => void; + closeModal: () => void; +} + +const LocationSelectTableRow = ({ + city, + onLocationSelect, + closeModal, +}: LocationSearchTableRowProps) => { + const t = useScopedI18n("widget.common.location.table"); + const onSelect = useCallback(() => { + onLocationSelect({ + name: city.name, + latitude: city.latitude, + longitude: city.longitude, + }); + closeModal(); + }, [city, onLocationSelect, closeModal]); + + const formatter = Intl.NumberFormat("en", { notation: "compact" }); + + return ( + + + {city.name} + + + {city.country} + + + + + {city.latitude}, {city.longitude} + + + + + {city.population ? ( + + {formatter.format(city.population)} + + ) : ( + {t("population.fallback")} + )} + + + + + + + + + + ); +}; diff --git a/packages/widgets/src/_inputs/widget-slider-input.tsx b/packages/widgets/src/_inputs/widget-slider-input.tsx index 76c725e80..767f84f02 100644 --- a/packages/widgets/src/_inputs/widget-slider-input.tsx +++ b/packages/widgets/src/_inputs/widget-slider-input.tsx @@ -16,10 +16,11 @@ export const WidgetSliderInput = ({ return ( >( ); }, -).withOptions({}); +).withOptions({ + keepMounted: true, +}); diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts index 4c61be477..4a3c89048 100644 --- a/packages/widgets/src/options.ts +++ b/packages/widgets/src/options.ts @@ -39,7 +39,7 @@ interface SliderInput extends CommonInput { step?: number; } -interface OptLocation { +export interface OptionLocation { name: string; latitude: number; longitude: number; @@ -90,7 +90,7 @@ const optionsFactory = { withDescription: input.withDescription ?? false, validate: input.validate, }), - location: (input?: CommonInput) => ({ + location: (input?: CommonInput) => ({ type: "location" as const, defaultValue: input?.defaultValue ?? { name: "", diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 6bd0945b3..aecbbc2af 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,7 +1,209 @@ +import type { RouterOutputs } from "@homarr/api"; +import { clientApi } from "@homarr/api/client"; +import { + Card, + Flex, + Group, + IconArrowDownRight, + IconArrowUpRight, + IconMapPin, + Stack, + Text, + Title, +} from "@homarr/ui"; + import type { WidgetComponentProps } from "../definition"; +import { WeatherIcon } from "./icon"; export default function WeatherWidget({ - options: _options, + options, + width, }: WidgetComponentProps<"weather">) { - return
WEATHER
; + const [weather] = clientApi.widget.weather.atLocation.useSuspenseQuery( + { + latitude: options.location.latitude, + longitude: options.location.longitude, + }, + { + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }, + ); + + return ( + + + + + ); } + +interface DailyWeatherProps + extends Pick, "width" | "options"> { + shouldHide: boolean; + weather: RouterOutputs["widget"]["weather"]["atLocation"]; +} + +const DailyWeather = ({ + shouldHide, + width, + options, + weather, +}: DailyWeatherProps) => { + if (shouldHide) { + return null; + } + + return ( + <> + + + + {getPreferredUnit( + weather.current_weather.temperature, + options.isFormatFahrenheit, + )} + + + + {width > 200 && ( + + + {getPreferredUnit( + weather.daily.temperature_2m_max[0]!, + options.isFormatFahrenheit, + )} + + {getPreferredUnit( + weather.daily.temperature_2m_min[0]!, + options.isFormatFahrenheit, + )} + + )} + + {options.showCity && ( + + + {options.location.name} + + )} + + ); +}; + +interface WeeklyForecastProps + extends Pick, "width" | "options"> { + shouldHide: boolean; + weather: RouterOutputs["widget"]["weather"]["atLocation"]; +} + +const WeeklyForecast = ({ + shouldHide, + width, + options, + weather, +}: WeeklyForecastProps) => { + if (shouldHide) { + return null; + } + + return ( + <> + + {options.showCity && ( + + + + {options.location.name} + + + )} + + 20 ? "red" : "blue"} + > + {getPreferredUnit( + weather.current_weather.temperature, + options.isFormatFahrenheit, + )} + + + + + ); +}; + +interface ForecastProps + extends Pick, "options" | "width"> { + weather: RouterOutputs["widget"]["weather"]["atLocation"]; +} + +function Forecast({ weather, options, width }: ForecastProps) { + return ( + + {weather.daily.time + .slice( + 0, + Math.min(options.forecastDayCount, width / (width < 300 ? 64 : 92)), + ) + .map((time, index) => ( + + + + {new Date(time).getDate().toString().padStart(2, "0")} + + + + {getPreferredUnit( + weather.daily.temperature_2m_max[index]!, + options.isFormatFahrenheit, + )} + + + {getPreferredUnit( + weather.daily.temperature_2m_min[index]!, + options.isFormatFahrenheit, + )} + + + + ))} + + ); +} + +const getPreferredUnit = (value: number, isFahrenheit = false): string => + isFahrenheit + ? `${(value * (9 / 5) + 32).toFixed(1)}°F` + : `${value.toFixed(1)}°C`; diff --git a/packages/widgets/src/weather/icon.tsx b/packages/widgets/src/weather/icon.tsx new file mode 100644 index 000000000..3aba71a0f --- /dev/null +++ b/packages/widgets/src/weather/icon.tsx @@ -0,0 +1,81 @@ +import type { TranslationObject } from "@homarr/translation"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { TablerIcon } from "@homarr/ui"; +import { + Box, + IconCloud, + IconCloudFog, + IconCloudRain, + IconCloudSnow, + IconCloudStorm, + IconQuestionMark, + IconSnowflake, + IconSun, + Tooltip, +} from "@homarr/ui"; + +interface WeatherIconProps { + code: number; + size?: number; +} + +/** + * Icon which should be displayed when specific code is defined + * @param code weather code from api + * @returns weather tile component + */ +export const WeatherIcon = ({ code, size = 50 }: WeatherIconProps) => { + const t = useScopedI18n("widget.weather"); + + const { icon: Icon, name } = + weatherDefinitions.find((definition) => definition.codes.includes(code)) ?? + unknownWeather; + + return ( + + + + + + ); +}; + +interface WeatherDefinitionType { + icon: TablerIcon; + name: keyof TranslationObject["widget"]["weather"]["kind"]; + codes: number[]; +} + +// 0 Clear sky +// 1, 2, 3 Mainly clear, partly cloudy, and overcast +// 45, 48 Fog and depositing rime fog +// 51, 53, 55 Drizzle: Light, moderate, and dense intensity +// 56, 57 Freezing Drizzle: Light and dense intensity +// 61, 63, 65 Rain: Slight, moderate and heavy intensity +// 66, 67 Freezing Rain: Light and heavy intensity +// 71, 73, 75 Snow fall: Slight, moderate, and heavy intensity +// 77 Snow grains +// 80, 81, 82 Rain showers: Slight, moderate, and violent +// 85, 86Snow showers slight and heavy +// 95 *Thunderstorm: Slight or moderate +// 96, 99 *Thunderstorm with slight and heavy hail +const weatherDefinitions: WeatherDefinitionType[] = [ + { icon: IconSun, name: "clear", codes: [0] }, + { icon: IconCloud, name: "mainlyClear", codes: [1, 2, 3] }, + { icon: IconCloudFog, name: "fog", codes: [45, 48] }, + { icon: IconCloud, name: "drizzle", codes: [51, 53, 55] }, + { icon: IconSnowflake, name: "freezingDrizzle", codes: [56, 57] }, + { icon: IconCloudRain, name: "rain", codes: [61, 63, 65] }, + { icon: IconCloudRain, name: "freezingRain", codes: [66, 67] }, + { icon: IconCloudSnow, name: "snowFall", codes: [71, 73, 75] }, + { icon: IconCloudSnow, name: "snowGrains", codes: [77] }, + { icon: IconCloudRain, name: "rainShowers", codes: [80, 81, 82] }, + { icon: IconCloudSnow, name: "snowShowers", codes: [85, 86] }, + { icon: IconCloudStorm, name: "thunderstorm", codes: [95] }, + { icon: IconCloudStorm, name: "thunderstormWithHail", codes: [96, 99] }, +]; + +const unknownWeather: Omit = { + icon: IconQuestionMark, + name: "unknown", +}; diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index dacb27ace..ba441fb97 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -1,4 +1,5 @@ import { IconCloud } from "@homarr/ui"; +import { z } from "@homarr/validation"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; @@ -7,9 +8,32 @@ export const { definition, componentLoader } = createWidgetDefinition( "weather", { icon: IconCloud, - options: optionsBuilder.from((factory) => ({ - location: factory.location(), - showCity: factory.switch(), - })), + options: optionsBuilder.from( + (factory) => ({ + isFormatFahrenheit: factory.switch(), + location: factory.location({ + defaultValue: { + name: "Paris", + latitude: 48.85341, + longitude: 2.3488, + }, + }), + showCity: factory.switch(), + hasForecast: factory.switch(), + forecastDayCount: factory.slider({ + defaultValue: 5, + validate: z.number().min(1).max(7), + step: 1, + withDescription: true, + }), + }), + { + forecastDayCount: { + shouldHide({ hasForecast }) { + return !hasForecast; + }, + }, + }, + ), }, ).withDynamicImport(() => import("./component"));