feat: custom widgets (#5889)
This commit is contained in:
1 parent
48e1844b5b
commit
aecbca2b81
69 files changed
+14620
-142
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import type { WidgetOptionType } from "../options";
|
||||
import { WidgetAnchorNoteInput } from "./widget-anchor-note-input";
|
||||
import { WidgetAppInput } from "./widget-app-input";
|
||||
import { WidgetCustomWidgetSelectInput } from "./widget-custom-widget-select-input";
|
||||
import { WidgetDynamicSelectInput } from "./widget-dynamic-select-input";
|
||||
import { WidgetLocationInput } from "./widget-location-input";
|
||||
import { WidgetMultiTextInput } from "./widget-multi-text-input";
|
||||
@@ -33,6 +34,7 @@ const mapping = {
|
||||
umamiEventName: WidgetUmamiEventInput,
|
||||
umamiEventNames: WidgetUmamiEventNamesInput,
|
||||
umamiWebsite: WidgetUmamiWebsiteInput,
|
||||
customWidgetSelect: WidgetCustomWidgetSelectInput,
|
||||
} satisfies Record<WidgetOptionType, unknown>;
|
||||
|
||||
export const getInputForType = <TType extends WidgetOptionType>(type: TType) => {
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Badge, Combobox, Group, InputBase, ScrollArea, Text, useCombobox } from "@mantine/core";
|
||||
import {
|
||||
IconBraces,
|
||||
IconChevronDown,
|
||||
IconCircleCheck,
|
||||
IconHash,
|
||||
IconLetterT,
|
||||
IconList,
|
||||
IconToggleLeft,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
const VALUE_TRUNCATE_LENGTH = 80;
|
||||
const LEVEL_OFFSET = 14;
|
||||
const BASE_PADDING = 8;
|
||||
|
||||
const typeIcons: Record<string, typeof IconLetterT> = {
|
||||
string: IconLetterT,
|
||||
number: IconHash,
|
||||
boolean: IconToggleLeft,
|
||||
null: IconToggleLeft,
|
||||
object: IconBraces,
|
||||
array: IconList,
|
||||
};
|
||||
|
||||
function getValueType(val: unknown): string {
|
||||
if (val === null) return "null";
|
||||
if (Array.isArray(val)) return "array";
|
||||
return typeof val;
|
||||
}
|
||||
|
||||
function truncateValue(value: unknown): string {
|
||||
const str = value === null ? "null" : String(value);
|
||||
if (str.length <= VALUE_TRUNCATE_LENGTH) return str;
|
||||
return `${str.slice(0, VALUE_TRUNCATE_LENGTH)}…`;
|
||||
}
|
||||
|
||||
function appendPath(prefix: string, segment: string): string {
|
||||
if (segment.startsWith("[")) return `${prefix}${segment}`;
|
||||
return prefix === "$" ? `$.${segment}` : `${prefix}.${segment}`;
|
||||
}
|
||||
|
||||
function lastSegment(path: string): string {
|
||||
if (path === "$") return "$";
|
||||
const bracketMatch = path.match(/\[(\d+)\]$/);
|
||||
if (bracketMatch) return `[${bracketMatch[1]}]`;
|
||||
const dotIndex = path.lastIndexOf(".");
|
||||
return dotIndex === -1 ? path : path.slice(dotIndex + 1);
|
||||
}
|
||||
|
||||
function getParentPath(path: string): string | null {
|
||||
if (path.length <= 1) return null;
|
||||
const cutIdx = Math.max(path.lastIndexOf("."), path.lastIndexOf("["));
|
||||
if (cutIdx <= 0) return null;
|
||||
return path.slice(0, cutIdx);
|
||||
}
|
||||
|
||||
function isPrimitive(value: unknown): boolean {
|
||||
return value === null || (typeof value !== "object" && typeof value !== "function");
|
||||
}
|
||||
|
||||
function formatObjectPreview(entries: [string, unknown][]): string {
|
||||
if (entries.length === 0) return "{}";
|
||||
const parts = entries.slice(0, 3).map(([k, v]) => `${k}: ${truncateValue(v)}`);
|
||||
const suffix = entries.length > 3 ? ", …" : "";
|
||||
return `{${parts.join(", ")}${suffix}}`;
|
||||
}
|
||||
|
||||
interface FlatNode {
|
||||
path: string;
|
||||
label: string;
|
||||
preview: string | null;
|
||||
type: string;
|
||||
depth: number;
|
||||
hasChildren: boolean;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
function flattenJson(
|
||||
json: unknown,
|
||||
prefix: string,
|
||||
depth: number,
|
||||
expanded: Record<string, boolean>,
|
||||
seen: WeakSet<object>,
|
||||
expandAll = false,
|
||||
): FlatNode[] {
|
||||
if (isPrimitive(json)) {
|
||||
return [
|
||||
{
|
||||
path: prefix,
|
||||
label: lastSegment(prefix),
|
||||
preview: truncateValue(json),
|
||||
type: getValueType(json),
|
||||
depth,
|
||||
hasChildren: false,
|
||||
isExpanded: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const obj = json as object;
|
||||
if (seen.has(obj)) return [];
|
||||
seen.add(obj);
|
||||
|
||||
const nodes: FlatNode[] = [];
|
||||
|
||||
if (Array.isArray(json)) {
|
||||
const isExpanded = expandAll || (expanded[prefix] ?? depth === 0);
|
||||
const preview = json.length === 0 ? "[]" : `${json.length} items`;
|
||||
nodes.push({
|
||||
path: prefix,
|
||||
label: lastSegment(prefix),
|
||||
preview,
|
||||
type: "array",
|
||||
depth,
|
||||
hasChildren: json.length > 0,
|
||||
isExpanded,
|
||||
});
|
||||
|
||||
if (isExpanded) {
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const childPath = `${prefix}[${i}]`;
|
||||
nodes.push(...flattenJson(json[i], childPath, depth + 1, expanded, seen, expandAll));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const entries = Object.entries(json as Record<string, unknown>);
|
||||
const isExpanded = expandAll || (expanded[prefix] ?? depth <= 1);
|
||||
if (depth > 0) {
|
||||
nodes.push({
|
||||
path: prefix,
|
||||
label: lastSegment(prefix),
|
||||
preview: formatObjectPreview(entries),
|
||||
type: "object",
|
||||
depth,
|
||||
hasChildren: entries.length > 0,
|
||||
isExpanded,
|
||||
});
|
||||
}
|
||||
|
||||
if (isExpanded || depth === 0) {
|
||||
for (const [key, val] of entries) {
|
||||
const childPath = appendPath(prefix, key);
|
||||
if (isPrimitive(val)) {
|
||||
nodes.push({
|
||||
path: childPath,
|
||||
label: key,
|
||||
preview: truncateValue(val),
|
||||
type: getValueType(val),
|
||||
depth: depth + 1,
|
||||
hasChildren: false,
|
||||
isExpanded: false,
|
||||
});
|
||||
} else {
|
||||
nodes.push(...flattenJson(val, childPath, depth + 1, expanded, seen, expandAll));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const SEARCH_FIELDS = ["path", "label", "preview"] as const;
|
||||
|
||||
function matchesSearch(node: FlatNode, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
return SEARCH_FIELDS.some((key) => node[key]?.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
interface JsonPathTreePickerProps {
|
||||
value?: string;
|
||||
onChange: (path: string) => void;
|
||||
json: unknown | null;
|
||||
label?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
noDataHint?: string;
|
||||
loadedHint?: string;
|
||||
}
|
||||
|
||||
const isChildPath = (child: string, parent: string) =>
|
||||
child !== parent && (child.startsWith(`${parent}.`) || child.startsWith(`${parent}[`));
|
||||
|
||||
export function JsonPathTreePicker({
|
||||
value = "",
|
||||
onChange,
|
||||
json,
|
||||
label,
|
||||
description,
|
||||
required,
|
||||
error,
|
||||
placeholder,
|
||||
noDataHint = "Run a test to browse response fields",
|
||||
loadedHint = "Browsing live response",
|
||||
}: JsonPathTreePickerProps) {
|
||||
const [search, setSearch] = useState(value);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTyping) {
|
||||
setSearch(value || "");
|
||||
}
|
||||
}, [value, isTyping]);
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
setIsTyping(false);
|
||||
},
|
||||
});
|
||||
|
||||
const hasData = json != null;
|
||||
const searchQuery = isTyping ? search : "";
|
||||
const isSearching = !!searchQuery.trim();
|
||||
|
||||
const flatNodes = useMemo(
|
||||
() => (hasData ? flattenJson(json, "$", 0, expanded, new WeakSet(), isSearching) : []),
|
||||
[json, expanded, hasData, isSearching],
|
||||
);
|
||||
|
||||
const nodesByPath = useMemo(() => new Map(flatNodes.map((n) => [n.path, n])), [flatNodes]);
|
||||
|
||||
const fieldCount = flatNodes.length;
|
||||
|
||||
const visibleNodes = useMemo(() => {
|
||||
if (!isSearching) return flatNodes;
|
||||
const matchingPaths = new Set<string>();
|
||||
for (const n of flatNodes) {
|
||||
if (matchesSearch(n, searchQuery)) {
|
||||
matchingPaths.add(n.path);
|
||||
for (let parent = getParentPath(n.path); parent; parent = getParentPath(parent)) {
|
||||
matchingPaths.add(parent);
|
||||
}
|
||||
}
|
||||
}
|
||||
return flatNodes.filter((n) => matchingPaths.has(n.path));
|
||||
}, [flatNodes, searchQuery, isSearching]);
|
||||
|
||||
const toggleExpand = (path: string) => {
|
||||
const childPaths = flatNodes.filter((n) => n.hasChildren && isChildPath(n.path, path)).map((n) => n.path);
|
||||
setExpanded((prev) => {
|
||||
const isCurrentlyExpanded = prev[path] ?? false;
|
||||
const next = { ...prev, [path]: !isCurrentlyExpanded };
|
||||
for (const cp of childPaths) {
|
||||
next[cp] = !isCurrentlyExpanded;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal
|
||||
width={560}
|
||||
onOptionSubmit={(path) => {
|
||||
const node = nodesByPath.get(path);
|
||||
if (node?.hasChildren) {
|
||||
toggleExpand(path);
|
||||
return;
|
||||
}
|
||||
onChange(path);
|
||||
setSearch(path);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
rightSection={<Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
const val = event.currentTarget.value;
|
||||
setSearch(val);
|
||||
setIsTyping(true);
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => {
|
||||
combobox.closeDropdown();
|
||||
if (isTyping && search.trim()) {
|
||||
onChange(search);
|
||||
} else {
|
||||
setSearch(value || "");
|
||||
}
|
||||
setIsTyping(false);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && isTyping) {
|
||||
event.preventDefault();
|
||||
onChange(search);
|
||||
setIsTyping(false);
|
||||
combobox.closeDropdown();
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
description={description}
|
||||
error={error}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
{hasData && (
|
||||
<Combobox.Header>
|
||||
<Group gap="xs" px="xs" pt="xs" pb={4}>
|
||||
<Badge size="sm" color="teal" variant="light" leftSection={<IconCircleCheck size={12} />}>
|
||||
{loadedHint}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{fieldCount} {fieldCount === 1 ? "field" : "fields"}
|
||||
</Text>
|
||||
</Group>
|
||||
</Combobox.Header>
|
||||
)}
|
||||
<Combobox.Options>
|
||||
<ScrollArea.Autosize mah={420} type="scroll">
|
||||
{!hasData && <Combobox.Empty>{noDataHint}</Combobox.Empty>}
|
||||
{hasData && visibleNodes.length === 0 && <Combobox.Empty>No matching paths</Combobox.Empty>}
|
||||
{visibleNodes.map((node) => (
|
||||
<TreeNodeOption key={node.path} node={node} selected={value === node.path} isSearching={isSearching} />
|
||||
))}
|
||||
</ScrollArea.Autosize>
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodeOption({ node, selected, isSearching }: { node: FlatNode; selected: boolean; isSearching: boolean }) {
|
||||
const TypeIcon = typeIcons[node.type] ?? IconLetterT;
|
||||
const indent = isSearching ? 0 : (node.depth - 1) * LEVEL_OFFSET;
|
||||
|
||||
return (
|
||||
<Combobox.Option
|
||||
value={node.path}
|
||||
active={selected}
|
||||
style={{
|
||||
paddingInlineStart: BASE_PADDING + Math.max(0, indent),
|
||||
paddingBlock: 4,
|
||||
}}
|
||||
>
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{node.hasChildren && (
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
color: "var(--mantine-color-dimmed)",
|
||||
transform: node.isExpanded ? "rotate(0deg)" : "rotate(-90deg)",
|
||||
transition: "transform 150ms ease",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TypeIcon size={14} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<Text
|
||||
size="sm"
|
||||
fw={selected ? 600 : 400}
|
||||
c={selected ? "var(--mantine-color-primary-filled)" : undefined}
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}
|
||||
>
|
||||
{node.label}
|
||||
</Text>
|
||||
{node.preview !== null && (
|
||||
<Text
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
style={{ flexShrink: 0, maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{node.preview}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Combobox, InputBase, Loader, useCombobox } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
import type { CommonWidgetInputProps } from "./common";
|
||||
import { useWidgetInputTranslation } from "./common";
|
||||
import { useFormContext } from "./form";
|
||||
|
||||
export const WidgetCustomWidgetSelectInput = ({
|
||||
property,
|
||||
kind,
|
||||
options,
|
||||
}: CommonWidgetInputProps<"customWidgetSelect">) => {
|
||||
const t = useWidgetInputTranslation(kind, property);
|
||||
const form = useFormContext();
|
||||
const { data, isLoading } = clientApi.customWidget.all.useQuery();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const currentValue = form.values.options[property] as string;
|
||||
|
||||
const definitions = useMemo(
|
||||
() =>
|
||||
(data ?? [])
|
||||
.filter((def) => def.enabled || def.id === currentValue)
|
||||
.map((def) => ({ value: def.id, label: def.name })),
|
||||
[data, currentValue],
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
definitions.filter(
|
||||
(def) =>
|
||||
def.label.toLowerCase().includes(search.toLowerCase()) ||
|
||||
def.value.toLowerCase().includes(search.toLowerCase()),
|
||||
),
|
||||
[definitions, search],
|
||||
);
|
||||
|
||||
const selectedLabel = definitions.find((d) => d.value === currentValue)?.label;
|
||||
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
setSearch("");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={(val) => {
|
||||
form.setFieldValue(`options.${property}`, val);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label={t("label")}
|
||||
description={options.withDescription ? t("description") : undefined}
|
||||
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
|
||||
rightSectionPointerEvents="none"
|
||||
value={combobox.dropdownOpened ? search : (selectedLabel ?? currentValue)}
|
||||
onChange={(event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
form.setFieldValue(`options.${property}`, event.currentTarget.value);
|
||||
combobox.openDropdown();
|
||||
combobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
onFocus={() => combobox.openDropdown()}
|
||||
onBlur={() => combobox.closeDropdown()}
|
||||
placeholder={t("label")}
|
||||
/>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
{filteredOptions.map((def) => (
|
||||
<Combobox.Option key={def.value} value={def.value} active={def.value === currentValue}>
|
||||
{def.label}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
{filteredOptions.length === 0 && !isLoading && <Combobox.Empty>No definitions found</Combobox.Empty>}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
@@ -457,7 +457,7 @@ const RepositoryEditModal = createModal<RepositoryEditProps>(({ innerProps, acti
|
||||
isNaN(precision) || precision <= 0
|
||||
? undefined
|
||||
: {
|
||||
...(tempRepository.versionFilter ?? {}),
|
||||
...tempRepository.versionFilter,
|
||||
precision,
|
||||
},
|
||||
});
|
||||
@@ -561,11 +561,11 @@ const ImportRepositorySelect = ({
|
||||
{tRepository("versionFilter.label")}:
|
||||
</Text>
|
||||
|
||||
<Code>{repository.versionFilter.prefix && repository.versionFilter.prefix}</Code>
|
||||
<Code>{repository.versionFilter.prefix}</Code>
|
||||
<Code color="var(--mantine-primary-color-light)" fw={700}>
|
||||
{versionFilterPrecisionOptions[repository.versionFilter.precision]}
|
||||
</Code>
|
||||
<Code>{repository.versionFilter.suffix && repository.versionFilter.suffix}</Code>
|
||||
<Code>{repository.versionFilter.suffix}</Code>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
Reference in new issue
Block a user