diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 22091946d4..7eb9a87b0a 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -1,10 +1,19 @@ +import { announce } from '@react-aria/live-announcer' +import { hashQueryKey } from '@tanstack/react-query' import type { ReactNode } from 'react' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import type { FieldValues, UseFormReturn } from 'react-hook-form' import { useNavigationType } from 'react-router-dom' import type { ApiError } from '@oxide/api' -import { Button, SideModal } from '@oxide/ui' +import { Button, Message, SideModal } from '@oxide/ui' + +import { + clearPersistedFormValues, + getPersistedFormValues, + saveFormValues, + setPersistedFormValues, +} from 'app/util/persist-form' type SideModalFormProps = { id: string @@ -55,6 +64,36 @@ export function SideModalForm({ }: SideModalFormProps) { const { isSubmitting } = form.formState + const [hasPersistedForm, setHasPersistedForm] = useState(false) + + const handleOnDismiss = () => { + const values = form.getValues() + + // use hashQueryKey to guarantee same key order + const hasDefaultValues = + hashQueryKey([values]) === hashQueryKey([form.formState.defaultValues]) + + if (hasDefaultValues) { + // We clear persisted form values if the user resets the form to default + clearPersistedFormValues(id) + } else { + // Save the form state in local storage if they aren't just the default values + saveFormValues(id, values) + } + onDismiss() + } + + useEffect(() => { + const formValues = getPersistedFormValues(id) + + if (!formValues || hasPersistedForm) return + + setHasPersistedForm(true) + setPersistedFormValues(form.setValue, formValues as TFieldValues) + form.trigger() + announce('Restored previous form session', 'polite') + }, [id, form, hasPersistedForm]) + useEffect(() => { if (submitError?.errorCode === 'ObjectAlreadyExists' && 'name' in form.getValues()) { // @ts-expect-error @@ -64,7 +103,7 @@ export function SideModalForm({ return ( ({ // SideModalForm from inside another form, in which case submitting // the inner form submits the outer form unless we stop propagation e.stopPropagation() + clearPersistedFormValues(id) form.handleSubmit(onSubmit)(e) }} > + {hasPersistedForm && ( + +
Restored form state
+
+ +
+ + } + /> + )} {children} diff --git a/app/util/persist-form.ts b/app/util/persist-form.ts new file mode 100644 index 0000000000..d94e737ff2 --- /dev/null +++ b/app/util/persist-form.ts @@ -0,0 +1,39 @@ +import type { FieldValues, Path, UseFormSetValue } from 'react-hook-form' + +export function saveFormValues(key: string, values: FieldValues) { + sessionStorage.setItem(key, JSON.stringify(values)) +} + +export function getPersistedFormValues(key: string) { + const data = sessionStorage.getItem(key) + if (!data) return null + + let parsedData: FieldValues | null = null + try { + parsedData = JSON.parse(data) + } catch (err) { + console.log(err) + } + + if (!parsedData) { + sessionStorage.removeItem(key) + return null + } + + return parsedData +} + +export function setPersistedFormValues( + setValue: UseFormSetValue, + values: TFieldValues +) { + Object.keys(values).forEach((key) => { + if (values[key]) { + setValue(key as Path, values[key]) + } + }) +} + +export function clearPersistedFormValues(key: string) { + sessionStorage.removeItem(key) +} diff --git a/libs/ui/lib/badge/Badge.tsx b/libs/ui/lib/badge/Badge.tsx index bb66553b63..b9686bcfb7 100644 --- a/libs/ui/lib/badge/Badge.tsx +++ b/libs/ui/lib/badge/Badge.tsx @@ -22,16 +22,16 @@ export const badgeColors: Record> = { destructive: `ring-1 ring-inset bg-destructive-secondary text-destructive ring-[rgba(var(--base-red-800-rgb),0.15)]`, notice: `ring-1 ring-inset bg-notice-secondary text-notice ring-[rgba(var(--base-yellow-800-rgb),0.15)]`, neutral: `ring-1 ring-inset bg-secondary text-secondary ring-[rgba(var(--base-neutral-700-rgb),0.15)]`, - purple: `ring-1 ring-inset bg-[var(--base-purple-200)] text-[var(--base-purple-700)] ring-[rgba(var(--base-purple-700-rgb),0.15)]`, - blue: `ring-1 ring-inset bg-[var(--base-blue-200)] text-[var(--base-blue-700)] ring-[rgba(var(--base-blue-700-rgb),0.15)]`, + purple: `ring-1 ring-inset bg-[var(--base-purple-200)] text-[var(--base-purple-700)] ring-[rgba(var(--base-purple-800-rgb),0.15)]`, + blue: `ring-1 ring-inset bg-info-secondary text-info ring-[rgba(var(--base-blue-800-rgb),0.15)]`, }, solid: { default: 'bg-accent text-inverse', destructive: 'bg-destructive text-inverse', notice: 'bg-notice text-inverse', neutral: 'bg-inverse-tertiary text-inverse', - purple: 'bg-[var(--base-purple-700)] text-[var(--base-purple-200)]', - blue: 'bg-[var(--base-blue-700)] text-[var(--base-blue-200)]', + purple: 'bg-[var(--base-purple-700)] text-inverse', + blue: 'bg-info text-inverse', }, } diff --git a/libs/ui/lib/message/Message.stories.tsx b/libs/ui/lib/message/Message.stories.tsx index a709224b00..94da49b3fc 100644 --- a/libs/ui/lib/message/Message.stories.tsx +++ b/libs/ui/lib/message/Message.stories.tsx @@ -10,9 +10,9 @@ export const Default = () => ( are not updated after instance launch. } - cta={{ + link={{ text: 'Learn more about SSH keys', - link: '/', + to: '/', }} /> ) diff --git a/libs/ui/lib/message/Message.tsx b/libs/ui/lib/message/Message.tsx index 169be5a97c..89173fe16d 100644 --- a/libs/ui/lib/message/Message.tsx +++ b/libs/ui/lib/message/Message.tsx @@ -6,7 +6,7 @@ import { OpenLink12Icon } from '@oxide/ui' import { Error12Icon, Success12Icon, Warning12Icon } from '../icons' -type Variant = 'success' | 'error' | 'notice' +type Variant = 'success' | 'error' | 'notice' | 'info' export interface MessageProps { title?: string @@ -15,7 +15,11 @@ export interface MessageProps { variant?: Variant cta?: { text: string - link: To + action: () => void + } + link?: { + text: string + to: To } } @@ -23,30 +27,35 @@ const icon: Record = { success: , error: , notice: , + info: , } const color: Record = { success: 'bg-accent-secondary', error: 'bg-error-secondary', notice: 'bg-notice-secondary', + info: 'bg-info-secondary', } const textColor: Record = { success: 'text-accent children:text-accent', error: 'text-error children:text-error', notice: 'text-notice children:text-notice', + info: 'text-info children:text-info', } const secondaryTextColor: Record = { success: 'text-accent-secondary', error: 'text-error-secondary', notice: 'text-notice-secondary', + info: 'text-info-secondary', } const linkColor: Record = { success: 'text-accent-secondary hover:text-accent', error: 'text-error-secondary hover:text-error', notice: 'text-notice-secondary hover:text-notice', + info: 'text-info-secondary hover:text-info', } export const Message = ({ @@ -55,6 +64,7 @@ export const Message = ({ className, variant = 'success', cta, + link, }: MessageProps) => { return (
- {cta && ( + {link && ( - {cta.text} + {link.text} )} + + {cta && ( + + )}
) diff --git a/package-lock.json b/package-lock.json index e33d96ceb8..12a70f27f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "license": "MIT", "dependencies": { "@floating-ui/react": "^0.21.1", - "@oxide/design-system": "^0.9.1", + "@oxide/design-system": "^0.10.0", "@oxide/identicon": "0.0.4", "@radix-ui/react-dialog": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.4", @@ -3282,9 +3282,9 @@ "dev": true }, "node_modules/@oxide/design-system": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-0.9.1.tgz", - "integrity": "sha512-6Pmr6r+qJe1pJHCDtZ82dTXwNP+wNSFCaxZPC4pSgPxsYXS4adZgkSMcwx5CvmIpmX5D8ZLkNMmZF51lmqwaGA==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-0.10.0.tgz", + "integrity": "sha512-/oZR5bvrxsZCG4dh3tW0czKlyG0o+1/q6Wtpz4OIHrr06R92LCl02t0p+aTgvCAGSLhm+I58V7adz8hrDzT1Lw==" }, "node_modules/@oxide/identicon": { "version": "0.0.4", @@ -23294,9 +23294,9 @@ "dev": true }, "@oxide/design-system": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-0.9.1.tgz", - "integrity": "sha512-6Pmr6r+qJe1pJHCDtZ82dTXwNP+wNSFCaxZPC4pSgPxsYXS4adZgkSMcwx5CvmIpmX5D8ZLkNMmZF51lmqwaGA==" + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@oxide/design-system/-/design-system-0.10.0.tgz", + "integrity": "sha512-/oZR5bvrxsZCG4dh3tW0czKlyG0o+1/q6Wtpz4OIHrr06R92LCl02t0p+aTgvCAGSLhm+I58V7adz8hrDzT1Lw==" }, "@oxide/identicon": { "version": "0.0.4", diff --git a/package.json b/package.json index d935fa8bd4..ccd2978b3f 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "private": true, "dependencies": { "@floating-ui/react": "^0.21.1", - "@oxide/design-system": "^0.9.1", + "@oxide/design-system": "^0.10.0", "@oxide/identicon": "0.0.4", "@radix-ui/react-dialog": "^1.0.3", "@radix-ui/react-dropdown-menu": "^2.0.4",