import type React from "react"; import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core"; import { z } from "zod/v4"; import type { RefinementCtx, ZodType } from "zod/v4"; import type { IntegrationKind } from "@homarr/definitions"; import type { DynamicSelectOption } from "./_inputs/widget-dynamic-select-input"; import type { inferSelectOptionValue, SelectOption } from "./_inputs/widget-select-input"; import type { ReleasesRepository } from "./releases/releases-repository"; interface CommonInput { defaultValue?: TType; withDescription?: boolean; skipContextMenu?: boolean; } interface TextInput extends CommonInput { validate?: z.ZodType; } type AnchorNoteInput = CommonInput; interface MultiSelectInput extends CommonInput< inferSelectOptionValue[] > { options: TOptions; searchable?: boolean; } export interface SortableItemListInput extends Omit< CommonInput, "withDescription" > { AddButton: (props: { addItem: (item: TItem) => void; migrateItems: (items: TItem[], optionsPatch: Record) => void; removeItem: (value: TOptionValue) => void; values: TOptionValue[]; initialOptions: Record; }) => React.ReactNode; ItemComponent: (props: { item: TItem; removeItem: () => void; removeLabel: string; rootAttributes: DraggableAttributes; handle: React.ReactNode; }) => React.ReactNode; uniqueIdentifier: (item: TItem) => TOptionValue; useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown }; } interface SelectInput extends CommonInput< inferSelectOptionValue > { options: TOptions; searchable?: boolean; withPlaceholder?: boolean; } interface DynamicSelectInput extends CommonInput { useOptions: ( query: string, integrationIds: string[], options: Record, itemId?: string, boardId?: string, ) => { error?: string; isPending: boolean; isError?: boolean; options: DynamicSelectOption[]; }; } interface DynamicMultiSelectInput extends CommonInput { maxValues?: number; useOptions: () => { data: DynamicSelectOption[]; isPending: boolean; isError: boolean; }; } interface IntegrationSelectInput extends CommonInput { clearable?: boolean; searchable?: boolean; useOptions: (integrationIds: string[]) => { data: { value: string; label: string }[]; isPending: boolean; isError: boolean; }; } interface IntegrationMultiSelectInput extends CommonInput { withDescription?: boolean; useOptions: (integrationIds: string[]) => { data: { value: string; label: string }[]; isPending: boolean; isError: boolean; }; } interface NumberInput extends CommonInput { validate: z.ZodNumber; step?: number; } interface SliderInput extends CommonInput { validate: z.ZodNumber; step?: number; } export interface OptionTimezone { id: string; label: string; timeZone: string; } interface TimezoneListInput extends CommonInput { maxValues?: number; presets?: readonly OptionTimezone[]; timeZoneOptions: readonly { value: string; label: string }[]; } export type DateTimeEventRecurrence = "none" | "yearly"; export interface OptionDateTimeEvent { id: string; label: string; targetUtc: string; timeZone: string; startUtc?: string; recurrence: DateTimeEventRecurrence; } interface DateTimeEventListInput extends CommonInput { maxValues?: number; timeZoneOptions: readonly { value: string; label: string }[]; } export interface OptionLocation { name: string; latitude: number; longitude: number; } const optionsFactory = { internal: (input: CommonInput & { defaultValue: T }) => ({ type: "internal" as const, defaultValue: input.defaultValue, withDescription: false, skipContextMenu: true, }), switch: (input?: CommonInput) => ({ type: "switch" as const, defaultValue: input?.defaultValue ?? false, withDescription: input?.withDescription ?? false, skipContextMenu: input?.skipContextMenu ?? false, }), text: (input?: TextInput) => ({ type: "text" as const, defaultValue: input?.defaultValue ?? "", withDescription: input?.withDescription ?? false, validate: input?.validate, }), anchorNote: (input?: AnchorNoteInput) => ({ type: "anchorNote" as const, defaultValue: input?.defaultValue ?? "", withDescription: input?.withDescription ?? false, }), multiSelect: (input: MultiSelectInput) => ({ type: "multiSelect" as const, defaultValue: input.defaultValue ?? [], options: input.options, searchable: input.searchable ?? false, withDescription: input.withDescription ?? false, }), dynamicSelect: (input: DynamicSelectInput) => ({ type: "dynamicSelect" as const, defaultValue: input.defaultValue ?? null, useOptions: input.useOptions, withDescription: input.withDescription ?? false, }), dynamicMultiSelect: (input: DynamicMultiSelectInput) => ({ type: "dynamicMultiSelect" as const, defaultValue: input.defaultValue ?? [], maxValues: input.maxValues, useOptions: input.useOptions, withDescription: input.withDescription ?? false, }), select: (input: SelectInput) => ({ type: "select" as const, defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue, options: input.options, searchable: input.searchable ?? false, withDescription: input.withDescription ?? false, withPlaceholder: input.withPlaceholder ?? false, }), number: (input: NumberInput) => ({ type: "number" as const, defaultValue: input.defaultValue ?? 0, step: input.step, withDescription: input.withDescription ?? false, validate: input.validate, }), slider: (input: SliderInput) => ({ type: "slider" as const, defaultValue: input.defaultValue ?? input.validate.minValue ?? 0, step: input.step, withDescription: input.withDescription ?? false, validate: input.validate, }), location: (input?: CommonInput) => ({ type: "location" as const, defaultValue: input?.defaultValue ?? { name: "", latitude: 0, longitude: 0, }, withDescription: input?.withDescription ?? false, validate: z.object({ name: z.string().min(1), latitude: z.number(), longitude: z.number(), }), }), timezoneList: (input: TimezoneListInput) => { const maxValues = input.maxValues ?? 6; return { type: "timezoneList" as const, defaultValue: input.defaultValue ?? [], maxValues, presets: input.presets ?? [], timeZoneOptions: input.timeZoneOptions, withDescription: input.withDescription ?? false, validate: z .array( z.object({ id: z.string().min(1), label: z.string().trim().min(1).max(64), timeZone: z.string().min(1), }), ) .max(maxValues) .superRefine((values, ctx) => { const ids = new Set(); const timeZones = new Set(); values.forEach((value, index) => { if (ids.has(value.id)) { ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" }); } if (timeZones.has(value.timeZone)) { ctx.addIssue({ code: "custom", path: [index, "timeZone"], message: "Duplicate timezone" }); } ids.add(value.id); timeZones.add(value.timeZone); }); }), }; }, dateTimeEventList: (input: DateTimeEventListInput) => { const maxValues = input.maxValues ?? 20; const dateTimeEventSchema = z.object({ id: z.string().min(1), label: z.string().trim().min(1).max(64), targetUtc: z.iso.datetime(), timeZone: z.string().min(1), startUtc: z.iso.datetime().optional(), recurrence: z.enum(["none", "yearly"]), }); return { type: "dateTimeEventList" as const, defaultValue: input.defaultValue ?? [], maxValues, timeZoneOptions: input.timeZoneOptions, withDescription: input.withDescription ?? false, validate: z .array(dateTimeEventSchema) .max(maxValues) .superRefine((values, ctx) => { const ids = new Set(); values.forEach((value, index) => { if (ids.has(value.id)) { ctx.addIssue({ code: "custom", path: [index, "id"], message: "Duplicate identifier" }); } ids.add(value.id); }); }), }; }, multiText: (input?: CommonInput & { validate?: ZodType }) => ({ type: "multiText" as const, defaultValue: input?.defaultValue ?? [], withDescription: input?.withDescription ?? false, values: [] as string[], validate: input?.validate, }), multiReleasesRepositories: (input?: CommonInput & { validate?: ZodType }) => ({ type: "multiReleasesRepositories" as const, defaultValue: input?.defaultValue ?? [], withDescription: input?.withDescription ?? false, values: [] as ReleasesRepository[], validate: input?.validate, }), app: () => ({ type: "app" as const, defaultValue: "", withDescription: false, }), umamiEventName: () => ({ type: "umamiEventName" as const, defaultValue: "", withDescription: true, }), umamiEventNames: () => ({ type: "umamiEventNames" as const, defaultValue: [] as string[], withDescription: true, }), umamiWebsite: () => ({ type: "umamiWebsite" as const, defaultValue: "", withDescription: true, }), integrationSelect: (input: IntegrationSelectInput) => ({ type: "integrationSelect" as const, defaultValue: input.defaultValue ?? "", withDescription: input.withDescription ?? false, clearable: input.clearable ?? false, searchable: input.searchable ?? true, useOptions: input.useOptions, }), integrationMultiSelect: (input: IntegrationMultiSelectInput) => ({ type: "integrationMultiSelect" as const, defaultValue: input.defaultValue ?? [], withDescription: input.withDescription ?? false, useOptions: input.useOptions, }), customWidgetSelect: (input?: CommonInput) => ({ type: "customWidgetSelect" as const, defaultValue: input?.defaultValue ?? "", withDescription: input?.withDescription ?? false, }), customWidgetConfiguration: (input?: CommonInput>) => ({ type: "customWidgetConfiguration" as const, defaultValue: input?.defaultValue ?? {}, withDescription: input?.withDescription ?? false, skipContextMenu: true, }), sortableItemList: ( input: SortableItemListInput, ) => ({ type: "sortableItemList" as const, defaultValue: [] as TOptionValue[], itemComponent: input.ItemComponent, addButton: input.AddButton, uniqueIdentifier: input.uniqueIdentifier, useData: input.useData, withDescription: false, }), }; type WidgetOptionFactory = typeof optionsFactory; export type WidgetOptionDefinition = | ReturnType]> // We allow any here as it's already type guarded with Record and it still infers the correct type // eslint-disable-next-line @typescript-eslint/no-explicit-any | ReturnType>; export type WidgetOptionsRecord = Record; export type WidgetOptionType = WidgetOptionDefinition["type"]; export type WidgetOptionOfType = Extract; type inferOptionFromDefinition = TDefinition["defaultValue"]; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type inferOptionsFromCreator WidgetOptionsRecord> = inferOptionsFromDefinition>; export type inferOptionsFromDefinition = { [key in keyof TOptions]: inferOptionFromDefinition; }; interface FieldConfiguration { shouldHide: (options: inferOptionsFromDefinition, integrationKinds: IntegrationKind[]) => boolean; } type ConfigurationInput = Partial< Record> >; export const OPTIONS_SUPER_REFINE = Symbol("optionsSuperRefine"); const createOptions = ( optionsCallback: (factory: WidgetOptionFactory) => TOptions, configuration?: ConfigurationInput, optionsSuperRefine?: (data: inferOptionsFromDefinition, ctx: RefinementCtx) => void, ) => { const obj = {} as Record; const options = optionsCallback(optionsFactory); for (const key in options) { obj[key] = { ...configuration?.[key], ...options[key], }; } const result = obj as { [key in keyof TOptions]: TOptions[key] & FieldConfiguration; }; if (optionsSuperRefine) { Object.defineProperty(result, OPTIONS_SUPER_REFINE, { value: optionsSuperRefine, enumerable: false, }); } return result; }; type OptionsBuilder = typeof createOptions; export type OptionsBuilderResult = ReturnType; export const optionsBuilder = { from: createOptions, };