mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
"Save" and "Cancel" buttons for settings sections (#11290)
This commit is contained in:
parent
39a176e61b
commit
0e51ce63f8
@ -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",
|
||||
|
@ -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 ===
|
||||
// ===================
|
||||
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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('')
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
*/
|
||||
export * from './Close'
|
||||
export * from './Dialog'
|
||||
export * from './DialogDismiss'
|
||||
export { useDialogContext, type DialogContextValue } from './DialogProvider'
|
||||
export {
|
||||
DialogStackProvider,
|
||||
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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) => {
|
||||
|
@ -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. */
|
||||
|
@ -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,
|
||||
|
@ -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: {},
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
66
app/gui/src/dashboard/layouts/Settings/FormEntry.tsx
Normal file
66
app/gui/src/dashboard/layouts/Settings/FormEntry.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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),
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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} />
|
||||
))}
|
||||
|
@ -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 />
|
||||
|
@ -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)
|
||||
}
|
||||
})()
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
@ -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} />
|
||||
))}
|
||||
|
@ -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 ===
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
</>
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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;
|
||||
|
@ -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()
|
||||
|
@ -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)',
|
||||
|
@ -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/**/*",
|
||||
|
Loading…
Reference in New Issue
Block a user