diff --git a/app/dashboard/e2e/api.ts b/app/dashboard/e2e/api.ts
index 1ba1c79bdce0..23730aa1b4a8 100644
--- a/app/dashboard/e2e/api.ts
+++ b/app/dashboard/e2e/api.ts
@@ -504,24 +504,21 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
(a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type],
)
const json: remoteBackend.ListDirectoryResponseBody = { assets: filteredAssets }
+
return json
})
- await get(
- remoteBackendPaths.LIST_FILES_PATH + '*',
- () => ({ files: [] }) satisfies remoteBackend.ListFilesResponseBody,
- )
- await get(
- remoteBackendPaths.LIST_PROJECTS_PATH + '*',
- () => ({ projects: [] }) satisfies remoteBackend.ListProjectsResponseBody,
- )
- await get(
- remoteBackendPaths.LIST_SECRETS_PATH + '*',
- () => ({ secrets: [] }) satisfies remoteBackend.ListSecretsResponseBody,
- )
- await get(
- remoteBackendPaths.LIST_TAGS_PATH + '*',
- () => ({ tags: labels }) satisfies remoteBackend.ListTagsResponseBody,
- )
+ await get(remoteBackendPaths.LIST_FILES_PATH + '*', () => {
+ return { files: [] } satisfies remoteBackend.ListFilesResponseBody
+ })
+ await get(remoteBackendPaths.LIST_PROJECTS_PATH + '*', () => {
+ return { projects: [] } satisfies remoteBackend.ListProjectsResponseBody
+ })
+ await get(remoteBackendPaths.LIST_SECRETS_PATH + '*', () => {
+ return { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody
+ })
+ await get(remoteBackendPaths.LIST_TAGS_PATH + '*', () => {
+ return { tags: labels } satisfies remoteBackend.ListTagsResponseBody
+ })
await get(remoteBackendPaths.LIST_USERS_PATH + '*', async (route) => {
if (currentUser != null) {
return { users } satisfies remoteBackend.ListUsersResponseBody
@@ -584,6 +581,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
interface Body {
readonly parentDirectoryId: backend.DirectoryId
}
+
const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
// eslint-disable-next-line no-restricted-syntax
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
@@ -605,7 +603,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const body: Body = await request.postDataJSON()
const parentId = body.parentDirectoryId
// Can be any asset ID.
- const id = backend.DirectoryId(uniqueString.uniqueString())
+ const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
const json: backend.CopyAssetResponse = {
asset: {
id,
@@ -621,6 +619,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await route.fulfill({ json })
}
})
+
await get(remoteBackendPaths.INVITATION_PATH + '*', async (route) => {
await route.fulfill({
json: { invitations: [] } satisfies backend.ListInvitationsResponseBody,
@@ -695,7 +694,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const searchParams: SearchParams = Object.fromEntries(
new URL(request.url()).searchParams.entries(),
) as never
- const file = createFile(searchParams.file_name)
+
+ const file = addFile(searchParams.file_name)
+
return { path: '', id: file.id, project: null } satisfies backend.FileInfo
})
@@ -703,7 +704,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.CreateSecretRequestBody = await request.postDataJSON()
- const secret = createSecret(body.name)
+ const secret = addSecret(body.name)
return secret.id
})
@@ -721,6 +722,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
if (body.description != null) {
object.unsafeMutable(asset).description = body.description
}
+
+ if (body.parentDirectoryId != null) {
+ object.unsafeMutable(asset).parentId = body.parentDirectoryId
+ }
}
})
await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => {
@@ -813,7 +818,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
currentUser = { ...currentUser, name: body.username }
}
})
- await get(remoteBackendPaths.USERS_ME_PATH + '*', () => currentUser)
+ await get(remoteBackendPaths.USERS_ME_PATH + '*', () => {
+ return currentUser
+ })
await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
diff --git a/app/dashboard/e2e/copy.spec.ts b/app/dashboard/e2e/copy.spec.ts
index 373edefcc272..feb5f700dc96 100644
--- a/app/dashboard/e2e/copy.spec.ts
+++ b/app/dashboard/e2e/copy.spec.ts
@@ -23,7 +23,7 @@ test.test('copy', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
- await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
+ await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@@ -46,7 +46,7 @@ test.test('copy (keyboard)', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
- await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
+ await test.expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@@ -69,7 +69,7 @@ test.test('move', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
- await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
+ await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@@ -88,7 +88,7 @@ test.test('move (drag)', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
- await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
+ await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@@ -129,7 +129,7 @@ test.test('move (keyboard)', ({ page }) =>
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
- await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
+ await test.expect(rows.nth(1)).toHaveText(/^New Folder 1/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
@@ -164,11 +164,11 @@ test.test('duplicate', ({ page }) =>
.driveTable.rightClickRow(0)
.contextMenu.duplicate()
.driveTable.withRows(async (rows) => {
- // Assets: [0: New Project 1 (copy), 1: New Project 1]
+ // Assets: [0: New Project 1, 1: New Project 1 (copy)]
await test.expect(rows).toHaveCount(2)
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
- await test.expect(rows.nth(0)).toBeVisible()
- await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
+ await test.expect(rows.nth(1)).toBeVisible()
+ await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}),
)
@@ -184,7 +184,7 @@ test.test('duplicate (keyboard)', ({ page }) =>
.driveTable.withRows(async (rows) => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
- await test.expect(rows.nth(0)).toBeVisible()
- await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
+ await test.expect(rows.nth(1)).toBeVisible()
+ await test.expect(rows.nth(1)).toHaveText(/^New Project 1 [(]copy[)]/)
}),
)
diff --git a/app/dashboard/e2e/driveView.spec.ts b/app/dashboard/e2e/driveView.spec.ts
index 49e614f746b3..bdb80e765f8a 100644
--- a/app/dashboard/e2e/driveView.spec.ts
+++ b/app/dashboard/e2e/driveView.spec.ts
@@ -33,7 +33,7 @@ test.test('drive view', ({ page }) =>
// user that project creation may take a while. Previously opened projects are stopped when the
// new project is created.
.driveTable.withRows(async (rows) => {
- await actions.locateStopProjectButton(rows.nth(0)).click()
+ await actions.locateStopProjectButton(rows.nth(1)).click()
})
// Project context menu
.driveTable.rightClickRow(0)
diff --git a/app/dashboard/src/App.tsx b/app/dashboard/src/App.tsx
index f23bef9b5d06..2fbba4abad1c 100644
--- a/app/dashboard/src/App.tsx
+++ b/app/dashboard/src/App.tsx
@@ -49,7 +49,6 @@ import * as inputBindingsModule from '#/configurations/inputBindings'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider, { useLocalBackend } from '#/providers/BackendProvider'
import DriveProvider from '#/providers/DriveProvider'
-import DevtoolsProvider from '#/providers/EnsoDevtoolsProvider'
import { useHttpClient } from '#/providers/HttpClientProvider'
import InputBindingsProvider from '#/providers/InputBindingsProvider'
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
@@ -91,10 +90,11 @@ import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as eventModule from '#/utilities/event'
import LocalStorage from '#/utilities/LocalStorage'
import * as object from '#/utilities/object'
+import { Path } from '#/utilities/path'
import { useInitAuthService } from '#/authentication/service'
import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
-import { Path } from '#/utilities/path'
+import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider'
// ============================
// === Global configuration ===
@@ -492,7 +492,7 @@ function AppRouter(props: AppRouterProps) {
)
return (
-
+
-
+
+
+
)}
@@ -527,7 +529,7 @@ function AppRouter(props: AppRouterProps) {
-
+
)
}
diff --git a/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx b/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx
index 6f2d91e4e1dd..f69c25dfae82 100644
--- a/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx
+++ b/app/dashboard/src/components/AriaComponents/Dialog/DialogTrigger.tsx
@@ -5,24 +5,39 @@ import * as modalProvider from '#/providers/ModalProvider'
import * as aria from '#/components/aria'
-import type * as types from './types'
+import { AnimatePresence, motion } from 'framer-motion'
const PLACEHOLDER =
+/**
+ * Props passed to the render function of a {@link DialogTrigger}.
+ */
+export interface DialogTriggerRenderProps {
+ readonly isOpened: boolean
+}
/**
* Props for a {@link DialogTrigger}.
*/
-export interface DialogTriggerProps extends types.DialogTriggerProps {}
+export interface DialogTriggerProps extends Omit {
+ /**
+ * The trigger element.
+ */
+ readonly children: [
+ React.ReactElement,
+ React.ReactElement | ((props: DialogTriggerRenderProps) => React.ReactElement),
+ ]
+}
/** A DialogTrigger opens a dialog when a trigger element is pressed. */
export function DialogTrigger(props: DialogTriggerProps) {
const { children, onOpenChange, ...triggerProps } = props
+ const [isOpened, setIsOpened] = React.useState(false)
const { setModal, unsetModal } = modalProvider.useSetModal()
const onOpenChangeInternal = React.useCallback(
- (isOpened: boolean) => {
- if (isOpened) {
+ (opened: boolean) => {
+ if (opened) {
// We're using a placeholder here just to let the rest of the code know that the modal
// is open.
setModal(PLACEHOLDER)
@@ -30,14 +45,36 @@ export function DialogTrigger(props: DialogTriggerProps) {
unsetModal()
}
- onOpenChange?.(isOpened)
+ setIsOpened(opened)
+ onOpenChange?.(opened)
},
[setModal, unsetModal, onOpenChange],
)
+ const renderProps = {
+ isOpened,
+ } satisfies DialogTriggerRenderProps
+
+ const [trigger, dialog] = children
+
return (
- {children}
+ {trigger}
+
+ {/* We're using AnimatePresence here to animate the dialog in and out. */}
+
+ {isOpened && (
+
+ {typeof dialog === 'function' ? dialog(renderProps) : dialog}
+
+ )}
+
)
}
diff --git a/app/dashboard/src/components/AriaComponents/Dialog/types.ts b/app/dashboard/src/components/AriaComponents/Dialog/types.ts
index 7e7b7f2cbcc0..127d3c5443eb 100644
--- a/app/dashboard/src/components/AriaComponents/Dialog/types.ts
+++ b/app/dashboard/src/components/AriaComponents/Dialog/types.ts
@@ -10,6 +10,3 @@ export interface DialogProps extends aria.DialogProps {
readonly modalProps?: Pick
readonly testId?: string
}
-
-/** The props for the DialogTrigger component. */
-export interface DialogTriggerProps extends aria.DialogTriggerProps {}
diff --git a/app/dashboard/src/components/AriaComponents/Form/Form.tsx b/app/dashboard/src/components/AriaComponents/Form/Form.tsx
index a1621671f0cf..0c0e10048870 100644
--- a/app/dashboard/src/components/AriaComponents/Form/Form.tsx
+++ b/app/dashboard/src/components/AriaComponents/Form/Form.tsx
@@ -14,23 +14,11 @@ import * as aria from '#/components/aria'
import * as errorUtils from '#/utilities/error'
import { forwardRef } from '#/utilities/react'
-import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as dialog from '../Dialog'
import * as components from './components'
import * as styles from './styles'
import type * as types from './types'
-/**
- * Maps the value to the event object.
- */
-function mapValueOnEvent(value: unknown) {
- if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
- return value
- } else {
- return { target: { value } }
- }
-}
-
/** Form component. It wraps a `form` and provides form context.
* It also handles form submission.
* Provides better error handling and form state management and better UX out of the box. */
@@ -71,19 +59,12 @@ export const Form = forwardRef(function Form(
formOptions.defaultValues = defaultValues
}
- const innerForm = components.useForm(
- form ?? {
- shouldFocusError: true,
- schema,
- ...formOptions,
- },
- defaultValues,
- )
-
- const dialogContext = dialog.useDialogContext()
+ const innerForm = components.useForm(form ?? { shouldFocusError: true, schema, ...formOptions })
React.useImperativeHandle(formRef, () => innerForm, [innerForm])
+ const dialogContext = dialog.useDialogContext()
+
const formMutation = reactQuery.useMutation({
// We use template literals to make the mutation key more readable in the devtools
// This mutation exists only for debug purposes - React Query dev tools record the mutation,
@@ -140,51 +121,13 @@ export const Form = forwardRef(function Form(
{ isDisabled: canSubmitOffline },
)
- const {
- formState,
- clearErrors,
- getValues,
- setValue,
- setError,
- register,
- unregister,
- setFocus,
- reset,
- control,
- } = innerForm
-
- const formStateRenderProps: types.FormStateRenderProps = {
- formState,
- register: (name, options) => {
- const registered = register(name, options)
-
- const result: types.UseFormRegisterReturn = {
- ...registered,
- isDisabled: registered.disabled ?? false,
- isRequired: registered.required ?? false,
- isInvalid: Boolean(formState.errors[name]),
- onChange: (value) => registered.onChange(mapValueOnEvent(value)),
- onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
- }
-
- return result
- },
- unregister,
- setError,
- clearErrors,
- getValues,
- setValue,
- setFocus,
- reset,
- control,
- form: innerForm,
- }
-
const base = styles.FORM_STYLES({
- className: typeof className === 'function' ? className(formStateRenderProps) : className,
+ className: typeof className === 'function' ? className(innerForm) : className,
gap,
})
+ const { formState, setError } = innerForm
+
// eslint-disable-next-line no-restricted-syntax
const errors = Object.fromEntries(
Object.entries(formState.errors).map(([key, error]) => {
@@ -208,35 +151,34 @@ export const Form = forwardRef(function Form(
}
}}
className={base}
- style={typeof style === 'function' ? style(formStateRenderProps) : style}
+ style={typeof style === 'function' ? style(innerForm) : style}
noValidate
data-testid={testId}
{...formProps}
>
- {typeof children === 'function' ? children(formStateRenderProps) : children}
+ {typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
)
-}) as unknown as Mutable<
- Pick<
- typeof components,
- | 'FIELD_STYLES'
- | 'Field'
- | 'FormError'
- | 'Reset'
- | 'schema'
- | 'Submit'
- | 'useField'
- | 'useForm'
- | 'useFormSchema'
- >
-> &
- ((
- props: React.RefAttributes & types.FormProps,
- ) => React.JSX.Element)
+}) as unknown as ((
+ props: React.RefAttributes & types.FormProps,
+) => React.JSX.Element) & {
+ /* eslint-disable @typescript-eslint/naming-convention */
+ schema: typeof components.schema
+ useForm: typeof components.useForm
+ useField: typeof components.useField
+ Submit: typeof components.Submit
+ Reset: typeof components.Reset
+ Field: typeof components.Field
+ FormError: typeof components.FormError
+ useFormSchema: typeof components.useFormSchema
+ Controller: typeof components.Controller
+ FIELD_STYLES: typeof components.FIELD_STYLES
+ /* eslint-enable @typescript-eslint/naming-convention */
+}
Form.schema = components.schema
Form.useForm = components.useForm
@@ -246,4 +188,5 @@ Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.Field = components.Field
+Form.Controller = components.Controller
Form.FIELD_STYLES = components.FIELD_STYLES
diff --git a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx
index e841a76ad20e..37538602286d 100644
--- a/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx
+++ b/app/dashboard/src/components/AriaComponents/Form/components/Field.tsx
@@ -23,7 +23,7 @@ export interface FieldComponentProps
types.FieldProps {
readonly 'data-testid'?: string | undefined
readonly name: Path>
- readonly form?: types.FormInstance
+ readonly form?: types.FormInstance | undefined
readonly isInvalid?: boolean | undefined
readonly className?: string | undefined
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
diff --git a/app/dashboard/src/components/AriaComponents/Form/components/index.ts b/app/dashboard/src/components/AriaComponents/Form/components/index.ts
index ae7aa01ceda6..497ad213673e 100644
--- a/app/dashboard/src/components/AriaComponents/Form/components/index.ts
+++ b/app/dashboard/src/components/AriaComponents/Form/components/index.ts
@@ -3,6 +3,7 @@
*
* Barrel file for form components.
*/
+export { Controller } from 'react-hook-form'
export * from './Field'
export * from './FormError'
export * from './Reset'
diff --git a/app/dashboard/src/components/AriaComponents/Form/components/types.ts b/app/dashboard/src/components/AriaComponents/Form/components/types.ts
index 9d60651847b5..2162e80e08f9 100644
--- a/app/dashboard/src/components/AriaComponents/Form/components/types.ts
+++ b/app/dashboard/src/components/AriaComponents/Form/components/types.ts
@@ -42,12 +42,41 @@ export interface UseFormProps
readonly schema: Schema | ((schema: typeof schemaModule.schema) => Schema)
}
+/**
+ * Register function for a form field.
+ */
+export type UseFormRegister = <
+ TFieldName extends FieldPath = FieldPath,
+>(
+ name: TFieldName,
+ options?: reactHookForm.RegisterOptions, TFieldName>,
+ // eslint-disable-next-line no-restricted-syntax
+) => UseFormRegisterReturn
+
+/**
+ * UseFormRegister return type.
+ */
+export interface UseFormRegisterReturn<
+ Schema extends TSchema,
+ TFieldName extends FieldPath = FieldPath,
+> extends Omit, 'onBlur' | 'onChange'> {
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ readonly onChange: (value: Value) => Promise
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ readonly onBlur: (value: Value) => Promise
+ readonly isDisabled?: boolean
+ readonly isRequired?: boolean
+ readonly isInvalid?: boolean
+}
+
/**
* Return type of the useForm hook.
* @alias reactHookForm.UseFormReturn
*/
export interface UseFormReturn
- extends reactHookForm.UseFormReturn, unknown, TransformedValues> {}
+ extends reactHookForm.UseFormReturn, unknown, TransformedValues> {
+ readonly register: UseFormRegister
+}
/**
* Form state type.
diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts
index 165bcc8e3820..f3bfb69d17c1 100644
--- a/app/dashboard/src/components/AriaComponents/Form/components/useField.ts
+++ b/app/dashboard/src/components/AriaComponents/Form/components/useField.ts
@@ -39,7 +39,6 @@ export function useField<
const { field, fieldState, formState } = reactHookForm.useController({
name,
- control: formInstance.control,
disabled: isDisabled,
...(defaultValue != null ? { defaultValue } : {}),
})
diff --git a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts
index ef0147673465..9d20e04645c0 100644
--- a/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts
+++ b/app/dashboard/src/components/AriaComponents/Form/components/useForm.ts
@@ -12,12 +12,24 @@ import invariant from 'tiny-invariant'
import * as schemaModule from './schema'
import type * as types from './types'
+/**
+ * Maps the value to the event object.
+ */
+function mapValueOnEvent(value: unknown) {
+ if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
+ return value
+ } else {
+ return { target: { value } }
+ }
+}
+
/**
* A hook that returns a form instance.
* @param optionsOrFormInstance - Either form options or a form instance
*
- * If form instance is passed, it will be returned as is
- * If form options are passed, a form instance will be created and returned
+ * If form instance is passed, it will be returned as is.
+ *
+ * If form options are passed, a form instance will be created and returned.
*
* ***Note:*** This hook accepts either a form instance(If form is created outside)
* or form options(and creates a form instance).
@@ -28,9 +40,6 @@ import type * as types from './types'
*/
export function useForm(
optionsOrFormInstance: types.UseFormProps | types.UseFormReturn,
- defaultValues?:
- | reactHookForm.DefaultValues>
- | ((payload?: unknown) => Promise>),
): types.UseFormReturn {
const initialTypePassed = React.useRef(getArgsType(optionsOrFormInstance))
@@ -44,38 +53,49 @@ export function useForm(
`,
)
- const form =
- 'formState' in optionsOrFormInstance ? optionsOrFormInstance : (
- (() => {
- const { schema, ...options } = optionsOrFormInstance
-
- const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
-
- return reactHookForm.useForm<
- types.FieldValues,
- unknown,
- types.TransformedValues
- >({
- ...options,
- resolver: zodResolver.zodResolver(computedSchema, { async: true }),
- })
- })()
- )
-
- const initialDefaultValues = React.useRef(defaultValues)
-
- React.useEffect(() => {
- // Expose default values to controlled inputs like `Selector` and `MultiSelector`.
- // Using `defaultValues` is not sufficient as the value needs to be manually set at least once.
- const defaults = initialDefaultValues.current
- if (defaults) {
- if (typeof defaults !== 'function') {
- form.reset(defaults)
+ if ('formState' in optionsOrFormInstance) {
+ return optionsOrFormInstance
+ } else {
+ const { schema, ...options } = optionsOrFormInstance
+
+ const computedSchema = typeof schema === 'function' ? schema(schemaModule.schema) : schema
+
+ const formInstance = reactHookForm.useForm<
+ types.FieldValues,
+ unknown,
+ types.TransformedValues
+ >({
+ ...options,
+ resolver: zodResolver.zodResolver(computedSchema),
+ })
+
+ const register: types.UseFormRegister = (name, opts) => {
+ const registered = formInstance.register(name, opts)
+
+ const onChange: types.UseFormRegisterReturn['onChange'] = (value) =>
+ registered.onChange(mapValueOnEvent(value))
+
+ const onBlur: types.UseFormRegisterReturn['onBlur'] = (value) =>
+ registered.onBlur(mapValueOnEvent(value))
+
+ const result: types.UseFormRegisterReturn = {
+ ...registered,
+ ...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
+ ...(registered.required != null ? { isRequired: registered.required } : {}),
+ isInvalid: !!formInstance.formState.errors[name],
+ onChange,
+ onBlur,
}
+
+ return result
}
- }, [form])
- return form
+ return {
+ ...formInstance,
+ control: { ...formInstance.control, register },
+ register,
+ } satisfies types.UseFormReturn
+ }
}
/**
diff --git a/app/dashboard/src/components/AriaComponents/Form/types.ts b/app/dashboard/src/components/AriaComponents/Form/types.ts
index 0f27e4685a40..28fe075b093e 100644
--- a/app/dashboard/src/components/AriaComponents/Form/types.ts
+++ b/app/dashboard/src/components/AriaComponents/Form/types.ts
@@ -42,11 +42,17 @@ interface BaseFormProps
) => unknown
readonly style?:
| React.CSSProperties
- | ((props: FormStateRenderProps) => React.CSSProperties)
- readonly children: React.ReactNode | ((props: FormStateRenderProps) => React.ReactNode)
+ | ((props: components.UseFormReturn) => React.CSSProperties)
+ readonly children:
+ | React.ReactNode
+ | ((
+ props: components.UseFormReturn & {
+ readonly form: components.UseFormReturn
+ },
+ ) => React.ReactNode)
readonly formRef?: React.MutableRefObject>
- readonly className?: string | ((props: FormStateRenderProps) => string)
+ readonly className?: string | ((props: components.UseFormReturn) => string)
readonly onSubmitFailed?: (error: unknown) => Promise | void
readonly onSubmitSuccess?: () => Promise | void
diff --git a/app/dashboard/src/components/AriaComponents/Inputs/variants.ts b/app/dashboard/src/components/AriaComponents/Inputs/variants.ts
index 83b9cdf47bfd..08179c94cadf 100644
--- a/app/dashboard/src/components/AriaComponents/Inputs/variants.ts
+++ b/app/dashboard/src/components/AriaComponents/Inputs/variants.ts
@@ -39,7 +39,7 @@ export const INPUT_STYLES = tv({
variant: {
custom: {},
outline: {
- base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[-1px] focus-within:outline-primary',
+ base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[0.5px] focus-within:outline-primary',
textArea: 'border-transparent focus-within:border-transparent',
},
},
diff --git a/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx
new file mode 100644
index 000000000000..07827c44d07d
--- /dev/null
+++ b/app/dashboard/src/components/AriaComponents/Switch/Switch.tsx
@@ -0,0 +1,140 @@
+/**
+ * @file
+ *
+ * A switch allows a user to turn a setting on or off.
+ */
+import {
+ Switch as AriaSwitch,
+ mergeProps,
+ type SwitchProps as AriaSwitchProps,
+} from '#/components/aria'
+import { mergeRefs } from '#/utilities/mergeRefs'
+import { forwardRef } from '#/utilities/react'
+import type { CSSProperties, ForwardedRef } from 'react'
+import { useRef } from 'react'
+import { tv, type VariantProps } from 'tailwind-variants'
+import { Form, type FieldPath, type FieldProps, type FieldStateProps, type TSchema } from '../Form'
+import { TEXT_STYLE } from '../Text'
+
+/**
+ * Props for the {@Switch} component.
+ */
+export interface SwitchProps>
+ extends FieldStateProps<
+ Omit & { value: boolean },
+ Schema,
+ TFieldName
+ >,
+ FieldProps,
+ Omit, 'disabled' | 'invalid'> {
+ readonly className?: string
+ readonly style?: CSSProperties
+}
+
+export const SWITCH_STYLES = tv({
+ base: '',
+ variants: {
+ disabled: { true: 'cursor-not-allowed opacity-50' },
+ size: {
+ small: {
+ background: 'h-4 w-7 p-0.5',
+ },
+ },
+ },
+ slots: {
+ switch: 'group flex items-center gap-1',
+ label: TEXT_STYLE({
+ variant: 'body',
+ color: 'primary',
+ className: 'flex-1',
+ }),
+ background:
+ 'flex shrink-0 cursor-default items-center rounded-full bg-primary/30 bg-clip-padding shadow-inner outline-none ring-black transition duration-200 ease-in-out group-focus-visible:ring-2 group-pressed:bg-primary/60 group-selected:bg-primary group-selected:group-pressed:bg-primary/50',
+ thumb:
+ 'aspect-square h-full flex-none translate-x-0 transform rounded-full bg-white transition duration-200 ease-in-out group-selected:translate-x-[100%]',
+ },
+ defaultVariants: {
+ size: 'small',
+ disabled: false,
+ },
+})
+
+/**
+ * A switch allows a user to turn a setting on or off.
+ */
+// eslint-disable-next-line no-restricted-syntax
+export const Switch = forwardRef(function Switch<
+ Schema extends TSchema,
+ TFieldName extends FieldPath,
+>(props: SwitchProps, ref: ForwardedRef) {
+ const {
+ label,
+ isDisabled = false,
+ isRequired = false,
+ defaultValue,
+ className,
+ name,
+ form,
+ description,
+ error,
+ size,
+ ...ariaSwitchProps
+ } = props
+
+ const switchRef = useRef(null)
+
+ const { fieldState, formInstance, field } = Form.useField({
+ name,
+ isDisabled,
+ form,
+ defaultValue,
+ })
+
+ const { ref: fieldRef, ...fieldProps } = formInstance.register(name, {
+ disabled: isDisabled,
+ required: isRequired,
+ ...(props.onBlur && { onBlur: props.onBlur }),
+ ...(props.onChange && { onChange: props.onChange }),
+ })
+
+ const {
+ base,
+ thumb,
+ background,
+ label: labelStyle,
+ switch: switchStyles,
+ } = SWITCH_STYLES({ size, disabled: fieldProps.disabled })
+
+ return (
+
+ ()(ariaSwitchProps, fieldProps, {
+ defaultSelected: field.value,
+ className: switchStyles(),
+ })}
+ >
+
+
+
+
+ {label}
+
+
+ )
+})
diff --git a/app/dashboard/src/components/AriaComponents/Switch/index.ts b/app/dashboard/src/components/AriaComponents/Switch/index.ts
new file mode 100644
index 000000000000..9fcf650eab47
--- /dev/null
+++ b/app/dashboard/src/components/AriaComponents/Switch/index.ts
@@ -0,0 +1,6 @@
+/**
+ * @file
+ *
+ * Barrel file for Switch component.
+ */
+export * from './Switch'
diff --git a/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx b/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx
index c81be71df98b..6e18983a3ff4 100644
--- a/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx
+++ b/app/dashboard/src/components/AriaComponents/Tooltip/Tooltip.tsx
@@ -12,7 +12,7 @@ import * as text from '../Text'
// =================
export const TOOLTIP_STYLES = twv.tv({
- base: 'group flex justify-center items-center text-center text-balance break-words z-50',
+ base: 'group flex justify-center items-center text-center text-balance break-all z-50',
variants: {
variant: {
custom: '',
diff --git a/app/dashboard/src/components/AriaComponents/index.ts b/app/dashboard/src/components/AriaComponents/index.ts
index eae16324c1d6..b7b1be5f77e3 100644
--- a/app/dashboard/src/components/AriaComponents/index.ts
+++ b/app/dashboard/src/components/AriaComponents/index.ts
@@ -10,6 +10,7 @@ export * from './Form'
export * from './Inputs'
export * from './Radio'
export * from './Separator'
+export * from './Switch'
export * from './Text'
export * from './Tooltip'
export * from './VisuallyHidden'
diff --git a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx
index 0f89a8aa6237..3b1e74fdb1e2 100644
--- a/app/dashboard/src/components/Devtools/EnsoDevtools.tsx
+++ b/app/dashboard/src/components/Devtools/EnsoDevtools.tsx
@@ -17,17 +17,19 @@ import * as billing from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import { UserSessionType } from '#/providers/AuthProvider'
+import * as textProvider from '#/providers/TextProvider'
import {
useEnableVersionChecker,
+ usePaywallDevtools,
useSetEnableVersionChecker,
-} from '#/providers/EnsoDevtoolsProvider'
-import * as textProvider from '#/providers/TextProvider'
+} from './EnsoDevtoolsProvider'
+
+import * as ariaComponents from '#/components/AriaComponents'
+import Portal from '#/components/Portal'
-import { Switch } from '#/components/aria'
import {
Button,
ButtonGroup,
- DialogTrigger,
Form,
Popover,
Radio,
@@ -35,246 +37,249 @@ import {
Separator,
Text,
} from '#/components/AriaComponents'
-import Portal from '#/components/Portal'
-
+import {
+ FEATURE_FLAGS_SCHEMA,
+ useFeatureFlags,
+ useSetFeatureFlags,
+} from '#/providers/FeatureFlagsProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import * as backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import { unsafeEntries } from 'enso-common/src/utilities/data/object'
-/**
- * Configuration for a paywall feature.
- */
-export interface PaywallDevtoolsFeatureConfiguration {
- readonly isForceEnabled: boolean | null
-}
-
-const PaywallDevtoolsContext = React.createContext<{
- features: Record
-}>({
- features: {
- share: { isForceEnabled: null },
- shareFull: { isForceEnabled: null },
- userGroups: { isForceEnabled: null },
- userGroupsFull: { isForceEnabled: null },
- inviteUser: { isForceEnabled: null },
- inviteUserFull: { isForceEnabled: null },
- },
-})
-
-/**
- * Props for the {@link EnsoDevtools} component.
- */
-interface EnsoDevtoolsProps extends React.PropsWithChildren {}
-
/**
* A component that provides a UI for toggling paywall features.
*/
-export function EnsoDevtools(props: EnsoDevtoolsProps) {
- const { children } = props
-
+export function EnsoDevtools() {
const { getText } = textProvider.useText()
const { authQueryKey, session } = authProvider.useAuth()
+ const queryClient = reactQuery.useQueryClient()
+ const { getFeature } = billing.usePaywallFeatures()
+ const { features, setFeature } = usePaywallDevtools()
const enableVersionChecker = useEnableVersionChecker()
const setEnableVersionChecker = useSetEnableVersionChecker()
const { localStorage } = useLocalStorage()
- const [features, setFeatures] = React.useState<
- Record
- >({
- share: { isForceEnabled: null },
- shareFull: { isForceEnabled: null },
- userGroups: { isForceEnabled: null },
- userGroupsFull: { isForceEnabled: null },
- inviteUser: { isForceEnabled: null },
- inviteUserFull: { isForceEnabled: null },
- })
-
- const { getFeature } = billing.usePaywallFeatures()
-
- const queryClient = reactQuery.useQueryClient()
-
- const onConfigurationChange = React.useCallback(
- (feature: billing.PaywallFeatureName, configuration: PaywallDevtoolsFeatureConfiguration) => {
- setFeatures((prev) => ({ ...prev, [feature]: configuration }))
- },
- [],
- )
+ const featureFlags = useFeatureFlags()
+ const setFeatureFlags = useSetFeatureFlags()
return (
-
- {children}
-
-
-
-
-
-
-
- {getText('paywallDevtoolsPopoverHeading')}
-
-
-
-
- {session?.type === UserSessionType.full && (
- <>
- {getText('paywallDevtoolsPlanSelectSubtitle')}
-
-