From 65657251d672b6ff5434ce9df37a7f77357e97b8 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 5 May 2026 13:05:29 -0700 Subject: [PATCH] Add remove in-use toasts --- packages/react/src/api/atoms.tsx | 20 +++++++++++ packages/react/src/pages/connections.tsx | 27 ++++++++++----- packages/react/src/pages/secrets.tsx | 44 ++++++++++++++++-------- 3 files changed, 68 insertions(+), 23 deletions(-) diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index f9779a712..d65f2443e 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -153,6 +153,26 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); +// --------------------------------------------------------------------------- +// Secrets — optimistic removals. +// --------------------------------------------------------------------------- + +export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) => + Atom.optimistic(secretsAtom(scopeId)), +); + +export const removeSecretOptimistic = Atom.family((scopeId: ScopeId) => + secretsOptimisticAtom(scopeId).pipe( + Atom.optimisticFn({ + reducer: (current, arg) => + AsyncResult.map(current, (rows) => + rows.filter((secret) => secret.id !== arg.params.secretId), + ), + fn: removeSecret, + }), + ), +); + // --------------------------------------------------------------------------- // Policies — optimistic surface. Reads go through `policiesOptimisticAtom` // (which layers in-flight transitions on top of `policiesAtom`), and writes diff --git a/packages/react/src/pages/connections.tsx b/packages/react/src/pages/connections.tsx index a5570d777..697c94dff 100644 --- a/packages/react/src/pages/connections.tsx +++ b/packages/react/src/pages/connections.tsx @@ -1,7 +1,9 @@ import { Suspense } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { ConnectionId, type ScopeId } from "@executor-js/sdk"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk"; import { toast } from "sonner"; import { connectionUsagesAtom, removeConnection } from "../api/atoms"; @@ -161,18 +163,25 @@ export function ConnectionsPage() { const scopeStack = useScopeStack(); const connections = useConnectionsWithPendingRemovals(scopeId); const { beginRemove } = usePendingConnectionRemovals(); - const doRemove = useAtomSet(removeConnection, { mode: "promise" }); + const doRemove = useAtomSet(removeConnection, { mode: "promiseExit" }); const handleRemove = async (connectionId: string) => { const pending = beginRemove(connectionId); - try { - await doRemove({ - params: { scopeId, connectionId: ConnectionId.make(connectionId) }, - reactivityKeys: connectionWriteKeys, - }); - } catch (e) { + const exit = await doRemove({ + params: { scopeId, connectionId: ConnectionId.make(connectionId) }, + reactivityKeys: connectionWriteKeys, + }); + if (Exit.isFailure(exit)) { pending.undo(); - toast.error(e instanceof Error ? e.message : "Failed to remove connection"); + const error = Exit.findErrorOption(exit); + if (Option.isSome(error) && error.value instanceof ConnectionInUseError) { + const count = error.value.usageCount; + toast.error( + `Connection is used by ${count} ${count === 1 ? "source" : "sources"}. Detach it before removing it.`, + ); + } else { + toast.error("Failed to remove connection"); + } } }; diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index 09429d001..66fb592ef 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -1,10 +1,17 @@ import { useMemo, useState, Suspense } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { secretsAtom, secretUsagesAtom, removeSecret } from "../api/atoms"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import { toast } from "sonner"; +import { + removeSecretOptimistic, + secretsOptimisticAtom, + secretUsagesAtom, +} from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; -import { SecretId, type ScopeId } from "@executor-js/sdk"; +import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk"; import { SecretForm } from "../plugins/secret-form"; import { useScope } from "../hooks/use-scope"; import { @@ -234,7 +241,7 @@ export function SecretsPage(props: { const secretProviderPlugins = useSecretProviderPlugins(); const [addOpen, setAddOpen] = useState(false); const scopeId = useScope(); - const secrets = useAtomValue(secretsAtom(scopeId)); + const secrets = useAtomValue(secretsOptimisticAtom(scopeId)); const existingSecretIds = useMemo( () => AsyncResult.match(secrets, { @@ -244,19 +251,28 @@ export function SecretsPage(props: { }), [secrets], ); - const doRemove = useAtomSet(removeSecret, { mode: "promise" }); + const doRemove = useAtomSet(removeSecretOptimistic(scopeId), { + mode: "promiseExit", + }); const handleRemove = async (secretId: string) => { - try { - await doRemove({ - params: { - scopeId, - secretId: SecretId.make(secretId), - }, - reactivityKeys: secretWriteKeys, - }); - } catch { - // TODO: toast + const exit = await doRemove({ + params: { + scopeId, + secretId: SecretId.make(secretId), + }, + reactivityKeys: secretWriteKeys, + }); + if (Exit.isFailure(exit)) { + const error = Exit.findErrorOption(exit); + if (Option.isSome(error) && error.value instanceof SecretInUseError) { + const count = error.value.usageCount; + toast.error( + `Secret is used by ${count} ${count === 1 ? "source" : "sources"}. Detach it before removing it.`, + ); + } else { + toast.error("Failed to remove secret"); + } } };