diff --git a/app/components/TimeAgo.tsx b/app/components/TimeAgo.tsx index 76e1b536bd..f2ee5cb2ce 100644 --- a/app/components/TimeAgo.tsx +++ b/app/components/TimeAgo.tsx @@ -14,16 +14,16 @@ import { timeAgoAbbr } from 'app/util/date' export const TimeAgo = ({ datetime, - description, + tooltipText, placement = 'top', }: { datetime: Date - description?: string + tooltipText?: string placement?: Placement }): JSX.Element => { const content = (
- {description} + {tooltipText} {format(datetime, 'MMM d, yyyy p')}
) diff --git a/app/components/form/fields/FileField.tsx b/app/components/form/fields/FileField.tsx index 8c746a00b2..389ac99e57 100644 --- a/app/components/form/fields/FileField.tsx +++ b/app/components/form/fields/FileField.tsx @@ -18,20 +18,20 @@ export function FileField< id, name, label, - description, + tooltipText, control, required = false, accept, - helpText, + description, }: { id: string name: TName label: string - description?: string + tooltipText?: string control: Control required?: boolean accept?: string - helpText?: string + description?: string | React.ReactNode }) { return ( {label} - {helpText && {helpText}} + {description && ( + {description} + )} diff --git a/app/components/form/fields/ListboxField.tsx b/app/components/form/fields/ListboxField.tsx index 177aaa4e69..e472484a3f 100644 --- a/app/components/form/fields/ListboxField.tsx +++ b/app/components/form/fields/ListboxField.tsx @@ -22,8 +22,8 @@ export type ListboxFieldProps< className?: string label?: string required?: boolean - helpText?: string - description?: string + description?: string | React.ReactNode | React.ReactNode + tooltipText?: string control: Control disabled?: boolean items: ListboxItem[] @@ -41,8 +41,8 @@ export function ListboxField< label = capitalize(name), disabled, required, + tooltipText, description, - helpText, className, control, onChange, @@ -59,9 +59,9 @@ export function ListboxField< render={({ field, fieldState: { error } }) => ( <> Network interface - { - const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] +
+ { + const newType = event.target.value as InstanceNetworkInterfaceAttachment['type'] - if (value.type === 'create') { - setOldParams(value.params) - } + if (value.type === 'create') { + setOldParams(value.params) + } - newType === 'create' - ? onChange({ type: newType, params: oldParams }) - : onChange({ type: newType }) - }} - disabled={disabled} - > - None - Default - Custom - - {value.type === 'create' && ( - <> - {value.params.length > 0 && ( - - - Name - VPC - Subnet - {/* For remove button */} - - - - {value.params.map((item, index) => ( - - {item.name} - {item.vpcName} - {item.subnetName} - - - - - ))} - - - )} + newType === 'create' + ? onChange({ type: newType, params: oldParams }) + : onChange({ type: newType }) + }} + disabled={disabled} + > + None + Default + Custom + + {value.type === 'create' && ( + <> + {value.params.length > 0 && ( + + + Name + VPC + Subnet + {/* For remove button */} + + + + {value.params.map((item, index) => ( + + {item.name} + {item.vpcName} + {item.subnetName} + + + + + ))} + + + )} - {showForm && ( - { - onChange({ - type: 'create', - params: [...value.params, networkInterface], - }) - setShowForm(false) - }} - onDismiss={() => setShowForm(false)} - /> - )} -
- -
- - )} + {showForm && ( + { + onChange({ + type: 'create', + params: [...value.params, networkInterface], + }) + setShowForm(false) + }} + onDismiss={() => setShowForm(false)} + /> + )} +
+ +
+ + )} +
) } diff --git a/app/components/form/fields/NumberField.tsx b/app/components/form/fields/NumberField.tsx index d09c0417b5..14f74ba023 100644 --- a/app/components/form/fields/NumberField.tsx +++ b/app/components/form/fields/NumberField.tsx @@ -23,8 +23,8 @@ export function NumberField< name, label = capitalize(name), units, + tooltipText, description, - helpText, required, ...props }: Omit, 'id'>) { @@ -33,12 +33,12 @@ export function NumberField< return (
- + {label} {units && ({units})} - {helpText && ( + {description && ( - {helpText} + {description} )}
@@ -65,7 +65,7 @@ export const NumberFieldInner = < label = capitalize(name), validate, control, - description, + tooltipText, required, id: idProp, disabled, @@ -85,9 +85,9 @@ export const NumberFieldInner = < id={id} error={!!error} aria-labelledby={cn(`${id}-label`, { - [`${id}-help-text`]: !!description, + [`${id}-help-text`]: !!tooltipText, })} - aria-describedby={description ? `${id}-label-tip` : undefined} + aria-describedby={tooltipText ? `${id}-label-tip` : undefined} isDisabled={disabled} {...field} /> diff --git a/app/components/form/fields/RadioField.tsx b/app/components/form/fields/RadioField.tsx index 60c76f8e4b..8d0722452c 100644 --- a/app/components/form/fields/RadioField.tsx +++ b/app/components/form/fields/RadioField.tsx @@ -37,7 +37,7 @@ export type RadioFieldProps< * complete the input. This will be announced in tandem with the * label when using a screen reader. */ - helpText?: string + description?: string | React.ReactNode /** * Displayed in a tooltip beside the title. This field should be used * for auxiliary context that helps users understand extra context about @@ -46,7 +46,7 @@ export type RadioFieldProps< * * @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-description */ - description?: string + tooltipText?: string placeholder?: string units?: string control: Control @@ -68,8 +68,8 @@ export function RadioField< >({ name, label = capitalize(name), - helpText, description, + tooltipText, units, control, items, @@ -81,12 +81,12 @@ export function RadioField<
{label && ( - + {label} {units && ({units})} )} {/* TODO: Figure out where this hint field def should live */} - {helpText && {helpText}} + {description && {description}}
parseValue ? onChange(parseValue(e.target.value)) : onChange(e) } @@ -137,8 +137,8 @@ export function RadioFieldDyn< >({ name, label = capitalize(name), - helpText, description, + tooltipText, units, control, children, @@ -149,12 +149,12 @@ export function RadioFieldDyn<
{label && ( - + {label} {units && ({units})} )} {/* TODO: Figure out where this hint field def should live */} - {helpText && {helpText}} + {description && {description}}
, TFieldValues> @@ -65,8 +65,8 @@ export function TextField< name, label = capitalize(name), units, + tooltipText, description, - helpText, required, ...props }: Omit, 'id'> & UITextAreaProps) { @@ -75,12 +75,12 @@ export function TextField< return (
- + {label} {units && ({units})} - {helpText && ( + {description && ( - {helpText} + {description} )}
@@ -116,7 +116,7 @@ export const TextFieldInner = < label = capitalize(name), validate, control, - description, + tooltipText, required, id: idProp, ...props @@ -137,9 +137,9 @@ export const TextFieldInner = < type={type} error={!!error} aria-labelledby={cn(`${id}-label`, { - [`${id}-help-text`]: !!description, + [`${id}-help-text`]: !!tooltipText, })} - aria-describedby={description ? `${id}-label-tip` : undefined} + aria-describedby={tooltipText ? `${id}-label-tip` : undefined} // note special handling for number fields, which produce a number // for the calling code despite the actual input value necessarily // being a string. diff --git a/app/components/form/form.css b/app/components/form/form.css index e3143fd845..2e3864ecd2 100644 --- a/app/components/form/form.css +++ b/app/components/form/form.css @@ -17,7 +17,8 @@ } .ox-form, -.ox-form .ox-tabs-panel { +.ox-form .ox-tabs-panel, +.ox-form .ox-accordion-content { @apply space-y-6; } diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index 59a0b4631f..e4d56ba87b 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -154,7 +154,7 @@ export const CommonFields = ({ error, control }: CommonFieldsProps) => { @@ -351,7 +351,7 @@ export const CommonFields = ({ error, control }: CommonFieldsProps) => { diff --git a/app/forms/idp/create.tsx b/app/forms/idp/create.tsx index 7bfecbe07a..f56135a862 100644 --- a/app/forms/idp/create.tsx +++ b/app/forms/idp/create.tsx @@ -111,7 +111,7 @@ export function CreateIdpSideModalForm() { @@ -120,7 +120,7 @@ export function CreateIdpSideModalForm() { @@ -134,7 +134,7 @@ export function CreateIdpSideModalForm() { {/* TODO: Email field, probably */} @@ -151,14 +151,14 @@ export function CreateIdpSideModalForm() { diff --git a/app/forms/idp/edit.tsx b/app/forms/idp/edit.tsx index 91c1c1202a..4ef6de5fd2 100644 --- a/app/forms/idp/edit.tsx +++ b/app/forms/idp/edit.tsx @@ -67,7 +67,7 @@ export function EditIdpSideModalForm() { */} diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index f5a52d0983..35331adfb1 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -5,6 +5,7 @@ * * Copyright Oxide Computer Company */ +import * as Accordion from '@radix-ui/react-accordion' import { useEffect, useState } from 'react' import { useWatch } from 'react-hook-form' import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom' @@ -21,6 +22,7 @@ import { type InstanceCreate, } from '@oxide/api' import { + DirectionRightIcon, EmptyMessage, FieldLabel, FormDivider, @@ -41,6 +43,7 @@ import { DescriptionField, DiskSizeField, DisksTableField, + FileField, Form, FullPageForm, ImageSelectField, @@ -51,6 +54,8 @@ import { type DiskTableItem, } from 'app/components/form' import { getProjectSelector, useForm, useProjectSelector, useToast } from 'app/hooks' +import { readBlobAsBase64 } from 'app/util/file' +import { links } from 'app/util/links' import { pb } from 'app/util/path-builder' export type InstanceCreateInput = Assign< @@ -62,6 +67,7 @@ export type InstanceCreateInput = Assign< bootDiskName: string bootDiskSize: number image: string + userData: File | null } > @@ -85,6 +91,8 @@ const baseDefaultValues: InstanceCreateInput = { networkInterfaces: { type: 'default' }, start: true, + + userData: null, } CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => { @@ -153,7 +161,7 @@ export function CreateInstanceForm() { form={form} title="Create instance" icon={} - onSubmit={(values) => { + onSubmit={async (values) => { setIsSubmitting(true) // we should never have a presetId that's not in the list const preset = PRESETS.find((option) => option.id === values.presetId)! @@ -169,6 +177,10 @@ export function CreateInstanceForm() { const bootDiskName = values.bootDiskName || genName(values.name, image.name) + const userData = values.userData + ? await readBlobAsBase64(values.userData) + : undefined + createInstance.mutate({ query: projectSelector, body: { @@ -197,6 +209,7 @@ export function CreateInstanceForm() { externalIps: [{ type: 'ephemeral' }], start: values.start, networkInterfaces: values.networkInterfaces, + userData, }, }) }} @@ -390,7 +403,7 @@ export function CreateInstanceForm() { - Networking + Advanced - + + + Networking + + - + + + + + Configuration + + + Data or scripts to be passed to cloud-init as{' '} + + user data + {' '} + + (examples) + {' '} + if the selected boot image supports it. Maximum size 32 KiB. + + } + name="userData" + label="User Data" + control={control} + /> + + + Create instance @@ -425,6 +468,21 @@ export function CreateInstanceForm() { ) } +const AccordionHeader = ({ id, children }: { id: string; children: React.ReactNode }) => ( + + +
{children}
+ +
+
+) + +const AccordionContent = ({ children }: { children: React.ReactNode }) => ( + +
{children}
+
+) + const SshKeysTable = () => { const keys = usePrefetchedApiQuery('currentUserSshKeyList', {}).data?.items || [] diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 3fafed1b56..f7e4b36228 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -144,7 +144,7 @@ export function CreateSiloSideModalForm() {
diff --git a/app/test/e2e/instance-create.e2e.ts b/app/test/e2e/instance-create.e2e.ts index e0b96c4712..54ac350d44 100644 --- a/app/test/e2e/instance-create.e2e.ts +++ b/app/test/e2e/instance-create.e2e.ts @@ -7,7 +7,7 @@ */ import { images } from '@oxide/api-mocks' -import { expect, expectRowVisible, expectVisible, test } from './utils' +import { expect, expectNotVisible, expectRowVisible, expectVisible, test } from './utils' test('can create an instance', async ({ page }) => { await page.goto('/projects/mock-project/instances') @@ -23,8 +23,6 @@ test('can create an instance', async ({ page }) => { 'role=textbox[name="Description"]', 'role=textbox[name="Disk name"]', 'role=textbox[name="Disk size (GiB)"]', - 'role=radiogroup[name="Network interface"]', - 'role=textbox[name="Hostname"]', 'role=button[name="Create instance"]', ]) @@ -42,6 +40,24 @@ test('can create an instance', async ({ page }) => { await page.getByRole('button', { name: 'Image' }).click() await page.getByRole('option', { name: images[2].name }).click() + // should be hidden in accordion + await expectNotVisible(page, [ + 'role=radiogroup[name="Network interface"]', + 'role=textbox[name="Hostname"]', + 'text="User Data"', + ]) + + // open networking and config accordions + await page.getByRole('button', { name: 'Networking' }).click() + await page.getByRole('button', { name: 'Configuration' }).click() + + // should be visible in accordion + await expectVisible(page, [ + 'role=radiogroup[name="Network interface"]', + 'role=textbox[name="Hostname"]', + 'text="User Data"', + ]) + await page.getByRole('button', { name: 'Create instance' }).click() await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`) diff --git a/app/util/links.spec.ts b/app/util/links.spec.ts new file mode 100644 index 0000000000..a59a4d2149 --- /dev/null +++ b/app/util/links.spec.ts @@ -0,0 +1,20 @@ +/* + * 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 { describe, expect, test } from 'vitest' + +import { links } from './links' + +describe('check links are accessible', () => { + for (const [key, url] of Object.entries(links)) { + test(key, async () => { + // if we run into a page where HEAD doesn't work, we can fall back to GET + const response = await fetch(url, { method: 'HEAD' }) + expect(response.status).toEqual(200) + }) + } +}) diff --git a/app/util/links.ts b/app/util/links.ts new file mode 100644 index 0000000000..7a2137633f --- /dev/null +++ b/app/util/links.ts @@ -0,0 +1,11 @@ +/* + * 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 + */ +export const links: Record = { + cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html', + cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html', +} diff --git a/libs/table/cells/InstanceStatusCell.tsx b/libs/table/cells/InstanceStatusCell.tsx index c495917930..f7eef1321f 100644 --- a/libs/table/cells/InstanceStatusCell.tsx +++ b/libs/table/cells/InstanceStatusCell.tsx @@ -18,7 +18,7 @@ export const InstanceStatusCell = ({ return (
- +
) } diff --git a/libs/ui/lib/listbox/Listbox.tsx b/libs/ui/lib/listbox/Listbox.tsx index 9346c0ca1c..78c31dc2bd 100644 --- a/libs/ui/lib/listbox/Listbox.tsx +++ b/libs/ui/lib/listbox/Listbox.tsx @@ -46,8 +46,8 @@ export interface ListboxProps { hasError?: boolean name?: string label?: string - description?: string - helpText?: string + tooltipText?: string + description?: string | React.ReactNode required?: boolean isLoading?: boolean } @@ -61,8 +61,8 @@ export const Listbox = ({ onChange, hasError = false, label, + tooltipText, description, - helpText, required, disabled, isLoading = false, @@ -102,10 +102,10 @@ export const Listbox = ({ <> {label && (
- + {label} - {helpText && {helpText}} + {description && {description}}
)} ( -
+
_a]:underline hover:[&_>_a]:text-default', + className + )} + > {children}
) diff --git a/libs/ui/styles/index.css b/libs/ui/styles/index.css index e8289ca0ed..63de89f55e 100644 --- a/libs/ui/styles/index.css +++ b/libs/ui/styles/index.css @@ -67,6 +67,44 @@ } } +@layer components { + @media screen and (min-width: 720px) { + .AccordionContent[data-state='open'] { + animation: accordionSlideDown 300ms cubic-bezier(0.87, 0, 0.13, 1); + } + .AccordionContent[data-state='closed'] { + animation: accordionSlideUp 300ms cubic-bezier(0.87, 0, 0.13, 1); + } + } + + @media screen and (prefers-reduced-motion) { + .AccordionContent[data-state='open'] { + animation-name: none; + } + .AccordionContent[data-state='closed'] { + animation-name: none; + } + } + + @keyframes accordionSlideDown { + from { + height: 0; + } + to { + height: var(--radix-accordion-content-height); + } + } + + @keyframes accordionSlideUp { + from { + height: var(--radix-accordion-content-height); + } + to { + height: 0; + } + } +} + /** * Remove focus ring for non-explicit scenarios. */ diff --git a/package-lock.json b/package-lock.json index 92bea71340..cbb59694a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@headlessui/react": "^1.7.17", "@oxide/design-system": "^1.2.10", "@oxide/identicon": "0.0.4", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-tabs": "^1.0.3", @@ -2860,6 +2861,37 @@ "@babel/runtime": "^7.13.10" } }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", @@ -2883,6 +2915,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", @@ -23282,6 +23344,23 @@ "@babel/runtime": "^7.13.10" } }, + "@radix-ui/react-accordion": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.1.2.tgz", + "integrity": "sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collapsible": "1.0.3", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, "@radix-ui/react-arrow": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz", @@ -23291,6 +23370,22 @@ "@radix-ui/react-primitive": "1.0.3" } }, + "@radix-ui/react-collapsible": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.0.3.tgz", + "integrity": "sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-controllable-state": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, "@radix-ui/react-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", diff --git a/package.json b/package.json index a3a98d812f..4b18819bcc 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@headlessui/react": "^1.7.17", "@oxide/design-system": "^1.2.10", "@oxide/identicon": "0.0.4", + "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-tabs": "^1.0.3",