-
+
{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",