diff --git a/app/components/form/Form.tsx b/app/components/form/Form.tsx index a9cfb94d7f..b677823f72 100644 --- a/app/components/form/Form.tsx +++ b/app/components/form/Form.tsx @@ -12,7 +12,9 @@ import './form.css' interface FormActionsProps { formId?: string children: React.ReactNode - submitDisabled?: boolean + /** Must be provided with a reason why the submit button is disabled */ + submitDisabled?: string + loading?: boolean error?: Error | null className?: string } @@ -27,9 +29,10 @@ export const Form = { Actions: ({ children, formId, - submitDisabled = true, + submitDisabled, error, className, + loading, }: FormActionsProps) => { const childArray = flattenChildren(children) @@ -49,7 +52,12 @@ export const Form = { className )} > - {cloneElement(submit, { form: formId, disabled: submitDisabled })} + {cloneElement(submit, { + form: formId, + disabled: !!submitDisabled, + disabledReason: submitDisabled, + loading, + })} {childArray} {error && (
diff --git a/app/components/form/FullPageForm.tsx b/app/components/form/FullPageForm.tsx index b5562ae51b..5cd30169a2 100644 --- a/app/components/form/FullPageForm.tsx +++ b/app/components/form/FullPageForm.tsx @@ -14,9 +14,11 @@ interface FullPageFormProps { id: string title: string icon: ReactElement - submitDisabled?: boolean + /** Must provide a reason for submit being disabled */ + submitDisabled?: string error?: Error formOptions: UseFormProps + loading?: boolean onSubmit: (values: TFieldValues) => void /** Error from the API call */ submitError: ErrorResult | null @@ -37,14 +39,15 @@ export function FullPageForm({ id, title, children, - submitDisabled = false, + submitDisabled, error, icon, + loading, formOptions, onSubmit, }: FullPageFormProps) { const form = useForm(formOptions) - const { isSubmitting, isDirty } = form.formState + const { isSubmitting, isSubmitSuccessful } = form.formState const childArray = flattenChildren(children(form)) const actions = pluckFirstOfType(childArray, Form.Actions) @@ -62,7 +65,8 @@ export function FullPageForm({ {cloneElement(actions, { formId: id, - submitDisabled: submitDisabled || isSubmitting || !isDirty, + submitDisabled, + loading: loading || isSubmitting || isSubmitSuccessful, error, })} diff --git a/app/components/form/SideModalForm.tsx b/app/components/form/SideModalForm.tsx index 96abc8724e..682ff8159f 100644 --- a/app/components/form/SideModalForm.tsx +++ b/app/components/form/SideModalForm.tsx @@ -20,9 +20,11 @@ type SideModalFormProps = { */ children: (form: UseFormReturn) => ReactNode onDismiss: () => void - submitDisabled?: boolean + /** Must be provided with a reason describing why it's disabled */ + submitDisabled?: string /** Error from the API call */ submitError: ErrorResult | null + loading?: boolean title: string onSubmit: (values: TFieldValues) => void submitLabel?: string @@ -33,19 +35,18 @@ export function SideModalForm({ formOptions, children, onDismiss, - submitDisabled = false, + submitDisabled, submitError, title, onSubmit, submitLabel, + loading, }: SideModalFormProps) { // TODO: RHF docs warn about the performance impact of validating on every // change const form = useForm({ mode: 'all', ...formOptions }) - const { isDirty, isValid } = form.formState - - const canSubmit = isDirty && isValid + const { isSubmitting, isSubmitSuccessful } = form.formState /** * Only animate the modal in when we're navigating by a client-side click. @@ -87,7 +88,14 @@ export function SideModalForm({ -
diff --git a/app/forms/disk-attach.tsx b/app/forms/disk-attach.tsx index 16f9951c26..0dcaf8f83a 100644 --- a/app/forms/disk-attach.tsx +++ b/app/forms/disk-attach.tsx @@ -58,7 +58,7 @@ export function AttachDiskSideModalForm({ }) }) } - submitDisabled={attachDisk.isLoading} + loading={attachDisk.isLoading} submitError={attachDisk.error} onDismiss={onDismiss} > diff --git a/app/forms/disk-create.tsx b/app/forms/disk-create.tsx index aca0ca8080..3cacc70790 100644 --- a/app/forms/disk-create.tsx +++ b/app/forms/disk-create.tsx @@ -73,7 +73,7 @@ export function CreateDiskSideModalForm({ const body = { size: size * GiB, ...rest } onSubmit ? onSubmit(body) : createDisk.mutate({ path: pathParams, body }) }} - submitDisabled={createDisk.isLoading} + loading={createDisk.isLoading} submitError={createDisk.error} > {({ control }) => ( diff --git a/app/forms/firewall-rules-create.tsx b/app/forms/firewall-rules-create.tsx index d591479cab..24ed574023 100644 --- a/app/forms/firewall-rules-create.tsx +++ b/app/forms/firewall-rules-create.tsx @@ -451,7 +451,7 @@ export function CreateFirewallRuleForm({ }, }) }} - submitDisabled={updateRules.isLoading} + loading={updateRules.isLoading} submitError={updateRules.error} submitLabel="Add rule" > diff --git a/app/forms/firewall-rules-edit.tsx b/app/forms/firewall-rules-edit.tsx index d548a61676..cd1bd58a13 100644 --- a/app/forms/firewall-rules-edit.tsx +++ b/app/forms/firewall-rules-edit.tsx @@ -67,7 +67,7 @@ export function EditFirewallRuleForm({ }} // validationSchema={validationSchema} // validateOnBlur - submitDisabled={updateRules.isLoading} + loading={updateRules.isLoading} submitError={updateRules.error} submitLabel="Update rule" > diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 7ea48200e7..814ef52517 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -154,7 +154,7 @@ export function CreateInstanceForm() { }, }) }} - submitDisabled={createInstance.isLoading} + loading={createInstance.isLoading} submitError={createInstance.error} > {({ control }) => ( diff --git a/app/forms/network-interface-create.tsx b/app/forms/network-interface-create.tsx index ed0d632926..01457a4997 100644 --- a/app/forms/network-interface-create.tsx +++ b/app/forms/network-interface-create.tsx @@ -69,7 +69,7 @@ export default function CreateNetworkInterfaceForm({ }) }) } - submitDisabled={createNetworkInterface.isLoading} + loading={createNetworkInterface.isLoading} submitError={createNetworkInterface.error} > {({ control }) => ( diff --git a/app/forms/network-interface-edit.tsx b/app/forms/network-interface-edit.tsx index cb396db7fc..452b580351 100644 --- a/app/forms/network-interface-edit.tsx +++ b/app/forms/network-interface-edit.tsx @@ -44,7 +44,7 @@ export default function EditNetworkInterfaceForm({ body, }) }} - submitDisabled={editNetworkInterface.isLoading} + loading={editNetworkInterface.isLoading} submitError={editNetworkInterface.error} submitLabel="Save changes" > diff --git a/app/forms/org-access.tsx b/app/forms/org-access.tsx index c030be9317..b36e345e9c 100644 --- a/app/forms/org-access.tsx +++ b/app/forms/org-access.tsx @@ -41,7 +41,7 @@ export function OrgAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPro body: setUserRole(userId, roleName, policy), }) }} - submitDisabled={updatePolicy.isLoading} + loading={updatePolicy.isLoading} submitError={updatePolicy.error} submitLabel="Add user" > @@ -95,7 +95,7 @@ export function OrgAccessEditUserSideModal({ body: setUserRole(userId, roleName, policy), }) }} - submitDisabled={updatePolicy.isLoading || !policy} + loading={updatePolicy.isLoading} submitError={updatePolicy.error} onDismiss={onDismiss} submitLabel="Update role" diff --git a/app/forms/org-create.tsx b/app/forms/org-create.tsx index 88d04a40fb..550b776d1d 100644 --- a/app/forms/org-create.tsx +++ b/app/forms/org-create.tsx @@ -40,7 +40,7 @@ export function CreateOrgSideModalForm() { title="Create organization" onDismiss={() => navigate(pb.orgs())} onSubmit={(values) => createOrg.mutate({ body: values })} - submitDisabled={createOrg.isLoading} + loading={createOrg.isLoading} submitError={createOrg.error} > {({ control }) => ( diff --git a/app/forms/org-edit.tsx b/app/forms/org-edit.tsx index d440ad2409..fbc3635142 100644 --- a/app/forms/org-edit.tsx +++ b/app/forms/org-edit.tsx @@ -52,7 +52,7 @@ export function EditOrgSideModalForm() { body: { name, description }, }) } - submitDisabled={updateOrg.isLoading} + loading={updateOrg.isLoading} submitError={updateOrg.error} submitLabel="Save changes" > diff --git a/app/forms/project-access.tsx b/app/forms/project-access.tsx index 310c11ef98..705eacd97a 100644 --- a/app/forms/project-access.tsx +++ b/app/forms/project-access.tsx @@ -41,7 +41,7 @@ export function ProjectAccessAddUserSideModal({ onDismiss, policy }: AddRoleModa body: setUserRole(userId, roleName, policy), }) }} - submitDisabled={updatePolicy.isLoading} + loading={updatePolicy.isLoading} submitError={updatePolicy.error} submitLabel="Add user" onDismiss={onDismiss} @@ -96,7 +96,7 @@ export function ProjectAccessEditUserSideModal({ body: setUserRole(userId, roleName, policy), }) }} - submitDisabled={updatePolicy.isLoading || !policy} + loading={updatePolicy.isLoading} submitError={updatePolicy.error} submitLabel="Update role" onDismiss={onDismiss} diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index b0478a7632..610365e864 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -51,7 +51,7 @@ export function CreateProjectSideModalForm() { body: { name, description }, }) }} - submitDisabled={createProject.isLoading} + loading={createProject.isLoading} submitError={createProject.error} > {({ control }) => ( diff --git a/app/forms/project-edit.tsx b/app/forms/project-edit.tsx index 5d84bf828e..dc1d01d9cb 100644 --- a/app/forms/project-edit.tsx +++ b/app/forms/project-edit.tsx @@ -57,7 +57,7 @@ export function EditProjectSideModalForm() { body: { name, description }, }) }} - submitDisabled={editProject.isLoading} + loading={editProject.isLoading} submitError={editProject.error} submitLabel="Save changes" > diff --git a/app/forms/silo-access.tsx b/app/forms/silo-access.tsx index 3d45106502..973260167b 100644 --- a/app/forms/silo-access.tsx +++ b/app/forms/silo-access.tsx @@ -37,7 +37,7 @@ export function SiloAccessAddUserSideModal({ onDismiss, policy }: AddRoleModalPr body: setUserRole(userId, roleName, policy), }) }} - submitDisabled={updatePolicy.isLoading} + loading={updatePolicy.isLoading} submitError={updatePolicy.error} submitLabel="Add user" > @@ -88,7 +88,7 @@ export function SiloAccessEditUserSideModal({ body: setUserRole(userId, roleName, policy), }) }} - submitDisabled={updatePolicy.isLoading || !policy} + loading={updatePolicy.isLoading} submitError={updatePolicy.error} submitLabel="Update role" onDismiss={onDismiss} diff --git a/app/forms/silo-create.tsx b/app/forms/silo-create.tsx index 746c1603bf..e1058f5ed1 100644 --- a/app/forms/silo-create.tsx +++ b/app/forms/silo-create.tsx @@ -52,7 +52,7 @@ export function CreateSiloSideModalForm() { body: { name, description, discoverable, identityMode }, }) } - submitDisabled={createSilo.isLoading} + loading={createSilo.isLoading} submitError={createSilo.error} > {({ control }) => ( diff --git a/app/forms/ssh-key-create.tsx b/app/forms/ssh-key-create.tsx index 7136499f4e..42136d0d1b 100644 --- a/app/forms/ssh-key-create.tsx +++ b/app/forms/ssh-key-create.tsx @@ -32,7 +32,7 @@ export function CreateSSHKeySideModalForm() { formOptions={{ defaultValues }} onDismiss={onDismiss} onSubmit={(body) => createSshKey.mutate({ body })} - submitDisabled={createSshKey.isLoading} + loading={createSshKey.isLoading} submitError={createSshKey.error} > {({ control }) => ( diff --git a/app/forms/subnet-create.tsx b/app/forms/subnet-create.tsx index 4b30749e9f..7a9163d492 100644 --- a/app/forms/subnet-create.tsx +++ b/app/forms/subnet-create.tsx @@ -32,7 +32,7 @@ export function CreateSubnetForm({ onDismiss }: CreateSubnetFormProps) { formOptions={{ defaultValues }} onDismiss={onDismiss} onSubmit={(body) => createSubnet.mutate({ path: parentNames, body })} - submitDisabled={createSubnet.isLoading} + loading={createSubnet.isLoading} submitError={createSubnet.error} > {({ control }) => ( diff --git a/app/forms/subnet-edit.tsx b/app/forms/subnet-edit.tsx index dea0a0b18d..2196c4f69c 100644 --- a/app/forms/subnet-edit.tsx +++ b/app/forms/subnet-edit.tsx @@ -35,7 +35,7 @@ export function EditSubnetForm({ onDismiss, editing }: EditSubnetFormProps) { body, }) }} - submitDisabled={updateSubnet.isLoading} + loading={updateSubnet.isLoading} submitError={updateSubnet.error} submitLabel="Update subnet" > diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index d1fcdbc99f..5b4bdbb548 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -42,7 +42,7 @@ export function CreateVpcSideModalForm() { formOptions={{ defaultValues }} onSubmit={(values) => createVpc.mutate({ path: parentNames, body: values })} onDismiss={() => navigate(pb.vpcs(parentNames))} - submitDisabled={createVpc.isLoading} + loading={createVpc.isLoading} submitError={createVpc.error} > {({ control }) => ( diff --git a/app/forms/vpc-edit.tsx b/app/forms/vpc-edit.tsx index 74b109522d..d0fcaf515e 100644 --- a/app/forms/vpc-edit.tsx +++ b/app/forms/vpc-edit.tsx @@ -55,7 +55,7 @@ export function EditVpcSideModalForm() { body: { name, description, dnsName }, }) }} - submitDisabled={editVpc.isLoading} + loading={editVpc.isLoading} submitLabel="Save changes" submitError={editVpc.error} > diff --git a/app/forms/vpc-router-create.tsx b/app/forms/vpc-router-create.tsx index 77ef60b962..a922ef7ff1 100644 --- a/app/forms/vpc-router-create.tsx +++ b/app/forms/vpc-router-create.tsx @@ -46,7 +46,7 @@ export function CreateVpcRouterForm({ onDismiss }: CreateVpcRouterFormProps) { onSubmit={({ name, description }) => createRouter.mutate({ path: parentNames, body: { name, description } }) } - submitDisabled={createRouter.isLoading} + loading={createRouter.isLoading} submitError={createRouter.error} > {({ control }) => ( diff --git a/app/forms/vpc-router-edit.tsx b/app/forms/vpc-router-edit.tsx index 52cfc639d5..af51b2a486 100644 --- a/app/forms/vpc-router-edit.tsx +++ b/app/forms/vpc-router-edit.tsx @@ -35,7 +35,7 @@ export function EditVpcRouterForm({ onDismiss, editing }: EditVpcRouterFormProps body: { name, description }, }) }} - submitDisabled={updateRouter.isLoading} + loading={updateRouter.isLoading} submitError={updateRouter.error} > {({ control }) => ( diff --git a/app/pages/__tests__/click-everything.e2e.ts b/app/pages/__tests__/click-everything.e2e.ts index 2710dbe5d2..23e6e1be67 100644 --- a/app/pages/__tests__/click-everything.e2e.ts +++ b/app/pages/__tests__/click-everything.e2e.ts @@ -60,7 +60,7 @@ test("Click through everything and make it's all there", async ({ page }) => { 'role=textbox[name="Description"]', 'role=radiogroup[name="Block size (Bytes)"]', 'role=spinbutton[name="Size (GiB)"]', - 'role=button[name="Create Disk"][disabled]', + 'role=button[name="Create Disk"]', ]) await page.goBack() @@ -104,7 +104,7 @@ test("Click through everything and make it's all there", async ({ page }) => { 'role=textbox[name="Description"]', 'role=textbox[name="DNS name"]', 'role=textbox[name="IPV6 prefix"]', - 'role=button[name="Create VPC"][disabled]', + 'role=button[name="Create VPC"]', ]) await page.goBack() @@ -119,7 +119,7 @@ test("Click through everything and make it's all there", async ({ page }) => { 'role=textbox[name="Name"]', 'role=textbox[name="Description"]', 'role=textbox[name="DNS name"]', - 'role=button[name="Save changes"][disabled]', + 'role=button[name="Save changes"]', ]) await page.fill('role=textbox[name="Name"]', 'new-vpc') await page.click('role=button[name="Save changes"]') @@ -146,7 +146,7 @@ test("Click through everything and make it's all there", async ({ page }) => { await page.click('role=button[name="New subnet"]') await expectVisible(page, [ 'role=heading[name="Create subnet"]', - 'role=button[name="Create subnet"][disabled]', + 'role=button[name="Create subnet"]', ]) await page.fill('role=textbox[name="Name"]', 'new-subnet') await page.fill('role=textbox[name="IPv4 block"]', '10.1.1.1/24') @@ -162,7 +162,7 @@ test("Click through everything and make it's all there", async ({ page }) => { await expectVisible(page, [ 'role=heading[name="Edit subnet"]', - 'role=button[name="Update subnet"][disabled]', + 'role=button[name="Update subnet"]', ]) await page.fill('role=textbox[name="Name"]', 'edited-subnet') await page.fill('role=textbox[name="Description"]', 'behold') @@ -181,7 +181,7 @@ test("Click through everything and make it's all there", async ({ page }) => { await page.click('role=button[name="New router"]') await expectVisible(page, [ 'role=heading[name="Create VPC Router"]', - 'role=button[name="Create VPC Router"][disabled]', + 'role=button[name="Create VPC Router"]', ]) await page.fill('role=textbox[name="Name"]', 'new-router') await page.click('role=button[name="Create VPC Router"]') diff --git a/app/pages/__tests__/instance/attach-disk.e2e.ts b/app/pages/__tests__/instance/attach-disk.e2e.ts index 9480d56f39..d1fb00d3c1 100644 --- a/app/pages/__tests__/instance/attach-disk.e2e.ts +++ b/app/pages/__tests__/instance/attach-disk.e2e.ts @@ -17,7 +17,7 @@ test('Attach disk', async ({ page }) => { 'role=textbox[name="Description"]', 'role=radiogroup[name="Block size (Bytes)"]', 'role=spinbutton[name="Size (GiB)"]', - 'role=button[name="Create Disk"][disabled]', + 'role=button[name="Create Disk"]', ]) await page.click('role=button[name="Cancel"]') diff --git a/app/pages/__tests__/orgs.e2e.ts b/app/pages/__tests__/orgs.e2e.ts index 7a43a51fe6..a95c17b7ca 100644 --- a/app/pages/__tests__/orgs.e2e.ts +++ b/app/pages/__tests__/orgs.e2e.ts @@ -17,7 +17,7 @@ test('Orgs list and detail click work', async ({ page }) => { 'role=heading[name*="Create organization"]', 'role=textbox[name="Name"]', 'role=textbox[name="Description"]', - 'role=button[name="Create organization"][disabled]', + 'role=button[name="Create organization"]', ]) await page.goBack() diff --git a/app/pages/__tests__/project-create.e2e.tsx b/app/pages/__tests__/project-create.e2e.tsx index d1cbb0b523..c0d0dea1fe 100644 --- a/app/pages/__tests__/project-create.e2e.tsx +++ b/app/pages/__tests__/project-create.e2e.tsx @@ -12,7 +12,7 @@ test.describe('Project create', () => { 'role=heading[name*="Create project"]', // TODO: standardize capitalization 'role=textbox[name="Name"]', 'role=textbox[name="Description"]', - 'role=button[name="Create project"][disabled]', + 'role=button[name="Create project"]', ]) }) @@ -25,8 +25,6 @@ test.describe('Project create', () => { test('shows field-level validation error and does not POST', async ({ page }) => { await page.fill('role=textbox[name="Name"]', 'Invalid name') - await expect(page.locator('role=button[name="Create project"]')).toBeDisabled() - await page.click('role=textbox[name="Description"]') // just to blur name input await expectVisible(page, ['text="Must start with a lower-case letter"']) }) diff --git a/app/test/instance-create.e2e.ts b/app/test/instance-create.e2e.ts index 7c87d9ca6f..74dcbe71b5 100644 --- a/app/test/instance-create.e2e.ts +++ b/app/test/instance-create.e2e.ts @@ -28,7 +28,7 @@ test('can invoke instance create form from instances page', async ({ 'role=spinbutton[name="Disk size (GiB)"]', 'role=radiogroup[name="Network interface"]', 'role=textbox[name="Hostname"]', - 'role=button[name="Create instance"][disabled]', + 'role=button[name="Create instance"]', ]) const instanceName = genName('instance') diff --git a/libs/ui/lib/button/Button.tsx b/libs/ui/lib/button/Button.tsx index 78430f1683..a31762b07b 100644 --- a/libs/ui/lib/button/Button.tsx +++ b/libs/ui/lib/button/Button.tsx @@ -70,7 +70,6 @@ export const Button = forwardRef( innerClassName, disabled, onClick, - 'aria-disabled': ariaDisabled, disabledReason, // needs to be a spread because we pass this component to Reach // with the `as` prop and get passed arbitrary