diff --git a/src/prefect/_internal/concurrency/services.py b/src/prefect/_internal/concurrency/services.py
index 1f992763ef83..eabee601948a 100644
--- a/src/prefect/_internal/concurrency/services.py
+++ b/src/prefect/_internal/concurrency/services.py
@@ -32,7 +32,7 @@ def __init__(self, *args: Hashable) -> None:
self._task: Optional[asyncio.Task[None]] = None
self._stopped: bool = False
self._started: bool = False
- self._key = hash(args)
+ self._key = hash((self.__class__, *args))
self._lock = threading.Lock()
self._queue_get_thread = WorkerThread(
# TODO: This thread should not need to be a daemon but when it is not, it
@@ -256,7 +256,7 @@ def instance(cls, *args: Hashable) -> Self:
If an instance already exists with the given arguments, it will be returned.
"""
with cls._instance_lock:
- key = hash(args)
+ key = hash((cls, *args))
if key not in cls._instances:
cls._instances[key] = cls._new_instance(*args)
diff --git a/tests/_internal/concurrency/test_services.py b/tests/_internal/concurrency/test_services.py
index fe41e17820b3..15cea7996acc 100644
--- a/tests/_internal/concurrency/test_services.py
+++ b/tests/_internal/concurrency/test_services.py
@@ -97,6 +97,14 @@ def test_instance_returns_new_instance_with_unique_key():
assert isinstance(new_instance, MockService)
+def test_different_subclasses_have_unique_instances():
+ instance = MockService.instance()
+ assert isinstance(instance, MockService)
+ new_instance = MockBatchedService.instance()
+ assert new_instance is not instance
+ assert isinstance(new_instance, MockBatchedService)
+
+
def test_instance_returns_same_instance_after_error():
event = threading.Event()
diff --git a/ui-v2/src/components/automations/automation-page/automation-page.tsx b/ui-v2/src/components/automations/automation-page/automation-page.tsx
new file mode 100644
index 000000000000..94f0f01a0bb7
--- /dev/null
+++ b/ui-v2/src/components/automations/automation-page/automation-page.tsx
@@ -0,0 +1,109 @@
+import { Automation, buildGetAutomationQuery } from "@/api/automations";
+import { AutomationEnableToggle } from "@/components/automations/automation-enable-toggle";
+import { AutomationsActionsMenu } from "@/components/automations/automations-actions-menu";
+import { useDeleteAutomationConfirmationDialog } from "@/components/automations/use-delete-automation-confirmation-dialog";
+import { Card } from "@/components/ui/card";
+import { DeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
+import { Typography } from "@/components/ui/typography";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { NavHeader } from "./nav-header";
+
+type AutomationPageProps = {
+ id: string;
+};
+
+export const AutomationPage = ({ id }: AutomationPageProps) => {
+ const { data } = useSuspenseQuery(buildGetAutomationQuery(id));
+ const [dialogState, confirmDelete] = useDeleteAutomationConfirmationDialog();
+
+ const handleDelete = () => confirmDelete(data, { shouldNavigate: true });
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+type AutomationPageHeaderProps = {
+ data: Automation;
+ onDelete: () => void;
+};
+
+const AutomationPageHeader = ({
+ data,
+ onDelete,
+}: AutomationPageHeaderProps) => {
+ return (
+
+ );
+};
+
+type AutomationDescriptionProps = {
+ data: Automation;
+};
+
+const AutomationDescription = ({ data }: AutomationDescriptionProps) => {
+ return (
+
+
+ Description
+
+
+ {data.description || "None"}
+
+
+ );
+};
+
+type AutomationTriggerProps = {
+ data: Automation;
+};
+
+const AutomationTrigger = ({ data }: AutomationTriggerProps) => {
+ const { trigger } = data;
+ return (
+
+ Trigger
+
+ TODO: {JSON.stringify(trigger)}
+
+
+ );
+};
+
+type AutomationActionsProps = {
+ data: Automation;
+};
+
+const AutomationActions = ({ data }: AutomationActionsProps) => {
+ const { actions } = data;
+ return (
+
+
{`Action${actions.length > 1 ? "s" : ""}`}
+
+ {actions.map((action, i) => (
+ -
+
+
TODO: {JSON.stringify(action)}
+
+
+ ))}
+
+
+ );
+};
diff --git a/ui-v2/src/components/automations/automation-page/index.ts b/ui-v2/src/components/automations/automation-page/index.ts
new file mode 100644
index 000000000000..24c21ecdd121
--- /dev/null
+++ b/ui-v2/src/components/automations/automation-page/index.ts
@@ -0,0 +1 @@
+export { AutomationPage } from "./automation-page";
diff --git a/ui-v2/src/components/automations/automation-page/nav-header.tsx b/ui-v2/src/components/automations/automation-page/nav-header.tsx
new file mode 100644
index 000000000000..f99625e02129
--- /dev/null
+++ b/ui-v2/src/components/automations/automation-page/nav-header.tsx
@@ -0,0 +1,32 @@
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@/components/ui/breadcrumb";
+
+type NavHeaderProps = {
+ name: string;
+};
+
+export const NavHeader = ({ name }: NavHeaderProps) => {
+ return (
+
+
+
+
+
+ Automations
+
+
+
+
+ {name}
+
+
+
+
+ );
+};
diff --git a/ui-v2/src/components/automations/automations-header/automations-header.tsx b/ui-v2/src/components/automations/automations-header/automations-header.tsx
index 93b13b7449ea..6d81703e7cd4 100644
--- a/ui-v2/src/components/automations/automations-header/automations-header.tsx
+++ b/ui-v2/src/components/automations/automations-header/automations-header.tsx
@@ -1,7 +1,6 @@
import {
Breadcrumb,
BreadcrumbItem,
- BreadcrumbLink,
BreadcrumbList,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
@@ -22,20 +21,20 @@ const Header = () => (
-
-
- Automations
-
+
+ Automations
-
+
);
diff --git a/ui-v2/src/components/automations/automations-page.tsx b/ui-v2/src/components/automations/automations-page.tsx
new file mode 100644
index 000000000000..12acba4493c9
--- /dev/null
+++ b/ui-v2/src/components/automations/automations-page.tsx
@@ -0,0 +1,19 @@
+import { buildListAutomationsQuery } from "@/api/automations";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { AutomationsEmptyState } from "./automations-empty-state";
+import { AutomationsHeader } from "./automations-header";
+
+export const AutomationsPage = () => {
+ const { data } = useSuspenseQuery(buildListAutomationsQuery());
+
+ return (
+
+
+ {data.length === 0 ? (
+
+ ) : (
+
TODO: AUTOMATIONS_LIST
+ )}
+
+ );
+};
diff --git a/ui-v2/src/components/automations/automations-wizard/actions-step/automations-select-state-fields.tsx b/ui-v2/src/components/automations/automations-wizard/actions-step/automations-select-state-fields.tsx
index 9cf7229ef553..659b87f23f6f 100644
--- a/ui-v2/src/components/automations/automations-wizard/actions-step/automations-select-state-fields.tsx
+++ b/ui-v2/src/components/automations/automations-wizard/actions-step/automations-select-state-fields.tsx
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/form";
import { Skeleton } from "@/components/ui/skeleton";
import { useQuery } from "@tanstack/react-query";
+import { useDeferredValue, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
const INFER_AUTOMATION = {
@@ -44,37 +45,35 @@ const getButtonLabel = (
return INFER_AUTOMATION.name;
}
const automation = data?.find((automation) => automation.id === fieldValue);
- if (automation?.name) {
+ if (automation) {
return automation.name;
}
return undefined;
};
-/** Because ShadCN only filters by `value` and not by a specific field, we need to write custom logic to filter objects by id */
-const filterAutomations = (
- value: string | null,
- search: string,
- data: Array | undefined,
-) => {
- const searchTerm = search.toLowerCase();
- const automation = data?.find((automation) => automation.id === value);
- if (!automation) {
- return 0;
- }
- const automationName = automation.name.toLowerCase();
- if (automationName.includes(searchTerm)) {
- return 1;
- }
- return 0;
-};
-
export const AutomationsSelectStateFields = ({
action,
index,
}: AutomationsSelectStateFieldsProps) => {
+ const [search, setSearch] = useState("");
const form = useFormContext();
const { data, isSuccess } = useQuery(buildListAutomationsQuery());
+ // nb: because automations API does not have filtering _like by name, do client-side filtering
+ const deferredSearch = useDeferredValue(search);
+ const filteredData = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+ return data.filter((automation) =>
+ automation.name.toLowerCase().includes(deferredSearch.toLowerCase()),
+ );
+ }, [data, deferredSearch]);
+
+ const isInferredOptionFiltered = INFER_AUTOMATION.name
+ .toLowerCase()
+ .includes(deferredSearch.toLowerCase());
+
return (
- {getButtonLabel(data, field.value) ?? "Select automation"}
+ {buttonLabel ?? "Select automation"}
-
- filterAutomations(value, search, data)
- }
- >
-
+
+
No automation found
-
- {INFER_AUTOMATION.name}
-
+ {isInferredOptionFiltered && (
+ {
+ field.onChange(value);
+ setSearch("");
+ }}
+ value={INFER_AUTOMATION.value}
+ >
+ {INFER_AUTOMATION.name}
+
+ )}
{isSuccess ? (
- data.map((automation) => (
+ filteredData.map((automation) => (
{
+ field.onChange(value);
+ setSearch("");
+ }}
value={automation.id}
>
{automation.name}
diff --git a/ui-v2/src/components/automations/use-delete-automation-confirmation-dialog.ts b/ui-v2/src/components/automations/use-delete-automation-confirmation-dialog.ts
new file mode 100644
index 000000000000..bdbdf0f0e9c7
--- /dev/null
+++ b/ui-v2/src/components/automations/use-delete-automation-confirmation-dialog.ts
@@ -0,0 +1,45 @@
+import { Automation, useDeleteAutomation } from "@/api/automations";
+import { useDeleteConfirmationDialog } from "@/components/ui/delete-confirmation-dialog";
+import { useToast } from "@/hooks/use-toast";
+import { getRouteApi } from "@tanstack/react-router";
+
+const routeApi = getRouteApi("/concurrency-limits/");
+
+export const useDeleteAutomationConfirmationDialog = () => {
+ const navigate = routeApi.useNavigate();
+ const { toast } = useToast();
+ const [dialogState, confirmDelete] = useDeleteConfirmationDialog();
+
+ const { deleteAutomation } = useDeleteAutomation();
+
+ const handleConfirmDelete = (
+ automation: Automation,
+ {
+ shouldNavigate = false,
+ }: {
+ /** Should navigate back to /automations */
+ shouldNavigate?: boolean;
+ },
+ ) =>
+ confirmDelete({
+ title: "Delete Automation",
+ description: `Are you sure you want to delete ${automation.name}?`,
+ onConfirm: () => {
+ deleteAutomation(automation.id, {
+ onSuccess: () => {
+ toast({ title: "Automation deleted" });
+ if (shouldNavigate) {
+ void navigate({ to: "/automations" });
+ }
+ },
+ onError: (error) => {
+ const message =
+ error.message || "Unknown error while deleting automation.";
+ console.error(message);
+ },
+ });
+ },
+ });
+
+ return [dialogState, handleConfirmDelete] as const;
+};
diff --git a/ui-v2/src/components/ui/combobox/combobox.tsx b/ui-v2/src/components/ui/combobox/combobox.tsx
index dc0a409acab6..a46c639d6aa0 100644
--- a/ui-v2/src/components/ui/combobox/combobox.tsx
+++ b/ui-v2/src/components/ui/combobox/combobox.tsx
@@ -67,21 +67,34 @@ const ComboboxTrigger = ({
};
const ComboboxContent = ({
- filter,
children,
}: {
- filter?: (value: string, search: string, keywords?: string[]) => number;
children: React.ReactNode;
}) => {
return (
- {children}
+ {children}
);
};
-const ComboboxCommandInput = ({ placeholder }: { placeholder?: string }) => {
- return ;
+const ComboboxCommandInput = ({
+ value,
+ onValueChange,
+ placeholder,
+}: {
+ value?: string;
+ onValueChange?: (value: string) => void;
+ placeholder?: string;
+}) => {
+ return (
+
+ );
};
const ComboboxCommandList = ({ children }: { children: React.ReactNode }) => {
diff --git a/ui-v2/src/components/ui/combobox/comboxbox.stories.tsx b/ui-v2/src/components/ui/combobox/comboxbox.stories.tsx
index 1340e9b4c432..d16fef76abd1 100644
--- a/ui-v2/src/components/ui/combobox/comboxbox.stories.tsx
+++ b/ui-v2/src/components/ui/combobox/comboxbox.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { Automation } from "@/api/automations";
import { createFakeAutomation } from "@/mocks";
-import { useState } from "react";
+import { useDeferredValue, useMemo, useState } from "react";
import {
Combobox,
ComboboxCommandEmtpy,
@@ -27,13 +27,7 @@ const INFER_AUTOMATION = {
name: "Infer Automation" as const,
} as const;
-const MOCK_DATA = [
- createFakeAutomation(),
- createFakeAutomation(),
- createFakeAutomation(),
- createFakeAutomation(),
- createFakeAutomation(),
-];
+const MOCK_DATA = Array.from({ length: 5 }, createFakeAutomation);
const getButtonLabel = (data: Array, fieldValue: string) => {
if (fieldValue === INFER_AUTOMATION.value) {
@@ -46,29 +40,24 @@ const getButtonLabel = (data: Array, fieldValue: string) => {
return undefined;
};
-/** Because ShadCN only filters by `value` and not by a specific field, we need to write custom logic to filter objects by id */
-const filterAutomations = (
- value: string,
- search: string,
- data: Array | undefined,
-) => {
- const searchTerm = search.toLowerCase();
- const automation = data?.find((automation) => automation.id === value);
- if (!automation) {
- return 0;
- }
- const automationName = automation.name.toLowerCase();
- if (automationName.includes(searchTerm)) {
- return 1;
- }
- return 0;
-};
-
function ComboboxStory() {
+ const [search, setSearch] = useState("");
const [selectedAutomationId, setSelectedAutomationId] = useState<
typeof UNASSIGNED | (string & {})
>(INFER_AUTOMATION.value);
+ const deferredSearch = useDeferredValue(search);
+
+ const filteredData = useMemo(() => {
+ return MOCK_DATA.filter((automation) =>
+ automation.name.toLowerCase().includes(deferredSearch.toLowerCase()),
+ );
+ }, [deferredSearch]);
+
+ const isInferredOptionFiltered = INFER_AUTOMATION.name
+ .toLowerCase()
+ .includes(deferredSearch.toLowerCase());
+
const buttonLabel = getButtonLabel(MOCK_DATA, selectedAutomationId);
return (
@@ -76,25 +65,35 @@ function ComboboxStory() {
{buttonLabel ?? "Select automation"}
- filterAutomations(value, search, MOCK_DATA)}
- >
-
+
+
No automation found
-
- {INFER_AUTOMATION.name}
-
- {MOCK_DATA.map((automation) => (
+ {isInferredOptionFiltered && (
+ {
+ setSelectedAutomationId(value);
+ setSearch("");
+ }}
+ value={INFER_AUTOMATION.value}
+ >
+ {INFER_AUTOMATION.name}
+
+ )}
+ {filteredData.map((automation) => (
{
+ setSelectedAutomationId(value);
+ setSearch("");
+ }}
value={automation.id}
>
{automation.name}
diff --git a/ui-v2/src/components/ui/delete-confirmation-dialog/index.tsx b/ui-v2/src/components/ui/delete-confirmation-dialog/index.ts
similarity index 100%
rename from ui-v2/src/components/ui/delete-confirmation-dialog/index.tsx
rename to ui-v2/src/components/ui/delete-confirmation-dialog/index.ts
diff --git a/ui-v2/src/routeTree.gen.ts b/ui-v2/src/routeTree.gen.ts
index 48ead2dc29a9..a7a79e422456 100644
--- a/ui-v2/src/routeTree.gen.ts
+++ b/ui-v2/src/routeTree.gen.ts
@@ -601,7 +601,7 @@ export const routeTree = rootRoute
"filePath": "runs/index.tsx"
},
"/automations/automation/$id": {
- "filePath": "automations/automation.$id.ts",
+ "filePath": "automations/automation.$id.tsx",
"children": [
"/automations/automation/$id/edit"
]
diff --git a/ui-v2/src/routes/automations/automation.$id.ts b/ui-v2/src/routes/automations/automation.$id.tsx
similarity index 72%
rename from ui-v2/src/routes/automations/automation.$id.ts
rename to ui-v2/src/routes/automations/automation.$id.tsx
index f1286e4bcbf7..b75eee89c196 100644
--- a/ui-v2/src/routes/automations/automation.$id.ts
+++ b/ui-v2/src/routes/automations/automation.$id.tsx
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { buildGetAutomationQuery } from "@/api/automations";
+import { AutomationPage } from "@/components/automations/automation-page";
export const Route = createFileRoute("/automations/automation/$id")({
component: RouteComponent,
@@ -10,5 +11,6 @@ export const Route = createFileRoute("/automations/automation/$id")({
});
function RouteComponent() {
- return "🚧🚧 Pardon our dust! 🚧🚧";
+ const { id } = Route.useParams();
+ return ;
}
diff --git a/ui-v2/src/routes/automations/index.ts b/ui-v2/src/routes/automations/index.ts
index 53ad499aec45..b823c20ef5f1 100644
--- a/ui-v2/src/routes/automations/index.ts
+++ b/ui-v2/src/routes/automations/index.ts
@@ -1,14 +1,11 @@
import { buildListAutomationsQuery } from "@/api/automations";
+import { AutomationsPage } from "@/components/automations/automations-page";
import { createFileRoute } from "@tanstack/react-router";
// nb: Currently there is no filtering or search params used on this page
export const Route = createFileRoute("/automations/")({
- component: RouteComponent,
+ component: AutomationsPage,
loader: ({ context }) =>
context.queryClient.ensureQueryData(buildListAutomationsQuery()),
wrapInSuspense: true,
});
-
-function RouteComponent() {
- return "🚧🚧 Pardon our dust! 🚧🚧";
-}