E2E tests for setup flow (#11148)

- Add E2E tests for setup flow
- Remove `styled.Checkbox` in favor of `ariaComponents.Checkbox`
- Remove `styled.Input` in favor of `ariaComponents.Input`
- Remove `styled.Button` in favor of `ariaComponents.Button`
- Rename unnecessary `Settings` prefix from components in `Settings/`

# Important Notes
None
This commit is contained in:
somebody1234 2024-09-27 17:05:10 +10:00 committed by GitHub
parent 612289e498
commit f0d02de5c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 1982 additions and 1898 deletions

View File

@ -62,7 +62,7 @@
"request": "launch",
"name": "Dashboard (E2E UI)",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test:e2e:debug"],
"runtimeArgs": ["run", "--filter", "enso-dashboard", "test-dev:e2e"],
"outputCapture": "std"
},
{

View File

@ -2,7 +2,7 @@
/** @file Various actions, locators, and constants used in end-to-end tests. */
import * as test from '@playwright/test'
import * as text from 'enso-common/src/text'
import { TEXTS } from 'enso-common/src/text'
import DrivePageActions from './actions/DrivePageActions'
import LoginPageActions from './actions/LoginPageActions'
@ -20,7 +20,7 @@ export const INVALID_PASSWORD = 'password'
export const VALID_PASSWORD = 'Password0!'
/** An example valid email address. */
export const VALID_EMAIL = 'email@example.com'
export const TEXT = text.TEXTS.english
export const TEXT = TEXTS.english
// ================
// === Locators ===
@ -43,11 +43,6 @@ export function locateConfirmPasswordInput(page: test.Locator | test.Page) {
return page.getByPlaceholder('Confirm your password')
}
/** Find a "username" input (if any) on the current page. */
export function locateUsernameInput(page: test.Locator | test.Page) {
return page.getByPlaceholder('Enter your username')
}
/** Find a "name" input for a "new label" modal (if any) on the current page. */
export function locateNewLabelModalNameInput(page: test.Page) {
return locateNewLabelModal(page).getByLabel('Name').and(page.getByRole('textbox'))
@ -95,11 +90,6 @@ export function locateRegisterButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Register' }).getByText('Register')
}
/** Find a "set username" button (if any) on the current page. */
export function locateSetUsernameButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username')
}
/** Find a "create" button (if any) on the current page. */
export function locateCreateButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Create' }).getByText('Create')
@ -151,37 +141,37 @@ export function locateAssetLabels(page: test.Locator | test.Page) {
/** Find a toggle for the "Name" column (if any) on the current page. */
export function locateNameColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Name')
return page.getByLabel('Name')
}
/** Find a toggle for the "Modified" column (if any) on the current page. */
export function locateModifiedColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Modified')
return page.getByLabel('Modified')
}
/** Find a toggle for the "Shared with" column (if any) on the current page. */
export function locateSharedWithColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Shared With')
return page.getByLabel('Shared With')
}
/** Find a toggle for the "Labels" column (if any) on the current page. */
export function locateLabelsColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Labels')
return page.getByLabel('Labels')
}
/** Find a toggle for the "Accessed by projects" column (if any) on the current page. */
export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Accessed By Projects')
return page.getByLabel('Accessed By Projects')
}
/** Find a toggle for the "Accessed data" column (if any) on the current page. */
export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Accessed Data')
return page.getByLabel('Accessed Data')
}
/** Find a toggle for the "Docs" column (if any) on the current page. */
export function locateDocsColumnToggle(page: test.Locator | test.Page) {
return page.getByAltText('Docs')
return page.getByLabel('Docs')
}
/** Find a button for the "Recent" category (if any) on the current page. */
@ -355,7 +345,7 @@ export function locateUpsertSecretModal(page: test.Page) {
/** Find a user menu (if any) on the current page. */
export function locateUserMenu(page: test.Page) {
return page.getByAltText('User Settings').locator('visible=true')
return page.getByLabel(TEXT.userMenuLabel).and(page.getByRole('button')).locator('visible=true')
}
/** Find a "set username" panel (if any) on the current page. */
@ -718,9 +708,9 @@ export async function press(page: test.Page, keyOrShortcut: string) {
})
}
// =============
// === login ===
// =============
// ===============================
// === Miscellaneous utilities ===
// ===============================
/** Perform a successful login. */
// This syntax is required for Playwright to work properly.
@ -735,32 +725,24 @@ export async function login(
await locateEmailInput(page).fill(email)
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
if (first) {
await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
})
}
// ==============
// === reload ===
// ==============
/** Reload. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function reload({ page }: MockParams) {
await test.test.step('Reload', async () => {
await page.reload()
await test.expect(page.getByText('Logging in to Enso...')).not.toBeVisible()
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
})
}
// =============
// === relog ===
// =============
/** Logout and then login again. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
@ -770,16 +752,15 @@ export async function relog(
password = VALID_PASSWORD,
) {
await test.test.step('Relog', async () => {
await page.getByAltText('User Settings').locator('visible=true').click()
await page.getByRole('button', { name: 'Logout' }).getByText('Logout').click()
await page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click()
await page
.getByRole('button', { name: TEXT.signOutShortcut })
.getByText(TEXT.signOutShortcut)
.click()
await login({ page, setupAPI }, email, password, false)
})
}
// ================
// === mockDate ===
// ================
/** A placeholder date for visual regression testing. */
const MOCK_DATE = Number(new Date('01/23/45 01:23:45'))
@ -816,24 +797,22 @@ async function mockDate({ page }: MockParams) {
export async function passAgreementsDialog({ page }: MockParams) {
await test.test.step('Accept Terms and Conditions', async () => {
await page.waitForSelector('#agreements-modal')
await page.getByTestId('terms-of-service-checkbox').click()
await page.getByTestId('privacy-policy-checkbox').click()
await page
.getByRole('group', { name: TEXT.licenseAgreementCheckbox })
.getByText(TEXT.licenseAgreementCheckbox)
.click()
await page
.getByRole('group', { name: TEXT.privacyPolicyCheckbox })
.getByText(TEXT.privacyPolicyCheckbox)
.click()
await page.getByRole('button', { name: 'Accept' }).click()
})
}
// ===============
// === mockApi ===
// ===============
// This is a function, even though it does not use function syntax.
// eslint-disable-next-line no-restricted-syntax
export const mockApi = apiModule.mockApi
// ===============
// === mockAll ===
// ===============
/** Set up all mocks, without logging in. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
@ -845,10 +824,6 @@ export function mockAll({ page, setupAPI }: MockParams) {
})
}
// =======================
// === mockAllAndLogin ===
// =======================
/** Set up all mocks, and log in with dummy credentials. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
@ -862,10 +837,6 @@ export function mockAllAndLogin({ page, setupAPI }: MockParams) {
.do((thePage) => login({ page: thePage, setupAPI }))
}
// ===================================
// === mockAllAndLoginAndExposeAPI ===
// ===================================
/** Set up all mocks, and log in with dummy credentials.
* @deprecated Prefer {@link mockAllAndLogin}. */
// This syntax is required for Playwright to work properly.

View File

@ -65,6 +65,7 @@ export default class BaseActions implements Promise<void> {
}
})
}
/** Proxies the `then` method of the internal {@link Promise}. */
async then<T, E>(
// The following types are copied almost verbatim from the type definitions for `Promise`.

View File

@ -190,37 +190,37 @@ export default class DrivePageActions extends PageActions {
/** Toggle visibility for the "modified" column. */
modified() {
return self.step('Toggle "modified" column', (page) =>
page.getByAltText(TEXT.modifiedColumnName).click(),
page.getByLabel(TEXT.modifiedColumnName).click(),
)
},
/** Toggle visibility for the "shared with" column. */
sharedWith() {
return self.step('Toggle "shared with" column', (page) =>
page.getByAltText(TEXT.sharedWithColumnName).click(),
page.getByLabel(TEXT.sharedWithColumnName).click(),
)
},
/** Toggle visibility for the "labels" column. */
labels() {
return self.step('Toggle "labels" column', (page) =>
page.getByAltText(TEXT.labelsColumnName).click(),
page.getByLabel(TEXT.labelsColumnName).click(),
)
},
/** Toggle visibility for the "accessed by projects" column. */
accessedByProjects() {
return self.step('Toggle "accessed by projects" column', (page) =>
page.getByAltText(TEXT.accessedByProjectsColumnName).click(),
page.getByLabel(TEXT.accessedByProjectsColumnName).click(),
)
},
/** Toggle visibility for the "accessed data" column. */
accessedData() {
return self.step('Toggle "accessed data" column', (page) =>
page.getByAltText(TEXT.accessedDataColumnName).click(),
page.getByLabel(TEXT.accessedDataColumnName).click(),
)
},
/** Toggle visibility for the "docs" column. */
docs() {
return self.step('Toggle "docs" column', (page) =>
page.getByAltText(TEXT.docsColumnName).click(),
page.getByLabel(TEXT.docsColumnName).click(),
)
},
}

View File

@ -1,12 +1,12 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '../actions'
import { TEXT, VALID_EMAIL, VALID_PASSWORD, passAgreementsDialog } from '../actions'
import BaseActions, { type LocatorCallback } from './BaseActions'
import DrivePageActions from './DrivePageActions'
import ForgotPasswordPageActions from './ForgotPasswordPageActions'
import RegisterPageActions from './RegisterPageActions'
import SetupPageActions from './SetupPageActions'
import SetupUsernamePageActions from './SetupUsernamePageActions'
// ========================
// === LoginPageActions ===
@ -35,9 +35,10 @@ export default class LoginPageActions extends BaseActions {
/** Perform a login as a new user (a user that does not yet have a username). */
loginAsNewUser(email = VALID_EMAIL, password = VALID_PASSWORD) {
return this.step('Login (as new user)', () => this.loginInternal(email, password)).into(
SetupPageActions,
)
return this.step('Login (as new user)', async (page) => {
await this.loginInternal(email, password)
await passAgreementsDialog({ page })
}).into(SetupUsernamePageActions)
}
/** Perform a failing login. */

View File

@ -83,8 +83,14 @@ export default class RegisterPageActions extends BaseActions {
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
await this.page.getByPlaceholder(TEXT.confirmPasswordPlaceholder).fill(confirmPassword)
await this.page.getByTestId('terms-of-service-checkbox').click()
await this.page.getByTestId('privacy-policy-checkbox').click()
await this.page
.getByRole('group', { name: TEXT.licenseAgreementCheckbox })
.getByText(TEXT.licenseAgreementCheckbox)
.click()
await this.page
.getByRole('group', { name: TEXT.privacyPolicyCheckbox })
.getByText(TEXT.privacyPolicyCheckbox)
.click()
await this.page
.getByRole('button', { name: TEXT.register, exact: true })
.getByText(TEXT.register)

View File

@ -0,0 +1,21 @@
/** @file Actions for the fourth step of the "setup" page. */
import { TEXT } from '../actions'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
// ============================
// === SetupDonePageActions ===
// ============================
/** Actions for the fourth step of the "setup" page. */
export default class SetupDonePageActions extends BaseActions {
/** Go to the drive page. */
get goToPage() {
return {
drive: () =>
this.step("Finish setup and go to 'drive' page", async (page) => {
await page.getByText(TEXT.goToDashboard).click()
}).into(DrivePageActions),
}
}
}

View File

@ -0,0 +1,26 @@
/** @file Actions for the third step of the "setup" page. */
import { TEXT } from '../actions'
import BaseActions from './BaseActions'
import SetupTeamPageActions from './SetupTeamPageActions'
// ==============================
// === SetupInvitePageActions ===
// ==============================
/** Actions for the "invite users" step of the "setup" page. */
export default class SetupInvitePageActions extends BaseActions {
/** Invite users by email. */
inviteUsers(emails: string) {
return this.step(`Invite users '${emails.split(/[ ;,]+/).join("', '")}'`, async (page) => {
await page.getByLabel(TEXT.inviteEmailFieldLabel).getByRole('textbox').fill(emails)
await page.getByText(TEXT.inviteSubmit).click()
}).into(SetupTeamPageActions)
}
/** Continue to the next step without inviting users. */
skipInvitingUsers() {
return this.step('Skip inviting users in setup', async (page) => {
await page.getByText(TEXT.skip).click()
}).into(SetupTeamPageActions)
}
}

View File

@ -0,0 +1,22 @@
/** @file Actions for the third step of the "setup" page. */
import { TEXT } from '../actions'
import BaseActions from './BaseActions'
import SetupInvitePageActions from './SetupInvitePageActions'
// ====================================
// === SetupOrganizationPageActions ===
// ====================================
/** Actions for the third step of the "setup" page. */
export default class SetupOrganizationPageActions extends BaseActions {
/** Set the organization name for this organization. */
setOrganizationName(organizationName: string) {
return this.step(`Set organization name to '${organizationName}'`, async (page) => {
await page
.getByLabel(TEXT.organizationNameSettingsInput)
.and(page.getByRole('textbox'))
.fill(organizationName)
await page.getByText(TEXT.next).click()
}).into(SetupInvitePageActions)
}
}

View File

@ -1,19 +0,0 @@
/** @file Actions for the "setup" page. */
import * as actions from '../actions'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
// ==============================
// === SetUsernamePageActions ===
// ==============================
/** Actions for the "set username" page. */
export default class SetupPageActions extends BaseActions {
/** Set the userame for a new user that does not yet have a username. */
setUsername(username: string) {
return this.step(`Set username to '${username}'`, async (page) => {
await actions.locateUsernameInput(page).fill(username)
await actions.locateSetUsernameButton(page).click()
}).into(DrivePageActions)
}
}

View File

@ -0,0 +1,59 @@
/** @file Actions for the second step of the "setup" page. */
import { PLAN_TO_UPGRADE_LABEL_ID } from '#/modules/payments/constants'
import { Plan } from 'enso-common/src/services/Backend'
import { TEXT } from '../actions'
import BaseActions from './BaseActions'
import SetupDonePageActions from './SetupDonePageActions'
import SetupOrganizationPageActions from './SetupOrganizationPageActions'
// ============================
// === SetupPlanPageActions ===
// ============================
/** Actions for the "select plan" step of the "setup" page. */
export default class SetupPlanPageActions extends BaseActions {
/** Select a plan. */
selectSoloPlan() {
return this.step(`Select 'solo' plan`, async (page) => {
await page.getByLabel(TEXT[PLAN_TO_UPGRADE_LABEL_ID[Plan.solo]]).click()
await page
.getByRole('group', { name: TEXT.licenseAgreementCheckbox })
.getByText(TEXT.licenseAgreementCheckbox)
.click()
await page.getByText(TEXT.startTrial).click()
}).into(SetupDonePageActions)
}
/** Select a plan that has teams. */
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
selectTeamPlan(plan: Plan.enterprise | Plan.team, seats = 1, duration: 12 | 36 = 12) {
return this.step(`Select '${plan}' plan`, async (page) => {
await page.getByLabel(TEXT[PLAN_TO_UPGRADE_LABEL_ID[plan]]).click()
await page
.getByRole('group', { name: TEXT.licenseAgreementCheckbox })
.getByText(TEXT.licenseAgreementCheckbox)
.click()
await page.getByLabel(TEXT.seats).getByRole('spinbutton').fill(String(seats))
await page
.getByLabel(TEXT.billingPeriod)
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
.getByText(duration === 12 ? TEXT.billingPeriodOneYear : TEXT.billingPeriodThreeYears)
.click()
await page.getByText(TEXT.startTrial).click()
}).into(SetupOrganizationPageActions)
}
/** Stay on the current (free) plan. */
stayOnFreePlan() {
return this.step(`Stay on current plan`, async (page) => {
await page.getByText(TEXT.skip).click()
}).into(SetupDonePageActions)
}
/** Stay on the current (paid) plan. */
stayOnPaidPlan() {
return this.step(`Stay on current plan`, async (page) => {
await page.getByText(TEXT.skip).click()
}).into(SetupOrganizationPageActions)
}
}

View File

@ -0,0 +1,22 @@
/** @file Actions for the "setup" page. */
import { TEXT } from '../actions'
import BaseActions from './BaseActions'
import SetupDonePageActions from './SetupDonePageActions'
// ================================
// === SetupTeamNamePageActions ===
// ================================
/** Actions for the "setup team name" page. */
export default class SetupTeamNamePagePageActions extends BaseActions {
/** Set the username for a new user that does not yet have a username. */
setTeamName(teamName: string) {
return this.step(`Set team name to '${teamName}'`, async (page) => {
await page
.getByLabel(TEXT.groupNameSettingsInput)
.and(page.getByRole('textbox'))
.fill(teamName)
await page.getByText(TEXT.next).click()
}).into(SetupDonePageActions)
}
}

View File

@ -0,0 +1,19 @@
/** @file Actions for the "setup" page. */
import { TEXT } from '../actions'
import BaseActions from './BaseActions'
import SetupPlanPageActions from './SetupPlanPageActions'
// ================================
// === SetupUsernamePageActions ===
// ================================
/** Actions for the "setup" page. */
export default class SetupUsernamePageActions extends BaseActions {
/** Set the username for a new user that does not yet have a username. */
setUsername(username: string) {
return this.step(`Set username to '${username}'`, async (page) => {
await page.getByPlaceholder(TEXT.usernamePlaceholder).fill(username)
await page.getByText(TEXT.next).click()
}).into(SetupPlanPageActions)
}
}

View File

@ -1,6 +1,7 @@
/** @file An action to open the User Menu. */
import type * as baseActions from './BaseActions'
import { TEXT } from '../actions'
import type BaseActions from './BaseActions'
import type { PageCallback } from './BaseActions'
// ==========================
// === openUserMenuAction ===
@ -8,9 +9,9 @@ import type BaseActions from './BaseActions'
/** An action to open the User Menu. */
export function openUserMenuAction<T extends BaseActions>(
step: (name: string, callback: baseActions.PageCallback) => T,
step: (name: string, callback: PageCallback) => T,
) {
return step('Open user menu', (page) =>
page.getByAltText('User Settings').locator('visible=true').click(),
page.getByLabel(TEXT.userMenuLabel).locator('visible=true').click(),
)
}

View File

@ -24,6 +24,8 @@ const HTTP_STATUS_NO_CONTENT = 204
const HTTP_STATUS_BAD_REQUEST = 400
/** The HTTP status code representing a URL that does not exist. */
const HTTP_STATUS_NOT_FOUND = 404
/** A user id that is a path glob. */
const GLOB_USER_ID = backend.UserId('*')
/** An asset ID that is a path glob. */
const GLOB_ASSET_ID: backend.AssetId = backend.DirectoryId('*')
/** A directory ID that is a path glob. */
@ -32,6 +34,8 @@ const GLOB_DIRECTORY_ID = backend.DirectoryId('*')
const GLOB_PROJECT_ID = backend.ProjectId('*')
/** A tag ID that is a path glob. */
const GLOB_TAG_ID = backend.TagId('*')
/** A checkout session ID that is a path glob. */
const GLOB_CHECKOUT_SESSION_ID = backend.CheckoutSessionId('*')
/* eslint-enable no-restricted-syntax */
const BASE_URL = 'https://mock/'
@ -92,6 +96,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
website: null,
subscription: {},
}
let totalSeats = 1
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let subscriptionDuration = 0
let isOnline = true
let currentUser: backend.User | null = defaultUser
@ -116,6 +123,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
},
]
const checkoutSessionsMap = new Map<
backend.CheckoutSessionId,
{
readonly body: backend.CreateCheckoutSessionRequestBody
readonly status: backend.CheckoutSessionStatus
}
>()
usersMap.set(defaultUser.userId, defaultUser)
const addAsset = <T extends backend.AnyAsset>(asset: T) => {
@ -257,6 +271,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
}
const createCheckoutSession = (
body: backend.CreateCheckoutSessionRequestBody,
rest: Partial<backend.CheckoutSessionStatus> = {},
) => {
const id = backend.CheckoutSessionId(`checkoutsession-${uniqueString.uniqueString()}`)
const status = rest.status ?? 'trialing'
const paymentStatus = status === 'trialing' ? 'no_payment_needed' : 'unpaid'
const checkoutSessionStatus = {
status,
paymentStatus,
...rest,
} satisfies backend.CheckoutSessionStatus
checkoutSessionsMap.set(id, { body, status: checkoutSessionStatus })
return {
id,
clientSecret: '',
} satisfies backend.CheckoutSession
}
const addUser = (name: string, rest: Partial<backend.User> = {}) => {
const organizationId = currentOrganization?.id ?? defaultOrganizationId
const user: backend.User = {
@ -287,7 +320,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
}
const addUserGroup = (name: string, rest: Partial<backend.UserGroupInfo>) => {
const addUserGroup = (name: string, rest?: Partial<backend.UserGroupInfo>) => {
const userGroup: backend.UserGroupInfo = {
id: backend.UserGroupId(`usergroup-${uniqueString.uniqueString()}`),
groupName: name,
@ -308,10 +341,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
}
// FIXME[sb]: Add missing endpoints:
// addPermission,
// deletePermission,
// addUserGroupToUser,
// deleteUserGroupFromUser,
const addUserGroupToUser = (userId: backend.UserId, userGroupId: backend.UserGroupId) => {
const user = usersMap.get(userId)
if (user == null || user.userGroups?.includes(userGroupId) === true) {
@ -432,7 +465,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
await page.route(BASE_URL + '**', (_route, request) => {
throw new Error(`Missing route handler for '${request.url().replace(BASE_URL, '')}'.`)
throw new Error(
`Missing route handler for '${request.method()} ${request.url().replace(BASE_URL, '')}'.`,
)
})
// === Mock Cognito endpoints ===
@ -589,8 +624,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
// eslint-disable-next-line no-restricted-syntax
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
const asset =
assetId != null ? assetMap.get(backend.DirectoryId(decodeURIComponent(assetId))) : null
if (asset == null) {
if (assetId == null) {
await route.fulfill({
@ -626,13 +663,11 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await get(remoteBackendPaths.INVITATION_PATH + '*', async (route) => {
await route.fulfill({
json: {
invitations: [],
availableLicenses: 0,
} satisfies backend.ListInvitationsResponseBody,
})
await get(remoteBackendPaths.INVITATION_PATH + '*', (): backend.ListInvitationsResponseBody => {
return {
invitations: [],
availableLicenses: totalSeats - usersMap.size,
}
})
await post(remoteBackendPaths.INVITE_USER_PATH + '*', async (route) => {
await route.fulfill()
@ -724,6 +759,33 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
// === Other endpoints ===
await post(remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH + '*', async (_route, request) => {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.CreateCheckoutSessionRequestBody = await request.postDataJSON()
return createCheckoutSession(body)
})
await get(
remoteBackendPaths.getCheckoutSessionPath(GLOB_CHECKOUT_SESSION_ID) + '*',
(_route, request) => {
const checkoutSessionId = request.url().match(/[/]payments[/]subscriptions[/]([^/?]+)/)?.[1]
if (checkoutSessionId == null) {
throw new Error('GetCheckoutSession: Missing checkout session ID in path')
} else {
const result = checkoutSessionsMap.get(backend.CheckoutSessionId(checkoutSessionId))
if (result) {
if (currentUser) {
object.unsafeMutable(currentUser).plan = result.body.plan
}
totalSeats = result.body.quantity
subscriptionDuration = result.body.interval
return result.status
} else {
throw new Error('GetCheckoutSession: Unknown checkout session ID')
}
}
},
)
await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => {
const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? ''
// The type of the body sent by this app is statically known.
@ -786,7 +848,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}
})
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => {
const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? ''
const assetId = decodeURIComponent(request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? '')
// This could be an id for an arbitrary asset, but pretend it's a
// `DirectoryId` to make TypeScript happy.
deleteAsset(backend.DirectoryId(assetId))
@ -803,7 +865,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
undeleteAsset(body.assetId)
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
})
await post(remoteBackendPaths.CREATE_USER_PATH + '*', async (route, request) => {
await post(remoteBackendPaths.CREATE_USER_PATH + '*', async (_route, request) => {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.CreateUserRequestBody = await request.postDataJSON()
@ -816,14 +878,38 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
name: body.userName,
organizationId,
userId: backend.UserId(`user-${uniqueString.uniqueString()}`),
isEnabled: false,
isEnabled: true,
rootDirectoryId,
userGroups: null,
plan: backend.Plan.enterprise,
isOrganizationAdmin: true,
}
await route.fulfill({ json: currentUser })
return currentUser
})
await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.CreateUserGroupRequestBody = await request.postDataJSON()
const userGroup = addUserGroup(body.name)
return userGroup
})
await put(
remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*',
async (route, request) => {
const userId = backend.UserId(
decodeURIComponent(request.url().match(/[/]users[/]([^?/]+)/)?.[1] ?? ''),
)
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const body: backend.ChangeUserGroupRequestBody = await request.postDataJSON()
const user = usersMap.get(userId)
if (!user) {
await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST })
} else {
object.unsafeMutable(user).userGroups = body.userGroups
return user
}
},
)
await put(remoteBackendPaths.UPDATE_CURRENT_USER_PATH + '*', async (_route, request) => {
// The type of the body sent by this app is statically known.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
@ -832,8 +918,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
currentUser = { ...currentUser, name: body.username }
}
})
await get(remoteBackendPaths.USERS_ME_PATH + '*', () => {
return currentUser
await get(remoteBackendPaths.USERS_ME_PATH + '*', (route) => {
if (currentUser == null) {
return route.fulfill({ status: HTTP_STATUS_NOT_FOUND })
} else {
return currentUser
}
})
await patch(remoteBackendPaths.UPDATE_ORGANIZATION_PATH + '*', async (route, request) => {
// The type of the body sent by this app is statically known.
@ -981,6 +1071,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
createLabel,
addLabel,
setLabels,
createCheckoutSession,
addUser,
deleteUser,
addUserGroup,

View File

@ -19,20 +19,20 @@ test.test('labels', async ({ page }) => {
// Empty labels panel
await test.expect(locateLabelsPanel(page)).toBeVisible()
// "Create label" modal
// "New Label" modal
await locateNewLabelButton(page).click()
await test.expect(locateNewLabelModal(page)).toBeVisible()
await page.press('body', 'Escape')
await test.expect(locateNewLabelModal(page)).not.toBeVisible()
await locateNewLabelButton(page).click()
// "Create label" modal with name set
// "New Label" modal with name set
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)
await page.press('body', 'Escape')
// "Create label" modal with color set
// "New Label" modal with color set
// The exact number is allowed to vary; but to click the fourth color, there must be at least
// four colors.
await locateNewLabelButton(page).click()
@ -42,7 +42,7 @@ test.test('labels', async ({ page }) => {
await locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
await test.expect(locateNewLabelModal(page)).toBeVisible()
// "Create label" modal with name and color set
// "New Label" modal with name and color set
await locateNewLabelModalNameInput(page).fill('New Label')
await test.expect(locateNewLabelModal(page)).toHaveText(/^New Label/)

View File

@ -0,0 +1,126 @@
/** @file Mock for `@stripe/react-stripe-js` */
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type {
CardElementProps,
ElementsConsumer as StripeElementConsumer,
Elements as StripeElements,
} from '@stripe/react-stripe-js'
import { createContext, useContext, useEffect, useState } from 'react'
/** */
type ElementsContextValue_ = Parameters<Parameters<typeof StripeElementConsumer>[0]['children']>[0]
/** */
interface ElementsContextValue extends ElementsContextValue_ {
//
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const ElementsContext = createContext<ElementsContextValue>(null!)
/** Elements provider for Stripe. */
export function Elements(...[props]: Parameters<typeof StripeElements>) {
const { stripe: stripeRaw, children } = props
const [stripe, setStripe] = useState(stripeRaw && 'then' in stripeRaw ? null : stripeRaw)
const [elements] = useState(() => {
// eslint-disable-next-line no-restricted-syntax
return {
getElement: (type) => {
switch (type) {
case 'card': {
return CardElement
}
default: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, no-restricted-syntax, @typescript-eslint/no-unsafe-return
return (<></>) as any
}
}
},
} satisfies Partial<
ElementsContextValue['elements']
> as unknown as ElementsContextValue['elements']
})
useEffect(() => {
let canceled = false
if (stripeRaw && 'then' in stripeRaw) {
void stripeRaw.then((awaitedStripe) => {
if (!canceled) {
setStripe(awaitedStripe)
}
})
}
return () => {
canceled = true
}
}, [stripeRaw])
return (
stripe && (
<ElementsContext.Provider
// eslint-disable-next-line no-restricted-syntax
value={{
stripe,
elements,
}}
>
{children}
</ElementsContext.Provider>
)
)
}
/** Elements consumer for Stripe. */
export function ElementsConsumer(...[props]: Parameters<typeof StripeElementConsumer>) {
return props.children(useContext(ElementsContext))
}
/** Card element for Stripe. */
export function CardElement(props: CardElementProps) {
const { onReady: onReadyRaw, onChange: onChangeRaw } = props
const onReady = useEventCallback(onReadyRaw ?? (() => {}))
const onChange = useEventCallback(onChangeRaw ?? (() => {}))
useEffect(() => {
onReady({
blur: () => {},
clear: () => {},
destroy: () => {},
focus: () => {},
mount: () => {},
unmount: () => {},
update: () => {},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
on: () => null!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
off: () => null!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
once: () => null!,
})
}, [onReady])
useEffect(() => {
// eslint-disable-next-line no-restricted-syntax
onChange({
elementType: 'card',
empty: false,
complete: true,
error: undefined,
value: { postalCode: '40001' },
brand: 'mastercard',
})
}, [onChange])
return <></>
}
// eslint-disable-next-line no-restricted-syntax
export const useStripe = () => ({
confirmCardSetup: () => {},
})
// eslint-disable-next-line no-restricted-syntax
export const useElements = () => ({
getElement: () => {},
})

View File

@ -0,0 +1,29 @@
/** @file Mock for `@stripe/stripe-js` */
import type { Stripe } from '@stripe/stripe-js'
// eslint-disable-next-line no-restricted-syntax
export const loadStripe = (): Promise<Stripe> =>
// eslint-disable-next-line no-restricted-syntax
Promise.resolve({
createPaymentMethod: () =>
Promise.resolve({
paymentMethod: {
id: '',
object: 'payment_method',
// eslint-disable-next-line @typescript-eslint/naming-convention
billing_details: {
address: null,
email: null,
name: null,
phone: null,
},
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
created: Number(new Date()) / 1_000,
customer: null,
livemode: true,
metadata: {},
type: '',
},
error: undefined,
}),
} satisfies Partial<Stripe> as Partial<Stripe> as Stripe)

View File

@ -0,0 +1,82 @@
/** @file Test the setup flow. */
import * as test from '@playwright/test'
import { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions'
test.test('setup (free plan)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.stayOnFreePlan()
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
test.test('setup (solo plan)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.selectSoloPlan()
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
test.test('setup (team plan, skipping invites)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
.selectTeamPlan(Plan.team)
.setOrganizationName('test organization')
.skipInvitingUsers()
.setTeamName('test team')
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
test.test('setup (team plan)', ({ page }) =>
actions
.mockAll({
page,
setupAPI: (api) => {
api.setCurrentUser(null)
},
})
.loginAsNewUser()
.setUsername('test user')
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
.selectTeamPlan(Plan.team, 10)
.setOrganizationName('test organization')
.inviteUsers('foo@bar.baz bar@bar.baz, baz@example.com; other+email@org.co.uk')
.setTeamName('test team')
.goToPage.drive()
.withDriveView(async (drive) => {
await test.expect(drive).toBeVisible()
}),
)
// No test for enterprise plan as the plan must be set to enterprise manually.

View File

@ -1,23 +1,19 @@
/** @file Test the user menu. */
import * as test from '@playwright/test'
import { expect, test } from '@playwright/test'
import * as actions from './actions'
import { mockAllAndLogin, TEXT } from './actions'
test.test('user menu', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('user menu', ({ page }) =>
mockAllAndLogin({ page })
.openUserMenu()
.do(async (thePage) => {
await test.expect(actions.locateUserMenu(thePage)).toBeVisible()
}),
)
await expect(thePage.getByLabel(TEXT.userMenuLabel).locator('visible=true')).toBeVisible()
}))
test.test('download app', ({ page }) =>
actions
.mockAllAndLogin({ page })
test('download app', ({ page }) =>
mockAllAndLogin({ page })
.openUserMenu()
.userMenu.downloadApp(async (download) => {
await download.cancel()
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
}),
)
expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
}))

View File

@ -82,7 +82,13 @@ export class Cognito {
private readonly logger: loggerProvider.Logger,
private readonly supportsDeepLinks: boolean,
private readonly amplifyConfig: service.AmplifyConfig,
) {}
) {
const username = localStorage.getItem(MOCK_EMAIL_KEY)
if (username != null) {
this.isSignedIn = true
mockEmail = username
}
}
/** Save the access token to a file for further reuse. */
saveAccessToken() {

View File

@ -9,7 +9,7 @@ import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import { forwardRef } from '#/utilities/react'
import type { VariantProps } from '#/utilities/tailwindVariants'
import type { ExtractFunction, VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import { TEXT_STYLE } from '../Text'
@ -68,7 +68,7 @@ export interface BaseButtonProps<Render>
/** Defaults to `full`. When `full`, the entire button will be replaced with the loader.
* When `icon`, only the icon will be replaced with the loader. */
readonly loaderPosition?: 'full' | 'icon'
readonly styles?: typeof BUTTON_STYLES
readonly styles?: ExtractFunction<typeof BUTTON_STYLES> | undefined
}
export const BUTTON_STYLES = tv({
@ -104,7 +104,7 @@ export const BUTTON_STYLES = tv({
loading: { true: { base: 'cursor-wait' } },
fullWidth: { true: 'w-full' },
size: {
custom: { base: '', extraClickZone: '', icon: 'h-full' },
custom: { base: '', extraClickZone: '', icon: 'h-full w-unset min-w-[1.906cap]' },
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
large: {
base: TEXT_STYLE({
@ -322,7 +322,7 @@ export const Button = forwardRef(function Button(
const goodDefaults = {
...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }),
'data-testid': testId ?? (isLink ? 'link' : 'button'),
'data-testid': testId,
}
const isIconOnly = (children == null || children === '' || children === false) && icon != null
@ -380,15 +380,7 @@ export const Button = forwardRef(function Button(
}
}
const {
base,
content,
wrapper,
loader,
extraClickZone,
icon: iconClasses,
text: textClasses,
} = variants({
const styles = variants({
isDisabled,
isActive,
loading: isLoading,
@ -410,7 +402,7 @@ export const Button = forwardRef(function Button(
return null
} else if (isLoading && loaderPosition === 'icon') {
return (
<span className={iconClasses()}>
<span className={styles.icon()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span>
)
@ -419,21 +411,21 @@ export const Button = forwardRef(function Button(
const actualIcon = typeof icon === 'function' ? icon(render) : icon
if (typeof actualIcon === 'string') {
return <SvgMask src={actualIcon} className={iconClasses()} />
return <SvgMask src={actualIcon} className={styles.icon()} />
} else {
return <span className={iconClasses()}>{actualIcon}</span>
return <span className={styles.icon()}>{actualIcon}</span>
}
}
})()
// Icon only button
if (isIconOnly) {
return <span className={extraClickZone()}>{iconComponent}</span>
return <span className={styles.extraClickZone()}>{iconComponent}</span>
} else {
// Default button
return (
<>
{iconComponent}
<span className={textClasses()}>
<span className={styles.text()}>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{typeof children === 'function' ? children(render) : children}
</span>
@ -456,26 +448,23 @@ export const Button = forwardRef(function Button(
}
},
className: aria.composeRenderProps(className, (classNames, states) =>
base({ className: classNames, ...states }),
styles.base({ className: classNames, ...states }),
),
})}
>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{(render) => (
<>
<span className={wrapper()}>
<span ref={contentRef} className={content({ className: contentClassName })}>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */}
{childrenFactory(render)}
</span>
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={loader()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span>
)}
{(render: aria.ButtonRenderProps | aria.LinkRenderProps) => (
<span className={styles.wrapper()}>
<span ref={contentRef} className={styles.content({ className: contentClassName })}>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */}
{childrenFactory(render)}
</span>
</>
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
</span>
)}
</span>
)}
</Tag>
)

View File

@ -89,6 +89,7 @@ export const CHECKBOX_STYLES = tv({
'outline -outline-offset-2 outline-transparent group-focus-visible:outline-offset-0 group-focus-visible:outline-primary',
'border-primary group-selected:border-transparent',
'group-pressed:border',
'shrink-0',
],
},
defaultVariants: {
@ -204,7 +205,7 @@ export const Checkbox = forwardRef(function Checkbox<
size,
})
const testId = props['data-testid'] ?? props['testId'] ?? 'Checkbox'
const testId = props['data-testid'] ?? props['testId']
return (
<AriaCheckbox

View File

@ -67,7 +67,7 @@ export const CheckboxGroup = forwardRef(
const formInstance = (form ?? Form.useFormContext()) as FormInstance<Schema>
const styles = variants({ fullWidth, className })
const testId = props['data-testid'] ?? props.testId ?? 'CheckboxGroup'
const testId = props['data-testid'] ?? props.testId
return (
<Form.Controller

View File

@ -82,6 +82,7 @@ const DIALOG_STYLES = tv({
header: 'p-0 max-h-0 min-h-0 h-0 border-0 z-1',
content: 'isolate',
},
none: {},
},
rounded: {
none: { base: '' },
@ -271,23 +272,27 @@ export function Dialog(props: DialogProps) {
{(opts) => {
return (
<dialogProvider.DialogProvider value={{ close: opts.close, dialogId }}>
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
<ariaComponents.CloseButton
className={styles.closeButton()}
onPress={opts.close}
/>
{(closeButton !== 'none' || title != null) && (
<aria.Header className={styles.header({ scrolledToTop: isScrolledToTop })}>
{closeButton !== 'none' && (
<ariaComponents.CloseButton
className={styles.closeButton()}
onPress={opts.close}
/>
)}
{title != null && (
<ariaComponents.Text.Heading
id={titleId}
level={2}
className={styles.heading()}
weight="semibold"
>
{title}
</ariaComponents.Text.Heading>
)}
</aria.Header>
{title != null && (
<ariaComponents.Text.Heading
id={titleId}
level={2}
className={styles.heading()}
weight="semibold"
>
{title}
</ariaComponents.Text.Heading>
)}
</aria.Header>
)}
<div
ref={(ref) => {

View File

@ -17,9 +17,7 @@ import * as dialogStackProvider from './DialogStackProvider'
import * as utlities from './utilities'
import * as variants from './variants'
/**
* Props for the Popover component.
*/
/** Props for a {@link Popover}. */
export interface PopoverProps
extends Omit<aria.PopoverProps, 'children'>,
twv.VariantProps<typeof POPOVER_STYLES> {
@ -30,7 +28,7 @@ export interface PopoverProps
}
export const POPOVER_STYLES = twv.tv({
base: 'shadow-md w-full overflow-clip',
base: 'shadow-xl w-full overflow-clip',
variants: {
isEntering: {
true: 'animate-in fade-in placement-bottom:slide-in-from-top-1 placement-top:slide-in-from-bottom-1 placement-left:slide-in-from-right-1 placement-right:slide-in-from-left-1 ease-out duration-200',
@ -40,6 +38,7 @@ export const POPOVER_STYLES = twv.tv({
},
size: {
auto: { base: 'w-[unset]', dialog: 'p-2.5' },
xxsmall: { base: 'max-w-[206px]', dialog: 'p-2' },
xsmall: { base: 'max-w-xs', dialog: 'p-2.5' },
small: { base: 'max-w-sm', dialog: 'p-3.5' },
medium: { base: 'max-w-md', dialog: 'p-3.5' },

View File

@ -1,14 +1,18 @@
/** @file A select menu with a dropdown. */
import * as React from 'react'
import {
useEffect,
useMemo,
useRef,
useState,
type HTMLInputTypeAttribute,
type KeyboardEvent,
type MutableRefObject,
} from 'react'
import CloseIcon from '#/assets/cross.svg'
import { Button, Input, Text } from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import Input from '#/components/styled/Input'
import { Button, Text } from '#/components/AriaComponents'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { twJoin, twMerge } from 'tailwind-merge'
import { twJoin, twMerge } from '#/utilities/tailwindMerge'
// =================
// === Constants ===
@ -24,8 +28,8 @@ const ZWSP = '\u200b'
/** Base props for a {@link Autocomplete}. */
interface InternalBaseAutocompleteProps<T> {
readonly multiple?: boolean
readonly type?: React.HTMLInputTypeAttribute
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
readonly type?: HTMLInputTypeAttribute
readonly inputRef?: MutableRefObject<HTMLFieldSetElement | null>
readonly placeholder?: string
readonly values: readonly T[]
readonly autoFocus?: boolean
@ -53,7 +57,7 @@ interface InternalMultipleAutocompleteProps<T> extends InternalBaseAutocompleteP
readonly multiple: true
/** This is `null` when multiple values are selected, causing the input to switch to a
* {@link HTMLTextAreaElement}. */
readonly inputRef?: React.MutableRefObject<HTMLInputElement | null>
readonly inputRef?: MutableRefObject<HTMLFieldSetElement | null>
readonly setValues: (value: readonly T[]) => void
readonly itemsToString: (items: readonly T[]) => string
}
@ -82,25 +86,25 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
const { text, setText, autoFocus = false, items, itemToKey, children, itemsToString } = props
const { matches } = props
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const valuesSet = React.useMemo(() => new Set(values), [values])
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const valuesSet = useMemo(() => new Set(values), [values])
const canEditText = setText != null && values.length === 0
// We are only interested in the initial value of `canEditText` in effects.
const canEditTextRef = React.useRef(canEditText)
const canEditTextRef = useRef(canEditText)
const isMultipleAndCustomValue = multiple === true && text != null
const matchingItems = React.useMemo(
const matchingItems = useMemo(
() => (text == null ? items : items.filter((item) => matches(item, text))),
[items, matches, text],
)
React.useEffect(() => {
useEffect(() => {
if (!canEditTextRef.current) {
setIsDropdownVisible(true)
}
}, [])
const fallbackInputRef = React.useRef<HTMLInputElement>(null)
const fallbackInputRef = useRef<HTMLFieldSetElement>(null)
const inputRef = rawInputRef ?? fallbackInputRef
// This type is a little too wide but it is unavoidable.
@ -127,7 +131,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
)
}
const onKeyDown = (event: React.KeyboardEvent) => {
const onKeyDown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowUp': {
event.preventDefault()
@ -190,13 +194,14 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
<div className="relative z-1 flex flex-1 rounded-full">
{canEditText ?
<Input
name="autocomplete"
type={type}
ref={inputRef}
autoFocus={autoFocus}
size={1}
size="custom"
value={text ?? ''}
autoComplete="off"
placeholder={placeholder == null ? placeholder : placeholder}
{...(placeholder == null ? {} : { placeholder })}
className="text grow rounded-full bg-transparent px-button-x"
onFocus={() => {
setIsDropdownVisible(true)
@ -240,7 +245,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
</div>
</FocusRing>
<div
className={tailwindMerge.twMerge(
className={twMerge(
'relative z-1 grid h-max w-full rounded-b-xl transition-grid-template-rows duration-200',
isDropdownVisible && matchingItems.length !== 0 ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
@ -251,7 +256,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
{matchingItems.map((item, index) => (
<div
key={itemToKey(item)}
className={tailwindMerge.twMerge(
className={twMerge(
'text relative cursor-pointer whitespace-nowrap px-input-x last:rounded-b-xl hover:bg-hover-bg',
valuesSet.has(item) && 'bg-hover-bg',
index === selectedIndex && 'bg-black/5',

View File

@ -1,10 +1,9 @@
/** @file A dynamic wizard for creating an arbitrary type of Datalink. */
import { Fragment, type JSX, useState } from 'react'
import { Input, Text } from '#/components/aria'
import { Checkbox, Input, Text } from '#/components/aria'
import { Button, Dropdown } from '#/components/AriaComponents'
import Autocomplete from '#/components/Autocomplete'
import Checkbox from '#/components/styled/Checkbox'
import FocusRing from '#/components/styled/FocusRing'
import { useBackendQuery } from '#/hooks/backendHooks'
import { useRemoteBackendStrict } from '#/providers/BackendProvider'

View File

@ -115,6 +115,7 @@ export default function MenuEntry(props: MenuEntryProps) {
} = props
const { getText } = textProvider.useText()
const { unsetModal } = modalProvider.useSetModal()
const dialogContext = ariaComponents.useDialogContext()
const inputBindings = inputBindingsProvider.useInputBindings()
const focusChildProps = focusHooks.useFocusChild()
const info = inputBindings.metadata[action]
@ -149,14 +150,23 @@ export default function MenuEntry(props: MenuEntryProps) {
isDisabled,
className: 'group flex w-full rounded-menu-entry',
onPress: () => {
unsetModal()
if (dialogContext) {
// Closing a dialog takes precedence over unsetting the modal.
dialogContext.close()
} else {
unsetModal()
}
doAction()
},
})}
>
<div className={MENU_ENTRY_VARIANTS(variantProps)}>
<div title={title} className="flex items-center gap-menu-entry whitespace-nowrap">
<SvgMask src={icon ?? info.icon ?? BlankIcon} color={info.color} className="size-4" />
<SvgMask
src={icon ?? info.icon ?? BlankIcon}
color={info.color}
className="size-4 text-primary"
/>
<ariaComponents.Text slot="label">
{label ?? getText(labelTextId)}
</ariaComponents.Text>

View File

@ -73,7 +73,7 @@ export function Stepper(props: StepperProps) {
totalSteps,
} satisfies BaseRenderProps
const classes = STEPPER_STYLES({})
const styles = STEPPER_STYLES({})
const style = typeof props.style === 'function' ? props.style(baseRenderProps) : props.style
@ -96,7 +96,7 @@ export function Stepper(props: StepperProps) {
return (
<div
className={classes.base({
className={styles.base({
className:
typeof props.className === 'function' ?
props.className(baseRenderProps)
@ -107,7 +107,7 @@ export function Stepper(props: StepperProps) {
<stepperProvider.StepperProvider
value={{ totalSteps, currentStep, goToStep, nextStep, previousStep, state }}
>
<div className={classes.steps()}>
<div className={styles.steps()}>
{Array.from({ length: totalSteps }).map((_, index) => {
const renderStepProps = {
index,
@ -124,14 +124,14 @@ export function Stepper(props: StepperProps) {
} satisfies RenderStepProps
return (
<div key={index} className={classes.step({})}>
<div key={index} className={styles.step({})}>
{renderStep(renderStepProps)}
</div>
)
})}
</div>
<div className={classes.content()}>
<div className={styles.content()}>
<AnimatePresence initial={false} mode="sync" custom={direction}>
<motion.div
key={currentStep}

View File

@ -1,112 +0,0 @@
/** @file A styled button. */
import * as React from 'react'
import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { forwardRef } from '#/utilities/react'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ==============
// === Button ===
// ==============
/** Props for a {@link Button}. */
export interface ButtonProps {
/** Falls back to `aria-label`. Pass `false` to explicitly disable the tooltip. */
readonly tooltip?: React.ReactNode
readonly autoFocus?: boolean
readonly mask?: boolean
/** When `true`, the button uses a lighter color when it is not active. */
readonly light?: boolean
/** When `true`, the button is not faded out even when not hovered. */
readonly active?: boolean
/** When `true`, the button is not clickable. */
readonly isDisabled?: boolean
readonly image: string
readonly alt?: string
readonly tooltipPlacement?: aria.Placement
/** A title that is only shown when `disabled` is `true`. */
readonly error?: string | null
/** Class names for the icon itself. */
readonly className?: string
/** Extra class names for the `button` element wrapping the icon.
* This is useful for things like positioning the entire button (e.g. `absolute`). */
readonly buttonClassName?: string
readonly onPress: (event: aria.PressEvent) => void
}
export default forwardRef(Button)
/** A styled button. */
function Button(props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) {
const {
tooltip,
light = false,
active = false,
mask = true,
image,
error,
alt,
className,
buttonClassName,
tooltipPlacement,
...buttonProps
} = props
const { isDisabled = false } = buttonProps
const focusChildProps = focusHooks.useFocusChild()
const tooltipElement = tooltip === false ? null : tooltip ?? alt
const Img = mask ? SvgMask : 'img'
const button = (
<FocusRing placement="after">
<aria.Button
{...aria.mergeProps<aria.ButtonProps & React.RefAttributes<HTMLButtonElement>>()(
buttonProps,
focusChildProps,
{
ref,
className: tailwindMerge.twMerge(
'relative after:pointer-events-none after:absolute after:inset after:rounded-button-focus-ring transition-colors hover:enabled:bg-primary/10 rounded-button-focus-ring -m-1 p-1',
buttonClassName,
),
},
)}
>
<div
className={tailwindMerge.twMerge(
'group flex opacity-50 transition-all hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-30 [&.disabled]:cursor-not-allowed [&.disabled]:opacity-30',
light && 'opacity-25',
isDisabled && 'disabled',
active &&
'opacity-100 hover:opacity-100 disabled:cursor-default disabled:opacity-100 [&.disabled]:cursor-default [&.disabled]:opacity-100',
)}
>
<Img
src={image}
{...(!active && isDisabled && error != null ? { title: error } : {})}
{...(alt != null ? { alt } : {})}
className={className}
/>
</div>
</aria.Button>
</FocusRing>
)
return tooltipElement == null ? button : (
<ariaComponents.TooltipTrigger delay={0} closeDelay={0}>
{button}
<ariaComponents.Tooltip
{...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}
>
{tooltipElement}
</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
)
}

View File

@ -1,33 +0,0 @@
/** @file A styled checkbox. */
import * as React from 'react'
import CheckMarkIcon from '#/assets/check_mark.svg'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
// ================
// === Checkbox ===
// ================
/** Props for a {@link Checkbox}. */
export interface CheckboxProps extends Omit<Readonly<aria.CheckboxProps>, 'className'> {}
/** A styled checkbox. */
export default function Checkbox(props: CheckboxProps) {
return (
<FocusRing>
<aria.Checkbox
className="group flex size-3 cursor-pointer overflow-clip rounded-sm text-invite outline outline-1 outline-primary checkbox"
{...props}
>
<SvgMask
invert
src={CheckMarkIcon}
className="-m-0.5 size-4 opacity-0 transition-all duration-75 group-selected:opacity-100"
/>
</aria.Checkbox>
</FocusRing>
)
}

View File

@ -1,36 +0,0 @@
/** @file An input that handles focus movement. */
import type { ForwardedRef, RefAttributes } from 'react'
import {
Input as AriaInput,
mergeProps,
type InputProps as AriaInputProps,
} from '#/components/aria'
import { useHandleFocusMove } from '#/hooks/focusHooks'
import { useFocusDirection } from '#/providers/FocusDirectionProvider'
import { forwardRef } from '#/utilities/react'
// =============
// === Input ===
// =============
/** Props for a {@link Input}. */
export interface InputProps extends Readonly<AriaInputProps> {}
export default forwardRef(Input)
/** An input that handles focus movement. */
function Input(props: InputProps, ref: ForwardedRef<HTMLInputElement>) {
const focusDirection = useFocusDirection()
const handleFocusMove = useHandleFocusMove(focusDirection)
return (
<AriaInput
{...mergeProps<AriaInputProps & RefAttributes<HTMLInputElement>>()(props, {
ref,
className: 'focus-child',
onKeyDown: handleFocusMove,
})}
/>
)
}

View File

@ -53,6 +53,7 @@ export type MutationMethod = DefineBackendMethods<
| 'openProject'
| 'removeUser'
| 'resendInvitation'
| 'restoreUser'
| 'undoDeleteAsset'
| 'updateAsset'
| 'updateDirectory'
@ -120,7 +121,10 @@ const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
const INVALIDATION_MAP: Partial<
Record<MutationMethod, readonly (backendQuery.BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
> = {
createUser: ['usersMe'],
updateUser: ['usersMe'],
deleteUser: ['usersMe'],
restoreUser: ['usersMe'],
uploadUserPicture: ['usersMe'],
updateOrganization: ['getOrganization'],
uploadOrganizationPicture: ['getOrganization'],
@ -204,10 +208,8 @@ export function backendMutationOptions<Method extends MutationMethod>(
meta: {
invalidates: [
...(options?.meta?.invalidates ?? []),
...(INVALIDATION_MAP[method]?.flatMap((queryMethod) =>
queryMethod === INVALIDATE_ALL_QUERIES ?
[[backend?.type]]
: [[backend?.type, queryMethod], ...(queryMethod === 'usersMe' ? [[queryMethod]] : [])],
...(INVALIDATION_MAP[method]?.map((queryMethod) =>
queryMethod === INVALIDATE_ALL_QUERIES ? [backend?.type] : [backend?.type, queryMethod],
) ?? []),
],
awaitInvalidates: options?.meta?.awaitInvalidates ?? true,

View File

@ -1,19 +1,13 @@
/** @file Displays information describing a specific version of an asset. */
import * as React from 'react'
import { useState } from 'react'
import LogsIcon from '#/assets/logs.svg'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import Button from '#/components/styled/Button'
import { Button, DialogTrigger } from '#/components/AriaComponents'
import ProjectLogsModal from '#/modals/ProjectLogsModal'
import type * as backendModule from '#/services/Backend'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as dateTime from '#/utilities/dateTime'
import type { ProjectAsset, ProjectSession } from '#/services/Backend'
import { formatDateTime } from '#/utilities/dateTime'
// ===========================
// === AssetProjectSession ===
@ -22,26 +16,24 @@ import * as dateTime from '#/utilities/dateTime'
/** Props for a {@link AssetProjectSession}. */
export interface AssetProjectSessionProps {
readonly backend: Backend
readonly project: backendModule.ProjectAsset
readonly projectSession: backendModule.ProjectSession
readonly project: ProjectAsset
readonly projectSession: ProjectSession
}
/** Displays information describing a specific version of an asset. */
export default function AssetProjectSession(props: AssetProjectSessionProps) {
const { backend, project, projectSession } = props
const { getText } = textProvider.useText()
const [isOpen, setIsOpen] = React.useState(false)
const { getText } = useText()
const [isOpen, setIsOpen] = useState(false)
return (
<div className="flex w-full flex-1 shrink-0 select-none flex-row gap-4 rounded-2xl p-2">
<div className="flex flex-1 flex-col">
<time className="text-xs">
{dateTime.formatDateTime(new Date(projectSession.createdAt))}
</time>
<time className="text-xs">{formatDateTime(new Date(projectSession.createdAt))}</time>
</div>
<div className="flex items-center gap-1">
<ariaComponents.DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button active image={LogsIcon} alt={getText('showLogs')} onPress={() => {}} />
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
<Button isActive icon={LogsIcon} aria-label={getText('showLogs')} />
<ProjectLogsModal
isOpen={isOpen}
@ -49,7 +41,7 @@ export default function AssetProjectSession(props: AssetProjectSessionProps) {
projectSessionId={projectSession.projectSessionId}
projectTitle={project.title}
/>
</ariaComponents.DialogTrigger>
</DialogTrigger>
</div>
</div>
)

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,11 @@
/** @file A toolbar containing chat and the user menu. */
import * as React from 'react'
import ChatIcon from '#/assets/chat.svg'
import LogoIcon from '#/assets/enso_logo.svg'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import InfoMenu from '#/layouts/InfoMenu'
import * as ariaComponents from '#/components/AriaComponents'
import Button from '#/components/styled/Button'
import { Button, DialogTrigger } from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import InfoMenu from '#/layouts/InfoMenu'
import { useText } from '#/providers/TextProvider'
// ===============
// === InfoBar ===
@ -27,8 +20,7 @@ export interface InfoBarProps {
/** A toolbar containing chat and the user menu. */
export default function InfoBar(props: InfoBarProps) {
const { isHelpChatOpen, setIsHelpChatOpen } = props
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { getText } = useText()
return (
<FocusArea direction="horizontal">
@ -42,27 +34,29 @@ export default function InfoBar(props: InfoBarProps) {
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
{false && (
<Button
active={isHelpChatOpen}
image={ChatIcon}
size="custom"
variant="custom"
isActive={isHelpChatOpen}
icon={ChatIcon}
onPress={() => {
setIsHelpChatOpen(!isHelpChatOpen)
}}
/>
)}
<ariaComponents.Button
size="custom"
variant="custom"
className="flex size-row-h select-none items-center overflow-clip rounded-full"
onPress={() => {
setModal(<InfoMenu />)
}}
>
<SvgMask
src={LogoIcon}
alt={getText('openInfoMenu')}
className="pointer-events-none size-7"
/>
</ariaComponents.Button>
<DialogTrigger>
<Button
size="custom"
variant="icon"
className="flex size-row-h select-none items-center overflow-clip rounded-full"
>
<SvgMask
src={LogoIcon}
alt={getText('openInfoMenu')}
className="pointer-events-none size-7"
/>
</Button>
<InfoMenu />
</DialogTrigger>
{/* Required for shortcuts to work. */}
<div className="hidden">
<InfoMenu hidden />

View File

@ -1,23 +1,15 @@
/** @file A menu containing info about the app. */
import * as React from 'react'
import * as common from 'enso-common'
import { PRODUCT_NAME } from 'enso-common'
import LogoIcon from '#/assets/enso_logo.svg'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import { Popover, Text } from '#/components/AriaComponents'
import MenuEntry from '#/components/MenuEntry'
import Modal from '#/components/Modal'
import FocusArea from '#/components/styled/FocusArea'
import SvgMask from '#/components/SvgMask'
import AboutModal from '#/modals/AboutModal'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useAuth } from '#/providers/AuthProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
// ================
// === InfoMenu ===
@ -31,63 +23,33 @@ export interface InfoMenuProps {
/** A menu containing info about the app. */
export default function InfoMenu(props: InfoMenuProps) {
const { hidden = false } = props
const { signOut, session } = authProvider.useAuth()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [initialized, setInitialized] = React.useState(false)
React.useLayoutEffect(() => {
// Change the CSS from the initial state to the final state after the first render.
// This ensures that the CSS transition triggers.
setInitialized(true)
}, [])
const { signOut, session } = useAuth()
const { setModal } = useSetModal()
const { getText } = useText()
return (
<Modal hidden={hidden} className="absolute size-full overflow-hidden bg-dim">
<div
{...(!hidden ? { 'data-testid': 'info-menu' } : {})}
className={tailwindMerge.twMerge(
'absolute right-2.5 top-2.5 flex flex-col gap-user-menu rounded-default bg-selected-frame backdrop-blur-default transition-all duration-user-menu',
initialized ? 'w-user-menu p-user-menu' : 'size-row-h',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
<div
className={tailwindMerge.twMerge(
'flex items-center gap-icons overflow-hidden transition-all duration-user-menu',
initialized && 'px-menu-entry',
)}
>
<SvgMask src={LogoIcon} className="pointer-events-none h-7 w-7" />
<aria.Text className="text">{common.PRODUCT_NAME}</aria.Text>
</div>
<div
className={tailwindMerge.twMerge(
'grid transition-all duration-user-menu',
initialized ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
>
<FocusArea direction="vertical">
{(innerProps) => (
<div
aria-label={getText('infoMenuLabel')}
className="flex flex-col overflow-hidden"
{...innerProps}
>
<MenuEntry
action="aboutThisApp"
doAction={() => {
setModal(<AboutModal />)
}}
/>
{session && <MenuEntry action="signOut" doAction={signOut} />}
</div>
)}
</FocusArea>
</div>
<Popover {...(!hidden ? { testId: 'info-menu' } : {})} size="xxsmall">
<div className="mb-2 flex items-center gap-icons overflow-hidden px-menu-entry transition-all duration-user-menu">
<SvgMask src={LogoIcon} className="pointer-events-none h-7 w-7 text-primary" />
<Text>{PRODUCT_NAME}</Text>
</div>
</Modal>
<FocusArea direction="vertical">
{(innerProps) => (
<div
aria-label={getText('infoMenuLabel')}
className="flex flex-col overflow-hidden"
{...innerProps}
>
<MenuEntry
action="aboutThisApp"
doAction={() => {
setModal(<AboutModal />)
}}
/>
{session && <MenuEntry action="signOut" doAction={signOut} />}
</div>
)}
</FocusArea>
</Popover>
)
}

View File

@ -32,13 +32,13 @@ export default function SearchBar(props: SearchBarProps) {
data-testid={props['data-testid']}
{...aria.mergeProps<aria.LabelProps>()(innerProps, {
className:
'group relative flex grow sm:grow-0 sm:basis-[512px] h-row items-center gap-asset-search-bar rounded-full px-input-x text-primary border-0.5 border-primary/20 transition-colors focus-within:outline focus-within:outline-2 outline-primary -outline-offset-1',
'group relative flex w-full sm:w-[512px] h-row items-center gap-asset-search-bar rounded-full px-input-x text-primary border-0.5 border-primary/20 transition-colors focus-within:outline focus-within:outline-2 outline-primary -outline-offset-1',
})}
>
<SvgMask src={FindIcon} className="text-primary/30" />
<aria.SearchField
aria-label={label}
className="before:inset-x-button-focus-ring-inset relative grow before:text before:absolute before:my-auto before:rounded-full before:transition-all"
className="relative grow"
value={query}
onKeyDown={(event) => {
event.continuePropagation()
@ -48,7 +48,7 @@ export default function SearchBar(props: SearchBarProps) {
type="search"
size={1}
placeholder={placeholder}
className="focus-child peer text relative z-1 w-full bg-transparent placeholder:text-center"
className="focus-child w-full bg-transparent text-xs placeholder:text-center"
onChange={(event) => {
setQuery(event.target.value)
}}

View File

@ -1,15 +1,12 @@
/** @file A form for changing the user's password. */
import * as React from 'react'
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 SettingsAriaInput from '#/layouts/Settings/SettingsAriaInput'
import { passwordSchema, passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { PASSWORD_REGEX } from '#/utilities/validation'
import SettingsAriaInput from './AriaInput'
/** Create the schema for this form. */
function createChangePasswordFormSchema(getText: GetText) {

View File

@ -1,11 +1,9 @@
/** @file Rendering for an {@link settingsData.SettingsCustomEntryData}. */
import * as React from 'react'
import type * as settingsData from './data'
import type * as settingsData from '#/layouts/Settings/settingsData'
// ==========================
// ===========================
// === SettingsCustomEntry ===
// ==========================
// ===========================
/** Props for a {@link SettingsCustomEntry}. */
export interface SettingsCustomEntryProps {

View File

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

View File

@ -1,16 +1,23 @@
/** @file A styled input specific to settings pages. */
import * as React from 'react'
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 * as focusHooks from '#/hooks/focusHooks'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import Button from '#/components/styled/Button'
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'
import { useText } from '#/providers/TextProvider'
import { forwardRef } from '#/utilities/react'
import { twMerge } from '#/utilities/tailwindMerge'
@ -23,25 +30,25 @@ export interface SettingsInputProps {
readonly isDisabled?: boolean
readonly type?: string
readonly placeholder?: string
readonly autoComplete?: React.HTMLInputAutoCompleteAttribute
readonly onChange?: React.ChangeEventHandler<HTMLInputElement>
readonly onSubmit?: (event: React.SyntheticEvent<HTMLInputElement>) => void
readonly autoComplete?: HTMLInputAutoCompleteAttribute
readonly onChange?: ChangeEventHandler<HTMLInputElement>
readonly onSubmit?: (event: SyntheticEvent<HTMLInputElement>) => void
}
export default forwardRef(SettingsInput)
/** A styled input specific to settings pages. */
function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLInputElement>) {
function SettingsInput(props: SettingsInputProps, ref: ForwardedRef<HTMLInputElement>) {
const { isDisabled = false, type, placeholder, autoComplete, onChange, onSubmit } = props
const focusChildProps = focusHooks.useFocusChild()
const { getText } = textProvider.useText()
const focusChildProps = useFocusChild()
const { getText } = useText()
// This is SAFE. The value of this context is never a `SlottedContext`.
// eslint-disable-next-line no-restricted-syntax
const inputProps = (React.useContext(aria.InputContext) ?? null) as aria.InputProps | null
const [isShowingPassword, setIsShowingPassword] = React.useState(false)
const cancelled = React.useRef(false)
const inputProps = (useContext(InputContext) ?? null) as InputProps | null
const [isShowingPassword, setIsShowingPassword] = useState(false)
const cancelled = useRef(false)
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'Escape': {
cancelled.current = true
@ -60,9 +67,9 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
return (
<div className="text my-auto grow font-bold">
<FocusRing within placement="after">
<aria.Group className="relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-full">
<aria.Input
{...aria.mergeProps<aria.InputProps & React.RefAttributes<HTMLInputElement>>()(
<Group className="relative rounded-full after:pointer-events-none after:absolute after:inset after:rounded-full">
<Input
{...mergeProps<InputProps & RefAttributes<HTMLInputElement>>()(
{
ref,
className: twMerge(
@ -87,16 +94,18 @@ function SettingsInput(props: SettingsInputProps, ref: React.ForwardedRef<HTMLIn
/>
{type === 'password' && (
<Button
active
image={isShowingPassword ? EyeIcon : EyeCrossedIcon}
alt={isShowingPassword ? getText('hidePassword') : getText('showPassword')}
buttonClassName="absolute right-2 top-1 cursor-pointer rounded-full size-6"
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)
}}
/>
)}
</aria.Group>
</Group>
</FocusRing>
</div>
)

View File

@ -1,14 +1,11 @@
/** @file Rendering for an {@link settingsData.SettingsInputEntryData}. */
import * as React from 'react'
/** @file Rendering for an {@link SettingsInputEntryData}. */
import { useRef, useState } from 'react'
import * as textProvider from '#/providers/TextProvider'
import type * as settingsData from '#/layouts/Settings/settingsData'
import * as aria from '#/components/aria'
import SettingsInput from '#/components/styled/SettingsInput'
import * as errorModule from '#/utilities/error'
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 ===
@ -23,17 +20,17 @@ const FIELD_NAME = 'value'
/** Props for a {@link SettingsInputEntry}. */
export interface SettingsInputEntryProps {
readonly context: settingsData.SettingsContext
readonly data: settingsData.SettingsInputEntryData
readonly context: SettingsContext
readonly data: SettingsInputEntryData
}
/** Rendering for an {@link settingsData.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 } = textProvider.useText()
const [errorMessage, setErrorMessage] = React.useState('')
const isSubmitting = React.useRef(false)
const { getText } = useText()
const [errorMessage, setErrorMessage] = useState('')
const isSubmitting = useRef(false)
const value = getValue(context)
const isEditable = getEditable(context)
@ -52,7 +49,7 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
)
return (
<aria.Form
<Form
validationErrors={{ [FIELD_NAME]: errorMessage }}
onSubmit={async (event) => {
event.preventDefault()
@ -64,31 +61,29 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
try {
await setValue(context, newValue)
} catch (error) {
setErrorMessage(errorModule.getMessageOrToString(error))
setErrorMessage(getMessageOrToString(error))
}
}
isSubmitting.current = false
}
}}
>
<aria.TextField
<TextField
key={value}
name={FIELD_NAME}
defaultValue={value}
className="flex h-row items-center gap-settings-entry"
{...(validate ? { validate: (newValue) => validate(newValue, context) } : {})}
>
<aria.Label className="text my-auto w-organization-settings-label">
{getText(nameId)}
</aria.Label>
<Label className="text my-auto w-organization-settings-label">{getText(nameId)}</Label>
{validate ?
<div className="flex grow flex-col">
{input}
<aria.FieldError className="text-red-700" />
<FieldError className="text-red-700" />
</div>
: input}
<aria.Button type="submit" className="sr-only" />
</aria.TextField>
</aria.Form>
<Button type="submit" className="sr-only" />
</TextField>
</Form>
)
}

View File

@ -1,23 +1,25 @@
/** @file A list of members in the organization. */
import * as React from 'react'
import * as mimeTypes from '#/data/mimeTypes'
import * as backendHooks from '#/hooks/backendHooks'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import UserRow from '#/layouts/Settings/UserRow'
import * as aria from '#/components/aria'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Column,
Table,
TableBody,
TableHeader,
Text,
useDragAndDrop,
type Selection,
} from '#/components/aria'
import { USER_MIME_TYPE } from '#/data/mimeTypes'
import { useBackendQuery } from '#/hooks/backendHooks'
import { useStickyTableHeaderOnScroll } from '#/hooks/scrollHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { UserId, type User } from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge'
import UserRow from './UserRow'
// ====================
// === MembersTable ===
@ -35,53 +37,51 @@ export interface MembersTableProps {
/** A list of members in the organization. */
export default function MembersTable(props: MembersTableProps) {
const { backend, populateWithSelf = false, draggable = false, allowDelete = false } = props
const { user } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [selectedKeys, setSelectedKeys] = React.useState<aria.Selection>(new Set())
const rootRef = React.useRef<HTMLTableElement>(null)
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
const userWithPlaceholder = React.useMemo(() => ({ isPlaceholder: false, ...user }), [user])
const { user } = useFullUserSession()
const { getText } = useText()
const toastAndLog = useToastAndLog()
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set())
const rootRef = useRef<HTMLTableElement>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLTableSectionElement>(null)
const userWithPlaceholder = useMemo(() => ({ isPlaceholder: false, ...user }), [user])
const { data: allUsers } = backendHooks.useBackendQuery(backend, 'listUsers', [])
const { data: allUsers } = useBackendQuery(backend, 'listUsers', [])
const users = React.useMemo(
const users = useMemo(
() => allUsers ?? (populateWithSelf ? [userWithPlaceholder] : null),
[allUsers, populateWithSelf, userWithPlaceholder],
)
const usersMap = React.useMemo(
const usersMap = useMemo(
() => new Map((users ?? []).map((member) => [member.userId, member])),
[users],
)
const { onScroll, shadowClassName } = scrollHooks.useStickyTableHeaderOnScroll(
scrollContainerRef,
bodyRef,
{ trackShadowClass: true },
)
const { onScroll, shadowClassName } = useStickyTableHeaderOnScroll(scrollContainerRef, bodyRef, {
trackShadowClass: true,
})
const { dragAndDropHooks } = aria.useDragAndDrop({
const { dragAndDropHooks } = useDragAndDrop({
getItems: (keys) =>
[...keys].flatMap((key) => {
const userId = backendModule.UserId(String(key))
const userId = UserId(String(key))
const member = usersMap.get(userId)
return member != null ? [{ [mimeTypes.USER_MIME_TYPE]: JSON.stringify(member) }] : []
return member != null ? [{ [USER_MIME_TYPE]: JSON.stringify(member) }] : []
}),
renderDragPreview: (items) => {
return (
<div className="flex flex-col rounded-default bg-white backdrop-blur-default">
{items.flatMap((item) => {
const payload = item[mimeTypes.USER_MIME_TYPE]
const payload = item[USER_MIME_TYPE]
if (payload == null) {
return []
} else {
// This is SAFE. The type of the payload is known as it is set in `getItems` above.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const member: backendModule.User = JSON.parse(payload)
const member: User = JSON.parse(payload)
return [
<div key={member.userId} className="flex h-row items-center px-cell-x">
<aria.Text className="text">{member.name}</aria.Text>
<Text className="text">{member.name}</Text>
</div>,
]
}
@ -91,7 +91,7 @@ export default function MembersTable(props: MembersTableProps) {
},
})
React.useEffect(() => {
useEffect(() => {
const onClick = (event: Event) => {
if (event.target instanceof Node && rootRef.current?.contains(event.target) === false) {
setSelectedKeys(new Set())
@ -103,7 +103,7 @@ export default function MembersTable(props: MembersTableProps) {
}
}, [])
const doDeleteUser = async (userToDelete: backendModule.User) => {
const doDeleteUser = async (userToDelete: User) => {
try {
await Promise.resolve()
throw new Error('Not implemented yet')
@ -116,10 +116,10 @@ export default function MembersTable(props: MembersTableProps) {
return (
<div
ref={scrollContainerRef}
className={tailwindMerge.twMerge('overflow-auto overflow-x-hidden', shadowClassName)}
className={twMerge('overflow-auto overflow-x-hidden', shadowClassName)}
onScroll={onScroll}
>
<aria.Table
<Table
ref={rootRef}
aria-label={getText('users')}
selectionMode={draggable ? 'multiple' : 'none'}
@ -129,25 +129,20 @@ export default function MembersTable(props: MembersTableProps) {
className="w-settings-main-section max-w-full table-fixed self-start rounded-rows"
{...(draggable ? { dragAndDropHooks } : {})}
>
<aria.TableHeader className="sticky top h-row">
<aria.Column
<TableHeader className="sticky top h-row">
<Column
isRowHeader
className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
>
{getText('name')}
</aria.Column>
<aria.Column className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
</Column>
<Column className="w-48 border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
{getText('email')}
</aria.Column>
</Column>
{/* Delete button. */}
{allowDelete && <aria.Column className="w border-0" />}
</aria.TableHeader>
<aria.TableBody
ref={bodyRef}
items={users ?? []}
dependencies={[users]}
className="select-text"
>
{allowDelete && <Column className="w border-0" />}
</TableHeader>
<TableBody ref={bodyRef} items={users ?? []} dependencies={[users]} className="select-text">
{(member) => (
<UserRow
id={member.userId}
@ -156,8 +151,8 @@ export default function MembersTable(props: MembersTableProps) {
doDeleteUser={!allowDelete ? null : doDeleteUser}
/>
)}
</aria.TableBody>
</aria.Table>
</TableBody>
</Table>
</div>
)
}

View File

@ -1,9 +1,9 @@
/** @file Rendering for a settings section. */
import { Text } from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import type { SettingsContext, SettingsSectionData } from '#/layouts/Settings/settingsData'
import SettingsEntry from '#/layouts/Settings/SettingsEntry'
import { useText } from '#/providers/TextProvider'
import type { SettingsContext, SettingsSectionData } from './data'
import SettingsEntry from './Entry'
// =======================
// === SettingsSection ===

View File

@ -1,29 +0,0 @@
/** @file Rendering for an arbitrary {@link settingsData.SettingsEntryData}. */
import * as React from 'react'
import SettingsCustomEntry from '#/layouts/Settings/SettingsCustomEntry'
import * as settingsData from '#/layouts/Settings/settingsData'
import SettingsInputEntry from '#/layouts/Settings/SettingsInputEntry'
// =====================
// === SettingsEntry ===
// =====================
/** Props for a {@link SettingsEntry}. */
export interface SettingsEntryProps {
readonly context: settingsData.SettingsContext
readonly data: settingsData.SettingsEntryData
}
/** Rendering for an arbitrary {@link settingsData.SettingsEntryData}. */
export default function SettingsEntry(props: SettingsEntryProps) {
const { context, data } = props
switch (data.type) {
case settingsData.SettingsEntryType.input: {
return <SettingsInputEntry context={context} data={data} />
}
case settingsData.SettingsEntryType.custom: {
return <SettingsCustomEntry context={context} data={data} />
}
}
}

View File

@ -5,10 +5,10 @@ import { Header } from '#/components/aria'
import { ButtonGroup } from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import SidebarTabButton from '#/components/styled/SidebarTabButton'
import { SETTINGS_DATA, type SettingsContext } from '#/layouts/Settings/settingsData'
import type SettingsTabType from '#/layouts/Settings/SettingsTabType'
import { useText } from '#/providers/TextProvider'
import { twMerge } from '#/utilities/tailwindMerge'
import { SETTINGS_DATA, type SettingsContext } from './data'
import type SettingsTabType from './TabType'
// =======================
// === SettingsSidebar ===

View File

@ -1,18 +1,15 @@
/** @file Rendering for a settings section. */
import * as React from 'react'
import { Suspense, useMemo } from 'react'
import * as tailwindMerge from 'tailwind-merge'
import { twMerge } from 'tailwind-merge'
import * as billing from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import type * as settingsData from '#/layouts/Settings/settingsData'
import SettingsPaywall from '#/layouts/Settings/SettingsPaywall'
import SettingsSection from '#/layouts/Settings/SettingsSection'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as loader from '#/components/Loader'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Loader } from '#/components/Loader'
import { usePaywall } from '#/hooks/billing'
import { useFullUserSession } from '#/providers/AuthProvider'
import type { SettingsContext, SettingsSectionData, SettingsTabData } from './data'
import SettingsPaywall from './Paywall'
import SettingsSection from './Section'
// ===================
// === SettingsTab ===
@ -20,8 +17,8 @@ import * as loader from '#/components/Loader'
/** Props for a {@link SettingsTab}. */
export interface SettingsTabProps {
readonly context: settingsData.SettingsContext
readonly data: settingsData.SettingsTabData
readonly context: SettingsContext
readonly data: SettingsTabData
readonly onInteracted: () => void
}
@ -29,14 +26,14 @@ export interface SettingsTabProps {
export default function SettingsTab(props: SettingsTabProps) {
const { context, data, onInteracted } = props
const { sections } = data
const { user } = authProvider.useFullUserSession()
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan })
const { user } = useFullUserSession()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const paywallFeature =
data.feature != null && isFeatureUnderPaywall(data.feature) ? data.feature : null
const [columns, classes] = React.useMemo<
[readonly (readonly settingsData.SettingsSectionData[])[], readonly string[]]
const [columns, classes] = useMemo<
[readonly (readonly SettingsSectionData[])[], readonly string[]]
>(() => {
const resultColumns: settingsData.SettingsSectionData[][] = []
const resultColumns: SettingsSectionData[][] = []
const resultClasses: string[] = []
for (const section of sections) {
const columnNumber = section.column ?? 1
@ -79,7 +76,7 @@ export default function SettingsTab(props: SettingsTabProps) {
{columns.map((sectionsInColumn, i) => (
<div
key={i}
className={tailwindMerge.twMerge(
className={twMerge(
'flex h-fit flex-1 flex-col gap-settings-subsection pb-12',
classes[i],
)}
@ -92,11 +89,9 @@ export default function SettingsTab(props: SettingsTabProps) {
</div>
return (
<errorBoundary.ErrorBoundary>
<React.Suspense fallback={<loader.Loader size="medium" minHeight="h64" />}>
{content}
</React.Suspense>
</errorBoundary.ErrorBoundary>
<ErrorBoundary>
<Suspense fallback={<Loader size="medium" minHeight="h64" />}>{content}</Suspense>
</ErrorBoundary>
)
}
}

View File

@ -3,35 +3,33 @@ import * as React from 'react'
import { useMutation } from '@tanstack/react-query'
import * as mimeTypes from '#/data/mimeTypes'
import { Cell, Column, Row, Table, TableBody, TableHeader, useDragAndDrop } from '#/components/aria'
import { Button, ButtonGroup } from '#/components/AriaComponents'
import { PaywallDialogButton } from '#/components/Paywall'
import StatelessSpinner, { SpinnerState } from '#/components/StatelessSpinner'
import { USER_MIME_TYPE } from '#/data/mimeTypes'
import {
backendMutationOptions,
useBackendQuery,
useListUserGroupsWithUsers,
} from '#/hooks/backendHooks'
import * as billingHooks from '#/hooks/billing'
import * as scrollHooks from '#/hooks/scrollHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import UserGroupRow from '#/layouts/Settings/UserGroupRow'
import UserGroupUserRow from '#/layouts/Settings/UserGroupUserRow'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as paywallComponents from '#/components/Paywall'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import { usePaywall } from '#/hooks/billing'
import { useStickyTableHeaderOnScroll } from '#/hooks/scrollHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import NewUserGroupModal from '#/modals/NewUserGroupModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import {
isPlaceholderUserGroupId,
isUserGroupId,
type User,
type UserGroupInfo,
} from '#/services/Backend'
import { twMerge } from '#/utilities/tailwindMerge'
import UserGroupRow from './UserGroupRow'
import UserGroupUserRow from './UserGroupUserRow'
// =================================
// === UserGroupsSettingsSection ===
@ -45,10 +43,10 @@ export interface UserGroupsSettingsSectionProps {
/** Settings tab for viewing and editing organization members. */
export default function UserGroupsSettingsSection(props: UserGroupsSettingsSectionProps) {
const { backend } = props
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { user } = authProvider.useFullUserSession()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { setModal } = useSetModal()
const { getText } = useText()
const { user } = useFullUserSession()
const toastAndLog = useToastAndLog()
const { data: users } = useBackendQuery(backend, 'listUsers', [])
const userGroups = useListUserGroupsWithUsers(backend)
const rootRef = React.useRef<HTMLDivElement>(null)
@ -66,36 +64,39 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
const isLoading = userGroups == null || users == null
const isAdmin = user.isOrganizationAdmin
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('userGroupsFull')
const userGroupsLeft = isUnderPaywall ? 1 - (userGroups?.length ?? 0) : Infinity
const shouldDisplayPaywall = isUnderPaywall ? userGroupsLeft <= 0 : false
const { onScroll: onUserGroupsTableScroll, shadowClassName } =
scrollHooks.useStickyTableHeaderOnScroll(rootRef, bodyRef, { trackShadowClass: true })
const { onScroll: onUserGroupsTableScroll, shadowClassName } = useStickyTableHeaderOnScroll(
rootRef,
bodyRef,
{ trackShadowClass: true },
)
const { dragAndDropHooks } = aria.useDragAndDrop({
const { dragAndDropHooks } = useDragAndDrop({
isDisabled: !isAdmin,
getDropOperation: (target, types, allowedOperations) =>
(
allowedOperations.includes('copy') &&
types.has(mimeTypes.USER_MIME_TYPE) &&
types.has(USER_MIME_TYPE) &&
target.type === 'item' &&
typeof target.key === 'string' &&
backendModule.isUserGroupId(target.key) &&
!backendModule.isPlaceholderUserGroupId(target.key)
isUserGroupId(target.key) &&
!isPlaceholderUserGroupId(target.key)
) ?
'copy'
: 'cancel',
onItemDrop: (event) => {
if (typeof event.target.key === 'string' && backendModule.isUserGroupId(event.target.key)) {
if (typeof event.target.key === 'string' && isUserGroupId(event.target.key)) {
const userGroupId = event.target.key
for (const item of event.items) {
if (item.kind === 'text' && item.types.has(mimeTypes.USER_MIME_TYPE)) {
void item.getText(mimeTypes.USER_MIME_TYPE).then(async (text) => {
if (item.kind === 'text' && item.types.has(USER_MIME_TYPE)) {
void item.getText(USER_MIME_TYPE).then(async (text) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const newUser: backendModule.User = JSON.parse(text)
const newUser: User = JSON.parse(text)
const groups = usersMap.get(newUser.userId)?.userGroups ?? []
if (!groups.includes(userGroupId)) {
try {
@ -116,7 +117,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
},
})
const doDeleteUserGroup = async (userGroup: backendModule.UserGroupInfo) => {
const doDeleteUserGroup = async (userGroup: UserGroupInfo) => {
try {
await deleteUserGroup([userGroup.id, userGroup.groupName])
} catch (error) {
@ -124,10 +125,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
}
}
const doRemoveUserFromUserGroup = async (
otherUser: backendModule.User,
userGroup: backendModule.UserGroupInfo,
) => {
const doRemoveUserFromUserGroup = async (otherUser: User, userGroup: UserGroupInfo) => {
try {
const intermediateUserGroups =
otherUser.userGroups?.filter((userGroupId) => userGroupId !== userGroup.id) ?? null
@ -141,9 +139,9 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
return (
<>
{isAdmin && (
<ariaComponents.ButtonGroup verticalAlign="center">
<ButtonGroup verticalAlign="center">
{shouldDisplayPaywall && (
<paywallComponents.PaywallDialogButton
<PaywallDialogButton
feature="userGroupsFull"
variant="outline"
size="medium"
@ -152,10 +150,10 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
tooltip={getText('userGroupsPaywallMessage')}
>
{getText('newUserGroup')}
</paywallComponents.PaywallDialogButton>
</PaywallDialogButton>
)}
{!shouldDisplayPaywall && (
<ariaComponents.Button
<Button
size="medium"
variant="outline"
onPress={(event) => {
@ -165,7 +163,7 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
}}
>
{getText('newUserGroup')}
</ariaComponents.Button>
</Button>
)}
{isUnderPaywall && (
@ -175,40 +173,40 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
: getText('userGroupsLimitMessage', userGroupsLeft)}
</span>
)}
</ariaComponents.ButtonGroup>
</ButtonGroup>
)}
<div
ref={rootRef}
className={tailwindMerge.twMerge(
className={twMerge(
'overflow-auto overflow-x-hidden transition-all lg:mb-2',
shadowClassName,
)}
onScroll={onUserGroupsTableScroll}
>
<aria.Table
<Table
aria-label={getText('userGroups')}
className="w-full max-w-3xl table-fixed self-start rounded-rows"
dragAndDropHooks={dragAndDropHooks}
>
<aria.TableHeader className="sticky top h-row">
<aria.Column
<TableHeader className="sticky top h-row">
<Column
isRowHeader
className="w-full border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0"
>
{getText('userGroup')}
</aria.Column>
</Column>
{/* Delete button. */}
<aria.Column className="relative border-0" />
</aria.TableHeader>
<aria.TableBody
<Column className="relative border-0" />
</TableHeader>
<TableBody
ref={bodyRef}
items={userGroups ?? []}
dependencies={[isLoading, userGroups]}
className="select-text"
>
{isLoading ?
<aria.Row className="h-row">
<aria.Cell
<Row className="h-row">
<Cell
ref={(element) => {
if (element instanceof HTMLTableCellElement) {
element.colSpan = 2
@ -216,21 +214,18 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
}}
>
<div className="flex justify-center">
<StatelessSpinner
size={32}
state={statelessSpinner.SpinnerState.loadingMedium}
/>
<StatelessSpinner size={32} state={SpinnerState.loadingMedium} />
</div>
</aria.Cell>
</aria.Row>
</Cell>
</Row>
: userGroups.length === 0 ?
<aria.Row className="h-row">
<aria.Cell className="col-span-2 px-2.5 placeholder">
<Row className="h-row">
<Cell className="col-span-2 px-2.5 placeholder">
{isAdmin ?
getText('youHaveNoUserGroupsAdmin')
: getText('youHaveNoUserGroupsNonAdmin')}
</aria.Cell>
</aria.Row>
</Cell>
</Row>
: (userGroup) => (
<>
<UserGroupRow userGroup={userGroup} doDeleteUserGroup={doDeleteUserGroup} />
@ -245,8 +240,8 @@ export default function UserGroupsSettingsSection(props: UserGroupsSettingsSecti
</>
)
}
</aria.TableBody>
</aria.Table>
</TableBody>
</Table>
</div>
</>
)

View File

@ -1,10 +1,10 @@
/** @file Metadata for rendering each settings section. */
import * as React from 'react'
import type { ReactNode } from 'react'
import type * as reactQuery from '@tanstack/react-query'
import type { QueryClient } from '@tanstack/react-query'
import isEmail from 'validator/lib/isEmail'
import type * as text from 'enso-common/src/text'
import type { TextId } from 'enso-common/src/text'
import ComputerIcon from '#/assets/computer.svg'
import CreditCardIcon from '#/assets/credit_card.svg'
@ -13,36 +13,35 @@ import LogIcon from '#/assets/log.svg'
import PeopleIcon from '#/assets/people.svg'
import PeopleSettingsIcon from '#/assets/people_settings.svg'
import SettingsIcon from '#/assets/settings.svg'
import * as inputBindings from '#/configurations/inputBindings'
import type * as billing from '#/hooks/billing'
import type * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import type * as textProvider from '#/providers/TextProvider'
import ActivityLogSettingsSection from '#/layouts/Settings/ActivityLogSettingsSection'
import ChangePasswordForm from '#/layouts/Settings/ChangePasswordForm'
import DeleteUserAccountSettingsSection from '#/layouts/Settings/DeleteUserAccountSettingsSection'
import KeyboardShortcutsSettingsSection from '#/layouts/Settings/KeyboardShortcutsSettingsSection'
import MembersSettingsSection from '#/layouts/Settings/MembersSettingsSection'
import MembersTable from '#/layouts/Settings/MembersTable'
import OrganizationProfilePictureInput from '#/layouts/Settings/OrganizationProfilePictureInput'
import ProfilePictureInput from '#/layouts/Settings/ProfilePictureInput'
import SettingsTabType from '#/layouts/Settings/SettingsTabType'
import UserGroupsSettingsSection from '#/layouts/Settings/UserGroupsSettingsSection'
import { Button, ButtonGroup } from '#/components/AriaComponents'
import * as menuEntry from '#/components/MenuEntry'
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 type { GetText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as backend from '#/services/Backend'
import {
EmailAddress,
HttpsUrl,
isUserOnPlanWithOrganization,
type OrganizationInfo,
type User,
} from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
import { normalizePath } from '#/utilities/fileInfo'
import * as object from '#/utilities/object'
import { unsafeEntries } from '#/utilities/object'
import ActivityLogSettingsSection from './ActivityLogSettingsSection'
import ChangePasswordForm from './ChangePasswordForm'
import DeleteUserAccountSettingsSection from './DeleteUserAccountSettingsSection'
import KeyboardShortcutsSettingsSection from './KeyboardShortcutsSettingsSection'
import MembersSettingsSection from './MembersSettingsSection'
import MembersTable from './MembersTable'
import OrganizationProfilePictureInput from './OrganizationProfilePictureInput'
import ProfilePictureInput from './ProfilePictureInput'
import { SetupTwoFaForm } from './SetupTwoFaForm'
import SettingsTabType from './TabType'
import UserGroupsSettingsSection from './UserGroupsSettingsSection'
// =========================
// === SettingsEntryType ===
@ -171,7 +170,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.organization,
icon: PeopleSettingsIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
visible: ({ user }) => isUserOnPlanWithOrganization(user),
sections: [
{
nameId: 'organizationSettingsSection',
@ -194,7 +193,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
nameId: 'organizationEmailSettingsInput',
getValue: (context) => context.organization?.email ?? '',
setValue: async (context, newValue) => {
const newEmail = backend.EmailAddress(newValue)
const newEmail = EmailAddress(newValue)
const oldEmail = context.organization?.email ?? null
if (oldEmail !== newEmail) {
await context.updateOrganization([{ email: newEmail }])
@ -211,7 +210,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
nameId: 'organizationWebsiteSettingsInput',
getValue: (context) => context.organization?.website ?? '',
setValue: async (context, newValue) => {
const newWebsite = backend.HttpsUrl(newValue)
const newWebsite = HttpsUrl(newValue)
const oldWebsite = context.organization?.website ?? null
if (oldWebsite !== newWebsite) {
await context.updateOrganization([{ website: newWebsite }])
@ -332,7 +331,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.members,
icon: PeopleIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
visible: ({ user }) => isUserOnPlanWithOrganization(user),
feature: 'inviteUser',
sections: [
{
@ -346,7 +345,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.userGroups,
icon: PeopleSettingsIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
visible: ({ user }) => isUserOnPlanWithOrganization(user),
feature: 'userGroups',
sections: [
{
@ -390,16 +389,14 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
type: SettingsEntryType.custom,
aliasesId: 'keyboardShortcutsSettingsCustomEntryAliases',
getExtraAliases: (context) => {
const rebindableBindings = object
.unsafeEntries(inputBindings.BINDINGS)
.flatMap((kv) => {
const [k, v] = kv
if (v.rebindable === false) {
return []
} else {
return menuEntry.ACTION_TO_TEXT_ID[k]
}
})
const rebindableBindings = unsafeEntries(BINDINGS).flatMap((kv) => {
const [k, v] = kv
if (v.rebindable === false) {
return []
} else {
return ACTION_TO_TEXT_ID[k]
}
})
return rebindableBindings.map((binding) => context.getText(binding))
},
render: KeyboardShortcutsSettingsSection,
@ -413,7 +410,7 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
settingsTab: SettingsTabType.activityLog,
icon: LogIcon,
organizationOnly: true,
visible: ({ user }) => backend.isUserOnPlanWithOrganization(user),
visible: ({ user }) => isUserOnPlanWithOrganization(user),
sections: [
{
nameId: 'activityLogSettingsSection',
@ -466,19 +463,19 @@ export const ALL_SETTINGS_TABS = SETTINGS_DATA.flatMap((section) =>
/** Metadata describing inputs passed to every settings entry. */
export interface SettingsContext {
readonly accessToken: string
readonly user: backend.User
readonly user: User
readonly backend: RemoteBackend
readonly localBackend: LocalBackend | null
readonly organization: backend.OrganizationInfo | null
readonly organization: OrganizationInfo | null
readonly updateUser: (variables: Parameters<Backend['updateUser']>) => Promise<void>
readonly updateOrganization: (
variables: Parameters<Backend['updateOrganization']>,
) => Promise<backend.OrganizationInfo | null | undefined>
) => Promise<OrganizationInfo | null | undefined>
readonly updateLocalRootPath: (rootPath: string) => void
readonly resetLocalRootPath: () => void
readonly toastAndLog: toastAndLogHooks.ToastAndLogCallback
readonly getText: textProvider.GetText
readonly queryClient: reactQuery.QueryClient
readonly toastAndLog: ToastAndLogCallback
readonly getText: GetText
readonly queryClient: QueryClient
}
// ==============================
@ -488,7 +485,7 @@ export interface SettingsContext {
/** Metadata describing a settings entry that is an input. */
export interface SettingsInputEntryData {
readonly type: SettingsEntryType.input
readonly nameId: text.TextId & `${string}SettingsInput`
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
@ -503,9 +500,9 @@ export interface SettingsInputEntryData {
/** Metadata describing a settings entry that needs custom rendering. */
export interface SettingsCustomEntryData {
readonly type: SettingsEntryType.custom
readonly aliasesId?: text.TextId & `${string}SettingsCustomEntryAliases`
readonly aliasesId?: TextId & `${string}SettingsCustomEntryAliases`
readonly getExtraAliases?: (context: SettingsContext) => readonly string[]
readonly render: (context: SettingsContext) => React.ReactNode
readonly render: (context: SettingsContext) => ReactNode
readonly getVisible?: (context: SettingsContext) => boolean
}
@ -522,13 +519,13 @@ export type SettingsEntryData = SettingsCustomEntryData | SettingsInputEntryData
/** Metadata describing a settings section. */
export interface SettingsSectionData {
readonly nameId: text.TextId & `${string}SettingsSection`
readonly nameId: TextId & `${string}SettingsSection`
/** The first column is column 1, not column 0. */
readonly column?: number
readonly heading?: false
readonly focusArea?: false
readonly columnClassName?: string
readonly aliases?: text.TextId[]
readonly aliases?: TextId[]
readonly entries: readonly SettingsEntryData[]
}
@ -538,14 +535,14 @@ export interface SettingsSectionData {
/** Metadata describing a settings tab. */
export interface SettingsTabData {
readonly nameId: text.TextId & `${string}SettingsTab`
readonly nameId: TextId & `${string}SettingsTab`
readonly settingsTab: SettingsTabType
readonly icon: string
readonly visible?: (context: SettingsContext) => boolean
readonly organizationOnly?: true
/** The feature behind which this settings tab is locked. If the user cannot access the feature,
* a paywall is shown instead of the settings tab. */
readonly feature?: billing.PaywallFeatureName
readonly feature?: PaywallFeatureName
readonly sections: readonly SettingsSectionData[]
readonly onPress?: (context: SettingsContext) => Promise<void> | void
}
@ -556,7 +553,7 @@ export interface SettingsTabData {
/** Metadata describing a settings tab section. */
export interface SettingsTabSectionData {
readonly nameId: text.TextId & `${string}SettingsTabSection`
readonly nameId: TextId & `${string}SettingsTabSection`
readonly tabs: readonly SettingsTabData[]
}

View File

@ -4,33 +4,35 @@ import * as React from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import BurgerMenuIcon from '#/assets/burger_menu.svg'
import { MenuTrigger } from '#/components/aria'
import { Button, Popover, Text } from '#/components/AriaComponents'
import { useStrictPortalContext } from '#/components/Portal'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import SearchBar from '#/layouts/SearchBar'
import * as settingsData from '#/layouts/Settings/settingsData'
import SettingsTab from '#/layouts/Settings/SettingsTab'
import SettingsTabType from '#/layouts/Settings/SettingsTabType'
import SettingsSidebar from '#/layouts/SettingsSidebar'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import * as portal from '#/components/Portal'
import Button from '#/components/styled/Button'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useLocalBackend, useRemoteBackendStrict } from '#/providers/BackendProvider'
import { useLocalStorageState } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as projectManager from '#/services/ProjectManager'
import * as array from '#/utilities/array'
import * as string from '#/utilities/string'
import { Path } from '#/services/ProjectManager'
import { includesPredicate } from '#/utilities/array'
import { regexEscape } from '#/utilities/string'
import {
ALL_SETTINGS_TABS,
SETTINGS_DATA,
SETTINGS_NO_RESULTS_SECTION_DATA,
SETTINGS_TAB_DATA,
SettingsEntryType,
type SettingsContext,
type SettingsEntryData,
type SettingsTabData,
} from './data'
import SettingsSidebar from './Sidebar'
import SettingsTab from './Tab'
import SettingsTabType from './TabType'
// ================
// === Settings ===
@ -44,18 +46,18 @@ export interface SettingsProps {
/** Settings screen. */
export default function Settings() {
const queryClient = useQueryClient()
const backend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
const [tab, setTab] = searchParamsState.useSearchParamsState(
const backend = useRemoteBackendStrict()
const localBackend = useLocalBackend()
const [tab, setTab] = useSearchParamsState(
'SettingsTab',
SettingsTabType.account,
array.includesPredicate(Object.values(SettingsTabType)),
includesPredicate(Object.values(SettingsTabType)),
)
const { user, accessToken } = authProvider.useFullUserSession()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user, accessToken } = useFullUserSession()
const { getText } = useText()
const toastAndLog = useToastAndLog()
const [query, setQuery] = React.useState('')
const root = portal.useStrictPortalContext()
const root = useStrictPortalContext()
const [isSidebarPopoverOpen, setIsSidebarPopoverOpen] = React.useState(false)
const { data: organization = null } = useBackendQuery(backend, 'getOrganization', [])
const isQueryBlank = !/\S/.test(query)
@ -69,7 +71,7 @@ export default function Settings() {
const updateLocalRootPath = useEventCallback((value: string) => {
setLocalRootDirectory(value)
if (localBackend) {
localBackend.setRootPath(projectManager.Path(value))
localBackend.setRootPath(Path(value))
}
})
const resetLocalRootPath = useEventCallback(() => {
@ -77,7 +79,7 @@ export default function Settings() {
localBackend?.resetRootPath()
})
const context = React.useMemo<settingsData.SettingsContext>(
const context = React.useMemo<SettingsContext>(
() => ({
accessToken,
user,
@ -109,17 +111,17 @@ export default function Settings() {
)
const isMatch = React.useMemo(() => {
const regex = new RegExp(string.regexEscape(query.trim()).replace(/\s+/g, '.+'), 'i')
const regex = new RegExp(regexEscape(query.trim()).replace(/\s+/g, '.+'), 'i')
return (name: string) => regex.test(name)
}, [query])
const doesEntryMatchQuery = React.useCallback(
(entry: settingsData.SettingsEntryData) => {
(entry: SettingsEntryData) => {
switch (entry.type) {
case settingsData.SettingsEntryType.input: {
case SettingsEntryType.input: {
return isMatch(getText(entry.nameId))
}
case settingsData.SettingsEntryType.custom: {
case SettingsEntryType.custom: {
const doesAliasesIdMatch =
entry.aliasesId == null ? false : getText(entry.aliasesId).split('\n').some(isMatch)
if (doesAliasesIdMatch) {
@ -137,9 +139,9 @@ export default function Settings() {
const tabsToShow = React.useMemo<readonly SettingsTabType[]>(() => {
if (isQueryBlank) {
return settingsData.ALL_SETTINGS_TABS
return ALL_SETTINGS_TABS
} else {
return settingsData.SETTINGS_DATA.flatMap((tabSection) =>
return SETTINGS_DATA.flatMap((tabSection) =>
tabSection.tabs
.filter((tabData) =>
isMatch(getText(tabData.nameId)) || isMatch(getText(tabSection.nameId)) ?
@ -154,8 +156,8 @@ export default function Settings() {
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch])
const effectiveTab = tabsToShow.includes(tab) ? tab : tabsToShow[0] ?? SettingsTabType.account
const data = React.useMemo<settingsData.SettingsTabData>(() => {
const tabData = settingsData.SETTINGS_TAB_DATA[effectiveTab]
const data = React.useMemo<SettingsTabData>(() => {
const tabData = SETTINGS_TAB_DATA[effectiveTab]
if (isQueryBlank) {
return tabData
} else {
@ -175,8 +177,7 @@ export default function Settings() {
})
return {
...tabData,
sections:
sections.length === 0 ? [settingsData.SETTINGS_NO_RESULTS_SECTION_DATA] : sections,
sections: sections.length === 0 ? [SETTINGS_NO_RESULTS_SECTION_DATA] : sections,
}
}
}
@ -184,10 +185,10 @@ export default function Settings() {
return (
<div className="flex flex-1 flex-col gap-4 overflow-hidden pl-page-x pt-4">
<aria.Heading level={1} className="flex items-center px-heading-x">
<aria.MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button image={BurgerMenuIcon} buttonClassName="mr-3 sm:hidden" onPress={() => {}} />
<aria.Popover UNSTABLE_portalContainer={root}>
<Text.Heading level={1} className="flex items-center px-heading-x">
<MenuTrigger isOpen={isSidebarPopoverOpen} onOpenChange={setIsSidebarPopoverOpen}>
<Button size="custom" variant="custom" icon={BurgerMenuIcon} className="mr-3 sm:hidden" />
<Popover UNSTABLE_portalContainer={root}>
<SettingsSidebar
isMenu
context={context}
@ -198,21 +199,21 @@ export default function Settings() {
setIsSidebarPopoverOpen(false)
}}
/>
</aria.Popover>
</aria.MenuTrigger>
</Popover>
</MenuTrigger>
<ariaComponents.Text variant="h1" className="font-bold">
<Text variant="h1" className="font-bold">
{getText('settingsFor')}
</ariaComponents.Text>
</Text>
<ariaComponents.Text
<Text
variant="h1"
truncate="1"
className="ml-2.5 mr-8 max-w-lg rounded-full bg-white px-2.5 font-bold"
className="ml-2.5 mr-8 max-w-[min(32rem,_100%)] rounded-full bg-white px-2.5 font-bold"
aria-hidden
>
{data.organizationOnly === true ? organization?.name ?? 'your organization' : user.name}
</ariaComponents.Text>
</Text>
<SearchBar
data-testid="settings-search-bar"
@ -221,7 +222,7 @@ export default function Settings() {
label={getText('settingsSearchBarLabel')}
placeholder={getText('settingsSearchBarPlaceholder')}
/>
</aria.Heading>
</Text.Heading>
<div className="flex sm:ml-[222px]" />
<div className="flex flex-1 gap-4 overflow-hidden">
<aside className="hidden h-full shrink-0 basis-[206px] flex-col overflow-y-auto overflow-x-hidden pb-12 sm:flex">

View File

@ -1,23 +1,15 @@
/** @file A toolbar containing chat and the user menu. */
import { SUBSCRIBE_PATH } from '#/appUtils'
import ChatIcon from '#/assets/chat.svg'
import DefaultUserIcon from '#/assets/default_user.svg'
import * as appUtils from '#/appUtils'
import * as billing from '#/hooks/billing'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import UserMenu from '#/layouts/UserMenu'
import * as ariaComponents from '#/components/AriaComponents'
import * as paywall from '#/components/Paywall'
import Button from '#/components/styled/Button'
import { Button, DialogTrigger } from '#/components/AriaComponents'
import { PaywallDialogButton } from '#/components/Paywall'
import FocusArea from '#/components/styled/FocusArea'
import { usePaywall } from '#/hooks/billing'
import UserMenu from '#/layouts/UserMenu'
import InviteUsersModal from '#/modals/InviteUsersModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
import { Plan } from '#/services/Backend'
// =================
@ -46,10 +38,9 @@ export interface UserBarProps {
export default function UserBar(props: UserBarProps) {
const { invisible = false, setIsHelpChatOpen, onShareClick, goToSettingsPage, onSignOut } = props
const { user } = authProvider.useFullUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan })
const { user } = useFullUserSession()
const { getText } = useText()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const shouldShowUpgradeButton =
user.isOrganizationAdmin && user.plan !== Plan.enterprise && user.plan !== Plan.team
@ -71,7 +62,7 @@ export default function UserBar(props: UserBarProps) {
{...innerProps}
>
{SHOULD_SHOW_CHAT_BUTTON && (
<ariaComponents.Button
<Button
variant="icon"
size="custom"
className="mr-1"
@ -84,48 +75,51 @@ export default function UserBar(props: UserBarProps) {
)}
{shouldShowPaywallButton && (
<paywall.PaywallDialogButton feature="inviteUser" size="medium" variant="accent">
<PaywallDialogButton feature="inviteUser" size="medium" variant="accent">
{getText('invite')}
</paywall.PaywallDialogButton>
</PaywallDialogButton>
)}
{shouldShowInviteButton && (
<ariaComponents.DialogTrigger>
<ariaComponents.Button size="medium" variant="accent">
<DialogTrigger>
<Button size="medium" variant="accent">
{getText('invite')}
</ariaComponents.Button>
</Button>
<InviteUsersModal />
</ariaComponents.DialogTrigger>
</DialogTrigger>
)}
{shouldShowUpgradeButton && (
<ariaComponents.Button variant="primary" size="medium" href={appUtils.SUBSCRIBE_PATH}>
<Button variant="primary" size="medium" href={SUBSCRIBE_PATH}>
{getText('upgrade')}
</ariaComponents.Button>
</Button>
)}
{shouldShowShareButton && (
<ariaComponents.Button
<Button
size="medium"
variant="accent"
aria-label={getText('shareButtonAltText')}
onPress={onShareClick}
>
{getText('share')}
</ariaComponents.Button>
</Button>
)}
<Button
active
mask={false}
alt={getText('userMenuAltText')}
image={user.profilePicture ?? DefaultUserIcon}
buttonClassName="rounded-full after:rounded-full"
className="h-row-h w-row-h rounded-full"
onPress={() => {
setModal(<UserMenu goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />)
}}
/>
<DialogTrigger>
<Button
size="custom"
variant="icon"
isActive
icon={
<img src={user.profilePicture ?? DefaultUserIcon} className="aspect-square" />
}
aria-label={getText('userMenuLabel')}
className="overflow-clip rounded-full opacity-100"
contentClassName="size-8"
/>
<UserMenu goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />
</DialogTrigger>
{/* Required for shortcuts to work. */}
<div className="hidden">
<UserMenu hidden goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />

View File

@ -1,27 +1,17 @@
/** @file The UserMenu component provides a dropdown menu of user actions and settings. */
import * as React from 'react'
/** @file A dropdown menu of user actions and settings. */
import DefaultUserIcon from '#/assets/default_user.svg'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import { Text } from '#/components/AriaComponents'
import { Popover, Text } from '#/components/AriaComponents'
import MenuEntry from '#/components/MenuEntry'
import Modal from '#/components/Modal'
import FocusArea from '#/components/styled/FocusArea'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import AboutModal from '#/modals/AboutModal'
import { useAuth, useFullUserSession } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import { Plan } from '#/services/Backend'
import * as download from '#/utilities/download'
import * as github from '#/utilities/github'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { download } from '#/utilities/download'
import { getDownloadUrl } from '#/utilities/github'
// ================
// === UserMenu ===
@ -35,21 +25,16 @@ export interface UserMenuProps {
readonly onSignOut: () => void
}
/** Handling the UserMenuItem click event logic and displaying its content. */
/** A dropdown menu of user actions and settings. */
export default function UserMenu(props: UserMenuProps) {
const { hidden = false, goToSettingsPage, onSignOut } = props
const [initialized, setInitialized] = React.useState(false)
const localBackend = backendProvider.useLocalBackend()
const { signOut } = authProvider.useAuth()
const { user } = authProvider.useFullUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
React.useLayoutEffect(() => {
setInitialized(true)
}, [])
const localBackend = useLocalBackend()
const { signOut } = useAuth()
const { user } = useFullUserSession()
const { setModal, unsetModal } = useSetModal()
const { getText } = useText()
const toastAndLog = useToastAndLog()
const aboutThisAppMenuEntry = (
<MenuEntry
@ -61,82 +46,55 @@ export default function UserMenu(props: UserMenuProps) {
)
return (
<Modal hidden={hidden} className="absolute size-full overflow-hidden bg-dim">
<div
{...(!hidden ? { 'data-testid': 'user-menu' } : {})}
className={tailwindMerge.twMerge(
'absolute right-2 top-2 flex flex-col gap-user-menu rounded-default bg-selected-frame backdrop-blur-default transition-all duration-user-menu',
initialized ? 'w-user-menu p-user-menu' : 'size-row-h',
)}
onClick={(event) => {
event.stopPropagation()
}}
>
<div
className={tailwindMerge.twMerge(
'flex items-center gap-icons overflow-hidden transition-all duration-user-menu',
initialized && 'px-menu-entry',
)}
>
<div className="flex size-row-h shrink-0 items-center overflow-clip rounded-full">
<img
src={user.profilePicture ?? DefaultUserIcon}
className="pointer-events-none size-row-h"
/>
</div>
<div className="flex flex-col">
<Text disableLineHeightCompensation variant="body" truncate="1" weight="semibold">
{user.name}
</Text>
<Text disableLineHeightCompensation>{getText(`${user.plan ?? Plan.free}`)}</Text>
</div>
<Popover {...(!hidden ? { testId: 'user-menu' } : {})} size="xxsmall">
<div className="mb-2 flex items-center gap-icons overflow-hidden px-menu-entry transition-all duration-user-menu">
<div className="flex size-row-h shrink-0 items-center overflow-clip rounded-full">
<img
src={user.profilePicture ?? DefaultUserIcon}
className="pointer-events-none size-row-h"
/>
</div>
<div
className={tailwindMerge.twMerge(
'grid transition-all duration-user-menu',
initialized ? 'grid-rows-1fr' : 'grid-rows-0fr',
)}
>
<FocusArea direction="vertical">
{(innerProps) => (
<div
aria-label={getText('userMenuLabel')}
className="flex flex-col overflow-hidden"
{...innerProps}
>
{localBackend == null && (
<MenuEntry
action="downloadApp"
doAction={async () => {
unsetModal()
const downloadUrl = await github.getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download.download(downloadUrl)
}
}}
/>
)}
<MenuEntry action="settings" doAction={goToSettingsPage} />
{aboutThisAppMenuEntry}
<MenuEntry
action="signOut"
doAction={() => {
onSignOut()
// Wait until React has switched back to drive view, before signing out.
window.setTimeout(() => {
void signOut()
}, 0)
}}
/>
</div>
)}
</FocusArea>
<div className="flex min-w-0 flex-col">
<Text disableLineHeightCompensation variant="body" truncate="1" weight="semibold">
{user.name}
</Text>
<Text disableLineHeightCompensation>{getText(`${user.plan ?? Plan.free}`)}</Text>
</div>
</div>
</Modal>
<FocusArea direction="vertical">
{(innerProps) => (
<div className="flex flex-col overflow-hidden" {...innerProps}>
{localBackend == null && (
<MenuEntry
action="downloadApp"
doAction={async () => {
unsetModal()
const downloadUrl = await getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('noAppDownloadError')
} else {
download(downloadUrl)
}
}}
/>
)}
<MenuEntry action="settings" doAction={goToSettingsPage} />
{aboutThisAppMenuEntry}
<MenuEntry
action="signOut"
doAction={() => {
onSignOut()
// Wait until React has switched back to drive view, before signing out.
window.setTimeout(() => {
void signOut()
}, 0)
}}
/>
</div>
)}
</FocusArea>
</Popover>
)
}

View File

@ -151,9 +151,7 @@ export function AgreementsModal() {
</Button>
}
>
<Checkbox testId="terms-of-service-checkbox" value="agree">
{getText('licenseAgreementCheckbox')}
</Checkbox>
<Checkbox value="agree">{getText('licenseAgreementCheckbox')}</Checkbox>
</Checkbox.Group>
<Checkbox.Group
@ -165,9 +163,7 @@ export function AgreementsModal() {
</Button>
}
>
<Checkbox testId="privacy-policy-checkbox" value="agree">
{getText('privacyPolicyCheckbox')}
</Checkbox>
<Checkbox value="agree">{getText('privacyPolicyCheckbox')}</Checkbox>
</Checkbox.Group>
<Form.Submit fullWidth>{getText('accept')}</Form.Submit>

View File

@ -144,14 +144,10 @@ export function InviteUsersForm(props: InviteUsersFormProps) {
})
}}
>
<ariaComponents.Text disableLineHeightCompensation>
{getText('inviteFormDescription')}
</ariaComponents.Text>
<ariaComponents.ResizableContentEditableInput
ref={inputRef}
name="emails"
aria-label={getText('inviteEmailFieldLabel')}
label={getText('inviteEmailFieldLabel')}
placeholder={getText('inviteEmailFieldPlaceholder')}
description={getText('inviteEmailFieldDescription')}
/>

View File

@ -1,30 +1,31 @@
/** @file A modal to select labels for an asset. */
import * as React from 'react'
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from 'react'
import { useMutation } from '@tanstack/react-query'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import { Heading, Text } from '#/components/aria'
import { Button, ButtonGroup, Input } from '#/components/AriaComponents'
import ColorPicker from '#/components/ColorPicker'
import Label from '#/components/dashboard/Label'
import Modal from '#/components/Modal'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import Input from '#/components/styled/Input'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import * as eventModule from '#/utilities/event'
import * as object from '#/utilities/object'
import * as string from '#/utilities/string'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import {
findLeastUsedColor,
LabelName,
lChColorToCssColor,
type AnyAsset,
type LChColor,
} from '#/services/Backend'
import { submitForm } from '#/utilities/event'
import { merge } from '#/utilities/object'
import { regexEscape } from '#/utilities/string'
import { twMerge } from '#/utilities/tailwindMerge'
// =================
// === Constants ===
@ -38,12 +39,10 @@ const MAXIMUM_DARK_LIGHTNESS = 50
// =========================
/** Props for a {@link ManageLabelsModal}. */
export interface ManageLabelsModalProps<
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
> {
export interface ManageLabelsModalProps<Asset extends AnyAsset = AnyAsset> {
readonly backend: Backend
readonly item: Asset
readonly setItem: React.Dispatch<React.SetStateAction<Asset>>
readonly setItem: Dispatch<SetStateAction<Asset>>
/** If this is `null`, this modal will be centered. */
readonly eventTarget: HTMLElement | null
}
@ -51,25 +50,22 @@ export interface ManageLabelsModalProps<
/** A modal to select labels for an asset.
* @throws {Error} when the current backend is the local backend, or when the user is offline.
* This should never happen, as this modal should not be accessible in either case. */
export default function ManageLabelsModal<
Asset extends backendModule.AnyAsset = backendModule.AnyAsset,
>(props: ManageLabelsModalProps<Asset>) {
export default function ManageLabelsModal<Asset extends AnyAsset = AnyAsset>(
props: ManageLabelsModalProps<Asset>,
) {
const { backend, item, setItem, eventTarget } = props
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { unsetModal } = useSetModal()
const { getText } = useText()
const toastAndLog = useToastAndLog()
const { data: allLabels } = useBackendQuery(backend, 'listTags', [])
const [labels, setLabelsRaw] = React.useState(item.labels ?? [])
const [query, setQuery] = React.useState('')
const [color, setColor] = React.useState<backendModule.LChColor | null>(null)
const leastUsedColor = React.useMemo(
() => backendModule.getLeastUsedColor(allLabels ?? []),
[allLabels],
)
const position = React.useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const labelNames = React.useMemo(() => new Set(labels), [labels])
const regex = React.useMemo(() => new RegExp(string.regexEscape(query), 'i'), [query])
const canSelectColor = React.useMemo(
const [labels, setLabelsRaw] = useState(item.labels ?? [])
const [query, setQuery] = useState('')
const [color, setColor] = useState<LChColor | null>(null)
const leastUsedColor = useMemo(() => findLeastUsedColor(allLabels ?? []), [allLabels])
const position = useMemo(() => eventTarget?.getBoundingClientRect(), [eventTarget])
const labelNames = useMemo(() => new Set(labels), [labels])
const regex = useMemo(() => new RegExp(regexEscape(query), 'i'), [query])
const canSelectColor = useMemo(
() => query !== '' && (allLabels ?? []).filter((label) => regex.test(label.value)).length === 0,
[allLabels, query, regex],
)
@ -78,13 +74,13 @@ export default function ManageLabelsModal<
const createTag = useMutation(backendMutationOptions(backend, 'createTag')).mutateAsync
const associateTag = useMutation(backendMutationOptions(backend, 'associateTag')).mutateAsync
const setLabels = React.useCallback(
(valueOrUpdater: React.SetStateAction<readonly backendModule.LabelName[]>) => {
const setLabels = useCallback(
(valueOrUpdater: SetStateAction<readonly LabelName[]>) => {
setLabelsRaw(valueOrUpdater)
setItem((oldItem) =>
// This is SAFE, as the type of asset is not being changed.
// eslint-disable-next-line no-restricted-syntax
object.merge(oldItem, {
merge(oldItem, {
labels:
typeof valueOrUpdater !== 'function' ? valueOrUpdater : (
valueOrUpdater(oldItem.labels ?? [])
@ -95,7 +91,7 @@ export default function ManageLabelsModal<
[setItem],
)
const doToggleLabel = async (name: backendModule.LabelName) => {
const doToggleLabel = async (name: LabelName) => {
const newLabels =
labelNames.has(name) ? labels.filter((label) => label !== name) : [...labels, name]
setLabels(newLabels)
@ -109,7 +105,7 @@ export default function ManageLabelsModal<
const doSubmit = async () => {
unsetModal()
const labelName = backendModule.LabelName(query)
const labelName = LabelName(query)
setLabels((oldLabels) => [...oldLabels, labelName])
try {
await createTag([{ value: labelName, color: color ?? leastUsedColor }])
@ -155,19 +151,16 @@ export default function ManageLabelsModal<
void doSubmit()
}}
>
<aria.Heading
level={2}
className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x"
>
<aria.Text className="text text-sm font-bold">{getText('labels')}</aria.Text>
</aria.Heading>
<Heading level={2} className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<Text className="text text-sm font-bold">{getText('labels')}</Text>
</Heading>
{
<FocusArea direction="horizontal">
{(innerProps) => (
<ariaComponents.ButtonGroup className="relative" {...innerProps}>
<ButtonGroup className="relative" {...innerProps}>
<FocusRing within>
<div
className={tailwindMerge.twMerge(
className={twMerge(
'flex grow items-center rounded-full border border-primary/10 px-input-x',
(
canSelectColor &&
@ -181,14 +174,15 @@ export default function ManageLabelsModal<
!canSelectColor || color == null ?
{}
: {
backgroundColor: backendModule.lChColorToCssColor(color),
backgroundColor: lChColorToCssColor(color),
}
}
>
<Input
name="search-labels"
autoFocus
type="text"
size={1}
size="custom"
placeholder={getText('labelSearchPlaceholder')}
className="text grow bg-transparent"
onChange={(event) => {
@ -197,14 +191,10 @@ export default function ManageLabelsModal<
/>
</div>
</FocusRing>
<ariaComponents.Button
variant="submit"
isDisabled={!canCreateNewLabel}
onPress={eventModule.submitForm}
>
<Button variant="submit" isDisabled={!canCreateNewLabel} onPress={submitForm}>
{getText('create')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
</Button>
</ButtonGroup>
)}
</FocusArea>
}

View File

@ -11,7 +11,7 @@ import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import { getLeastUsedColor } from '#/services/Backend'
import { findLeastUsedColor } from '#/services/Backend'
import { EMPTY_ARRAY } from 'enso-common/src/utilities/data/array'
// =====================
@ -33,7 +33,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
[labels],
)
const labelNamesRef = useSyncRef(labelNames)
const leastUsedColor = React.useMemo(() => getLeastUsedColor(labels), [labels])
const leastUsedColor = React.useMemo(() => findLeastUsedColor(labels), [labels])
const createTag = useMutation(backendMutationOptions(backend, 'createTag')).mutateAsync
@ -45,7 +45,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
schema={z.object({
name: z
.string()
.min(1, getText('emptyStringError'))
.min(1)
.refine((value) => !labelNamesRef.current.has(value), {
message: getText('duplicateLabelError'),
}),

View File

@ -1,88 +1,79 @@
/**
* @file
*
* A modal for adding a payment method.
*/
import * as React from 'react'
/** @file A modal for adding a payment method. */
import { CardElement } from '@stripe/react-stripe-js'
import type { PaymentMethod, Stripe, StripeCardElement, StripeElements } from '@stripe/stripe-js'
import * as stripeReact from '@stripe/react-stripe-js'
import type * as stripeJs from '@stripe/stripe-js'
import * as text from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import { Form, useDialogContext, type FormInstance, type schema } from '#/components/AriaComponents'
import { useText, type GetText } from '#/providers/TextProvider'
import { useCreatePaymentMethodMutation } from '../api/createPaymentMethod'
/**
* Props for {@link AddPaymentMethodForm}.
*/
/** Props for an {@link AddPaymentMethodForm}. */
export interface AddPaymentMethodFormProps<
Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA,
Schema extends ReturnType<typeof createAddPaymentMethodFormSchema> = ReturnType<
typeof createAddPaymentMethodFormSchema
>,
> {
readonly stripeInstance: stripeJs.Stripe
readonly elements: stripeJs.StripeElements
readonly stripeInstance: Stripe
readonly elements: StripeElements
readonly submitText: string
readonly onSubmit?:
| ((paymentMethodId: stripeJs.PaymentMethod['id']) => Promise<void> | void)
| undefined
readonly form?: ariaComponents.FormInstance<Schema>
readonly onSubmit?: ((paymentMethodId: PaymentMethod['id']) => Promise<void> | void) | undefined
readonly form?: FormInstance<Schema>
}
export const ADD_PAYMENT_METHOD_FORM_SCHEMA = ariaComponents.Form.schema.object({
card: ariaComponents.Form.schema
.object(
{
complete: ariaComponents.Form.schema.boolean(),
error: ariaComponents.Form.schema
.object({ message: ariaComponents.Form.schema.string() })
.nullish(),
},
{ message: 'This field is required' },
)
.nullable()
.refine(
(data) => data?.error == null,
(data) => ({ message: data?.error?.message ?? 'This field is required' }),
),
cardElement: ariaComponents.Form.schema.custom<stripeJs.StripeCardElement | null>(),
stripeInstance: ariaComponents.Form.schema.custom<stripeJs.Stripe>(),
})
/** The validation schema for this form. */
export function createAddPaymentMethodFormSchema(z: typeof schema, getText: GetText) {
return z.object({
card: z
.object(
{
complete: z.boolean(),
error: z.object({ message: z.string() }).nullish(),
},
{ message: getText('arbitraryFieldRequired') },
)
.nullable()
.refine(
(data) => data?.error == null,
(data) => ({ message: data?.error?.message ?? getText('arbitraryFieldRequired') }),
),
cardElement: z.custom<StripeCardElement | null | undefined>(),
stripeInstance: z.custom<Stripe>(),
})
}
/**
* A form for adding a payment method.
*/
export function AddPaymentMethodForm<
Schema extends typeof ADD_PAYMENT_METHOD_FORM_SCHEMA = typeof ADD_PAYMENT_METHOD_FORM_SCHEMA,
Schema extends ReturnType<typeof createAddPaymentMethodFormSchema> = ReturnType<
typeof createAddPaymentMethodFormSchema
>,
>(props: AddPaymentMethodFormProps<Schema>) {
const { stripeInstance, onSubmit, submitText, form } = props
const { getText } = text.useText()
const dialogContext = ariaComponents.useDialogContext()
const { stripeInstance, onSubmit, submitText, form: formRaw } = props
const { getText } = useText()
const dialogContext = useDialogContext()
const createPaymentMethodMutation = useCreatePaymentMethodMutation()
// No idea if it's safe or not, but outside of the function everything is fine
// but for some reason TypeScript fails to infer the `card` field from the schema (it should always be there)
// eslint-disable-next-line no-restricted-syntax
const formInstance = ariaComponents.Form.useForm(
form ?? {
schema: ADD_PAYMENT_METHOD_FORM_SCHEMA,
const form = Form.useForm(
formRaw ?? {
mode: 'onChange',
schema: (z) => createAddPaymentMethodFormSchema(z, getText),
onSubmit: ({ cardElement }) =>
createPaymentMethodMutation.mutateAsync({ stripeInstance, cardElement }),
onSubmitSuccess: ({ paymentMethod }) => onSubmit?.(paymentMethod.id),
},
) as unknown as ariaComponents.FormInstance<typeof ADD_PAYMENT_METHOD_FORM_SCHEMA>
)
const cardElement = ariaComponents.Form.useWatch({
control: formInstance.control,
name: 'cardElement',
})
const cardElement =
// FIXME[sb]: I do not understand why `useWatch` is not sufficient for Playwright.
// (The value is always `undefined` with `useWatch` alone)
// It is worth noting that E2E tests previously worked without requiring this change - as of:
// 1500849c32f70f5f4d95240b7e31377c649dc25b
Form.useWatch({ control: form.control, name: 'cardElement' }) ?? form.getValues().cardElement
return (
<ariaComponents.Form method="dialog" form={formInstance}>
<ariaComponents.Form.Field name="card" fullWidth label={getText('bankCardLabel')}>
<stripeReact.CardElement
<Form method="dialog" form={form}>
<Form.Field name="card" fullWidth label={getText('bankCardLabel')}>
<CardElement
options={{
classes: {
base: 'border border-primary/15 rounded-2xl px-3 py-2 transition-[outline] w-full',
@ -93,27 +84,25 @@ export function AddPaymentMethodForm<
}}
onEscape={() => dialogContext?.close()}
onReady={(element) => {
formInstance.setValue('cardElement', element)
formInstance.setValue('stripeInstance', stripeInstance)
form.setValue('cardElement', element)
form.setValue('stripeInstance', stripeInstance)
}}
onChange={(event) => {
if (event.error?.message != null) {
formInstance.setError('card', { message: event.error.message })
form.setError('card', { message: event.error.message })
cardElement?.focus()
} else {
formInstance.clearErrors('card')
form.clearErrors('card')
}
formInstance.setValue('card', event)
form.setValue('card', event)
}}
onBlur={() => formInstance.trigger('card')}
onBlur={() => form.trigger('card')}
/>
</ariaComponents.Form.Field>
</Form.Field>
<ariaComponents.Form.Submit loading={cardElement == null}>
{submitText}
</ariaComponents.Form.Submit>
<Form.Submit loading={cardElement == null}>{submitText}</Form.Submit>
<ariaComponents.Form.FormError />
</ariaComponents.Form>
<Form.FormError />
</Form>
)
}

View File

@ -138,8 +138,10 @@ export function PlanSelector(props: PlanSelectorProps) {
const { data: session } = await refetchSession()
if (session && 'user' in session && session.user.plan === newPlan) {
onSubscribeSuccess?.(newPlan, paymentMethodId)
// Invalidate all queries as the user has changed the plan.
await queryClient.invalidateQueries({ queryKey: ['usersMe'] })
// Invalidate "users me" query as the user has changed the plan.
await queryClient.invalidateQueries({
queryKey: [backend.type, 'usersMe'],
})
break
} else {
const timePassedMs = Number(new Date()) - startEpochMs

View File

@ -34,7 +34,7 @@ import {
PRICE_CURRENCY,
TRIAL_DURATION_DAYS,
} from '../../../constants'
import { ADD_PAYMENT_METHOD_FORM_SCHEMA, AddPaymentMethodForm } from '../../AddPaymentMethodForm'
import { AddPaymentMethodForm, createAddPaymentMethodFormSchema } from '../../AddPaymentMethodForm'
import { StripeProvider } from '../../StripeProvider'
import { PlanFeatures } from './PlanFeatures'
@ -80,8 +80,9 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
const createPaymentMethodMutation = useCreatePaymentMethodMutation()
const form = Form.useForm({
mode: 'onChange',
schema: (z) =>
ADD_PAYMENT_METHOD_FORM_SCHEMA.extend({
createAddPaymentMethodFormSchema(z, getText).extend({
seats: z
.number()
.int()
@ -96,7 +97,6 @@ export function PlanSelectorDialog(props: PlanSelectorDialogProps) {
}),
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
defaultValues: { seats: 1, period: 12, agree: [] },
mode: 'onChange',
onSubmit: async ({ cardElement, stripeInstance, seats, period }) => {
const res = await createPaymentMethodMutation.mutateAsync({
cardElement,

View File

@ -1,19 +1,11 @@
/**
* @file
* The subscribe button component.
*/
/** @file A button to subscribe to a plan. */
import { getSalesEmail } from '#/appUtils'
import { useText } from '#/providers/TextProvider'
import { Button, DialogTrigger, Text } from '#/components/AriaComponents'
import { TRIAL_DURATION_DAYS } from '../../../constants'
import { useText } from '#/providers/TextProvider'
import { PLAN_TO_UPGRADE_LABEL_ID, TRIAL_DURATION_DAYS } from '../../../constants'
import { PlanSelectorDialog, type PlanSelectorDialogProps } from './PlanSelectorDialog'
/**
* The props for the submit button.
*/
/** Props for a {@link SubscribeButton}. */
export interface SubscribeButtonProps
extends Omit<PlanSelectorDialogProps, 'isTrialing' | 'title'> {
readonly isOrganizationAdmin?: boolean
@ -25,9 +17,7 @@ export interface SubscribeButtonProps
readonly defaultOpen?: boolean
}
/**
* Subscribe to a plan button
*/
/** A button to subscribe to a plan. */
export function SubscribeButton(props: SubscribeButtonProps) {
const {
defaultOpen,
@ -47,51 +37,34 @@ export function SubscribeButton(props: SubscribeButtonProps) {
const buttonText = (() => {
if (isDowngrade) {
// eslint-disable-next-line no-restricted-syntax
return getText('downgrade')
}
if (isCurrent) {
// eslint-disable-next-line no-restricted-syntax
return getText('currentPlan')
}
if (userHasSubscription) {
// eslint-disable-next-line no-restricted-syntax
return getText('upgrade')
}
// eslint-disable-next-line no-restricted-syntax
return canTrial ? getText('trialDescription', TRIAL_DURATION_DAYS) : getText('subscribe')
})()
const description = (() => {
if (isDowngrade) {
// eslint-disable-next-line no-restricted-syntax
return (
<Text transform="none">
<Button variant="link" href={getSalesEmail() + `?subject=Downgrade%20our%20plan`}>
{getText('contactSales')}
</Button>{' '}
{getText('downgradeInfo')}
</Text>
)
if (canTrial) {
return getText('trialDescription', TRIAL_DURATION_DAYS)
}
return null
return getText('subscribe')
})()
const description =
isDowngrade ?
<Text transform="none">
<Button variant="link" href={getSalesEmail() + `?subject=Downgrade%20our%20plan`}>
{getText('contactSales')}
</Button>{' '}
{getText('downgradeInfo')}
</Text>
: null
const variant = (() => {
if (isCurrent) {
// eslint-disable-next-line no-restricted-syntax
if (isCurrent || isDowngrade) {
return 'outline'
}
if (isDowngrade) {
// eslint-disable-next-line no-restricted-syntax
return 'outline'
}
return 'submit'
})()
@ -111,7 +84,14 @@ export function SubscribeButton(props: SubscribeButtonProps) {
: defaultOpen == null ? {}
: { defaultOpen })}
>
<Button fullWidth isDisabled={disabled} variant={variant} size="medium" rounded="full">
<Button
fullWidth
isDisabled={disabled}
variant={variant}
size="medium"
rounded="full"
aria-label={getText(PLAN_TO_UPGRADE_LABEL_ID[plan])}
>
{buttonText}
</Button>

View File

@ -16,6 +16,15 @@ export const PLAN_TO_TEXT_ID: Readonly<Record<backendModule.Plan, text.TextId>>
[backendModule.Plan.team]: 'teamPlanName',
[backendModule.Plan.enterprise]: 'enterprisePlanName',
} satisfies { [Plan in backendModule.Plan]: `${Plan}PlanName` }
/**
* The text id for the plan name.
*/
export const PLAN_TO_UPGRADE_LABEL_ID: Readonly<Record<backendModule.Plan, text.TextId>> = {
[backendModule.Plan.free]: 'freePlanUpgradeLabel',
[backendModule.Plan.solo]: 'soloPlanUpgradeLabel',
[backendModule.Plan.team]: 'teamPlanUpgradeLabel',
[backendModule.Plan.enterprise]: 'enterprisePlanUpgradeLabel',
} satisfies { [Plan in backendModule.Plan]: `${Plan}PlanUpgradeLabel` }
export const PRICE_CURRENCY = 'USD'
export const PRICE_BY_PLAN: Readonly<Record<backendModule.Plan, number>> = {

View File

@ -220,9 +220,7 @@ export default function Registration() {
</Button>
}
>
<Checkbox testId="terms-of-service-checkbox" value="agree">
{getText('licenseAgreementCheckbox')}
</Checkbox>
<Checkbox value="agree">{getText('licenseAgreementCheckbox')}</Checkbox>
</Checkbox.Group>
<Checkbox.Group
@ -238,9 +236,7 @@ export default function Registration() {
</Button>
}
>
<Checkbox testId="privacy-policy-checkbox" value="agree">
{getText('privacyPolicyCheckbox')}
</Checkbox>
<Checkbox value="agree">{getText('privacyPolicyCheckbox')}</Checkbox>
</Checkbox.Group>
<Form.Submit size="large" icon={CreateAccountIcon} fullWidth>

View File

@ -60,9 +60,7 @@ const BASE_STEPS: Step[] = [
title: 'setUsername',
text: 'setUsernameDescription',
hideNext: true,
/**
* Step component
*/
/** Setup step for setting username. */
component: function SetUsernameStep({ session, goToNextStep }) {
const { setUsername } = useAuth()
const userSession = useUserSession()
@ -85,16 +83,13 @@ const BASE_STEPS: Step[] = [
})
}
defaultValues={{ username: defaultName }}
onSubmit={({ username }) => {
// If user is already created we shouldn't call `setUsername` if value wasn't changed
if (username === defaultName && isUserCreated) {
goToNextStep()
return
} else {
return setUsername(username).then(() => {
goToNextStep()
})
onSubmit={async ({ username }) => {
// If user is already created we shouldn't call `setUsername` if the value has not been
// changed.
if (username !== defaultName || !isUserCreated) {
await setUsername(username)
}
goToNextStep()
}}
>
<ariaComponents.Input
@ -120,9 +115,7 @@ const BASE_STEPS: Step[] = [
session && 'user' in session ? !session.user.isOrganizationAdmin : true,
canSkip: ({ plan }) => plan === Plan.free,
hideNext: ({ plan }) => plan === Plan.free,
/**
* Step component
*/
/** Setup step for choosing plan. */
component: function ChoosePlanStep({ goToNextStep, plan, session }) {
const isOrganizationAdmin =
session && 'user' in session ? session.user.isOrganizationAdmin : false
@ -150,10 +143,8 @@ const BASE_STEPS: Step[] = [
},
hideNext: true,
hidePrevious: true,
/**
* Step component
*/
component: function SetOrganizatioNameStep({ goToNextStep, goToPreviousStep, session }) {
/** Setup step for setting organization name. */
component: function SetOrganizationNameStep({ goToNextStep, goToPreviousStep, session }) {
const { getText } = textProvider.useText()
const remoteBackend = useRemoteBackendStrict()
const userId = session && 'user' in session ? session.user.userId : null
@ -224,19 +215,13 @@ const BASE_STEPS: Step[] = [
},
hideNext: true,
hidePrevious: true,
/**
* Step component
*/
/** Setup step for inviting users to the organization. */
component: function InviteUsersStep({ goToNextStep, goToPreviousStep }) {
const { getText } = textProvider.useText()
return (
<div className="max-w-96">
<InviteUsersForm
onSubmitted={() => {
goToNextStep()
}}
/>
<InviteUsersForm onSubmitted={goToNextStep} />
<ariaComponents.ButtonGroup align="start" className="mt-4">
<ariaComponents.Button variant="outline" onPress={goToPreviousStep}>
@ -264,9 +249,7 @@ const BASE_STEPS: Step[] = [
},
hideNext: true,
hidePrevious: true,
/**
* Step component
*/
/** Setup step for creating the first user group. */
component: function CreateUserGroupStep({ goToNextStep, goToPreviousStep }) {
const { getText } = textProvider.useText()
const remoteBackend = useRemoteBackendStrict()
@ -333,9 +316,7 @@ const BASE_STEPS: Step[] = [
text: 'allSetDescription',
hideNext: true,
hidePrevious: true,
/**
* Step component
*/
/** Final setup step. */
component: function AllSetStep({ goToPreviousStep }) {
const { getText } = textProvider.useText()
const navigate = useNavigate()

View File

@ -140,11 +140,11 @@ function createUsersMeQuery(
performLogout: () => Promise<void>,
) {
return reactQuery.queryOptions({
queryKey: ['usersMe', session?.clientId] as const,
queryKey: [remoteBackend.type, 'usersMe', session?.clientId] as const,
queryFn: async () => {
if (session == null) {
// eslint-disable-next-line no-restricted-syntax
return null
return Promise.resolve(null)
}
try {
const user = await remoteBackend.usersMe()
@ -186,9 +186,6 @@ export default function AuthProvider(props: AuthProviderProps) {
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const { unsetModal } = modalProvider.useSetModal()
// This must not be `hooks.useNavigate` as `goOffline` would be inaccessible,
// and the function call would error.
// eslint-disable-next-line no-restricted-properties
const navigate = router.useNavigate()
const toastId = React.useId()
@ -217,22 +214,19 @@ export default function AuthProvider(props: AuthProviderProps) {
}
const logoutMutation = reactQuery.useMutation({
mutationKey: ['usersMe', 'logout', session?.clientId] as const,
mutationFn: () => performLogout(),
onMutate: () => {
// If the User Menu is still visible, it breaks when `userSession` is set to `null`.
unsetModal()
},
mutationKey: [remoteBackend.type, 'usersMe', 'logout', session?.clientId] as const,
mutationFn: performLogout,
// If the User Menu is still visible, it breaks when `userSession` is set to `null`.
onMutate: unsetModal,
onSuccess: () => toast.toast.success(getText('signOutSuccess')),
onError: () => toast.toast.error(getText('signOutError')),
meta: { invalidates: [sessionQueryKey], awaitInvalidates: true },
})
const usersMeQueryOptions = createUsersMeQuery(session, remoteBackend, () =>
performLogout().then(() => {
toast.toast.info(getText('userNotAuthorizedError'))
}),
)
const usersMeQueryOptions = createUsersMeQuery(session, remoteBackend, async () => {
await performLogout()
toast.toast.info(getText('userNotAuthorizedError'))
})
const usersMeQuery = reactQuery.useSuspenseQuery(usersMeQueryOptions)
const userData = usersMeQuery.data
@ -343,11 +337,13 @@ export default function AuthProvider(props: AuthProviderProps) {
}
})
const refetchSession = usersMeQuery.refetch
const setUsername = useEventCallback(async (username: string) => {
gtagEvent('cloud_user_created')
if (userData?.type === UserSessionType.full) {
await updateUserMutation.mutateAsync({ username: username })
await updateUserMutation.mutateAsync({ username })
} else {
const organizationId = await cognito.organizationId()
const email = session?.email ?? ''
@ -359,6 +355,12 @@ export default function AuthProvider(props: AuthProviderProps) {
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
})
}
// Wait until the backend returns a value from `users/me`,
// otherwise the rest of the steps are skipped.
// This only happens on specific devices, and (seemingly) only when using
// the Vite development server, not with the built application bundle.
// i.e. PROD=1
await refetchSession()
return true
})
@ -519,7 +521,7 @@ export default function AuthProvider(props: AuthProviderProps) {
forgotPassword,
resetPassword,
changePassword: withLoadingToast(changePassword),
refetchSession: usersMeQuery.refetch,
refetchSession,
session: userData,
signOut: logoutMutation.mutateAsync,
setUser,

View File

@ -1083,11 +1083,11 @@ export default class RemoteBackend extends Backend {
/** Create a payment checkout session.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async createCheckoutSession(
params: backend.CreateCheckoutSessionRequestParams,
params: backend.CreateCheckoutSessionRequestBody,
): Promise<backend.CheckoutSession> {
const response = await this.post<backend.CheckoutSession>(
remoteBackendPaths.CREATE_CHECKOUT_SESSION_PATH,
params satisfies backend.CreateCheckoutSessionRequestBody,
params,
)
if (!responseIsSuccessful(response)) {
return await this.throw(response, 'createCheckoutSessionBackendError', params.plan)

View File

@ -70,7 +70,7 @@ export const LIST_VERSIONS_PATH = 'versions'
/** Relative HTTP path to the "create checkout session" endpoint of the Cloud backend API. */
export const CREATE_CHECKOUT_SESSION_PATH = 'payments/subscriptions'
/** Relative HTTP path to the "get checkout session" endpoint of the Cloud backend API. */
export const GET_CHECKOUT_SESSION_PATH = 'payments/subscriptions'
const GET_CHECKOUT_SESSION_PATH = 'payments/subscriptions'
export const CANCEL_SUBSCRIPTION_PATH = 'payments/subscription'
/** Relative HTTP path to the "get log events" endpoint of the Cloud backend API. */
export const GET_LOG_EVENTS_PATH = 'log_events'

View File

@ -204,8 +204,6 @@
/* The vertical gap between each group in the user menu. */
--user-menu-transition-duration: 200ms;
--user-menu-gap: 0.75rem;
--user-menu-padding: 0.5rem;
--user-menu-width: 12.875rem;
/*************************\
|* Right hand side panel *|

View File

@ -129,7 +129,6 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
'asset-search-bar-wide': 'var(--asset-search-bar-wide-width)',
chat: 'var(--chat-width)',
'chat-indicator': 'var(--chat-indicator-width)',
'user-menu': 'var(--user-menu-width)',
'modal-label': 'var(--modal-label-width)',
'settings-sidebar': 'var(--settings-sidebar-width)',
'asset-panel': 'var(--asset-panel-width)',
@ -282,7 +281,6 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
'label-x': 'var(--label-padding-x)',
'sidebar-section-heading-x': 'var(--sidebar-section-heading-padding-x)',
'sidebar-section-heading-y': 'var(--sidebar-section-heading-padding-y)',
'user-menu': 'var(--user-menu-padding)',
'permission-type-selector': 'var(--permission-type-selector-padding)',
'permission-type-button': 'var(--permission-type-button-padding)',
'permission-type-y': 'var(--permission-type-padding-y)',

View File

@ -1,20 +1,30 @@
/** @file Configuration for vite. */
import * as vite from 'vite'
import { fileURLToPath } from 'node:url'
import * as appConfig from 'enso-common/src/appConfig'
import { defineConfig, mergeConfig } from 'vite'
import { loadTestEnvironmentVariables } from 'enso-common/src/appConfig'
// =====================
// === Configuration ===
// =====================
appConfig.loadTestEnvironmentVariables()
loadTestEnvironmentVariables()
const CONFIG = (await import('./vite.config')).default
export default vite.mergeConfig(
export default mergeConfig(
CONFIG,
vite.defineConfig({
defineConfig({
resolve: {
alias: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'@stripe/stripe-js/pure': fileURLToPath(new URL('./e2e/mock/stripe.ts', import.meta.url)),
// eslint-disable-next-line @typescript-eslint/naming-convention
'@stripe/react-stripe-js': fileURLToPath(
new URL('./e2e/mock/react-stripe.tsx', import.meta.url),
),
},
extensions: [
'.mock.mjs',
'.mock.js',

View File

@ -263,10 +263,7 @@ export default [
'react/prop-types': 'off',
'react/self-closing-comp': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'error',
{ additionalHooks: 'useOnScroll|useStickyTableHeaderOnScroll' },
],
'react-hooks/exhaustive-deps': ['error', { additionalHooks: 'useOnScroll' }],
'react/jsx-pascal-case': ['error', { allowNamespace: true }],
// Prefer `interface` over `type`.
'@typescript-eslint/consistent-type-definitions': 'error',

View File

@ -685,7 +685,7 @@ export const COLOR_STRING_TO_COLOR = new Map(
export const INITIAL_COLOR_COUNTS = new Map(COLORS.map(color => [lChColorToCssColor(color), 0]))
/** The color that is used for the least labels. Ties are broken by order. */
export function getLeastUsedColor(labels: Iterable<Label>) {
export function findLeastUsedColor(labels: Iterable<Label>) {
const colorCounts = new Map(INITIAL_COLOR_COUNTS)
for (const label of labels) {
const colorString = lChColorToCssColor(label.color)
@ -1146,6 +1146,8 @@ export interface CreateUserGroupRequestBody {
export interface CreateCheckoutSessionRequestBody {
readonly plan: Plan
readonly paymentMethodId: string
readonly quantity: number
readonly interval: number
}
/** URL query string parameters for the "list directory" endpoint. */
@ -1175,16 +1177,6 @@ export interface ListVersionsRequestParams {
readonly default: boolean
}
/**
* POST request body for the "create checkout session" endpoint.
*/
export interface CreateCheckoutSessionRequestParams {
readonly plan: Plan
readonly paymentMethodId: string
readonly quantity: number
readonly interval: number
}
// ==============================
// === detectVersionLifecycle ===
// ==============================
@ -1503,9 +1495,7 @@ export default abstract class Backend {
/** Return a list of backend or IDE versions. */
abstract listVersions(params: ListVersionsRequestParams): Promise<readonly Version[]>
/** Create a payment checkout session. */
abstract createCheckoutSession(
params: CreateCheckoutSessionRequestParams,
): Promise<CheckoutSession>
abstract createCheckoutSession(body: CreateCheckoutSessionRequestBody): Promise<CheckoutSession>
/** Get the status of a payment checkout session. */
abstract getCheckoutSession(sessionId: CheckoutSessionId): Promise<CheckoutSessionStatus>
/** List events in the organization's audit log. */

View File

@ -426,14 +426,13 @@
"placeholderChatPrompt": "Login or register to access live chat with our support team.",
"confirmPrompt": "Are you sure you want to $0?",
"couldNotInviteUser": "Could not invite user $0",
"inviteFormDescription": "Invite users to join your organization by entering their email addresses below.",
"inviteFormSeatsLeft": "You have $0 seats left on your plan. Upgrade to invite more",
"inviteFormSeatsLeftError": "You have exceed the number of seats on your plan by $0",
"inviteSuccess": "You've invited $0 to join Enso!",
"inviteUserLinkCopyDescription": "You can also copy the invite link to send it directly",
"inviteSubmit": "Send invites",
"inviteEmailFieldPlaceholder": "Enter email addresses",
"inviteEmailFieldLabel": "Email addresses",
"inviteEmailFieldLabel": "Users to invite",
"inviteEmailFieldDescription": "Separate email addresses with spaces, commas, or semicolons.",
"inviteManyUsersSuccess": "You've invited $0 users to join Enso!",
"copyInviteLink": "Copy invite link",
@ -528,6 +527,7 @@
"setYourUsername": "Set your username",
"usernamePlaceholder": "Enter your username",
"organizationNamePlaceholder": "Enter your organization name",
"setUsername": "Set username",
"forgotYourPassword": "Forgot Your Password?",
@ -565,28 +565,31 @@
"chatButtonAltText": "Chat",
"inviteButtonAltText": "Invite others to try Enso",
"shareButtonAltText": "Share",
"userMenuAltText": "User Settings",
"billedMonthly": "Billed monthly",
"billedAnnually": "Billed annually",
"tryFree": "Try free for $0 days, then ",
"priceTemplate": "$0 per user / month, $1",
"freePlanName": "Free",
"freePlanUpgradeLabel": "Upgrade to Free plan",
"freePlanSubtitle": "Try Enso",
"freePlanPricing": "$0 forever",
"freePlanFeatures": "Interactive Live Data Analytics; Python, Java, JavaScript Support; Local workflow execution",
"freePlanSeatsDescription": "This plan does not allow to adjust seats",
"soloPlanName": "Solo",
"soloPlanUpgradeLabel": "Upgrade to Solo plan",
"soloPlanSubtitle": "For individuals",
"soloPlanPricing": "$60 per user / month, billed annually",
"soloPlanFeatures": "Everything from free plan; 10GB Cloud Storage; Single user only; Cloud catalog and execution; Secrets and Datalinks; Basic Version Control",
"soloPlanSeatsDescription": "This plan is only for a single user",
"teamPlanName": "Team",
"teamPlanUpgradeLabel": "Upgrade to Team plan",
"teamPlanSubtitle": "For small teams",
"teamPlanPricing": "$150 per user / month, billed annually",
"teamPlanFeatures": "Everything from Solo plan; 100GB Cloud Storage; Up to 10 users; Time-based Scheduling; Advanced Version Control; Browser based execution; Basic audit logs",
"teamPlanSeatsDescription": "This plan allows to buy up to $0 seats",
"enterprisePlanName": "Enterprise",
"enterprisePlanUpgradeLabel": "Upgrade to Enterprise plan",
"enterprisePlanSubtitle": "For large organizations",
"enterprisePlanPricing": "$250 per user / month, billed annually",
"enterprisePlanFeatures": "Everything from Team plan; 1000+GB Cloud Storage; Unlimited users; Advanced Scheduling; REST API; Data access and modification logs; Priority Support; Fine-grained permission Model; Federated Log On",
@ -614,7 +617,6 @@
"seats": "Seats",
"wantMoreSeats": "Looking for more seats? Consider upgrading to a higher plan.",
"adjustSeats": "Adjust seats",
"billingPeriodOneMonth": "1 month",
"billingPeriodOneYear": "1 year",
"billingPeriodThreeYears": "3 years",
"SLSA": "Enso Software License And Services Agreement",
@ -763,7 +765,7 @@
"assetSearchFieldLabel": "Search through items",
"startModalLabel": "Start modal",
"userMenuLabel": "User menu",
"userMenuLabel": "User Settings",
"infoMenuLabel": "Info menu",
"categorySwitcherMenuLabel": "Category switcher",
"labelsListLabel": "Labels",