diff --git a/app/.vscode/launch.json b/app/.vscode/launch.json index 636a3989e6..ef2d7ab58f 100644 --- a/app/.vscode/launch.json +++ b/app/.vscode/launch.json @@ -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" }, { diff --git a/app/dashboard/e2e/actions.ts b/app/dashboard/e2e/actions.ts index 2c925d01c0..1371c40a52 100644 --- a/app/dashboard/e2e/actions.ts +++ b/app/dashboard/e2e/actions.ts @@ -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. diff --git a/app/dashboard/e2e/actions/BaseActions.ts b/app/dashboard/e2e/actions/BaseActions.ts index da2388c3d3..61b87e5a70 100644 --- a/app/dashboard/e2e/actions/BaseActions.ts +++ b/app/dashboard/e2e/actions/BaseActions.ts @@ -65,6 +65,7 @@ export default class BaseActions implements Promise { } }) } + /** Proxies the `then` method of the internal {@link Promise}. */ async then( // The following types are copied almost verbatim from the type definitions for `Promise`. diff --git a/app/dashboard/e2e/actions/DrivePageActions.ts b/app/dashboard/e2e/actions/DrivePageActions.ts index 50f13cad48..72e67944b1 100644 --- a/app/dashboard/e2e/actions/DrivePageActions.ts +++ b/app/dashboard/e2e/actions/DrivePageActions.ts @@ -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(), ) }, } diff --git a/app/dashboard/e2e/actions/LoginPageActions.ts b/app/dashboard/e2e/actions/LoginPageActions.ts index 6a73e9dfbc..31093ad242 100644 --- a/app/dashboard/e2e/actions/LoginPageActions.ts +++ b/app/dashboard/e2e/actions/LoginPageActions.ts @@ -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. */ diff --git a/app/dashboard/e2e/actions/RegisterPageActions.ts b/app/dashboard/e2e/actions/RegisterPageActions.ts index 48ee44855d..00d169fc14 100644 --- a/app/dashboard/e2e/actions/RegisterPageActions.ts +++ b/app/dashboard/e2e/actions/RegisterPageActions.ts @@ -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) diff --git a/app/dashboard/e2e/actions/SetupDonePageActions.ts b/app/dashboard/e2e/actions/SetupDonePageActions.ts new file mode 100644 index 0000000000..a1af0f9d20 --- /dev/null +++ b/app/dashboard/e2e/actions/SetupDonePageActions.ts @@ -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), + } + } +} diff --git a/app/dashboard/e2e/actions/SetupInvitePageActions.ts b/app/dashboard/e2e/actions/SetupInvitePageActions.ts new file mode 100644 index 0000000000..a99ad2efd4 --- /dev/null +++ b/app/dashboard/e2e/actions/SetupInvitePageActions.ts @@ -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) + } +} diff --git a/app/dashboard/e2e/actions/SetupOrganizationPageActions.ts b/app/dashboard/e2e/actions/SetupOrganizationPageActions.ts new file mode 100644 index 0000000000..aea051629b --- /dev/null +++ b/app/dashboard/e2e/actions/SetupOrganizationPageActions.ts @@ -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) + } +} diff --git a/app/dashboard/e2e/actions/SetupPageActions.ts b/app/dashboard/e2e/actions/SetupPageActions.ts deleted file mode 100644 index f25b46b1da..0000000000 --- a/app/dashboard/e2e/actions/SetupPageActions.ts +++ /dev/null @@ -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) - } -} diff --git a/app/dashboard/e2e/actions/SetupPlanPageActions.ts b/app/dashboard/e2e/actions/SetupPlanPageActions.ts new file mode 100644 index 0000000000..4e32726e07 --- /dev/null +++ b/app/dashboard/e2e/actions/SetupPlanPageActions.ts @@ -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) + } +} diff --git a/app/dashboard/e2e/actions/SetupTeamPageActions.ts b/app/dashboard/e2e/actions/SetupTeamPageActions.ts new file mode 100644 index 0000000000..85fb6fd06b --- /dev/null +++ b/app/dashboard/e2e/actions/SetupTeamPageActions.ts @@ -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) + } +} diff --git a/app/dashboard/e2e/actions/SetupUsernamePageActions.ts b/app/dashboard/e2e/actions/SetupUsernamePageActions.ts new file mode 100644 index 0000000000..642797cd6e --- /dev/null +++ b/app/dashboard/e2e/actions/SetupUsernamePageActions.ts @@ -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) + } +} diff --git a/app/dashboard/e2e/actions/openUserMenuAction.ts b/app/dashboard/e2e/actions/openUserMenuAction.ts index 19172762c8..7de885b692 100644 --- a/app/dashboard/e2e/actions/openUserMenuAction.ts +++ b/app/dashboard/e2e/actions/openUserMenuAction.ts @@ -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( - 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(), ) } diff --git a/app/dashboard/e2e/api.ts b/app/dashboard/e2e/api.ts index d527bad76a..fe17a52bc8 100644 --- a/app/dashboard/e2e/api.ts +++ b/app/dashboard/e2e/api.ts @@ -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 = (asset: T) => { @@ -257,6 +271,25 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } } + const createCheckoutSession = ( + body: backend.CreateCheckoutSessionRequestBody, + rest: Partial = {}, + ) => { + 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 = {}) => { 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) => { + const addUserGroup = (name: string, rest?: Partial) => { 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, diff --git a/app/dashboard/e2e/labelsPanel.spec.ts b/app/dashboard/e2e/labelsPanel.spec.ts index 2a61fa22b1..28b613ee84 100644 --- a/app/dashboard/e2e/labelsPanel.spec.ts +++ b/app/dashboard/e2e/labelsPanel.spec.ts @@ -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/) diff --git a/app/dashboard/e2e/mock/react-stripe.tsx b/app/dashboard/e2e/mock/react-stripe.tsx new file mode 100644 index 0000000000..2a4e1e5cdd --- /dev/null +++ b/app/dashboard/e2e/mock/react-stripe.tsx @@ -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[0]['children']>[0] + +/** */ +interface ElementsContextValue extends ElementsContextValue_ { + // +} + +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const ElementsContext = createContext(null!) + +/** Elements provider for Stripe. */ +export function Elements(...[props]: Parameters) { + 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 && ( + + {children} + + ) + ) +} + +/** Elements consumer for Stripe. */ +export function ElementsConsumer(...[props]: Parameters) { + 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: () => {}, +}) diff --git a/app/dashboard/e2e/mock/stripe.ts b/app/dashboard/e2e/mock/stripe.ts new file mode 100644 index 0000000000..1356fddfda --- /dev/null +++ b/app/dashboard/e2e/mock/stripe.ts @@ -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 => + // 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 as Partial as Stripe) diff --git a/app/dashboard/e2e/setup.spec.ts b/app/dashboard/e2e/setup.spec.ts new file mode 100644 index 0000000000..cf0aa4fa3d --- /dev/null +++ b/app/dashboard/e2e/setup.spec.ts @@ -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. diff --git a/app/dashboard/e2e/userMenu.spec.ts b/app/dashboard/e2e/userMenu.spec.ts index cb25ebeaca..6f511747cd 100644 --- a/app/dashboard/e2e/userMenu.spec.ts +++ b/app/dashboard/e2e/userMenu.spec.ts @@ -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/) + })) diff --git a/app/dashboard/src/authentication/cognito.mock.ts b/app/dashboard/src/authentication/cognito.mock.ts index f293b78d9b..9c666603fa 100644 --- a/app/dashboard/src/authentication/cognito.mock.ts +++ b/app/dashboard/src/authentication/cognito.mock.ts @@ -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() { diff --git a/app/dashboard/src/components/AriaComponents/Button/Button.tsx b/app/dashboard/src/components/AriaComponents/Button/Button.tsx index 78e8ce5f3a..78ad0e34b1 100644 --- a/app/dashboard/src/components/AriaComponents/Button/Button.tsx +++ b/app/dashboard/src/components/AriaComponents/Button/Button.tsx @@ -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 /** 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 | 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 ( - + ) @@ -419,21 +411,21 @@ export const Button = forwardRef(function Button( const actualIcon = typeof icon === 'function' ? icon(render) : icon if (typeof actualIcon === 'string') { - return + return } else { - return {actualIcon} + return {actualIcon} } } })() // Icon only button if (isIconOnly) { - return {iconComponent} + return {iconComponent} } else { // Default button return ( <> {iconComponent} - + {/* @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} @@ -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) => ( - <> - - - {/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */} - {childrenFactory(render)} - - - {isLoading && loaderPosition === 'full' && ( - - - - )} + {(render: aria.ButtonRenderProps | aria.LinkRenderProps) => ( + + + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-argument */} + {childrenFactory(render)} - + + {isLoading && loaderPosition === 'full' && ( + + + + )} + )} ) diff --git a/app/dashboard/src/components/AriaComponents/Checkbox/Checkbox.tsx b/app/dashboard/src/components/AriaComponents/Checkbox/Checkbox.tsx index 23796c039f..b27235f130 100644 --- a/app/dashboard/src/components/AriaComponents/Checkbox/Checkbox.tsx +++ b/app/dashboard/src/components/AriaComponents/Checkbox/Checkbox.tsx @@ -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 ( const styles = variants({ fullWidth, className }) - const testId = props['data-testid'] ?? props.testId ?? 'CheckboxGroup' + const testId = props['data-testid'] ?? props.testId return ( { return ( - - + {(closeButton !== 'none' || title != null) && ( + + {closeButton !== 'none' && ( + + )} - {title != null && ( - - {title} - - )} - + {title != null && ( + + {title} + + )} + + )}
{ diff --git a/app/dashboard/src/components/AriaComponents/Dialog/Popover.tsx b/app/dashboard/src/components/AriaComponents/Dialog/Popover.tsx index d8598d02dd..52e5a178a0 100644 --- a/app/dashboard/src/components/AriaComponents/Dialog/Popover.tsx +++ b/app/dashboard/src/components/AriaComponents/Dialog/Popover.tsx @@ -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, twv.VariantProps { @@ -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' }, diff --git a/app/dashboard/src/components/Autocomplete.tsx b/app/dashboard/src/components/Autocomplete.tsx index f7033219d5..7a598ba2f0 100644 --- a/app/dashboard/src/components/Autocomplete.tsx +++ b/app/dashboard/src/components/Autocomplete.tsx @@ -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 { readonly multiple?: boolean - readonly type?: React.HTMLInputTypeAttribute - readonly inputRef?: React.MutableRefObject + readonly type?: HTMLInputTypeAttribute + readonly inputRef?: MutableRefObject readonly placeholder?: string readonly values: readonly T[] readonly autoFocus?: boolean @@ -53,7 +57,7 @@ interface InternalMultipleAutocompleteProps 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 + readonly inputRef?: MutableRefObject readonly setValues: (value: readonly T[]) => void readonly itemsToString: (items: readonly T[]) => string } @@ -82,25 +86,25 @@ export default function Autocomplete(props: AutocompleteProps) { 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(null) - const valuesSet = React.useMemo(() => new Set(values), [values]) + const [isDropdownVisible, setIsDropdownVisible] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(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(null) + const fallbackInputRef = useRef(null) const inputRef = rawInputRef ?? fallbackInputRef // This type is a little too wide but it is unavoidable. @@ -127,7 +131,7 @@ export default function Autocomplete(props: AutocompleteProps) { ) } - const onKeyDown = (event: React.KeyboardEvent) => { + const onKeyDown = (event: KeyboardEvent) => { switch (event.key) { case 'ArrowUp': { event.preventDefault() @@ -190,13 +194,14 @@ export default function Autocomplete(props: AutocompleteProps) {
{canEditText ? { setIsDropdownVisible(true) @@ -240,7 +245,7 @@ export default function Autocomplete(props: AutocompleteProps) {
(props: AutocompleteProps) { {matchingItems.map((item, index) => (
{ - unsetModal() + if (dialogContext) { + // Closing a dialog takes precedence over unsetting the modal. + dialogContext.close() + } else { + unsetModal() + } doAction() }, })} >
- + {label ?? getText(labelTextId)} diff --git a/app/dashboard/src/components/Stepper/Stepper.tsx b/app/dashboard/src/components/Stepper/Stepper.tsx index c1b657d6aa..d97afeae99 100644 --- a/app/dashboard/src/components/Stepper/Stepper.tsx +++ b/app/dashboard/src/components/Stepper/Stepper.tsx @@ -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 (
-
+
{Array.from({ length: totalSteps }).map((_, index) => { const renderStepProps = { index, @@ -124,14 +124,14 @@ export function Stepper(props: StepperProps) { } satisfies RenderStepProps return ( -
+
{renderStep(renderStepProps)}
) })}
-
+
void -} - -export default forwardRef(Button) - -/** A styled button. */ -function Button(props: ButtonProps, ref: React.ForwardedRef) { - 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 = ( - - >()( - 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, - ), - }, - )} - > -
- -
-
-
- ) - - return tooltipElement == null ? button : ( - - {button} - - {tooltipElement} - - - ) -} diff --git a/app/dashboard/src/components/styled/Checkbox.tsx b/app/dashboard/src/components/styled/Checkbox.tsx deleted file mode 100644 index ed1e237a07..0000000000 --- a/app/dashboard/src/components/styled/Checkbox.tsx +++ /dev/null @@ -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, 'className'> {} - -/** A styled checkbox. */ -export default function Checkbox(props: CheckboxProps) { - return ( - - - - - - ) -} diff --git a/app/dashboard/src/components/styled/Input.tsx b/app/dashboard/src/components/styled/Input.tsx deleted file mode 100644 index 3d63f14769..0000000000 --- a/app/dashboard/src/components/styled/Input.tsx +++ /dev/null @@ -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 {} - -export default forwardRef(Input) - -/** An input that handles focus movement. */ -function Input(props: InputProps, ref: ForwardedRef) { - const focusDirection = useFocusDirection() - const handleFocusMove = useHandleFocusMove(focusDirection) - - return ( - >()(props, { - ref, - className: 'focus-child', - onKeyDown: handleFocusMove, - })} - /> - ) -} diff --git a/app/dashboard/src/hooks/backendHooks.ts b/app/dashboard/src/hooks/backendHooks.ts index d5ba0aaabe..09ada37f27 100644 --- a/app/dashboard/src/hooks/backendHooks.ts +++ b/app/dashboard/src/hooks/backendHooks.ts @@ -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 > = { + createUser: ['usersMe'], updateUser: ['usersMe'], + deleteUser: ['usersMe'], + restoreUser: ['usersMe'], uploadUserPicture: ['usersMe'], updateOrganization: ['getOrganization'], uploadOrganizationPicture: ['getOrganization'], @@ -204,10 +208,8 @@ export function backendMutationOptions( 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, diff --git a/app/dashboard/src/layouts/AssetProjectSession.tsx b/app/dashboard/src/layouts/AssetProjectSession.tsx index afa80e6d1f..6464389040 100644 --- a/app/dashboard/src/layouts/AssetProjectSession.tsx +++ b/app/dashboard/src/layouts/AssetProjectSession.tsx @@ -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 (
- +
- -
) diff --git a/app/dashboard/src/layouts/AssetsTable.tsx b/app/dashboard/src/layouts/AssetsTable.tsx index 238194c2fe..57ae843d45 100644 --- a/app/dashboard/src/layouts/AssetsTable.tsx +++ b/app/dashboard/src/layouts/AssetsTable.tsx @@ -1,5 +1,20 @@ /** @file Table displaying a list of projects. */ -import * as React from 'react' +import { + startTransition, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type Dispatch, + type DragEvent, + type KeyboardEvent, + type MutableRefObject, + type MouseEvent as ReactMouseEvent, + type Ref, + type RefObject, + type SetStateAction, +} from 'react' import { queryOptions, @@ -8,23 +23,57 @@ import { useQueryClient, useSuspenseQuery, } from '@tanstack/react-query' -import * as toast from 'react-toastify' +import { toast } from 'react-toastify' import invariant from 'tiny-invariant' import * as z from 'zod' import DropFilesImage from '#/assets/drop_files.svg' - -import * as mimeTypes from '#/data/mimeTypes' - -import * as autoScrollHooks from '#/hooks/autoScrollHooks' +import { FileTrigger, mergeProps } from '#/components/aria' +import { Button, Text } from '#/components/AriaComponents' +import type { AssetRowInnerProps } from '#/components/dashboard/AssetRow' +import { AssetRow } from '#/components/dashboard/AssetRow' +import { INITIAL_ROW_STATE } from '#/components/dashboard/AssetRow/assetRowUtils' +import type { SortableColumn } from '#/components/dashboard/column/columnUtils' +import { + Column, + COLUMN_CSS_CLASS, + COLUMN_ICONS, + COLUMN_SHOW_TEXT_ID, + DEFAULT_ENABLED_COLUMNS, + getColumnList, +} from '#/components/dashboard/column/columnUtils' +import NameColumn from '#/components/dashboard/column/NameColumn' +import { COLUMN_HEADING } from '#/components/dashboard/columnHeading' +import Label from '#/components/dashboard/Label' +import { ErrorDisplay } from '#/components/ErrorBoundary' +import SelectionBrush from '#/components/SelectionBrush' +import Spinner, { SpinnerState } from '#/components/Spinner' +import FocusArea from '#/components/styled/FocusArea' +import SvgMask from '#/components/SvgMask' +import { ASSETS_MIME_TYPE } from '#/data/mimeTypes' +import AssetEventType from '#/events/AssetEventType' +import type { AssetListEvent } from '#/events/assetListEvent' +import AssetListEventType from '#/events/AssetListEventType' +import { useAutoScroll } from '#/hooks/autoScrollHooks' import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks' -import * as intersectionHooks from '#/hooks/intersectionHooks' -import * as projectHooks from '#/hooks/projectHooks' -import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' +import { useEventCallback } from '#/hooks/eventCallbackHooks' +import { useIntersectionRatio } from '#/hooks/intersectionHooks' +import { useOpenProject } from '#/hooks/projectHooks' +import { useToastAndLog } from '#/hooks/toastAndLogHooks' import useOnScroll from '#/hooks/useOnScroll' - -import * as authProvider from '#/providers/AuthProvider' -import * as backendProvider from '#/providers/BackendProvider' +import type * as assetSearchBar from '#/layouts/AssetSearchBar' +import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' +import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu' +import { isLocalCategory, type Category } from '#/layouts/CategorySwitcher/Category' +import DragModal from '#/modals/DragModal' +import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal' +import UpsertSecretModal from '#/modals/UpsertSecretModal' +import { useFullUserSession } from '#/providers/AuthProvider' +import { + useBackend, + useDidLoadingProjectManagerFail, + useReconnectToProjectManager, +} from '#/providers/BackendProvider' import { useDriveStore, useSetAssetPanelProps, @@ -37,69 +86,75 @@ import { useSetTargetDirectory, useSetVisuallySelectedKeys, } from '#/providers/DriveProvider' -import * as inputBindingsProvider from '#/providers/InputBindingsProvider' -import * as localStorageProvider from '#/providers/LocalStorageProvider' -import * as modalProvider from '#/providers/ModalProvider' -import * as navigator2DProvider from '#/providers/Navigator2DProvider' -import * as projectsProvider from '#/providers/ProjectsProvider' -import * as textProvider from '#/providers/TextProvider' - -import AssetEventType from '#/events/AssetEventType' -import type * as assetListEvent from '#/events/assetListEvent' -import AssetListEventType from '#/events/AssetListEventType' - -import type * as assetSearchBar from '#/layouts/AssetSearchBar' -import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' -import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu' -import { isLocalCategory, type Category } from '#/layouts/CategorySwitcher/Category' - -import * as aria from '#/components/aria' -import type * as assetRow from '#/components/dashboard/AssetRow' -import { AssetRow } from '#/components/dashboard/AssetRow' -import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils' -import * as columnUtils from '#/components/dashboard/column/columnUtils' -import NameColumn from '#/components/dashboard/column/NameColumn' -import * as columnHeading from '#/components/dashboard/columnHeading' -import Label from '#/components/dashboard/Label' -import SelectionBrush from '#/components/SelectionBrush' -import Spinner, * as spinner from '#/components/Spinner' -import Button from '#/components/styled/Button' -import FocusArea from '#/components/styled/FocusArea' -import FocusRing from '#/components/styled/FocusRing' -import SvgMask from '#/components/SvgMask' - -import DragModal from '#/modals/DragModal' -import DuplicateAssetsModal from '#/modals/DuplicateAssetsModal' -import UpsertSecretModal from '#/modals/UpsertSecretModal' - -import type Backend from '#/services/Backend' -import * as backendModule from '#/services/Backend' -import LocalBackend, * as localBackendModule from '#/services/LocalBackend' -import * as projectManager from '#/services/ProjectManager' -import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend' - -import { ErrorDisplay } from '#/components/ErrorBoundary' -import { useEventCallback } from '#/hooks/eventCallbackHooks' import { useFeatureFlag } from '#/providers/FeatureFlagsProvider' -import type * as assetQuery from '#/utilities/AssetQuery' +import { useInputBindings } from '#/providers/InputBindingsProvider' +import { useLocalStorage, useLocalStorageState } from '#/providers/LocalStorageProvider' +import { useSetModal } from '#/providers/ModalProvider' +import { useNavigator2D } from '#/providers/Navigator2DProvider' +import { useLaunchedProjects } from '#/providers/ProjectsProvider' +import { useText } from '#/providers/TextProvider' +import type Backend from '#/services/Backend' +import { + assetIsDirectory, + assetIsFile, + assetIsProject, + AssetType, + BackendType, + createPlaceholderFileAsset, + createPlaceholderProjectAsset, + createRootDirectoryAsset, + createSpecialEmptyAsset, + createSpecialLoadingAsset, + DatalinkId, + DirectoryId, + extractProjectExtension, + fileIsNotProject, + fileIsProject, + FilterBy, + getAssetPermissionName, + Path, + Plan, + ProjectId, + ProjectState, + SecretId, + stripProjectExtension, + type AnyAsset, + type AssetId, + type DatalinkAsset, + type DirectoryAsset, + type LabelName, + type ProjectAsset, + type SecretAsset, +} from '#/services/Backend' +import LocalBackend, { extractTypeAndId, newProjectId } from '#/services/LocalBackend' +import { UUID } from '#/services/ProjectManager' +import { isSpecialReadonlyDirectoryId } from '#/services/RemoteBackend' +import type { AssetQueryKey } from '#/utilities/AssetQuery' import AssetQuery from '#/utilities/AssetQuery' -import type * as assetTreeNode from '#/utilities/AssetTreeNode' +import type { AnyAssetTreeNode } from '#/utilities/AssetTreeNode' import AssetTreeNode from '#/utilities/AssetTreeNode' -import * as dateTime from '#/utilities/dateTime' -import * as drag from '#/utilities/drag' -import * as fileInfo from '#/utilities/fileInfo' -import type * as geometry from '#/utilities/geometry' -import * as inputBindingsModule from '#/utilities/inputBindings' +import { toRfc3339 } from '#/utilities/dateTime' +import type { AssetRowsDragPayload } from '#/utilities/drag' +import { ASSET_ROWS, LABELS, setDragImageToBlank } from '#/utilities/drag' +import { fileExtension } from '#/utilities/fileInfo' +import type { DetailedRectangle } from '#/utilities/geometry' +import { DEFAULT_HANDLER } from '#/utilities/inputBindings' import LocalStorage from '#/utilities/LocalStorage' -import type * as pasteDataModule from '#/utilities/pasteData' +import type { PasteData } from '#/utilities/pasteData' import PasteType from '#/utilities/PasteType' -import * as permissions from '#/utilities/permissions' -import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' -import * as set from '#/utilities/set' -import * as sorting from '#/utilities/sorting' -import * as string from '#/utilities/string' -import * as tailwindMerge from '#/utilities/tailwindMerge' -import * as uniqueString from '#/utilities/uniqueString' +import { + canPermissionModifyDirectoryContents, + PermissionAction, + tryCreateOwnerPermission, + tryFindSelfPermission, +} from '#/utilities/permissions' +import { document } from '#/utilities/sanitizedEventTargets' +import { EMPTY_SET, setPresence, withPresence } from '#/utilities/set' +import type { SortInfo } from '#/utilities/sorting' +import { SortDirection } from '#/utilities/sorting' +import { regexEscape } from '#/utilities/string' +import { twMerge } from '#/utilities/tailwindMerge' +import { uniqueString } from '#/utilities/uniqueString' import Visibility from '#/utilities/Visibility' // ============================ @@ -109,12 +164,12 @@ import Visibility from '#/utilities/Visibility' declare module '#/utilities/LocalStorage' { /** */ interface LocalStorageData { - readonly enabledColumns: readonly columnUtils.Column[] + readonly enabledColumns: readonly Column[] } } LocalStorage.registerKey('enabledColumns', { - schema: z.nativeEnum(columnUtils.Column).array().readonly(), + schema: z.nativeEnum(Column).array().readonly(), }) // ================= @@ -224,15 +279,15 @@ interface DragSelectionInfo { // === Category to filter by === // ============================= -const CATEGORY_TO_FILTER_BY: Readonly> = { - cloud: backendModule.FilterBy.active, - local: backendModule.FilterBy.active, +const CATEGORY_TO_FILTER_BY: Readonly> = { + cloud: FilterBy.active, + local: FilterBy.active, recent: null, - trash: backendModule.FilterBy.trashed, - user: backendModule.FilterBy.active, - team: backendModule.FilterBy.active, + trash: FilterBy.trashed, + user: FilterBy.active, + team: FilterBy.active, // eslint-disable-next-line @typescript-eslint/naming-convention - 'local-directory': backendModule.FilterBy.active, + 'local-directory': FilterBy.active, } // =================== @@ -242,67 +297,57 @@ const CATEGORY_TO_FILTER_BY: Readonly - readonly visibilities: ReadonlyMap + readonly rootDirectoryId: DirectoryId + readonly expandedDirectoryIds: readonly DirectoryId[] + readonly scrollContainerRef: RefObject + readonly visibilities: ReadonlyMap readonly category: Category readonly hasPasteData: boolean - readonly setPasteData: (pasteData: pasteDataModule.PasteData>) => void - readonly sortInfo: sorting.SortInfo | null - readonly setSortInfo: (sortInfo: sorting.SortInfo | null) => void + readonly setPasteData: (pasteData: PasteData>) => void + readonly sortInfo: SortInfo | null + readonly setSortInfo: (sortInfo: SortInfo | null) => void readonly query: AssetQuery - readonly setQuery: React.Dispatch> - readonly nodeMap: Readonly< - React.MutableRefObject> - > - readonly pasteData: Readonly< - React.MutableRefObject> | null> - > - readonly hideColumn: (column: columnUtils.Column) => void + readonly setQuery: Dispatch> + readonly nodeMap: Readonly>> + readonly pasteData: Readonly> | null>> + readonly hideColumn: (column: Column) => void readonly doToggleDirectoryExpansion: ( - directoryId: backendModule.DirectoryId, - key: backendModule.DirectoryId, + directoryId: DirectoryId, + key: DirectoryId, override?: boolean, ) => void readonly doCopy: () => void readonly doCut: () => void - readonly doPaste: ( - newParentKey: backendModule.DirectoryId, - newParentId: backendModule.DirectoryId, - ) => void - readonly doDelete: (item: backendModule.AnyAsset, forever: boolean) => Promise - readonly doRestore: (item: backendModule.AnyAsset) => Promise - readonly doMove: ( - newParentKey: backendModule.DirectoryId, - item: backendModule.AnyAsset, - ) => Promise + readonly doPaste: (newParentKey: DirectoryId, newParentId: DirectoryId) => void + readonly doDelete: (item: AnyAsset, forever: boolean) => Promise + readonly doRestore: (item: AnyAsset) => Promise + readonly doMove: (newParentKey: DirectoryId, item: AnyAsset) => Promise } /** Data associated with a {@link AssetRow}, used for rendering. */ export interface AssetRowState { readonly setVisibility: (visibility: Visibility) => void readonly isEditingName: boolean - readonly temporarilyAddedLabels: ReadonlySet - readonly temporarilyRemovedLabels: ReadonlySet + readonly temporarilyAddedLabels: ReadonlySet + readonly temporarilyRemovedLabels: ReadonlySet } /** Props for a {@link AssetsTable}. */ export interface AssetsTableProps { readonly hidden: boolean readonly query: AssetQuery - readonly setQuery: React.Dispatch> + readonly setQuery: Dispatch> readonly category: Category readonly initialProjectName: string | null - readonly assetManagementApiRef: React.Ref + readonly assetManagementApiRef: Ref } /** * The API for managing assets in the table. */ export interface AssetManagementApi { - readonly getAsset: (id: backendModule.AssetId) => backendModule.AnyAsset | null - readonly setAsset: (id: backendModule.AssetId, asset: backendModule.AnyAsset) => void + readonly getAsset: (id: AssetId) => AnyAsset | null + readonly setAsset: (id: AssetId, asset: AnyAsset) => void } /** The table of project assets. */ @@ -310,46 +355,41 @@ export default function AssetsTable(props: AssetsTableProps) { const { hidden, query, setQuery, category, assetManagementApiRef } = props const { initialProjectName } = props - const openedProjects = projectsProvider.useLaunchedProjects() - const doOpenProject = projectHooks.useOpenProject() + const openedProjects = useLaunchedProjects() + const doOpenProject = useOpenProject() const setCanDownload = useSetCanDownload() const setSuggestions = useSetSuggestions() - const { user } = authProvider.useFullUserSession() - const backend = backendProvider.useBackend(category) + const { user } = useFullUserSession() + const backend = useBackend(category) const { data: labels } = useBackendQuery(backend, 'listTags', []) - const { setModal, unsetModal } = modalProvider.useSetModal() - const { localStorage } = localStorageProvider.useLocalStorage() - const { getText } = textProvider.useText() - const inputBindings = inputBindingsProvider.useInputBindings() - const navigator2D = navigator2DProvider.useNavigator2D() - const toastAndLog = toastAndLogHooks.useToastAndLog() - const previousCategoryRef = React.useRef(category) + const { setModal, unsetModal } = useSetModal() + const { localStorage } = useLocalStorage() + const { getText } = useText() + const inputBindings = useInputBindings() + const navigator2D = useNavigator2D() + const toastAndLog = useToastAndLog() + const previousCategoryRef = useRef(category) const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const setCanCreateAssets = useSetCanCreateAssets() const setTargetDirectoryInStore = useSetTargetDirectory() - const didLoadingProjectManagerFail = backendProvider.useDidLoadingProjectManagerFail() - const reconnectToProjectManager = backendProvider.useReconnectToProjectManager() - const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS) + const didLoadingProjectManagerFail = useDidLoadingProjectManagerFail() + const reconnectToProjectManager = useReconnectToProjectManager() + const [enabledColumns, setEnabledColumns] = useState(DEFAULT_ENABLED_COLUMNS) const setIsAssetPanelTemporarilyVisible = useSetIsAssetPanelTemporarilyVisible() const setAssetPanelProps = useSetAssetPanelProps() - const hiddenColumns = columnUtils - .getColumnList(user, backend.type, category) - .filter((column) => !enabledColumns.has(column)) - const [sortInfo, setSortInfo] = - React.useState | null>(null) + const hiddenColumns = getColumnList(user, backend.type, category).filter( + (column) => !enabledColumns.has(column), + ) + const [sortInfo, setSortInfo] = useState | null>(null) const driveStore = useDriveStore() const setNewestFolderId = useSetNewestFolderId() const setSelectedKeys = useSetSelectedKeys() const setVisuallySelectedKeys = useSetVisuallySelectedKeys() - const updateAssetRef = React.useRef< - Record void> - >({}) - const [pasteData, setPasteData] = React.useState - > | null>(null) + const updateAssetRef = useRef void>>({}) + const [pasteData, setPasteData] = useState> | null>(null) const { data: users } = useBackendQuery(backend, 'listUsers', []) const { data: userGroups } = useBackendQuery(backend, 'listUserGroups', []) @@ -360,10 +400,10 @@ export default function AssetsTable(props: AssetsTableProps) { const organization = organizationQuery.data - const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName) - const [localRootDirectory] = localStorageProvider.useLocalStorageState('localRootDirectory') - const rootDirectoryId = React.useMemo(() => { - const localRootPath = localRootDirectory != null ? backendModule.Path(localRootDirectory) : null + const nameOfProjectToImmediatelyOpenRef = useRef(initialProjectName) + const [localRootDirectory] = useLocalStorageState('localRootDirectory') + const rootDirectoryId = useMemo(() => { + const localRootPath = localRootDirectory != null ? Path(localRootDirectory) : null const id = 'homeDirectoryId' in category ? category.homeDirectoryId @@ -372,11 +412,8 @@ export default function AssetsTable(props: AssetsTableProps) { return id }, [category, backend, user, organization, localRootDirectory]) - const rootParentDirectoryId = backendModule.DirectoryId('') - const rootDirectory = React.useMemo( - () => backendModule.createRootDirectoryAsset(rootDirectoryId), - [rootDirectoryId], - ) + const rootParentDirectoryId = DirectoryId('') + const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId]) const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh') const assetsTableBackgroundRefreshInterval = useFeatureFlag( @@ -387,16 +424,14 @@ export default function AssetsTable(props: AssetsTableProps) { * We don't include the root directory as it might change when a user switches * between items in sidebar and we don't want to reset the expanded state using useEffect. */ - const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = React.useState< - backendModule.DirectoryId[] - >(() => []) + const [privateExpandedDirectoryIds, setExpandedDirectoryIds] = useState(() => []) - const expandedDirectoryIds = React.useMemo( + const expandedDirectoryIds = useMemo( () => privateExpandedDirectoryIds.concat(rootDirectoryId), [privateExpandedDirectoryIds, rootDirectoryId], ) - const expandedDirectoryIdsSet = React.useMemo( + const expandedDirectoryIdsSet = useMemo( () => new Set(expandedDirectoryIds), [expandedDirectoryIds], ) @@ -419,7 +454,7 @@ export default function AssetsTable(props: AssetsTableProps) { const directories = useQueries({ // We query only expanded directories, as we don't want to load the data for directories that are not visible. - queries: React.useMemo( + queries: useMemo( () => expandedDirectoryIds.map((directoryId) => queryOptions({ @@ -485,7 +520,7 @@ export default function AssetsTable(props: AssetsTableProps) { const rootDirectoryContent = directories.rootDirectory.data?.children const isLoading = directories.rootDirectory.isLoading - const assetTree = React.useMemo(() => { + const assetTree = useMemo(() => { const rootPath = 'rootPath' in category ? category.rootPath : backend.rootPath(user) // If the root directory is not loaded, then we cannot render the tree. @@ -493,7 +528,7 @@ export default function AssetsTable(props: AssetsTableProps) { if (rootDirectoryContent == null) { // eslint-disable-next-line no-restricted-syntax return AssetTreeNode.fromAsset( - backendModule.createRootDirectoryAsset(rootDirectoryId), + createRootDirectoryAsset(rootDirectoryId), rootParentDirectoryId, rootParentDirectoryId, -1, @@ -510,10 +545,10 @@ export default function AssetsTable(props: AssetsTableProps) { * in the loaded data. If it is loaded, we append that data to the asset node * and do the same for the children. */ - const withChildren = (node: assetTreeNode.AnyAssetTreeNode, depth: number) => { + const withChildren = (node: AnyAssetTreeNode, depth: number) => { const { item } = node - if (backendModule.assetIsDirectory(item)) { + if (assetIsDirectory(item)) { const childrenAssetsQuery = directories.directories.get(item.id) const nestedChildren = childrenAssetsQuery?.data?.children.map((child) => @@ -532,7 +567,7 @@ export default function AssetsTable(props: AssetsTableProps) { node = node.with({ children: [ AssetTreeNode.fromAsset( - backendModule.createSpecialLoadingAsset(item.id), + createSpecialLoadingAsset(item.id), item.id, item.id, depth, @@ -544,7 +579,7 @@ export default function AssetsTable(props: AssetsTableProps) { node = node.with({ children: [ AssetTreeNode.fromAsset( - backendModule.createSpecialEmptyAsset(item.id), + createSpecialEmptyAsset(item.id), item.id, item.id, depth, @@ -597,40 +632,38 @@ export default function AssetsTable(props: AssetsTableProps) { directories.directories, ]) - const filter = React.useMemo(() => { + const filter = useMemo(() => { const globCache: Record = {} if (/^\s*$/.test(query.query)) { return null } else { - return (node: assetTreeNode.AnyAssetTreeNode) => { + return (node: AnyAssetTreeNode) => { if ( - node.item.type === backendModule.AssetType.specialEmpty || - node.item.type === backendModule.AssetType.specialLoading + node.item.type === AssetType.specialEmpty || + node.item.type === AssetType.specialLoading ) { // This is FINE, as these assets have no meaning info to match with. // eslint-disable-next-line no-restricted-syntax return false } const assetType = - node.item.type === backendModule.AssetType.directory ? 'folder' - : node.item.type === backendModule.AssetType.datalink ? 'datalink' + node.item.type === AssetType.directory ? 'folder' + : node.item.type === AssetType.datalink ? 'datalink' : String(node.item.type) const assetExtension = - node.item.type !== backendModule.AssetType.file ? - null - : fileInfo.fileExtension(node.item.title).toLowerCase() + node.item.type !== AssetType.file ? null : fileExtension(node.item.title).toLowerCase() const assetModifiedAt = new Date(node.item.modifiedAt) const nodeLabels: readonly string[] = node.item.labels ?? [] const lowercaseName = node.item.title.toLowerCase() const lowercaseDescription = node.item.description?.toLowerCase() ?? '' const owners = node.item.permissions - ?.filter((permission) => permission.permission === permissions.PermissionAction.own) - .map(backendModule.getAssetPermissionName) ?? [] + ?.filter((permission) => permission.permission === PermissionAction.own) + .map(getAssetPermissionName) ?? [] const globMatch = (glob: string, match: string) => { const regex = (globCache[glob] = globCache[glob] ?? - new RegExp('^' + string.regexEscape(glob).replace(/(?:\\\*)+/g, '.*') + '$', 'i')) + new RegExp('^' + regexEscape(glob).replace(/(?:\\\*)+/g, '.*') + '$', 'i')) return regex.test(match) } const isAbsent = (type: string) => { @@ -707,13 +740,13 @@ export default function AssetsTable(props: AssetsTableProps) { } }, [query]) - const visibilities = React.useMemo(() => { - const map = new Map() - const processNode = (node: assetTreeNode.AnyAssetTreeNode) => { + const visibilities = useMemo(() => { + const map = new Map() + const processNode = (node: AnyAssetTreeNode) => { let displayState = Visibility.hidden const visible = filter?.(node) ?? true for (const child of node.children ?? []) { - if (visible && child.item.type === backendModule.AssetType.specialEmpty) { + if (visible && child.item.type === AssetType.specialEmpty) { map.set(child.key, Visibility.visible) } else { processNode(child) @@ -732,20 +765,20 @@ export default function AssetsTable(props: AssetsTableProps) { return map }, [assetTree, filter]) - const displayItems = React.useMemo(() => { + const displayItems = useMemo(() => { if (sortInfo == null) { return assetTree.preorderTraversal((children) => children.filter((child) => expandedDirectoryIdsSet.has(child.directoryId)), ) } else { - const multiplier = sortInfo.direction === sorting.SortDirection.ascending ? 1 : -1 - let compare: (a: assetTreeNode.AnyAssetTreeNode, b: assetTreeNode.AnyAssetTreeNode) => number + const multiplier = sortInfo.direction === SortDirection.ascending ? 1 : -1 + let compare: (a: AnyAssetTreeNode, b: AnyAssetTreeNode) => number switch (sortInfo.field) { - case columnUtils.Column.name: { + case Column.name: { compare = (a, b) => multiplier * a.item.title.localeCompare(b.item.title, 'en') break } - case columnUtils.Column.modified: { + case Column.modified: { compare = (a, b) => { const aOrder = Number(new Date(a.item.modifiedAt)) const bOrder = Number(new Date(b.item.modifiedAt)) @@ -760,36 +793,32 @@ export default function AssetsTable(props: AssetsTableProps) { } }, [assetTree, sortInfo, expandedDirectoryIdsSet]) - const visibleItems = React.useMemo( + const visibleItems = useMemo( () => displayItems.filter((item) => visibilities.get(item.key) !== Visibility.hidden), [displayItems, visibilities], ) - const [isDraggingFiles, setIsDraggingFiles] = React.useState(false) - const [droppedFilesCount, setDroppedFilesCount] = React.useState(0) - const isCloud = backend.type === backendModule.BackendType.remote + const [isDraggingFiles, setIsDraggingFiles] = useState(false) + const [droppedFilesCount, setDroppedFilesCount] = useState(0) + const isCloud = backend.type === BackendType.remote /** Events sent when the asset list was still loading. */ - const queuedAssetListEventsRef = React.useRef([]) - const rootRef = React.useRef(null) - const cleanupRootRef = React.useRef(() => {}) - const mainDropzoneRef = React.useRef(null) - const lastSelectedIdsRef = React.useRef< - backendModule.AssetId | ReadonlySet | null - >(null) - const headerRowRef = React.useRef(null) - const assetTreeRef = React.useRef(assetTree) - const pasteDataRef = React.useRef - > | null>(null) - const nodeMapRef = React.useRef< - ReadonlyMap - >(new Map()) + const queuedAssetListEventsRef = useRef([]) + const rootRef = useRef(null) + const cleanupRootRef = useRef(() => {}) + const mainDropzoneRef = useRef(null) + const lastSelectedIdsRef = useRef | null>(null) + const headerRowRef = useRef(null) + const assetTreeRef = useRef(assetTree) + const pasteDataRef = useRef> | null>(null) + const nodeMapRef = useRef>( + new Map(), + ) const isAssetContextMenuVisible = - category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo + category.type !== 'cloud' || user.plan == null || user.plan === Plan.solo const queryClient = useQueryClient() - const isMainDropzoneVisible = intersectionHooks.useIntersectionRatio( + const isMainDropzoneVisible = useIntersectionRatio( rootRef, mainDropzoneRef, MINIMUM_DROPZONE_INTERSECTION_RATIO, @@ -797,34 +826,32 @@ export default function AssetsTable(props: AssetsTableProps) { true, ) - React.useEffect(() => { + useEffect(() => { previousCategoryRef.current = category }) const setTargetDirectory = useEventCallback( - (targetDirectory: AssetTreeNode | null) => { + (targetDirectory: AssetTreeNode | null) => { const targetDirectorySelfPermission = targetDirectory == null ? null : ( - permissions.tryFindSelfPermission(user, targetDirectory.item.permissions) + tryFindSelfPermission(user, targetDirectory.item.permissions) ) const canCreateAssets = targetDirectory == null ? - category.type !== 'cloud' || user.plan == null || user.plan === backendModule.Plan.solo + category.type !== 'cloud' || user.plan == null || user.plan === Plan.solo : isLocalCategory(category) || (targetDirectorySelfPermission != null && - permissions.canPermissionModifyDirectoryContents( - targetDirectorySelfPermission.permission, - )) + canPermissionModifyDirectoryContents(targetDirectorySelfPermission.permission)) setCanCreateAssets(canCreateAssets) setTargetDirectoryInStore(targetDirectory) }, ) - React.useEffect(() => { + useEffect(() => { setNewestFolderId(null) }, [category, setNewestFolderId]) - React.useEffect( + useEffect( () => driveStore.subscribe(({ selectedKeys }, { selectedKeys: oldSelectedKeys }) => { if (selectedKeys !== oldSelectedKeys) { @@ -833,19 +860,19 @@ export default function AssetsTable(props: AssetsTableProps) { } else if (selectedKeys.size === 1) { const [soleKey] = selectedKeys const node = soleKey == null ? null : nodeMapRef.current.get(soleKey) - if (node != null && node.isType(backendModule.AssetType.directory)) { + if (node != null && node.isType(AssetType.directory)) { setTargetDirectory(node) } } else { - let commonDirectoryKey: backendModule.AssetId | null = null - let otherCandidateDirectoryKey: backendModule.AssetId | null = null + let commonDirectoryKey: AssetId | null = null + let otherCandidateDirectoryKey: AssetId | null = null for (const key of selectedKeys) { const node = nodeMapRef.current.get(key) if (node != null) { if (commonDirectoryKey == null) { commonDirectoryKey = node.directoryKey otherCandidateDirectoryKey = - node.item.type === backendModule.AssetType.directory ? node.key : null + node.item.type === AssetType.directory ? node.key : null } else if ( node.key === commonDirectoryKey || node.directoryKey === commonDirectoryKey @@ -867,7 +894,7 @@ export default function AssetsTable(props: AssetsTableProps) { } const node = commonDirectoryKey == null ? null : nodeMapRef.current.get(commonDirectoryKey) - if (node != null && node.isType(backendModule.AssetType.directory)) { + if (node != null && node.isType(AssetType.directory)) { setTargetDirectory(node) } } @@ -876,10 +903,10 @@ export default function AssetsTable(props: AssetsTableProps) { [driveStore, setTargetDirectory], ) - React.useEffect(() => { + useEffect(() => { const nodeToSuggestion = ( - node: assetTreeNode.AnyAssetTreeNode, - key: assetQuery.AssetQueryKey = 'names', + node: AnyAssetTreeNode, + key: AssetQueryKey = 'names', ): assetSearchBar.Suggestion => ({ render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`, addToQuery: (oldQuery) => oldQuery.addToLastTerm({ [key]: [node.item.title] }), @@ -893,8 +920,8 @@ export default function AssetsTable(props: AssetsTableProps) { .filter( (node) => visibilities.get(node.key) === Visibility.visible && - node.item.type !== backendModule.AssetType.specialEmpty && - node.item.type !== backendModule.AssetType.specialLoading, + node.item.type !== AssetType.specialEmpty && + node.item.type !== AssetType.specialLoading, ) const allVisible = (negative = false) => allVisibleNodes().map((node) => nodeToSuggestion(node, negative ? 'negativeNames' : 'names')) @@ -938,8 +965,8 @@ export default function AssetsTable(props: AssetsTableProps) { case 'extension': case '-extension': { const extensions = allVisibleNodes() - .filter((node) => node.item.type === backendModule.AssetType.file) - .map((node) => fileInfo.fileExtension(node.item.title)) + .filter((node) => node.item.type === AssetType.file) + .map((node) => fileExtension(node.item.title)) setSuggestions( Array.from( new Set(extensions), @@ -996,8 +1023,8 @@ export default function AssetsTable(props: AssetsTableProps) { .preorderTraversal() .flatMap((node) => (node.item.permissions ?? []) - .filter((permission) => permission.permission === permissions.PermissionAction.own) - .map(backendModule.getAssetPermissionName), + .filter((permission) => permission.permission === PermissionAction.own) + .map(getAssetPermissionName), ) setSuggestions( Array.from( @@ -1052,20 +1079,20 @@ export default function AssetsTable(props: AssetsTableProps) { } }, [isCloud, assetTree, query, visibilities, labels, setSuggestions]) - React.useEffect(() => { + useEffect(() => { assetTreeRef.current = assetTree const newNodeMap = new Map(assetTree.preorderTraversal().map((asset) => [asset.key, asset])) newNodeMap.set(assetTree.key, assetTree) nodeMapRef.current = newNodeMap }, [assetTree]) - React.useEffect(() => { + useEffect(() => { pasteDataRef.current = pasteData }, [pasteData]) - React.useEffect(() => { + useEffect(() => { if (!hidden) { - return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { + return inputBindings.attach(document.body, 'keydown', { cancelCut: () => { if (pasteDataRef.current == null) { return false @@ -1079,7 +1106,7 @@ export default function AssetsTable(props: AssetsTableProps) { } }, [hidden, inputBindings, dispatchAssetEvent]) - React.useEffect( + useEffect( () => driveStore.subscribe(({ selectedKeys }) => { let newCanDownload: boolean @@ -1088,7 +1115,7 @@ export default function AssetsTable(props: AssetsTableProps) { selectedKeys.size !== 0 && Array.from(selectedKeys).every((key) => { const node = nodeMapRef.current.get(key) - return node?.item.type === backendModule.AssetType.project + return node?.item.type === AssetType.project }) } else { newCanDownload = @@ -1096,9 +1123,9 @@ export default function AssetsTable(props: AssetsTableProps) { Array.from(selectedKeys).every((key) => { const node = nodeMapRef.current.get(key) return ( - node?.item.type === backendModule.AssetType.project || - node?.item.type === backendModule.AssetType.file || - node?.item.type === backendModule.AssetType.datalink + node?.item.type === AssetType.project || + node?.item.type === AssetType.file || + node?.item.type === AssetType.datalink ) }) } @@ -1110,22 +1137,22 @@ export default function AssetsTable(props: AssetsTableProps) { [driveStore, isCloud, setCanDownload], ) - React.useEffect(() => { + useEffect(() => { if (isLoading) { nameOfProjectToImmediatelyOpenRef.current = initialProjectName } else { // The project name here might also be a string with project id, e.g. when opening // a project file from explorer on Windows. - const isInitialProject = (asset: backendModule.AnyAsset) => + const isInitialProject = (asset: AnyAsset) => asset.title === initialProjectName || asset.id === initialProjectName const projectToLoad = assetTree .preorderTraversal() .map((node) => node.item) - .filter(backendModule.assetIsProject) + .filter(assetIsProject) .find(isInitialProject) if (projectToLoad != null) { doOpenProject({ - type: backendModule.BackendType.local, + type: BackendType.local, id: projectToLoad.id, title: projectToLoad.title, parentId: projectToLoad.parentId, @@ -1138,18 +1165,18 @@ export default function AssetsTable(props: AssetsTableProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialProjectName]) - React.useEffect(() => { + useEffect(() => { const savedEnabledColumns = localStorage.get('enabledColumns') if (savedEnabledColumns != null) { setEnabledColumns(new Set(savedEnabledColumns)) } }, [localStorage]) - React.useEffect(() => { + useEffect(() => { localStorage.set('enabledColumns', [...enabledColumns]) }, [enabledColumns, localStorage]) - React.useEffect( + useEffect( () => driveStore.subscribe(({ selectedKeys }) => { if (selectedKeys.size !== 1) { @@ -1161,16 +1188,12 @@ export default function AssetsTable(props: AssetsTableProps) { ) const doToggleDirectoryExpansion = useEventCallback( - ( - directoryId: backendModule.DirectoryId, - _key: backendModule.DirectoryId, - override?: boolean, - ) => { + (directoryId: DirectoryId, _key: DirectoryId, override?: boolean) => { const isExpanded = expandedDirectoryIdsSet.has(directoryId) const shouldExpand = override ?? !isExpanded if (shouldExpand !== isExpanded) { - React.startTransition(() => { + startTransition(() => { if (shouldExpand) { setExpandedDirectoryIds((currentExpandedDirectoryIds) => [ ...currentExpandedDirectoryIds, @@ -1187,7 +1210,7 @@ export default function AssetsTable(props: AssetsTableProps) { ) const doCopyOnBackend = useEventCallback( - async (newParentId: backendModule.DirectoryId | null, asset: backendModule.AnyAsset) => { + async (newParentId: DirectoryId | null, asset: AnyAsset) => { try { newParentId ??= rootDirectoryId @@ -1203,77 +1226,68 @@ export default function AssetsTable(props: AssetsTableProps) { }, ) - const doMove = useEventCallback( - async (newParentId: backendModule.DirectoryId | null, asset: backendModule.AnyAsset) => { - try { - await updateAssetMutation.mutateAsync([ - asset.id, - { parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, - asset.title, - ]) - } catch (error) { - toastAndLog('moveAssetError', error, asset.title) - } - }, - ) + const doMove = useEventCallback(async (newParentId: DirectoryId | null, asset: AnyAsset) => { + try { + await updateAssetMutation.mutateAsync([ + asset.id, + { parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, + asset.title, + ]) + } catch (error) { + toastAndLog('moveAssetError', error, asset.title) + } + }) - const doDelete = useEventCallback( - async (asset: backendModule.AnyAsset, forever: boolean = false) => { - if (asset.type === backendModule.AssetType.directory) { - dispatchAssetListEvent({ - type: AssetListEventType.closeFolder, - id: asset.id, - // This is SAFE, as this asset is already known to be a directory. - // eslint-disable-next-line no-restricted-syntax - key: asset.id, - }) - } - try { - dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id }) - if ( - asset.type === backendModule.AssetType.project && - backend.type === backendModule.BackendType.local - ) { - try { - await closeProjectMutation.mutateAsync([asset.id, asset.title]) - } catch { - // Ignored. The project was already closed. - } - } - await deleteAssetMutation.mutateAsync([asset.id, { force: forever }, asset.title]) - } catch (error) { - toastAndLog('deleteAssetError', error, asset.title) - } - }, - ) - - const doDeleteById = useEventCallback( - async (assetId: backendModule.AssetId, forever: boolean = false) => { - const asset = nodeMapRef.current.get(assetId)?.item - - if (asset != null) { + const doDelete = useEventCallback(async (asset: AnyAsset, forever: boolean = false) => { + if (asset.type === AssetType.directory) { + dispatchAssetListEvent({ + type: AssetListEventType.closeFolder, + id: asset.id, + // This is SAFE, as this asset is already known to be a directory. // eslint-disable-next-line no-restricted-syntax - return doDelete(asset, forever) + key: asset.id, + }) + } + try { + dispatchAssetListEvent({ type: AssetListEventType.willDelete, key: asset.id }) + if (asset.type === AssetType.project && backend.type === BackendType.local) { + try { + await closeProjectMutation.mutateAsync([asset.id, asset.title]) + } catch { + // Ignored. The project was already closed. + } } - }, - ) + await deleteAssetMutation.mutateAsync([asset.id, { force: forever }, asset.title]) + } catch (error) { + toastAndLog('deleteAssetError', error, asset.title) + } + }) - const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial) - const [keyboardSelectedIndex, setKeyboardSelectedIndex] = React.useState(null) - const mostRecentlySelectedIndexRef = React.useRef(null) - const selectionStartIndexRef = React.useRef(null) - const bodyRef = React.useRef(null) + const doDeleteById = useEventCallback(async (assetId: AssetId, forever: boolean = false) => { + const asset = nodeMapRef.current.get(assetId)?.item + + if (asset != null) { + // eslint-disable-next-line no-restricted-syntax + return doDelete(asset, forever) + } + }) + + const [spinnerState, setSpinnerState] = useState(SpinnerState.initial) + const [keyboardSelectedIndex, setKeyboardSelectedIndex] = useState(null) + const mostRecentlySelectedIndexRef = useRef(null) + const selectionStartIndexRef = useRef(null) + const bodyRef = useRef(null) const setMostRecentlySelectedIndex = useEventCallback( (index: number | null, isKeyboard: boolean = false) => { - React.startTransition(() => { + startTransition(() => { mostRecentlySelectedIndexRef.current = index setKeyboardSelectedIndex(isKeyboard ? index : null) }) }, ) - React.useEffect(() => { + useEffect(() => { const body = bodyRef.current if (body == null) { return @@ -1288,7 +1302,7 @@ export default function AssetsTable(props: AssetsTableProps) { // This is not a React component, even though it contains JSX. // eslint-disable-next-line no-restricted-syntax - const onKeyDown = (event: React.KeyboardEvent) => { + const onKeyDown = (event: KeyboardEvent) => { const { selectedKeys } = driveStore.getState() const prevIndex = mostRecentlySelectedIndexRef.current const item = prevIndex == null ? null : visibleItems[prevIndex] @@ -1298,16 +1312,16 @@ export default function AssetsTable(props: AssetsTableProps) { case ' ': { if (event.key === ' ' && event.ctrlKey) { const keys = selectedKeys - setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key))) + setSelectedKeys(withPresence(keys, item.key, !keys.has(item.key))) } else { switch (item.type) { - case backendModule.AssetType.directory: { + case AssetType.directory: { event.preventDefault() event.stopPropagation() doToggleDirectoryExpansion(item.item.id, item.key) break } - case backendModule.AssetType.project: { + case AssetType.project: { event.preventDefault() event.stopPropagation() doOpenProject({ @@ -1318,13 +1332,13 @@ export default function AssetsTable(props: AssetsTableProps) { }) break } - case backendModule.AssetType.datalink: { + case AssetType.datalink: { event.preventDefault() event.stopPropagation() setIsAssetPanelTemporarilyVisible(true) break } - case backendModule.AssetType.secret: { + case AssetType.secret: { event.preventDefault() event.stopPropagation() const id = item.item.id @@ -1351,7 +1365,7 @@ export default function AssetsTable(props: AssetsTableProps) { break } case 'ArrowLeft': { - if (item.type === backendModule.AssetType.directory) { + if (item.type === AssetType.directory) { if (item.children != null) { // The folder is expanded; collapse it. event.preventDefault() @@ -1377,7 +1391,7 @@ export default function AssetsTable(props: AssetsTableProps) { break } case 'ArrowRight': { - if (item.type === backendModule.AssetType.directory && item.children == null) { + if (item.type === AssetType.directory && item.children == null) { // The folder is collapsed; expand it. event.preventDefault() event.stopPropagation() @@ -1391,12 +1405,12 @@ export default function AssetsTable(props: AssetsTableProps) { case ' ': { if (event.ctrlKey && item != null) { const keys = selectedKeys - setSelectedKeys(set.withPresence(keys, item.key, !keys.has(item.key))) + setSelectedKeys(withPresence(keys, item.key, !keys.has(item.key))) } break } case 'Escape': { - setSelectedKeys(set.EMPTY_SET) + setSelectedKeys(EMPTY_SET) setMostRecentlySelectedIndex(null) selectionStartIndexRef.current = null break @@ -1419,13 +1433,9 @@ export default function AssetsTable(props: AssetsTableProps) { itemType = visibleItems[index]?.item.type } while ( index !== oldIndex && - (itemType === backendModule.AssetType.specialEmpty || - itemType === backendModule.AssetType.specialLoading) + (itemType === AssetType.specialEmpty || itemType === AssetType.specialLoading) ) - if ( - itemType === backendModule.AssetType.specialEmpty || - itemType === backendModule.AssetType.specialLoading - ) { + if (itemType === AssetType.specialEmpty || itemType === AssetType.specialLoading) { index = prevIndex } } @@ -1456,7 +1466,7 @@ export default function AssetsTable(props: AssetsTableProps) { } else { // The arrow key will escape this container. In that case, do not stop propagation // and let `navigator2D` navigate to a different container. - setSelectedKeys(set.EMPTY_SET) + setSelectedKeys(EMPTY_SET) selectionStartIndexRef.current = null } break @@ -1464,7 +1474,7 @@ export default function AssetsTable(props: AssetsTableProps) { } } - React.useEffect(() => { + useEffect(() => { const onClick = () => { setKeyboardSelectedIndex(null) } @@ -1476,7 +1486,7 @@ export default function AssetsTable(props: AssetsTableProps) { }, [setMostRecentlySelectedIndex]) const getNewProjectName = useEventCallback( - (templateName: string | null, parentKey: backendModule.DirectoryId | null) => { + (templateName: string | null, parentKey: DirectoryId | null) => { const prefix = `${templateName ?? 'New Project'} ` const projectNameTemplate = new RegExp(`^${prefix}(?\\d+)$`) const siblings = @@ -1485,14 +1495,14 @@ export default function AssetsTable(props: AssetsTableProps) { : nodeMapRef.current.get(parentKey)?.children ?? [] const projectIndices = siblings .map((node) => node.item) - .filter(backendModule.assetIsProject) + .filter(assetIsProject) .map((item) => projectNameTemplate.exec(item.title)?.groups?.projectIndex) .map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0)) return `${prefix}${Math.max(0, ...projectIndices) + 1}` }, ) - const deleteAsset = useEventCallback((assetId: backendModule.AssetId) => { + const deleteAsset = useEventCallback((assetId: AssetId) => { const asset = nodeMapRef.current.get(assetId)?.item if (asset) { @@ -1512,7 +1522,7 @@ export default function AssetsTable(props: AssetsTableProps) { /** All items must have the same type. */ const insertAssets = useEventCallback( - (assets: readonly backendModule.AnyAsset[], parentId: backendModule.DirectoryId | null) => { + (assets: readonly AnyAsset[], parentId: DirectoryId | null) => { const actualParentId = parentId ?? rootDirectoryId const listDirectoryQuery = queryClient.getQueryCache().find({ @@ -1531,25 +1541,25 @@ export default function AssetsTable(props: AssetsTableProps) { // This is not a React component, even though it contains JSX. // eslint-disable-next-line no-restricted-syntax - const onAssetListEvent = useEventCallback((event: assetListEvent.AssetListEvent) => { + const onAssetListEvent = useEventCallback((event: AssetListEvent) => { switch (event.type) { case AssetListEventType.newFolder: { const parent = nodeMapRef.current.get(event.parentKey) const siblings = parent?.children ?? [] const directoryIndices = siblings .map((node) => node.item) - .filter(backendModule.assetIsDirectory) + .filter(assetIsDirectory) .map((item) => /^New Folder (?\d+)$/.exec(item.title)) .map((match) => match?.groups?.directoryIndex) .map((maybeIndex) => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0)) const title = `New Folder ${Math.max(0, ...directoryIndices) + 1}` - const placeholderItem: backendModule.DirectoryAsset = { - type: backendModule.AssetType.directory, - id: backendModule.DirectoryId(uniqueString.uniqueString()), + const placeholderItem: DirectoryAsset = { + type: AssetType.directory, + id: DirectoryId(uniqueString()), title, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: toRfc3339(new Date()), parentId: event.parentId, - permissions: permissions.tryCreateOwnerPermission( + permissions: tryCreateOwnerPermission( `${parent?.path ?? ''}/${title}`, category, user, @@ -1576,17 +1586,17 @@ export default function AssetsTable(props: AssetsTableProps) { case AssetListEventType.newProject: { const parent = nodeMapRef.current.get(event.parentKey) const projectName = getNewProjectName(event.preferredName, event.parentId) - const dummyId = backendModule.ProjectId(uniqueString.uniqueString()) + const dummyId = ProjectId(uniqueString()) const path = backend instanceof LocalBackend ? backend.joinPath(event.parentId, projectName) : null - const placeholderItem: backendModule.ProjectAsset = { - type: backendModule.AssetType.project, + const placeholderItem: ProjectAsset = { + type: AssetType.project, id: dummyId, title: projectName, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: toRfc3339(new Date()), parentId: event.parentId, - permissions: permissions.tryCreateOwnerPermission( + permissions: tryCreateOwnerPermission( `${parent?.path ?? ''}/${projectName}`, category, user, @@ -1594,7 +1604,7 @@ export default function AssetsTable(props: AssetsTableProps) { userGroups ?? [], ), projectState: { - type: backendModule.ProjectState.placeholder, + type: ProjectState.placeholder, volumeId: '', openedBy: user.email, ...(path != null ? { path } : {}), @@ -1641,48 +1651,46 @@ export default function AssetsTable(props: AssetsTableProps) { const parent = nodeMapRef.current.get(event.parentKey) const siblingNodes = parent?.children ?? [] const siblings = siblingNodes.map((node) => node.item) - const siblingFiles = siblings.filter(backendModule.assetIsFile) - const siblingProjects = siblings.filter(backendModule.assetIsProject) + const siblingFiles = siblings.filter(assetIsFile) + const siblingProjects = siblings.filter(assetIsProject) const siblingFileTitles = new Set(siblingFiles.map((asset) => asset.title)) const siblingProjectTitles = new Set(siblingProjects.map((asset) => asset.title)) - const files = reversedFiles.filter(backendModule.fileIsNotProject) - const projects = reversedFiles.filter(backendModule.fileIsProject) + const files = reversedFiles.filter(fileIsNotProject) + const projects = reversedFiles.filter(fileIsProject) const duplicateFiles = files.filter((file) => siblingFileTitles.has(file.name)) const duplicateProjects = projects.filter((project) => - siblingProjectTitles.has(backendModule.stripProjectExtension(project.name)), + siblingProjectTitles.has(stripProjectExtension(project.name)), ) - const ownerPermission = permissions.tryCreateOwnerPermission( + const ownerPermission = tryCreateOwnerPermission( parent?.path ?? '', category, user, users ?? [], userGroups ?? [], ) - const fileMap = new Map() - const uploadedFileIds: backendModule.AssetId[] = [] - const addIdToSelection = (id: backendModule.AssetId) => { + const fileMap = new Map() + const uploadedFileIds: AssetId[] = [] + const addIdToSelection = (id: AssetId) => { uploadedFileIds.push(id) const newIds = new Set(uploadedFileIds) setSelectedKeys(newIds) } - const doUploadFile = async (asset: backendModule.AnyAsset, method: 'new' | 'update') => { + const doUploadFile = async (asset: AnyAsset, method: 'new' | 'update') => { const file = fileMap.get(asset.id) if (file != null) { const fileId = method === 'new' ? null : asset.id switch (true) { - case backendModule.assetIsProject(asset): { - const { extension } = backendModule.extractProjectExtension(file.name) - const title = backendModule.stripProjectExtension(asset.title) + case assetIsProject(asset): { + const { extension } = extractProjectExtension(file.name) + const title = stripProjectExtension(asset.title) const assetNode = nodeMapRef.current.get(asset.id) - if (backend.type === backendModule.BackendType.local && localBackend != null) { - const directory = localBackendModule.extractTypeAndId( - assetNode?.directoryId ?? asset.parentId, - ).id + if (backend.type === BackendType.local && localBackend != null) { + const directory = extractTypeAndId(assetNode?.directoryId ?? asset.parentId).id let id: string if ( 'backendApi' in window && @@ -1709,7 +1717,7 @@ export default function AssetsTable(props: AssetsTableProps) { const response = await fetch(path, { method: 'POST', body }) id = await response.text() } - const projectId = localBackendModule.newProjectId(projectManager.UUID(id)) + const projectId = newProjectId(UUID(id)) addIdToSelection(projectId) await getProjectDetailsMutation @@ -1739,7 +1747,7 @@ export default function AssetsTable(props: AssetsTableProps) { break } - case backendModule.assetIsFile(asset): { + case assetIsFile(asset): { void uploadFileMutation .mutateAsync([ { fileId, fileName: asset.title, parentDirectoryId: asset.parentId }, @@ -1759,18 +1767,14 @@ export default function AssetsTable(props: AssetsTableProps) { if (duplicateFiles.length === 0 && duplicateProjects.length === 0) { const placeholderFiles = files.map((file) => { - const asset = backendModule.createPlaceholderFileAsset( - file.name, - event.parentId, - ownerPermission, - ) + const asset = createPlaceholderFileAsset(file.name, event.parentId, ownerPermission) fileMap.set(asset.id, file) return asset }) const placeholderProjects = projects.map((project) => { - const basename = backendModule.stripProjectExtension(project.name) - const asset = backendModule.createPlaceholderProjectAsset( + const basename = stripProjectExtension(project.name) + const asset = createPlaceholderProjectAsset( basename, event.parentId, ownerPermission, @@ -1798,21 +1802,17 @@ export default function AssetsTable(props: AssetsTableProps) { // with the same name. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion current: siblingFilesByName.get(file.name)!, - new: backendModule.createPlaceholderFileAsset( - file.name, - event.parentId, - ownerPermission, - ), + new: createPlaceholderFileAsset(file.name, event.parentId, ownerPermission), file, })) const conflictingProjects = duplicateProjects.map((project) => { - const basename = backendModule.stripProjectExtension(project.name) + const basename = stripProjectExtension(project.name) return { // This is SAFE, as `duplicateProjects` only contains projects that have // siblings with the same name. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion current: siblingProjectsByName.get(basename)!, - new: backendModule.createPlaceholderProjectAsset( + new: createPlaceholderProjectAsset( basename, event.parentId, ownerPermission, @@ -1856,7 +1856,7 @@ export default function AssetsTable(props: AssetsTableProps) { const newFiles = files .filter((file) => !siblingFileTitles.has(file.name)) .map((file) => { - const asset = backendModule.createPlaceholderFileAsset( + const asset = createPlaceholderFileAsset( file.name, event.parentId, ownerPermission, @@ -1867,12 +1867,11 @@ export default function AssetsTable(props: AssetsTableProps) { const newProjects = projects .filter( - (project) => - !siblingProjectTitles.has(backendModule.stripProjectExtension(project.name)), + (project) => !siblingProjectTitles.has(stripProjectExtension(project.name)), ) .map((project) => { - const basename = backendModule.stripProjectExtension(project.name) - const asset = backendModule.createPlaceholderProjectAsset( + const basename = stripProjectExtension(project.name) + const asset = createPlaceholderProjectAsset( basename, event.parentId, ownerPermission, @@ -1898,13 +1897,13 @@ export default function AssetsTable(props: AssetsTableProps) { } case AssetListEventType.newDatalink: { const parent = nodeMapRef.current.get(event.parentKey) - const placeholderItem: backendModule.DatalinkAsset = { - type: backendModule.AssetType.datalink, - id: backendModule.DatalinkId(uniqueString.uniqueString()), + const placeholderItem: DatalinkAsset = { + type: AssetType.datalink, + id: DatalinkId(uniqueString()), title: event.name, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: toRfc3339(new Date()), parentId: event.parentId, - permissions: permissions.tryCreateOwnerPermission( + permissions: tryCreateOwnerPermission( `${parent?.path ?? ''}/${event.name}`, category, user, @@ -1931,13 +1930,13 @@ export default function AssetsTable(props: AssetsTableProps) { } case AssetListEventType.newSecret: { const parent = nodeMapRef.current.get(event.parentKey) - const placeholderItem: backendModule.SecretAsset = { - type: backendModule.AssetType.secret, - id: backendModule.SecretId(uniqueString.uniqueString()), + const placeholderItem: SecretAsset = { + type: AssetType.secret, + id: SecretId(uniqueString()), title: event.name, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: toRfc3339(new Date()), parentId: event.parentId, - permissions: permissions.tryCreateOwnerPermission( + permissions: tryCreateOwnerPermission( `${parent?.path ?? ''}/${event.name}`, category, user, @@ -1977,13 +1976,13 @@ export default function AssetsTable(props: AssetsTableProps) { title = `${event.original.title} (${index})` } - const placeholderItem: backendModule.ProjectAsset = { - type: backendModule.AssetType.project, - id: backendModule.ProjectId(uniqueString.uniqueString()), + const placeholderItem: ProjectAsset = { + type: AssetType.project, + id: ProjectId(uniqueString()), title, - modifiedAt: dateTime.toRfc3339(new Date()), + modifiedAt: toRfc3339(new Date()), parentId: event.parentId, - permissions: permissions.tryCreateOwnerPermission( + permissions: tryCreateOwnerPermission( `${parent?.path ?? ''}/${title}`, category, user, @@ -1991,7 +1990,7 @@ export default function AssetsTable(props: AssetsTableProps) { userGroups ?? [], ), projectState: { - type: backendModule.ProjectState.placeholder, + type: ProjectState.placeholder, volumeId: '', openedBy: user.email, }, @@ -2106,42 +2105,40 @@ export default function AssetsTable(props: AssetsTableProps) { const { selectedKeys } = driveStore.getState() setPasteData({ type: PasteType.move, data: selectedKeys }) dispatchAssetEvent({ type: AssetEventType.cut, ids: selectedKeys }) - setSelectedKeys(set.EMPTY_SET) + setSelectedKeys(EMPTY_SET) }) - const doPaste = useEventCallback( - (newParentKey: backendModule.DirectoryId, newParentId: backendModule.DirectoryId) => { - unsetModal() - if (pasteData != null) { - if (pasteData.data.has(newParentKey)) { - toast.toast.error('Cannot paste a folder into itself.') + const doPaste = useEventCallback((newParentKey: DirectoryId, newParentId: DirectoryId) => { + unsetModal() + if (pasteData != null) { + if (pasteData.data.has(newParentKey)) { + toast.error('Cannot paste a folder into itself.') + } else { + doToggleDirectoryExpansion(newParentId, newParentKey, true) + if (pasteData.type === PasteType.copy) { + const assets = Array.from(pasteData.data, (id) => nodeMapRef.current.get(id)).flatMap( + (asset) => (asset ? [asset.item] : []), + ) + dispatchAssetListEvent({ + type: AssetListEventType.copy, + items: assets, + newParentId, + newParentKey, + }) } else { - doToggleDirectoryExpansion(newParentId, newParentKey, true) - if (pasteData.type === PasteType.copy) { - const assets = Array.from(pasteData.data, (id) => nodeMapRef.current.get(id)).flatMap( - (asset) => (asset ? [asset.item] : []), - ) - dispatchAssetListEvent({ - type: AssetListEventType.copy, - items: assets, - newParentId, - newParentKey, - }) - } else { - dispatchAssetEvent({ - type: AssetEventType.move, - ids: pasteData.data, - newParentKey, - newParentId, - }) - } - setPasteData(null) + dispatchAssetEvent({ + type: AssetEventType.move, + ids: pasteData.data, + newParentKey, + newParentId, + }) } + setPasteData(null) } - }, - ) + } + }) - const doRestore = useEventCallback(async (asset: backendModule.AnyAsset) => { + const doRestore = useEventCallback(async (asset: AnyAsset) => { try { await undoDeleteAssetMutation.mutateAsync([asset.id, asset.title]) } catch (error) { @@ -2149,11 +2146,11 @@ export default function AssetsTable(props: AssetsTableProps) { } }) - const hideColumn = useEventCallback((column: columnUtils.Column) => { - setEnabledColumns((columns) => set.withPresence(columns, column, false)) + const hideColumn = useEventCallback((column: Column) => { + setEnabledColumns((columns) => withPresence(columns, column, false)) }) - const hiddenContextMenu = React.useMemo( + const hiddenContextMenu = useMemo( () => (
) @@ -2857,7 +2835,7 @@ export default function AssetsTable(props: AssetsTableProps) { {(innerProps) => (
()(innerProps, { + {...mergeProps()(innerProps, { ref: (value) => { rootRef.current = value cleanupRootRef.current() @@ -2915,7 +2893,7 @@ export default function AssetsTable(props: AssetsTableProps) { {(columnsBarProps) => (
()(columnsBarProps, { + {...mergeProps()(columnsBarProps, { className: 'inline-flex gap-icons', onFocus: () => { setKeyboardSelectedIndex(null) @@ -2924,10 +2902,11 @@ export default function AssetsTable(props: AssetsTableProps) { > {hiddenColumns.map((column) => ( + + {/* Required for shortcuts to work. */}