diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index 55af5bc78c..b7cedaa85f 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'npm' - name: Cache node_modules uses: actions/cache@v4 diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index f50c83589d..25177c0dde 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -11,6 +11,7 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, usePrefetchedApiQuery } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Modal } from '~/ui/lib/Modal' @@ -29,9 +30,9 @@ export const AttachEphemeralIpModal = ({ onDismiss }: { onDismiss: () => void }) [siloPools] ) const instanceEphemeralIpAttach = useApiMutation('instanceEphemeralIpAttach', { - onSuccess() { + onSuccess(ephemeralIp) { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been attached' }) + addToast(<>IP {ephemeralIp.ip} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/components/AttachFloatingIpModal.tsx b/app/components/AttachFloatingIpModal.tsx index eaedd2dbdc..cc351bde18 100644 --- a/app/components/AttachFloatingIpModal.tsx +++ b/app/components/AttachFloatingIpModal.tsx @@ -10,6 +10,7 @@ import { useForm } from 'react-hook-form' import { useApiMutation, useApiQueryClient, type FloatingIp, type Instance } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { Modal } from '~/ui/lib/Modal' @@ -45,10 +46,10 @@ export const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/components/HL.tsx b/app/components/HL.tsx index 234dab7d30..9a5ca1e1b1 100644 --- a/app/components/HL.tsx +++ b/app/components/HL.tsx @@ -7,4 +7,11 @@ */ import { classed } from '~/util/classed' -export const HL = classed.span`text-sans-semi-md text-default` +// note parent with secondary text color must have 'group' on it for +// this to work. see Toast for an example +export const HL = classed.span` + text-sans-md text-default + group-[.text-accent-secondary]:text-accent + group-[.text-error-secondary]:text-error + group-[.text-info-secondary]:text-info +` diff --git a/app/components/ToastStack.tsx b/app/components/ToastStack.tsx index 56d5c9a4b5..78fe655e69 100644 --- a/app/components/ToastStack.tsx +++ b/app/components/ToastStack.tsx @@ -22,7 +22,10 @@ export function ToastStack() { }) return ( -
+
{transition((style, item) => ( Disk {data.name} created) // prettier-ignore onSuccess?.(data) onDismiss(navigate) }, diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 99bebe081f..35aee97230 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -18,6 +18,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' @@ -74,9 +75,10 @@ export function CreateFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules) { + const newRule = updatedRules.rules[updatedRules.rules.length - 1] queryClient.invalidateQueries('vpcFirewallRulesView') - addToast({ content: 'Your firewall rule has been created' }) + addToast(<>Firewall rule {newRule.name} created) // prettier-ignore navigate(pb.vpcFirewallRules(vpcSelector)) }, }) diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index 50957bff10..bbea4f975e 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -18,11 +18,13 @@ import { import { trigger404 } from '~/components/ErrorBoundary' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFirewallRuleSelector, useFirewallRuleSelector, useVpcSelector, } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { ALL_ISH } from '~/util/consts' import { invariant } from '~/util/invariant' import { pb } from '~/util/path-builder' @@ -64,13 +66,15 @@ export function EditFirewallRuleForm() { const onDismiss = () => navigate(pb.vpcFirewallRules(vpcSelector)) const updateRules = useApiMutation('vpcFirewallRulesUpdate', { - onSuccess() { + onSuccess(updatedRules, { body }) { // Nav before the invalidate because I once saw the above invariant fail // briefly after successful edit (error page flashed but then we land // on the rules list ok) and I think it was a race condition where the // invalidate managed to complete while the modal was still open. onDismiss() queryClient.invalidateQueries('vpcFirewallRulesView') + const updatedRule = body.rules[body.rules.length - 1] + addToast(<>Firewall rule {updatedRule.name} updated) // prettier-ignore }, }) diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 65a9d742bf..cab5b694e6 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -23,6 +23,7 @@ import { toIpPoolItem } from '~/components/form/fields/ip-pool-item' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -45,10 +46,10 @@ export function CreateFloatingIpSideModalForm() { const navigate = useNavigate() const createFloatingIp = useApiMutation('floatingIpCreate', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your Floating IP has been created' }) + addToast(<>Floating IP {floatingIp.name} created) // prettier-ignore navigate(pb.floatingIps(projectSelector)) }, }) diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 44b19bd538..26fe356f92 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getFloatingIpSelector, useFloatingIpSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from 'app/util/path-builder' @@ -47,7 +48,7 @@ export function EditFloatingIpSideModalForm() { const editFloatingIp = useApiMutation('floatingIpUpdate', { onSuccess(_floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been updated' }) + addToast(<>Floating IP {_floatingIp.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 079d9a0ed5..42004e88e1 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -15,6 +15,7 @@ import { FileField } from '~/components/form/fields/FileField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useSiloSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { readBlobAsBase64 } from '~/util/file' @@ -51,9 +52,9 @@ export function CreateIdpSideModalForm() { const onDismiss = () => navigate(pb.silo({ silo })) const createIdp = useApiMutation('samlIdentityProviderCreate', { - onSuccess() { + onSuccess(idp) { queryClient.invalidateQueries('siloIdentityProviderList') - addToast({ content: 'Your identity provider has been created' }) + addToast(<>IdP {idp.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/image-from-snapshot.tsx b/app/forms/image-from-snapshot.tsx index 48c6b9e793..c6a9ac1e20 100644 --- a/app/forms/image-from-snapshot.tsx +++ b/app/forms/image-from-snapshot.tsx @@ -21,6 +21,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getProjectSnapshotSelector, useProjectSnapshotSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { PropertiesTable } from '~/ui/lib/PropertiesTable' @@ -54,9 +55,9 @@ export function CreateImageFromSnapshotSideModalForm() { const onDismiss = () => navigate(pb.snapshots({ project })) const createImage = useApiMutation('imageCreate', { - onSuccess() { + onSuccess(image) { queryClient.invalidateQueries('imageList') - addToast({ content: 'Your image has been created' }) + addToast(<>Image {image.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index be7e9eaa2d..8350ccf20a 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -184,7 +184,7 @@ export function CreateInstanceForm() { { path: { instance: instance.name }, query: { project } }, instance ) - addToast({ content: 'Your instance has been created' }) + addToast(<>Instance {instance.name} created) // prettier-ignore navigate(pb.instance({ project, instance: instance.name })) }, }) diff --git a/app/forms/ip-pool-create.tsx b/app/forms/ip-pool-create.tsx index e1f87b15d9..8afa803e9e 100644 --- a/app/forms/ip-pool-create.tsx +++ b/app/forms/ip-pool-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type IpPoolCreate } from '@oxide/api import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' @@ -31,7 +32,7 @@ export function CreateIpPoolSideModalForm() { const createPool = useApiMutation('ipPoolCreate', { onSuccess(_pool) { queryClient.invalidateQueries('ipPoolList') - addToast({ content: 'Your IP pool has been created' }) + addToast(<>IP pool {_pool.name} created) // prettier-ignore navigate(pb.ipPools()) }, }) diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index 2b1a15978e..cbd0b7db7d 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getIpPoolSelector, useIpPoolSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -43,7 +44,7 @@ export function EditIpPoolSideModalForm() { onSuccess(updatedPool) { queryClient.invalidateQueries('ipPoolList') navigate(pb.ipPool({ pool: updatedPool.name })) - addToast({ content: 'Your IP pool has been updated' }) + addToast(<>IP pool {updatedPool.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating ipPoolView causes an error page to flash diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index c57bde7899..401403f900 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -20,7 +20,9 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextFieldInner } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useInstanceSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import * as MiniTable from '~/ui/lib/MiniTable' @@ -42,8 +44,9 @@ export function EditNetworkInterfaceForm({ const instanceSelector = useInstanceSelector() const editNetworkInterface = useApiMutation('instanceNetworkInterfaceUpdate', { - onSuccess() { + onSuccess(nic) { queryClient.invalidateQueries('instanceNetworkInterfaceList') + addToast(<>Network interface {nic.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 826b587744..ae9551cd37 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -17,6 +17,7 @@ import { import { ListboxField } from '~/components/form/fields/ListboxField' import { SideModalForm } from '~/components/form/SideModalForm' import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { actorToItem, @@ -35,6 +36,8 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + // We don't have the name of the user or group, so we'll just have a generic message + addToast({ content: 'Role assigned' }) onDismiss() }, }) @@ -97,6 +100,7 @@ export function ProjectAccessEditUserSideModal({ const updatePolicy = useApiMutation('projectPolicyUpdate', { onSuccess: () => { queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Role updated' }) onDismiss() }, }) diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 020894826c..faaee13df7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type ProjectCreate } from '@oxide/ap import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -33,7 +34,7 @@ export function CreateProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been created' }) + addToast(<>Project {project.name} created) // prettier-ignore navigate(pb.project({ project: project.name })) }, }) diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 284c1de8de..7af23a1723 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -45,7 +46,7 @@ export function EditProjectSideModalForm() { queryClient.invalidateQueries('projectList') // avoid the project fetch when the project page loads since we have the data queryClient.setQueryData('projectView', { path: { project: project.name } }, project) - addToast({ content: 'Your project has been updated' }) + addToast(<>Project {project.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 9508386bae..ea6b82651e 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -19,6 +19,7 @@ import { RadioField } from '~/components/form/fields/RadioField' import { TextField } from '~/components/form/fields/TextField' import { TlsCertsField } from '~/components/form/fields/TlsCertsField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' @@ -57,7 +58,7 @@ export function CreateSiloSideModalForm() { onSuccess(silo) { queryClient.invalidateQueries('siloList') queryClient.setQueryData('siloView', { path: { silo: silo.name } }, silo) - addToast({ content: 'Your silo has been created' }) + addToast(<>Silo {silo.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 930cec2381..25c7f90db8 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -22,6 +22,7 @@ import { ComboboxField } from '~/components/form/fields/ComboboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { toComboboxItems } from '~/ui/lib/Combobox' @@ -52,9 +53,9 @@ export function CreateSnapshotSideModalForm() { const onDismiss = () => navigate(pb.snapshots(projectSelector)) const createSnapshot = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Your snapshot has been created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 14e5b399a3..82ba183e23 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -35,10 +36,10 @@ export function CreateSSHKeySideModalForm({ onDismiss, message }: Props) { const handleDismiss = onDismiss ? onDismiss : () => navigate(pb.sshKeys()) const createSshKey = useApiMutation('currentUserSshKeyCreate', { - onSuccess() { + onSuccess(sshKey) { queryClient.invalidateQueries('currentUserSshKeyList') handleDismiss() - addToast({ content: 'Your SSH key has been created' }) + addToast(<>SSH key {sshKey.name} created) // prettier-ignore }, }) const form = useForm({ defaultValues }) diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 5ed229999c..e2bbb2666a 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -20,7 +20,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -42,9 +44,10 @@ export function CreateSubnetForm() { const onDismiss = () => navigate(pb.vpcSubnets(vpcSelector)) const createSubnet = useApiMutation('vpcSubnetCreate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') onDismiss() + addToast(<>Subnet {subnet.name} created) // prettier-ignore }, }) diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 6bfd7e18c1..49ab973fbc 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -25,7 +25,9 @@ import { useCustomRouterItems, } from '~/components/form/fields/useItemsList' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSubnetSelector, useVpcSubnetSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' @@ -51,8 +53,9 @@ export function EditSubnetForm() { }) const updateSubnet = useApiMutation('vpcSubnetUpdate', { - onSuccess() { + onSuccess(subnet) { queryClient.invalidateQueries('vpcSubnetList') + addToast(<>Subnet {subnet.name} updated) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index f93d040b8c..43f8fa15a1 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -14,6 +14,7 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -38,7 +39,7 @@ export function CreateVpcSideModalForm() { { path: { vpc: vpc.name }, query: projectSelector }, vpc ) - addToast({ content: 'Your VPC has been created' }) + addToast(<>VPC {vpc.name} created) // prettier-ignore navigate(pb.vpc({ vpc: vpc.name, ...projectSelector })) }, }) diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 9a6380f5fa..0982d17f10 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -18,6 +18,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -42,7 +43,7 @@ export function EditVpcSideModalForm() { onSuccess(updatedVpc) { queryClient.invalidateQueries('vpcList') navigate(pb.vpc({ project, vpc: updatedVpc.name })) - addToast({ content: 'Your VPC has been updated' }) + addToast(<>VPC {updatedVpc.name} updated) // prettier-ignore // Only invalidate if we're staying on the same page. If the name // _has_ changed, invalidating vpcView causes an error page to flash diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index c808d3a099..3d08d456cc 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -13,6 +13,7 @@ import { useApiMutation, useApiQueryClient, type VpcRouterCreate } from '@oxide/ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { useVpcSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -30,9 +31,9 @@ export function CreateRouterSideModalForm() { const onDismiss = () => navigate(pb.vpcRouters(vpcSelector)) const createRouter = useApiMutation('vpcRouterCreate', { - onSuccess() { + onSuccess(router) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been created' }) + addToast(<>Router {router.name} created) // prettier-ignore onDismiss() }, }) diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 3d8067022d..134aadcf26 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -23,6 +23,7 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' @@ -51,9 +52,9 @@ export function EditRouterSideModalForm() { } const editRouter = useApiMutation('vpcRouterUpdate', { - onSuccess() { + onSuccess(updatedRouter) { queryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been updated' }) + addToast(<>Router {updatedRouter.name} updated) // prettier-ignore navigate(pb.vpcRouters({ project, vpc })) }, }) diff --git a/app/forms/vpc-router-route-create.tsx b/app/forms/vpc-router-route-create.tsx index 4ed2afe6c0..8030b55dcd 100644 --- a/app/forms/vpc-router-route-create.tsx +++ b/app/forms/vpc-router-route-create.tsx @@ -11,6 +11,7 @@ import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, useApiQueryClient } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, type RouteFormValues } from '~/forms/vpc-router-route-common' import { getVpcRouterSelector, useVpcRouterSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -44,9 +45,9 @@ export function CreateRouterRouteSideModalForm() { const form = useForm({ defaultValues }) const createRouterRoute = useApiMutation('vpcRouterRouteCreate', { - onSuccess() { + onSuccess(route) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been created' }) + addToast(<>Route {route.name} created) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/forms/vpc-router-route-edit.tsx b/app/forms/vpc-router-route-edit.tsx index 19ac9934e2..da1c06338e 100644 --- a/app/forms/vpc-router-route-edit.tsx +++ b/app/forms/vpc-router-route-edit.tsx @@ -17,6 +17,7 @@ import { } from '@oxide/api' import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' import { RouteFormFields, routeFormMessage, @@ -62,9 +63,9 @@ export function EditRouterRouteSideModalForm() { const disabled = route?.kind === 'vpc_subnet' const updateRouterRoute = useApiMutation('vpcRouterRouteUpdate', { - onSuccess() { + onSuccess(updatedRoute) { queryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been updated' }) + addToast(<>Route {updatedRoute.name} updated) // prettier-ignore navigate(pb.vpcRouter(routerSelector)) }, }) diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index 92b14cc7f2..4ff7a48d2d 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -35,7 +35,7 @@ export function LoginPage() { useEffect(() => { if (loginPost.isSuccess) { - addToast({ title: 'Logged in' }) + addToast('Logged in') navigate(searchParams.get('redirect_uri') || pb.projects()) } }, [loginPost.isSuccess, navigate, searchParams]) diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 832749910e..05173294af 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -34,6 +34,7 @@ import { } from '~/forms/project-access' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' import { Badge } from '~/ui/lib/Badge' @@ -119,7 +120,10 @@ export function ProjectAccessPage() { const queryClient = useApiQueryClient() const { mutateAsync: updatePolicy } = useApiMutation('projectPolicyUpdate', { - onSuccess: () => queryClient.invalidateQueries('projectPolicyView'), + onSuccess: () => { + queryClient.invalidateQueries('projectPolicyView') + addToast({ content: 'Access removed' }) + }, // TODO: handle 403 }) diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 11e0c215d6..298e4af5f9 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -20,6 +20,7 @@ import { import { Storage16Icon, Storage24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { DiskStateBadge } from '~/components/StateBadge' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -99,15 +100,16 @@ export function DisksPage() { const { Table } = useQueryTable('diskList', { query: { project } }) const { mutateAsync: deleteDisk } = useApiMutation('diskDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('diskList') + addToast(<>Disk {variables.path.disk} deleted) // prettier-ignore }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot successfully created' }) + addToast(<>Snapshot {variables.body.name} created) // prettier-ignore }, onError(err) { addToast({ @@ -123,7 +125,7 @@ export function DisksPage() { { label: 'Snapshot', onActivate() { - addToast({ title: `Creating snapshot of disk '${disk.name}'` }) + addToast(<>Creating snapshot of disk {disk.name}) // prettier-ignore createSnapshot({ query: { project }, body: { diff --git a/app/pages/project/floating-ips/FloatingIpsPage.tsx b/app/pages/project/floating-ips/FloatingIpsPage.tsx index 08d035c033..ae5b95b57c 100644 --- a/app/pages/project/floating-ips/FloatingIpsPage.tsx +++ b/app/pages/project/floating-ips/FloatingIpsPage.tsx @@ -108,19 +108,19 @@ export function FloatingIpsPage() { const navigate = useNavigate() const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {floatingIp.name} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) }, }) const { mutateAsync: deleteFloatingIp } = useApiMutation('floatingIpDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('ipPoolUtilizationView') - addToast({ content: 'Your floating IP has been deleted' }) + addToast(<>Floating IP {variables.path.floatingIp} deleted) // prettier-ignore }, }) @@ -250,9 +250,9 @@ const AttachFloatingIpModal = ({ }) => { const queryClient = useApiQueryClient() const floatingIpAttach = useApiMutation('floatingIpAttach', { - onSuccess() { + onSuccess(floatingIp) { queryClient.invalidateQueries('floatingIpList') - addToast({ content: 'Your floating IP has been attached' }) + addToast(<>Floating IP {floatingIp.name} attached) // prettier-ignore onDismiss() }, onError: (err) => { diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index 726357a6f8..cf5c43dee2 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type Image } from '@ import { Images16Icon, Images24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' @@ -58,7 +59,7 @@ export function ImagesPage() { const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,11 @@ const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { addToast({ - content: `${data.name} has been promoted`, + content: ( + <> + Image {data.name} promoted + + ), cta: { text: 'View silo images', link: '/images', diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index df251f3ab5..b18886dd9a 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -58,7 +58,7 @@ export const useMakeInstanceActions = ( label: 'Start', onActivate() { startInstance(instanceParams, { - onSuccess: () => addToast({ title: `Starting instance '${instance.name}'` }), + onSuccess: () => addToast(<>Starting instance {instance.name}), // prettier-ignore onError: (error) => addToast({ variant: 'error', @@ -79,7 +79,7 @@ export const useMakeInstanceActions = ( doAction: () => stopInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Stopping instance '${instance.name}'` }), + addToast(<>Stopping instance {instance.name}), // prettier-ignore }), modalTitle: 'Confirm stop instance', modalContent: ( @@ -104,7 +104,8 @@ export const useMakeInstanceActions = ( label: 'Reboot', onActivate() { rebootInstance(instanceParams, { - onSuccess: () => addToast({ title: `Rebooting instance '${instance.name}'` }), + onSuccess: () => + addToast(<>Rebooting instance {instance.name}), // prettier-ignore onError: (error) => addToast({ variant: 'error', @@ -129,7 +130,7 @@ export const useMakeInstanceActions = ( doDelete: () => deleteInstanceAsync(instanceParams, { onSuccess: () => - addToast({ title: `Deleting instance '${instance.name}'` }), + addToast(<>Deleting instance {instance.name}), // prettier-ignore }), label: instance.name, resourceKind: 'instance', diff --git a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx index 0082f7cfc4..d1d8c11431 100644 --- a/app/pages/project/instances/instance/tabs/NetworkingTab.tsx +++ b/app/pages/project/instances/instance/tabs/NetworkingTab.tsx @@ -202,9 +202,9 @@ export function NetworkingTab() { }, }) const { mutateAsync: deleteNic } = useApiMutation('instanceNetworkInterfaceDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('instanceNetworkInterfaceList') - addToast({ content: 'Network interface deleted' }) + addToast(<>Network interface {variables.path.interface} deleted) // prettier-ignore }, }) const { mutate: editNic } = useApiMutation('instanceNetworkInterfaceUpdate', { @@ -297,7 +297,7 @@ export function NetworkingTab() { const { mutateAsync: ephemeralIpDetach } = useApiMutation('instanceEphemeralIpDetach', { onSuccess() { queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your ephemeral IP has been detached' }) + addToast({ content: 'Ephemeral IP detached' }) }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) @@ -305,10 +305,10 @@ export function NetworkingTab() { }) const { mutateAsync: floatingIpDetach } = useApiMutation('floatingIpDetach', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('floatingIpList') queryClient.invalidateQueries('instanceExternalIpList') - addToast({ content: 'Your floating IP has been detached' }) + addToast(<>Floating IP {variables.path.floatingIp} detached) // prettier-ignore }, onError: (err) => { addToast({ title: 'Error', content: err.message, variant: 'error' }) diff --git a/app/pages/project/instances/instance/tabs/StorageTab.tsx b/app/pages/project/instances/instance/tabs/StorageTab.tsx index 1083b509e5..2b7c24d126 100644 --- a/app/pages/project/instances/instance/tabs/StorageTab.tsx +++ b/app/pages/project/instances/instance/tabs/StorageTab.tsx @@ -87,9 +87,9 @@ export function StorageTab() { ) const { mutate: detachDisk } = useApiMutation('instanceDiskDetach', { - onSuccess() { + onSuccess(disk) { queryClient.invalidateQueries('instanceDiskList') - addToast({ content: 'Disk detached' }) + addToast(<>Disk {disk.name} detached) // prettier-ignore }, onError(err) { addToast({ @@ -100,9 +100,9 @@ export function StorageTab() { }, }) const { mutate: createSnapshot } = useApiMutation('snapshotCreate', { - onSuccess() { + onSuccess(snapshot) { queryClient.invalidateQueries('snapshotList') - addToast({ content: 'Snapshot created' }) + addToast(<>Snapshot {snapshot.name} created) // prettier-ignore }, onError(err) { addToast({ diff --git a/app/pages/project/vpcs/RouterPage.tsx b/app/pages/project/vpcs/RouterPage.tsx index b91ae862a5..1374602b31 100644 --- a/app/pages/project/vpcs/RouterPage.tsx +++ b/app/pages/project/vpcs/RouterPage.tsx @@ -90,7 +90,8 @@ export function RouterPage() { const { mutateAsync: deleteRouterRoute } = useApiMutation('vpcRouterRouteDelete', { onSuccess() { apiQueryClient.invalidateQueries('vpcRouterRouteList') - addToast({ content: 'Your route has been deleted' }) + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Route deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcPage/VpcPage.tsx b/app/pages/project/vpcs/VpcPage/VpcPage.tsx index 5c5c5d912a..97adda95eb 100644 --- a/app/pages/project/vpcs/VpcPage/VpcPage.tsx +++ b/app/pages/project/vpcs/VpcPage/VpcPage.tsx @@ -16,6 +16,7 @@ import { } from '@oxide/api' import { Networking24Icon } from '@oxide/design-system/icons/react' +import { HL } from '~/components/HL' import { MoreActionsMenu } from '~/components/MoreActionsMenu' import { RouteTabs, Tab } from '~/components/RouteTabs' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' @@ -46,10 +47,10 @@ export function VpcPage() { }) const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') navigate(pb.vpcs({ project })) - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx index 361d7a4921..cd411c4c90 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcRoutersTab.tsx @@ -11,6 +11,7 @@ import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, type VpcRouter } from '@oxide/api' +import { HL } from '~/components/HL' import { routeFormMessage } from '~/forms/vpc-router-route-common' import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' @@ -62,9 +63,9 @@ export function VpcRoutersTab() { ) const { mutateAsync: deleteRouter } = useApiMutation('vpcRouterDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('vpcRouterList') - addToast({ content: 'Your router has been deleted' }) + addToast(<>Router {variables.path.router} deleted) // prettier-ignore }, }) diff --git a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx index 285bb2b82c..0dcb974a19 100644 --- a/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/vpcs/VpcPage/tabs/VpcSubnetsTab.tsx @@ -18,6 +18,7 @@ import { import { getVpcSelector, useVpcSelector } from '~/hooks/use-params' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { RouterLinkCell } from '~/table/cells/RouterLinkCell' import { TwoLineCell } from '~/table/cells/TwoLineCell' @@ -47,6 +48,8 @@ export function VpcSubnetsTab() { const { mutateAsync: deleteSubnet } = useApiMutation('vpcSubnetDelete', { onSuccess() { queryClient.invalidateQueries('vpcSubnetList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'Subnet deleted' }) }, }) diff --git a/app/pages/project/vpcs/VpcsPage.tsx b/app/pages/project/vpcs/VpcsPage.tsx index e5ce773f28..69df4371e3 100644 --- a/app/pages/project/vpcs/VpcsPage.tsx +++ b/app/pages/project/vpcs/VpcsPage.tsx @@ -20,6 +20,7 @@ import { import { Networking16Icon, Networking24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -83,9 +84,9 @@ export function VpcsPage() { const navigate = useNavigate() const { mutateAsync: deleteVpc } = useApiMutation('vpcDelete', { - onSuccess() { + onSuccess(_data, variables) { queryClient.invalidateQueries('vpcList') - addToast({ content: 'Your VPC has been deleted' }) + addToast(<>VPC {variables.path.vpc} deleted) // prettier-ignore }, }) diff --git a/app/pages/settings/SSHKeysPage.tsx b/app/pages/settings/SSHKeysPage.tsx index 97ad48f883..3b2fd881c8 100644 --- a/app/pages/settings/SSHKeysPage.tsx +++ b/app/pages/settings/SSHKeysPage.tsx @@ -13,6 +13,7 @@ import { apiQueryClient, useApiMutation, useApiQueryClient, type SshKey } from ' import { Key16Icon, Key24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -46,9 +47,9 @@ export function SSHKeysPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteSshKey } = useApiMutation('currentUserSshKeyDelete', { - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries('currentUserSshKeyList') - addToast({ content: 'Your SSH key has been deleted' }) + addToast(<>SSH key {variables.path.sshKey} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 6c27a1cf53..2346153936 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -23,6 +23,7 @@ import { DocsPopover } from '~/components/DocsPopover' import { ComboboxField } from '~/components/form/fields/ComboboxField' import { toImageComboboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' +import { HL } from '~/components/HL' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -72,7 +73,7 @@ export function SiloImagesPage() { const queryClient = useApiQueryClient() const { mutateAsync: deleteImage } = useApiMutation('imageDelete', { onSuccess(_data, variables) { - addToast({ content: `${variables.path.image} has been deleted` }) + addToast(<>Image {variables.path.image} deleted) // prettier-ignore queryClient.invalidateQueries('imageList') }, }) @@ -131,7 +132,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { - addToast({ content: `${data.name} has been promoted` }) + addToast(<>Image {data.name} promoted) // prettier-ignore queryClient.invalidateQueries('imageList') }, onError: (err) => { @@ -218,7 +219,11 @@ const DemoteImageModal = ({ const demoteImage = useApiMutation('imageDemote', { onSuccess(data) { addToast({ - content: `${data.name} has been demoted`, + content: ( + <> + Image {data.name} demoted + + ), cta: selectedProject ? { text: `View images in ${selectedProject}`, diff --git a/app/pages/system/networking/IpPoolPage.tsx b/app/pages/system/networking/IpPoolPage.tsx index 7461dc41c1..a5c44487e7 100644 --- a/app/pages/system/networking/IpPoolPage.tsx +++ b/app/pages/system/networking/IpPoolPage.tsx @@ -82,10 +82,10 @@ export function IpPoolPage() { }) const navigate = useNavigate() const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') navigate(pb.ipPools()) - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/networking/IpPoolsPage.tsx b/app/pages/system/networking/IpPoolsPage.tsx index 8084dc77f7..eaa8fcf386 100644 --- a/app/pages/system/networking/IpPoolsPage.tsx +++ b/app/pages/system/networking/IpPoolsPage.tsx @@ -20,6 +20,7 @@ import { import { IpGlobal16Icon, IpGlobal24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { IpUtilCell } from '~/components/IpPoolUtilization' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' @@ -78,9 +79,9 @@ export function IpPoolsPage() { }) const { mutateAsync: deletePool } = useApiMutation('ipPoolDelete', { - onSuccess() { + onSuccess(_data, variables) { apiQueryClient.invalidateQueries('ipPoolList') - addToast({ content: 'IP pool deleted' }) + addToast(<>Pool {variables.path.pool} deleted) // prettier-ignore }, }) diff --git a/app/pages/system/silos/SiloIpPoolsTab.tsx b/app/pages/system/silos/SiloIpPoolsTab.tsx index 81aef8d4c9..b19a51ba88 100644 --- a/app/pages/system/silos/SiloIpPoolsTab.tsx +++ b/app/pages/system/silos/SiloIpPoolsTab.tsx @@ -81,6 +81,8 @@ export function SiloIpPoolsTab() { const { mutateAsync: unlinkPool } = useApiMutation('ipPoolSiloUnlink', { onSuccess() { queryClient.invalidateQueries('siloIpPoolList') + // We only have the ID, so will show a generic confirmation message + addToast({ content: 'IP pool unlinked' }) }, }) diff --git a/app/pages/system/silos/SiloQuotasTab.tsx b/app/pages/system/silos/SiloQuotasTab.tsx index 14df8fbb74..8037974cd1 100644 --- a/app/pages/system/silos/SiloQuotasTab.tsx +++ b/app/pages/system/silos/SiloQuotasTab.tsx @@ -18,6 +18,7 @@ import { import { NumberField } from '~/components/form/fields/NumberField' import { SideModalForm } from '~/components/form/SideModalForm' import { useSiloSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' import { Message } from '~/ui/lib/Message' import { Table } from '~/ui/lib/Table' @@ -106,6 +107,7 @@ function EditQuotasForm({ onDismiss }: { onDismiss: () => void }) { const updateQuotas = useApiMutation('siloQuotasUpdate', { onSuccess() { apiQueryClient.invalidateQueries('siloUtilizationView') + addToast({ content: 'Quotas updated' }) onDismiss() }, }) diff --git a/app/pages/system/silos/SilosPage.tsx b/app/pages/system/silos/SilosPage.tsx index 6fbec47227..7f10b98449 100644 --- a/app/pages/system/silos/SilosPage.tsx +++ b/app/pages/system/silos/SilosPage.tsx @@ -19,8 +19,10 @@ import { import { Cloud16Icon, Cloud24Icon } from '@oxide/design-system/icons/react' import { DocsPopover } from '~/components/DocsPopover' +import { HL } from '~/components/HL' import { useQuickActions } from '~/hooks/use-quick-actions' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { BooleanCell } from '~/table/cells/BooleanCell' import { makeLinkCell } from '~/table/cells/LinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' @@ -76,8 +78,9 @@ export function SilosPage() { }) const { mutateAsync: deleteSilo } = useApiMutation('siloDelete', { - onSuccess() { + onSuccess(silo, { path }) { queryClient.invalidateQueries('siloList') + addToast(<>Silo {path.silo} deleted) // prettier-ignore }, }) diff --git a/app/stores/toast.ts b/app/stores/toast.ts index ea06db7213..6bf3c4f5e8 100644 --- a/app/stores/toast.ts +++ b/app/stores/toast.ts @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import { type ReactElement } from 'react' import { v4 as uuid } from 'uuid' import { create } from 'zustand' @@ -17,9 +18,18 @@ type Toast = { export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] })) -export function addToast(options: Toast['options']) { +/** + * If argument is `ReactElement | string`, use it directly as `{ content }`. + * Otherwise it's a config object. + */ +export function addToast(optionsOrContent: Toast['options'] | ReactElement | string) { + const options = + typeof optionsOrContent === 'object' && 'content' in optionsOrContent + ? optionsOrContent + : { content: optionsOrContent } useToastStore.setState(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] })) } + export function removeToast(id: Toast['id']) { useToastStore.setState(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) })) } diff --git a/app/ui/lib/Toast.tsx b/app/ui/lib/Toast.tsx index c11a32d059..05d3cd8dbd 100644 --- a/app/ui/lib/Toast.tsx +++ b/app/ui/lib/Toast.tsx @@ -7,7 +7,7 @@ */ import { announce } from '@react-aria/live-announcer' import cn from 'classnames' -import { useEffect, type ReactElement } from 'react' +import { useEffect, type ReactElement, type ReactNode } from 'react' import { Link, type To } from 'react-router-dom' import { @@ -17,6 +17,8 @@ import { Warning12Icon, } from '@oxide/design-system/icons/react' +import { extractText } from '~/util/str' + import { TimeoutIndicator } from './TimeoutIndicator' import { Truncate } from './Truncate' @@ -24,7 +26,7 @@ type Variant = 'success' | 'error' | 'info' export interface ToastProps { title?: string - content?: string + content: ReactNode onClose: () => void variant?: Variant timeout?: number | null @@ -82,7 +84,7 @@ export const Toast = ({ const timeout = timeoutArg === undefined ? defaultTimeout : timeoutArg // TODO: consider assertive announce for error toasts useEffect( - () => announce((title || defaultTitle[variant]) + ' ' + content, 'polite'), + () => announce((title || defaultTitle[variant]) + ' ' + extractText(content), 'polite'), [title, content, variant] ) return ( @@ -95,8 +97,13 @@ export const Toast = ({ >
{icon[variant]}
-
{title || defaultTitle[variant]}
-
{content}
+ {(title || variant !== 'success') && ( +
{title || defaultTitle[variant]}
+ )} + {/* 'group' is necessary for HL color trick to work. see HL.tsx */} +
+ {content} +
{cta && ( { it('capitalizes the first letter', () => { @@ -76,3 +83,30 @@ describe('titleCase', () => { expect(titleCase('123 abc')).toBe('123 Abc') }) }) + +describe('extractText', () => { + it('extracts strings from React components', () => { + expect( + extractText( + <> + This is my text + + ) + ).toBe('This is my text') + }) + it('extracts strings from nested elements', () => { + expect( + extractText( +

+ This is my{' '} + + nested text + +

+ ) + ).toBe('This is my nested text') + }) + it('can handle regular strings', () => { + expect(extractText('Some more text')).toBe('Some more text') + }) +}) diff --git a/app/util/str.ts b/app/util/str.ts index 934530917c..a7620050c9 100644 --- a/app/util/str.ts +++ b/app/util/str.ts @@ -6,6 +6,8 @@ * Copyright Oxide Computer Company */ +import React from 'react' + export const capitalize = (s: string) => s && s.charAt(0).toUpperCase() + s.slice(1) export const pluralize = (s: string, n: number) => `${n} ${s}${n === 1 ? '' : 's'}` @@ -55,3 +57,19 @@ export const titleCase = (text: string): string => { * it look like `AAAAAAAAAAAAAAAA==`? */ export const isAllZeros = (base64Data: string) => /^A*=*$/.test(base64Data) + +/** + * Extract the string contents of a ReactNode, so <>This highlighted text becomes "This highlighted text" + */ +export const extractText = (children: React.ReactNode): string => + React.Children.toArray(children) + .map((child) => + typeof child === 'string' + ? child + : React.isValidElement(child) + ? extractText(child.props.children) + : '' + ) + .join(' ') + .trim() + .replace(/\s+/g, ' ') diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 03398c4cd6..e735f7dad6 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -5,7 +5,15 @@ * * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, expectVisible, test } from './utils' +import { + clickRowAction, + expect, + expectNoToast, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' test('List disks and snapshot', async ({ page }) => { await page.goto('/projects/mock-project/disks') @@ -28,8 +36,11 @@ test('List disks and snapshot', async ({ page }) => { }) await clickRowAction(page, 'disk-1 db1', 'Snapshot') - await expect(page.getByText("Creating snapshot of disk 'disk-1'").nth(0)).toBeVisible() - await expect(page.getByText('Snapshot successfully created').nth(0)).toBeVisible() + await expectToast(page, 'Creating snapshot of disk disk-1') + // expectToast should have closed the toast already, but verify + await expectNoToast(page, 'Creating snapshot of disk disk-1') + // Next line is a little awkward, but we don't actually know what the snapshot name will be + await expectToast(page, /Snapshot disk-1-[a-z0-9]{6} created/) }) test('Disk snapshot error', async ({ page }) => { @@ -37,11 +48,13 @@ test('Disk snapshot error', async ({ page }) => { // special disk that triggers snapshot error await clickRowAction(page, 'disk-snapshot-error', 'Snapshot') - await expect( - page.getByText("Creating snapshot of disk 'disk-snapshot-error'").nth(0) - ).toBeVisible() - await expect(page.getByText('Failed to create snapshot').nth(0)).toBeVisible() - await expect(page.getByText('Cannot snapshot disk').nth(0)).toBeVisible() + await expectToast(page, 'Creating snapshot of disk disk-snapshot-error') + // just including an actual expect to satisfy the linter + await expect(page.getByRole('cell', { name: 'disk-snapshot-error' })).toBeVisible() + // expectToast should have closed the toast already, but let's just verify … + await expectNoToast(page, 'Creating snapshot of disk disk-snapshot-error') + // … before we can check for the error toast + await expectToast(page, 'Failed to create snapshotCannot snapshot disk') }) test.describe('Disk create', () => { @@ -53,7 +66,7 @@ test.describe('Disk create', () => { test.afterEach(async ({ page }) => { await page.getByRole('button', { name: 'Create disk' }).click() - await expectVisible(page, ['text="Your disk has been created"']) + await expectToast(page, 'Disk a-new-disk created') await expectVisible(page, ['role=cell[name="a-new-disk"]']) }) diff --git a/test/e2e/floating-ip-update.e2e.ts b/test/e2e/floating-ip-update.e2e.ts index 68bcf0d05d..4ce1179c90 100644 --- a/test/e2e/floating-ip-update.e2e.ts +++ b/test/e2e/floating-ip-update.e2e.ts @@ -6,7 +6,14 @@ * Copyright Oxide Computer Company */ -import { clickRowAction, expect, expectRowVisible, expectVisible, test } from './utils' +import { + clickRowAction, + expect, + expectRowVisible, + expectToast, + expectVisible, + test, +} from './utils' const floatingIpsPage = '/projects/mock-project/floating-ips' const originalName = 'cola-float' @@ -32,6 +39,7 @@ test('can update a floating IP', async ({ page }) => { name: updatedName, description: updatedDescription, }) + await expectToast(page, `Floating IP ${updatedName} updated`) }) // Make sure that it still works even if the name doesn't change @@ -47,4 +55,5 @@ test('can update *just* the floating IP description', async ({ page }) => { name: originalName, description: updatedDescription, }) + await expectToast(page, `Floating IP ${originalName} updated`) }) diff --git a/test/e2e/images.e2e.ts b/test/e2e/images.e2e.ts index c15f91fc80..d78f83ca0a 100644 --- a/test/e2e/images.e2e.ts +++ b/test/e2e/images.e2e.ts @@ -12,6 +12,7 @@ import { clipboardText, expect, expectNotVisible, + expectToast, expectVisible, getPageAsUser, selectOption, @@ -52,7 +53,7 @@ test('can promote an image from silo', async ({ page }) => { await page.locator('role=button[name="Promote"]').click() // Check it was promoted successfully - await expectVisible(page, ['text="image-1 has been promoted"']) + await expect(page.getByText('Image image-1 promoted', { exact: true })).toBeVisible() await expectVisible(page, ['role=cell[name="image-1"]']) }) @@ -68,7 +69,7 @@ test('can promote an image from project', async ({ page }) => { // Promote image and check it was successful await page.locator('role=button[name="Promote"]').click() - await expectVisible(page, ['text="image-2 has been promoted"']) + await expect(page.getByText('Image image-2 promoted', { exact: true })).toBeVisible() await expectNotVisible(page, ['role=cell[name="image-2"]']) await page.click('role=link[name="View silo images"]') @@ -111,8 +112,10 @@ test('can demote an image from silo', async ({ page }) => { await selectOption(page, 'Project', 'mock-project') await page.getByRole('button', { name: 'Demote' }).click() - // Promote image and check it was successful - await expectVisible(page, ['text="arch-2022-06-01 has been demoted"']) + // Demote image and check it was successful + await expect( + page.getByText('Image arch-2022-06-01 demoted', { exact: true }) + ).toBeVisible() await expectNotVisible(page, ['role=cell[name="arch-2022-06-01"]']) await page.click('role=link[name="View images in mock-project"]') @@ -132,7 +135,7 @@ test('can delete an image from a project', async ({ page }) => { await expect(spinner).toBeVisible() // Check deletion was successful - await expect(page.getByText('image-3 has been deleted', { exact: true })).toBeVisible() + await expectToast(page, 'Image image-3 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() }) @@ -150,9 +153,7 @@ test('can delete an image from a silo', async ({ page }) => { await expect(spinner).toBeVisible() // Check deletion was successful - await expect( - page.getByText('ubuntu-20-04 has been deleted', { exact: true }) - ).toBeVisible() + await expectToast(page, 'Image ubuntu-20-04 deleted') await expect(cell).toBeHidden() await expect(spinner).toBeHidden() }) diff --git a/test/e2e/instance-disks.e2e.ts b/test/e2e/instance-disks.e2e.ts index 9e54716196..b53e872e4d 100644 --- a/test/e2e/instance-disks.e2e.ts +++ b/test/e2e/instance-disks.e2e.ts @@ -8,8 +8,10 @@ import { clickRowAction, expect, + expectNoToast, expectNotVisible, expectRowVisible, + expectToast, expectVisible, stopInstance, test, @@ -130,7 +132,7 @@ test('Detach disk', async ({ page }) => { // Have to stop instance to edit disks await stopInstance(page) - const successMsg = page.getByText('Disk detached').nth(0) + const successMsg = page.getByText('Disk disk-2 detached').first() const row = page.getByRole('row', { name: 'disk-2' }) await expect(row).toBeVisible() await expect(successMsg).toBeHidden() @@ -143,13 +145,13 @@ test('Detach disk', async ({ page }) => { test('Snapshot disk', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1') - // have to use nth with toasts because the text shows up in multiple spots - const successMsg = page.getByText('Snapshot created').nth(0) - await expect(successMsg).toBeHidden() + // we don't know the full name of the disk, but this will work to find the toast + const toastMessage = /Snapshot disk-1-[a-z0-9]{6} created/ + await expectNoToast(page, toastMessage) await clickRowAction(page, 'disk-1', 'Snapshot') - await expect(successMsg).toBeVisible() // we see the toast! + await expectToast(page, toastMessage) // we see the toast! // now go see the snapshot on the snapshots page await page.getByRole('link', { name: 'Snapshots' }).click() diff --git a/test/e2e/ip-pools.e2e.ts b/test/e2e/ip-pools.e2e.ts index df0de16b04..ea17e815dc 100644 --- a/test/e2e/ip-pools.e2e.ts +++ b/test/e2e/ip-pools.e2e.ts @@ -8,7 +8,7 @@ import { expect, test } from '@playwright/test' -import { clickRowAction, expectRowVisible } from './utils' +import { clickRowAction, expectRowVisible, expectToast } from './utils' test('IP pool list', async ({ page }) => { await page.goto('/system/networking/ip-pools') @@ -118,10 +118,10 @@ test('IP pool delete from IP Pools list page', async ({ page }) => { await expect(page.getByRole('dialog', { name: 'Confirm delete' })).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expect(page.getByText('Could not delete resource').first()).toBeVisible() - await expect( - page.getByText('IP pool cannot be deleted while it contains IP ranges').first() - ).toBeVisible() + await expectToast( + page, + 'Could not delete resourceIP pool cannot be deleted while it contains IP ranges' + ) await expect(page.getByRole('cell', { name: 'ip-pool-3' })).toBeVisible() diff --git a/test/e2e/utils.ts b/test/e2e/utils.ts index e74c39e0ae..8545adde8c 100644 --- a/test/e2e/utils.ts +++ b/test/e2e/utils.ts @@ -115,6 +115,21 @@ export async function stopInstance(page: Page) { await expect(page.getByText('statestopped')).toBeVisible() } +/** + * Assert that a toast with text matching `expectedText` is visible. + */ +export async function expectToast(page: Page, expectedText: string | RegExp) { + await expect(page.getByTestId('Toasts')).toHaveText(expectedText) + await closeToast(page) +} + +/** + * Assert that a toast with text matching `expectedText` is not visible. + */ +export async function expectNoToast(page: Page, expectedText: string | RegExp) { + await expect(page.getByTestId('Toasts')).not.toHaveText(expectedText) +} + /** * Close toast and wait for it to fade out. For some reason it prevents things * from working, but only in tests as far as we can tell.