New Terms of Service Dialog (#9975)

#### Tl;dr
Closes: enso-org/cloud-v2#1228
This PR adds a new DIalog that requires user to submit terms and conditions

![CleanShot 2024-05-16 at 16 44 52@2x](https://github.com/enso-org/enso/assets/61194245/02814557-e7b3-4e4a-9148-2f8be52c0858)


<details><summary>Demo Presentation</summary>
<p>
This commit is contained in:
Sergei Garin 2024-05-23 10:53:55 +03:00 committed by GitHub
parent a0a6f8c302
commit 5ed5c71e93
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 408 additions and 84 deletions

View File

@ -1,3 +1,5 @@
ENSO_CLOUD_ENSO_HOST=https://enso.org
ENSO_CLOUD_REDIRECT=http://localhost:8080
ENSO_CLOUD_ENVIRONMENT=production ENSO_CLOUD_ENVIRONMENT=production
ENSO_CLOUD_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com ENSO_CLOUD_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com
ENSO_CLOUD_CHAT_URL=wss://chat.example.com ENSO_CLOUD_CHAT_URL=wss://chat.example.com

View File

@ -120,6 +120,9 @@ export function getDefines() {
'process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH': stringify( 'process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH': stringify(
process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH process.env.ENSO_CLOUD_DASHBOARD_COMMIT_HASH
), ),
'process.env.ENSO_CLOUD_ENSO_HOST': stringify(
process.env.ENSO_CLOUD_ENSO_HOST ?? 'https://enso.org'
),
/* eslint-enable @typescript-eslint/naming-convention */ /* eslint-enable @typescript-eslint/naming-convention */
} }
} }

View File

@ -752,6 +752,7 @@ export async function login(
await locatePasswordInput(page).fill(password) await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click() await locateLoginButton(page).click()
await locateToastCloseButton(page).click() await locateToastCloseButton(page).click()
await passTermsAndConditionsDialog({ page })
} }
// ================ // ================
@ -787,6 +788,23 @@ async function mockDate({ page }: MockParams) {
}`) }`)
} }
/**
* Passes Terms and conditions dialog
*/
export async function passTermsAndConditionsDialog({ page }: MockParams) {
// wait for terms and conditions dialog to appear
// but don't fail if it doesn't appear
try {
// wait for terms and conditions dialog to appear
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await page.waitForSelector('#terms-of-service-modal', { timeout: 500 })
await page.getByRole('checkbox').click()
await page.getByRole('button', { name: 'Accept' }).click()
} catch (error) {
// do nothing
}
}
// ======================== // ========================
// === mockIDEContainer === // === mockIDEContainer ===
// ======================== // ========================
@ -836,8 +854,12 @@ export async function mockAll({ page }: MockParams) {
export async function mockAllAndLogin({ page }: MockParams) { export async function mockAllAndLogin({ page }: MockParams) {
const mocks = await mockAll({ page }) const mocks = await mockAll({ page })
await login({ page }) await login({ page })
await passTermsAndConditionsDialog({ page })
// This MUST run after login, otherwise the element's styles are reset when the browser // This MUST run after login, otherwise the element's styles are reset when the browser
// is navigated to another page. // is navigated to another page.
await mockIDEContainer({ page }) await mockIDEContainer({ page })
return mocks return mocks
} }

View File

@ -19,11 +19,15 @@ test.test('sign up flow', async ({ page }) => {
await actions.locateEmailInput(page).fill(email) await actions.locateEmailInput(page).fill(email)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click() await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
await test.expect(actions.locateSetUsernamePanel(page)).toBeVisible() await test.expect(actions.locateSetUsernamePanel(page)).toBeVisible()
// Logged in, but account disabled // Logged in, but account disabled
await actions.locateUsernameInput(page).fill(name) await actions.locateUsernameInput(page).fill(name)
await actions.locateSetUsernameButton(page).click() await actions.locateSetUsernameButton(page).click()
await test.expect(actions.locateUpgradeButton(page)).toBeVisible() await test.expect(actions.locateUpgradeButton(page)).toBeVisible()
await test.expect(actions.locateDriveView(page)).not.toBeVisible() await test.expect(actions.locateDriveView(page)).not.toBeVisible()

View File

@ -25,11 +25,15 @@ test.test('sign up with organization id', async ({ page }) => {
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateRegisterButton(page).click() await actions.locateRegisterButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Log in // Log in
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click() await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Set username // Set username
await actions.locateUsernameInput(page).fill('arbitrary username') await actions.locateUsernameInput(page).fill('arbitrary username')
await actions.locateSetUsernameButton(page).click() await actions.locateSetUsernameButton(page).click()

View File

@ -20,11 +20,15 @@ test.test('sign up without organization id', async ({ page }) => {
await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locateConfirmPasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateRegisterButton(page).click() await actions.locateRegisterButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Log in // Log in
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL) await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD) await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
await actions.locateLoginButton(page).click() await actions.locateLoginButton(page).click()
await actions.passTermsAndConditionsDialog({ page })
// Set username // Set username
await actions.locateUsernameInput(page).fill('arbitrary username') await actions.locateUsernameInput(page).fill('arbitrary username')
await actions.locateSetUsernameButton(page).click() await actions.locateSetUsernameButton(page).click()

View File

@ -42,7 +42,6 @@ import * as toastify from 'react-toastify'
import * as detect from 'enso-common/src/detect' import * as detect from 'enso-common/src/detect'
import * as appUtils from '#/appUtils' import * as appUtils from '#/appUtils'
import * as reactQueryClientModule from '#/reactQueryClient'
import * as inputBindingsModule from '#/configurations/inputBindings' import * as inputBindingsModule from '#/configurations/inputBindings'
@ -58,9 +57,7 @@ import SessionProvider from '#/providers/SessionProvider'
import SupportsLocalBackendProvider from '#/providers/SupportsLocalBackendProvider' import SupportsLocalBackendProvider from '#/providers/SupportsLocalBackendProvider'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration' import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ErrorScreen from '#/pages/authentication/ErrorScreen'
import ForgotPassword from '#/pages/authentication/ForgotPassword' import ForgotPassword from '#/pages/authentication/ForgotPassword'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
import Login from '#/pages/authentication/Login' import Login from '#/pages/authentication/Login'
import Registration from '#/pages/authentication/Registration' import Registration from '#/pages/authentication/Registration'
import ResetPassword from '#/pages/authentication/ResetPassword' import ResetPassword from '#/pages/authentication/ResetPassword'
@ -76,6 +73,7 @@ import * as rootComponent from '#/components/Root'
import AboutModal from '#/modals/AboutModal' import AboutModal from '#/modals/AboutModal'
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal' import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import LocalBackend from '#/services/LocalBackend' import LocalBackend from '#/services/LocalBackend'
@ -159,44 +157,40 @@ export interface AppProps {
export default function App(props: AppProps) { export default function App(props: AppProps) {
const { supportsLocalBackend } = props const { supportsLocalBackend } = props
const queryClient = React.useMemo(() => reactQueryClientModule.createReactQueryClient(), []) const { data: rootDirectoryPath } = reactQuery.useSuspenseQuery({
const [rootDirectoryPath, setRootDirectoryPath] = React.useState<projectManager.Path | null>(null) queryKey: ['root-directory', supportsLocalBackend],
const [error, setError] = React.useState<unknown>(null) queryFn: async () => {
const isLoading = supportsLocalBackend && rootDirectoryPath == null
React.useEffect(() => {
if (supportsLocalBackend) { if (supportsLocalBackend) {
void (async () => {
try {
const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`) const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`)
const text = await response.text() const text = await response.text()
setRootDirectoryPath(projectManager.Path(text)) return projectManager.Path(text)
} catch (innerError) { } else {
setError(innerError) return null
} }
})() },
})
const routerFuture: Partial<router.FutureConfig> = {
/* we want to use startTransition to enable concurrent rendering */
/* eslint-disable-next-line @typescript-eslint/naming-convention */
v7_startTransition: true,
} }
}, [supportsLocalBackend])
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`. // Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider` // Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
// will redirect the user between the login/register pages and the dashboard. // will redirect the user between the login/register pages and the dashboard.
return error != null ? ( return (
<ErrorScreen error={error} /> <>
) : isLoading ? (
<LoadingScreen />
) : (
<reactQuery.QueryClientProvider client={queryClient}>
<toastify.ToastContainer <toastify.ToastContainer
position="top-center" position="top-center"
theme="light" theme="light"
closeOnClick={false} closeOnClick={false}
draggable={false} draggable={false}
toastClassName="text-sm leading-cozy bg-selected-frame rounded-default backdrop-blur-default" toastClassName="text-sm leading-cozy bg-selected-frame rounded-lg backdrop-blur-default"
transition={toastify.Zoom} transition={toastify.Zoom}
limit={3} limit={3}
/> />
<router.BrowserRouter basename={getMainPageUrl().pathname}> <router.BrowserRouter basename={getMainPageUrl().pathname} future={routerFuture}>
<LocalStorageProvider> <LocalStorageProvider>
<ModalProvider> <ModalProvider>
<AppRouter {...props} projectManagerRootDirectory={rootDirectoryPath} /> <AppRouter {...props} projectManagerRootDirectory={rootDirectoryPath} />
@ -205,7 +199,7 @@ export default function App(props: AppProps) {
</router.BrowserRouter> </router.BrowserRouter>
<reactQueryDevtools.ReactQueryDevtools /> <reactQueryDevtools.ReactQueryDevtools />
</reactQuery.QueryClientProvider> </>
) )
} }
@ -393,6 +387,7 @@ function AppRouter(props: AppRouterProps) {
{/* Protected pages are visible to authenticated users. */} {/* Protected pages are visible to authenticated users. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}> <router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.ProtectedLayout />}> <router.Route element={<authProvider.ProtectedLayout />}>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}> <router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
<router.Route <router.Route
path={appUtils.DASHBOARD_PATH} path={appUtils.DASHBOARD_PATH}
@ -410,6 +405,7 @@ function AppRouter(props: AppRouterProps) {
} }
/> />
</router.Route> </router.Route>
</router.Route>
<router.Route <router.Route
path={appUtils.SUBSCRIBE_SUCCESS_PATH} path={appUtils.SUBSCRIBE_SUCCESS_PATH}
@ -424,12 +420,14 @@ function AppRouter(props: AppRouterProps) {
</router.Route> </router.Route>
</router.Route> </router.Route>
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
{/* Semi-protected pages are visible to users currently registering. */} {/* Semi-protected pages are visible to users currently registering. */}
<router.Route element={<authProvider.NotDeletedUserLayout />}> <router.Route element={<authProvider.NotDeletedUserLayout />}>
<router.Route element={<authProvider.SemiProtectedLayout />}> <router.Route element={<authProvider.SemiProtectedLayout />}>
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} /> <router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
</router.Route> </router.Route>
</router.Route> </router.Route>
</router.Route>
{/* Other pages are visible to unauthenticated and authenticated users. */} {/* Other pages are visible to unauthenticated and authenticated users. */}
<router.Route path={appUtils.CONFIRM_REGISTRATION_PATH} element={<ConfirmRegistration />} /> <router.Route path={appUtils.CONFIRM_REGISTRATION_PATH} element={<ConfirmRegistration />} />
@ -447,7 +445,9 @@ function AppRouter(props: AppRouterProps) {
<router.Route path="*" element={<router.Navigate to="/" replace />} /> <router.Route path="*" element={<router.Navigate to="/" replace />} />
</router.Routes> </router.Routes>
) )
let result = routes let result = routes
result = ( result = (
<SupportsLocalBackendProvider supportsLocalBackend={supportsLocalBackend}> <SupportsLocalBackendProvider supportsLocalBackend={supportsLocalBackend}>
{result} {result}

View File

@ -52,6 +52,8 @@ export interface BaseButtonProps extends Omit<twv.VariantProps<typeof BUTTON_STY
* If the handler returns a promise, the button will be in a loading state until the promise resolves. * If the handler returns a promise, the button will be in a loading state until the promise resolves.
*/ */
readonly onPress?: (event: aria.PressEvent) => Promise<void> | void readonly onPress?: (event: aria.PressEvent) => Promise<void> | void
readonly testId?: string
} }
export const BUTTON_STYLES = twv.tv({ export const BUTTON_STYLES = twv.tv({
@ -152,6 +154,7 @@ export const Button = React.forwardRef(function Button(
fullWidth, fullWidth,
rounded, rounded,
tooltip, tooltip,
testId,
onPress = () => {}, onPress = () => {},
...ariaProps ...ariaProps
} = props } = props
@ -163,7 +166,9 @@ export const Button = React.forwardRef(function Button(
const Tag = isLink ? aria.Link : aria.Button const Tag = isLink ? aria.Link : aria.Button
const goodDefaults = isLink ? { rel: 'noopener noreferrer' } : { type: 'button' } const goodDefaults = isLink
? { rel: 'noopener noreferrer', 'data-testid': testId ?? 'link' }
: { type: 'button', 'data-testid': testId ?? 'button' }
const isIconOnly = (children == null || children === '' || children === false) && icon != null const isIconOnly = (children == null || children === '' || children === false) && icon != null
const shouldShowTooltip = isIconOnly && tooltip !== false const shouldShowTooltip = isIconOnly && tooltip !== false
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null

View File

@ -9,6 +9,8 @@ import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal' import * as portal from '#/components/Portal'
import * as mergeRefs from '#/utilities/mergeRefs'
import type * as types from './types' import type * as types from './types'
import * as variants from './variants' import * as variants from './variants'
@ -60,6 +62,7 @@ export function Dialog(props: types.DialogProps) {
className, className,
onOpenChange = () => {}, onOpenChange = () => {},
modalProps = {}, modalProps = {},
testId = 'dialog',
...ariaDialogProps ...ariaDialogProps
} = props } = props
const dialogRef = React.useRef<HTMLDivElement>(null) const dialogRef = React.useRef<HTMLDivElement>(null)
@ -79,7 +82,7 @@ export function Dialog(props: types.DialogProps) {
dialogRef.current?.animate( dialogRef.current?.animate(
[{ transform: 'scale(1)' }, { transform: 'scale(1.015)' }, { transform: 'scale(1)' }], [{ transform: 'scale(1)' }, { transform: 'scale(1.015)' }, { transform: 'scale(1)' }],
{ duration, iterations: 2, direction: 'alternate' } { duration, iterations: 1, direction: 'alternate' }
) )
} }
}, },
@ -106,20 +109,38 @@ export function Dialog(props: types.DialogProps) {
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
{...modalProps} {...modalProps}
> >
<aria.Dialog ref={dialogRef} className={dialogSlots.base()} {...ariaDialogProps}> <aria.Dialog
ref={mergeRefs.mergeRefs(dialogRef, element => {
if (element) {
// This is a workaround for the `data-testid` attribute not being
// supported by the 'react-aria-components' library.
// We need to set the `data-testid` attribute on the dialog element
// so that we can use it in our tests.
// This is a temporary solution until we refactor the Dialog component
// to use `useDialog` hook from the 'react-aria-components' library.
// this will allow us to set the `data-testid` attribute on the dialog
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
element.dataset.testId = testId
}
})}
className={dialogSlots.base()}
{...ariaDialogProps}
>
{opts => ( {opts => (
<> <>
{shouldRenderTitle && ( {shouldRenderTitle && (
<aria.Header className={dialogSlots.header()}> <aria.Header className={dialogSlots.header()}>
<ariaComponents.CloseButton <ariaComponents.CloseButton
className={clsx('mr-auto mt-0.5', { hidden: hideCloseButton })} className={clsx('col-start-1 col-end-1 mr-auto mt-0.5', {
hidden: hideCloseButton,
})}
onPress={opts.close} onPress={opts.close}
/> />
<aria.Heading <aria.Heading
slot="title" slot="title"
level={2} level={2}
className="my-0 text-base font-semibold leading-6" className="col-start-2 col-end-2 my-0 text-base font-semibold leading-6"
> >
{title} {title}
</aria.Heading> </aria.Heading>

View File

@ -15,6 +15,8 @@ export interface DialogProps extends aria.DialogProps {
readonly onOpenChange?: (isOpen: boolean) => void readonly onOpenChange?: (isOpen: boolean) => void
readonly isKeyboardDismissDisabled?: boolean readonly isKeyboardDismissDisabled?: boolean
readonly modalProps?: Pick<aria.ModalOverlayProps, 'className' | 'defaultOpen' | 'isOpen'> readonly modalProps?: Pick<aria.ModalOverlayProps, 'className' | 'defaultOpen' | 'isOpen'>
readonly testId?: string
} }
/** The props for the DialogTrigger component. */ /** The props for the DialogTrigger component. */

View File

@ -43,6 +43,7 @@ export const Form = React.forwardRef(function Form<
onSubmitSuccess = () => {}, onSubmitSuccess = () => {},
onSubmitFailed = () => {}, onSubmitFailed = () => {},
id = formId, id = formId,
testId,
schema, schema,
...formProps ...formProps
} = props } = props
@ -59,7 +60,7 @@ export const Form = React.forwardRef(function Form<
React.useImperativeHandle(formRef, () => innerForm, [innerForm]) React.useImperativeHandle(formRef, () => innerForm, [innerForm])
const formMutation = reactQuery.useMutation({ const formMutation = reactQuery.useMutation({
mutationKey: ['FormSubmit', id], mutationKey: ['FormSubmit', testId, id],
mutationFn: async (fieldValues: TFieldValues) => { mutationFn: async (fieldValues: TFieldValues) => {
try { try {
await onSubmit(fieldValues, innerForm) await onSubmit(fieldValues, innerForm)
@ -82,11 +83,16 @@ export const Form = React.forwardRef(function Form<
// There is no way to avoid type casting here // There is no way to avoid type casting here
// eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument // eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax,@typescript-eslint/no-unsafe-argument
const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any) const formOnSubmit = innerForm.handleSubmit(formMutation.mutateAsync as any)
const { formState, clearErrors, getValues, setValue, setError, register, unregister } = innerForm
const formStateRenderProps = { const formStateRenderProps: types.FormStateRenderProps<TFieldValues> = {
formState: innerForm.formState, formState,
register: innerForm.register, register,
unregister: innerForm.unregister, unregister,
setError,
clearErrors,
getValues,
setValue,
} }
return ( return (
@ -97,6 +103,7 @@ export const Form = React.forwardRef(function Form<
className={typeof className === 'function' ? className(formStateRenderProps) : className} className={typeof className === 'function' ? className(formStateRenderProps) : className}
style={typeof style === 'function' ? style(formStateRenderProps) : style} style={typeof style === 'function' ? style(formStateRenderProps) : style}
noValidate noValidate
data-testid={testId}
{...formProps} {...formProps}
> >
<reactHookForm.FormProvider {...innerForm}> <reactHookForm.FormProvider {...innerForm}>

View File

@ -34,6 +34,7 @@ export function Reset(props: ResetProps): React.JSX.Element {
return ( return (
<ariaComponents.Button <ariaComponents.Button
{...props} {...props}
type="reset"
variant={variant} variant={variant}
size={size} size={size}
isDisabled={formState.isSubmitting} isDisabled={formState.isSubmitting}

View File

@ -38,7 +38,12 @@ export type SubmitProps = Omit<ariaComponents.ButtonProps, 'loading' | 'variant'
* Manages the form state and displays a loading spinner when the form is submitting. * Manages the form state and displays a loading spinner when the form is submitting.
*/ */
export function Submit(props: SubmitProps): React.JSX.Element { export function Submit(props: SubmitProps): React.JSX.Element {
const { form = reactHookForm.useFormContext(), variant = 'submit', size = 'medium' } = props const {
form = reactHookForm.useFormContext(),
variant = 'submit',
size = 'medium',
testId = 'form-submit-button',
} = props
const { formState } = form const { formState } = form
return ( return (
@ -48,6 +53,7 @@ export function Submit(props: SubmitProps): React.JSX.Element {
variant={variant} variant={variant}
size={size} size={size}
loading={formState.isSubmitting} loading={formState.isSubmitting}
testId={testId}
/> />
) )
} }

View File

@ -51,7 +51,7 @@ export function useForm<
return reactHookForm.useForm({ return reactHookForm.useForm({
...options, ...options,
...(schema ? { resolver: zodResolver.zodResolver(schema) } : {}), ...(schema ? { resolver: zodResolver.zodResolver(schema, { async: true }) } : {}),
}) })
} }
} }

View File

@ -3,6 +3,8 @@
* Types for the Form component. * Types for the Form component.
*/ */
import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form' import type * as reactHookForm from 'react-hook-form'
import type * as z from 'zod' import type * as z from 'zod'
@ -51,6 +53,8 @@ interface BaseFormProps<
readonly onSubmitFailed?: (error: unknown) => Promise<void> | void readonly onSubmitFailed?: (error: unknown) => Promise<void> | void
readonly onSubmitSuccess?: () => Promise<void> | void readonly onSubmitSuccess?: () => Promise<void> | void
readonly onSubmitted?: () => Promise<void> | void readonly onSubmitted?: () => Promise<void> | void
readonly testId?: string
} }
/** /**
@ -63,7 +67,7 @@ interface FormPropsWithParentForm<
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
TTransformedValues extends components.FieldValues | undefined = undefined, TTransformedValues extends components.FieldValues | undefined = undefined,
> { > {
readonly form: components.UseFormReturn<TFieldValues, TTransformedValues> readonly form?: components.UseFormReturn<TFieldValues, TTransformedValues>
readonly schema?: never readonly schema?: never
readonly formOptions?: never readonly formOptions?: never
} }
@ -75,7 +79,7 @@ interface FormPropsWithParentForm<
interface FormPropsWithOptions<TFieldValues extends components.FieldValues> { interface FormPropsWithOptions<TFieldValues extends components.FieldValues> {
readonly form?: never readonly form?: never
readonly schema?: z.ZodObject<TFieldValues> readonly schema?: z.ZodObject<TFieldValues>
readonly formOptions: Omit<components.UseFormProps<TFieldValues>, 'resolver'> readonly formOptions?: Omit<components.UseFormProps<TFieldValues>, 'resolver'>
} }
/** /**
@ -96,4 +100,8 @@ export interface FormStateRenderProps<TFieldValues extends components.FieldValue
* Removes a field from the form state. * Removes a field from the form state.
*/ */
readonly unregister: reactHookForm.UseFormUnregister<TFieldValues> readonly unregister: reactHookForm.UseFormUnregister<TFieldValues>
readonly setValue: reactHookForm.UseFormSetValue<TFieldValues>
readonly getValues: reactHookForm.UseFormGetValues<TFieldValues>
readonly setError: reactHookForm.UseFormSetError<TFieldValues>
readonly clearErrors: reactHookForm.UseFormClearErrors<TFieldValues>
} }

View File

@ -5,6 +5,8 @@
*/ */
import * as React from 'react' import * as React from 'react'
import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import * as errorBoundary from 'react-error-boundary' import * as errorBoundary from 'react-error-boundary'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
@ -37,12 +39,22 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
...rest ...rest
} = props } = props
return ( return (
<reactQuery.QueryErrorResetBoundary>
{({ reset }) => (
<errorBoundary.ErrorBoundary <errorBoundary.ErrorBoundary
FallbackComponent={FallbackComponent} FallbackComponent={FallbackComponent}
onError={onError} onError={(error, info) => {
onReset={onReset} sentry.captureException(error, { extra: { info } })
onError(error, info)
}}
onReset={details => {
reset()
onReset(details)
}}
{...rest} {...rest}
/> />
)}
</reactQuery.QueryErrorResetBoundary>
) )
} }

View File

@ -57,6 +57,12 @@ export function useCopy(props: UseCopyProps) {
successToastMessage === true ? getText('copiedToClipboard') : successToastMessage, successToastMessage === true ? getText('copiedToClipboard') : successToastMessage,
{ toastId, closeOnClick: true, hideProgressBar: true, position: 'bottom-right' } { toastId, closeOnClick: true, hideProgressBar: true, position: 'bottom-right' }
) )
// If user closes the toast, reset the button state
toastify.toast.onChange(toast => {
if (toast.id === toastId && toast.status === 'removed') {
copyQuery.reset()
}
})
} }
// Reset the button to its original state after a timeout. // Reset the button to its original state after a timeout.

View File

@ -4,6 +4,7 @@
import * as React from 'react' import * as React from 'react'
import * as sentry from '@sentry/react' import * as sentry from '@sentry/react'
import * as reactQuery from '@tanstack/react-query'
import * as reactDOM from 'react-dom/client' import * as reactDOM from 'react-dom/client'
import * as reactRouter from 'react-router-dom' import * as reactRouter from 'react-router-dom'
@ -11,8 +12,11 @@ import * as detect from 'enso-common/src/detect'
import type * as app from '#/App' import type * as app from '#/App'
import App from '#/App' import App from '#/App'
import * as reactQueryClientModule from '#/reactQueryClient'
import * as loader from '#/components/Loader' import LoadingScreen from '#/pages/authentication/LoadingScreen'
import * as errorBoundary from '#/components/ErrorBoundary'
// ================= // =================
// === Constants === // === Constants ===
@ -79,17 +83,21 @@ function run(props: app.AppProps) {
// `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages // `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages
// via the browser. // via the browser.
const actuallySupportsDeepLinks = supportsDeepLinks && detect.isOnElectron() const actuallySupportsDeepLinks = supportsDeepLinks && detect.isOnElectron()
const queryClient = reactQueryClientModule.createReactQueryClient()
reactDOM.createRoot(root).render( reactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<sentry.ErrorBoundary> <reactQuery.QueryClientProvider client={queryClient}>
<React.Suspense fallback={<loader.Loader size={64} />}> <errorBoundary.ErrorBoundary>
<React.Suspense fallback={<LoadingScreen />}>
{detect.IS_DEV_MODE ? ( {detect.IS_DEV_MODE ? (
<App {...props} /> <App {...props} />
) : ( ) : (
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} /> <App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
)} )}
</React.Suspense> </React.Suspense>
</sentry.ErrorBoundary> </errorBoundary.ErrorBoundary>
</reactQuery.QueryClientProvider>
</React.StrictMode> </React.StrictMode>
) )
} }

View File

@ -0,0 +1,158 @@
/**
* @file
*
* Modal for accepting the terms of service.
*/
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as router from 'react-router'
import * as twMerge from 'tailwind-merge'
import * as z from 'zod'
import * as authProvider from '#/providers/AuthProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import LocalStorage from '#/utilities/LocalStorage'
declare module '#/utilities/LocalStorage' {
/**
* Contains the latest terms of service version hash that the user has accepted.
*/
interface LocalStorageData {
readonly termsOfService: z.infer<typeof TERMS_OF_SERVICE_SCHEMA> | null
}
}
const TERMS_OF_SERVICE_SCHEMA = z.object({ versionHash: z.string() })
LocalStorage.registerKey('termsOfService', { schema: TERMS_OF_SERVICE_SCHEMA })
export const latestTermsOfService = reactQuery.queryOptions({
queryKey: ['termsOfService', 'currentVersion'],
queryFn: () =>
fetch(new URL('/eula.json', process.env.ENSO_CLOUD_ENSO_HOST))
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch terms of service')
} else {
return response.json()
}
})
.then(data => {
const schema = z.object({ hash: z.string() })
return schema.parse(data)
}),
refetchOnWindowFocus: true,
refetchIntervalInBackground: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
refetchInterval: 1000 * 60 * 10, // 10 minutes
})
/**
* Modal for accepting the terms of service.
*/
export function TermsOfServiceModal() {
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const checkboxId = React.useId()
const { session } = authProvider.useAuth()
const eula = reactQuery.useSuspenseQuery(latestTermsOfService)
const latestVersionHash = eula.data.hash
const localVersionHash = localStorage.get('termsOfService')?.versionHash
const isLatest = latestVersionHash === localVersionHash
const isAccepted = localVersionHash != null
const shouldDisplay = !(isAccepted && isLatest)
if (shouldDisplay) {
return (
<>
<ariaComponents.Dialog
title={getText('licenseAgreementTitle')}
isKeyboardDismissDisabled
isDismissable={false}
hideCloseButton
modalProps={{ isOpen: true }}
testId="terms-of-service-modal"
id="terms-of-service-modal"
>
<ariaComponents.Form
testId="terms-of-service-form"
schema={ariaComponents.Form.schema.object({
agree: ariaComponents.Form.schema
.boolean()
// we accept only true
.refine(value => value, getText('licenseAgreementCheckboxError')),
})}
onSubmit={() => {
localStorage.set('termsOfService', { versionHash: latestVersionHash })
}}
>
{({ register, formState }) => {
const agreeError = formState.errors.agree
const hasError = formState.errors.agree != null
const checkboxRegister = register('agree')
return (
<>
<div className="pb-6 pt-2">
<div className="mb-1">
<div className="flex items-center gap-1.5 text-sm">
<div className="mt-0">
<aria.Input
type="checkbox"
className={twMerge.twMerge(
`flex size-4 cursor-pointer overflow-clip rounded-lg border border-primary outline-primary focus-visible:outline focus-visible:outline-2 ${hasError ? 'border-red-700 text-red-500 outline-red-500' : ''}`
)}
id={checkboxId}
aria-invalid={hasError}
{...checkboxRegister}
onInput={event => {
void checkboxRegister.onChange(event)
}}
data-testid="terms-of-service-checkbox"
/>
</div>
<aria.Label htmlFor={checkboxId} className="text-sm">
{getText('licenseAgreementCheckbox')}
</aria.Label>
</div>
{agreeError && (
<p className="m-0 text-xs text-red-700" role="alert">
{agreeError.message}
</p>
)}
</div>
<ariaComponents.Button
variant="link"
target="_blank"
href="https://enso.org/eula"
>
{getText('viewLicenseAgreement')}
</ariaComponents.Button>
</div>
<ariaComponents.Form.FormError />
<ariaComponents.Form.Submit>{getText('accept')}</ariaComponents.Form.Submit>
</>
)
}}
</ariaComponents.Form>
</ariaComponents.Dialog>
</>
)
} else {
return <router.Outlet context={session} />
}
}

View File

@ -2,6 +2,8 @@
* via the shared React context. */ * via the shared React context. */
import * as React from 'react' import * as React from 'react'
import * as refreshHooks from '#/hooks/refreshHooks'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
// =========================== // ===========================
@ -27,7 +29,15 @@ export interface LocalStorageProviderProps extends Readonly<React.PropsWithChild
/** A React Provider that lets components get the shortcut registry. */ /** A React Provider that lets components get the shortcut registry. */
export default function LocalStorageProvider(props: LocalStorageProviderProps) { export default function LocalStorageProvider(props: LocalStorageProviderProps) {
const { children } = props const { children } = props
const localStorage = React.useMemo(() => new LocalStorage(), []) const [, doRefresh] = refreshHooks.useRefresh()
const localStorage = React.useMemo(
() =>
new LocalStorage(() => {
doRefresh()
}),
[doRefresh]
)
return ( return (
<LocalStorageContext.Provider value={{ localStorage }}>{children}</LocalStorageContext.Provider> <LocalStorageContext.Provider value={{ localStorage }}>{children}</LocalStorageContext.Provider>

View File

@ -183,6 +183,8 @@
"reset": "Reset", "reset": "Reset",
"members": "Members", "members": "Members",
"drop": "Drop", "drop": "Drop",
"accept": "Accept",
"reject": "Reject",
"clearTrash": "Clear Trash", "clearTrash": "Clear Trash",
"sharedWith": "Shared with", "sharedWith": "Shared with",
"editSecret": "Edit Secret", "editSecret": "Edit Secret",
@ -457,6 +459,11 @@
"subscribeSuccessTitle": "Success", "subscribeSuccessTitle": "Success",
"subscribeSuccessSubtitle": "We received your payment and now you on $0 plan", "subscribeSuccessSubtitle": "We received your payment and now you on $0 plan",
"licenseAgreementTitle": "Enso Terms of Service",
"licenseAgreementCheckbox": "I agree to the Enso Terms of Service",
"licenseAgreementCheckboxError": "You must agree to the Enso Terms of Service",
"viewLicenseAgreement": "View Terms of Service",
"metaModifier": "Meta", "metaModifier": "Meta",
"shiftModifier": "Shift", "shiftModifier": "Shift",
"altModifier": "Alt", "altModifier": "Alt",

View File

@ -1,4 +1,6 @@
/** @file A LocalStorage data manager. */ /** @file A LocalStorage data manager. */
import type * as z from 'zod'
import * as common from 'enso-common' import * as common from 'enso-common'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
@ -8,10 +10,36 @@ import * as object from '#/utilities/object'
// ==================== // ====================
/** Metadata describing runtime behavior associated with a {@link LocalStorageKey}. */ /** Metadata describing runtime behavior associated with a {@link LocalStorageKey}. */
export interface LocalStorageKeyMetadata<K extends LocalStorageKey> { export type LocalStorageKeyMetadata<K extends LocalStorageKey> =
| LocalStorageKeyMetadataWithParseFunction<K>
| LocalStorageKeyMetadataWithSchema<K>
/**
* A {@link LocalStorageKeyMetadata} with a `tryParse` function.
*/
interface LocalStorageKeyMetadataWithParseFunction<K extends LocalStorageKey> {
readonly isUserSpecific?: boolean readonly isUserSpecific?: boolean
/** A type-safe way to deserialize a value from `localStorage`. */ /**
* A function to parse a value from the stored data.
* If this is provided, the value will be parsed using this function.
* If this is not provided, the value will be parsed using the `schema`.
*/
readonly tryParse: (value: unknown) => LocalStorageData[K] | null readonly tryParse: (value: unknown) => LocalStorageData[K] | null
readonly schema?: never
}
/**
* A {@link LocalStorageKeyMetadata} with a `schema`.
*/
interface LocalStorageKeyMetadataWithSchema<K extends LocalStorageKey> {
readonly isUserSpecific?: boolean
/**
* The Zod schema to validate the value.
* If this is provided, the value will be parsed using this schema.
* If this is not provided, the value will be parsed using the `tryParse` function.
*/
readonly schema: z.ZodType<LocalStorageData[K]>
readonly tryParse?: never
} }
/** The data that can be stored in a {@link LocalStorage}. /** The data that can be stored in a {@link LocalStorage}.
@ -31,15 +59,18 @@ export default class LocalStorage {
protected values: Partial<LocalStorageData> protected values: Partial<LocalStorageData>
/** Create a {@link LocalStorage}. */ /** Create a {@link LocalStorage}. */
constructor() { constructor(private readonly triggerRerender: () => void) {
const savedValues: unknown = JSON.parse(localStorage.getItem(this.localStorageKey) ?? '{}') const savedValues: unknown = JSON.parse(localStorage.getItem(this.localStorageKey) ?? '{}')
this.values = {} this.values = {}
if (typeof savedValues === 'object' && savedValues != null) { if (typeof savedValues === 'object' && savedValues != null) {
for (const [key, metadata] of object.unsafeEntries(LocalStorage.keyMetadata)) { for (const [key, metadata] of object.unsafeEntries(LocalStorage.keyMetadata)) {
if (key in savedValues) { if (key in savedValues) {
// This is SAFE, as it is guarded by the `key in savedValues` check. // This is SAFE, as it is guarded by the `key in savedValues` check.
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
const value = metadata.tryParse((savedValues as any)[key]) const savedValue = (savedValues as any)[key]
const value = metadata.schema
? metadata.schema.safeParse(savedValue).data
: metadata.tryParse(savedValue)
if (value != null) { if (value != null) {
// This is SAFE, as the `tryParse` function is required by definition to // This is SAFE, as the `tryParse` function is required by definition to
// return a value of the correct type. // return a value of the correct type.
@ -89,5 +120,6 @@ export default class LocalStorage {
/** Save the current value of the stored data.. */ /** Save the current value of the stored data.. */
protected save() { protected save() {
localStorage.setItem(this.localStorageKey, JSON.stringify(this.values)) localStorage.setItem(this.localStorageKey, JSON.stringify(this.values))
this.triggerRerender()
} }
} }

View File

@ -199,6 +199,8 @@ declare global {
readonly ENSO_CLOUD_DASHBOARD_COMMIT_HASH?: string readonly ENSO_CLOUD_DASHBOARD_COMMIT_HASH?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars. // @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_SUPPORTS_VIBRANCY?: string readonly ENSO_SUPPORTS_VIBRANCY?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_ENSO_HOST?: string
// === Electron watch script variables === // === Electron watch script variables ===