mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 22:10:15 +03:00
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:
parent
a0a6f8c302
commit
5ed5c71e93
@ -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
|
||||||
|
@ -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 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
if (supportsLocalBackend) {
|
||||||
|
const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`)
|
||||||
|
const text = await response.text()
|
||||||
|
return projectManager.Path(text)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
React.useEffect(() => {
|
const routerFuture: Partial<router.FutureConfig> = {
|
||||||
if (supportsLocalBackend) {
|
/* we want to use startTransition to enable concurrent rendering */
|
||||||
void (async () => {
|
/* eslint-disable-next-line @typescript-eslint/naming-convention */
|
||||||
try {
|
v7_startTransition: true,
|
||||||
const response = await fetch(`${appBaseUrl.APP_BASE_URL}/api/root-directory`)
|
}
|
||||||
const text = await response.text()
|
|
||||||
setRootDirectoryPath(projectManager.Path(text))
|
|
||||||
} catch (innerError) {
|
|
||||||
setError(innerError)
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
}
|
|
||||||
}, [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,22 +387,24 @@ 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={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||||
<router.Route
|
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||||
path={appUtils.DASHBOARD_PATH}
|
<router.Route
|
||||||
element={shouldShowDashboard && <Dashboard {...props} />}
|
path={appUtils.DASHBOARD_PATH}
|
||||||
/>
|
element={shouldShowDashboard && <Dashboard {...props} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<router.Route
|
<router.Route
|
||||||
path={appUtils.SUBSCRIBE_PATH}
|
path={appUtils.SUBSCRIBE_PATH}
|
||||||
element={
|
element={
|
||||||
<errorBoundary.ErrorBoundary>
|
<errorBoundary.ErrorBoundary>
|
||||||
<React.Suspense fallback={<loader.Loader />}>
|
<React.Suspense fallback={<loader.Loader />}>
|
||||||
<subscribe.Subscribe />
|
<subscribe.Subscribe />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</errorBoundary.ErrorBoundary>
|
</errorBoundary.ErrorBoundary>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</router.Route>
|
||||||
</router.Route>
|
</router.Route>
|
||||||
|
|
||||||
<router.Route
|
<router.Route
|
||||||
@ -424,10 +420,12 @@ function AppRouter(props: AppRouterProps) {
|
|||||||
</router.Route>
|
</router.Route>
|
||||||
</router.Route>
|
</router.Route>
|
||||||
|
|
||||||
{/* Semi-protected pages are visible to users currently registering. */}
|
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
{/* Semi-protected pages are visible to users currently registering. */}
|
||||||
<router.Route element={<authProvider.SemiProtectedLayout />}>
|
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||||
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
|
<router.Route element={<authProvider.SemiProtectedLayout />}>
|
||||||
|
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
|
||||||
|
</router.Route>
|
||||||
</router.Route>
|
</router.Route>
|
||||||
</router.Route>
|
</router.Route>
|
||||||
|
|
||||||
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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>
|
||||||
|
@ -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. */
|
||||||
|
@ -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}>
|
||||||
|
@ -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}
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 }) } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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>
|
||||||
}
|
}
|
||||||
|
@ -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 (
|
||||||
<errorBoundary.ErrorBoundary
|
<reactQuery.QueryErrorResetBoundary>
|
||||||
FallbackComponent={FallbackComponent}
|
{({ reset }) => (
|
||||||
onError={onError}
|
<errorBoundary.ErrorBoundary
|
||||||
onReset={onReset}
|
FallbackComponent={FallbackComponent}
|
||||||
{...rest}
|
onError={(error, info) => {
|
||||||
/>
|
sentry.captureException(error, { extra: { info } })
|
||||||
|
onError(error, info)
|
||||||
|
}}
|
||||||
|
onReset={details => {
|
||||||
|
reset()
|
||||||
|
onReset(details)
|
||||||
|
}}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</reactQuery.QueryErrorResetBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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>
|
||||||
{detect.IS_DEV_MODE ? (
|
<React.Suspense fallback={<LoadingScreen />}>
|
||||||
<App {...props} />
|
{detect.IS_DEV_MODE ? (
|
||||||
) : (
|
<App {...props} />
|
||||||
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
|
) : (
|
||||||
)}
|
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
|
||||||
</React.Suspense>
|
)}
|
||||||
</sentry.ErrorBoundary>
|
</React.Suspense>
|
||||||
|
</errorBoundary.ErrorBoundary>
|
||||||
|
</reactQuery.QueryClientProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
158
app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx
Normal file
158
app/ide-desktop/lib/dashboard/src/modals/TermsOfServiceModal.tsx
Normal 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} />
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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",
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2
app/ide-desktop/lib/types/globals.d.ts
vendored
2
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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 ===
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user