mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 11:52:59 +03:00
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:
parent
612289e498
commit
f0d02de5c8
2
app/.vscode/launch.json
vendored
2
app/.vscode/launch.json
vendored
@ -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"
|
||||
},
|
||||
{
|
||||
|
@ -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.
|
||||
|
@ -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`.
|
||||
|
@ -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(),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
@ -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. */
|
||||
|
@ -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)
|
||||
|
21
app/dashboard/e2e/actions/SetupDonePageActions.ts
Normal file
21
app/dashboard/e2e/actions/SetupDonePageActions.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
26
app/dashboard/e2e/actions/SetupInvitePageActions.ts
Normal file
26
app/dashboard/e2e/actions/SetupInvitePageActions.ts
Normal 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)
|
||||
}
|
||||
}
|
22
app/dashboard/e2e/actions/SetupOrganizationPageActions.ts
Normal file
22
app/dashboard/e2e/actions/SetupOrganizationPageActions.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
59
app/dashboard/e2e/actions/SetupPlanPageActions.ts
Normal file
59
app/dashboard/e2e/actions/SetupPlanPageActions.ts
Normal 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)
|
||||
}
|
||||
}
|
22
app/dashboard/e2e/actions/SetupTeamPageActions.ts
Normal file
22
app/dashboard/e2e/actions/SetupTeamPageActions.ts
Normal 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)
|
||||
}
|
||||
}
|
19
app/dashboard/e2e/actions/SetupUsernamePageActions.ts
Normal file
19
app/dashboard/e2e/actions/SetupUsernamePageActions.ts
Normal 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)
|
||||
}
|
||||
}
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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/)
|
||||
|
||||
|
126
app/dashboard/e2e/mock/react-stripe.tsx
Normal file
126
app/dashboard/e2e/mock/react-stripe.tsx
Normal 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: () => {},
|
||||
})
|
29
app/dashboard/e2e/mock/stripe.ts
Normal file
29
app/dashboard/e2e/mock/stripe.ts
Normal 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)
|
82
app/dashboard/e2e/setup.spec.ts
Normal file
82
app/dashboard/e2e/setup.spec.ts
Normal 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.
|
@ -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/)
|
||||
}))
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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) => {
|
||||
|
@ -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' },
|
||||
|
@ -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',
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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,
|
||||
|
@ -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
@ -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 />
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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)
|
||||
}}
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
27
app/dashboard/src/layouts/Settings/Entry.tsx
Normal file
27
app/dashboard/src/layouts/Settings/Entry.tsx
Normal 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} />
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 ===
|
@ -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} />
|
||||
}
|
||||
}
|
||||
}
|
@ -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 ===
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
@ -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[]
|
||||
}
|
||||
|
@ -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">
|
@ -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} />
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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')}
|
||||
/>
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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'),
|
||||
}),
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>> = {
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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'
|
||||
|
@ -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 *|
|
||||
|
@ -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)',
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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. */
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user