From d43bc4293c8cdb02bc0eab86879933bd7bb5aad7 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Fri, 10 Oct 2025 17:51:21 +0100 Subject: [PATCH 1/5] First pass --- app/components/Modal.tsx | 34 +++++++ app/components/NewRfdButton.tsx | 156 +++++++++++++++++++++++++------- app/components/TextInput.tsx | 39 ++++++++ app/components/rfd/index.css | 74 --------------- app/routes/api.new-rfd.tsx | 146 ++++++++++++++++++++++++++++++ app/styles/index.css | 67 ++++++++++++++ 6 files changed, 408 insertions(+), 108 deletions(-) create mode 100644 app/components/TextInput.tsx delete mode 100644 app/components/rfd/index.css create mode 100644 app/routes/api.new-rfd.tsx diff --git a/app/components/Modal.tsx b/app/components/Modal.tsx index 9f567ad..b330fce 100644 --- a/app/components/Modal.tsx +++ b/app/components/Modal.tsx @@ -7,6 +7,7 @@ */ import { Dialog, DialogDismiss, type DialogStore } from '@ariakit/react' +import { Button } from '@oxide/design-system' import cn from 'classnames' import Icon from '~/components/Icon' @@ -23,11 +24,17 @@ const Modal = ({ title, children, width = 'medium', + onSubmit, + isLoading = false, + disabled = false, }: { dialogStore: DialogStore title: string children: React.ReactElement width?: Width + onSubmit?: () => void + isLoading?: boolean + disabled?: boolean }) => { return ( <> @@ -49,6 +56,33 @@ const Modal = ({
{children}
+ + {onSubmit && ( + + )} ) diff --git a/app/components/NewRfdButton.tsx b/app/components/NewRfdButton.tsx index ab0a3e6..53341f7 100644 --- a/app/components/NewRfdButton.tsx +++ b/app/components/NewRfdButton.tsx @@ -6,16 +6,20 @@ * Copyright Oxide Computer Company */ -import { useDialogStore } from '@ariakit/react' +import { useDialogStore, type DialogStore } from '@ariakit/react' +import cn from 'classnames' +import { useState } from 'react' +import { useFetcher } from 'react-router' 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 ( <> @@ -26,40 +30,124 @@ const NewRfdButton = () => { - - <> -

- There is a prototype script in the rfd{' '} - - repository - - ,{' '} - - scripts/new.sh - - , that will create a new RFD when used like the code below. -

- -

- {newRfdNumber - ? 'The snippet below automatically updates to ensure the new RFD number is correct.' - : 'Replace the number below with the next free number'} -

-
-            
-              $
-              scripts/new.sh{' '}
-              {newRfdNumber ? newRfdNumber.toString().padStart(4, '0') : '0042'} "My title
-              here"
-            
-          
- -
+ ) } +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: `/api/new-rfd`, + encType: 'application/json', + }, + ) + } + + const formDisabled = fetcher.state !== 'idle' + const isFormInvalid = !title.trim() + const submitDisabled = formDisabled || isFormInvalid + + return ( + + +
+ setTitle(el.target.value)} + disabled={formDisabled} + required + /> +
+ setName(el.target.value)} + disabled={formDisabled} + className="w-1/3" + /> + setEmail(el.target.value)} + disabled={formDisabled} + className="w-2/3" + /> +
+
+ +
+          {`:state: prediscussion
+:discussion:
+:authors: ${name ? name : data.name} <${email ? email : data.email}>
+
+= RFD ${newRfdNumber} ${title ? title : '{title}'}
+
+== Determinations
+`}
+          {body}
+          
+
+ {fetcher.state === 'idle' && + fetcher.data && + !fetcher.data.ok && + fetcher.data.message && ( +
+ {fetcher.data.message} +
+ )} +
+
+ ) +} + export default NewRfdButton diff --git a/app/components/TextInput.tsx b/app/components/TextInput.tsx new file mode 100644 index 0000000..af5f624 --- /dev/null +++ b/app/components/TextInput.tsx @@ -0,0 +1,39 @@ +/* + * 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' + +export type TextInputBaseProps = React.ComponentPropsWithRef<'input'> & { + disabled?: boolean + className?: string +} + +export const TextInput = forwardRef( + ({ type = 'text', className, disabled, ...fieldProps }, ref) => { + return ( +
+ +
+ ) + }, +) diff --git a/app/components/rfd/index.css b/app/components/rfd/index.css deleted file mode 100644 index 907797b..0000000 --- a/app/components/rfd/index.css +++ /dev/null @@ -1,74 +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 - */ - -.dialog { - opacity: 0; - transition-property: opacity, transform; - transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); - transition-duration: 100ms; - transform: translate3d(50%, 0px, 0px); -} - -.dialog[data-enter] { - opacity: 1; - transition-duration: 100ms; - transform: translate3d(0%, 0px, 0px); -} - -.dialog[data-leave] { - transition-duration: 50ms; -} - -.spinner { - --radius: 4; - --PI: 3.14159265358979; - --circumference: calc(var(--PI) * var(--radius) * 2px); - animation: rotate 5s linear infinite; -} - -.spinner .path { - stroke-dasharray: var(--circumference); - transform-origin: center; - animation: dash 4s ease-in-out infinite; - stroke: var(--content-accent); -} - -@media (prefers-reduced-motion) { - .spinner { - animation: rotate 6s linear infinite; - } - - .spinner .path { - animation: none; - stroke-dasharray: 20; - stroke-dashoffset: 100; - } - - .spinner-lg .path { - stroke-dasharray: 50; - } -} - -.spinner .bg { - stroke: var(--content-default); -} - -@keyframes rotate { - 100% { - transform: rotate(360deg); - } -} - -@keyframes dash { - from { - stroke-dashoffset: var(--circumference); - } - to { - stroke-dashoffset: calc(var(--circumference) * -1); - } -} diff --git a/app/routes/api.new-rfd.tsx b/app/routes/api.new-rfd.tsx new file mode 100644 index 0000000..9fd8a54 --- /dev/null +++ b/app/routes/api.new-rfd.tsx @@ -0,0 +1,146 @@ +/* + * 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 { ActionFunctionArgs, data, redirect } from 'react-router' + +import { authenticate } from '../services/auth.server' + +interface CreateRfdPayload { + title: string + body: string +} + +interface CreateRfdResponse { + number: string + error?: string +} + +const RFD_API_BASE_URL = process.env.RFD_API +const RFD_API_KEY = process.env.RFD_API_KEY + +const DEFAULT_HEADERS = { + Authorization: `Bearer ${RFD_API_KEY || ''}`, + 'Content-Type': 'application/json; charset=utf-8', +} + +const RETRY_CONFIG = { + maxAttempts: 20, + initialDelay: 2000, + backoffMultiplier: 1.5, + backoffThreshold: 10, +} as const + +export async function action({ request }: ActionFunctionArgs) { + try { + const user = await authenticate(request) + if (!RFD_API_KEY || !RFD_API_BASE_URL) { + throw new Response('RFD API config missing', { status: 500 }) + } + + if (!user) { + return data({ status: 'error', error: 'Unauthorized' }, { status: 401 }) + } + + const payload = await parseRequestPayload(request) + const rfdNumber = await createRfd(payload) + await waitForRfdAvailability(rfdNumber) + + return redirect(`/rfd/${rfdNumber}`) + } catch (error) { + return handleError(error) + } +} + +async function parseRequestPayload(request: Request): Promise { + try { + const { title, body } = await request.json() + + if (!title || !body) { + throw new Response('Missing required fields: title and body', { status: 400 }) + } + + return { title, body } + } catch (error) { + if (error instanceof Response) throw error + throw new Response('Invalid JSON payload', { status: 400 }) + } +} + +async function createRfd({ title, body }: CreateRfdPayload): Promise { + const response = await fetch(`${RFD_API_BASE_URL}/rfd`, { + method: 'POST', + headers: DEFAULT_HEADERS, + body: JSON.stringify({ title, content: body }), + }) + + const result: CreateRfdResponse = await response.json() + + if (!response.ok) { + const errorMessage = result.error || `HTTP ${response.status}: ${response.statusText}` + throw new Response(errorMessage, { status: response.status }) + } + + if (!result.number) { + throw new Response('Invalid response: missing RFD number', { status: 500 }) + } + + return result.number +} + +async function waitForRfdAvailability(rfdNumber: string): Promise { + let attempt = 0 + let delay = RETRY_CONFIG.initialDelay + + while (attempt < RETRY_CONFIG.maxAttempts) { + try { + const response = await fetch(`${RFD_API_BASE_URL}/rfd/${rfdNumber}`, { + method: 'GET', + headers: DEFAULT_HEADERS, + }) + + if (response.ok) { + return // Success - RFD is available + } + + if (response.status !== 404) { + // If it's not a 404, it's likely a different error we shouldn't retry + throw new Response(`Failed to verify RFD availability: ${response.status}`, { + status: response.status, + }) + } + } catch { + // Network errors or other fetch failures + if (attempt >= RETRY_CONFIG.maxAttempts - 1) { + throw new Response('Failed to verify RFD creation', { status: 500 }) + } + } + + // Wait before next attempt + await sleep(delay) + attempt++ + + // Apply backoff after threshold + if (attempt > RETRY_CONFIG.backoffThreshold) { + delay *= RETRY_CONFIG.backoffMultiplier + } + } + + throw new Response('RFD creation verification timeout', { status: 504 }) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function handleError(error: unknown) { + if (error instanceof Response) { + return error + } + + console.error('Unexpected error in RFD creation:', error) + return data({ status: 'error', error: 'An unexpected error occurred' }, { status: 500 }) +} diff --git a/app/styles/index.css b/app/styles/index.css index 27cabc4..2ee2906 100644 --- a/app/styles/index.css +++ b/app/styles/index.css @@ -159,3 +159,70 @@ table.inline-table { } } } + +.dialog { + opacity: 0; + transition-property: opacity, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 100ms; + transform: translate3d(50%, 0px, 0px); +} + +.dialog[data-enter] { + opacity: 1; + transition-duration: 100ms; + transform: translate3d(0%, 0px, 0px); +} + +.dialog[data-leave] { + transition-duration: 50ms; +} + +.spinner { + --radius: 4; + --PI: 3.14159265358979; + --circumference: calc(var(--PI) * var(--radius) * 2px); + animation: rotate 5s linear infinite; +} + +.spinner .path { + stroke-dasharray: var(--circumference); + transform-origin: center; + animation: dash 4s ease-in-out infinite; + stroke: var(--content-accent); +} + +@media (prefers-reduced-motion) { + .spinner { + animation: rotate 6s linear infinite; + } + + .spinner .path { + animation: none; + stroke-dasharray: 20; + stroke-dashoffset: 100; + } + + .spinner-lg .path { + stroke-dasharray: 50; + } +} + +.spinner .bg { + stroke: var(--content-default); +} + +@keyframes rotate { + 100% { + transform: rotate(360deg); + } +} + +@keyframes dash { + from { + stroke-dashoffset: var(--circumference); + } + to { + stroke-dashoffset: calc(var(--circumference) * -1); + } +} From 70f0e0902624df6d5d865740c7b3de51d72205f8 Mon Sep 17 00:00:00 2001 From: Benjamin Leonard Date: Mon, 13 Oct 2025 10:36:13 +0100 Subject: [PATCH 2/5] Remove dupe meta --- app/root.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/root.tsx b/app/root.tsx index 401119d..c222d39 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -119,7 +119,6 @@ const Layout = ({ children, theme }: { children: React.ReactNode; theme?: string - {/* Use plausible analytics only on Vercel */} {process.env.NODE_ENV === 'production' && (