Repository navigation
refactor(automations): one save state for the automation page #7087
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
38891e1
e49b67e
3ce4223
a4aab31
b7d00a5
d92d74e
b87207a
7532b14
8a25b8e
cdc6eaa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,32 @@ | ||
| import { Trans, useLingui } from "@lingui/react/macro"; | ||
| import type { SelectAutomationRun } from "@superset/db/schema"; | ||
| import { errorMessage } from "@superset/i18n/errors"; | ||
| import type { DraftTrigger } from "@superset/shared/automation-triggers"; | ||
| import type { RouterOutputs } from "@superset/trpc"; | ||
| import { Button } from "@superset/ui/button"; | ||
| import { toast } from "@superset/ui/sonner"; | ||
| import { Switch } from "@superset/ui/switch"; | ||
| import { cn } from "@superset/ui/utils"; | ||
| import { useMutation, useQueryClient } from "@tanstack/react-query"; | ||
| import { useEffect, useRef, useState } from "react"; | ||
| import { useMemo, useState } from "react"; | ||
| import { LuTriangleAlert } from "react-icons/lu"; | ||
| import { EmojiTextInput } from "renderer/components/EmojiTextInput"; | ||
| import { MarkdownEditor } from "renderer/components/MarkdownEditor"; | ||
| import { useHostUrl } from "renderer/hooks/host-service/useHostTargetUrl"; | ||
| import { useV2AgentChoices } from "renderer/hooks/useV2AgentChoices"; | ||
| import { apiTrpcClient } from "renderer/lib/api-trpc-client"; | ||
| import { useWorkspaceHostOptions } from "renderer/routes/_authenticated/components/DashboardNewWorkspaceModal/components/DashboardNewWorkspaceForm/components/DevicePicker/hooks/useWorkspaceHostOptions/useWorkspaceHostOptions"; | ||
| import { AgentPicker } from "../../../components/AgentPicker"; | ||
| import { useProviderOptions } from "../../../components/providers/useProviderOptions"; | ||
| import { useProjectFileSearch } from "../../../hooks/useProjectFileSearch"; | ||
| import { matchAgentChoice } from "../../../utils/agentIdentity"; | ||
| import { PreviousRunsList } from "../PreviousRunsList"; | ||
| import { type AutomationUpdatePatch, TriggersCard } from "../TriggersCard"; | ||
| import { | ||
| type AutomationUpdatePatch, | ||
| type ScopeDraft, | ||
| TriggersCard, | ||
| } from "../TriggersCard"; | ||
| import { useAutomationDraft } from "./hooks/useAutomationDraft"; | ||
|
|
||
| type DetailTab = "settings" | "runs"; | ||
|
|
||
|
|
@@ -39,35 +48,15 @@ export function AutomationBody({ | |
| }) { | ||
| const { t } = useLingui(); | ||
| const [tab, setTab] = useState<DetailTab>("settings"); | ||
| const [name, setName] = useState(automation.name); | ||
| const [prompt, setPrompt] = useState(automation.prompt); | ||
| const lastSyncedPromptRef = useRef(automation.prompt); | ||
| const queryClient = useQueryClient(); | ||
|
|
||
| useEffect(() => { | ||
| if (automation.prompt !== lastSyncedPromptRef.current) { | ||
| lastSyncedPromptRef.current = automation.prompt; | ||
| setPrompt(automation.prompt); | ||
| } | ||
| }, [automation.prompt]); | ||
|
|
||
| const updateMutation = useMutation({ | ||
| mutationFn: (patch: AutomationUpdatePatch) => | ||
| apiTrpcClient.automation.update.mutate({ id: automation.id, ...patch }), | ||
| // Only the trigger set gets a confirmation. Every other patch shows its | ||
| // own result — the picker relabels, the toggle flips — but a saved | ||
| // trigger set looks exactly like the unsaved one it replaced. | ||
| onSuccess: (_result, patch) => { | ||
| if (patch.triggers) | ||
| toast.success( | ||
| t({ | ||
| id: "dashboard.automations.body.triggersSavedToast", | ||
| message: "Triggers saved", | ||
| }), | ||
| ); | ||
| }, | ||
| // The pickers re-render from the Electric-synced row, so a rejected | ||
| // update silently snaps back without this. | ||
| onSuccess: () => | ||
| queryClient.invalidateQueries({ | ||
| queryKey: ["automation-versions", automation.id], | ||
| }), | ||
| onError: (error) => | ||
| toast.error( | ||
| errorMessage( | ||
|
|
@@ -80,36 +69,57 @@ export function AutomationBody({ | |
| ), | ||
| }); | ||
|
|
||
| const setPromptMutation = useMutation({ | ||
| mutationFn: (next: string) => | ||
| apiTrpcClient.automation.setPrompt.mutate({ | ||
| id: automation.id, | ||
| prompt: next, | ||
| const saved = useMemo( | ||
| () => ({ | ||
| name: automation.name, | ||
| prompt: automation.prompt, | ||
| agent: automation.agent, | ||
| targetHostId: automation.targetHostId, | ||
| v2ProjectId: automation.v2ProjectId, | ||
| v2WorkspaceId: automation.v2WorkspaceId, | ||
| tags: automation.tags, | ||
| triggers: automation.triggers.map((trigger) => ({ | ||
| id: trigger.id, | ||
| config: trigger.config as DraftTrigger["config"], | ||
| })), | ||
| }), | ||
| [automation], | ||
| ); | ||
|
|
||
| const { | ||
| draft, | ||
| dirty, | ||
| saving, | ||
| shownProblems, | ||
| banner, | ||
| edit, | ||
| editTriggers, | ||
| save, | ||
| discard, | ||
| } = useAutomationDraft(saved, async (next) => { | ||
| await updateMutation.mutateAsync(next); | ||
| toast.success( | ||
| t({ | ||
| id: "dashboard.automations.body.savedToast", | ||
| message: "Automation saved", | ||
| }), | ||
| onSuccess: () => { | ||
| queryClient.invalidateQueries({ | ||
| queryKey: ["automation-versions", automation.id], | ||
| }); | ||
| }, | ||
| onError: (error) => | ||
| toast.error( | ||
| errorMessage( | ||
| error, | ||
| t({ | ||
| id: "dashboard.automations.body.promptUpdateFailedToast", | ||
| message: "Failed to update prompt", | ||
| }), | ||
| ), | ||
| ), | ||
| ); | ||
| // Saving may have joined channels, which flips `botMember`. | ||
| optionState.slack?.refetch(); | ||
| }); | ||
|
|
||
| const { options, state: optionState } = useProviderOptions( | ||
| automation.organizationId, | ||
| draft.triggers, | ||
| ); | ||
|
|
||
| const searchFiles = useProjectFileSearch({ | ||
| hostId: automation.targetHostId ?? null, | ||
| projectId: automation.v2ProjectId, | ||
| hostId: draft.targetHostId, | ||
| projectId: draft.v2ProjectId, | ||
| }); | ||
|
|
||
| const { localHostId } = useWorkspaceHostOptions(); | ||
| const hostId = automation.targetHostId ?? localHostId ?? null; | ||
| const hostId = draft.targetHostId ?? localHostId ?? null; | ||
| const hostUrl = useHostUrl(hostId); | ||
| const { agents: hostAgents, isFetched: hostAgentsFetched } = | ||
| useV2AgentChoices(hostUrl); | ||
|
|
@@ -119,31 +129,59 @@ export function AutomationBody({ | |
| const agentMissing = | ||
| hostAgentsFetched && | ||
| hostAgents.length > 0 && | ||
| !matchAgentChoice(hostAgents, automation.agent); | ||
| !matchAgentChoice(hostAgents, draft.agent); | ||
|
|
||
| return ( | ||
| <div className="flex-1 overflow-y-auto px-12 py-8"> | ||
| {/* Full width, not a centered max-w column: a Slack sentence is wider | ||
| than 3xl and would wrap onto a second line, shifting the rows below | ||
| every time one renders. */} | ||
| <div className="flex w-full flex-col"> | ||
| <EmojiTextInput | ||
| value={name} | ||
| onChange={setName} | ||
| editable={!readOnly} | ||
| onBlur={(next) => { | ||
| if (readOnly) return; | ||
| const trimmed = next.trim(); | ||
| if (trimmed && trimmed !== automation.name) { | ||
| updateMutation.mutate({ name: trimmed }); | ||
| } | ||
| }} | ||
| placeholder={t({ | ||
| id: "dashboard.automations.body.titlePlaceholder", | ||
| message: "Automation title", | ||
| })} | ||
| className="mb-3 text-2xl font-semibold" | ||
| /> | ||
| <div className="mb-3 flex items-start gap-3"> | ||
| <EmojiTextInput | ||
| value={draft.name} | ||
| onChange={(next) => edit({ name: next })} | ||
| editable={!readOnly} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Prevent edits while a save is pending. The title and prompt remain editable when The later edit remains visible but has no Save control, so it is not persisted. Disable every editor while saving, or track a draft revision and retain dirty state when it changes during the commit. Also applies to: 316-316 🤖 Prompt for AI Agents |
||
| onBlur={(next) => { | ||
| if (readOnly) return; | ||
| edit({ name: next.trim() || automation.name }); | ||
| }} | ||
| placeholder={t({ | ||
| id: "dashboard.automations.body.titlePlaceholder", | ||
| message: "Automation title", | ||
| })} | ||
| className="min-w-0 flex-1 text-2xl font-semibold" | ||
| /> | ||
| {dirty && !readOnly && ( | ||
| <div className="flex shrink-0 items-center gap-1.5 pt-1"> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| size="sm" | ||
| onClick={discard} | ||
| disabled={saving} | ||
| className="h-7 text-[13px]" | ||
| > | ||
| <Trans id="dashboard.automations.body.discard">Discard</Trans> | ||
| </Button> | ||
| <Button | ||
| type="button" | ||
| size="sm" | ||
| onClick={save} | ||
| disabled={saving} | ||
| className="h-7 text-[13px]" | ||
| > | ||
| {saving ? ( | ||
| <Trans id="dashboard.automations.body.saving"> | ||
| Saving... | ||
| </Trans> | ||
| ) : ( | ||
| <Trans id="dashboard.automations.body.save">Save</Trans> | ||
| )} | ||
| </Button> | ||
| </div> | ||
| )} | ||
| </div> | ||
| <div className="flex items-center gap-2 text-sm"> | ||
| <Switch | ||
| checked={automation.enabled} | ||
|
|
@@ -174,6 +212,13 @@ export function AutomationBody({ | |
| <span className="text-muted-foreground">{ownerName}</span> | ||
| </> | ||
| )} | ||
|
|
||
| {banner && ( | ||
| <p className="flex min-w-0 items-center gap-1.5 text-[13px] text-amber-600 dark:text-amber-400"> | ||
| <LuTriangleAlert className="size-3.5 shrink-0" /> | ||
| <span className="truncate">{banner}</span> | ||
| </p> | ||
| )} | ||
| </div> | ||
| {readOnly && ( | ||
| <p className="select-text cursor-text mt-2 text-xs text-muted-foreground"> | ||
|
|
@@ -230,13 +275,18 @@ export function AutomationBody({ | |
| automation={automation} | ||
| hostId={hostId} | ||
| readOnly={readOnly} | ||
| onUpdate={(patch) => updateMutation.mutate(patch)} | ||
| // Awaited, unlike the pickers: the editor holds the only copy | ||
| // of an unsaved set, so it must not clear its dirty state until | ||
| // the write actually lands. | ||
| onSaveTriggers={(triggers) => | ||
| updateMutation.mutateAsync({ triggers }) | ||
| } | ||
| scope={{ | ||
| v2ProjectId: draft.v2ProjectId, | ||
| targetHostId: draft.targetHostId, | ||
| v2WorkspaceId: draft.v2WorkspaceId, | ||
| tags: draft.tags, | ||
| }} | ||
| onScopeChange={(patch: Partial<ScopeDraft>) => edit(patch)} | ||
| drafts={draft.triggers} | ||
| onEditTriggers={editTriggers} | ||
| problems={shownProblems} | ||
| options={options} | ||
| optionState={optionState} | ||
| /> | ||
|
|
||
| <span className="mt-8 mb-2 text-sm text-muted-foreground"> | ||
|
|
@@ -247,15 +297,10 @@ export function AutomationBody({ | |
| <div className="flex flex-col rounded-xl border border-border bg-card/40"> | ||
| <div className="min-h-[240px] px-4 py-3"> | ||
| <MarkdownEditor | ||
| content={prompt} | ||
| onChange={setPrompt} | ||
| content={draft.prompt} | ||
| // No onSave: it fires on blur, which would save twice. | ||
| onChange={(next: string) => edit({ prompt: next })} | ||
| editable={!readOnly} | ||
| onSave={(next) => { | ||
| if (readOnly) return; | ||
| if (next !== automation.prompt) { | ||
| setPromptMutation.mutate(next); | ||
| } | ||
| }} | ||
| placeholder={t({ | ||
| id: "dashboard.automations.body.promptPlaceholder", | ||
| message: "Add prompt e.g. look for crashes in $sentry", | ||
|
|
@@ -267,19 +312,20 @@ export function AutomationBody({ | |
| <AgentPicker | ||
| hostId={hostId} | ||
| disabled={readOnly} | ||
| value={automation.agent} | ||
| value={draft.agent} | ||
| onChange={(id) => { | ||
| // The picker is scoped to `hostId` and emits a preset slug | ||
| // when unambiguous, falling back to the instance UUID. If | ||
| // the automation was previously auto-routed (targetHostId | ||
| // null), pin it to the host this value came from so a | ||
| // UUID-shaped agent can't be dispatched to a host that's | ||
| // never seen it. | ||
| const patch: AutomationUpdatePatch = { agent: id }; | ||
| if (!automation.targetHostId && hostId) { | ||
| patch.targetHostId = hostId; | ||
| } | ||
| updateMutation.mutate(patch); | ||
| edit({ | ||
| agent: id, | ||
| ...(!draft.targetHostId && hostId | ||
| ? { targetHostId: hostId } | ||
| : {}), | ||
| }); | ||
| }} | ||
| /> | ||
| </div> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { type AutomationDraft, useAutomationDraft } from "./useAutomationDraft"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: After saving a newly added trigger, the draft still lacks the server-assigned
id, but this callback marks it clean without adopting persisted trigger rows. If the user edits and saves again before the query refreshes, the server treats that trigger as new, deletes the original row, and recreates it, losing row state such as a webhook signing key. Refetch and adopt the persisted trigger set, including IDs, before clearing the draft.Prompt for AI agents