diff --git a/AGENTS.md b/AGENTS.md index 32a74b8c9..1748b7dc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - Before starting a feature, skim an existing page or form with similar behavior and mirror the conventions—this codebase is intentionally conventional. Look for similar pages in `app/pages` and forms in `app/forms` to use as templates. - `@oxide/api` is at `app/api` and `@oxide/api-mocks` is at `mock-api/index.ts`. - The language server often has out of date errors. TypeScript 7 is extremely fast, so confirm errors that come from the language server by running `npm run tsc` +- This repo uses oxfmt and oxlint, not prettier or eslint - Use Node.js 22+, then install deps and start the mock-backed dev server (skip if `npm run dev` is already running in another terminal): ```sh @@ -48,6 +49,7 @@ # Mutations & UI flow - Wrap writes in `useApiMutation`, use `confirmAction` to guard destructive intent, and surface results with `addToast`. +- When a form's `onSuccess` always navigates away, pass `loading={mutation.isPending || mutation.isSuccess}` to the form shell. `isPending` alone flips false before the navigation unmounts the modal, so the button's spinner animates back out right before close. Skip `isSuccess` if the form can stay open and be reused after success, or if the mutation lives in a component that survives the modal (e.g., a tab page with `{open && }`) — there success closes the modal synchronously so `isPending` alone is glitch-free, and a sticky `isSuccess` would strand a spinner on next open. - Mutation error display depends on context. In forms, errors display inline via `submitError={mutation.error}` — do not add `onError` with a toast to the `useApiMutation` call. In `confirmAction`/`confirmDelete` flows, the confirm modal catches the error and shows a toast using `errorTitle` — do not also add `onError` on the mutation, or the user will see two toasts. For standalone actions (fire-and-forget `mutate` calls not wrapped in a confirm modal or form), use `onError` on the mutation to show an error toast. - Keep page scaffolding consistent: `PageHeader`, `PageTitle`, `DocsPopover`, `RefreshButton`, `PropertiesTable`, and `CardBlock` provide the expected layout for new system pages. - When a page should be discoverable from the command palette, extend `useQuickActions` with the new entry so it appears in the quick actions menu (see `app/pages/ProjectsPage.tsx:100-115`). diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 0afc39048..d98ec68cc 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -85,7 +85,7 @@ export const AttachEphemeralIpModal = ({ submitLabel="Attach" submitDisabled={submitDisabled} submitError={instanceEphemeralIpAttach.error} - loading={instanceEphemeralIpAttach.isPending} + loading={instanceEphemeralIpAttach.isPending || instanceEphemeralIpAttach.isSuccess} onSubmit={({ pool }) => { instanceEphemeralIpAttach.mutate({ path: { instance }, diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index a6563145a..b24adf40d 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -91,7 +91,7 @@ export const AttachFloatingIpModal = ({ onDismiss={onDismiss} submitLabel="Attach floating IP" submitError={floatingIpAttach.error} - loading={floatingIpAttach.isPending} + loading={floatingIpAttach.isPending || floatingIpAttach.isSuccess} title="Attach floating IP" onSubmit={() => floatingIpAttach.mutate({ diff --git a/app/forms/anti-affinity-group-create.tsx b/app/forms/anti-affinity-group-create.tsx index dfaaaa037..5b84f002b 100644 --- a/app/forms/anti-affinity-group-create.tsx +++ b/app/forms/anti-affinity-group-create.tsx @@ -62,7 +62,7 @@ export default function CreateAntiAffinityGroupForm() { body: { ...values, failureDomain: 'sled' }, }) } - loading={createAntiAffinityGroup.isPending} + loading={createAntiAffinityGroup.isPending || createAntiAffinityGroup.isSuccess} submitError={createAntiAffinityGroup.error} submitLabel="Add group" > diff --git a/app/forms/anti-affinity-group-edit.tsx b/app/forms/anti-affinity-group-edit.tsx index d238827f8..82736bf88 100644 --- a/app/forms/anti-affinity-group-edit.tsx +++ b/app/forms/anti-affinity-group-edit.tsx @@ -77,7 +77,7 @@ export default function EditAntiAffintyGroupForm() { body: values, }) }} - loading={editAntiAffinityGroup.isPending} + loading={editAntiAffinityGroup.isPending || editAntiAffinityGroup.isSuccess} submitError={editAntiAffinityGroup.error} submitLabel="Edit group" > diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 32c097d1e..8de92bd88 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -191,7 +191,7 @@ export function CreateDiskSideModalForm({ createDisk.mutate({ query: { project }, body }) } }} - loading={createDisk.isPending} + loading={createDisk.isPending || createDisk.isSuccess} submitError={createDisk.error} > diff --git a/app/forms/external-subnet-edit.tsx b/app/forms/external-subnet-edit.tsx index 68260f7ca..b1f00d29d 100644 --- a/app/forms/external-subnet-edit.tsx +++ b/app/forms/external-subnet-edit.tsx @@ -98,7 +98,7 @@ export default function EditExternalSubnetSideModalForm() { body: { name, description }, }) }} - loading={editExternalSubnet.isPending} + loading={editExternalSubnet.isPending || editExternalSubnet.isSuccess} submitError={editExternalSubnet.error} > diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 7eaa231f7..613e18c1d 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -122,7 +122,7 @@ export default function CreateFirewallRuleForm() { }, }) }} - loading={updateRules.isPending} + loading={updateRules.isPending || updateRules.isSuccess} submitError={updateRules.error} submitLabel="Add rule" > diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 48cff6e12..e3b0438cd 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -132,7 +132,7 @@ export default function EditFirewallRuleForm() { } // validationSchema={validationSchema} // validateOnBlur - loading={updateRules.isPending} + loading={updateRules.isPending || updateRules.isSuccess} submitError={updateRules.error} > diff --git a/app/forms/fleet-access.tsx b/app/forms/fleet-access.tsx index 018097f55..9b5ed0447 100644 --- a/app/forms/fleet-access.tsx +++ b/app/forms/fleet-access.tsx @@ -66,7 +66,7 @@ export function FleetAccessAddUserSideModal({ body: updateRole({ identityId, identityType, roleName }, policy), }) }} - loading={updatePolicy.isPending} + loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} > { updatePolicy.reset() // clear API error state so it doesn't persist on next open diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index b36b218fe..981eafa2d 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -94,7 +94,7 @@ export default function CreateFloatingIpSideModalForm() { } createFloatingIp.mutate({ query: projectSelector, body }) }} - loading={createFloatingIp.isPending} + loading={createFloatingIp.isPending || createFloatingIp.isSuccess} submitError={createFloatingIp.error} > diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index c5b44542d..261377d2f 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -97,7 +97,7 @@ export default function EditFloatingIpSideModalForm() { body: { name, description }, }) }} - loading={editFloatingIp.isPending} + loading={editFloatingIp.isPending || editFloatingIp.isSuccess} submitError={editFloatingIp.error} > diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 9ff9c039b..ffc9458b8 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -129,7 +129,7 @@ export default function CreateIdpSideModalForm() { }, }) }} - loading={createIdp.isPending} + loading={createIdp.isPending || createIdp.isSuccess} submitError={createIdp.error} submitLabel="Create provider" > diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 012548590..07fc254f5 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -89,7 +89,7 @@ export default function CreateImageFromSnapshotSideModalForm() { }) } submitError={createImage.error} - loading={createImage.isPending} + loading={createImage.isPending || createImage.isSuccess} > {data.name} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 6065a08fb..6674c0e17 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -606,7 +606,7 @@ export default function CreateInstanceForm() { }, }) }} - loading={createInstance.isPending} + loading={createInstance.isPending || createInstance.isSuccess} submitError={createInstance.error} > @@ -854,7 +854,9 @@ export default function CreateInstanceForm() { disabled={isSubmitting} /> - Create instance + + Create instance + navigate(pb.instances({ project }))} /> diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index 67bd8e9d3..98b5dc326 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -59,7 +59,7 @@ export default function CreateIpPoolSideModalForm() { onSubmit={({ name, description, ipVersion, poolType }) => { createPool.mutate({ body: { name, description, ipVersion, poolType } }) }} - loading={createPool.isPending} + loading={createPool.isPending || createPool.isSuccess} submitError={createPool.error} > diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index fee459f14..ff8e13f8d 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -71,7 +71,7 @@ export default function EditIpPoolSideModalForm() { onSubmit={({ name, description }) => { editPool.mutate({ path: poolSelector, body: { name, description } }) }} - loading={editPool.isPending} + loading={editPool.isPending || editPool.isSuccess} submitError={editPool.error} > diff --git a/app/forms/ip-pool-range-add.tsx b/app/forms/ip-pool-range-add.tsx index ba3c740e2..490e4fcc1 100644 --- a/app/forms/ip-pool-range-add.tsx +++ b/app/forms/ip-pool-range-add.tsx @@ -71,7 +71,7 @@ export default function IpPoolAddRange() { title="Add IP range" onDismiss={onDismiss} onSubmit={(body) => addRange.mutate({ path: { pool }, body })} - loading={addRange.isPending} + loading={addRange.isPending || addRange.isSuccess} submitError={addRange.error} > diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 15566bc56..45be3fd2c 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -64,7 +64,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa body: updateRole({ identityId, identityType, roleName }, policy), }) }} - loading={updatePolicy.isPending} + loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} onDismiss={onDismiss} > @@ -118,7 +118,7 @@ export function ProjectAccessEditUserSideModal({ body: updateRole({ identityId, identityType, roleName }, policy), }) }} - loading={updatePolicy.isPending} + loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} onDismiss={onDismiss} > diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 46c0fec48..0f865b6a7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -56,7 +56,7 @@ export default function ProjectCreateSideModalForm() { onSubmit={({ name, description }) => { createProject.mutate({ body: { name, description } }) }} - loading={createProject.isPending} + loading={createProject.isPending || createProject.isSuccess} submitError={createProject.error} > diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 4e3521ca4..dbce4fa4f 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -66,7 +66,7 @@ export default function EditProjectSideModalForm() { onSubmit={({ name, description }) => { editProject.mutate({ path: projectSelector, body: { name, description } }) }} - loading={editProject.isPending} + loading={editProject.isPending || editProject.isSuccess} submitError={editProject.error} > diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index dd1388c99..bb0d6c09e 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -65,7 +65,7 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr body: updateRole({ identityId, identityType, roleName }, policy), }) }} - loading={updatePolicy.isPending} + loading={updatePolicy.isPending || updatePolicy.isSuccess} submitError={updatePolicy.error} > { updatePolicy.reset() // clear API error state so it doesn't persist on next open diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 2a72e6f2c..bfc475e71 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -113,7 +113,7 @@ export default function CreateSiloSideModalForm() { }, }) }} - loading={createSilo.isPending} + loading={createSilo.isPending || createSilo.isSuccess} submitError={createSilo.error} > } /> diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 8079c59f3..68a235db8 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -77,7 +77,7 @@ export default function SnapshotCreate() { createSnapshot.mutate({ query: projectSelector, body: values }) }} submitError={createSnapshot.error} - loading={createSnapshot.isPending} + loading={createSnapshot.isPending || createSnapshot.isSuccess} > diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 01e05201c..8162b3f4f 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -62,7 +62,7 @@ export function SSHKeyCreate({ onDismiss, onSuccess, message }: Props) { title="Add SSH key" onDismiss={handleDismiss} onSubmit={(body) => createSshKey.mutate({ body })} - loading={createSshKey.isPending} + loading={createSshKey.isPending || createSshKey.isSuccess} submitError={createSshKey.error} > diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index ddfe14501..ba4ad5803 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -78,7 +78,7 @@ export default function CreateSubnetForm() { }, }) } - loading={createSubnet.isPending} + loading={createSubnet.isPending || createSubnet.isSuccess} submitError={createSubnet.error} > diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index d7a9986f9..65c399578 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -94,7 +94,7 @@ export default function EditSubnetForm() { }, }) }} - loading={updateSubnet.isPending} + loading={updateSubnet.isPending || updateSubnet.isSuccess} submitError={updateSubnet.error} > diff --git a/app/forms/subnet-pool-create.tsx b/app/forms/subnet-pool-create.tsx index 9767a2fb0..857d5c5c6 100644 --- a/app/forms/subnet-pool-create.tsx +++ b/app/forms/subnet-pool-create.tsx @@ -55,7 +55,7 @@ export default function CreateSubnetPoolSideModalForm() { onSubmit={({ name, description, ipVersion }) => { createPool.mutate({ body: { name, description, ipVersion } }) }} - loading={createPool.isPending} + loading={createPool.isPending || createPool.isSuccess} submitError={createPool.error} > diff --git a/app/forms/subnet-pool-edit.tsx b/app/forms/subnet-pool-edit.tsx index 2c5943c4d..cb9d024d5 100644 --- a/app/forms/subnet-pool-edit.tsx +++ b/app/forms/subnet-pool-edit.tsx @@ -70,7 +70,7 @@ export default function EditSubnetPoolSideModalForm() { body: { name, description }, }) }} - loading={editPool.isPending} + loading={editPool.isPending || editPool.isSuccess} submitError={editPool.error} > diff --git a/app/forms/subnet-pool-member-add.tsx b/app/forms/subnet-pool-member-add.tsx index b1709fe41..79cbf9a83 100644 --- a/app/forms/subnet-pool-member-add.tsx +++ b/app/forms/subnet-pool-member-add.tsx @@ -138,7 +138,7 @@ export default function SubnetPoolMemberAdd() { }, }) }} - loading={addMember.isPending} + loading={addMember.isPending || addMember.isSuccess} submitError={addMember.error} > navigate(pb.vpcs(projectSelector))} - loading={createVpc.isPending} + loading={createVpc.isPending || createVpc.isSuccess} submitError={createVpc.error} > diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 574a1957f..5a9f3510e 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -73,7 +73,7 @@ export default function EditVpcSideModalForm() { body: { name, description, dnsName }, }) }} - loading={editVpc.isPending} + loading={editVpc.isPending || editVpc.isSuccess} submitError={editVpc.error} > diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index 2578df6b0..d11b329ef 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -52,7 +52,7 @@ export default function RouterCreate() { resourceName="router" onDismiss={onDismiss} onSubmit={(body) => createRouter.mutate({ query: vpcSelector, body })} - loading={createRouter.isPending} + loading={createRouter.isPending || createRouter.isSuccess} submitError={createRouter.error} > diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 883fda9d6..ca6ff35f8 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -78,7 +78,7 @@ export default function EditRouterSideModalForm() { body, }) } - loading={editRouter.isPending} + loading={editRouter.isPending || editRouter.isSuccess} submitError={editRouter.error} > diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 764497144..4eefb64e2 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -79,7 +79,7 @@ export default function CreateRouterRouteSideModalForm() { }, }) } - loading={createRouterRoute.isPending} + loading={createRouterRoute.isPending || createRouterRoute.isSuccess} submitError={createRouterRoute.error} > diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 2e7583969..81880349f 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -98,7 +98,7 @@ export default function EditRouterRouteSideModalForm() { }, }) } - loading={updateRouterRoute.isPending} + loading={updateRouterRoute.isPending || updateRouterRoute.isSuccess} submitError={updateRouterRoute.error} submitDisabled={disabled ? routeFormMessage.vpcSubnetNotModifiable : undefined} > diff --git a/app/pages/SiloImagesPage.tsx b/app/pages/SiloImagesPage.tsx index ff522771b..9944039c4 100644 --- a/app/pages/SiloImagesPage.tsx +++ b/app/pages/SiloImagesPage.tsx @@ -191,7 +191,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { { if (!image || !project) return // shouldn't happen because of validation @@ -275,7 +275,7 @@ const DemoteImageModal = ({ { if (!project) return // shouldn't happen because of validation diff --git a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx index 3004700ce..e8958cf9e 100644 --- a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx +++ b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx @@ -291,7 +291,7 @@ const AttachExternalSubnetModal = ({ }} submitLabel="Attach" submitError={externalSubnetAttach.error} - loading={externalSubnetAttach.isPending} + loading={externalSubnetAttach.isPending || externalSubnetAttach.isSuccess} onDismiss={onDismiss} > setCreateModalOpen(false)} + onDismiss={() => { + setCreateModalOpen(false) + createNic.reset() // clear stale error state + }} onSubmit={(body) => createNic.mutate({ query: instanceSelector, body })} + // not || isSuccess: the modal unmounts synchronously in onSuccess, + // and a sticky isSuccess would strand a spinner on next open + loading={createNic.isPending} submitError={createNic.error} /> )} @@ -897,7 +903,7 @@ const AttachExternalSubnetModal = ({ }} submitLabel="Attach" submitError={externalSubnetAttach.error} - loading={externalSubnetAttach.isPending} + loading={externalSubnetAttach.isPending || externalSubnetAttach.isSuccess} onDismiss={onDismiss} > void }) { path: { silo }, }) } - loading={updateQuotas.isPending} + loading={updateQuotas.isPending || updateQuotas.isSuccess} submitError={updateQuotas.error} > } variant="info" />