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" : ""}`} + +
+ ); +}; 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! 🚧🚧"; -}