"Save" and "Cancel" buttons for settings sections (#11290)

This commit is contained in:
somebody1234 2024-11-21 18:26:42 +10:00 committed by GitHub
parent 39a176e61b
commit 0e51ce63f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 498 additions and 627 deletions

View File

@ -273,6 +273,7 @@
"path": "Path",
"reload": "Reload",
"seeLatestRelease": "See latest release",
"save": "Save",
"options": "Options",
"googleIcon": "Google icon",
"gitHubIcon": "GitHub icon",
@ -843,6 +844,9 @@
"userAccountSettingsSection": "User Account",
"userNameSettingsInput": "Name",
"userEmailSettingsInput": "Email",
"userCurrentPasswordSettingsInput": "Current password",
"userNewPasswordSettingsInput": "New password",
"userConfirmNewPasswordSettingsInput": "Confirm new password",
"changePasswordSettingsSection": "Change Password",
"setup2FASettingsSection": "Two-Factor Authentication (2FA)",
"setup2FASettingsCustomEntryAliases": "two-factor authentication\n2fa",

View File

@ -134,19 +134,33 @@ export function singletonObjectOrNull(value: unknown): [] | [object] {
// ============
/** UNSAFE when `Ks` contains strings that are not in the runtime array. */
export function omit<T, Ks extends readonly (string & keyof T)[] | []>(
export function omit<T, Ks extends readonly [string & keyof T, ...(string & keyof T)[]]>(
object: T,
...keys: Ks
): Omit<T, Ks[number]> {
const keysSet = new Set<string>(keys)
return Object.fromEntries(
// This is SAFE, as it is a reaonly upcast.
Object.entries(object as Readonly<Record<string, unknown>>).flatMap(kv =>
!keysSet.has(kv[0]) ? [kv] : [],
),
// This is SAFE, as it is a readonly upcast.
Object.entries(object as Readonly<Record<string, unknown>>).filter(([k]) => !keysSet.has(k)),
) as Omit<T, Ks[number]>
}
// ============
// === pick ===
// ============
/** UNSAFE when `Ks` contains strings that are not in the runtime array. */
export function pick<T, Ks extends readonly [string & keyof T, ...(string & keyof T)[]]>(
object: T,
...keys: Ks
): Pick<T, Ks[number]> {
const keysSet = new Set<string>(keys)
return Object.fromEntries(
// This is SAFE, as it is a readonly upcast.
Object.entries(object as Readonly<Record<string, unknown>>).filter(([k]) => keysSet.has(k)),
) as Pick<T, Ks[number]>
}
// ===================
// === ExtractKeys ===
// ===================

View File

@ -445,7 +445,7 @@ export namespace settings {
/** Find a "name" input in the "user account" settings section. */
export function locateNameInput(page: test.Page) {
return locate(page).getByLabel('Name')
return locate(page).getByLabel(TEXT.userNameSettingsInput).getByRole('textbox')
}
}
@ -482,9 +482,9 @@ export namespace settings {
.getByRole('textbox')
}
/** Find a "change" button. */
export function locateChangeButton(page: test.Page) {
return locate(page).getByRole('button', { name: 'Change' }).getByText('Change')
/** Find a "save" button. */
export function locateSaveButton(page: test.Page) {
return locate(page).getByRole('button', { name: 'Save' }).getByText('Save')
}
}
@ -525,22 +525,22 @@ export namespace settings {
/** Find a "name" input in the "organization" settings section. */
export function locateNameInput(page: test.Page) {
return locate(page).getByLabel('Organization display name')
return locate(page).getByLabel(TEXT.organizationNameSettingsInput).getByRole('textbox')
}
/** Find an "email" input in the "organization" settings section. */
export function locateEmailInput(page: test.Page) {
return locate(page).getByLabel('Email')
return locate(page).getByLabel(TEXT.organizationEmailSettingsInput).getByRole('textbox')
}
/** Find an "website" input in the "organization" settings section. */
export function locateWebsiteInput(page: test.Page) {
return locate(page).getByLabel('Website')
return locate(page).getByLabel(TEXT.organizationWebsiteSettingsInput).getByRole('textbox')
}
/** Find an "location" input in the "organization" settings section. */
export function locateLocationInput(page: test.Page) {
return locate(page).getByLabel('Location')
return locate(page).getByLabel(TEXT.organizationLocationSettingsInput).getByRole('textbox')
}
}

View File

@ -39,6 +39,7 @@ test.test('organization settings', async ({ page }) => {
await nameInput.press('Enter')
await test.expect(nameInput).toHaveValue('')
test.expect(api.currentOrganization()?.name).toBe(newName)
await page.getByRole('button', { name: actions.TEXT.cancel }).click()
})
const invalidEmail = 'invalid@email'
@ -47,7 +48,7 @@ test.test('organization settings', async ({ page }) => {
await test.test.step('Set invalid email', async () => {
await emailInput.fill(invalidEmail)
await emailInput.press('Enter')
test.expect(api.currentOrganization()?.email).toBe(null)
test.expect(api.currentOrganization()?.email).toBe('')
})
const newEmail = 'organization@email.com'

View File

@ -30,7 +30,7 @@ test.test('change password form', async ({ page }) => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await localActions.locateChangeButton(page).click()
await localActions.locateSaveButton(page).click()
await test
.expect(
localActions
@ -46,7 +46,7 @@ test.test('change password form', async ({ page }) => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a')
await localActions.locateChangeButton(page).click()
await localActions.locateSaveButton(page).click()
await test
.expect(
localActions
@ -62,7 +62,7 @@ test.test('change password form', async ({ page }) => {
const newPassword = '1234!' + actions.VALID_PASSWORD
await localActions.locateNewPasswordInput(page).fill(newPassword)
await localActions.locateConfirmNewPasswordInput(page).fill(newPassword)
await localActions.locateChangeButton(page).click()
await localActions.locateSaveButton(page).click()
await test.expect(localActions.locateCurrentPasswordInput(page)).toHaveText('')
await test.expect(localActions.locateNewPasswordInput(page)).toHaveText('')
await test.expect(localActions.locateConfirmNewPasswordInput(page)).toHaveText('')

View File

@ -0,0 +1,42 @@
/** @file A button to close a dialog without submitting it. */
import type { JSX } from 'react'
import { Button, useDialogContext, type ButtonProps } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
/** Additional props for the Cancel component. */
interface DialogDismissBaseProps {
readonly variant?: ButtonProps['variant']
}
/** Props for a {@link DialogDismiss}. */
export type DialogDismissProps = DialogDismissBaseProps &
Omit<ButtonProps, 'formnovalidate' | 'href' | 'variant'>
/** Dismiss button for dialogs. */
export function DialogDismiss(props: DialogDismissProps): JSX.Element {
const { getText } = useText()
const { size = 'medium', ...buttonProps } = props
const dialogContext = useDialogContext()
return (
<Button
testId="form-cancel-button"
formnovalidate
type="button"
variant="outline"
size={size}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
onPress={async (event) => {
dialogContext?.close()
await buttonProps.onPress?.(event)
}}
>
{getText('cancel')}
</Button>
)
}

View File

@ -5,6 +5,7 @@
*/
export * from './Close'
export * from './Dialog'
export * from './DialogDismiss'
export { useDialogContext, type DialogContextValue } from './DialogProvider'
export {
DialogStackProvider,

View File

@ -1,8 +1,4 @@
/**
* @file
*
* Reset button for forms.
*/
/** @file Reset button for forms. */
import * as React from 'react'
import * as ariaComponents from '#/components/AriaComponents'
@ -14,16 +10,14 @@ import type * as types from './types'
/** Props for the Reset component. */
export interface ResetProps extends Omit<ariaComponents.ButtonProps, 'loading'> {
/**
* Connects the submit button to a form.
* Connects the reset button to a form.
* If not provided, the button will use the nearest form context.
*
* This field is helpful when you need to use the submit button outside of the form.
* This field is helpful when you need to use the reset button outside of a form.
*/
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any>
/** Defaults to `reset`. */
readonly action?: 'cancel' | 'reset'
}
/** Reset button for forms. */
@ -33,8 +27,7 @@ export function Reset(props: ResetProps): React.JSX.Element {
variant = 'outline',
size = 'medium',
testId = 'form-reset-button',
action = 'reset',
children = action === 'cancel' ? getText('cancel') : getText('reset'),
children = getText('reset'),
...buttonProps
} = props
@ -43,17 +36,19 @@ export function Reset(props: ResetProps): React.JSX.Element {
return (
<ariaComponents.Button
onPress={() => {
form.reset()
}}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
variant={variant}
size={size}
isDisabled={formState.isSubmitting || !formState.isDirty}
testId={testId}
children={children}
onPress={() => {
// `type="reset"` triggers native HTML reset, which does not work here as it clears inputs
// rather than resetting them to default values.
form.reset()
}}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
/>
)
}

View File

@ -6,7 +6,7 @@
*/
import type { JSX } from 'react'
import { Button, useDialogContext, type ButtonProps } from '#/components/AriaComponents'
import { Button, type ButtonProps } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import { useFormContext } from './FormProvider'
import type { FormInstance } from './types'
@ -23,8 +23,7 @@ interface SubmitButtonBaseProps {
// We do not need to know the form fields.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: FormInstance<any>
/** Defaults to `submit`. */
readonly action?: 'cancel' | 'submit' | 'update'
readonly cancel?: boolean
}
/** Props for the Submit component. */
@ -41,38 +40,26 @@ export function Submit(props: SubmitProps): JSX.Element {
const {
size = 'medium',
action = 'submit',
loading = false,
children = action === 'cancel' ? getText('cancel')
: action === 'update' ? getText('update')
: getText('submit'),
variant = action === 'cancel' ? 'outline' : 'submit',
testId = action === 'cancel' ? 'form-cancel-button' : 'form-submit-button',
children = getText('submit'),
variant = 'submit',
testId = 'form-submit-button',
...buttonProps
} = props
const dialogContext = useDialogContext()
const form = useFormContext(props.form)
const { formState } = form
const isLoading = action === 'cancel' ? false : loading || formState.isSubmitting
const type = action === 'cancel' || isLoading ? 'button' : 'submit'
return (
<Button
type="submit"
variant={variant}
size={size}
loading={loading || formState.isSubmitting}
testId={testId}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}
type={type}
variant={variant}
size={size}
loading={isLoading}
testId={testId}
onPress={() => {
if (action === 'cancel') {
dialogContext?.close()
}
}}
>
{children}
</Button>

View File

@ -69,13 +69,12 @@ export const Input = forwardRef(function Input<
variant,
variants = INPUT_STYLES,
fieldVariants,
form,
form: formRaw,
autoFocus = false,
...inputProps
} = props
const form = Form.useFormContext(formRaw)
const testId = props.testId ?? props['data-testid']
const privateInputRef = useRef<HTMLInputElement>(null)
const { fieldProps, formInstance } = Form.useFieldRegister<
@ -113,7 +112,7 @@ export const Input = forwardRef(function Input<
return (
<Form.Field
{...aria.mergeProps<FieldComponentProps<Schema>>()(inputProps, omit(fieldProps), {
{...aria.mergeProps<FieldComponentProps<Schema>>()(inputProps, fieldProps, {
isHidden: props.hidden,
fullWidth: true,
variants: fieldVariants,
@ -135,8 +134,8 @@ export const Input = forwardRef(function Input<
<div className={classes.inputContainer()}>
<aria.Input
{...aria.mergeProps<aria.InputProps>()(
inputProps,
{ className: classes.textArea(), type, name },
omit(inputProps, 'isInvalid', 'isRequired', 'isDisabled'),
omit(fieldProps, 'isInvalid', 'isRequired', 'isDisabled', 'invalid'),
)}
ref={(el) => {

View File

@ -1,6 +1,8 @@
/** @file A horizontal selector supporting multiple input. */
import { useRef, type CSSProperties, type ForwardedRef, type Ref } from 'react'
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
import {
FieldError,
ListBox,
@ -19,7 +21,6 @@ import {
import { mergeRefs } from '#/utilities/mergeRefs'
import { forwardRef } from '#/utilities/react'
import { tv, type VariantProps } from '#/utilities/tailwindVariants'
import { omit, unsafeRemoveUndefined } from 'enso-common/src/utilities/data/object'
import { MultiSelectorOption } from './MultiSelectorOption'
/** * Props for the MultiSelector component. */

View File

@ -104,7 +104,7 @@ export const OTPInput = forwardRef(function OTPInput<
return (
<Form.Field
{...mergeProps<FieldComponentProps<Schema>>()(inputProps, omit(fieldProps), {
{...mergeProps<FieldComponentProps<Schema>>()(inputProps, fieldProps, {
isHidden: props.hidden,
fullWidth: true,
variants: fieldVariants,

View File

@ -9,17 +9,19 @@ import { TEXT_STYLE } from '../Text'
export const INPUT_STYLES = tv({
base: 'block w-full overflow-hidden bg-transparent transition-[border-color,outline] duration-200',
variants: {
// All variants SHOULD use objects, otherwise extending from them with e.g. `{ base: '' }`
// results in `readOnly: { true: 'cursor-default [object Object]' }` for example.
disabled: {
true: { base: 'cursor-default opacity-50', textArea: 'cursor-default' },
false: { base: 'cursor-text', textArea: 'cursor-text' },
},
invalid: {
// Specified in compoundVariants. Real classes depend on Variants
true: '',
true: { base: '' },
},
readOnly: {
true: 'cursor-default',
false: 'cursor-text',
true: { base: 'cursor-default' },
false: { base: 'cursor-text' },
},
size: {
medium: { base: 'px-[11px] pb-[6.5px] pt-[8.5px]', icon: 'size-4' },
@ -27,14 +29,14 @@ export const INPUT_STYLES = tv({
custom: {},
},
rounded: {
none: 'rounded-none',
small: 'rounded-sm',
medium: 'rounded-md',
large: 'rounded-lg',
xlarge: 'rounded-xl',
xxlarge: 'rounded-2xl',
xxxlarge: 'rounded-3xl',
full: 'rounded-full',
none: { base: 'rounded-none' },
small: { base: 'rounded-sm' },
medium: { base: 'rounded-md' },
large: { base: 'rounded-lg' },
xlarge: { base: 'rounded-xl' },
xxlarge: { base: 'rounded-2xl' },
xxxlarge: { base: 'rounded-3xl' },
full: { base: 'rounded-full' },
},
variant: {
custom: {},

View File

@ -1,12 +1,8 @@
/**
* @file
*
* Common types for ARIA components.
*/
/** @file Common types for ARIA components. */
/** Props for adding a test id to a component */
export interface TestIdProps {
/** @deprecated Use `testid` instead */
/** @deprecated Use `testId` instead. */
readonly 'data-testid'?: string | undefined
readonly testId?: string | undefined
}

View File

@ -3,6 +3,7 @@ import {
Form,
INPUT_STYLES,
Input,
TEXT_STYLE,
type FieldPath,
type InputProps,
type TSchema,
@ -11,9 +12,20 @@ import { tv } from '#/utilities/tailwindVariants'
const SETTINGS_INPUT_STYLES = tv({
extend: INPUT_STYLES,
variants: {
readOnly: {
true: {
base: 'opacity-100 focus-within:outline-0 border-transparent focus-within:border-transparent',
},
},
},
slots: {
base: 'p-0',
textArea: 'rounded-2xl border-0.5 border-primary/20 px-1',
base: 'p-0 transition-[border-color,outline] outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-primary border-0.5 border-primary/20 rounded-2xl',
inputContainer: TEXT_STYLE({ disableLineHeightCompensation: true }),
addonStart: 'px-1',
textArea: 'h-6 rounded-full px-1',
addonEnd: 'px-1',
description: 'px-1',
},
})
@ -22,7 +34,10 @@ const SETTINGS_FIELD_STYLES = tv({
slots: {
base: 'flex-row flex-wrap',
labelContainer: 'flex min-h-row items-center gap-5 w-full',
label: 'text mb-auto w-40 shrink-0',
label: TEXT_STYLE({
className: 'text self-start w-40 shrink-0',
variant: 'body',
}),
error: 'ml-[180px]',
},
})
@ -44,11 +59,11 @@ export default function SettingsAriaInput<
>(props: SettingsAriaInputProps<Schema, TFieldName>) {
return (
<Input
{...props}
variant="custom"
size="custom"
variants={SETTINGS_INPUT_STYLES}
fieldVariants={SETTINGS_FIELD_STYLES}
{...props}
/>
)
}

View File

@ -1,84 +0,0 @@
/** @file A form for changing the user's password. */
import * as z from 'zod'
import { ButtonGroup, Form, Input } from '#/components/AriaComponents'
import { passwordSchema, passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { useAuth, useFullUserSession } from '#/providers/AuthProvider'
import { type GetText, useText } from '#/providers/TextProvider'
import { PASSWORD_REGEX } from '#/utilities/validation'
import SettingsAriaInput from './AriaInput'
/** Create the schema for this form. */
function createChangePasswordFormSchema(getText: GetText) {
return z
.object({
username: z.string().email(getText('invalidEmailValidationError')),
currentPassword: passwordSchema(getText),
newPassword: passwordWithPatternSchema(getText),
confirmNewPassword: z.string(),
})
.superRefine((object, context) => {
if (
PASSWORD_REGEX.test(object.newPassword) &&
object.newPassword !== object.confirmNewPassword
) {
context.addIssue({
path: ['confirmNewPassword'],
code: 'custom',
message: getText('passwordMismatchError'),
})
}
})
}
// ==========================
// === ChangePasswordForm ===
// ==========================
/** A form for changing the user's password. */
export default function ChangePasswordForm() {
const { user } = useFullUserSession()
const { changePassword } = useAuth()
const { getText } = useText()
return (
<Form
schema={createChangePasswordFormSchema(getText)}
gap="none"
onSubmit={({ currentPassword, newPassword }) => changePassword(currentPassword, newPassword)}
>
<Input hidden name="username" autoComplete="username" value={user.email} readOnly />
<SettingsAriaInput
data-testid="current-password-input"
name="currentPassword"
type="password"
autoComplete="current-password"
label={getText('currentPasswordLabel')}
placeholder={getText('currentPasswordPlaceholder')}
/>
<SettingsAriaInput
data-testid="new-password-input"
name="newPassword"
type="password"
label={getText('newPasswordLabel')}
placeholder={getText('newPasswordPlaceholder')}
autoComplete="new-password"
description={getText('passwordValidationMessage')}
/>
<SettingsAriaInput
data-testid="confirm-new-password-input"
name="confirmNewPassword"
type="password"
label={getText('confirmNewPasswordLabel')}
placeholder={getText('confirmNewPasswordPlaceholder')}
autoComplete="new-password"
/>
<Form.FormError />
<ButtonGroup>
<Form.Submit>{getText('change')}</Form.Submit>
<Form.Reset>{getText('cancel')}</Form.Reset>
</ButtonGroup>
</Form>
)
}

View File

@ -1,18 +1,18 @@
/** @file Rendering for an {@link settingsData.SettingsCustomEntryData}. */
import type * as settingsData from './data'
/** @file Rendering for an {@link SettingsCustomEntryData}. */
import type { SettingsContext, SettingsCustomEntryData } from './data'
// ===========================
// ==========================
// === SettingsCustomEntry ===
// ===========================
/** Props for a {@link SettingsCustomEntry}. */
export interface SettingsCustomEntryProps {
readonly context: settingsData.SettingsContext
readonly data: settingsData.SettingsCustomEntryData
readonly context: SettingsContext
readonly data: SettingsCustomEntryData
}
/** Rendering for an {@link settingsData.SettingsCustomEntryData}. */
export default function SettingsCustomEntry(props: SettingsCustomEntryProps) {
/** Rendering for an {@link SettingsCustomEntryData}. */
export function SettingsCustomEntry(props: SettingsCustomEntryProps) {
const { context, data } = props
const { render: Render, getVisible } = data
const visible = getVisible?.(context) ?? true

View File

@ -23,7 +23,7 @@ export default function DeleteUserAccountSettingsSection() {
<FocusArea direction="vertical">
{(innerProps) => (
<div
className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]"
className="flex flex-col items-start gap-2.5 rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]"
{...innerProps}
>
<aria.Heading level={2} className="h-[2.375rem] py-0.5 text-xl font-bold text-danger">

View File

@ -1,7 +1,7 @@
/** @file Rendering for an arbitrary {@link SettingsEntryData}. */
import SettingsCustomEntry from './CustomEntry'
import { SettingsEntryType, type SettingsContext, type SettingsEntryData } from './data'
import SettingsInputEntry from './InputEntry'
import { SettingsCustomEntry } from './CustomEntry'
import { type SettingsContext, type SettingsEntryData } from './data'
import { SettingsFormEntry } from './FormEntry'
// =====================
// === SettingsEntry ===
@ -17,10 +17,10 @@ export interface SettingsEntryProps {
export default function SettingsEntry(props: SettingsEntryProps) {
const { context, data } = props
switch (data.type) {
case SettingsEntryType.input: {
return <SettingsInputEntry context={context} data={data} />
case 'form': {
return <SettingsFormEntry context={context} data={data} />
}
case SettingsEntryType.custom: {
case 'custom': {
return <SettingsCustomEntry context={context} data={data} />
}
}

View File

@ -0,0 +1,66 @@
/** @file Rendering for an {@link SettingsFormEntryData}. */
import { ButtonGroup, Form } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import { useEffect, useMemo, useRef, useState } from 'react'
import SettingsInput from './Input'
import type { SettingsContext, SettingsFormEntryData } from './data'
// =========================
// === SettingsFormEntry ===
// =========================
/** Props for a {@link SettingsFormEntry}. */
export interface SettingsFormEntryProps<T extends Record<keyof T, string>> {
readonly context: SettingsContext
readonly data: SettingsFormEntryData<T>
}
/** Rendering for an {@link SettingsFormEntryData}. */
export function SettingsFormEntry<T extends Record<keyof T, string>>(
props: SettingsFormEntryProps<T>,
) {
const { context, data } = props
const { schema: schemaRaw, getValue, inputs, onSubmit, getVisible } = data
const { getText } = useText()
const visible = getVisible?.(context) ?? true
const value = getValue(context)
const [initialValueString] = useState(() => JSON.stringify(value))
const valueStringRef = useRef(initialValueString)
const schema = useMemo(
() => (typeof schemaRaw === 'function' ? schemaRaw(context) : schemaRaw),
[context, schemaRaw],
)
const form = Form.useForm({
// @ts-expect-error This is SAFE, as the type `T` is statically known.
schema,
defaultValues: value,
onSubmit: async (newValue) => {
// @ts-expect-error This is SAFE, as the type `T` is statically known.
await onSubmit(context, newValue)
form.reset(newValue)
// The form should not be reset on error.
},
})
useEffect(() => {
const newValueString = JSON.stringify(value)
if (newValueString !== valueStringRef.current) {
form.reset(value)
valueStringRef.current = newValueString
}
}, [form, value])
return !visible ? null : (
<Form form={form} gap="none">
{inputs.map((input) => (
<SettingsInput key={input.name} context={context} data={input} />
))}
<ButtonGroup>
<Form.Submit isDisabled={!form.formState.isDirty}>{getText('save')}</Form.Submit>
<Form.Reset>{getText('cancel')}</Form.Reset>
</ButtonGroup>
<Form.FormError />
</Form>
)
}

View File

@ -1,112 +1,38 @@
/** @file A styled input specific to settings pages. */
import {
useContext,
useRef,
useState,
type ChangeEventHandler,
type ForwardedRef,
type HTMLInputAutoCompleteAttribute,
type KeyboardEvent,
type RefAttributes,
type SyntheticEvent,
} from 'react'
import EyeIcon from '#/assets/eye.svg'
import EyeCrossedIcon from '#/assets/eye_crossed.svg'
import { Group, Input, InputContext, mergeProps, type InputProps } from '#/components/aria'
import { Button } from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import { useFocusChild } from '#/hooks/focusHooks'
/** @file Rendering for an {@link SettingsInputData}. */
import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react'
import { twMerge } from '#/utilities/tailwindMerge'
import SettingsAriaInput from './AriaInput'
import type { SettingsContext, SettingsInputData } from './data'
// =====================
// === SettingsInput ===
// =====================
/** Props for an {@link SettingsInput}. */
export interface SettingsInputProps {
readonly isDisabled?: boolean
readonly type?: string
readonly placeholder?: string
readonly autoComplete?: HTMLInputAutoCompleteAttribute
readonly onChange?: ChangeEventHandler<HTMLInputElement>
readonly onSubmit?: (event: SyntheticEvent<HTMLInputElement>) => void
/** Props for a {@link SettingsInput}. */
export interface SettingsInputProps<T extends Record<keyof T, string>> {
readonly context: SettingsContext
readonly data: SettingsInputData<T>
}
export default forwardRef(SettingsInput)
/** A styled input specific to settings pages. */
function SettingsInput(props: SettingsInputProps, ref: ForwardedRef<HTMLInputElement>) {
const { isDisabled = false, type, placeholder, autoComplete, onChange, onSubmit } = props
const focusChildProps = useFocusChild()
/** Rendering for an {@link SettingsInputData}. */
export default function SettingsInput<T extends Record<keyof T, string>>(
props: SettingsInputProps<T>,
) {
const { context, data } = props
const { name, nameId, autoComplete, hidden: hiddenRaw, editable, descriptionId } = data
const { getText } = useText()
// This is SAFE. The value of this context is never a `SlottedContext`.
// eslint-disable-next-line no-restricted-syntax
const inputProps = (useContext(InputContext) ?? null) as InputProps | null
const [isShowingPassword, setIsShowingPassword] = useState(false)
const cancelled = useRef(false)
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'Escape': {
cancelled.current = true
event.stopPropagation()
event.currentTarget.value = String(inputProps?.defaultValue ?? '')
event.currentTarget.blur()
break
}
default: {
cancelled.current = false
break
}
}
}
const isEditable = typeof editable === 'function' ? editable(context) : editable ?? true
const hidden = typeof hiddenRaw === 'function' ? hiddenRaw(context) : hiddenRaw ?? false
return (
<div className="text my-auto grow font-bold">
<FocusRing within placement="after">
<Group className="relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-full">
<Input
{...mergeProps<InputProps & RefAttributes<HTMLInputElement>>()(
{
ref,
className: twMerge(
'w-full rounded-full bg-transparent font-bold placeholder-black/30 transition-colors invalid:border invalid:border-red-700 hover:bg-selected-frame focus:bg-selected-frame px-1 border-0.5 border-transparent',
!isDisabled && 'border-primary/20',
),
...(type == null ? {} : { type: isShowingPassword ? 'text' : type }),
disabled: isDisabled,
size: 1,
autoComplete,
placeholder,
onKeyDown,
onChange,
onBlur: (event) => {
if (!cancelled.current) {
onSubmit?.(event)
}
},
},
focusChildProps,
)}
/>
{type === 'password' && (
<Button
size="custom"
variant="custom"
isActive
icon={isShowingPassword ? EyeIcon : EyeCrossedIcon}
aria-label={isShowingPassword ? getText('hidePassword') : getText('showPassword')}
className="absolute right-2 top-1 size-6 cursor-pointer rounded-full"
onPress={() => {
setIsShowingPassword((show) => !show)
}}
/>
)}
</Group>
</FocusRing>
</div>
<SettingsAriaInput
readOnly={!isEditable}
label={getText(nameId)}
name={name}
hidden={hidden}
autoComplete={autoComplete}
{...(descriptionId != null && {
description: getText(descriptionId),
})}
/>
)
}

View File

@ -1,89 +0,0 @@
/** @file Rendering for an {@link SettingsInputEntryData}. */
import { useRef, useState } from 'react'
import { Button, FieldError, Form, Label, TextField } from '#/components/aria'
import { useText } from '#/providers/TextProvider'
import { getMessageOrToString } from '#/utilities/error'
import type { SettingsContext, SettingsInputEntryData } from './data'
import SettingsInput from './Input'
// =================
// === Constants ===
// =================
/** The name of the single field in this form. */
const FIELD_NAME = 'value'
// ==========================
// === SettingsInputEntry ===
// ==========================
/** Props for a {@link SettingsInputEntry}. */
export interface SettingsInputEntryProps {
readonly context: SettingsContext
readonly data: SettingsInputEntryData
}
/** Rendering for an {@link SettingsInputEntryData}. */
export default function SettingsInputEntry(props: SettingsInputEntryProps) {
const { context, data } = props
const { nameId, getValue, setValue, validate, getEditable } = data
const { getText } = useText()
const [errorMessage, setErrorMessage] = useState('')
const isSubmitting = useRef(false)
const value = getValue(context)
const isEditable = getEditable(context)
const input = (
<SettingsInput
isDisabled={!isEditable}
key={value}
type="text"
onSubmit={(event) => {
// Technically causes the form to submit twice when pressing `Enter` due to `Enter`
// also triggering the submit button.This is worked around by using a ref
// tracking whether the form is currently being submitted.
event.currentTarget.form?.requestSubmit()
}}
/>
)
return (
<Form
validationErrors={{ [FIELD_NAME]: errorMessage }}
onSubmit={async (event) => {
event.preventDefault()
if (!isSubmitting.current) {
isSubmitting.current = true
const [[, newValue] = []] = new FormData(event.currentTarget)
if (typeof newValue === 'string') {
setErrorMessage('')
try {
await setValue(context, newValue)
} catch (error) {
setErrorMessage(getMessageOrToString(error))
}
}
isSubmitting.current = false
}
}}
>
<TextField
key={value}
name={FIELD_NAME}
defaultValue={value}
className="flex h-row items-center gap-settings-entry"
{...(validate ? { validate: (newValue) => validate(newValue, context) } : {})}
>
<Label className="text my-auto w-organization-settings-label">{getText(nameId)}</Label>
{validate ?
<div className="flex grow flex-col">
{input}
<FieldError className="text-red-700" />
</div>
: input}
<Button type="submit" className="sr-only" />
</TextField>
</Form>
)
}

View File

@ -20,18 +20,20 @@ export default function SettingsSection(props: SettingsSectionProps) {
const { context, data } = props
const { nameId, focusArea = true, heading = true, entries } = data
const { getText } = useText()
const isVisible = entries.some((entry) => entry.getVisible?.(context) ?? true)
const isVisible = entries.some((entry) =>
'getVisible' in entry ? entry.getVisible(context) : true,
)
return !isVisible ? null : (
<FocusArea active={focusArea} direction="vertical">
{(innerProps) => (
<div className="flex w-full flex-col gap-settings-section-header" {...innerProps}>
<div className="flex w-full flex-col gap-2.5" {...innerProps}>
{!heading ? null : (
<Text.Heading level={2} weight="bold">
{getText(nameId)}
</Text.Heading>
)}
<div className="flex flex-col overflow-auto">
<div className="flex flex-col gap-2 overflow-auto">
{entries.map((entry, i) => (
<SettingsEntry key={i} context={context} data={entry} />
))}

View File

@ -12,6 +12,7 @@ import {
ButtonGroup,
CopyBlock,
Dialog,
DialogDismiss,
DialogTrigger,
Form,
OTPInput,
@ -114,7 +115,7 @@ export function SetupTwoFaForm() {
<ButtonGroup>
<Form.Submit variant="delete">{getText('disable')}</Form.Submit>
<Form.Submit action="cancel" />
<DialogDismiss />
</ButtonGroup>
<Form.FormError />

View File

@ -36,7 +36,7 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
<div
aria-label={getText('settingsSidebarLabel')}
className={twMerge(
'w-settings-sidebar shrink-0 flex-col gap-settings-sidebar overflow-y-auto',
'w-settings-sidebar shrink-0 flex-col gap-4 overflow-y-auto',
!isMenu ? 'hidden sm:flex' : (
'relative rounded-default p-modal text-xs text-primary before:absolute before:inset before:rounded-default before:bg-frame before:backdrop-blur-default sm:hidden'
),
@ -75,7 +75,11 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
// even though this function returns void, we don't want to
// complicate things by returning only in case of custom onPress
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
: setTab(tabData.settingsTab)
: (() => {
if (tab !== tabData.settingsTab) {
setTab(tabData.settingsTab)
}
})()
}
/>
))}

View File

@ -64,26 +64,17 @@ export default function SettingsTab(props: SettingsTabProps) {
} else {
const content =
columns.length === 1 ?
<div
className={twMerge('flex grow flex-col gap-settings-subsection', classes[0])}
{...contentProps}
>
<div className={twMerge('flex grow flex-col gap-8', classes[0])} {...contentProps}>
{sections.map((section) => (
<SettingsSection key={section.nameId} context={context} data={section} />
))}
</div>
: <div
className="flex min-h-full grow flex-col gap-settings-section lg:h-auto lg:flex-row"
className="flex min-h-full grow flex-col gap-8 lg:h-auto lg:flex-row"
{...contentProps}
>
{columns.map((sectionsInColumn, i) => (
<div
key={i}
className={twMerge(
'flex h-fit flex-1 flex-col gap-settings-subsection pb-12',
classes[i],
)}
>
<div key={i} className={twMerge('flex h-fit flex-1 flex-col gap-8 pb-12', classes[i])}>
{sectionsInColumn.map((section) => (
<SettingsSection key={section.nameId} context={context} data={section} />
))}

View File

@ -1,8 +1,8 @@
/** @file Metadata for rendering each settings section. */
import type { ReactNode } from 'react'
import type { HTMLInputAutoCompleteAttribute, ReactNode } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import isEmail from 'validator/lib/isEmail'
import * as z from 'zod'
import type { TextId } from 'enso-common/src/text'
@ -18,6 +18,7 @@ import { ACTION_TO_TEXT_ID } from '#/components/MenuEntry'
import { BINDINGS } from '#/configurations/inputBindings'
import type { PaywallFeatureName } from '#/hooks/billing'
import type { ToastAndLogCallback } from '#/hooks/toastAndLogHooks'
import { passwordSchema, passwordWithPatternSchema } from '#/pages/authentication/schemas'
import type { GetText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
@ -30,9 +31,9 @@ import {
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
import { normalizePath } from '#/utilities/fileInfo'
import { unsafeEntries } from '#/utilities/object'
import { pick, unsafeEntries } from '#/utilities/object'
import { PASSWORD_REGEX } from '#/utilities/validation'
import ActivityLogSettingsSection from './ActivityLogSettingsSection'
import ChangePasswordForm from './ChangePasswordForm'
import DeleteUserAccountSettingsSection from './DeleteUserAccountSettingsSection'
import KeyboardShortcutsSettingsSection from './KeyboardShortcutsSettingsSection'
import MembersSettingsSection from './MembersSettingsSection'
@ -47,12 +48,6 @@ import UserGroupsSettingsSection from './UserGroupsSettingsSection'
// === SettingsEntryType ===
// =========================
/** The tag for the {@link SettingsEntryData} discriminated union. */
export enum SettingsEntryType {
input = 'input',
custom = 'custom',
}
// =================
// === Constants ===
// =================
@ -62,7 +57,7 @@ export const SETTINGS_NO_RESULTS_SECTION_DATA: SettingsSectionData = {
heading: false,
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
render: (context) => (
<div className="grid max-w-[512px] justify-center">{context.getText('noResultsFound')}</div>
),
@ -79,40 +74,85 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
{
nameId: 'userAccountSettingsSection',
entries: [
{
type: SettingsEntryType.input,
nameId: 'userNameSettingsInput',
getValue: (context) => context.user.name,
setValue: async (context, newName) => {
settingsFormEntryData({
type: 'form',
schema: z.object({
name: z.string().regex(/.*\S.*/),
email: z.string().email(),
}),
getValue: (context) => pick(context.user, 'name', 'email'),
onSubmit: async (context, { name }) => {
const oldName = context.user.name
if (newName !== oldName) {
await context.updateUser([{ username: newName }])
if (name !== oldName) {
await context.updateUser([{ username: name }])
}
},
validate: (name) => (/\S/.test(name) ? true : ''),
getEditable: () => true,
},
{
type: SettingsEntryType.input,
nameId: 'userEmailSettingsInput',
getValue: (context) => context.user.email,
// A user's email currently cannot be changed.
setValue: async () => {},
validate: (email, context) =>
isEmail(email) ? true
: email === '' ? ''
: context.getText('invalidEmailValidationError'),
getEditable: () => false,
},
inputs: [
{ nameId: 'userNameSettingsInput', name: 'name' },
{ nameId: 'userEmailSettingsInput', name: 'email', editable: false },
],
}),
],
},
{
nameId: 'changePasswordSettingsSection',
entries: [
{
type: SettingsEntryType.custom,
aliasesId: 'changePasswordSettingsCustomEntryAliases',
render: ChangePasswordForm,
settingsFormEntryData({
type: 'form',
schema: ({ getText }) =>
z
.object({
username: z.string().email(getText('invalidEmailValidationError')),
currentPassword: passwordSchema(getText),
newPassword: passwordWithPatternSchema(getText),
confirmNewPassword: z.string(),
})
.superRefine((object, context) => {
if (
PASSWORD_REGEX.test(object.newPassword) &&
object.newPassword !== object.confirmNewPassword
) {
context.addIssue({
path: ['confirmNewPassword'],
code: 'custom',
message: getText('passwordMismatchError'),
})
}
}),
getValue: ({ user }) => ({
username: user.email,
currentPassword: '',
newPassword: '',
confirmNewPassword: '',
}),
onSubmit: async ({ changePassword }, { currentPassword, newPassword }) => {
await changePassword(currentPassword, newPassword)
},
inputs: [
{
nameId: 'userNameSettingsInput',
name: 'username',
autoComplete: 'username',
editable: false,
hidden: true,
},
{
nameId: 'userCurrentPasswordSettingsInput',
name: 'currentPassword',
autoComplete: 'current-assword',
},
{
nameId: 'userNewPasswordSettingsInput',
name: 'newPassword',
autoComplete: 'new-password',
descriptionId: 'passwordValidationMessage',
},
{
nameId: 'userConfirmNewPasswordSettingsInput',
name: 'confirmNewPassword',
autoComplete: 'new-password',
},
],
getVisible: (context) => {
// The shape of the JWT payload is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -121,14 +161,14 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
JSON.parse(atob(context.accessToken.split('.')[1]!)).username
return username != null ? !/^Github_|^Google_/.test(username) : false
},
},
}),
],
},
{
nameId: 'setup2FASettingsSection',
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
render: SetupTwoFaForm,
getVisible: (context) => {
// The shape of the JWT payload is statically known.
@ -146,7 +186,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
heading: false,
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
aliasesId: 'deleteUserAccountSettingsCustomEntryAliases',
render: () => <DeleteUserAccountSettingsSection />,
},
@ -157,7 +197,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
column: 2,
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
aliasesId: 'profilePictureSettingsCustomEntryAliases',
render: (context) => <ProfilePictureInput backend={context.backend} />,
},
@ -175,61 +215,56 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
{
nameId: 'organizationSettingsSection',
entries: [
{
type: SettingsEntryType.input,
nameId: 'organizationNameSettingsInput',
getValue: (context) => context.organization?.name ?? '',
setValue: async (context, newName) => {
const oldName = context.organization?.name ?? null
if (oldName !== newName) {
await context.updateOrganization([{ name: newName }])
settingsFormEntryData({
type: 'form',
schema: z.object({
name: z.string().regex(/^.*\S.*$|^$/),
email: z.string().email().or(z.literal('')),
website: z.string(),
address: z.string(),
}),
getValue: (context) => {
const { name, email, website, address } = context.organization ?? {}
return {
name: name ?? '',
email: String(email ?? ''),
website: String(website ?? ''),
address: address ?? '',
}
},
validate: (name) => (/\S/.test(name) ? true : ''),
getEditable: (context) => context.user.isOrganizationAdmin,
},
{
type: SettingsEntryType.input,
nameId: 'organizationEmailSettingsInput',
getValue: (context) => context.organization?.email ?? '',
setValue: async (context, newValue) => {
const newEmail = EmailAddress(newValue)
const oldEmail = context.organization?.email ?? null
if (oldEmail !== newEmail) {
await context.updateOrganization([{ email: newEmail }])
}
onSubmit: async (context, { name, email, website, address }) => {
await context.updateOrganization([
{
name,
email: EmailAddress(email),
website: HttpsUrl(website),
address,
},
])
},
validate: (email, context) =>
isEmail(email) ? true
: email === '' ? ''
: context.getText('invalidEmailValidationError'),
getEditable: (context) => context.user.isOrganizationAdmin,
},
{
type: SettingsEntryType.input,
nameId: 'organizationWebsiteSettingsInput',
getValue: (context) => context.organization?.website ?? '',
setValue: async (context, newValue) => {
const newWebsite = HttpsUrl(newValue)
const oldWebsite = context.organization?.website ?? null
if (oldWebsite !== newWebsite) {
await context.updateOrganization([{ website: newWebsite }])
}
},
getEditable: (context) => context.user.isOrganizationAdmin,
},
{
type: SettingsEntryType.input,
nameId: 'organizationLocationSettingsInput',
getValue: (context) => context.organization?.address ?? '',
setValue: async (context, newLocation) => {
const oldLocation = context.organization?.address ?? null
if (oldLocation !== newLocation) {
await context.updateOrganization([{ address: newLocation }])
}
},
getEditable: (context) => context.user.isOrganizationAdmin,
},
inputs: [
{
nameId: 'organizationNameSettingsInput',
name: 'name',
editable: (context) => context.user.isOrganizationAdmin,
},
{
nameId: 'organizationEmailSettingsInput',
name: 'email',
editable: (context) => context.user.isOrganizationAdmin,
},
{
nameId: 'organizationWebsiteSettingsInput',
name: 'website',
editable: (context) => context.user.isOrganizationAdmin,
},
{
nameId: 'organizationLocationSettingsInput',
name: 'address',
editable: (context) => context.user.isOrganizationAdmin,
},
],
}),
],
},
{
@ -237,7 +272,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
column: 2,
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
aliasesId: 'organizationProfilePictureSettingsCustomEntryAliases',
render: (context) => <OrganizationProfilePictureInput backend={context.backend} />,
},
@ -249,23 +284,24 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
nameId: 'localSettingsTab',
settingsTab: SettingsTabType.local,
icon: ComputerIcon,
visible: (context) => context.localBackend != null,
visible: ({ localBackend }) => localBackend != null,
sections: [
{
nameId: 'localSettingsSection',
entries: [
{
type: SettingsEntryType.input,
nameId: 'localRootPathSettingsInput',
getValue: (context) => context.localBackend?.rootPath() ?? '',
setValue: async (context, value) => {
context.updateLocalRootPath(value)
await Promise.resolve()
settingsFormEntryData({
type: 'form',
schema: z.object({
localRootPath: z.string(),
}),
getValue: ({ localBackend }) => ({ localRootPath: localBackend?.rootPath() ?? '' }),
onSubmit: ({ updateLocalRootPath }, { localRootPath }) => {
updateLocalRootPath(localRootPath)
},
getEditable: () => true,
},
inputs: [{ nameId: 'localRootPathSettingsInput', name: 'localRootPath' }],
}),
{
type: SettingsEntryType.custom,
type: 'custom',
aliasesId: 'localRootPathButtonSettingsCustomEntryAliases',
render: (context) => (
<ButtonGroup>
@ -337,7 +373,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
sections: [
{
nameId: 'membersSettingsSection',
entries: [{ type: SettingsEntryType.custom, render: () => <MembersSettingsSection /> }],
entries: [{ type: 'custom', render: () => <MembersSettingsSection /> }],
},
],
},
@ -354,7 +390,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
columnClassName: 'h-3/5 lg:h-[unset] overflow-auto',
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
render: (context) => <UserGroupsSettingsSection backend={context.backend} />,
},
],
@ -365,7 +401,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
columnClassName: 'h-2/5 lg:h-[unset] overflow-auto',
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
render: (context) => (
<MembersTable
backend={context.backend}
@ -388,7 +424,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
columnClassName: 'h-full *:flex-1 *:min-h-0',
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
aliasesId: 'keyboardShortcutsSettingsCustomEntryAliases',
getExtraAliases: (context) => {
const rebindableBindings = unsafeEntries(BINDINGS).flatMap((kv) => {
@ -418,7 +454,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
nameId: 'activityLogSettingsSection',
entries: [
{
type: SettingsEntryType.custom,
type: 'custom',
render: (context) => <ActivityLogSettingsSection backend={context.backend} />,
},
],
@ -478,30 +514,48 @@ export interface SettingsContext {
readonly toastAndLog: ToastAndLogCallback
readonly getText: GetText
readonly queryClient: QueryClient
readonly isMatch: (name: string) => boolean
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
}
// ==============================
// === SettingsInputEntryData ===
// ==============================
/** Metadata describing a settings entry that is an input. */
export interface SettingsInputEntryData {
readonly type: SettingsEntryType.input
/** Metadata describing an input in a {@link SettingsFormEntryData}. */
export interface SettingsInputData<T extends Record<keyof T, string>> {
readonly nameId: TextId & `${string}SettingsInput`
readonly getValue: (context: SettingsContext) => string
readonly setValue: (context: SettingsContext, value: string) => Promise<void>
readonly validate?: (value: string, context: SettingsContext) => string | true
readonly getEditable: (context: SettingsContext) => boolean
readonly name: string & keyof T
readonly autoComplete?: HTMLInputAutoCompleteAttribute
/** Defaults to `false`. */
readonly hidden?: boolean | ((context: SettingsContext) => boolean)
/** Defaults to `true`. */
readonly editable?: boolean | ((context: SettingsContext) => boolean)
readonly descriptionId?: TextId
}
/** Metadata describing a settings entry that is a form. */
export interface SettingsFormEntryData<T extends Record<keyof T, string>> {
readonly type: 'form'
readonly schema: z.ZodType<T> | ((context: SettingsContext) => z.ZodType<T>)
readonly getValue: (context: SettingsContext) => T
readonly onSubmit: (context: SettingsContext, value: T) => Promise<void> | void
readonly inputs: readonly SettingsInputData<NoInfer<T>>[]
readonly getVisible?: (context: SettingsContext) => boolean
}
/** A type-safe function to define a {@link SettingsFormEntryData}. */
function settingsFormEntryData<T extends Record<keyof T, string>>(data: SettingsFormEntryData<T>) {
return data
}
// ===============================
// === SettingsCustomEntryData ===
// ===============================
/** Metadata describing a settings entry that needs custom rendering. */
export interface SettingsCustomEntryData {
readonly type: SettingsEntryType.custom
readonly type: 'custom'
readonly aliasesId?: TextId & `${string}SettingsCustomEntryAliases`
readonly getExtraAliases?: (context: SettingsContext) => readonly string[]
readonly render: (context: SettingsContext) => ReactNode
@ -513,7 +567,8 @@ export interface SettingsCustomEntryData {
// =========================
/** A settings entry of an arbitrary type. */
export type SettingsEntryData = SettingsCustomEntryData | SettingsInputEntryData
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SettingsEntryData = SettingsCustomEntryData | SettingsFormEntryData<any>
// =======================
// === SettingsTabData ===

View File

@ -12,7 +12,7 @@ import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import SearchBar from '#/layouts/SearchBar'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useAuth, useFullUserSession } from '#/providers/AuthProvider'
import { useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
@ -25,7 +25,6 @@ import {
SETTINGS_DATA,
SETTINGS_NO_RESULTS_SECTION_DATA,
SETTINGS_TAB_DATA,
SettingsEntryType,
type SettingsContext,
type SettingsEntryData,
type SettingsTabData,
@ -54,6 +53,7 @@ export default function Settings() {
includesPredicate(Object.values(SettingsTabType)),
)
const { user, accessToken } = useFullUserSession()
const { changePassword } = useAuth()
const { getText } = useText()
const toastAndLog = useToastAndLog()
const [query, setQuery] = React.useState('')
@ -67,7 +67,7 @@ export default function Settings() {
backendMutationOptions(backend, 'updateOrganization'),
).mutateAsync
const [, setLocalRootDirectory] = useLocalStorageState('localRootDirectory')
const [localRootDirectory, setLocalRootDirectory] = useLocalStorageState('localRootDirectory')
const updateLocalRootPath = useEventCallback((value: string) => {
setLocalRootDirectory(value)
if (localBackend) {
@ -79,6 +79,11 @@ export default function Settings() {
localBackend?.resetRootPath()
})
const isMatch = React.useMemo(() => {
const regex = new RegExp(regexEscape(query.trim()).replace(/\s+/g, '.+'), 'i')
return (name: string) => regex.test(name)
}, [query])
const context = React.useMemo<SettingsContext>(
() => ({
accessToken,
@ -88,11 +93,14 @@ export default function Settings() {
organization,
updateUser,
updateOrganization,
localRootPath: localRootDirectory,
updateLocalRootPath,
resetLocalRootPath,
toastAndLog,
getText,
queryClient,
isMatch,
changePassword,
}),
[
accessToken,
@ -107,21 +115,19 @@ export default function Settings() {
updateUser,
user,
queryClient,
isMatch,
changePassword,
localRootDirectory,
],
)
const isMatch = React.useMemo(() => {
const regex = new RegExp(regexEscape(query.trim()).replace(/\s+/g, '.+'), 'i')
return (name: string) => regex.test(name)
}, [query])
const doesEntryMatchQuery = React.useCallback(
(entry: SettingsEntryData) => {
switch (entry.type) {
case SettingsEntryType.input: {
return isMatch(getText(entry.nameId))
case 'form': {
return entry.inputs.some((input) => isMatch(getText(input.nameId)))
}
case SettingsEntryType.custom: {
case 'custom': {
const doesAliasesIdMatch =
entry.aliasesId == null ? false : getText(entry.aliasesId).split('\n').some(isMatch)
if (doesAliasesIdMatch) {

View File

@ -3,7 +3,7 @@ import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { isOnMacOS } from 'enso-common/src/detect'
import { ButtonGroup, Dialog, Form, Text } from '#/components/AriaComponents'
import { ButtonGroup, Dialog, DialogDismiss, Form, Text } from '#/components/AriaComponents'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
@ -123,7 +123,7 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
</Text>
<ButtonGroup>
<Form.Submit isDisabled={!canSubmit}>{getText('confirm')}</Form.Submit>
<Form.Submit action="cancel" />
<DialogDismiss />
</ButtonGroup>
</Form>
</Dialog>

View File

@ -1,7 +1,7 @@
/** @file Modal for confirming delete of any type of asset. */
import * as z from 'zod'
import { ButtonGroup, Dialog, Form, Text } from '#/components/AriaComponents'
import { ButtonGroup, Dialog, DialogDismiss, Form, Text } from '#/components/AriaComponents'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
@ -39,7 +39,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
<Form.Submit variant="delete" className="relative">
{actionButtonLabel}
</Form.Submit>
<Form.Submit action="cancel" />
<DialogDismiss />
</ButtonGroup>
</Form>
</Dialog>

View File

@ -4,7 +4,7 @@ import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import * as z from 'zod'
import { ButtonGroup, Form, Input, Popover, Text } from '#/components/AriaComponents'
import { ButtonGroup, DialogDismiss, Form, Input, Popover, Text } from '#/components/AriaComponents'
import ColorPicker from '#/components/ColorPicker'
import FocusArea from '#/components/styled/FocusArea'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
@ -90,7 +90,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
</FocusArea>
<ButtonGroup className="relative">
<Form.Submit>{getText('create')}</Form.Submit>
<Form.Submit action="cancel" />
<DialogDismiss />
</ButtonGroup>
<Form.FormError />
</>

View File

@ -1,5 +1,5 @@
/** @file A modal for creating a Datalink. */
import { ButtonGroup, Dialog, Form, Input } from '#/components/AriaComponents'
import { ButtonGroup, Dialog, DialogDismiss, Form, Input } from '#/components/AriaComponents'
import { DatalinkFormInput } from '#/components/dashboard/DatalinkInput'
import SCHEMA from '#/data/datalinkSchema.json' with { type: 'json' }
import { validateDatalink } from '#/data/datalinkValidator'
@ -54,7 +54,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
<ButtonGroup>
<Form.Submit>{getText('create')}</Form.Submit>
<Form.Submit action="cancel" />
<DialogDismiss />
</ButtonGroup>
<Form.FormError />

View File

@ -1,5 +1,12 @@
/** @file Modal for confirming delete of any type of asset. */
import { ButtonGroup, Dialog, Form, INPUT_STYLES, Input } from '#/components/AriaComponents'
import {
ButtonGroup,
Dialog,
DialogDismiss,
Form,
INPUT_STYLES,
Input,
} from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import type { SecretId } from '#/services/Backend'
import { tv } from '#/utilities/tailwindVariants'
@ -91,8 +98,8 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
/>
<ButtonGroup className="mt-2">
<Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>
{canCancel && <Form.Submit action="cancel" />}
{canReset && <Form.Reset action="cancel" />}
{canCancel && <DialogDismiss />}
{canReset && <Form.Reset>{getText('cancel')}</Form.Reset>}
</ButtonGroup>
</Form>
)

View File

@ -1,7 +1,4 @@
/**
* @file
* The tab bar for the dashboard page.
*/
/** @file The tab bar for the dashboard page. */
import DriveIcon from '#/assets/drive.svg'
import SettingsIcon from '#/assets/settings.svg'
import WorkspaceIcon from '#/assets/workspace.svg'
@ -20,17 +17,13 @@ import { useText } from '#/providers/TextProvider'
import type { ProjectId } from '#/services/Backend'
import type { TextId } from 'enso-common/src/text'
/**
* The props for the {@link DashboardTabBar} component.
*/
/** The props for the {@link DashboardTabBar} component. */
export interface DashboardTabBarProps {
readonly onCloseProject: (project: LaunchedProject) => void
readonly onOpenEditor: (projectId: ProjectId) => void
}
/**
* The tab bar for the dashboard page.
*/
/** The tab bar for the dashboard page. */
export function DashboardTabBar(props: DashboardTabBarProps) {
const { onCloseProject, onOpenEditor } = props

View File

@ -1,7 +1,4 @@
/**
* @file
* The tab panels for the dashboard page.
*/
/** @file The tab panels for the dashboard page. */
import * as aria from '#/components/aria'
@ -17,9 +14,7 @@ import { TabType, useLaunchedProjects, usePage } from '#/providers/ProjectsProvi
import type { ProjectId } from '#/services/Backend'
import { Collection } from 'react-aria-components'
/**
* The props for the {@link DashboardTabPanels} component.
*/
/** The props for the {@link DashboardTabPanels} component. */
export interface DashboardTabPanelsProps {
readonly appRunner: GraphEditorRunner | null
readonly initialProjectName: string | null
@ -29,9 +24,7 @@ export interface DashboardTabPanelsProps {
readonly setCategory: (category: Category) => void
}
/**
* The tab panels for the dashboard page.
*/
/** The tab panels for the dashboard page. */
export function DashboardTabPanels(props: DashboardTabPanelsProps) {
const { appRunner, initialProjectName, ydocUrl, assetManagementApiRef, category, setCategory } =
props

View File

@ -88,5 +88,13 @@ export function useLocalStorageState<K extends LocalStorageKey>(
},
)
React.useEffect(
() =>
localStorage.subscribe(key, (newValue) => {
privateSetValue(newValue ?? defaultValue)
}),
[defaultValue, key, localStorage],
)
return [value, setValue]
}

View File

@ -6,28 +6,17 @@ import * as z from 'zod'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as backendModule from '#/services/Backend'
import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage'
// ===============
// === TabType ===
// ===============
/** Main content of the screen. Only one should be visible at a time. */
export enum TabType {
drive = 'drive',
settings = 'settings',
}
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
@ -37,10 +26,6 @@ declare module '#/utilities/LocalStorage' {
}
}
// =================
// === Constants ===
// =================
const PROJECT_SCHEMA = z
.object({
id: z.custom<backendModule.ProjectId>((x) => typeof x === 'string' && x.startsWith('project-')),
@ -73,10 +58,6 @@ export const PAGES_SCHEMA = z
LocalStorage.registerKey('page', { schema: PAGES_SCHEMA })
// =======================
// === ProjectsContext ===
// =======================
/** State contained in a `ProjectsContext`. */
export interface ProjectsContextType {
readonly setLaunchedProjects: (launchedProjects: readonly LaunchedProject[]) => void
@ -99,10 +80,6 @@ const LaunchedProjectsContext = React.createContext<readonly LaunchedProject[] |
/** Props for a {@link ProjectsProvider}. */
export type ProjectsProviderProps = Readonly<React.PropsWithChildren>
// ========================
// === ProjectsProvider ===
// ========================
/**
* A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused.
@ -112,7 +89,7 @@ export default function ProjectsProvider(props: ProjectsProviderProps) {
const [launchedProjects, setLaunchedProjects] = localStorageProvider.useLocalStorageState(
'launchedProjects',
[],
array.EMPTY_ARRAY,
)
const [page, setPage] = searchParamsState.useSearchParamsState(
'page',
@ -172,10 +149,6 @@ export default function ProjectsProvider(props: ProjectsProviderProps) {
)
}
// ========================
// === useProjectsStore ===
// ========================
/** The projects store. */
export function useProjectsStore() {
const context = React.useContext(ProjectsContext)
@ -185,9 +158,7 @@ export function useProjectsStore() {
return context
}
/**
* Returns the page context.
*/
/** The page context. */
export function usePage() {
const context = React.useContext(PageContext)
@ -196,10 +167,6 @@ export function usePage() {
return context
}
// ==================
// === useSetPage ===
// ==================
/** A function to set the current page. */
export function useSetPage() {
const { setPage } = useProjectsStore()
@ -208,9 +175,7 @@ export function useSetPage() {
})
}
/**
* Returns the launched projects context.
*/
/** Returns the launched projects context. */
export function useLaunchedProjects() {
const context = React.useContext(LaunchedProjectsContext)
@ -222,40 +187,24 @@ export function useLaunchedProjects() {
return context
}
// =================================
// === useUpdateLaunchedProjects ===
// =================================
/** A function to update launched projects. */
export function useUpdateLaunchedProjects() {
const { updateLaunchedProjects } = useProjectsStore()
return updateLaunchedProjects
}
// =============================
// === useAddLaunchedProject ===
// =============================
/** A function to add a new launched project. */
export function useAddLaunchedProject() {
const { addLaunchedProject } = useProjectsStore()
return addLaunchedProject
}
// ================================
// === useRemoveLaunchedProject ===
// ================================
/** A function to remove a launched project. */
export function useRemoveLaunchedProject() {
const { removeLaunchedProject } = useProjectsStore()
return removeLaunchedProject
}
// ================================
// === useClearLaunchedProjects ===
// ================================
/** A function to remove all launched projects. */
export function useClearLaunchedProjects() {
const { setLaunchedProjects } = useProjectsStore()

View File

@ -297,19 +297,9 @@
/* The horizontal gap between the settings sidebar and main content. */
--settings-gap: 2rem;
/* The vertical gap between the settings heading and everything else. */
--settings-header-gap: 2rem;
/* The horizontal gap between horizontally stacked settings sections. */
--settings-section-gap: 2rem;
/* The vertical gap between vertically stacked settings subsections. */
--settings-subsection-gap: 2rem;
/* The gap between the header and contents of a section in a settings page. */
--settings-section-header-gap: 0.625rem;
/* The gap between the label and value of a settings entry. */
--settings-entry-gap: 1.25rem;
--settings-sidebar-width: 12.875rem;
/* The gap between each section in the settings sidebar. */
--settings-sidebar-gap: 1rem;
/* The width of the main section of the settings page, if there are multiple sections stacked
* horizontally. */
--settings-main-section-width: 32rem;

View File

@ -1,7 +1,7 @@
/** @file Utilities related to debugging. */
import * as React from 'react'
import { useEffect, useRef, useState } from 'react'
import * as debugHooks from '#/hooks/debugHooks'
import { useMonitorDependencies } from '#/hooks/debugHooks'
/* eslint-disable no-restricted-properties */
@ -44,14 +44,14 @@ export default function Debug(props: DebugProps) {
: typeof typeRaw === 'object' && typeRaw != null && '$$typeof' in typeRaw ?
String(typeRaw.$$typeof)
: String(children.type))
const typeNameRef = React.useRef(typeName)
const typeNameRef = useRef(typeName)
typeNameRef.current = typeName
const monitorMountUnmountRef = React.useRef(monitorMountUnmount)
const monitorMountUnmountRef = useRef(monitorMountUnmount)
monitorMountUnmountRef.current = monitorMountUnmount
const [mountId] = React.useState(() => `(Mount #${nextMountId++})`)
const [mountId] = useState(() => `(Mount #${nextMountId++})`)
const renderId = `(Render #${nextRenderId++})`
React.useEffect(() => {
useEffect(() => {
if (monitorMountUnmountRef.current) {
console.log(`[Debug(${typeNameRef.current})] Mounted ${mountId}`)
}
@ -62,7 +62,7 @@ export default function Debug(props: DebugProps) {
}
}, [mountId])
debugHooks.useMonitorDependencies(
useMonitorDependencies(
[children.key, ...propsValues],
typeName,
['key', ...Object.keys(childProps)],
@ -94,7 +94,7 @@ export default function Debug(props: DebugProps) {
console.group(`[Debug(${typeName})] Rendering ${renderId}`, Component, childProps)
}
const element = <Component {...patchedChildProps} />
React.useEffect(() => {
useEffect(() => {
if (monitorRender) {
console.log(`[Debug(${typeName})] Finished rendering ${renderId}`)
console.groupEnd()

View File

@ -215,12 +215,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
sample: 'var(--sample-gap)',
samples: 'var(--samples-gap)',
settings: 'var(--settings-gap)',
'settings-header': 'var(--settings-header-gap)',
'settings-section': 'var(--settings-section-gap)',
'settings-subsection': 'var(--settings-subsection-gap)',
'settings-section-header': 'var(--settings-section-header-gap)',
'settings-entry': 'var(--settings-entry-gap)',
'settings-sidebar': 'var(--settings-sidebar-gap)',
'new-empty-project': 'var(--new-empty-project-gap)',
'side-panel': 'var(--side-panel-gap)',
'side-panel-section': 'var(--side-panel-section-gap)',

View File

@ -10,6 +10,7 @@
"tailwind.config.js",
"e2e/**/*",
"src/dashboard/hooks/eventCallbackHooks.ts",
"src/dashboard/modules/payments/constants.ts",
"src/dashboard/services/Backend.ts",
"src/dashboard/services/RemoteBackend.ts",
"src/dashboard/utilities/**/*",