diff --git a/app/components/ToastStack.tsx b/app/components/ToastStack.tsx index 7d87a0cbed..56d5c9a4b5 100644 --- a/app/components/ToastStack.tsx +++ b/app/components/ToastStack.tsx @@ -7,12 +7,11 @@ */ import { animated, useTransition } from '@react-spring/web' -import { useToastStore } from '~/stores/toast' +import { removeToast, useToastStore } from '~/stores/toast' import { Toast } from '~/ui/lib/Toast' export function ToastStack() { const toasts = useToastStore((state) => state.toasts) - const remove = useToastStore((state) => state.remove) const transition = useTransition(toasts, { keys: (toast) => toast.id, @@ -36,7 +35,7 @@ export function ToastStack() { key={item.id} {...item.options} onClose={() => { - remove(item.id) + removeToast(item.id) item.options.onClose?.() }} /> diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 590a027a37..d20c0f928d 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -29,7 +29,8 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { RadioField } from '~/components/form/fields/RadioField' import { SideModalForm } from '~/components/form/SideModalForm' -import { useForm, useProjectSelector, useToast } from '~/hooks' +import { useForm, useProjectSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { FieldLabel } from '~/ui/lib/FieldLabel' import { Radio } from '~/ui/lib/Radio' @@ -69,7 +70,6 @@ export function CreateDiskSideModalForm({ onDismiss, }: CreateSideModalFormProps) { const queryClient = useApiQueryClient() - const addToast = useToast() const navigate = useNavigate() const createDisk = useApiMutation('diskCreate', { diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 3ef9869f2f..017c9ec096 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -24,7 +24,8 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' import { SideModalForm } from '~/components/form/SideModalForm' -import { useForm, useProjectSelector, useToast } from '~/hooks' +import { useForm, useProjectSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { Badge } from '~/ui/lib/Badge' import { Message } from '~/ui/lib/Message' import { pb } from '~/util/path-builder' @@ -65,7 +66,6 @@ export function CreateFloatingIpSideModalForm() { const queryClient = useApiQueryClient() const projectSelector = useProjectSelector() - const addToast = useToast() const navigate = useNavigate() const createFloatingIp = useApiMutation('floatingIpCreate', { diff --git a/app/forms/floating-ip-edit.tsx b/app/forms/floating-ip-edit.tsx index 94b568adfa..d0120aa6a1 100644 --- a/app/forms/floating-ip-edit.tsx +++ b/app/forms/floating-ip-edit.tsx @@ -17,7 +17,8 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' -import { getFloatingIpSelector, useFloatingIpSelector, useForm, useToast } from 'app/hooks' +import { addToast } from '~/stores/toast' +import { getFloatingIpSelector, useFloatingIpSelector, useForm } from 'app/hooks' import { pb } from 'app/util/path-builder' EditFloatingIpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { @@ -31,7 +32,6 @@ EditFloatingIpSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { export function EditFloatingIpSideModalForm() { const queryClient = useApiQueryClient() - const addToast = useToast() const navigate = useNavigate() const floatingIpSelector = useFloatingIpSelector() diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 8bed8ea2db..e32d24c6fa 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -14,7 +14,8 @@ 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 { useForm, useSiloSelector, useToast } from '~/hooks' +import { useForm, useSiloSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { readBlobAsBase64 } from '~/util/file' import { pb } from '~/util/path-builder' @@ -43,7 +44,6 @@ const defaultValues: IdpCreateFormValues = { export function CreateIdpSideModalForm() { const navigate = useNavigate() const queryClient = useApiQueryClient() - const addToast = useToast() const { silo } = useSiloSelector() diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index a86eaf4d64..6d53acc033 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -48,7 +48,8 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField' import { TextField } from '~/components/form/fields/TextField' import { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' -import { getProjectSelector, useForm, useProjectSelector, useToast } from '~/hooks' +import { getProjectSelector, useForm, useProjectSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { Message } from '~/ui/lib/Message' @@ -150,7 +151,6 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => { export function CreateInstanceForm() { const [isSubmitting, setIsSubmitting] = useState(false) const queryClient = useApiQueryClient() - const addToast = useToast() const { project } = useProjectSelector() const navigate = useNavigate() diff --git a/app/forms/ip-pool-edit.tsx b/app/forms/ip-pool-edit.tsx index da2467ebf8..befe790dcf 100644 --- a/app/forms/ip-pool-edit.tsx +++ b/app/forms/ip-pool-edit.tsx @@ -17,7 +17,8 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' -import { getIpPoolSelector, useForm, useIpPoolSelector, useToast } from '~/hooks' +import { getIpPoolSelector, useForm, useIpPoolSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { @@ -28,7 +29,6 @@ EditIpPoolSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { export function EditIpPoolSideModalForm() { const queryClient = useApiQueryClient() - const addToast = useToast() const navigate = useNavigate() const poolSelector = useIpPoolSelector() diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 300df72b92..a610c95c0d 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -18,9 +18,10 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { useForm } from '~/hooks' +import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' -import { getProjectSelector, useProjectSelector, useToast } from '../hooks' +import { getProjectSelector, useProjectSelector } from '../hooks' EditProjectSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { const { project } = getProjectSelector(params) @@ -30,7 +31,6 @@ EditProjectSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { export function EditProjectSideModalForm() { const queryClient = useApiQueryClient() - const addToast = useToast() const navigate = useNavigate() const projectSelector = useProjectSelector() diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 782fd18dba..701c7a0a28 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -17,7 +17,8 @@ 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 { useForm, useToast } from '~/hooks' +import { useForm } from '~/hooks' +import { addToast } from '~/stores/toast' import { FormDivider } from '~/ui/lib/Divider' import { pb } from '~/util/path-builder' import { GiB } from '~/util/units' @@ -50,7 +51,6 @@ function validateQuota(value: number) { export function CreateSiloSideModalForm() { const navigate = useNavigate() const queryClient = useApiQueryClient() - const addToast = useToast() const onDismiss = () => navigate(pb.silos()) diff --git a/app/forms/snapshot-create.tsx b/app/forms/snapshot-create.tsx index 7a173a8c09..dfb9e111b7 100644 --- a/app/forms/snapshot-create.tsx +++ b/app/forms/snapshot-create.tsx @@ -20,7 +20,8 @@ import { DescriptionField } from '~/components/form/fields/DescriptionField' import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' -import { useForm, useProjectSelector, useToast } from '~/hooks' +import { useForm, useProjectSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' const useSnapshotDiskItems = (projectSelector: PP.Project) => { @@ -43,7 +44,6 @@ const defaultValues: SnapshotCreate = { export function CreateSnapshotSideModalForm() { const queryClient = useApiQueryClient() const projectSelector = useProjectSelector() - const addToast = useToast() const navigate = useNavigate() const diskItems = useSnapshotDiskItems(projectSelector) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index e89683e40d..92bff170d6 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -13,7 +13,8 @@ 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 { useForm, useProjectSelector, useToast } from '~/hooks' +import { useForm, useProjectSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' const defaultValues: VpcCreate = { @@ -25,7 +26,6 @@ const defaultValues: VpcCreate = { export function CreateVpcSideModalForm() { const projectSelector = useProjectSelector() const queryClient = useApiQueryClient() - const addToast = useToast() const navigate = useNavigate() const createVpc = useApiMutation('vpcCreate', { diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 169098cd51..87859b5521 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -17,7 +17,8 @@ import { import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' -import { getVpcSelector, useForm, useToast, useVpcSelector } from '~/hooks' +import { getVpcSelector, useForm, useVpcSelector } from '~/hooks' +import { addToast } from '~/stores/toast' import { pb } from '~/util/path-builder' EditVpcSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { @@ -29,7 +30,6 @@ EditVpcSideModalForm.loader = async ({ params }: LoaderFunctionArgs) => { export function EditVpcSideModalForm() { const { vpc: vpcName, project } = useVpcSelector() const queryClient = useApiQueryClient() - const addToast = useToast() const navigate = useNavigate() const { data: vpc } = usePrefetchedApiQuery('vpcView', { diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 1a4d9ce75f..3c2b3cb2bd 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -12,4 +12,3 @@ export * from './use-key' export * from './use-params' export * from './use-quick-actions' export * from './use-reduce-motion' -export * from './use-toast' diff --git a/app/hooks/use-toast.ts b/app/hooks/use-toast.ts deleted file mode 100644 index 37845c195c..0000000000 --- a/app/hooks/use-toast.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import { useToastStore } from '~/stores/toast' - -export const useToast = () => useToastStore(({ add }) => add) diff --git a/app/pages/LoginPage.tsx b/app/pages/LoginPage.tsx index ed21d57337..dfa905db0f 100644 --- a/app/pages/LoginPage.tsx +++ b/app/pages/LoginPage.tsx @@ -12,11 +12,12 @@ import { useApiMutation, type UsernamePasswordCredentials } from '@oxide/api' import { TextFieldInner } from '~/components/form/fields/TextField' import { useForm } from '~/hooks' +import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' import { Identicon } from '~/ui/lib/Identicon' import { pb } from '~/util/path-builder' -import { useSiloSelector, useToast } from '../hooks' +import { useSiloSelector } from '../hooks' const defaultValues: UsernamePasswordCredentials = { username: '', @@ -27,7 +28,6 @@ const defaultValues: UsernamePasswordCredentials = { export function LoginPage() { const [searchParams] = useSearchParams() const navigate = useNavigate() - const addToast = useToast() const { silo } = useSiloSelector() const form = useForm({ defaultValues }) @@ -39,7 +39,7 @@ export function LoginPage() { addToast({ title: 'Logged in' }) navigate(searchParams.get('redirect_uri') || pb.projects()) } - }, [loginPost.isSuccess, navigate, searchParams, addToast]) + }, [loginPost.isSuccess, navigate, searchParams]) return ( <> diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index 8342bfc589..2f0eb2a0f9 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -20,8 +20,9 @@ import { import { Storage24Icon } from '@oxide/design-system/icons/react' import { DiskStatusBadge } from '~/components/StatusBadge' -import { getProjectSelector, useProjectSelector, useToast } from '~/hooks' +import { getProjectSelector, useProjectSelector } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { InstanceLinkCell } from '~/table/cells/InstanceLinkCell' import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -94,7 +95,6 @@ export function DisksPage() { const queryClient = useApiQueryClient() const { project } = useProjectSelector() const { Table } = useQueryTable('diskList', { query: { project } }) - const addToast = useToast() const deleteDisk = useApiMutation('diskDelete', { onSuccess() { @@ -153,7 +153,7 @@ export function DisksPage() { )), }, ], - [addToast, createSnapshot, deleteDisk, project] + [createSnapshot, deleteDisk, project] ) const columns = useColsWithActions(staticCols, makeActions) diff --git a/app/pages/project/images/ImagesPage.tsx b/app/pages/project/images/ImagesPage.tsx index f51172d0db..f984a63389 100644 --- a/app/pages/project/images/ImagesPage.tsx +++ b/app/pages/project/images/ImagesPage.tsx @@ -12,8 +12,9 @@ import { Link, Outlet, type LoaderFunctionArgs } from 'react-router-dom' import { apiQueryClient, useApiMutation, useApiQueryClient, type Image } from '@oxide/api' import { Images24Icon } from '@oxide/design-system/icons/react' -import { getProjectSelector, useProjectSelector, useToast } from '~/hooks' +import { getProjectSelector, useProjectSelector } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' import { getActionsCol, type MenuAction } from '~/table/columns/action-col' import { Columns } from '~/table/columns/common' @@ -50,7 +51,6 @@ export function ImagesPage() { const { project } = useProjectSelector() const { Table } = useQueryTable('imageList', { query: { project } }) const queryClient = useApiQueryClient() - const addToast = useToast() const [promoteImageName, setPromoteImageName] = useState(null) @@ -121,7 +121,7 @@ type PromoteModalProps = { onDismiss: () => void; imageName: string } const PromoteImageModal = ({ onDismiss, imageName }: PromoteModalProps) => { const { project } = useProjectSelector() const queryClient = useApiQueryClient() - const addToast = useToast() + const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { addToast({ diff --git a/app/pages/settings/ProfilePage.tsx b/app/pages/settings/ProfilePage.tsx index 8cfa3aa2f5..f2f046ba57 100644 --- a/app/pages/settings/ProfilePage.tsx +++ b/app/pages/settings/ProfilePage.tsx @@ -10,12 +10,13 @@ import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/re import type { Group } from '@oxide/api' import { Settings24Icon } from '@oxide/design-system/icons/react' -import { TextField } from '~/components/form/fields/TextField' -import { FullPageForm } from '~/components/form/FullPageForm' -import { useForm } from '~/hooks' import { useCurrentUser } from '~/layouts/AuthenticatedLayout' import { getActionsCol } from '~/table/columns/action-col' import { Table } from '~/table/Table' +import { CopyToClipboard } from '~/ui/lib/CopyToClipboard' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { TableControls, TableTitle } from '~/ui/lib/Table' const colHelper = createColumnHelper() @@ -34,42 +35,30 @@ export function ProfilePage() { getCoreRowModel: getCoreRowModel(), }) - const form = useForm({ - defaultValues: { - id: me.id, - }, - }) - return ( - } - submitError={null} - onSubmit={() => Promise.resolve()} - > - -

Groups

- - - Your user information is managed by your organization. + <> + + }>Profile + + + + {me.displayName} + + {me.id} + + + + + + Groups + +
+

+ Your user information is managed by your organization.{' '} - To update, contact your{' '} - - IDP admin - - . + To update your information, contact your administrator. - - +

+ ) } diff --git a/app/pages/system/SiloImagesPage.tsx b/app/pages/system/SiloImagesPage.tsx index 17d4d0dfea..f67329fba4 100644 --- a/app/pages/system/SiloImagesPage.tsx +++ b/app/pages/system/SiloImagesPage.tsx @@ -21,7 +21,7 @@ import { Images24Icon } from '@oxide/design-system/icons/react' import { toListboxItem } from '~/components/form/fields/ImageSelectField' import { ListboxField } from '~/components/form/fields/ListboxField' -import { useForm, useToast } from '~/hooks' +import { useForm } from '~/hooks' import { confirmDelete } from '~/stores/confirm-delete' import { addToast } from '~/stores/toast' import { makeLinkCell } from '~/table/cells/LinkCell' @@ -119,7 +119,7 @@ const PromoteImageModal = ({ onDismiss }: { onDismiss: () => void }) => { const { control, handleSubmit, watch, resetField } = useForm({ defaultValues }) const queryClient = useApiQueryClient() - const addToast = useToast() + const promoteImage = useApiMutation('imagePromote', { onSuccess(data) { addToast({ content: `${data.name} has been promoted` }) @@ -208,7 +208,7 @@ const DemoteImageModal = ({ const selectedProject: string | undefined = watch('project') const queryClient = useApiQueryClient() - const addToast = useToast() + const demoteImage = useApiMutation('imageDemote', { onSuccess(data) { addToast({ diff --git a/app/stores/toast.ts b/app/stores/toast.ts index 1168f2ec9f..ea06db7213 100644 --- a/app/stores/toast.ts +++ b/app/stores/toast.ts @@ -15,20 +15,11 @@ type Toast = { options: Optional } -type ToastStore = { - toasts: Toast[] - add: (options: Toast['options']) => void - remove: (id: Toast['id']) => void -} - -export const useToastStore = create((set) => ({ - toasts: [], - add: (options) => set(({ toasts }) => ({ toasts: [...toasts, { id: uuid(), options }] })), - remove: (id) => set(({ toasts }) => ({ toasts: toasts.filter((t) => t.id !== id) })), -})) - -// TODO: take add and remove out of the store once everthing is converted to this addToast +export const useToastStore = create<{ toasts: Toast[] }>(() => ({ toasts: [] })) export function addToast(options: Toast['options']) { - useToastStore.getState().add(options) + 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/CopyToClipboard.tsx b/app/ui/lib/CopyToClipboard.tsx index cb411b2142..71a707de49 100644 --- a/app/ui/lib/CopyToClipboard.tsx +++ b/app/ui/lib/CopyToClipboard.tsx @@ -14,13 +14,17 @@ import { Copy12Icon, Success12Icon } from '@oxide/design-system/icons/react' import { useTimeout } from './use-timeout' -export const CopyToClipboard = ({ - ariaLabel = 'Click to copy this text', - text, -}: { +type Props = { ariaLabel?: string text: string -}) => { + className?: string +} + +export const CopyToClipboard = ({ + ariaLabel = 'Click to copy', + text, + className, +}: Props) => { const [hasCopied, setHasCopied] = useState(false) useTimeout(() => setHasCopied(false), hasCopied ? 2000 : null) @@ -46,7 +50,9 @@ export const CopyToClipboard = ({ 'relative h-5 w-5 rounded', hasCopied ? 'text-accent bg-accent-secondary' - : 'text-quaternary hover:text-secondary hover:bg-hover' + : 'text-quaternary hover:text-secondary hover:bg-hover', + + className )} onClick={handleCopy} type="button" diff --git a/test/e2e/profile.e2e.ts b/test/e2e/profile.e2e.ts index e94ef6ad43..7dd4b517ed 100644 --- a/test/e2e/profile.e2e.ts +++ b/test/e2e/profile.e2e.ts @@ -11,6 +11,7 @@ import { expect, test } from './utils' test('Profile page works', async ({ page }) => { await page.goto('/settings/profile') - await expect(page.getByRole('textbox', { name: 'User ID' })).toHaveValue(user1.id) + await expect(page.getByText('User ID')).toBeVisible() + await expect(page.getByText(user1.id)).toBeVisible() await expect(page.getByRole('cell', { name: 'web-devs' })).toBeVisible() })