This commit is contained in:
1 parent
e3a713d2df
commit
e842177d9b
163 files changed
+7390
-1240
No files matched your search
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { SetupMetricProperties } from "./setup-analytics";
|
||||
|
||||
describe("SetupMetricProperties", () => {
|
||||
test("keeps the measurement contract coarse and configuration-free", () => {
|
||||
const allowedKeys = [
|
||||
"entryPoint",
|
||||
"intent",
|
||||
"outcome",
|
||||
"elapsedMs",
|
||||
"hasBoardContext",
|
||||
"canResolveInline",
|
||||
] satisfies (keyof SetupMetricProperties)[];
|
||||
type ExpectedKey = (typeof allowedKeys)[number];
|
||||
type ActualKey = keyof SetupMetricProperties;
|
||||
const exactContract: [ActualKey] extends [ExpectedKey]
|
||||
? [ExpectedKey] extends [ActualKey]
|
||||
? true
|
||||
: false
|
||||
: false = true;
|
||||
|
||||
expect(exactContract).toBe(true);
|
||||
expect(allowedKeys).not.toContain("url");
|
||||
expect(allowedKeys).not.toContain("query");
|
||||
expect(allowedKeys).not.toContain("recordId");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
export type SetupMetricEvent =
|
||||
| "surface-opened"
|
||||
| "intent-selected"
|
||||
| "widget-started"
|
||||
| "dependency-blocked"
|
||||
| "dependency-resolved-inline"
|
||||
| "widget-completed"
|
||||
| "completion-recipe-selected"
|
||||
| "checklist-resumed";
|
||||
|
||||
export type SetupMetricEntryPoint = "header" | "spotlight" | "board" | "assistant" | "docker" | "management";
|
||||
|
||||
export interface SetupMetricProperties {
|
||||
entryPoint: SetupMetricEntryPoint;
|
||||
intent?: string;
|
||||
outcome?: "completed" | "blocked" | "continued";
|
||||
elapsedMs?: number;
|
||||
hasBoardContext?: boolean;
|
||||
canResolveInline?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits only coarse workflow measurements through Homarr's existing opt-in analytics gate.
|
||||
* Never pass record IDs, names, URLs, search text, credentials, or provider responses here.
|
||||
*/
|
||||
export const useSetupAnalytics = () => {
|
||||
const t = useI18n();
|
||||
const { mutate } = clientApi.analytics.trackFeature.useMutation({
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
title: t("common.error"),
|
||||
message: t("universalCreate.analyticsError"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
(event: SetupMetricEvent, properties: SetupMetricProperties) => {
|
||||
mutate({ feature: `setup:${event}`, properties: { ...properties } });
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { RankedUniversalCreateAction } from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
|
||||
describe("getUniversalCreateActionDefinitions", () => {
|
||||
test("only exposes actions allowed without a board context", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: false,
|
||||
permissions: ["board-create", "integration-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["integration", "board"]);
|
||||
});
|
||||
|
||||
test("places globally-created apps in the library group", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: false, permissions: ["app-create"] });
|
||||
|
||||
expect(actions).toEqual([expect.objectContaining({ key: "app", group: "library" })]);
|
||||
});
|
||||
|
||||
test("keeps board content actions available without global create permissions", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({ hasBoardContext: true, permissions: [] });
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual(["widget", "app", "container"]);
|
||||
});
|
||||
|
||||
test("exposes the complete surface to administrators on a board", () => {
|
||||
const actions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: true,
|
||||
permissions: ["admin", "app-create", "integration-create", "board-create"],
|
||||
});
|
||||
|
||||
expect(actions.map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterAndRankUniversalCreateActions", () => {
|
||||
const actions = [
|
||||
{ key: "widget", group: "currentBoard", priority: 100, name: "Add widget", description: "Add content" },
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
name: "Connect service",
|
||||
description: "Create an integration",
|
||||
keywords: ["provider"],
|
||||
},
|
||||
{ key: "board", group: "boards", priority: 60, name: "Create board", description: "Start a dashboard" },
|
||||
] satisfies RankedUniversalCreateAction[];
|
||||
|
||||
test("uses product priority when no search is entered", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "").map(({ key }) => key)).toEqual([
|
||||
"widget",
|
||||
"integration",
|
||||
"board",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ranks a name prefix ahead of a description or keyword match", () => {
|
||||
const results = filterAndRankUniversalCreateActions(actions, "create");
|
||||
|
||||
expect(results.map(({ key }) => key)).toEqual(["board", "integration"]);
|
||||
});
|
||||
|
||||
test("matches keywords and removes unrelated actions", () => {
|
||||
expect(filterAndRankUniversalCreateActions(actions, "provider").map(({ key }) => key)).toEqual(["integration"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
export const universalCreateActionKeys = [
|
||||
"widget",
|
||||
"app",
|
||||
"integration",
|
||||
"container",
|
||||
"board",
|
||||
"workshop",
|
||||
"customWidget",
|
||||
] as const;
|
||||
|
||||
export type UniversalCreateActionKey = (typeof universalCreateActionKeys)[number];
|
||||
export type UniversalCreateActionGroup = "currentBoard" | "library" | "boards";
|
||||
|
||||
export interface UniversalCreateContext {
|
||||
hasBoardContext: boolean;
|
||||
permissions: readonly GroupPermissionKey[];
|
||||
}
|
||||
|
||||
interface UniversalCreateActionDefinition {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
isVisible: (context: UniversalCreateContext) => boolean;
|
||||
}
|
||||
|
||||
const hasPermission = (context: UniversalCreateContext, permission: GroupPermissionKey) =>
|
||||
context.permissions.includes(permission);
|
||||
|
||||
const definitions: readonly UniversalCreateActionDefinition[] = [
|
||||
{
|
||||
key: "widget",
|
||||
group: "currentBoard",
|
||||
priority: 100,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "app",
|
||||
group: "currentBoard",
|
||||
priority: 90,
|
||||
isVisible: (context) => context.hasBoardContext || hasPermission(context, "app-create"),
|
||||
},
|
||||
{
|
||||
key: "integration",
|
||||
group: "library",
|
||||
priority: 80,
|
||||
isVisible: (context) => hasPermission(context, "integration-create"),
|
||||
},
|
||||
{
|
||||
key: "container",
|
||||
group: "currentBoard",
|
||||
priority: 70,
|
||||
isVisible: ({ hasBoardContext }) => hasBoardContext,
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
group: "boards",
|
||||
priority: 60,
|
||||
isVisible: (context) => hasPermission(context, "board-create"),
|
||||
},
|
||||
{
|
||||
key: "workshop",
|
||||
group: "library",
|
||||
priority: 40,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
{
|
||||
key: "customWidget",
|
||||
group: "library",
|
||||
priority: 30,
|
||||
isVisible: (context) => hasPermission(context, "admin"),
|
||||
},
|
||||
];
|
||||
|
||||
export interface RankedUniversalCreateAction {
|
||||
key: UniversalCreateActionKey;
|
||||
group: UniversalCreateActionGroup;
|
||||
priority: number;
|
||||
name: string;
|
||||
description: string;
|
||||
keywords?: readonly string[];
|
||||
}
|
||||
|
||||
export const getUniversalCreateActionDefinitions = (context: UniversalCreateContext) =>
|
||||
definitions
|
||||
.filter((definition) => definition.isVisible(context))
|
||||
.map((definition) =>
|
||||
definition.key === "app" && !context.hasBoardContext ? { ...definition, group: "library" as const } : definition,
|
||||
);
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
|
||||
export const filterAndRankUniversalCreateActions = <TAction extends RankedUniversalCreateAction>(
|
||||
actions: readonly TAction[],
|
||||
query: string,
|
||||
) => {
|
||||
const normalizedQuery = normalize(query);
|
||||
|
||||
return actions
|
||||
.map((action) => {
|
||||
const name = normalize(action.name);
|
||||
const searchableText = normalize([action.name, action.description, ...(action.keywords ?? [])].join(" "));
|
||||
const relevance =
|
||||
normalizedQuery.length === 0
|
||||
? 0
|
||||
: name === normalizedQuery
|
||||
? 300
|
||||
: name.startsWith(normalizedQuery)
|
||||
? 200
|
||||
: searchableText.includes(normalizedQuery)
|
||||
? 100
|
||||
: -1;
|
||||
|
||||
return { action, relevance };
|
||||
})
|
||||
.filter(({ relevance }) => relevance >= 0)
|
||||
.toSorted((left, right) => right.relevance - left.relevance || right.action.priority - left.action.priority)
|
||||
.map(({ action }) => action);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
.action {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
padding: var(--mantine-spacing-sm);
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.action:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.action:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
declare const classes: {
|
||||
readonly action: string;
|
||||
readonly actionIcon: string;
|
||||
};
|
||||
export default classes;
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Group, Stack, Text, TextInput, ThemeIcon, UnstyledButton } from "@mantine/core";
|
||||
import {
|
||||
IconApps,
|
||||
IconBox,
|
||||
IconCode,
|
||||
IconLayoutDashboard,
|
||||
IconLayoutGridAdd,
|
||||
IconPlugConnected,
|
||||
IconSearch,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { AddBoardModal } from "@homarr/modals-collection";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { TablerIcon } from "@homarr/ui";
|
||||
|
||||
import classes from "./universal-create-modal.module.css";
|
||||
import type {
|
||||
RankedUniversalCreateAction,
|
||||
UniversalCreateActionGroup,
|
||||
UniversalCreateActionKey,
|
||||
} from "./universal-create-actions";
|
||||
import { filterAndRankUniversalCreateActions, getUniversalCreateActionDefinitions } from "./universal-create-actions";
|
||||
import type { SetupMetricEntryPoint } from "./setup-analytics";
|
||||
import { useSetupAnalytics } from "./setup-analytics";
|
||||
|
||||
type BoardActionKey = "widget" | "app" | "integration" | "container";
|
||||
|
||||
export interface UniversalCreateModalProps {
|
||||
boardActions?: Partial<Record<BoardActionKey, () => void>>;
|
||||
entryPoint?: SetupMetricEntryPoint;
|
||||
}
|
||||
|
||||
const actionIcons = {
|
||||
widget: IconApps,
|
||||
app: IconBox,
|
||||
integration: IconPlugConnected,
|
||||
container: IconLayoutGridAdd,
|
||||
board: IconLayoutDashboard,
|
||||
workshop: IconSearch,
|
||||
customWidget: IconCode,
|
||||
} satisfies Record<UniversalCreateActionKey, TablerIcon>;
|
||||
|
||||
export const UniversalCreateModal = createModal<UniversalCreateModalProps>(({ actions, innerProps }) => {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal: openAddBoardModal } = useModalAction(AddBoardModal);
|
||||
const [query, setQuery] = useState("");
|
||||
const trackedOpen = useRef(false);
|
||||
const boardActions = innerProps.boardActions;
|
||||
const entryPoint = innerProps.entryPoint ?? (boardActions ? "board" : "header");
|
||||
const trackSetup = useSetupAnalytics();
|
||||
const permissions = (session?.user.permissions ?? []) as GroupPermissionKey[];
|
||||
const definitions = getUniversalCreateActionDefinitions({
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
permissions,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedOpen.current) return;
|
||||
trackedOpen.current = true;
|
||||
trackSetup("surface-opened", {
|
||||
entryPoint,
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
}, [boardActions, entryPoint, trackSetup]);
|
||||
|
||||
const invokeAction = (key: UniversalCreateActionKey) => {
|
||||
const boardAction = key in (boardActions ?? {}) ? boardActions?.[key as BoardActionKey] : undefined;
|
||||
actions.closeModal();
|
||||
trackSetup("intent-selected", {
|
||||
entryPoint,
|
||||
intent: key,
|
||||
outcome: "continued",
|
||||
hasBoardContext: boardActions !== undefined,
|
||||
});
|
||||
|
||||
if (boardAction) {
|
||||
boardAction();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case "app":
|
||||
router.push("/manage/apps/new");
|
||||
break;
|
||||
case "integration":
|
||||
router.push("/manage/integrations/new");
|
||||
break;
|
||||
case "board":
|
||||
openAddBoardModal(undefined);
|
||||
break;
|
||||
case "workshop":
|
||||
router.push("/manage/workshop");
|
||||
break;
|
||||
case "customWidget":
|
||||
router.push("/manage/custom-widgets/new");
|
||||
break;
|
||||
case "widget":
|
||||
case "container":
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const resolvedActions = definitions.map(
|
||||
(definition) =>
|
||||
({
|
||||
...definition,
|
||||
name: t(`action.${definition.key}.label`),
|
||||
description: t(`action.${definition.key}.description`),
|
||||
keywords: t(`action.${definition.key}.keywords`).split(" "),
|
||||
}) satisfies RankedUniversalCreateAction,
|
||||
);
|
||||
const visibleActions = filterAndRankUniversalCreateActions(resolvedActions, query);
|
||||
const groups = ["currentBoard", "library", "boards"] satisfies UniversalCreateActionGroup[];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
data-autofocus
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
placeholder={t("search.placeholder")}
|
||||
aria-label={t("search.label")}
|
||||
/>
|
||||
|
||||
{visibleActions.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg">
|
||||
{t("search.noResults")}
|
||||
</Text>
|
||||
) : (
|
||||
groups.map((group) => {
|
||||
const groupActions = visibleActions.filter((action) => action.group === group);
|
||||
if (groupActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Stack key={group} gap={4}>
|
||||
<Text c="dimmed" fw={600} size="xs" tt="uppercase" px="sm">
|
||||
{t(`group.${group}`)}
|
||||
</Text>
|
||||
{groupActions.map((action) => {
|
||||
const Icon = actionIcons[action.key];
|
||||
return (
|
||||
<UnstyledButton key={action.key} className={classes.action} onClick={() => invokeAction(action.key)}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<ThemeIcon className={classes.actionIcon} variant="light" radius="md">
|
||||
<Icon size={18} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1}>
|
||||
<Text fw={600} size="sm">
|
||||
{action.name}
|
||||
</Text>
|
||||
<Text c="dimmed" size="xs">
|
||||
{action.description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle: (t) => t("universalCreate.title"),
|
||||
size: "lg",
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
/** Keeps Universal Create available from ordinary Spotlight search, without requiring command mode. */
|
||||
export const UniversalCreateSpotlightResult = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const item = useMemo(
|
||||
() => ({
|
||||
id: "universal-create",
|
||||
name: t("title"),
|
||||
description: t("trigger.description"),
|
||||
icon: IconPlus,
|
||||
alwaysVisible: true,
|
||||
interaction: () => ({
|
||||
type: "javaScript" as const,
|
||||
onSelect: () => {
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
},
|
||||
}),
|
||||
}),
|
||||
[openModal, t],
|
||||
);
|
||||
|
||||
useRegisterSpotlightContextResults("universal-create", [item], [item]);
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { HeaderButton } from "~/components/layout/header/button";
|
||||
import { UniversalCreateModal } from "./universal-create-modal";
|
||||
|
||||
export const UniversalCreateHeaderAction = () => {
|
||||
const t = useScopedI18n("universalCreate");
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
|
||||
return (
|
||||
<HeaderButton onClick={() => openModal({ entryPoint: "header" })} aria-label={t("trigger.label")}>
|
||||
<IconPlus stroke={1.5} />
|
||||
</HeaderButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const UniversalCreateQueryGate = () => {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { openModal } = useModalAction(UniversalCreateModal);
|
||||
const handled = useRef(false);
|
||||
const shouldOpen = searchParams.get("create") === "true";
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldOpen) {
|
||||
handled.current = false;
|
||||
return;
|
||||
}
|
||||
if (handled.current) return;
|
||||
|
||||
handled.current = true;
|
||||
openModal({ entryPoint: "spotlight" });
|
||||
|
||||
const nextSearchParams = new URLSearchParams(searchParams.toString());
|
||||
nextSearchParams.delete("create");
|
||||
const query = nextSearchParams.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
|
||||
}, [openModal, pathname, router, searchParams, shouldOpen]);
|
||||
|
||||
return null;
|
||||
};
|
||||
Reference in new issue
Block a user