diff --git a/app/components/DisksTableField.tsx b/app/components/DisksTableField.tsx new file mode 100644 index 0000000000..87ce56e718 --- /dev/null +++ b/app/components/DisksTableField.tsx @@ -0,0 +1,108 @@ +import React, { useState } from 'react' +import { useField } from 'formik' +import { CreateDiskForm } from 'app/forms/disk-create' +import { AttachDiskForm } from 'app/forms/disk-attach' +import { + Button, + Error16Icon, + FieldLabel, + MiniTable, + SideModal, +} from '@oxide/ui' +import type { FormValues } from '../forms' + +type DiskTableItem = + | (FormValues<'disk-create'> & { type: 'create' }) + | (FormValues<'disk-attach'> & { type: 'attach' }) + +export function DisksTableField() { + const [showDiskCreate, setShowDiskCreate] = useState(false) + const [showDiskAttach, setShowDiskAttach] = useState(false) + + const [, { value: items = [] }, { setValue: setItems }] = useField< + DiskTableItem[] + >({ name: 'disks' }) + + return ( + <> +
+ {/* this was empty */} + {!!items.length && ( + + + Name + Type + {/* For remove button */} + + + + {items.map((item, index) => ( + + {item.name} + {item.type} + + + + + ))} + + + )} + +
+ + +
+
+ + setShowDiskCreate(false)} + > + { + setItems([...items, { type: 'create', ...values }]) + setShowDiskCreate(false) + }} + /> + + setShowDiskAttach(false)} + > + { + setItems([...items, { type: 'attach', ...values }]) + setShowDiskAttach(false) + }} + /> + + + ) +} diff --git a/app/forms/__tests__/instance-create.e2e.ts b/app/forms/__tests__/instance-create.e2e.ts new file mode 100644 index 0000000000..0425e3d20a --- /dev/null +++ b/app/forms/__tests__/instance-create.e2e.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test' + +test.describe('Instance Create Form', () => { + test('can invoke instance create form from instances page', async ({ + page, + }) => { + await page.goto('/orgs/maze-war/projects/mock-project/instances') + await page.locator('text="New Instance"').click() + await expect(page.locator('h1:has-text("Create instance")')).toBeVisible() + + await page.fill('input[name=name]', 'mock-instance') + await page.locator('.ox-radio-card').nth(0).click() + + await page.locator('input[value=ubuntu] ~ .ox-radio-card').click() + + await page.locator('button:has-text("Create instance")').click() + + await page.waitForNavigation() + + expect(page.url()).toContain( + '/orgs/maze-war/projects/mock-project/instances/mock-instance' + ) + + await expect(page.locator('h1:has-text("mock-instance")')).toBeVisible() + }) +}) diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx new file mode 100644 index 0000000000..6d2751b8c0 --- /dev/null +++ b/app/forms/disk-attach.tsx @@ -0,0 +1,67 @@ +import { Form, NameField } from '@oxide/form' +import React from 'react' +import type { Disk } from '@oxide/api' +import { useApiMutation, useApiQueryClient } from '@oxide/api' +import { invariant } from '@oxide/util' +import { useParams } from 'app/hooks' +import type { PrebuiltFormProps } from 'app/forms' + +const values = { + name: '', +} + +export function AttachDiskForm({ + id = 'form-disk-attach', + title = 'Attach Disk', + initialValues = values, + onSubmit, + onSuccess, + onError, + ...props +}: PrebuiltFormProps) { + const queryClient = useApiQueryClient() + const pathParams = useParams('orgName', 'projectName') + + const attachDisk = useApiMutation('instanceDisksAttach', { + onSuccess(data) { + const { instanceName, ...others } = pathParams + invariant(instanceName, 'instanceName is required') + queryClient.invalidateQueries('instanceDisksGet', { + instanceName, + ...others, + }) + onSuccess?.(data) + }, + onError, + }) + + return ( +
{ + const { instanceName, ...others } = pathParams + invariant(instanceName, 'instanceName is required') + attachDisk.mutate({ + instanceName, + ...others, + body: { name }, + }) + }) + } + mutation={attachDisk} + {...props} + > + + + {title} + + + + ) +} + +export default AttachDiskForm diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index 6c7db1dd10..840f62944a 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -1,3 +1,4 @@ +import React from 'react' import { DescriptionField, Form, @@ -7,17 +8,16 @@ import { Radio, } from '@oxide/form' import { Divider } from '@oxide/ui' -import React from 'react' -import type { PrebuiltFormProps } from '@oxide/form' -import { useParams } from 'app/hooks' import type { Disk } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' +import type { PrebuiltFormProps } from 'app/forms' +import { useParams } from 'app/hooks' + const values = { name: '', description: '', size: 0, - type: '', sourceType: '', deletionRule: '', } @@ -31,12 +31,12 @@ export function CreateDiskForm({ onError, ...props }: PrebuiltFormProps) { - const parentNames = useParams('orgName', 'projectName') const queryClient = useApiQueryClient() + const pathParams = useParams('orgName', 'projectName') const createDisk = useApiMutation('projectDisksPost', { onSuccess(data) { - queryClient.invalidateQueries('projectDisksGet', parentNames) + queryClient.invalidateQueries('projectDisksGet', pathParams) onSuccess?.(data) }, onError, @@ -50,7 +50,7 @@ export function CreateDiskForm({ onSubmit={ onSubmit || ((body) => { - createDisk.mutate({ ...parentNames, body }) + createDisk.mutate({ ...pathParams, body }) }) } mutation={createDisk} @@ -59,7 +59,6 @@ export function CreateDiskForm({ - Blank disk Image diff --git a/app/forms/index.spec.ts b/app/forms/index.spec.ts index 56c931b566..c6dc616226 100644 --- a/app/forms/index.spec.ts +++ b/app/forms/index.spec.ts @@ -2,6 +2,7 @@ import babel from '@babel/core' import { traverse } from '@babel/core' import fs from 'fs/promises' import path from 'path' +import './index' test('FormTypes must contain references to all forms', async () => { let formIds: string[] = [] diff --git a/app/forms/index.ts b/app/forms/index.ts index 9e757e4f9a..62b4fa8939 100644 --- a/app/forms/index.ts +++ b/app/forms/index.ts @@ -1,8 +1,16 @@ +// TODO: Make these just be default exports + import type { CreateSubnetForm } from './subnet-create' import type { EditSubnetForm } from './subnet-edit' import type { CreateOrgForm } from './org-create' import type { CreateDiskForm } from './disk-create' import type { CreateProjectForm } from './project-create' +import type CreateInstanceForm from './instance-create' +import type AttachDiskForm from './disk-attach' + +import type { FormProps } from '@oxide/form' +import type { ErrorResponse } from '@oxide/api' +import type { ComponentType } from 'react' /** * A map of all existing forms. When a new form is created in the forms directory, a @@ -10,9 +18,50 @@ import type { CreateProjectForm } from './project-create' * and a value of the form's type. There's a test to validate that this happens. */ export interface FormTypes { + 'instance-create': typeof CreateInstanceForm 'org-create': typeof CreateOrgForm 'project-create': typeof CreateProjectForm + 'disk-attach': typeof AttachDiskForm 'disk-create': typeof CreateDiskForm 'subnet-create': typeof CreateSubnetForm 'subnet-edit': typeof EditSubnetForm } + +export type FormValues = ExtractFormValues< + FormTypes[K] +> + +/** + * A form that's built out ahead of time and intended to be re-used dynamically. Fields + * that are expected to be provided by default are set to optional. + */ +export type PrebuiltFormProps = Omit< + Optional< + FormProps, + 'id' | 'title' | 'initialValues' | 'onSubmit' | 'mutation' + >, + 'children' +> & { + children?: never + onSuccess?: (data: Data) => void + onError?: (err: ErrorResponse) => void +} + +/** + * A utility type for a prebuilt form that extends another form + */ +export type ExtendedPrebuiltFormProps = C extends ComponentType< + infer B +> + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + B extends PrebuiltFormProps + ? PrebuiltFormProps + : never + : never + +export type ExtractFormValues = C extends ComponentType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + PrebuiltFormProps +> + ? V + : never diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx new file mode 100644 index 0000000000..8652ecc98b --- /dev/null +++ b/app/forms/instance-create.tsx @@ -0,0 +1,353 @@ +import React from 'react' +import type { Instance } from '@oxide/api' +import { useApiMutation, useApiQueryClient } from '@oxide/api' +import type { PrebuiltFormProps } from 'app/forms' +import { + DescriptionField, + Form, + NameField, + RadioField, + TagsField, + TextField, +} from '@oxide/form' +import { + CentOSResponsiveIcon, + DebianResponsiveIcon, + Divider, + FedoraResponsiveIcon, + FreeBSDResponsiveIcon, + RadioCard, + Success16Icon, + Tab, + Tabs, + TextFieldHint, + UbuntuResponsiveIcon, + WindowsResponsiveIcon, +} from '@oxide/ui' +import { useParams, useToast } from 'app/hooks' +import { DisksTableField } from 'app/components/DisksTableField' +import filesize from 'filesize' + +const values = { + name: '', + description: '', + tags: {}, + type: '', + hostname: '', + disks: [], + attachedDisks: [], +} + +export default function CreateInstanceForm({ + id = 'create-instance-form', + title = 'Create instance', + initialValues = values, + onSubmit, + onSuccess, + onError, + ...props +}: PrebuiltFormProps) { + const queryClient = useApiQueryClient() + const addToast = useToast() + const pageParams = useParams('orgName', 'projectName') + + const createInstance = useApiMutation('projectInstancesPost', { + onSuccess(instance) { + // refetch list of instances + queryClient.invalidateQueries('projectInstancesGet', pageParams) + // avoid the instance fetch when the instance page loads since we have the data + queryClient.setQueryData( + 'projectInstancesGetInstance', + { ...pageParams, instanceName: instance.name }, + instance + ) + addToast({ + icon: , + title: 'Success!', + content: 'Your instance has been created.', + timeout: 5000, + }) + onSuccess?.(instance) + }, + onError, + }) + + return ( +
{ + const instance = INSTANCE_SIZES.find( + (option) => option.id === values['type'] + ) || { memory: 0, ncpus: 0 } + createInstance.mutate({ + ...pageParams, + body: { + name: values['name'], + hostname: values.hostname, + description: `An instance in project: ${pageParams.projectName}`, + memory: filesize(instance.memory, { output: 'object', base: 2 }) + .value, + ncpus: instance.ncpus, + disks: values.disks, + }, + }) + }) + } + mutation={createInstance} + {...props} + > + + + + + + + Hardware + + General Purpose + + + General purpose instances provide a good balance of CPU, memory, and + high performance storage; well suited for a wide range of use cases. + + + {renderLargeRadioCards('general')} + + + + CPU Optimized + + + CPU optimized instances provide a good balance of... + + + {renderLargeRadioCards('cpuOptimized')} + + + + Memory optimized + + + CPU optimized instances provide a good balance of... + + + {renderLargeRadioCards('memoryOptimized')} + + + + Custom + + + Custom instances... + + + {renderLargeRadioCards('custom')} + + + + + + + Boot disk + + Distros + + + {renderDistroRadioCard({ + label: 'Ubuntu', + value: 'ubuntu', + Icon: UbuntuResponsiveIcon, + })} + {renderDistroRadioCard({ + label: 'FreeBSD', + value: 'freeBsd', + Icon: FreeBSDResponsiveIcon, + })} + {renderDistroRadioCard({ + label: 'Fedora', + value: 'fedora', + Icon: FedoraResponsiveIcon, + })} + {renderDistroRadioCard({ + label: 'Debian', + value: 'debian', + Icon: DebianResponsiveIcon, + })} + {renderDistroRadioCard({ + label: 'CentOS', + value: 'centos', + Icon: CentOSResponsiveIcon, + })} + {renderDistroRadioCard({ + label: 'Windows', + value: 'windows', + Icon: WindowsResponsiveIcon, + })} + + + + + Images + + Snapshots + + + + Additional disks + + + + + Networking + + + + + {title} + + + + ) +} + +interface DistroRadioCardProps { + label: string + value: string + Icon: React.ComponentType<{ className: string }> +} +const renderDistroRadioCard = ({ + label, + value, + Icon, +}: DistroRadioCardProps) => { + return ( + +
+ + {label} +
+
+ ) +} + +const renderLargeRadioCards = (category: string) => { + return INSTANCE_SIZES.filter((option) => option.category === category).map( + (option) => ( + +
+ {option.ncpus}{' '} + + CPU{option.ncpus === 1 ? '' : 's'} + +
+
+ {option.memory}{' '} + GB RAM +
+
+ ) + ) +} + +// This data structure is completely made up for the purposes of demonstration +// only. It is not meant to reflect any opinions on how the backend API endpoint +// should be structured. Thank you for reading and have a good day! +const INSTANCE_SIZES = [ + { + category: 'general', + id: 'general-xs', + memory: 2, + ncpus: 1, + }, + { + category: 'general', + id: 'general-sm', + memory: 4, + ncpus: 2, + }, + { + category: 'general', + id: 'general-med', + memory: 16, + ncpus: 4, + }, + { + category: 'general', + id: 'general-lg', + memory: 24, + ncpus: 6, + }, + { + category: 'general', + id: 'general-xl', + memory: 32, + ncpus: 8, + }, + { + category: 'cpuOptimized', + id: 'cpuOptimized-xs', + memory: 3, + ncpus: 1, + }, + { + category: 'cpuOptimized', + id: 'cpuOptimized-sm', + memory: 5, + ncpus: 3, + }, + { + category: 'cpuOptimized', + id: 'cpuOptimized-med', + memory: 7, + ncpus: 5, + }, + { + category: 'memoryOptimized', + id: 'memoryOptimized-xs', + memory: 3, + ncpus: 2, + }, + { + category: 'memoryOptimized', + id: 'memoryOptimized-sm', + memory: 5, + ncpus: 3, + }, + { + category: 'memoryOptimized', + id: 'memoryOptimized-med', + memory: 17, + ncpus: 5, + }, + { + category: 'custom', + id: 'custom-xs', + memory: 2, + ncpus: 1, + }, + { + category: 'custom', + id: 'custom-sm', + memory: 4, + ncpus: 2, + }, + { + category: 'custom', + id: 'custom-med', + memory: 16, + ncpus: 4, + }, +] diff --git a/app/forms/org-create.tsx b/app/forms/org-create.tsx index d442a721f9..705b68855d 100644 --- a/app/forms/org-create.tsx +++ b/app/forms/org-create.tsx @@ -4,7 +4,7 @@ import type { Organization } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { useToast } from 'app/hooks' import { Success16Icon } from '@oxide/ui' -import type { PrebuiltFormProps } from '@oxide/form' +import type { PrebuiltFormProps } from 'app/forms' const values = { name: '', diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 2077a6a67f..a4f6cc77a2 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -5,7 +5,7 @@ import type { Project } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { useParams, useToast } from '../hooks' import { Form, NameField, DescriptionField } from '@oxide/form' -import type { PrebuiltFormProps } from '@oxide/form' +import type { PrebuiltFormProps } from 'app/forms' const values = { name: '', diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 5c29e890e8..61989deef0 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -1,10 +1,10 @@ import { DescriptionField, Form, NameField, TextField } from '@oxide/form' import { Divider } from '@oxide/ui' import React from 'react' -import type { PrebuiltFormProps } from '@oxide/form' import type { VpcSubnet } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { useParams } from 'app/hooks' +import type { PrebuiltFormProps } from 'app/forms' const values = { name: '', diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index 96b2c8bf96..795a1e6395 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -1,11 +1,12 @@ import React from 'react' -import { CreateSubnetForm } from './subnet-create' -import type { ExtendedPrebuiltFormProps } from '@oxide/form' -import { useParams } from 'app/hooks' import type { VpcSubnet } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import { invariant } from '@oxide/util' +import { CreateSubnetForm } from './subnet-create' +import { useParams } from 'app/hooks' +import type { ExtendedPrebuiltFormProps } from 'app/forms' + export function EditSubnetForm({ id = 'edit-subnet-form', title = 'Edit subnet', diff --git a/app/hooks/use-form.tsx b/app/hooks/use-form.tsx deleted file mode 100644 index ddac69bb74..0000000000 --- a/app/hooks/use-form.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { ComponentProps } from 'react' -import { SideModal } from '@oxide/ui' -import { useCallback } from 'react' -import { useState, Suspense, useMemo } from 'react' -import React from 'react' -import type { FormTypes } from 'app/forms' - -/** - * Dynamically load a form from the `forms` directory where id is the name of the form. It - * returns an element that can be used to render the form and an invocation function to display - * the form. The invocation can take the form's props to alter the form's behavior. - */ -export const useForm = ( - type: K, - props?: ComponentProps -) => { - const [isOpen, setShowForm] = useState(false) - const [formProps, setFormProps] = useState(props) - - const invokeForm = (innerProps?: typeof props) => { - if (innerProps) { - setFormProps(innerProps) - } - setShowForm(true) - } - - const onDismiss = useCallback(() => { - setShowForm(false) - formProps?.onDismiss?.() - }, [formProps, setShowForm]) - - const onSuccess = useCallback( - (data) => { - setShowForm(false) - formProps?.onSuccess?.(data) - }, - [formProps, setShowForm] - ) - - const DynForm = useMemo( - () => React.lazy(() => import(`../forms/${type}.tsx`)), - [type] - ) - - return [ - - - {/* @ts-expect-error TODO: Figure out why this is erroring */} - - - , - invokeForm, - ] as const -} diff --git a/app/hooks/use-params.ts b/app/hooks/use-params.ts index 4e17ceeeef..e272978c35 100644 --- a/app/hooks/use-params.ts +++ b/app/hooks/use-params.ts @@ -1,15 +1,19 @@ +import type { Params } from 'react-router-dom' import { useParams as _useParams } from 'react-router-dom' import { invariant } from '@oxide/util' /** * Wrapper for React Router's `useParams` that throws (in dev) if any of the - * specified params is missing. + * specified params is missing. The specified params are guaranteed by TS to be + * present on the resulting params object. Any other param is allowed to be + * pulled off the object, but TS will require you to check if it's undefined. * - * @returns an object where the params are guaranteed (in dev) to be present + * @returns an object where the specified params are guaranteed (in dev) to be + * present */ -export function useParams( - ...paramNames: K[] -): Record { +// default of never is required to prevent the highly undesirable property that if +// you don't pass any arguments, the result object thinks every property is defined +export function useParams(...paramNames: K[]) { const params = _useParams() if (process.env.NODE_ENV !== 'production') { for (const k of paramNames) { @@ -19,5 +23,5 @@ export function useParams( ) } } - return params as Record + return params as { readonly [k in K]: string } & Params } diff --git a/app/pages/__tests__/InstanceCreatePage.spec.tsx b/app/pages/__tests__/InstanceCreatePage.spec.tsx deleted file mode 100644 index ef42f76494..0000000000 --- a/app/pages/__tests__/InstanceCreatePage.spec.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - clickByRole, - fireEvent, - override, - renderAppAt, - screen, - typeByRole, - waitFor, -} from 'app/test/utils' -import { org, project, instance } from '@oxide/api-mocks' - -const formUrl = `/orgs/${org.name}/projects/${project.name}/instances/new` - -describe('InstanceCreatePage', () => { - it('shows specific message for known server error code', async () => { - renderAppAt(formUrl) - typeByRole('textbox', 'Choose a name', instance.name) // already exists in db - - clickByRole('button', 'Create instance') - - await screen.findByText( - 'An instance with that name already exists in this project' - ) - // don't nav away - expect(window.location.pathname).toEqual(formUrl) - }) - - it('shows generic message for unknown server error', async () => { - const createUrl = `/api/organizations/${org.name}/projects/${project.name}/instances` - override('post', createUrl, 400, { error_code: 'UnknownCode' }) - renderAppAt(formUrl) - - clickByRole('button', 'Create instance') - - await screen.findByText('Unknown error from server') - // don't nav away - expect(window.location.pathname).toEqual(formUrl) - }) - - it('navigates to project instances page on success', async () => { - renderAppAt(formUrl) - - const instancesPage = `/orgs/${org.name}/projects/${project.name}/instances` - expect(window.location.pathname).not.toEqual(instancesPage) - - typeByRole('textbox', 'Choose a name', 'new-instance') - fireEvent.click(screen.getByLabelText(/6 CPUs/)) - - clickByRole('button', 'Create instance') - - const submit = screen.getByRole('button', { name: 'Create instance' }) - await waitFor(() => expect(submit).toBeDisabled()) - - // nav to instances list - await waitFor(() => expect(window.location.pathname).toEqual(instancesPage)) - - // new instance shows up in the list - await screen.findByText('new-instance') - }) -}) diff --git a/app/pages/project/instances/create/InstancesCreatePage.tsx b/app/pages/project/instances/create/InstancesCreatePage.tsx deleted file mode 100644 index bcd58f01e6..0000000000 --- a/app/pages/project/instances/create/InstancesCreatePage.tsx +++ /dev/null @@ -1,327 +0,0 @@ -import React, { useState } from 'react' -import { useNavigate } from 'react-router-dom' -import cn from 'classnames' -import { Formik, Form } from 'formik' - -import { - Button, - RadioGroupHint, - RadioGroup, - RadioCard, - Tabs, - Tab, - TextField, - TextFieldHint, - FieldLabel, - Badge, -} from '@oxide/ui' -import { classed } from '@oxide/util' -import { useApiMutation } from '@oxide/api' -import { getServerError } from 'app/util/errors' -import { INSTANCE_SIZES } from './instance-types' -import { NewDiskModal } from './modals/new-disk-modal' -import { ExistingDiskModal } from './modals/existing-disk-modal' -import { NetworkModal } from './modals/network-modal' -import { useParams } from 'app/hooks' - -// TODO: these probably should not both exist -const headingStyle = 'text-white text-sans-xl' -const Heading = classed.h2`text-white text-sans-xl mt-16 mb-8` - -// TODO: need to fix page container if we want these to go all the way across -const Divider = () =>
- -const GB = 1024 * 1024 * 1024 - -const ERROR_CODES = { - ObjectAlreadyExists: - 'An instance with that name already exists in this project', -} - -export default function InstanceCreatePage() { - const navigate = useNavigate() - const { orgName, projectName } = useParams('orgName', 'projectName') - - const [showNewDiskModal, setShowNewDiskModal] = useState(false) - const [showExistingDiskModal, setShowExistingDiskModal] = useState(false) - const [showNetworkModal, setShowNetworkModal] = useState(false) - - const createInstance = useApiMutation('projectInstancesPost', { - onSuccess() { - navigate('..') // project page - }, - }) - - const renderLargeRadioCards = (category: string) => { - return INSTANCE_SIZES.filter((option) => option.category === category).map( - (option) => ( - -
- {option.ncpus} CPUs -
-
- {option.memory} GB RAM -
-
- ) - ) - } - - return ( - <> - { - if (!createInstance.isLoading) { - const instance = INSTANCE_SIZES.find( - (option) => option.id === values['instance-type'] - ) || { memory: 0, ncpus: 0 } - - createInstance.mutate({ - orgName, - projectName, - body: { - name: values['instance-name'], - hostname: values.hostname, - description: `An instance in project: ${projectName}`, - memory: instance.memory * GB, - ncpus: instance.ncpus, - }, - }) - } - }} - > -
- Choose an image - - Distributions - -
- Choose a pre-built image - - CentOS - Debian - Fedora - FreeBSD - Ubuntu - Windows - -
-
- - Custom Images - -
- Choose a custom image - - Custom CentOS - Custom Debian - Custom Fedora - -
-
-
- - Choose CPUs and RAM - - General purpose - -
- - Choose a general purpose instance - - - General purpose instances provide a good balance of CPU, - memory, and high performance storage; well suited for a wide - range of use cases. - - {/* TODO: find the logic behind this ad hoc spacing */} - - {renderLargeRadioCards('general')} - -
-
- - CPU-optimized - -
- - Choose a CPU-optimized instance - - - CPU optimized instances provide a good balance of... - - - {renderLargeRadioCards('cpuOptimized')} - -
-
- - Memory-optimized - -
- - Choose a memory-optimized instance - - - Memory optimized instances provide a good balance of... - - - {renderLargeRadioCards('memoryOptimized')} - -
-
- - - Custom New - - -
- Choose a custom instance - - Custom instances... - - - {renderLargeRadioCards('custom')} - -
-
-
- -
-
- - Boot disk storage - - - - 100 GB - - - 200 GB - - - 500 GB - - - 1,000 GB - - - 2,000 GB - - Custom - -
- -
-

Additional volumes

- - setShowNewDiskModal(false)} - /> - - setShowExistingDiskModal(false)} - orgName={orgName} - projectName={projectName} - /> -
-
- - Networking - - setShowNetworkModal(false)} - orgName={orgName} - projectName={projectName} - /> - - - - Finalize and create -
- Choose a name - - Choose an identifying name you will remember. Names may contain - alphanumeric characters, dashes, and periods. - - -
-
- Choose a hostname - - Optional. If left blank, we will use the instance name. - - -
- - {/* this is going to be a tag multiselect, not a text input */} -
- Add tags - - Use tags to organize and relate resources. Tags may contain - letters, numbers, colons, dashes, and underscores. - - -
- - -
- {getServerError(createInstance.error, ERROR_CODES)} -
- -
- - ) -} diff --git a/app/pages/project/instances/create/index.ts b/app/pages/project/instances/create/index.ts deleted file mode 100644 index 41849f6a2a..0000000000 --- a/app/pages/project/instances/create/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './InstancesCreatePage' diff --git a/app/pages/project/instances/create/instance-types.ts b/app/pages/project/instances/create/instance-types.ts deleted file mode 100644 index 89807de227..0000000000 --- a/app/pages/project/instances/create/instance-types.ts +++ /dev/null @@ -1,89 +0,0 @@ -// This data structure is completely made up for the purposes of demonstration -// only. It is not meant to reflect any opinions on how the backend API endpoint -// should be structured. Thank you for reading and have a good day! -export const INSTANCE_SIZES = [ - { - category: 'general', - id: 'general-xs', - memory: 2, - ncpus: 1, - }, - { - category: 'general', - id: 'general-sm', - memory: 4, - ncpus: 2, - }, - { - category: 'general', - id: 'general-med', - memory: 16, - ncpus: 4, - }, - { - category: 'general', - id: 'general-lg', - memory: 24, - ncpus: 6, - }, - { - category: 'general', - id: 'general-xl', - memory: 32, - ncpus: 8, - }, - { - category: 'cpuOptimized', - id: 'cpuOptimized-xs', - memory: 3, - ncpus: 1, - }, - { - category: 'cpuOptimized', - id: 'cpuOptimized-sm', - memory: 5, - ncpus: 3, - }, - { - category: 'cpuOptimized', - id: 'cpuOptimized-med', - memory: 7, - ncpus: 5, - }, - { - category: 'memoryOptimized', - id: 'memoryOptimized-xs', - memory: 3, - ncpus: 2, - }, - { - category: 'memoryOptimized', - id: 'memoryOptimized-sm', - memory: 5, - ncpus: 3, - }, - { - category: 'memoryOptimized', - id: 'memoryOptimized-med', - memory: 17, - ncpus: 5, - }, - { - category: 'custom', - id: 'custom-xs', - memory: 2, - ncpus: 1, - }, - { - category: 'custom', - id: 'custom-sm', - memory: 4, - ncpus: 2, - }, - { - category: 'custom', - id: 'custom-med', - memory: 16, - ncpus: 4, - }, -] diff --git a/app/pages/project/instances/create/modals/existing-disk-modal.tsx b/app/pages/project/instances/create/modals/existing-disk-modal.tsx deleted file mode 100644 index 99fdf7fa16..0000000000 --- a/app/pages/project/instances/create/modals/existing-disk-modal.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react' -import { Formik, Form } from 'formik' - -import type { Disk } from '@oxide/api' -import { useApiQuery } from '@oxide/api' -import { - Button, - Dropdown, - FieldLabel, - Radio, - RadioGroup, - SideModal_old as SideModal, -} from '@oxide/ui' - -type Props = { - isOpen: boolean - onDismiss: () => void - orgName: string - projectName: string -} - -const isUnattached = ({ state }: Disk) => { - const stateStr = state.state - return ( - stateStr !== 'attached' && - stateStr !== 'attaching' && - stateStr !== 'detaching' - ) -} - -export function ExistingDiskModal({ - isOpen, - onDismiss, - orgName, - projectName, -}: Props) { - // TODO: maybe wait to fetch until you open the modal - const { data } = useApiQuery('projectDisksGet', { orgName, projectName }) - - const disks = data?.items - .filter(isUnattached) - .map((d) => ({ value: d.id, label: d.name })) - - return ( - - - {}} - > -
- {/*

Disk

*/} - -
- Mode - - Read/Write - Read only - -
-
- Deletion Rule - - Keep disk - Delete disk - -
- -
-
- - Deletion rules - Disk naming - - - {/* TODO: not supposed to be a ghost button */} - - - -
- ) -} diff --git a/app/pages/project/instances/create/modals/network-modal.tsx b/app/pages/project/instances/create/modals/network-modal.tsx deleted file mode 100644 index 0160fee191..0000000000 --- a/app/pages/project/instances/create/modals/network-modal.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' - -import { Button, Dropdown, SideModal_old as SideModal } from '@oxide/ui' -import { useApiQuery } from '@oxide/api' - -type Props = { - isOpen: boolean - onDismiss: () => void - orgName: string - projectName: string -} - -export function NetworkModal({ - isOpen, - onDismiss, - orgName, - projectName, -}: Props) { - const { data: vpcs } = useApiQuery('projectVpcsGet', { orgName, projectName }) - const vpcItems = vpcs?.items.map((v) => ({ value: v.id, label: v.name })) - return ( - - - {/* TODO: tearing up Dropdown into bits will let us fix button alignment */} -
- - -
-
- - -
- - - -
- - Subnetworks - External IPs - - - {/* TODO: not supposed to be a ghost button */} - - - -
- ) -} diff --git a/app/pages/project/instances/create/modals/new-disk-modal.tsx b/app/pages/project/instances/create/modals/new-disk-modal.tsx deleted file mode 100644 index 72b3d8bc95..0000000000 --- a/app/pages/project/instances/create/modals/new-disk-modal.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from 'react' -import { Formik, Form } from 'formik' - -import { - Button, - FieldLabel, - Radio, - RadioGroup, - SideModal_old as SideModal, - TextField, -} from '@oxide/ui' - -type Props = { - isOpen: boolean - onDismiss: () => void -} - -export function NewDiskModal({ isOpen, onDismiss }: Props) { - return ( - - - {}} - > -
-
- - Name - - -
-
- - Description - - -
-
- Type - -
-
- - Source type - - -
-
- Deletion rule - - Keep disk - Delete disk - -
-
- Size (GiB) - -
-
- Configuration options - - Manually format and mount - Automatically format and mount - -
-
-
-
- - Formatting a persistent disk - Deletion Rules - Disk naming - - - {/* TODO: not supposed to be a ghost button */} - - - -
- ) -} diff --git a/app/pages/project/instances/index.tsx b/app/pages/project/instances/index.tsx index 2b56c14ad1..96dc1aabc7 100644 --- a/app/pages/project/instances/index.tsx +++ b/app/pages/project/instances/index.tsx @@ -1,3 +1,2 @@ -export * from './create' export * from './instance/InstancePage' export * from './InstancesPage' diff --git a/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx b/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx index 46bf6660e0..263c9d6ca1 100644 --- a/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx +++ b/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx @@ -73,11 +73,13 @@ const CommonForm = ({ id, error }: FormProps) => { {/* TODO: better text or heading or tip or something on this checkbox */} Enabled
- Name + + Name +
- + Description {/* TODO: indicate optional */} @@ -85,7 +87,9 @@ const CommonForm = ({ id, error }: FormProps) => {
- Priority + + Priority + Must be 0–65535 @@ -125,7 +129,9 @@ const CommonForm = ({ id, error }: FormProps) => { }} />
- Target name + + Target name +
@@ -212,7 +218,9 @@ const CommonForm = ({ id, error }: FormProps) => { So we should probably have the label on this field change when the host type changes. Also need to confirm that it's just an IP and not a block. */} - Value + + Value + For IP, an address. For the rest, a name. [TODO: copy] @@ -286,7 +294,9 @@ const CommonForm = ({ id, error }: FormProps) => {
- Port filter + + Port filter + A single port (1234) or a range (1234-2345) diff --git a/app/pages/project/networking/VpcPage/modals/vpc-routers.tsx b/app/pages/project/networking/VpcPage/modals/vpc-routers.tsx index dfd2acac02..7eb01e01fd 100644 --- a/app/pages/project/networking/VpcPage/modals/vpc-routers.tsx +++ b/app/pages/project/networking/VpcPage/modals/vpc-routers.tsx @@ -25,13 +25,18 @@ const CommonForm = ({ error, id }: FormProps) => (
- + Name
diff --git a/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx b/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx deleted file mode 100644 index af8067d1fa..0000000000 --- a/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import React from 'react' -import { Formik, Form } from 'formik' - -import { - Button, - FieldLabel, - SideModal_old as SideModal, - TextField, -} from '@oxide/ui' -import type { VpcSubnet, ErrorResponse } from '@oxide/api' -import { useApiMutation, useApiQueryClient } from '@oxide/api' -import { getServerError } from 'app/util/errors' - -type FormProps = { - error: ErrorResponse | null - id: string -} - -// the moment the two forms diverge, inline them rather than introducing BS -// props here -const CommonForm = ({ id, error }: FormProps) => ( - - -
- - IPv4 block - - -
-
- - IPv6 block - - -
-
- -
- - Name - - -
-
- - Description {/* TODO: indicate optional */} - - -
-
- -
{getServerError(error)}
-
- -) - -type CreateProps = { - isOpen: boolean - onDismiss: () => void - orgName: string - projectName: string - vpcName: string -} - -export function CreateVpcSubnetModal({ - isOpen, - onDismiss, - orgName, - projectName, - vpcName, -}: CreateProps) { - const parentNames = { orgName, projectName, vpcName } - const queryClient = useApiQueryClient() - - function dismiss() { - createSubnet.reset() - onDismiss() - } - - const createSubnet = useApiMutation('vpcSubnetsPost', { - onSuccess() { - queryClient.invalidateQueries('vpcSubnetsGet', parentNames) - dismiss() - }, - }) - - const formId = 'create-vpc-subnet-form' - - return ( - - { - // XXX body is optional. useApiMutation should be smarter and require body when it's required - // TODO: validate IP blocks client-side using the patterns. sadly non-trivial - createSubnet.mutate({ ...parentNames, body }) - }} - > - - - - - - - - ) -} - -type EditProps = { - onDismiss: () => void - orgName: string - projectName: string - vpcName: string - originalSubnet: VpcSubnet | null -} - -export function EditVpcSubnetModal({ - onDismiss, - orgName, - projectName, - vpcName, - originalSubnet, -}: EditProps) { - const parentNames = { orgName, projectName, vpcName } - const queryClient = useApiQueryClient() - - function dismiss() { - updateSubnet.reset() - onDismiss() - } - - const updateSubnet = useApiMutation('vpcSubnetsPutSubnet', { - onSuccess() { - queryClient.invalidateQueries('vpcSubnetsGet', parentNames) - dismiss() - }, - }) - - if (!originalSubnet) return null - - const formId = 'edit-vpc-subnet-form' - return ( - - { - updateSubnet.mutate({ - ...parentNames, - subnetName: originalSubnet.name, - body: { - name, - description, - // TODO: validate these client-side using the patterns. sadly non-trivial - ipv4Block: ipv4Block || null, - ipv6Block: ipv6Block || null, - }, - }) - }} - > - - - - - - - - ) -} diff --git a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx b/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx index 40c4d36f04..1ac19bac1e 100644 --- a/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx +++ b/app/pages/project/networking/VpcPage/tabs/VpcSubnetsTab.tsx @@ -1,22 +1,23 @@ -import React from 'react' +import React, { useState } from 'react' import { useParams } from 'app/hooks' import type { MenuAction } from '@oxide/table' import { useQueryTable, TwoLineCell, DateCell } from '@oxide/table' -import { Button } from '@oxide/ui' +import { Button, SideModal } from '@oxide/ui' import type { VpcSubnet } from '@oxide/api' -import { useForm } from 'app/hooks/use-form' +import { CreateSubnetForm } from 'app/forms/subnet-create' +import { EditSubnetForm } from 'app/forms/subnet-edit' export const VpcSubnetsTab = () => { const vpcParams = useParams('orgName', 'projectName', 'vpcName') const { Table, Column } = useQueryTable('vpcSubnetsGet', vpcParams) - const [createSubnetForm, showCreateSubnet] = useForm('subnet-create') - const [editSubnetForm, showEditSubnet] = useForm('subnet-edit') + const [showCreate, setShowCreate] = useState(false) + const [editing, setEditing] = useState(null) const makeActions = (subnet: VpcSubnet): MenuAction[] => [ { label: 'Edit', - onActivate: () => showEditSubnet({ initialValues: subnet }), + onActivate: () => setEditing(subnet), }, ] @@ -26,12 +27,29 @@ export const VpcSubnetsTab = () => { - {createSubnetForm} - {editSubnetForm} + setShowCreate(false)} + > + setShowCreate(false)} /> + + setEditing(null)} + > + {editing && ( + setEditing(null)} + /> + )} +
diff --git a/app/routes.tsx b/app/routes.tsx index 575c965863..d615114af2 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -4,7 +4,6 @@ import type { RouteMatch, RouteObject } from 'react-router-dom' import { Navigate, Route, Routes } from 'react-router-dom' import LoginPage from './pages/LoginPage' -import InstanceCreatePage from './pages/project/instances/create/InstancesCreatePage' import { AccessPage, DisksPage, @@ -109,7 +108,7 @@ export const routes = ( } /> } + element={} title="Create instance" icon={} /> diff --git a/libs/form/Form.tsx b/libs/form/Form.tsx index 46f98c29dd..f9ec09527b 100644 --- a/libs/form/Form.tsx +++ b/libs/form/Form.tsx @@ -2,14 +2,12 @@ import type { ButtonProps } from '@oxide/ui' import { Button } from '@oxide/ui' import { SideModal } from '@oxide/ui' import { useIsInSideModal } from '@oxide/ui' -import { Divider } from '@oxide/ui' import { addProps, classed, flattenChildren, invariant, isOneOf, - kebabCase, pluckFirstOfType, tunnel, Wrap, @@ -77,7 +75,7 @@ export function Form({ <>
( Form.PageActions = PageActionsTunnel.Out -const FormHeading = classed.h2`ox-form-heading text-content text-sans-2xl` +Form.Heading = classed.h2`ox-form-heading text-content text-sans-2xl` export interface FormSectionProps { id?: string children: React.ReactNode title: string } -Form.Section = ({ id, title, children }: FormSectionProps) => { - return ( - <> - - {title} - {children} - - ) -} diff --git a/libs/form/fields/NameField.spec.tsx b/libs/form/fields/NameField.spec.tsx index f7e6830338..1daf3be52e 100644 --- a/libs/form/fields/NameField.spec.tsx +++ b/libs/form/fields/NameField.spec.tsx @@ -1,28 +1,29 @@ -import { validateName } from './NameField' +import { getNameValidator } from './NameField' describe('validateName', () => { + const validate = getNameValidator('Name', true) it('returns undefined for valid names', () => { - expect(validateName('abc')).toBeUndefined() - expect(validateName('abc-def')).toBeUndefined() - expect(validateName('abc9-d0ef-6')).toBeUndefined() + expect(validate('abc')).toBeUndefined() + expect(validate('abc-def')).toBeUndefined() + expect(validate('abc9-d0ef-6')).toBeUndefined() }) it('detects names starting with something other than lower-case letter', () => { - expect(validateName('Abc')).toEqual('Must start with a lower-case letter') - expect(validateName('9bc')).toEqual('Must start with a lower-case letter') - expect(validateName('Abc-')).toEqual('Must start with a lower-case letter') + expect(validate('Abc')).toEqual('Must start with a lower-case letter') + expect(validate('9bc')).toEqual('Must start with a lower-case letter') + expect(validate('Abc-')).toEqual('Must start with a lower-case letter') }) it('requires names to end with letter or number', () => { - expect(validateName('abc-')).toEqual('Must end with a letter or number') - expect(validateName('abc---')).toEqual('Must end with a letter or number') + expect(validate('abc-')).toEqual('Must end with a letter or number') + expect(validate('abc---')).toEqual('Must end with a letter or number') }) it('rejects invalid characters', () => { - expect(validateName('aBc')).toEqual( + expect(validate('aBc')).toEqual( 'Can only contain lower-case letters, numbers, and dashes' ) - expect(validateName('asldk:c')).toEqual( + expect(validate('asldk:c')).toEqual( 'Can only contain lower-case letters, numbers, and dashes' ) }) diff --git a/libs/form/fields/NameField.tsx b/libs/form/fields/NameField.tsx index 62fecea4f3..1beafe3562 100644 --- a/libs/form/fields/NameField.tsx +++ b/libs/form/fields/NameField.tsx @@ -1,6 +1,7 @@ import type { TextFieldProps } from './TextField' import { TextField } from './TextField' import React from 'react' +import { capitalize } from '@oxide/util' export interface NameFieldProps extends Omit { @@ -10,12 +11,14 @@ export interface NameFieldProps export function NameField({ required = true, name = 'name', + label = capitalize(name), ...textFieldProps }: NameFieldProps) { return ( @@ -23,14 +26,17 @@ export function NameField({ } // TODO Update JSON schema to match this, add fuzz testing between this and name pattern -export function validateName(name: string) { - if (name.length === 0) { - return 'A name is required' - } else if (!/^[a-z]/.test(name)) { - return 'Must start with a lower-case letter' - } else if (!/[a-z0-9]$/.test(name)) { - return 'Must end with a letter or number' - } else if (!/^[a-z0-9-]+$/.test(name)) { - return 'Can only contain lower-case letters, numbers, and dashes' +export const getNameValidator = + (label: string, required: boolean) => (name: string) => { + if (!required && !name) return + + if (name.length === 0) { + return `${label} is required` + } else if (!/^[a-z]/.test(name)) { + return 'Must start with a lower-case letter' + } else if (!/[a-z0-9]$/.test(name)) { + return 'Must end with a letter or number' + } else if (!/^[a-z0-9-]+$/.test(name)) { + return 'Can only contain lower-case letters, numbers, and dashes' + } } -} diff --git a/libs/form/fields/RadioField.tsx b/libs/form/fields/RadioField.tsx index 7b798eba57..160edad05d 100644 --- a/libs/form/fields/RadioField.tsx +++ b/libs/form/fields/RadioField.tsx @@ -1,6 +1,6 @@ import type { RadioGroupProps } from '@oxide/ui' import { FieldLabel, RadioGroup, TextFieldHint } from '@oxide/ui' -import { capitalize } from '@oxide/util' +import cn from 'classnames' import React from 'react' // TODO: Centralize these docstrings perhaps on the `FieldLabel` component? @@ -32,16 +32,23 @@ export interface RadioFieldProps extends Omit { export function RadioField({ id, name = id, - label = capitalize(name), + label, helpText, description, ...props }: RadioFieldProps) { return ( -
- - {label} - +
+ {label && ( + + {label} + + )} {/* TODO: Figure out where this hint field def should live */} {helpText && ( {helpText} diff --git a/libs/form/form.css b/libs/form/form.css index 869d8dcd75..e2d24ee8b5 100644 --- a/libs/form/form.css +++ b/libs/form/form.css @@ -16,3 +16,8 @@ width: calc(100% + var(--content-gutter) * 2) !important; margin-left: calc(var(--content-gutter) * -1) !important; } + +.ox-form, +.ox-form .ox-tab-panel { + @apply space-y-7; +} diff --git a/libs/form/index.ts b/libs/form/index.ts index cc9c74b943..dcf2030d15 100644 --- a/libs/form/index.ts +++ b/libs/form/index.ts @@ -1,3 +1,2 @@ export * from './Form' export * from './fields' -export * from './types' diff --git a/libs/form/types.ts b/libs/form/types.ts deleted file mode 100644 index e6b3528949..0000000000 --- a/libs/form/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { ErrorResponse } from '@oxide/api' -import type { ComponentType } from 'react' -import type { FormProps } from './Form' - -/** - * A form that's built out ahead of time and intended to be re-used dynamically. Fields - * that are expected to be provided by default are set to optional. - */ -export type PrebuiltFormProps = Omit< - Optional< - FormProps, - 'id' | 'title' | 'initialValues' | 'onSubmit' | 'mutation' - >, - 'children' -> & { - children?: never - onSuccess?: (data: Data) => void - onError?: (err: ErrorResponse) => void -} - -/** - * A utility type for a prebuilt form that extends another form - */ -export type ExtendedPrebuiltFormProps = C extends ComponentType< - infer B -> - ? // eslint-disable-next-line @typescript-eslint/no-explicit-any - B extends PrebuiltFormProps - ? PrebuiltFormProps - : never - : never diff --git a/libs/ui/lib/field-label/FieldLabel.tsx b/libs/ui/lib/field-label/FieldLabel.tsx index 5b3c22bf69..d816682326 100644 --- a/libs/ui/lib/field-label/FieldLabel.tsx +++ b/libs/ui/lib/field-label/FieldLabel.tsx @@ -2,43 +2,22 @@ import React from 'react' import type { ElementType, PropsWithChildren } from 'react' import { Info8Icon, Tooltip } from '@oxide/ui' -/** - * Ensures that label always has an `htmlFor` prop associated with it - */ -type FieldLabelProps = ( - | { - id: string - htmlFor?: string - as?: never - } - | { - as: 'label' - htmlFor: string - id?: never - } - | { - as?: never - htmlFor: string - id?: never - } - | { - as: Exclude - htmlFor?: string - id?: never - } -) & { +interface FieldLabelProps { + id: string + as?: ElementType + htmlFor?: string tip?: string optional?: boolean } -export const FieldLabel = ({ +export const FieldLabel = ({ id, children, htmlFor, tip, optional, as, -}: PropsWithChildren>) => { +}: PropsWithChildren) => { const Component = as || 'label' return (
diff --git a/libs/ui/lib/mini-table/mini-table.css b/libs/ui/lib/mini-table/mini-table.css index 5e1ee4d352..16ba8c1f74 100644 --- a/libs/ui/lib/mini-table/mini-table.css +++ b/libs/ui/lib/mini-table/mini-table.css @@ -24,7 +24,14 @@ } .ox-mini-table td > div { - @apply border-y py-3 pl-3 text-default bg-default border-accent; + @apply flex h-11 items-center border-y py-3 pl-3 text-default bg-default border-accent; +} + +.ox-mini-table td:last-child > div { + @apply w-12 justify-center pl-0 pr-0; +} +.ox-mini-table td:last-child > div > button { + @apply pl-4; } .ox-mini-table tr:hover td > div { diff --git a/libs/ui/lib/radio/Radio.tsx b/libs/ui/lib/radio/Radio.tsx index adc171d2da..395542f181 100644 --- a/libs/ui/lib/radio/Radio.tsx +++ b/libs/ui/lib/radio/Radio.tsx @@ -6,7 +6,7 @@ * difference is that label content is handled through children. */ -import type { PropsWithChildren } from 'react' +import type { ComponentProps } from 'react' import React from 'react' import cn from 'classnames' import { Field } from 'formik' @@ -72,6 +72,12 @@ export function RadioCard({ children, className, ...inputProps }: RadioProps) { } // TODO: Remove importants after tailwind variantOrder bug fixed -RadioCard.Unit = ({ children }: PropsWithChildren) => ( - {children} +RadioCard.Unit = ({ + children, + className, + ...props +}: ComponentProps<'span'>) => ( + + {children} + ) diff --git a/libs/ui/lib/tabs/Tabs.tsx b/libs/ui/lib/tabs/Tabs.tsx index 463dcf697d..1642e996e9 100644 --- a/libs/ui/lib/tabs/Tabs.tsx +++ b/libs/ui/lib/tabs/Tabs.tsx @@ -45,12 +45,13 @@ export function Tabs({ addKey((i) => `${id}-tab-${i}`) ) const panels = pluckAllOfType(childArray, Tab.Panel).map( - addProps((i) => ({ + addProps((i, panelProps) => ({ key: `${id}-panel-${i}`, index: i, className: cn( fullWidth && - 'children:mx-[var(--content-gutter)] children:w-[calc(100%-var(--content-gutter)*2)]' + 'children:mx-[var(--content-gutter)] children:w-[calc(100%-var(--content-gutter)*2)]', + panelProps.className ), })) ) @@ -98,7 +99,7 @@ export function Tab({ className, ...props }: TabProps) { export interface TabPanelProps extends RTabPanelProps { className?: string } -Tab.Panel = function Panel({ children, ...props }: TabPanelProps) { +Tab.Panel = function Panel({ children, className, ...props }: TabPanelProps) { const { selectedIndex } = useTabsContext() // `index` is a secret prop that's automatically generated by the parents tab // component. We use it here to determine if the panel's contents should @@ -108,7 +109,7 @@ Tab.Panel = function Panel({ children, ...props }: TabPanelProps) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const isSelected = selectedIndex === (props as any).index return ( - + {(isSelected && children) || } ) diff --git a/libs/util/classed.ts b/libs/util/classed.ts index c4e500a198..8b0331c387 100644 --- a/libs/util/classed.ts +++ b/libs/util/classed.ts @@ -15,7 +15,9 @@ const make = ) // allow arbitrary components to hang off this one, e.g., Table.Body Comp.displayName = `classed.${tag}` - return Comp as typeof Comp & Record + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Comp as typeof Comp & Record> } // JSX.IntrinsicElements[T] ensures same props as the real DOM element. For example, diff --git a/libs/util/str.spec.ts b/libs/util/str.spec.ts index 0aed11ad1c..ac3333f9e6 100644 --- a/libs/util/str.spec.ts +++ b/libs/util/str.spec.ts @@ -4,11 +4,6 @@ describe('capitalize', () => { it('capitalizes the first letter', () => { expect(capitalize('this is a sentence')).toEqual('This is a sentence') }) - - it('passes through falsy values', () => { - expect(capitalize('')).toEqual('') - expect(capitalize(undefined)).toEqual(undefined) - }) }) describe('camelCase', () => { diff --git a/libs/util/str.ts b/libs/util/str.ts index c2040ca059..5573cc5891 100644 --- a/libs/util/str.ts +++ b/libs/util/str.ts @@ -1,5 +1,4 @@ -// TODO: should this even accept undefined? kind of weird -export const capitalize = (s: string | undefined) => +export const capitalize = (s: string) => s && s.charAt(0).toUpperCase() + s.slice(1) export const pluralize = (s: string, n: number) =>