Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New RFD form modal #65

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
32 changes: 32 additions & 0 deletions app/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
*/

import { Dialog, DialogDismiss, type DialogStore } from '@ariakit/react'
import { Button } from '@oxide/design-system'

import Icon from '~/components/Icon'

const Modal = ({
dialogStore,
title,
children,
onSubmit,
isLoading = false,
disabled = false,
}: {
dialogStore: DialogStore
title: string
children: React.ReactElement
onSubmit?: () => void
isLoading?: boolean
disabled?: boolean
}) => {
return (
<>
Expand All @@ -34,6 +41,31 @@ const Modal = ({
</div>

<main className="px-4 py-6 text-sans-md text-secondary">{children}</main>

{onSubmit && (
<footer className="flex items-center justify-end border-t px-3 py-3 border-secondary">
<div className="space-x-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
dialogStore.hide()
}}
>
Cancel
</Button>
<Button
size="sm"
type="submit"
onClick={() => !disabled && onSubmit()}
loading={isLoading}
className={disabled ? 'cursor-not-allowed opacity-40' : ''}
>
Create RFD
</Button>
</div>
</footer>
)}
</Dialog>
</>
)
Expand Down
151 changes: 117 additions & 34 deletions app/components/NewRfdButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@
* Copyright Oxide Computer Company
*/

import { useDialogStore } from '@ariakit/react'
import { useDialogStore, type DialogStore } from '@ariakit/react'
import { useFetcher } from '@remix-run/react'
import cn from 'classnames'
import { useState } from 'react'

import Icon from '~/components/Icon'
import { useRootLoaderData } from '~/root'

import Modal from './Modal'
import { TextInput } from './TextInput'

const NewRfdButton = () => {
const dialog = useDialogStore()
const newRfdNumber = useRootLoaderData().newRfdNumber
const { user } = useRootLoaderData()

return (
<>
Expand All @@ -26,40 +30,119 @@ const NewRfdButton = () => {
<Icon name="add-roundel" size={16} />
</button>

<Modal dialogStore={dialog} title="Create new RFD">
<>
<p>
There is a prototype script in the rfd{' '}
<a
href="https://github.com/oxidecomputer/rfd"
className="text-accent-tertiary hover:text-accent-secondary"
>
repository
</a>
,{' '}
<code className="align-[1px]; ml-[1px] mr-[1px] rounded border px-[4px] py-[1px] text-mono-code bg-raise border-secondary">
scripts/new.sh
</code>
, that will create a new RFD when used like the code below.
</p>

<p className="mt-2">
{newRfdNumber
? 'The snippet below automatically updates to ensure the new RFD number is correct.'
: 'Replace the number below with the next free number'}
</p>
<pre className="mt-4 overflow-x-auto rounded border px-[1.25rem] py-[1rem] text-mono-code border-secondary 800:px-[1.75rem] 800:py-[1.5rem]">
<code className="!text-[0.825rem] text-mono-code">
<span className="mr-2 inline-block select-none text-quinary">$</span>
scripts/new.sh{' '}
{newRfdNumber ? newRfdNumber.toString().padStart(4, '0') : '0042'} "My title
here"
</code>
</pre>
</>
</Modal>
<CreateRfdModal
data={{
title: 'Untitled',
name: user?.displayName || '',
email: user?.email || '',
}}
dialog={dialog}
/>
</>
)
}

const CreateRfdModal = ({
data,
dialog,
}: {
data: { title: string; name: string; email: string }
dialog: DialogStore
}) => {
const [title, setTitle] = useState('')
const [name, setName] = useState('')
const [email, setEmail] = useState('')

const { newRfdNumber } = useRootLoaderData()
const fetcher = useFetcher()

const body = ''

const handleSubmit = () => {
fetcher.submit(
{ title, body },
{
method: 'post',
action: `/create-rfd`,
encType: 'application/json',
},
)
}

const formDisabled = fetcher.state !== 'idle'

return (
<Modal
dialogStore={dialog}
title="Create new RFD"
onSubmit={handleSubmit}
disabled={formDisabled}
isLoading={fetcher.state === 'loading' || fetcher.state === 'submitting'}
>
<fetcher.Form className="space-y-4">
<div className="space-y-2">
<TextInput
name="title"
placeholder="Title"
value={title}
onChange={(el) => setTitle(el.target.value)}
disabled={formDisabled}
required
/>
<div className="flex w-full gap-2">
<TextInput
name="name"
placeholder={data.name !== '' ? data.name : 'Author name'}
value={name}
onChange={(el) => setName(el.target.value)}
disabled={formDisabled}
className="w-1/3"
/>
<TextInput
name="email"
placeholder={data.email !== '' ? data.email : 'Author email'}
value={email}
onChange={(el) => setEmail(el.target.value)}
disabled={formDisabled}
className="w-2/3"
/>
</div>
</div>

<pre
className={cn(
'relative h-[160px] select-none overflow-hidden rounded-lg border p-4',
formDisabled
? 'text-quaternary bg-disabled border-default'
: 'bg-default border-secondary',
)}
>
{`:state: prediscussion
:discussion:
:authors: ${name ? name : data.name} <${email ? email : data.email}>

= RFD ${newRfdNumber} ${title ? title : '{title}'}

== Determinations
`}
{body}
<div
className="absolute left-0 bottom-0 h-[100px] w-full"
style={{
background: `linear-gradient(0, ${
formDisabled ? 'var(--surface-disabled)' : 'var(--surface-default)'
} 0%, rgba(8, 15, 17, 0) 100%)`,
}}
/>
</pre>
{fetcher.type === 'done' && !fetcher.data.ok && fetcher.data.message && (
<div className="my-2 text-sans-lg text-error-secondary ">
{fetcher.data.message}
</div>
)}
</fetcher.Form>
</Modal>
)
}

export default NewRfdButton
115 changes: 115 additions & 0 deletions app/components/TextInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* 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 cn from 'classnames'
import { forwardRef } from 'react'

/**
* This is a little complicated. We only want to allow the `rows` prop if
* `as="textarea"`. But the derivatives of `TextField`, like `NameField`, etc.,
* can't use `as` no matter what. So we have them only use `TextFieldBaseProps`,
* which doesn't know about `as`. But `TextField` itself secretly takes
* `TextFieldBaseProps & TextAreaProps`.
*/
export type TextAreaProps =
| {
as: 'textarea'
/** Only used with `as="textarea"` */
rows?: number
}
| {
as?: never
rows?: never
}

// would prefer to refer directly to the props of Field and pass them all
// through, but couldn't get it to work. FieldAttributes<string> is closest but
// it makes a bunch of props required that should be optional. Instead we simply
// take the props of an input field (which are part of the Field props) and
// manually tack on validate.
export type TextInputBaseProps = React.ComponentPropsWithRef<'input'> & {
// error is used to style the wrapper, also to put aria-invalid on the input
error?: boolean
disabled?: boolean
className?: string
fieldClassName?: string
}

export const TextInput = forwardRef<HTMLInputElement, TextInputBaseProps & TextAreaProps>(
(
{
type = 'text',
error,
className,
disabled,
fieldClassName,
as: asProp,
...fieldProps
},
ref,
) => {
const Component = asProp || 'input'
return (
<div
className={cn(
'flex rounded border',
error
? 'border-error-secondary hover:border-error'
: 'border-default hover:border-hover',
disabled && '!border-default',
className,
)}
>
<Component
// @ts-expect-error this is fine, it's just mad because Component is a variable
ref={ref}
type={type}
className={cn(
`w-full rounded border-none px-3
py-[0.6875rem] !outline-offset-1 text-sans-md
text-default bg-default placeholder:text-quaternary
focus:outline-none disabled:cursor-not-allowed disabled:text-tertiary disabled:bg-disabled`,
error && 'focus-error',
fieldClassName,
disabled && 'text-disabled bg-disabled',
)}
aria-invalid={error}
disabled={disabled}
{...fieldProps}
/>
</div>
)
},
)

TextInput.displayName = 'TextInput'

type HintProps = {
// ID required as a reminder to pass aria-describedby on TextField
id: string
children: React.ReactNode
className?: string
}

/**
* Pass id here and include that ID in aria-describedby on the TextField
*/
export const TextInputHint = ({ id, children, className }: HintProps) => (
<div
id={id}
className={cn(
'mt-1 text-sans-sm text-tertiary [&_>_a]:underline hover:[&_>_a]:text-default',
className,
)}
>
{children}
</div>
)

export const TextInputError = ({ children }: { children: string }) => {
return <div className="ml-px py-2 text-sans-md text-destructive">{children}</div>
}
Loading
Loading