* docs(helm): Update Helm chart documentation * feat(widgets): add right-click context menu with auto-generated toggle actions - Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu - Extend WidgetDefinition with contextActions API for widget-specific actions - Auto-generate context menu toggle items for switch-type widget options - Add 'Search' context action to media-requests widgets (list + stats) - Keep existing dots-button menu for discoverability in edit mode - Add widgetStateRef to WidgetComponentProps for client-side-only state - Add skipContextMenu opt-out on switch option definitions * style: auto-fix lint and format * fix(widgets): address CodeRabbit review — badge in context menu target, edit-only pending gate - Move title badge inside WidgetContextMenu wrapper so right-clicking the title overlay also triggers the context menu - Remove full-menu hide during integration loading; only disable 'Edit' action while isPending - Add isPending to allActions useMemo dependency array * style: auto-fix lint and format * fix(widgets): gate context menu behind authenticated session - Only show right-click context menu for logged-in users - Unauthenticated users see plain children without menu wrapper - Matches existing BoardItemMenu session gating pattern * style: auto-fix lint and format * chore: revert unrelated sitemap change from auto-fix * style: auto-fix lint and format * fix(context-menu): persist widget option changes to server Toggle switches and edit modal changes from the context menu now call saveBoard mutation to persist to DB. Previously only the client cache was updated, losing changes on reload. Removes unnecessary invalidation that caused cascading refetch loops. * style: auto-fix lint and format * fix: use explicit React type imports instead of global namespace --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
269 lines
8.8 KiB
TypeScript
269 lines
8.8 KiB
TypeScript
import type React from "react";
|
|
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
|
|
import type { ActionIconProps } from "@mantine/core";
|
|
import { z } from "zod/v4";
|
|
import type { 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<TType> {
|
|
defaultValue?: TType;
|
|
withDescription?: boolean;
|
|
skipContextMenu?: boolean;
|
|
}
|
|
|
|
interface TextInput extends CommonInput<string> {
|
|
validate?: z.ZodType<string>;
|
|
}
|
|
|
|
type AnchorNoteInput = CommonInput<string>;
|
|
|
|
interface MultiSelectInput<TOptions extends SelectOption[]> extends CommonInput<
|
|
inferSelectOptionValue<TOptions[number]>[]
|
|
> {
|
|
options: TOptions;
|
|
searchable?: boolean;
|
|
}
|
|
|
|
export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentifier> extends Omit<
|
|
CommonInput<TOptionValue[]>,
|
|
"withDescription"
|
|
> {
|
|
AddButton: (props: { addItem: (item: TItem) => void; values: TOptionValue[] }) => React.ReactNode;
|
|
ItemComponent: (props: {
|
|
item: TItem;
|
|
removeItem: () => void;
|
|
rootAttributes: DraggableAttributes;
|
|
handle: (props: Partial<Pick<ActionIconProps, "size" | "color" | "variant">>) => React.ReactNode;
|
|
}) => React.ReactNode;
|
|
uniqueIdentifier: (item: TItem) => TOptionValue;
|
|
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
|
|
}
|
|
|
|
interface SelectInput<TOptions extends readonly SelectOption[]> extends CommonInput<
|
|
inferSelectOptionValue<TOptions[number]>
|
|
> {
|
|
options: TOptions;
|
|
searchable?: boolean;
|
|
}
|
|
|
|
interface DynamicSelectInput extends CommonInput<DynamicSelectOption | null> {
|
|
useOptions: (
|
|
query: string,
|
|
integrationIds: string[],
|
|
) => {
|
|
isPending: boolean;
|
|
options: DynamicSelectOption[];
|
|
};
|
|
}
|
|
|
|
interface IntegrationSelectInput extends CommonInput<string> {
|
|
clearable?: boolean;
|
|
searchable?: boolean;
|
|
useOptions: (integrationIds: string[]) => {
|
|
data: { value: string; label: string }[];
|
|
isPending: boolean;
|
|
isError: boolean;
|
|
};
|
|
}
|
|
|
|
interface NumberInput extends CommonInput<number> {
|
|
validate: z.ZodNumber;
|
|
step?: number;
|
|
}
|
|
|
|
interface SliderInput extends CommonInput<number> {
|
|
validate: z.ZodNumber;
|
|
step?: number;
|
|
}
|
|
|
|
export interface OptionLocation {
|
|
name: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
}
|
|
|
|
const optionsFactory = {
|
|
switch: (input?: CommonInput<boolean>) => ({
|
|
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: <const TOptions extends SelectOption[]>(input: MultiSelectInput<TOptions>) => ({
|
|
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,
|
|
}),
|
|
select: <const TOptions extends SelectOption[]>(input: SelectInput<TOptions>) => ({
|
|
type: "select" as const,
|
|
defaultValue: (input.defaultValue ?? input.options[0]) as inferSelectOptionValue<TOptions[number]>,
|
|
options: input.options,
|
|
searchable: input.searchable ?? false,
|
|
withDescription: input.withDescription ?? 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<OptionLocation>) => ({
|
|
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(),
|
|
}),
|
|
}),
|
|
multiText: (input?: CommonInput<string[]> & { validate?: ZodType }) => ({
|
|
type: "multiText" as const,
|
|
defaultValue: input?.defaultValue ?? [],
|
|
withDescription: input?.withDescription ?? false,
|
|
values: [] as string[],
|
|
validate: input?.validate,
|
|
}),
|
|
multiReleasesRepositories: (input?: CommonInput<ReleasesRepository[]> & { 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,
|
|
}),
|
|
customWidgetSelect: (input?: CommonInput<string>) => ({
|
|
type: "customWidgetSelect" as const,
|
|
defaultValue: input?.defaultValue ?? "",
|
|
withDescription: input?.withDescription ?? false,
|
|
}),
|
|
sortableItemList: <const TItem, const TOptionValue extends UniqueIdentifier>(
|
|
input: SortableItemListInput<TItem, TOptionValue>,
|
|
) => ({
|
|
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<WidgetOptionFactory[Exclude<keyof WidgetOptionFactory, "sortableItemList">]>
|
|
// We allow any here as it's already type guarded with Record<string, unknown> and it still infers the correct type
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
| ReturnType<typeof optionsFactory.sortableItemList<any, any>>;
|
|
export type WidgetOptionsRecord = Record<string, WidgetOptionDefinition>;
|
|
export type WidgetOptionType = WidgetOptionDefinition["type"];
|
|
export type WidgetOptionOfType<TType extends WidgetOptionType> = Extract<WidgetOptionDefinition, { type: TType }>;
|
|
|
|
type inferOptionFromDefinition<TDefinition extends WidgetOptionDefinition> = TDefinition["defaultValue"];
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
export type inferOptionsFromCreator<TOptions extends (settings: any) => WidgetOptionsRecord> =
|
|
inferOptionsFromDefinition<ReturnType<TOptions>>;
|
|
export type inferOptionsFromDefinition<TOptions extends WidgetOptionsRecord> = {
|
|
[key in keyof TOptions]: inferOptionFromDefinition<TOptions[key]>;
|
|
};
|
|
|
|
interface FieldConfiguration<TOptions extends WidgetOptionsRecord> {
|
|
shouldHide: (options: inferOptionsFromDefinition<TOptions>, integrationKinds: IntegrationKind[]) => boolean;
|
|
}
|
|
|
|
type ConfigurationInput<TOptions extends WidgetOptionsRecord> = Partial<
|
|
Record<keyof TOptions, FieldConfiguration<TOptions>>
|
|
>;
|
|
|
|
const createOptions = <TOptions extends WidgetOptionsRecord>(
|
|
optionsCallback: (factory: WidgetOptionFactory) => TOptions,
|
|
configuration?: ConfigurationInput<TOptions>,
|
|
) => {
|
|
const obj = {} as Record<keyof TOptions, unknown>;
|
|
const options = optionsCallback(optionsFactory);
|
|
|
|
for (const key in options) {
|
|
obj[key] = {
|
|
...configuration?.[key],
|
|
...options[key],
|
|
};
|
|
}
|
|
|
|
return obj as {
|
|
[key in keyof TOptions]: TOptions[key] & FieldConfiguration<TOptions>;
|
|
};
|
|
};
|
|
|
|
type OptionsBuilder = typeof createOptions;
|
|
export type OptionsBuilderResult = ReturnType<OptionsBuilder>;
|
|
|
|
export const optionsBuilder = {
|
|
from: createOptions,
|
|
};
|