Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/react/src/api/atoms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 18 additions & 9 deletions packages/react/src/pages/connections.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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");
}
}
};

Expand Down
44 changes: 30 additions & 14 deletions packages/react/src/pages/secrets.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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, {
Expand All @@ -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");
}
}
};

Expand Down
Loading