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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions app/components/form/SideModalForm.tsx
Original file line number Diff line number Diff line change
@@ -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<TFieldValues extends FieldValues> = {
id: string
Expand Down Expand Up @@ -55,6 +64,36 @@ export function SideModalForm<TFieldValues extends FieldValues>({
}: SideModalFormProps<TFieldValues>) {
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
Expand All @@ -64,7 +103,7 @@ export function SideModalForm<TFieldValues extends FieldValues>({

return (
<SideModal
onDismiss={onDismiss}
onDismiss={handleOnDismiss}
isOpen
title={title}
animate={useShouldAnimateModal()}
Expand All @@ -84,9 +123,32 @@ export function SideModalForm<TFieldValues extends FieldValues>({
// 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 && (
<Message
variant="info"
content={
<div className="flex items-center justify-between">
<div>Restored form state</div>
<div className="flex items-center gap-1">
<button
className="text-mono-sm text-info-secondary hover:text-info"
onClick={() => {
form.reset()
setHasPersistedForm(false)
clearPersistedFormValues(id)
}}
>
Clear form
</button>
</div>
</div>
}
/>
)}
{children}
</form>
</SideModal.Body>
Expand Down
39 changes: 39 additions & 0 deletions app/util/persist-form.ts
Original file line number Diff line number Diff line change
@@ -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<TFieldValues extends FieldValues>(
setValue: UseFormSetValue<TFieldValues>,
values: TFieldValues
) {
Object.keys(values).forEach((key) => {
if (values[key]) {
setValue(key as Path<TFieldValues>, values[key])
}
})
}

export function clearPersistedFormValues(key: string) {
sessionStorage.removeItem(key)
}
8 changes: 4 additions & 4 deletions libs/ui/lib/badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,16 @@ export const badgeColors: Record<BadgeVariant, Record<BadgeColor, string>> = {
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',
},
}

Expand Down
4 changes: 2 additions & 2 deletions libs/ui/lib/message/Message.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ export const Default = () => (
are not updated after instance launch.
</>
}
cta={{
link={{
text: 'Learn more about SSH keys',
link: '/',
to: '/',
}}
/>
)
30 changes: 25 additions & 5 deletions libs/ui/lib/message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,38 +15,47 @@ export interface MessageProps {
variant?: Variant
cta?: {
text: string
link: To
action: () => void
}
link?: {
text: string
to: To
}
}

const icon: Record<Variant, ReactElement> = {
success: <Success12Icon />,
error: <Error12Icon />,
notice: <Warning12Icon />,
info: <Error12Icon className="rotate-180" />,
}

const color: Record<Variant, string> = {
success: 'bg-accent-secondary',
error: 'bg-error-secondary',
notice: 'bg-notice-secondary',
info: 'bg-info-secondary',
}

const textColor: Record<Variant, string> = {
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<Variant, string> = {
success: 'text-accent-secondary',
error: 'text-error-secondary',
notice: 'text-notice-secondary',
info: 'text-info-secondary',
}

const linkColor: Record<Variant, string> = {
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 = ({
Expand All @@ -55,6 +64,7 @@ export const Message = ({
className,
variant = 'success',
cta,
link,
}: MessageProps) => {
return (
<div
Expand All @@ -77,18 +87,28 @@ export const Message = ({
{content}
</div>

{cta && (
{link && (
<Link
className={cn(
'mt-1 block flex items-center underline text-sans-md',
linkColor[variant]
)}
to={cta.link}
to={link.to}
>
{cta.text}
{link.text}
<OpenLink12Icon className="ml-1" />
</Link>
)}

{cta && (
<button
className={cn('mt-3 block text-mono-sm hover:text-accent', linkColor[variant])}
type="button"
onClick={cta.action}
>
{cta.text}
</button>
)}
</div>
</div>
)
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down