Merge branch 'develop' into wip/akirathan/11326-more-mini-passes

This commit is contained in:
Pavel Marek 2024-11-20 17:55:21 +01:00
commit eaf10e803d
450 changed files with 11889 additions and 7338 deletions

View File

@ -512,6 +512,7 @@ jobs:
ENSO_TEST_USER: ${{ secrets.ENSO_CLOUD_TEST_ACCOUNT_USERNAME }}
ENSO_TEST_USER_PASSWORD: ${{ secrets.ENSO_CLOUD_TEST_ACCOUNT_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
continue-on-error: true
- run: rm $HOME/.enso/credentials
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@ -35,9 +35,10 @@ pnpm-lock.yaml
# Generated files
app/ide-desktop/lib/client/electron-builder-config.json
app/ide-desktop/lib/content-config/src/config.json
app/ide-desktop/lib/dashboard/playwright-report/
app/ide-desktop/lib/dashboard/playwright/.cache/
app/ide-desktop/lib/dashboard/dist/
app/gui/playwright-report/
app/gui/playwright/.auth/
app/gui/dist/
app/gui/view/documentation/assets/stylesheet.css
app/rust-ffi/pkg
app/gui/src/project-view/assets/font-*.css

View File

@ -24,8 +24,11 @@
component.][11452]
- [New documentation editor provides improved Markdown editing experience, and
paves the way for new documentation features.][11469]
- [You can now add images to documentation panel][11547] by pasting them from
clipboard or by drag'n'dropping image files.
- ["Write" button in component menu allows to evaluate it separately from the
rest of the workflow][11523].
- [The documentation editor can now display tables][11564]
[11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271
@ -42,7 +45,9 @@
[11448]: https://github.com/enso-org/enso/pull/11448
[11452]: https://github.com/enso-org/enso/pull/11452
[11469]: https://github.com/enso-org/enso/pull/11469
[11547]: https://github.com/enso-org/enso/pull/11547
[11523]: https://github.com/enso-org/enso/pull/11523
[11564]: https://github.com/enso-org/enso/pull/11564
#### Enso Standard Library
@ -54,12 +59,15 @@
- [Support for dates before 1900 in Excel and signed AWS requests.][11373]
- [Added `Data.read_many` that allows to read a list of files in a single
operation.][11490]
- [Added `Table.input` allowing creation of typed tables from vectors of data,
including auto parsing text columns.][11562]
[11235]: https://github.com/enso-org/enso/pull/11235
[11255]: https://github.com/enso-org/enso/pull/11255
[11371]: https://github.com/enso-org/enso/pull/11371
[11373]: https://github.com/enso-org/enso/pull/11373
[11490]: https://github.com/enso-org/enso/pull/11490
[11562]: https://github.com/enso-org/enso/pull/11562
#### Enso Language & Runtime

View File

@ -199,3 +199,10 @@
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
This project includes components that are licensed under the MIT license. The
full text of the MIT license and its copyright notice can be found in the
`app/licenses/` directory.

View File

@ -4,9 +4,10 @@ ENSO_CLOUD_API_URL=https://aaaaaaaaaa.execute-api.mars.amazonaws.com
ENSO_CLOUD_CHAT_URL=wss://chat.example.com
ENSO_CLOUD_SENTRY_DSN=https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@o0000000000000000.ingest.sentry.io/0000000000000000
ENSO_CLOUD_STRIPE_KEY=pk_test_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
ENSO_CLOUD_AUTH_ENDPOINT=https://aaaaaaaaaa.execute-api.mars.amazonaws.com/path/to/auth/endpoint
ENSO_CLOUD_AMPLIFY_USER_POOL_ID=mars_AAAAAAAAA
ENSO_CLOUD_AMPLIFY_USER_POOL_WEB_CLIENT_ID=zzzzzzzzzzzzzzzzzzzzzzzzzz
ENSO_CLOUD_AMPLIFY_DOMAIN=somewhere.auth.mars.amazoncognito.com
ENSO_CLOUD_AMPLIFY_REGION=mars
ENSO_POLYGLOT_YDOC_SERVER=false
ENSO_YDOC_LS_DEBUG=false
ENSO_YDOC_LS_DEBUG=false

View File

@ -94,6 +94,7 @@ export function getDefines() {
'process.env.ENSO_CLOUD_SENTRY_DSN': stringify(process.env.ENSO_CLOUD_SENTRY_DSN),
'process.env.ENSO_CLOUD_STRIPE_KEY': stringify(process.env.ENSO_CLOUD_STRIPE_KEY),
'process.env.ENSO_CLOUD_CHAT_URL': stringify(process.env.ENSO_CLOUD_CHAT_URL),
'process.env.ENSO_CLOUD_AUTH_ENDPOINT': stringify(process.env.ENSO_CLOUD_AUTH_ENDPOINT),
'process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID': stringify(
process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
),

View File

@ -709,6 +709,12 @@ export function findLeastUsedColor(labels: Iterable<Label>) {
// === AssetType ===
// =================
export enum SpecialAssetType {
loading = 'specialLoading',
empty = 'specialEmpty',
error = 'specialError',
}
/** All possible types of directory entries. */
export enum AssetType {
project = 'project',
@ -728,12 +734,19 @@ export enum AssetType {
}
/** The corresponding ID newtype for each {@link AssetType}. */
export interface IdType {
export interface IdType extends RealAssetIdType, SpecialAssetIdType {}
export type RealAssetId = ProjectId | FileId | DatalinkId | SecretId | DirectoryId
export interface RealAssetIdType {
readonly [AssetType.project]: ProjectId
readonly [AssetType.file]: FileId
readonly [AssetType.datalink]: DatalinkId
readonly [AssetType.secret]: SecretId
readonly [AssetType.directory]: DirectoryId
}
export type SpecialAssetId = LoadingAssetId | EmptyAssetId | ErrorAssetId
export interface SpecialAssetIdType {
readonly [AssetType.specialLoading]: LoadingAssetId
readonly [AssetType.specialEmpty]: EmptyAssetId
readonly [AssetType.specialError]: ErrorAssetId
@ -805,6 +818,27 @@ export type SpecialEmptyAsset = Asset<AssetType.specialEmpty>
/** A convenience alias for {@link Asset}<{@link AssetType.specialError}>. */
export type SpecialErrorAsset = Asset<AssetType.specialError>
const PLACEHOLDER_SIGNATURE = Symbol('placeholder')
/** Creates a new placeholder id. */
function createPlaceholderId(from?: string): string {
const id = new String(from ?? uniqueString.uniqueString())
Object.defineProperty(id, PLACEHOLDER_SIGNATURE, {
value: true,
enumerable: false,
configurable: false,
writable: false,
})
return id as string
}
/** Whether a given {@link AssetId} is a placeholder id. */
export function isPlaceholderId(id: AssetId) {
return typeof id !== 'string' && PLACEHOLDER_SIGNATURE in id
}
/**
* Creates a {@link DirectoryAsset} representing the root directory for the organization,
* with all irrelevant fields initialized to default values.
@ -839,7 +873,7 @@ export function createPlaceholderFileAsset(
): FileAsset {
return {
type: AssetType.file,
id: FileId(uniqueString.uniqueString()),
id: FileId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
@ -858,12 +892,12 @@ export function createPlaceholderProjectAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
organization: User | null,
user: User | null,
path: Path | null,
): ProjectAsset {
return {
type: AssetType.project,
id: ProjectId(uniqueString.uniqueString()),
id: ProjectId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
@ -871,7 +905,7 @@ export function createPlaceholderProjectAsset(
projectState: {
type: ProjectState.new,
volumeId: '',
...(organization != null ? { openedBy: organization.email } : {}),
...(user != null ? { openedBy: user.email } : {}),
...(path != null ? { path } : {}),
},
extension: null,
@ -882,6 +916,72 @@ export function createPlaceholderProjectAsset(
}
}
/** Creates a {@link DirectoryAsset} using the given values. */
export function createPlaceholderDirectoryAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
): DirectoryAsset {
return {
type: AssetType.directory,
id: DirectoryId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
}
/** Creates a {@link SecretAsset} using the given values. */
export function createPlaceholderSecretAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
): SecretAsset {
return {
type: AssetType.secret,
id: SecretId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
}
/** Creates a {@link DatalinkAsset} using the given values. */
export function createPlaceholderDatalinkAsset(
title: string,
parentId: DirectoryId,
assetPermissions: readonly AssetPermission[],
): DatalinkAsset {
return {
type: AssetType.datalink,
id: DatalinkId(createPlaceholderId()),
title,
parentId,
permissions: assetPermissions,
modifiedAt: dateTime.toRfc3339(new Date()),
projectState: null,
extension: null,
labels: [],
description: null,
parentsPath: '',
virtualParentsPath: '',
}
}
/**
* Creates a {@link SpecialLoadingAsset}, with all irrelevant fields initialized to default
* values.
@ -890,7 +990,9 @@ export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoad
return {
type: AssetType.specialLoading,
title: '',
id: LoadingAssetId(`${AssetType.specialLoading}-${uniqueString.uniqueString()}`),
id: LoadingAssetId(
createPlaceholderId(`${AssetType.specialLoading}-${uniqueString.uniqueString()}`),
),
modifiedAt: dateTime.toRfc3339(new Date()),
parentId: directoryId,
permissions: [],
@ -990,7 +1092,7 @@ export function createPlaceholderAssetId<Type extends AssetType>(
): IdType[Type] {
// This is required so that TypeScript can check the `switch` for exhaustiveness.
const assetType: AssetType = type
id ??= uniqueString.uniqueString()
id = createPlaceholderId(id)
let result: AssetId
switch (assetType) {
case AssetType.directory: {
@ -1537,7 +1639,7 @@ export default abstract class Backend {
title: string,
): Promise<UpdatedDirectory>
/** List previous versions of an asset. */
abstract listAssetVersions(assetId: AssetId, title: string | null): Promise<AssetVersions>
abstract listAssetVersions(assetId: AssetId): Promise<AssetVersions>
/** Change the parent directory of an asset. */
abstract updateAsset(assetId: AssetId, body: UpdateAssetRequestBody, title: string): Promise<void>
/** Delete an arbitrary asset. */
@ -1578,7 +1680,6 @@ export default abstract class Backend {
abstract getProjectDetails(
projectId: ProjectId,
directoryId: DirectoryId | null,
title: string,
): Promise<Project>
/** Return Language Server logs for a project session. */
abstract getProjectSessionLogs(
@ -1598,7 +1699,7 @@ export default abstract class Backend {
title: string,
): Promise<UpdatedProject>
/** Fetch the content of the `Main.enso` file of a project. */
abstract getFileContent(projectId: ProjectId, version: string, title: string): Promise<string>
abstract getFileContent(projectId: ProjectId, versionId?: S3ObjectVersionId): Promise<string>
/** Return project memory, processor and storage usage. */
abstract checkResources(projectId: ProjectId, title: string): Promise<ResourceUsage>
/** Return a list of files accessible by the current user. */
@ -1675,4 +1776,7 @@ export default abstract class Backend {
* @param returnUrl - The URL to redirect to after the customer visits the portal.
*/
abstract createCustomerPortalSession(returnUrl: string): Promise<string | null>
/** Resolve the path of an asset relative to a project. */
abstract resolveProjectAssetPath(projectId: ProjectId, relativePath: string): Promise<string>
}

View File

@ -122,8 +122,8 @@
"listRootFolderBackendError": "Could not list root folder",
"createFolderBackendError": "Could not create folder '$0'",
"updateFolderBackendError": "Could not update folder '$0'",
"listAssetVersionsBackendError": "Could not list versions for '$0'",
"getFileContentsBackendError": "Could not get contents of '$0",
"listAssetVersionsBackendError": "Failed to list versions for the selected asset",
"getFileContentsBackendError": "Failed to get contents of the file",
"updateAssetBackendError": "Could not update '$0'",
"deleteAssetBackendError": "Could not delete '$0'",
"undoDeleteAssetBackendError": "Could not restore '$0' from Trash",
@ -134,7 +134,7 @@
"duplicateProjectBackendError": "Could not duplicate project as '$0'",
"closeProjectBackendError": "Could not close project '$0'",
"listProjectSessionsBackendError": "Could not list sessions for project '$0'",
"getProjectDetailsBackendError": "Could not get details of project '$0'",
"getProjectDetailsBackendError": "Could not get details of project",
"getProjectLogsBackendError": "Could not get logs for project '$0'",
"openProjectBackendError": "Could not open project '$0'",
"openProjectMissingCredentialsBackendError": "Could not open project '$0': Missing credentials",
@ -241,7 +241,6 @@
"reset": "Reset",
"members": "Members",
"drop": "Drop",
"projectSessions": "Sessions",
"logs": "Logs",
"showLogs": "Show Logs",
"accept": "Accept",
@ -261,6 +260,9 @@
"active": "Active",
"pendingInvitation": "Pending Invitation",
"versions": "Versions",
"properties": "Properties",
"projectSessions": "Sessions",
"docs": "Docs",
"datalink": "Datalink",
"secret": "Secret",
"createDatalink": "Create Datalink",
@ -365,10 +367,13 @@
"startWithATemplate": "Discover",
"openInfoMenu": "Open info menu",
"noProjectIsCurrentlyOpen": "No project is currently open.",
"versionOutdatedTitle": "Upgrade Enso Now",
"versionOutdatedPrompt": "Download the latest version to get the latest upgrades and Cloud functionality.",
"versionOutdatedTitle": "Update available",
"versionOutdatedPrompt": "A new version of Enso is available. We recommend updating to access the latest features, improvements, and security enhancements.",
"yourVersion": "Your version:",
"latestVersion": "Latest version:",
"latestVersion": "$0 ($1)",
"changeLog": "View changelog",
"downloadingAppMessage": "The latest version of Enso is now downloading, which may take a while depending on your internet speed. Please check your downloads folder, and you may close this dialog.",
"remindMeLater": "Remind me later",
"offlineTitle": "You are offline",
"offlineErrorMessage": "It seems like you are offline. Please make sure you are connected to the internet and try again",
"offlineToastMessage": "You are offline. Some features may be unavailable.",
@ -412,6 +417,7 @@
"userAgent": "User Agent",
"compareWithLatest": "Compare with latest",
"compareVersionXWithLatest": "Compare version $0 with latest",
"projectSessionX": "Session $0",
"onDateX": "on $0",
"xUsersAndGroupsSelected": "$0 users and groups selected",
"allTrashedItemsForever": "all trashed items forever",
@ -491,14 +497,6 @@
"closeWindowDialogTitle": "Close window?",
"anUploadIsInProgress": "An upload is in progress.",
"enableMultitabs": "Enable Multi-Tabs",
"enableMultitabsDescription": "Open multiple projects at the same time.",
"enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
"enableAssetsTableBackgroundRefreshDescription": "Automatically refresh the assets table in the background.",
"enableAssetsTableBackgroundRefreshInterval": "Refresh interval",
"enableAssetsTableBackgroundRefreshIntervalDescription": "Set the interval in ms for the assets table to refresh in the background.",
"deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "delete '$0'",
"deleteSelectedAssetsActionText": "delete $0 selected items",
@ -806,8 +804,8 @@
"arbitraryFieldInvalid": "This field is invalid",
"arbitraryFieldTooShort": "This field is too short",
"arbitraryFieldTooLong": "This field is too long",
"arbitraryFieldTooSmall": "The value is too small, the minimum is $0",
"arbitraryFieldTooLarge": "The value is too large, the maximum is $0",
"arbitraryFieldTooSmall": "The value must be greater than $0",
"arbitraryFieldTooLarge": "The value must be less than $0",
"arbitraryFieldNotEqual": "This field is not equal to another field",
"arbitraryFieldNotMatch": "This field does not match the pattern",
"arbitraryFieldNotMatchAny": "This field does not match any of the patterns",
@ -939,6 +937,12 @@
"ensoDevtoolsPlanSelectSubtitle": "User Plan",
"ensoDevtoolsPaywallFeaturesToggles": "Paywall Features",
"ensoDevtoolsFeatureFlags": "Feature Flags",
"ensoDevtoolsFeatureFlags.enableMultitabs": "Enable Multitabs",
"ensoDevtoolsFeatureFlags.enableMultitabsDescription": "Allow multiple asset panels to be open at the same time.",
"ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefresh": "Enable Assets Table Background Refresh",
"ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefreshDescription": "Enable background refresh of the assets table.",
"ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshInterval": "Assets Table Background Refresh Interval",
"ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshIntervalDescription": "The interval at which the assets table will be refreshed in the background.",
"setupEnso": "Set up Enso",
"termsAndConditions": "Terms and Conditions",
@ -947,5 +951,15 @@
"skip": "Skip",
"setUsernameDescription": "Start by setting your username. You can always change it later.",
"allSet": "All set!",
"allSetDescription": "You're all set up! Click the button below to start using Enso."
"allSetDescription": "You're all set up! Click the button below to start using Enso.",
"assetDocs.notProject": "Please select a project to view its docs.",
"assetDocs.noDocs": "No docs available for this asset.",
"assetProperties.notSelected": "Select a single asset to view its properties.",
"assetVersions.localAssetsDoNotHaveVersions": "Local assets do not have versions.",
"assetVersions.notSelected": "Select a single asset to view its versions.",
"assetProjectSessions.noSessions": "No sessions yet! Open the project to start a session.",
"assetProjectSessions.notSelected": "Select a single project to view its sessions.",
"assetProjectSessions.localBackend": "Sessions are not available for local projects.",
"assetProjectSessions.notProjectAsset": "Select a single project to view its sessions."
}

View File

@ -60,6 +60,7 @@ interface PlaceholderOverrides {
readonly chromeVersionX: [chromeVersion: string]
readonly userAgentX: [userAgent: string]
readonly compareVersionXWithLatest: [versionNumber: number]
readonly projectSessionX: [count: number]
readonly onDateX: [dateString: string]
readonly xUsersAndGroupsSelected: [usersAndGroupsCount: number]
readonly removeTheLocalDirectoryXFromFavorites: [directoryName: string]
@ -77,8 +78,6 @@ interface PlaceholderOverrides {
readonly listFolderBackendError: [string]
readonly createFolderBackendError: [string]
readonly updateFolderBackendError: [string]
readonly listAssetVersionsBackendError: [string]
readonly getFileContentsBackendError: [string]
readonly updateAssetBackendError: [string]
readonly deleteAssetBackendError: [string]
readonly undoDeleteAssetBackendError: [string]
@ -88,7 +87,6 @@ interface PlaceholderOverrides {
readonly duplicateProjectBackendError: [string]
readonly closeProjectBackendError: [string]
readonly listProjectSessionsBackendError: [string]
readonly getProjectDetailsBackendError: [string]
readonly getProjectLogsBackendError: [string]
readonly openProjectBackendError: [string]
readonly openProjectMissingCredentialsBackendError: [string]
@ -145,6 +143,8 @@ interface PlaceholderOverrides {
readonly arbitraryFieldTooLarge: [maxSize: string]
readonly arbitraryFieldTooSmall: [minSize: string]
readonly uploadLargeFileStatus: [uploadedParts: number, totalParts: number]
readonly latestVersion: [version: string, date: string]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */

1
app/gui/.gitignore vendored
View File

@ -25,6 +25,7 @@ mockDist
test-results/
playwright-report/
playwright/
src/project-view/util/iconList.json
src/project-view/util/iconName.ts

View File

@ -285,11 +285,62 @@ export default class DrivePageActions extends PageActions {
})
}
/** Show the Asset Panel. */
showAssetPanel() {
return this.step('Show asset panel', async (page) => {
const isShown = await this.isAssetPanelShown(page)
if (!isShown) {
await this.toggleAssetPanel()
}
})
}
/** Hide the Asset Panel. */
hideAssetPanel() {
return this.step('Hide asset panel', async (page) => {
const isShown = await this.isAssetPanelShown(page)
if (isShown) {
await this.toggleAssetPanel()
}
})
}
/** Toggle the Asset Panel open or closed. */
toggleAssetPanel() {
return this.step('Toggle asset panel', (page) =>
page.getByLabel('Asset Panel').locator('visible=true').click(),
)
return this.step('Toggle asset panel', async (page) => {
page.getByLabel('Asset Panel').locator('visible=true').click()
await this.waitForAssetPanelShown(page)
})
}
/**
* Check if the Asset Panel is shown.
*/
async isAssetPanelShown(page: test.Page) {
return await page
.getByTestId('asset-panel')
.isVisible({ timeout: 0 })
.then(
() => true,
() => false,
)
}
/**
* Wait for the Asset Panel to be shown and visually stable
*/
async waitForAssetPanelShown(page: test.Page) {
await page.getByTestId('asset-panel').waitFor({ state: 'visible' })
}
/** Show the description tab of the Asset Panel. */
toggleDescriptionAssetPanel() {
return this.step('Toggle description asset panel', async (page) => {
await this.showAssetPanel()
await page.getByTestId('asset-panel-tab-settings').click()
})
}
/** Interact with the container element of the assets table. */

View File

@ -30,7 +30,10 @@ export default class LoginPageActions extends BaseActions {
/** Perform a successful login. */
login(email = VALID_EMAIL, password = VALID_PASSWORD) {
return this.step('Login', () => this.loginInternal(email, password)).into(DrivePageActions)
return this.step('Login', async (page) => {
await this.loginInternal(email, password)
await passAgreementsDialog({ page })
}).into(DrivePageActions)
}
/** Perform a login as a new user (a user that does not yet have a username). */

View File

@ -1,5 +1,6 @@
/** @file Various actions, locators, and constants used in end-to-end tests. */
import * as test from '@playwright/test'
import * as path from 'path'
import { TEXTS } from 'enso-common/src/text'
@ -636,46 +637,6 @@ export async function expectNotOpacity0(locator: test.Locator) {
})
}
/** A test assertion to confirm that the element is onscreen. */
export async function expectOnScreen(locator: test.Locator) {
await test.test.step('Expect to be onscreen', async () => {
await test
.expect(async () => {
const pageBounds = await locator.evaluate(() => document.body.getBoundingClientRect())
const bounds = await locator.evaluate((el) => el.getBoundingClientRect())
test
.expect(
bounds.left < pageBounds.right &&
bounds.right > pageBounds.left &&
bounds.top < pageBounds.bottom &&
bounds.bottom > pageBounds.top,
)
.toBe(true)
})
.toPass()
})
}
/** A test assertion to confirm that the element is onscreen. */
export async function expectNotOnScreen(locator: test.Locator) {
await test.test.step('Expect to not be onscreen', async () => {
await test
.expect(async () => {
const pageBounds = await locator.evaluate(() => document.body.getBoundingClientRect())
const bounds = await locator.evaluate((el) => el.getBoundingClientRect())
test
.expect(
bounds.left >= pageBounds.right ||
bounds.right <= pageBounds.left ||
bounds.top >= pageBounds.bottom ||
bounds.bottom <= pageBounds.top,
)
.toBe(true)
})
.toPass()
})
}
// ==========================
// === Keyboard utilities ===
// ==========================
@ -723,10 +684,18 @@ export async function login(
first = true,
) {
await test.test.step('Login', async () => {
const url = new URL(page.url())
if (url.pathname !== '/login') {
return
}
await locateEmailInput(page).fill(email)
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
if (first) {
await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
@ -808,22 +777,77 @@ export const mockApi = apiModule.mockApi
/** Set up all mocks, without logging in. */
export function mockAll({ page, setupAPI }: MockParams) {
return new LoginPageActions(page).step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
const actions = new LoginPageActions(page)
actions.step('Execute all mocks', async () => {
await Promise.all([
mockApi({ page, setupAPI }),
mockDate({ page, setupAPI }),
mockAllAnimations({ page }),
mockUnneededUrls({ page }),
])
await page.goto('/')
})
return actions
}
/** Set up all mocks, and log in with dummy credentials. */
export function mockAllAndLogin({ page, setupAPI }: MockParams) {
return new DrivePageActions(page)
.step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await page.goto('/')
})
.do((thePage) => login({ page: thePage, setupAPI }))
export function mockAllAndLogin({ page, setupAPI }: MockParams): DrivePageActions {
mockAll({ page, setupAPI })
const actions = new DrivePageActions(page)
actions.step('Login', async () => {
await login({ page, setupAPI })
})
return actions
}
/**
* Mock all animations.
*/
export async function mockAllAnimations({ page }: MockParams) {
await page.addInitScript({
content: `
window.DISABLE_ANIMATIONS = true;
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.classList.add('disable-animations')
})
`,
})
}
/**
* Mock unneeded URLs.
*/
export async function mockUnneededUrls({ page }: MockParams) {
const EULA_JSON = JSON.stringify(apiModule.EULA_JSON)
const PRIVACY_JSON = JSON.stringify(apiModule.PRIVACY_JSON)
return Promise.all([
page.route('https://*.ingest.sentry.io/api/*/envelope/*', async (route) => {
await route.fulfill()
}),
page.route('https://api.mapbox.com/mapbox-gl-js/*/mapbox-gl.css', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
}),
page.route('https://ensoanalytics.com/eula.json', async (route) => {
await route.fulfill({ contentType: 'text/json', body: EULA_JSON })
}),
page.route('https://ensoanalytics.com/privacy.json', async (route) => {
await route.fulfill({ contentType: 'text/json', body: PRIVACY_JSON })
}),
page.route('https://fonts.googleapis.com/css2*', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
}),
])
}
/**

View File

@ -62,6 +62,20 @@ export type MockApi = Awaited<ReturnType<typeof mockApiInternal>>
export const mockApi: (params: MockParams) => Promise<MockApi> = mockApiInternal
export const EULA_JSON = {
path: '/eula.md',
size: 9472,
modified: '2024-05-21T10:47:27.000Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
}
export const PRIVACY_JSON = {
path: '/privacy.md',
size: 1234,
modified: '2024-05-21T10:47:27.000Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
}
/** Add route handlers for the mock API to a page. */
async function mockApiInternal({ page, setupAPI }: MockParams) {
const defaultEmail = 'email@example.com' as backend.EmailAddress
@ -130,6 +144,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
const addAsset = <T extends backend.AnyAsset>(asset: T) => {
assets.push(asset)
assetMap.set(asset.id, asset)
return asset
}
@ -154,12 +169,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.AssetType.directory,
id: backend.DirectoryId('directory-' + uniqueString.uniqueString()),
projectState: null,
extension: null,
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -176,12 +194,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.ProjectState.closed,
volumeId: '',
},
extension: null,
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -192,12 +213,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.AssetType.file,
id: backend.FileId('file-' + uniqueString.uniqueString()),
projectState: null,
extension: '',
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -211,12 +235,15 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
type: backend.AssetType.secret,
id: backend.SecretId('secret-' + uniqueString.uniqueString()),
projectState: null,
extension: null,
title,
modifiedAt: dateTime.toRfc3339(new Date()),
description: null,
labels: [],
parentId: defaultDirectoryId,
permissions: [],
parentsPath: '',
virtualParentsPath: '',
},
rest,
)
@ -391,20 +418,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await page.route('https://www.googletagmanager.com/gtag/js*', (route) =>
route.fulfill({ contentType: 'text/javascript', body: 'export {};' }),
)
if (process.env.MOCK_ALL_URLS === 'true') {
await page.route('https://fonts.googleapis.com/css2*', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
})
await page.route('https://ensoanalytics.com/eula.json', async (route) => {
await route.fulfill({
json: {
path: '/eula.md',
size: 9472,
modified: '2024-06-26T10:44:04.939Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
},
})
})
await page.route(
'https://api.github.com/repos/enso-org/enso/releases/latest',
async (route) => {
@ -417,6 +432,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
headers: { location: 'https://objects.githubusercontent.com/foo/bar' },
})
})
await page.route('https://objects.githubusercontent.com/**', async (route) => {
await route.fulfill({
status: 200,
@ -446,10 +462,6 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
})
}
const isActuallyOnline = await page.evaluate(() => navigator.onLine)
if (!isActuallyOnline) {
await page.route('https://fonts.googleapis.com/*', (route) => route.abort())
}
await page.route(BASE_URL + '**', (_route, request) => {
throw new Error(
@ -493,6 +505,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
) as unknown as Query
const parentId = body.parent_id ?? defaultDirectoryId
let filteredAssets = assets.filter((asset) => asset.parentId === parentId)
// This lint rule is broken; there is clearly a case for `undefined` below.
switch (body.filter_by) {
case backend.FilterBy.active: {
@ -565,29 +578,41 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
}))
// === Endpoints with dummy implementations ===
await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (!project?.projectState) {
throw new Error('Attempting to get a project that does not exist.')
} else {
return {
organizationId: defaultOrganizationId,
projectId: projectId,
name: 'example project name',
state: project.projectState,
packageName: 'Project_root',
// eslint-disable-next-line camelcase
ide_version: null,
// eslint-disable-next-line camelcase
engine_version: {
value: '2023.2.1-nightly.2023.9.29',
lifecycle: backend.VersionLifecycle.development,
},
address: backend.Address('ws://localhost/'),
} satisfies backend.ProjectRaw
if (!project) {
throw new Error(`Cannot get details for a project that does not exist. Project ID: ${projectId} \n
Please make sure that you've created the project before opening it.
------------------------------------------------------------------------------------------------
Existing projects: ${Array.from(assetMap.values())
.filter((asset) => asset.type === backend.AssetType.project)
.map((asset) => asset.id)
.join(', ')}`)
}
if (!project.projectState) {
throw new Error(`Attempting to get a project that does not have a state. Usually it is a bug in the application.
------------------------------------------------------------------------------------------------
Tried to get: \n ${JSON.stringify(project, null, 2)}`)
}
return {
organizationId: defaultOrganizationId,
projectId: projectId,
name: 'example project name',
state: project.projectState,
packageName: 'Project_root',
// eslint-disable-next-line camelcase
ide_version: null,
// eslint-disable-next-line camelcase
engine_version: {
value: '2023.2.1-nightly.2023.9.29',
lifecycle: backend.VersionLifecycle.development,
},
address: backend.Address('ws://localhost/'),
} satisfies backend.ProjectRaw
})
// === Endpoints returning `void` ===
@ -616,10 +641,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
}
} else {
const body: Body = await request.postDataJSON()
const body: Body = request.postDataJSON()
const parentId = body.parentDirectoryId
// Can be any asset ID.
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
const id = backend.DirectoryId(`${assetId?.split('-')[0]}-${uniqueString.uniqueString()}`)
const json: backend.CopyAssetResponse = {
asset: {
id,
@ -632,7 +657,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
newAsset.parentId = parentId
newAsset.title += ' (copy)'
addAsset(newAsset)
await route.fulfill({ json })
return json
}
})
@ -661,11 +687,20 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
})
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (!project) {
throw new Error(
`Tried to open a project that does not exist. Project ID: ${projectId} \n Please make sure that you've created the project before opening it.`,
)
}
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
}
await route.fulfill()
route.fulfill()
})
await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async (route) => {
await route.fulfill()
@ -925,6 +960,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
projectId: id,
state: { type: backend.ProjectState.closed, volumeId: '' },
}
addProject(title, {
description: null,
id,
@ -944,6 +980,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
],
projectState: json.state,
})
return json
})
await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => {

View File

@ -25,24 +25,18 @@ const EMAIL = 'baz.quux@email.com'
test.test('open and close asset panel', ({ page }) =>
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.clickRow(0)
.withAssetPanel(async (assetPanel) => {
await actions.expectNotOnScreen(assetPanel)
await test.expect(assetPanel).toBeVisible()
})
.toggleAssetPanel()
.withAssetPanel(async (assetPanel) => {
await actions.expectOnScreen(assetPanel)
})
.toggleAssetPanel()
.withAssetPanel(async (assetPanel) => {
await actions.expectNotOnScreen(assetPanel)
await test.expect(assetPanel).not.toBeVisible()
}),
)
test.test('asset panel contents', ({ page }) =>
actions
.mockAll({
.mockAllAndLogin({
page,
setupAPI: (api) => {
const { defaultOrganizationId, defaultUserId } = api
@ -63,12 +57,8 @@ test.test('asset panel contents', ({ page }) =>
})
},
})
.login()
.do(async (thePage) => {
await actions.passAgreementsDialog({ page: thePage })
})
.driveTable.clickRow(0)
.toggleAssetPanel()
.toggleDescriptionAssetPanel()
.do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions.

View File

@ -0,0 +1,29 @@
import { test as setup } from '@playwright/test'
import { existsSync } from 'node:fs'
import path from 'node:path'
import * as actions from './actions'
const __dirname = path.dirname(new URL(import.meta.url).pathname)
const authFile = path.join(__dirname, '../../playwright/.auth/user.json')
const isProd = process.env.NODE_ENV === 'production'
const isFileExists = () => {
if (isProd) {
return false
}
return existsSync(authFile)
}
setup('authenticate', ({ page }) => {
if (isFileExists()) {
return setup.skip()
}
return actions
.mockAll({ page })
.login()
.do(async () => {
await page.context().storageState({ path: authFile })
})
})

View File

@ -2,6 +2,9 @@
import * as test from '@playwright/test'
import { VALID_EMAIL, mockAll } from './actions'
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('preserve email input when changing pages', ({ page }) =>
mockAll({ page })
.fillEmail(VALID_EMAIL)

View File

@ -14,29 +14,37 @@ export async function clickAssetRow(assetRow: test.Locator) {
test.test('drag labels onto single row', async ({ page }) => {
const label = 'aaaa'
await actions.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await actions.relog({ page })
return actions
.mockAllAndLogin({
page,
setupAPI: (api) => {
api.addLabel(label, backend.COLORS[0])
api.addLabel('bbbb', backend.COLORS[1])
api.addLabel('cccc', backend.COLORS[2])
api.addLabel('dddd', backend.COLORS[3])
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
.do(async () => {
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(1))
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(1))
await test
.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label))
.not.toBeVisible()
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
await test
.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label))
.not.toBeVisible()
await test
.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label))
.not.toBeVisible()
})
})
test.test('drag labels onto multiple rows', async ({ page }) => {
@ -54,6 +62,7 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label)

View File

@ -22,15 +22,12 @@ test.test('labels', async ({ page }) => {
// "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()
// "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')
await page.press('html', 'Escape')
// "New Label" modal with color set
// The exact number is allowed to vary; but to click the fourth color, there must be at least

View File

@ -7,12 +7,14 @@ import * as actions from './actions'
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('login and logout', ({ page }) =>
actions
.mockAll({ page })
.login()
.do(async (thePage) => {
await actions.passAgreementsDialog({ page: thePage })
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
})

View File

@ -14,6 +14,9 @@ import {
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('login screen', ({ page }) =>
mockAll({ page })
.loginThatShouldFail('invalid email', VALID_PASSWORD, {
@ -25,9 +28,6 @@ test.test('login screen', ({ page }) =>
})
// Technically it should not be allowed, but
.login(VALID_EMAIL, INVALID_PASSWORD)
.do(async (thePage) => {
await passAgreementsDialog({ page: thePage })
})
.withDriveView(async (driveView) => {
await test.expect(driveView).toBeVisible()
}),

View File

@ -1,38 +0,0 @@
/** @file Test the user settings tab. */
import * as test from '@playwright/test'
import * as backend from '#/services/Backend'
import * as actions from './actions'
test.test('members settings', async ({ page }) => {
const api = await actions.mockAllAndLoginAndExposeAPI({
page,
setupAPI: (theApi) => {
theApi.setPlan(backend.Plan.enterprise)
// Setup
theApi.setCurrentOrganization(theApi.defaultOrganization)
},
})
const localActions = actions.settings.members
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? ''])
const otherUserName = 'second.user_'
const otherUser = api.addUser(otherUserName)
await actions.relog({ page })
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
api.deleteUser(otherUser.userId)
await actions.relog({ page })
await localActions.go(page)
await test
.expect(localActions.locateMembersRows(page).locator('> :nth-child(1) > :nth-child(2)'))
.toHaveText([api.currentUser()?.name ?? ''])
})

View File

@ -4,6 +4,9 @@ import * as test from '@playwright/test'
import { Plan } from 'enso-common/src/services/Backend'
import * as actions from './actions'
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('setup (free plan)', ({ page }) =>
actions
.mockAll({

View File

@ -7,6 +7,9 @@ import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './
// === Tests ===
// =============
// Reset storage state for this file to avoid being authenticated
test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('sign up without organization id', ({ page }) =>
mockAll({ page })
.goToPage.register()

View File

@ -85,6 +85,7 @@ export const componentBrowser = componentLocator('.ComponentBrowser')
export const nodeOutputPort = componentLocator('.outputPortHoverArea')
export const smallPlusButton = componentLocator('.SmallPlusButton')
export const editorRoot = componentLocator('.EditorRoot')
export const nodeComment = componentLocator('.GraphNodeComment div[contentEditable]')
/**
* A not-selected variant of Component Browser Entry.

View File

@ -33,8 +33,8 @@ test('Copy node with comment', async ({ page }) => {
// Check state before operation.
const originalNodes = await locate.graphNode(page).count()
await expect(page.locator('.GraphNodeComment')).toExist()
const originalNodeComments = await page.locator('.GraphNodeComment').count()
await expect(locate.nodeComment(page)).toExist()
const originalNodeComments = await locate.nodeComment(page).count()
// Select a node.
const nodeToCopy = locate.graphNodeByBinding(page, 'final')
@ -48,7 +48,7 @@ test('Copy node with comment', async ({ page }) => {
// Node and comment have been copied.
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 1)
await expect(page.locator('.GraphNodeComment')).toHaveCount(originalNodeComments + 1)
await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1)
})
test('Copy multiple nodes', async ({ page }) => {
@ -56,8 +56,8 @@ test('Copy multiple nodes', async ({ page }) => {
// Check state before operation.
const originalNodes = await locate.graphNode(page).count()
await expect(page.locator('.GraphNodeComment')).toExist()
const originalNodeComments = await page.locator('.GraphNodeComment').count()
await expect(locate.nodeComment(page)).toExist()
const originalNodeComments = await locate.nodeComment(page).count()
// Select some nodes.
const node1 = locate.graphNodeByBinding(page, 'final')
@ -76,7 +76,7 @@ test('Copy multiple nodes', async ({ page }) => {
// Nodes and comment have been copied.
await expect(locate.graphNode(page)).toHaveCount(originalNodes + 2)
// `final` node has a comment.
await expect(page.locator('.GraphNodeComment')).toHaveCount(originalNodeComments + 1)
await expect(locate.nodeComment(page)).toHaveCount(originalNodeComments + 1)
// Check that two copied nodes are isolated, i.e. connected to each other, not original nodes.
await expect(locate.graphNodeByBinding(page, 'prod1')).toBeVisible()
await expect(locate.graphNodeByBinding(page, 'final1')).toBeVisible()

View File

@ -0,0 +1,75 @@
import test from 'playwright/test'
import * as actions from './actions'
import { expect } from './customExpect'
import { CONTROL_KEY } from './keyboard'
import * as locate from './locate'
test('Edit comment by click', async ({ page }) => {
await actions.goToGraph(page)
const nodeComment = locate.nodeComment(locate.graphNodeByBinding(page, 'final'))
await expect(nodeComment).toHaveText('This node can be entered')
await nodeComment.click()
await page.keyboard.press(`${CONTROL_KEY}+A`)
const NEW_COMMENT = 'New comment text'
await nodeComment.fill(NEW_COMMENT)
await page.keyboard.press(`Enter`)
await expect(nodeComment).not.toBeFocused()
await expect(nodeComment).toHaveText(NEW_COMMENT)
})
test('Start editing comment via menu', async ({ page }) => {
await actions.goToGraph(page)
const node = locate.graphNodeByBinding(page, 'final')
await node.click()
await locate.circularMenu(node).getByRole('button', { name: 'More' }).click()
await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click()
await expect(locate.nodeComment(node)).toBeFocused()
})
test('Add new comment via menu', async ({ page }) => {
await actions.goToGraph(page)
const INITIAL_NODE_COMMENTS = 1
await expect(locate.nodeComment(page)).toHaveCount(INITIAL_NODE_COMMENTS)
const node = locate.graphNodeByBinding(page, 'data')
const nodeComment = locate.nodeComment(node)
await node.click()
await locate.circularMenu(node).getByRole('button', { name: 'More' }).click()
await locate.circularMenu(node).getByRole('button', { name: 'Comment' }).click()
await expect(locate.nodeComment(node)).toBeFocused()
const NEW_COMMENT = 'New comment text'
await nodeComment.fill(NEW_COMMENT)
await page.keyboard.press(`Enter`)
await expect(nodeComment).not.toBeFocused()
await expect(nodeComment).toHaveText(NEW_COMMENT)
await expect(locate.nodeComment(page)).toHaveCount(INITIAL_NODE_COMMENTS + 1)
})
test('Delete comment by clearing text', async ({ page }) => {
await actions.goToGraph(page)
const nodeComment = locate.nodeComment(locate.graphNodeByBinding(page, 'final'))
await expect(nodeComment).toHaveText('This node can be entered')
await nodeComment.click()
await page.keyboard.press(`${CONTROL_KEY}+A`)
await page.keyboard.press(`Delete`)
await page.keyboard.press(`Enter`)
await expect(nodeComment).not.toExist()
})
test('URL added to comment is rendered as link', async ({ page }) => {
await actions.goToGraph(page)
const nodeComment = locate.nodeComment(locate.graphNodeByBinding(page, 'final'))
await expect(nodeComment).toHaveText('This node can be entered')
await expect(nodeComment.locator('a')).not.toExist()
await nodeComment.click()
await page.keyboard.press(`${CONTROL_KEY}+A`)
const NEW_COMMENT = "Here's a URL: https://example.com"
await nodeComment.fill(NEW_COMMENT)
await page.keyboard.press(`Enter`)
await expect(nodeComment).not.toBeFocused()
await expect(nodeComment).toHaveText(NEW_COMMENT)
await expect(nodeComment.locator('a')).toHaveCount(1)
})

View File

@ -7,13 +7,18 @@ import * as locate from './locate'
test('Main method documentation', async ({ page }) => {
await actions.goToGraph(page)
const rightDock = locate.rightDock(page)
// Documentation panel hotkey opens right-dock.
await expect(locate.rightDock(page)).toBeHidden()
await expect(rightDock).toBeHidden()
await page.keyboard.press(`${CONTROL_KEY}+D`)
await expect(locate.rightDock(page)).toBeVisible()
await expect(rightDock).toBeVisible()
// Right-dock displays main method documentation.
await expect(locate.editorRoot(locate.rightDock(page))).toHaveText('The main method')
await expect(locate.editorRoot(rightDock)).toContainText('The main method')
// All three images are loaded properly
await expect(rightDock.getByAltText('Image')).toHaveCount(3)
for (const img of await rightDock.getByAltText('Image').all())
await expect(img).toHaveJSProperty('naturalWidth', 3)
// Documentation hotkey closes right-dock.p
await page.keyboard.press(`${CONTROL_KEY}+D`)

View File

@ -44,7 +44,8 @@ test('Removing node', async ({ page }) => {
await page.keyboard.press(`${CONTROL_KEY}+Z`)
await expect(locate.graphNode(page)).toHaveCount(nodesCount)
await expect(deletedNode.locator('.WidgetToken')).toHaveText(['Main', '.', 'func1', 'prod'])
await expect(deletedNode.locator('.GraphNodeComment')).toHaveText('This node can be entered')
await expect(locate.nodeComment(deletedNode)).toHaveText('This node can be entered')
const restoredBBox = await deletedNode.boundingBox()
expect(restoredBBox).toEqual(deletedNodeBBox)

9
app/gui/env.d.ts vendored
View File

@ -176,6 +176,13 @@ declare global {
readonly fileBrowserApi?: FileBrowserApi
readonly versionInfo?: VersionInfo
toggleDevtools: () => void
/**
* If set to `true`, animations will be disabled.
* Used by playwright tests to speed up execution.
*
* ATM only affects the framer-motion animations.
*/
readonly DISABLE_ANIMATIONS?: boolean
}
namespace NodeJS {
@ -211,6 +218,8 @@ declare global {
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_STRIPE_KEY?: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_AUTH_ENDPOINT: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_USER_POOL_ID: string
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID: string

View File

@ -26,7 +26,7 @@
style-src 'self' 'unsafe-inline' data: https://*;
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
worker-src 'self' blob:;
img-src 'self' blob: data: https://*;
img-src 'self' blob: enso: data: https://*;
font-src 'self' data: https://*"
/>
<meta

View File

@ -22,7 +22,7 @@
"build-cloud": "cross-env CLOUD_BUILD=true corepack pnpm run build",
"preview": "vite preview",
"//": "max-warnings set to 41 to match the amount of warnings introduced by the new react compiler. Eventual goal is to remove all the warnings.",
"lint": "eslint . --max-warnings=41",
"lint": "eslint . --max-warnings=39",
"format": "prettier --version && prettier --write src/ && eslint . --fix",
"dev:vite": "vite",
"test": "corepack pnpm run /^^^^test:.*/",
@ -59,7 +59,7 @@
"react": "^18.3.1",
"react-aria": "^3.34.3",
"react-aria-components": "^1.3.3",
"react-compiler-runtime": "19.0.0-beta-8a03594-20241020",
"react-compiler-runtime": "19.0.0-beta-6fc168f-20241025",
"react-dom": "^18.3.1",
"react-error-boundary": "4.0.13",
"react-hook-form": "^7.51.4",
@ -80,7 +80,7 @@
"@ag-grid-enterprise/core": "^31.1.1",
"@ag-grid-enterprise/range-selection": "^31.1.1",
"@babel/parser": "^7.24.7",
"babel-plugin-react-compiler": "19.0.0-beta-9ee70a1-20241017",
"babel-plugin-react-compiler": "19.0.0-beta-6fc168f-20241025",
"@codemirror/commands": "^6.6.0",
"@codemirror/language": "^6.10.2",
"@codemirror/lang-markdown": "^v6.3.0",
@ -94,7 +94,6 @@
"@lexical/plain-text": "^0.16.0",
"@lexical/utils": "^0.16.0",
"@lezer/common": "^1.1.0",
"@lezer/markdown": "^1.3.1",
"@lezer/highlight": "^1.1.6",
"@noble/hashes": "^1.4.0",
"@vueuse/core": "^10.4.1",
@ -118,12 +117,12 @@
"veaury": "^2.3.18",
"vue": "^3.5.2",
"vue-component-type-helpers": "^2.0.29",
"y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5",
"y-textarea": "^1.0.0",
"y-websocket": "^1.5.0",
"ydoc-shared": "workspace:*",
"yjs": "^13.6.7"
"yjs": "^13.6.7",
"marked": "14.1.3"
},
"devDependencies": {
"@fast-check/vitest": "^0.0.8",

View File

@ -10,7 +10,16 @@ import { defineConfig } from '@playwright/test'
import net from 'net'
const DEBUG = process.env.DEBUG_E2E === 'true'
const TIMEOUT_MS = DEBUG ? 100_000_000 : 60_000
const isCI = process.env.CI === 'true'
const isProd = process.env.PROD === 'true'
const TIMEOUT_MS =
DEBUG ? 100_000_000
: isCI ? 60_000
: 15_000
// We tend to use less CPU on CI to reduce the number of failures due to timeouts.
const WORKERS = isCI ? '25%' : '35%'
async function findFreePortInRange(min: number, max: number) {
for (let i = 0; i < 50; i++) {
@ -52,6 +61,7 @@ process.env.PLAYWRIGHT_PORT_PV = `${ports.projectView}`
export default defineConfig({
fullyParallel: true,
...(WORKERS ? { workers: WORKERS } : {}),
forbidOnly: !!process.env.CI,
repeatEach: process.env.CI ? 3 : 1,
reporter: 'html',
@ -86,9 +96,36 @@ export default defineConfig({
}),
},
projects: [
// Setup project
{
name: 'Setup Dashboard',
testDir: './e2e/dashboard',
testMatch: /.*\.setup\.ts/,
timeout: TIMEOUT_MS,
use: {
baseURL: `http://localhost:${ports.dashboard}`,
actionTimeout: TIMEOUT_MS,
},
},
{
name: 'Dashboard',
testDir: './e2e/dashboard',
testMatch: /.*\.spec\.ts/,
dependencies: ['Setup Dashboard'],
expect: {
toHaveScreenshot: { threshold: 0 },
timeout: TIMEOUT_MS,
},
timeout: TIMEOUT_MS,
use: {
baseURL: `http://localhost:${ports.dashboard}`,
actionTimeout: TIMEOUT_MS,
storageState: './playwright/.auth/user.json',
},
},
{
name: 'Auth',
testDir: './e2e/dashboard/auth',
expect: {
toHaveScreenshot: { threshold: 0 },
timeout: TIMEOUT_MS,
@ -120,11 +157,9 @@ export default defineConfig({
],
webServer: [
{
env: {
E2E: 'true',
},
env: { E2E: 'true' },
command:
process.env.CI || process.env.PROD ?
isCI || isProd ?
`corepack pnpm build && corepack pnpm exec vite preview --port ${ports.projectView} --strictPort`
: `corepack pnpm exec vite dev --port ${ports.projectView}`,
// Build from scratch apparently can take a while on CI machines.
@ -135,7 +170,7 @@ export default defineConfig({
},
{
command:
process.env.CI || process.env.PROD ?
isCI || isProd ?
`corepack pnpm exec vite -c vite.test.config.ts build && vite -c vite.test.config.ts preview --port ${ports.dashboard} --strictPort`
: `corepack pnpm exec vite -c vite.test.config.ts --port ${ports.dashboard}`,
timeout: 240 * 1000,

View File

@ -59,6 +59,8 @@ import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import SessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider'
import type { Spring } from 'framer-motion'
import { MotionConfig } from 'framer-motion'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import ForgotPassword from '#/pages/authentication/ForgotPassword'
@ -103,6 +105,16 @@ import { InvitedToOrganizationModal } from '#/modals/InvitedToOrganizationModal'
// === Global configuration ===
// ============================
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
@ -497,40 +509,42 @@ function AppRouter(props: AppRouterProps) {
return (
<FeatureFlagsProvider>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={refreshUserSession}
>
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
<InputBindingsProvider inputBindings={inputBindings}>
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
* due to modals being in `TheModal`. */}
<DriveProvider>
<errorBoundary.ErrorBoundary>
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>
</AuthProvider>
</BackendProvider>
</SessionProvider>
</RouterProvider>
<MotionConfig reducedMotion="user" transition={DEFAULT_TRANSITION_OPTIONS}>
<RouterProvider navigate={navigate}>
<SessionProvider
saveAccessToken={authService.cognito.saveAccessToken.bind(authService.cognito)}
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
refreshUserSession={refreshUserSession}
>
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
authService={authService}
onAuthenticated={onAuthenticated}
>
<InputBindingsProvider inputBindings={inputBindings}>
{/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here
* due to modals being in `TheModal`. */}
<DriveProvider>
<errorBoundary.ErrorBoundary>
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>
</AuthProvider>
</BackendProvider>
</SessionProvider>
</RouterProvider>
</MotionConfig>
</FeatureFlagsProvider>
)
}

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 14C4.16421 14 4.5 14.3358 4.5 14.75V17.25C4.5 18.4926 5.50736 19.5 6.75 19.5H17.25C18.4926 19.5 19.5 18.4926 19.5 17.25V14.75C19.5 14.3358 19.8358 14 20.25 14C20.6642 14 21 14.3358 21 14.75V17.25C21 19.3211 19.3211 21 17.25 21H6.75C4.67893 21 3 19.3211 3 17.25V14.75C3 14.3358 3.33579 14 3.75 14Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 15.75C12.1989 15.75 12.3897 15.671 12.5303 15.5303L16.0303 12.0303C16.3232 11.7374 16.3232 11.2626 16.0303 10.9697C15.7374 10.6768 15.2626 10.6768 14.9697 10.9697L12.75 13.1893V3.75C12.75 3.33579 12.4142 3 12 3C11.5858 3 11.25 3.33579 11.25 3.75V13.1893L9.03033 10.9697C8.73744 10.6768 8.26256 10.6768 7.96967 10.9697C7.67678 11.2626 7.67678 11.7374 7.96967 12.0303L11.4697 15.5303C11.6103 15.671 11.8011 15.75 12 15.75Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 961 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13 3.5V7C13 8.10457 13.8954 9 15 9H18.5M9 13H12M9 17H15.5M8 3H12.1716C12.702 3 13.2107 3.21071 13.5858 3.58579L18.4142 8.41421C18.7893 8.78929 19 9.29799 19 9.82843V18C19 19.6569 17.6569 21 16 21H8C6.34315 21 5 19.6569 5 18V6C5 4.34315 6.34315 3 8 3Z" stroke="black" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.2759 13.1471C17.7492 12.5458 20.5095 13.7876 21.7972 16.8725C22.4674 18.4782 21.0226 20 19.2826 20H17.0017M10.5017 7C10.5017 8.65685 9.15859 10 7.50174 10C5.84489 10 4.50174 8.65685 4.50174 7C4.50174 5.34315 5.84489 4 7.50174 4C9.15859 4 10.5017 5.34315 10.5017 7ZM19.5017 7C19.5017 8.65685 18.1586 10 16.5017 10C14.8449 10 13.5017 8.65685 13.5017 7C13.5017 5.34315 14.8449 4 16.5017 4C18.1586 4 19.5017 5.34315 19.5017 7ZM10.2828 20H4.72066C2.98074 20 1.53609 18.4769 2.20657 16.8713C4.36215 11.7096 10.6413 11.7096 12.7969 16.8713C13.4674 18.4769 12.0227 20 10.2828 20Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24">
<path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11V6a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3h3.5M8 15h1M8 7h7m-7 4h3m8.121 9.121a3 3 0 1 1-4.242-4.242 3 3 0 0 1 4.242 4.242Zm0 0L21 22"/>
</svg>

After

Width:  |  Height:  |  Size: 342 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14 5C13.4477 5 13 4.55228 13 4C13 3.44772 13.4477 3 14 3H20C20.5523 3 21 3.44772 21 4V10C21 10.5523 20.5523 11 20 11C19.4477 11 19 10.5523 19 10V6.41421L11.7071 13.7071C11.3166 14.0976 10.6834 14.0976 10.2929 13.7071C9.90237 13.3166 9.90237 12.6834 10.2929 12.2929L17.5858 5H14ZM8.7587 5L9 5C9.55229 5 10 5.44772 10 6C10 6.55228 9.55229 7 9 7H8.8C7.94342 7 7.36113 7.00078 6.91104 7.03755C6.47262 7.07337 6.24842 7.1383 6.09202 7.21799C5.7157 7.40973 5.40973 7.71569 5.21799 8.09202C5.1383 8.24842 5.07337 8.47262 5.03755 8.91104C5.00078 9.36113 5 9.94342 5 10.8V15.2C5 16.0566 5.00078 16.6389 5.03755 17.089C5.07337 17.5274 5.1383 17.7516 5.21799 17.908C5.40973 18.2843 5.7157 18.5903 6.09202 18.782C6.24842 18.8617 6.47262 18.9266 6.91104 18.9624C7.36113 18.9992 7.94342 19 8.8 19H13.2C14.0566 19 14.6389 18.9992 15.089 18.9624C15.5274 18.9266 15.7516 18.8617 15.908 18.782C16.2843 18.5903 16.5903 18.2843 16.782 17.908C16.8617 17.7516 16.9266 17.5274 16.9624 17.089C16.9992 16.6389 17 16.0566 17 15.2V15C17 14.4477 17.4477 14 18 14C18.5523 14 19 14.4477 19 15V15.2413C19 16.0463 19 16.7106 18.9558 17.2518C18.9099 17.8139 18.8113 18.3306 18.564 18.816C18.1805 19.5686 17.5686 20.1805 16.816 20.564C16.3306 20.8113 15.8139 20.9099 15.2518 20.9558C14.7106 21 14.0463 21 13.2413 21H8.75868C7.95372 21 7.28936 21 6.74818 20.9558C6.18608 20.9099 5.66937 20.8113 5.18404 20.564C4.43139 20.1805 3.81947 19.5686 3.43597 18.816C3.18868 18.3306 3.09012 17.8139 3.04419 17.2518C2.99998 16.7106 2.99999 16.0463 3 15.2413V10.7587C2.99999 9.95373 2.99998 9.28937 3.04419 8.74817C3.09012 8.18608 3.18868 7.66937 3.43597 7.18404C3.81947 6.43139 4.43139 5.81947 5.18404 5.43597C5.66937 5.18868 6.18608 5.09012 6.74817 5.04419C7.28937 4.99998 7.95373 4.99999 8.7587 5Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,8 +1,4 @@
<svg width="16" height="16" viewBox="2 2 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10.7845" y="3.98547" width="5" height="10" transform="rotate(45 10.7845 3.98547)" fill="black" />
<path
d="M12.1987 2.57126C12.9798 1.79021 14.2461 1.79021 15.0271 2.57126L15.7342 3.27836C16.5153 4.05941 16.5153 5.32574 15.7342 6.10679L15.0271 6.8139L11.4916 3.27836L12.1987 2.57126Z"
fill="black" />
<path d="M3.71341 11.0565L7.24894 14.5921L3.71341 14.5921L3.71341 11.0565Z" fill="black" />
<rect x="2" y="16" width="16" height="2" fill="black" />
</svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6716 3.41425C16.2337 1.85215 18.7663 1.85215 20.3284 3.41425L20.5858 3.67161C22.1479 5.23371 22.1479 7.76637 20.5858 9.32846L9.08579 20.8285C8.33564 21.5786 7.31823 22 6.25736 22H3C2.44772 22 2 21.5523 2 21V17.7427C2 16.6818 2.42143 15.6644 3.17157 14.9142L14.6716 3.41425Z" fill="black"/>
<path d="M14 20C13.4477 20 13 20.4477 13 21C13 21.5523 13.4477 22 14 22H21C21.5523 22 22 21.5523 22 21C22 20.4477 21.5523 20 21 20H14Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 557 B

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.75 1C18.3358 1 18 1.33579 18 1.75C18 2.16421 18.3358 2.5 18.75 2.5V1ZM22.25 1.75L22.842 2.21046C23.0179 1.98435 23.0496 1.67781 22.9237 1.42049C22.7979 1.16316 22.5364 1 22.25 1V1.75ZM18.75 6.25L18.158 5.78954C17.9821 6.01565 17.9504 6.32219 18.0763 6.57951C18.2021 6.83684 18.4635 7 18.75 7V6.25ZM22.25 7C22.6642 7 23 6.66421 23 6.25C23 5.83579 22.6642 5.5 22.25 5.5V7ZM12.75 7.75C12.75 7.33579 12.4142 7 12 7C11.5858 7 11.25 7.33579 11.25 7.75H12.75ZM12 12H11.25C11.25 12.1989 11.329 12.3897 11.4697 12.5303L12 12ZM13.9697 15.0303C14.2626 15.3232 14.7374 15.3232 15.0303 15.0303C15.3232 14.7374 15.3232 14.2626 15.0303 13.9697L13.9697 15.0303ZM15.216 4.12926C15.5994 4.28609 16.0373 4.10243 16.1942 3.71906C16.351 3.33568 16.1673 2.89776 15.784 2.74093L15.216 4.12926ZM21.6304 9.29774C21.5187 8.89887 21.1048 8.66608 20.7059 8.77779C20.3071 8.88949 20.0743 9.3034 20.186 9.70226L21.6304 9.29774ZM18.75 2.5H22.25V1H18.75V2.5ZM21.658 1.28954L18.158 5.78954L19.342 6.71046L22.842 2.21046L21.658 1.28954ZM18.75 7H22.25V5.5H18.75V7ZM11.25 7.75V12H12.75V7.75H11.25ZM11.4697 12.5303L13.9697 15.0303L15.0303 13.9697L12.5303 11.4697L11.4697 12.5303ZM20.5 12C20.5 16.6944 16.6944 20.5 12 20.5V22C17.5228 22 22 17.5228 22 12H20.5ZM12 20.5C7.30558 20.5 3.5 16.6944 3.5 12H2C2 17.5228 6.47715 22 12 22V20.5ZM3.5 12C3.5 7.30558 7.30558 3.5 12 3.5V2C6.47715 2 2 6.47715 2 12H3.5ZM12 3.5C13.1396 3.5 14.225 3.72384 15.216 4.12926L15.784 2.74093C14.6157 2.26304 13.3377 2 12 2V3.5ZM20.186 9.70226C20.3904 10.4323 20.5 11.2027 20.5 12H22C22 11.0647 21.8714 10.1581 21.6304 9.29774L20.186 9.70226Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 7V6C15 4.34315 13.6569 3 12 3H8C6.34315 3 5 4.34315 5 6V14C5 15.6569 6.34315 17 8 17H9M16 21H12C10.3431 21 9 19.6569 9 18V10C9 8.34315 10.3431 7 12 7H16C17.6569 7 19 8.34315 19 10V18C19 19.6569 17.6569 21 16 21Z" stroke="black" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 411 B

View File

@ -33,6 +33,7 @@ import * as listen from '#/authentication/listen'
*/
export interface AmplifyConfig {
readonly region: string
readonly endpoint: string
readonly userPoolId: string
readonly userPoolWebClientId: string
readonly urlOpener: ((url: string, redirectUrl: string) => void) | null
@ -66,6 +67,7 @@ interface OauthAmplifyConfig {
/** Same as {@link AmplifyConfig}, but in a format recognized by the AWS Amplify library. */
export interface NestedAmplifyConfig {
readonly region: string
readonly endpoint: string
readonly userPoolId: string
readonly userPoolWebClientId: string
readonly oauth: OauthAmplifyConfig
@ -80,6 +82,7 @@ export interface NestedAmplifyConfig {
export function toNestedAmplifyConfig(config: AmplifyConfig): NestedAmplifyConfig {
return {
region: config.region,
endpoint: config.endpoint,
userPoolId: config.userPoolId,
userPoolWebClientId: config.userPoolWebClientId,
oauth: {
@ -183,6 +186,7 @@ function loadAmplifyConfig(
/** Load the platform-specific Amplify configuration. */
const signInOutRedirect = supportsDeepLinks ? `${common.DEEP_LINK_SCHEME}://auth` : redirectUrl
return {
endpoint: process.env.ENSO_CLOUD_AUTH_ENDPOINT,
userPoolId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_ID,
userPoolWebClientId: process.env.ENSO_CLOUD_COGNITO_USER_POOL_WEB_CLIENT_ID,
domain: process.env.ENSO_CLOUD_COGNITO_DOMAIN,

View File

@ -6,19 +6,23 @@
import type { Transition } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import type { PropsWithChildren } from 'react'
import { createContext, useContext, useId } from 'react'
import { createContext, memo, useContext, useId, useMemo } from 'react'
import { twJoin } from '#/utilities/tailwindMerge'
import invariant from 'tiny-invariant'
/** Props for {@link AnimatedBackground}. */
interface AnimatedBackgroundProps extends PropsWithChildren {
readonly value: string
/**
* Active value.
* You can omit this prop if you want to use the `isSelected` prop on {@link AnimatedBackground.Item}.
*/
readonly value?: string
readonly transition?: Transition
}
const AnimatedBackgroundContext = createContext<{
value: string | null
value: string | undefined
transition: Transition
layoutId: string
} | null>(null)
@ -38,46 +42,118 @@ const DEFAULT_TRANSITION: Transition = {
/** `<AnimatedBackground />` component visually highlights selected items by sliding a background into view when hovered over or clicked. */
export function AnimatedBackground(props: AnimatedBackgroundProps) {
const { value, transition = DEFAULT_TRANSITION, children } = props
const layoutId = useId()
const contextValue = useMemo(
() => ({ value, transition, layoutId }),
[value, transition, layoutId],
)
return (
<AnimatedBackgroundContext.Provider value={{ value, transition, layoutId }}>
<AnimatedBackgroundContext.Provider value={contextValue}>
{children}
</AnimatedBackgroundContext.Provider>
)
}
/** Props for {@link AnimatedBackground.Item}. */
interface AnimatedBackgroundItemProps extends PropsWithChildren {
readonly value: string
/**
* Props for {@link AnimatedBackground.Item}.
*/
type AnimatedBackgroundItemProps = PropsWithChildren<
AnimatedBackgroundItemPropsWithSelected | AnimatedBackgroundItemPropsWithValue
> & {
readonly className?: string
readonly animationClassName?: string
readonly underlayElement?: React.ReactNode
}
/**
* Props for {@link AnimatedBackground.Item} with a `value` prop.
*/
interface AnimatedBackgroundItemPropsWithValue {
readonly value: string
readonly isSelected?: never
}
/**
* Props for {@link AnimatedBackground.Item} with a `isSelected` prop.
*/
interface AnimatedBackgroundItemPropsWithSelected {
readonly isSelected: boolean
readonly value?: never
}
/** Item within an {@link AnimatedBackground}. */
AnimatedBackground.Item = function AnimatedBackgroundItem(props: AnimatedBackgroundItemProps) {
const context = useContext(AnimatedBackgroundContext)
invariant(context, 'useAnimatedBackground must be used within an AnimatedBackgroundProvider')
AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBackgroundItemProps) {
const {
value,
className,
animationClassName,
children,
isSelected,
underlayElement = <div className={twJoin('h-full w-full', animationClassName)} />,
} = props
const { value, className, animationClassName, children } = props
const context = useContext(AnimatedBackgroundContext)
invariant(context, '<AnimatedBackground.Item /> must be placed within an <AnimatedBackground />')
const { value: activeValue, transition, layoutId } = context
invariant(
activeValue === undefined || isSelected === undefined,
'isSelected shall be passed either directly or via context by matching the value prop in <AnimatedBackground.Item /> and value from <AnimatedBackground />',
)
const isActive = isSelected ?? activeValue === value
return (
<div className={twJoin('relative *:isolate', className)}>
<AnimatePresence initial={false}>
{activeValue === value && (
<motion.div
layoutId={`background-${layoutId}`}
className={twJoin('absolute inset-0', animationClassName)}
transition={transition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
)}
</AnimatePresence>
<AnimatedBackgroundItemUnderlay
isActive={isActive}
underlayElement={underlayElement}
layoutId={layoutId}
transition={transition}
/>
{children}
</div>
)
})
/**
* Props for {@link AnimatedBackgroundItemUnderlay}.
*/
interface AnimatedBackgroundItemUnderlayProps {
readonly isActive: boolean
readonly underlayElement: React.ReactNode
readonly layoutId: string
readonly transition: Transition
}
/**
* Underlay for {@link AnimatedBackground.Item}.
*/
// eslint-disable-next-line no-restricted-syntax
const AnimatedBackgroundItemUnderlay = memo(function AnimatedBackgroundItemUnderlay(
props: AnimatedBackgroundItemUnderlayProps,
) {
const { isActive, underlayElement, layoutId, transition } = props
return (
<AnimatePresence initial={!isActive}>
{isActive && (
<motion.div
layout="position"
layoutId={`background-${layoutId}`}
className="pointer-events-none absolute inset-0"
transition={transition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{underlayElement}
</motion.div>
)}
</AnimatePresence>
)
})

View File

@ -5,7 +5,7 @@ import * as focusHooks from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import { forwardRef } from '#/utilities/react'
@ -398,7 +398,7 @@ export const Button = forwardRef(function Button(
} else if (isLoading && loaderPosition === 'icon') {
return (
<span className={styles.icon()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
<StatelessSpinner state="loading-medium" size={16} />
</span>
)
} else {
@ -468,7 +468,7 @@ export const Button = forwardRef(function Button(
{isLoading && loaderPosition === 'full' && (
<span ref={loaderRef} className={styles.loader()}>
<StatelessSpinner state={spinnerModule.SpinnerState.loadingMedium} size={16} />
<StatelessSpinner state="loading-medium" size={16} />
</span>
)}
</span>

View File

@ -3,17 +3,15 @@
*
* Close button for a dialog.
*/
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as button from '../Button'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { type ButtonProps, Button } from '../Button'
import * as dialogProvider from './DialogProvider'
/** Props for {@link Close} component. */
export type CloseProps = button.ButtonProps
export type CloseProps = ButtonProps
/** Close button for a dialog. */
export function Close(props: CloseProps) {
@ -21,12 +19,10 @@ export function Close(props: CloseProps) {
invariant(dialogContext, 'Close must be used inside a DialogProvider')
const onPressCallback = eventCallback.useEventCallback<
NonNullable<button.ButtonProps['onPress']>
>((event) => {
const onPressCallback = useEventCallback<NonNullable<ButtonProps['onPress']>>((event) => {
dialogContext.close()
return props.onPress?.(event)
})
return <button.Button {...props} onPress={onPressCallback} />
return <Button {...props} onPress={onPressCallback} />
}

View File

@ -17,6 +17,7 @@ import type { Spring } from '#/utilities/motion'
import { motion } from '#/utilities/motion'
import type { VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
import { Close } from './Close'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import type * as types from './types'
@ -109,11 +110,11 @@ const DIALOG_STYLES = tv({
padding: {
none: { content: 'p-0' },
small: { content: 'px-1 pt-3.5 pb-3.5' },
medium: { content: 'px-3.5 pt-3.5 pb-3.5' },
large: { content: 'px-8 pt-3.5 pb-5' },
xlarge: { content: 'p-12 pt-3.5 pb-8' },
xxlarge: { content: 'p-16 pt-3.5 pb-12' },
xxxlarge: { content: 'p-20 pt-3.5 pb-16' },
medium: { content: 'px-4 pt-3 pb-4' },
large: { content: 'px-8 pt-5 pb-5' },
xlarge: { content: 'p-12 pt-6 pb-8' },
xxlarge: { content: 'p-16 pt-8 pb-12' },
xxxlarge: { content: 'p-20 pt-10 pb-16' },
},
scrolledToTop: { true: { header: 'border-transparent' } },
},
@ -140,7 +141,7 @@ const DIALOG_STYLES = tv({
hideCloseButton: false,
size: 'medium',
padding: 'medium',
rounded: 'xxlarge',
rounded: 'xxxlarge',
},
})
@ -181,12 +182,14 @@ export function Dialog(props: DialogProps) {
testId = 'dialog',
size,
rounded,
padding = type === 'modal' ? 'medium' : 'xlarge',
padding: paddingRaw,
fitContent,
variants = DIALOG_STYLES,
...ariaDialogProps
} = props
const padding = paddingRaw ?? (type === 'modal' ? 'medium' : 'xlarge')
const [isScrolledToTop, setIsScrolledToTop] = React.useState(true)
/** Handles the scroll event on the dialog content. */
@ -351,3 +354,5 @@ const TYPE_TO_DIALOG_TYPE: Record<
modal: 'dialog',
fullscreen: 'dialog-fullscreen',
}
Dialog.Close = Close

View File

@ -12,6 +12,7 @@ import * as suspense from '#/components/Suspense'
import * as twv from '#/utilities/tailwindVariants'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as dialogProvider from './DialogProvider'
import * as dialogStackProvider from './DialogStackProvider'
import * as utlities from './utilities'
@ -78,22 +79,31 @@ export function Popover(props: PopoverProps) {
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
const closeRef = React.useRef<(() => void) | null>(null)
// We use as here to make the types more accurate
// eslint-disable-next-line no-restricted-syntax
const contextState = React.useContext(
aria.OverlayTriggerStateContext,
) as aria.OverlayTriggerState | null
const root = portal.useStrictPortalContext()
const dialogId = aria.useId()
const close = useEventCallback(() => {
contextState?.close()
})
utlities.useInteractOutside({
ref: dialogRef,
id: dialogId,
onInteractOutside: () => closeRef.current?.(),
onInteractOutside: close,
})
return (
<aria.Popover
className={(values) =>
POPOVER_STYLES({
...values,
isEntering: values.isEntering,
isExiting: values.isExiting,
size,
rounded,
className: typeof className === 'function' ? className(values) : className,
@ -110,25 +120,19 @@ export function Popover(props: PopoverProps) {
>
{(opts) => (
<dialogStackProvider.DialogStackRegistrar id={dialogId} type="popover">
<aria.Dialog
<div
id={dialogId}
ref={dialogRef}
className={POPOVER_STYLES({ ...opts, size, rounded }).dialog()}
>
{({ close }) => {
closeRef.current = close
return (
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
)
}}
</aria.Dialog>
<dialogProvider.DialogProvider value={{ close, dialogId }}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={{ minHeight: 'h32' }}>
{typeof children === 'function' ? children({ ...opts, close }) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</dialogProvider.DialogProvider>
</div>
</dialogStackProvider.DialogStackRegistrar>
)}
</aria.Popover>

View File

@ -100,8 +100,6 @@ export const Form = forwardRef(function Form<
}),
) as Record<keyof FieldValues, string>
const values = components.useWatch({ control: innerForm.control })
return (
<form
{...formProps}
@ -115,9 +113,7 @@ export const Form = forwardRef(function Form<
>
<aria.FormValidationContext.Provider value={errors}>
<components.FormProvider form={innerForm}>
{typeof children === 'function' ?
children({ ...innerForm, form: innerForm, values })
: children}
{typeof children === 'function' ? children({ ...innerForm, form: innerForm }) : children}
</components.FormProvider>
</aria.FormValidationContext.Provider>
</form>
@ -133,6 +129,7 @@ export const Form = forwardRef(function Form<
Reset: typeof components.Reset
Field: typeof components.Field
FormError: typeof components.FormError
FieldValue: typeof components.FieldValue
useFormSchema: typeof components.useFormSchema
Controller: typeof components.Controller
FIELD_STYLES: typeof components.FIELD_STYLES
@ -151,6 +148,7 @@ Form.useFormSchema = components.useFormSchema
Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.FieldValue = components.FieldValue
Form.useFormContext = components.useFormContext
Form.useOptionalFormContext = components.useOptionalFormContext
Form.Field = components.Field

View File

@ -0,0 +1,31 @@
/**
* @file
*
* Component that passes the value of a field to its children.
*/
import { useWatch } from 'react-hook-form'
import { useFormContext } from './FormProvider'
import type { FieldPath, FieldValues, FormInstanceValidated, TSchema } from './types'
/**
*
*/
export interface FieldValueProps<Schema extends TSchema, TFieldName extends FieldPath<Schema>> {
readonly form?: FormInstanceValidated<Schema>
readonly name: TFieldName
readonly children: (value: FieldValues<Schema>[TFieldName]) => React.ReactNode
}
/**
* Component that passes the value of a field to its children.
*/
export function FieldValue<Schema extends TSchema, TFieldName extends FieldPath<Schema>>(
props: FieldValueProps<Schema, TFieldName>,
) {
const { form, name, children } = props
const formInstance = useFormContext(form)
const value = useWatch({ control: formInstance.control, name })
return <>{children(value)}</>
}

View File

@ -5,6 +5,7 @@
*/
export { Controller, useWatch } from 'react-hook-form'
export * from './Field'
export * from './FieldValue'
export * from './FormError'
export * from './FormProvider'
export * from './Reset'

View File

@ -85,7 +85,7 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
errorMap: (issue) => {
switch (issue.code) {
case 'too_small':
if (issue.minimum === 0) {
if (issue.minimum === 1 && issue.type === 'string') {
return {
message: getText('arbitraryFieldRequired'),
}

View File

@ -7,7 +7,6 @@ import type * as React from 'react'
import type * as reactHookForm from 'react-hook-form'
import type { DeepPartialSkipArrayKey } from 'react-hook-form'
import type { TestIdProps } from '../types'
import type * as components from './components'
import type * as styles from './styles'
@ -37,7 +36,6 @@ interface BaseFormProps<Schema extends components.TSchema>
| ((
props: components.UseFormReturn<Schema> & {
readonly form: components.UseFormReturn<Schema>
readonly values: DeepPartialSkipArrayKey<components.FieldValues<Schema>>
},
) => React.ReactNode)
readonly formRef?: React.MutableRefObject<components.UseFormReturn<Schema>>

View File

@ -17,8 +17,11 @@ export interface TextProps
readonly elementType?: keyof HTMLElementTagNameMap
readonly lineClamp?: number
readonly tooltip?: React.ReactElement | string | false | null
readonly tooltipTriggerRef?: React.RefObject<HTMLElement>
readonly tooltipDisplay?: visualTooltip.VisualTooltipProps['display']
readonly tooltipPlacement?: aria.Placement
readonly tooltipOffset?: number
readonly tooltipCrossOffset?: number
}
export const TEXT_STYLE = twv.tv({
@ -134,8 +137,11 @@ export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HT
balance,
elementType: ElementType = 'span',
tooltip: tooltipElement = children,
tooltipTriggerRef,
tooltipDisplay = 'whenOverflowing',
tooltipPlacement,
tooltipOffset,
tooltipCrossOffset,
textSelection,
disableLineHeightCompensation = false,
...ariaProps
@ -176,9 +182,18 @@ export const Text = forwardRef(function Text(props: TextProps, ref: React.Ref<HT
const { tooltip, targetProps } = visualTooltip.useVisualTooltip({
isDisabled: isTooltipDisabled(),
targetRef: textElementRef,
triggerRef: tooltipTriggerRef,
display: tooltipDisplay,
children: tooltipElement,
...(tooltipPlacement ? { overlayPositionProps: { placement: tooltipPlacement } } : {}),
...(tooltipPlacement || tooltipOffset != null ?
{
overlayPositionProps: {
...(tooltipPlacement && { placement: tooltipPlacement }),
...(tooltipOffset != null && { offset: tooltipOffset }),
...(tooltipCrossOffset != null && { crossOffset: tooltipCrossOffset }),
},
}
: {}),
})
return (

View File

@ -18,10 +18,11 @@ export interface VisualTooltipProps
readonly children: React.ReactNode
readonly className?: string
readonly targetRef: React.RefObject<HTMLElement>
readonly triggerRef?: React.RefObject<HTMLElement> | undefined
readonly isDisabled?: boolean
readonly overlayPositionProps?: Pick<
aria.AriaPositionProps,
'containerPadding' | 'offset' | 'placement'
'containerPadding' | 'crossOffset' | 'offset' | 'placement'
>
/**
* Determines when the tooltip should be displayed.
@ -56,6 +57,7 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
const {
children,
targetRef,
triggerRef = targetRef,
className,
isDisabled = false,
overlayPositionProps = {},
@ -70,6 +72,7 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
const {
containerPadding = 0,
offset = DEFAULT_OFFSET,
crossOffset = 0,
placement = 'bottom',
} = overlayPositionProps
@ -103,6 +106,7 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
isDisabled,
onHoverChange: handleHoverChange,
})
const { hoverProps: tooltipHoverProps } = aria.useHover({
isDisabled,
onHoverChange: handleHoverChange,
@ -114,8 +118,9 @@ export function useVisualTooltip(props: VisualTooltipProps): VisualTooltipReturn
const { overlayProps, updatePosition } = aria.useOverlayPosition({
isOpen: state.isOpen,
overlayRef: popoverRef,
targetRef,
targetRef: triggerRef,
offset,
crossOffset,
placement,
containerPadding,
})

View File

@ -1,6 +1,5 @@
/** @file A select menu with a dropdown. */
import {
useEffect,
useMemo,
useRef,
useState,
@ -92,22 +91,15 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const valuesSet = useMemo(() => new Set(values), [values])
const canEditText = setText != null && values.length === 0
// We are only interested in the initial value of `canEditText` in effects.
const canEditTextRef = useRef(canEditText)
const isMultipleAndCustomValue = multiple === true && text != null
const matchingItems = useMemo(
() => (text == null ? items : items.filter((item) => matches(item, text))),
[items, matches, text],
)
useEffect(() => {
if (!canEditTextRef.current) {
setIsDropdownVisible(true)
}
}, [])
const fallbackInputRef = useRef<HTMLFieldSetElement>(null)
const inputRef = rawInputRef ?? fallbackInputRef
const containerRef = useRef<HTMLDivElement>(null)
// This type is a little too wide but it is unavoidable.
/** Set values, while also changing the input text. */
@ -184,6 +176,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
return (
<div className={twJoin('relative isolate h-6 w-full', isDropdownVisible && 'z-1')}>
<div
ref={containerRef}
onKeyDown={onKeyDown}
className={twMerge(
'absolute w-full grow transition-colors',
@ -259,7 +252,7 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
<div
key={itemToKey(item)}
className={twMerge(
'text relative cursor-pointer whitespace-nowrap px-input-x last:rounded-b-xl hover:bg-hover-bg',
'text relative min-w-max cursor-pointer whitespace-nowrap rounded-full px-input-x last:rounded-b-xl hover:bg-hover-bg',
valuesSet.has(item) && 'bg-hover-bg',
index === selectedIndex && 'bg-black/5',
)}
@ -271,7 +264,12 @@ export default function Autocomplete<T>(props: AutocompleteProps<T>) {
toggleValue(item)
}}
>
<Text truncate="1" className="w-full" tooltipPlacement="left">
<Text
truncate="1"
className="w-full"
tooltipPlacement="top"
tooltipTriggerRef={containerRef}
>
{children(item)}
</Text>
</div>

View File

@ -188,8 +188,8 @@ export function EnsoDevtools() {
<ariaComponents.Switch
form={form}
name="enableMultitabs"
label={getText('enableMultitabs')}
description={getText('enableMultitabsDescription')}
label={getText('ensoDevtoolsFeatureFlags.enableMultitabs')}
description={getText('ensoDevtoolsFeatureFlags.enableMultitabsDescription')}
onChange={(value) => {
setFeatureFlags('enableMultitabs', value)
}}
@ -199,8 +199,10 @@ export function EnsoDevtools() {
<ariaComponents.Switch
form={form}
name="enableAssetsTableBackgroundRefresh"
label={getText('enableAssetsTableBackgroundRefresh')}
description={getText('enableAssetsTableBackgroundRefreshDescription')}
label={getText('ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefresh')}
description={getText(
'ensoDevtoolsFeatureFlags.enableAssetsTableBackgroundRefreshDescription',
)}
onChange={(value) => {
setFeatureFlags('enableAssetsTableBackgroundRefresh', value)
}}
@ -210,8 +212,12 @@ export function EnsoDevtools() {
type="number"
inputMode="numeric"
name="assetsTableBackgroundRefreshInterval"
label={getText('enableAssetsTableBackgroundRefreshInterval')}
description={getText('enableAssetsTableBackgroundRefreshIntervalDescription')}
label={getText(
'ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshInterval',
)}
description={getText(
'ensoDevtoolsFeatureFlags.assetsTableBackgroundRefreshIntervalDescription',
)}
onChange={(event) => {
setFeatureFlags(
'assetsTableBackgroundRefreshInterval',

View File

@ -3,9 +3,9 @@
* This file provides a zustand store that contains the state of the Enso devtools.
*/
import type { PaywallFeatureName } from '#/hooks/billing'
import * as zustand from '#/utilities/zustand'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import * as React from 'react'
import * as zustand from 'zustand'
/** Configuration for a paywall feature. */
export interface PaywallDevtoolsFeatureConfiguration {
@ -60,7 +60,9 @@ export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) =>
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useEnableVersionChecker() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.showVersionChecker)
return zustand.useStore(ensoDevtoolsStore, (state) => state.showVersionChecker, {
unsafeEnableTransition: true,
})
}
// ==================================
@ -69,20 +71,28 @@ export function useEnableVersionChecker() {
/** A function to set whether the version checker is forcibly shown/hidden. */
export function useSetEnableVersionChecker() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.setEnableVersionChecker)
return zustand.useStore(ensoDevtoolsStore, (state) => state.setEnableVersionChecker, {
unsafeEnableTransition: true,
})
}
/** A hook that provides access to the paywall devtools. */
export function usePaywallDevtools() {
return zustand.useStore(ensoDevtoolsStore, (state) => ({
features: state.paywallFeatures,
setFeature: state.setPaywallFeature,
}))
return zustand.useStore(
ensoDevtoolsStore,
(state) => ({
features: state.paywallFeatures,
setFeature: state.setPaywallFeature,
}),
{ unsafeEnableTransition: true },
)
}
/** A hook that provides access to the show devtools state. */
export function useShowDevtools() {
return zustand.useStore(ensoDevtoolsStore, (state) => state.showDevtools)
return zustand.useStore(ensoDevtoolsStore, (state) => state.showDevtools, {
unsafeEnableTransition: true,
})
}
// =================================

View File

@ -23,7 +23,12 @@ import * as errorUtils from '#/utilities/error'
/** Props for an {@link ErrorBoundary}. */
export interface ErrorBoundaryProps
extends Readonly<React.PropsWithChildren>,
Readonly<Pick<errorBoundary.ErrorBoundaryProps, 'FallbackComponent' | 'onError' | 'onReset'>> {}
Readonly<
Pick<
errorBoundary.ErrorBoundaryProps,
'FallbackComponent' | 'onError' | 'onReset' | 'resetKeys'
>
> {}
/**
* Catches errors in child components

View File

@ -51,8 +51,13 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
schema.format === 'enso-secret'
const { data: secrets } = useBackendQuery(remoteBackend, 'listSecrets', [], { enabled: isSecret })
const autocompleteItems = isSecret ? secrets?.map((secret) => secret.path) ?? null : null
const validityClassName =
isAbsent || getValidator(path)(value) ? 'border-primary/20' : 'border-red-700/60'
const isInvalid = !isAbsent && !getValidator(path)(value)
const validationErrorClassName =
isInvalid && 'border border-danger focus:border-danger focus:outline-danger'
const errors =
isInvalid && 'description' in schema && typeof schema.description === 'string' ?
[<Text className="px-2 text-danger">{schema.description}</Text>]
: []
// NOTE: `enum` schemas omitted for now as they are not yet used.
if ('const' in schema) {
@ -66,100 +71,121 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
if ('format' in schema && schema.format === 'enso-secret') {
const isValid = typeof value === 'string' && value !== ''
children.push(
<div className={twMerge('w-full rounded-default border-0.5', validityClassName)}>
<Autocomplete
items={autocompleteItems ?? []}
itemToKey={(item) => item}
placeholder={getText('enterSecretPath')}
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
values={isValid ? [value] : []}
setValues={(values) => {
onChange(values[0] ?? '')
}}
text={autocompleteText}
setText={setAutocompleteText}
<div className="flex flex-col">
<div
className={twMerge(
'w-full rounded-default border-0.5 border-primary/20 outline-offset-2 transition-[border-color,outline] duration-200 focus:border-primary/50 focus:outline focus:outline-2 focus:outline-offset-0 focus:outline-primary',
validationErrorClassName,
)}
>
{(item) => item}
</Autocomplete>
<Autocomplete
items={autocompleteItems ?? []}
itemToKey={(item) => item}
placeholder={getText('enterSecretPath')}
matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())}
values={isValid ? [value] : []}
setValues={(values) => {
onChange(values[0] ?? '')
}}
text={autocompleteText}
setText={setAutocompleteText}
>
{(item) => item}
</Autocomplete>
</div>
{...errors}
</div>,
...errors,
)
} else {
children.push(
<FocusRing>
<Input
type="text"
readOnly={readOnly}
value={typeof value === 'string' ? value : ''}
size={1}
className={twMerge(
'focus-child h-6 w-full grow rounded-input border-0.5 bg-transparent px-2 read-only:read-only',
validityClassName,
)}
placeholder={getText('enterText')}
onChange={(event) => {
const newValue: string = event.currentTarget.value
onChange(newValue)
}}
/>
</FocusRing>,
<div className="flex flex-col">
<FocusRing>
<Input
type="text"
readOnly={readOnly}
value={typeof value === 'string' ? value : ''}
size={1}
className={twMerge(
'focus-child h-6 w-full grow rounded-input border-0.5 border-primary/20 bg-transparent px-2 outline-offset-2 transition-[border-color,outline] duration-200 read-only:read-only focus:border-primary/50 focus:outline focus:outline-2 focus:outline-offset-0 focus:outline-primary',
validationErrorClassName,
)}
placeholder={getText('enterText')}
onChange={(event) => {
const newValue: string = event.currentTarget.value
onChange(newValue)
}}
/>
</FocusRing>
{...errors}
</div>,
)
}
break
}
case 'number': {
children.push(
<FocusRing>
<Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child h-6 w-full grow rounded-input border-0.5 bg-transparent px-2 read-only:read-only',
validityClassName,
)}
placeholder={getText('enterNumber')}
onChange={(event) => {
const newValue: number = event.currentTarget.valueAsNumber
if (Number.isFinite(newValue)) {
onChange(newValue)
}
}}
/>
</FocusRing>,
<div className="flex flex-col">
<FocusRing>
<Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child h-6 w-full grow rounded-input border-0.5 border-primary/20 bg-transparent px-2 outline-offset-2 transition-[border-color,outline] duration-200 read-only:read-only focus:border-primary/50 focus:outline focus:outline-2 focus:outline-offset-0 focus:outline-primary',
validationErrorClassName,
)}
placeholder={getText('enterNumber')}
onChange={(event) => {
const newValue: number = event.currentTarget.valueAsNumber
if (Number.isFinite(newValue)) {
onChange(newValue)
}
}}
/>
</FocusRing>
{...errors}
</div>,
)
break
}
case 'integer': {
children.push(
<FocusRing>
<Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child h-6 w-full grow rounded-input border-0.5 bg-transparent px-2 read-only:read-only',
validityClassName,
)}
placeholder={getText('enterInteger')}
onChange={(event) => {
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
onChange(newValue)
}}
/>
</FocusRing>,
<div className="flex flex-col">
<FocusRing>
<Input
type="number"
readOnly={readOnly}
value={typeof value === 'number' ? value : ''}
size={1}
className={twMerge(
'focus-child h-6 w-full grow rounded-input border-0.5 border-primary/20 bg-transparent px-2 outline-offset-2 transition-[border-color,outline] duration-200 read-only:read-only focus:border-primary/50 focus:outline focus:outline-2 focus:outline-offset-0 focus:outline-primary',
validationErrorClassName,
)}
placeholder={getText('enterInteger')}
onChange={(event) => {
const newValue: number = Math.floor(event.currentTarget.valueAsNumber)
onChange(newValue)
}}
/>
</FocusRing>
{...errors}
</div>,
)
break
}
case 'boolean': {
children.push(
<Checkbox
name="input"
isReadOnly={readOnly}
isSelected={typeof value === 'boolean' && value}
onChange={onChange}
/>,
<div className="flex flex-col">
<Checkbox
name="input"
isReadOnly={readOnly}
isSelected={typeof value === 'boolean' && value}
onChange={onChange}
/>
{...errors}
</div>,
)
break
}
@ -186,7 +212,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
>
{propertyDefinitions.map((definition) => {
const { key, schema: childSchema } = definition
const isOptional = !requiredProperties.includes(key)
const isOptional = !requiredProperties.includes(key) || isAbsent
const isPresent = !isAbsent && value != null && key in value
return constantValueOfSchema(defs, childSchema).length === 1 ?
null
@ -250,7 +276,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
newValue = unsafeValue!
}
const fullObject =
value ?? constantValueOfSchema(defs, childSchema, true)[0]
value ?? constantValueOfSchema(defs, schema, true)[0]
onChange(
(
typeof fullObject === 'object' &&
@ -346,6 +372,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
path={selectedChildPath}
getValidator={getValidator}
noBorder={noChildBorder}
isAbsent={isAbsent}
value={value}
onChange={onChange}
/>
@ -364,6 +391,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) {
path={`${path}/allOf/${i}`}
getValidator={getValidator}
noBorder={noChildBorder}
isAbsent={isAbsent}
value={value}
onChange={onChange}
/>

View File

@ -1,5 +1,5 @@
/** @file A full-screen loading spinner. */
import StatelessSpinner, * as spinnerModule from '#/components/StatelessSpinner'
import { StatelessSpinner, type SpinnerState } from '#/components/StatelessSpinner'
import * as twv from '#/utilities/tailwindVariants'
@ -57,7 +57,7 @@ export type Size = 'large' | 'medium' | 'small'
export interface LoaderProps extends twv.VariantProps<typeof STYLES> {
readonly className?: string
readonly size?: Size | number
readonly state?: spinnerModule.SpinnerState
readonly state?: SpinnerState
}
/** A full-screen loading spinner. */
@ -65,7 +65,7 @@ export function Loader(props: LoaderProps) {
const {
className,
size: sizeRaw = 'medium',
state = spinnerModule.SpinnerState.loadingFast,
state = 'loading-fast',
minHeight = 'full',
color = 'primary',
} = props

View File

@ -0,0 +1,81 @@
/** @file A WYSIWYG editor using Lexical.js. */
import { useSuspenseQuery } from '@tanstack/react-query'
import type { RendererObject } from 'marked'
import { marked } from 'marked'
import { useMemo } from 'react'
import { BUTTON_STYLES, TEXT_STYLE } from '../AriaComponents'
/** Props for a {@link MarkdownViewer}. */
export interface MarkdownViewerProps {
/** Markdown markup to parse and display. */
readonly text: string
readonly imgUrlResolver: (relativePath: string) => Promise<string>
readonly renderer?: RendererObject
}
const defaultRenderer: RendererObject = {
/** The renderer for headings. */
heading({ depth, tokens }) {
return `<h${depth} class="${TEXT_STYLE({ variant: 'h1', className: 'my-2' })}">${this.parser.parseInline(tokens)}</h${depth}>`
},
/** The renderer for paragraphs. */
paragraph({ tokens }) {
return `<p class="${TEXT_STYLE({ variant: 'body', className: 'my-1' })}">${this.parser.parseInline(tokens)}</p>`
},
/** The renderer for list items. */
listitem({ tokens }) {
return `<li class="${TEXT_STYLE({ variant: 'body' })}">${this.parser.parseInline(tokens)}</li>`
},
/** The renderer for lists. */
list({ items }) {
return `<ul class="my-1 list-disc pl-3">${items.map((item) => this.listitem(item)).join('\n')}</ul>`
},
/** The renderer for links. */
link({ href, tokens }) {
return `<a href="${href}" target="_blank" rel="noopener noreferrer" class="${BUTTON_STYLES({ variant: 'link' }).base()}">${this.parser.parseInline(tokens)}</a>`
},
/** The renderer for images. */
image({ href, title }) {
return `<img src="${href}" alt="${title}" class="my-1 h-auto max-w-full" />`
},
/** The renderer for code. */
code({ text }) {
return `<code class="block my-1 p-2 bg-primary/5 rounded-lg max-w-full overflow-auto max-h-48" >
<pre class="${TEXT_STYLE({ variant: 'body-sm' })}">${text}</pre>
</code>`
},
/** The renderer for blockquotes. */
blockquote({ tokens }) {
return `<blockquote class="${'relative my-1 pl-2 before:bg-primary/20 before:absolute before:left-0 before:top-0 before:h-full before:w-[1.5px] before:rounded-full'}">${this.parser.parse(tokens)}</blockquote>`
},
}
/**
* Markdown viewer component.
* Parses markdown passed in as a `text` prop into HTML and displays it.
*/
export function MarkdownViewer(props: MarkdownViewerProps) {
const { text, imgUrlResolver, renderer = defaultRenderer } = props
const markedInstance = useMemo(
() => marked.use({ renderer: Object.assign({}, defaultRenderer, renderer), async: true }),
[renderer],
)
const { data: markdownToHtml } = useSuspenseQuery({
queryKey: ['markdownToHtml', { text }],
queryFn: () =>
markedInstance.parse(text, {
async: true,
walkTokens: async (token) => {
if (token.type === 'image' && 'href' in token && typeof token.href === 'string') {
token.href = await imgUrlResolver(token.href)
}
},
}),
})
// eslint-disable-next-line @typescript-eslint/naming-convention
return <div className="select-text" dangerouslySetInnerHTML={{ __html: markdownToHtml }} />
}

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel export for MarkdownViewer
*/
export { MarkdownViewer, type MarkdownViewerProps } from './MarkdownViewer'

View File

@ -20,13 +20,10 @@ import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { useSyncRef } from '#/hooks/syncRefHooks'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import * as tailwindVariants from '#/utilities/tailwindVariants'
// =================
// === Constants ===
// =================
const MENU_ENTRY_VARIANTS = tailwindVariants.tv({
base: 'flex h-row grow place-content-between items-center rounded-inherit p-menu-entry text-left selectable group-enabled:active hover:bg-hover-bg disabled:bg-transparent',
variants: {
@ -84,10 +81,6 @@ export const ACTION_TO_TEXT_ID: Readonly<
openInFileBrowser: 'openInFileBrowserShortcut',
} satisfies { [Key in inputBindings.DashboardBindingKey]: `${Key}Shortcut` }
// =================
// === MenuEntry ===
// =================
/** Props for a {@link MenuEntry}. */
export interface MenuEntryProps extends tailwindVariants.VariantProps<typeof MENU_ENTRY_VARIANTS> {
readonly icon?: string
@ -119,6 +112,8 @@ export default function MenuEntry(props: MenuEntryProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const focusChildProps = focusHooks.useFocusChild()
const info = inputBindings.metadata[action]
const isDisabledRef = useSyncRef(isDisabled)
const labelTextId: text.TextId = (() => {
if (action === 'openInFileBrowser') {
return (
@ -131,17 +126,16 @@ export default function MenuEntry(props: MenuEntryProps) {
}
})()
React.useEffect(() => {
// This is slower (but more convenient) than registering every shortcut in the context menu
// at once.
if (isDisabled) {
return
}
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: doAction,
})
}, [isDisabled, inputBindings, action, doAction])
React.useEffect(
() =>
inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
[action]: () => {
if (isDisabledRef.current) return
doAction()
},
}),
[inputBindings, action, doAction, isDisabledRef],
)
return hidden ? null : (
<FocusRing>

View File

@ -51,6 +51,8 @@ const RESULT_STYLES = tv({
base: 'flex flex-col items-center justify-center max-w-full px-6 py-4 text-center h-[max-content]',
variants: {
centered: {
true: 'm-auto',
false: '',
horizontal: 'mx-auto',
vertical: 'my-auto',
all: 'm-auto',

View File

@ -2,29 +2,20 @@
* @file A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind
* classes.
*/
import * as React from 'react'
import { twMerge } from '#/utilities/tailwindMerge'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ===============
// === Spinner ===
// ===============
/** The state of the spinner. It should go from initial, to loading, to done. */
export enum SpinnerState {
initial = 'initial',
loadingSlow = 'loading-slow',
loadingMedium = 'loading-medium',
loadingFast = 'loading-fast',
done = 'done',
}
/** The state of the spinner. It should go from `initial`, to `loading`, to `done`. */
export type SpinnerState = 'done' | 'initial' | 'loading-fast' | 'loading-medium' | 'loading-slow'
export const SPINNER_CSS_CLASSES: Readonly<Record<SpinnerState, string>> = {
[SpinnerState.initial]: 'dasharray-5 ease-linear',
[SpinnerState.loadingSlow]: 'dasharray-75 duration-spinner-slow ease-linear',
[SpinnerState.loadingMedium]: 'dasharray-75 duration-spinner-medium ease-linear',
[SpinnerState.loadingFast]: 'dasharray-75 duration-spinner-fast ease-linear',
[SpinnerState.done]: 'dasharray-100 duration-spinner-fast ease-in',
initial: 'dasharray-5 ease-linear',
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'loading-slow': 'dasharray-75 duration-spinner-slow ease-linear',
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'loading-medium': 'dasharray-75 duration-spinner-medium ease-linear',
/* eslint-disable-next-line @typescript-eslint/naming-convention */
'loading-fast': 'dasharray-75 duration-spinner-fast ease-linear',
done: 'dasharray-100 duration-spinner-fast ease-in',
}
/** Props for a {@link Spinner}. */
@ -36,8 +27,9 @@ export interface SpinnerProps {
}
/** A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind classes. */
export default function Spinner(props: SpinnerProps) {
export function Spinner(props: SpinnerProps) {
const { size, padding, className, state } = props
return (
<svg
width={size}
@ -58,7 +50,7 @@ export default function Spinner(props: SpinnerProps) {
stroke="currentColor"
strokeLinecap="round"
strokeWidth={3}
className={tailwindMerge.twMerge(
className={twMerge(
'pointer-events-none origin-center !animate-spin-ease transition-stroke-dasharray [transition-duration:var(--spinner-slow-transition-duration)]',
SPINNER_CSS_CLASSES[state],
)}

View File

@ -1,27 +1,23 @@
/** @file A spinner that does not expose its {@link spinner.SpinnerState}. */
import * as React from 'react'
/** @file A spinner that does not expose its {@link SpinnerState}. */
import { startTransition, useEffect, useState } from 'react'
import Spinner, * as spinner from '#/components/Spinner'
// ========================
// === StatelessSpinner ===
// ========================
export { SpinnerState } from './Spinner'
import type { SpinnerProps, SpinnerState } from '#/components/Spinner'
import { Spinner } from '#/components/Spinner'
export type { SpinnerState } from '#/components/Spinner'
/** Props for a {@link StatelessSpinner}. */
export type StatelessSpinnerProps = spinner.SpinnerProps
export type StatelessSpinnerProps = SpinnerProps
/**
* A spinner that does not expose its {@link spinner.SpinnerState}. Instead, it begins at
* {@link spinner.SpinnerState.initial} and immediately changes to the given state.
* A spinner that does not expose its {@link SpinnerState}. Instead, it begins at
* `initial` and immediately changes to the given state.
*/
export default function StatelessSpinner(props: StatelessSpinnerProps) {
const { size, state: rawState, ...spinnerProps } = props
const [, startTransition] = React.useTransition()
const [state, setState] = React.useState(spinner.SpinnerState.initial)
export function StatelessSpinner(props: StatelessSpinnerProps) {
const { state: rawState, ...spinnerProps } = props
React.useLayoutEffect(() => {
const [state, setState] = useState<SpinnerState>('initial')
useEffect(() => {
const id = requestAnimationFrame(() => {
// consider this as a low-priority update
startTransition(() => {
@ -34,5 +30,5 @@ export default function StatelessSpinner(props: StatelessSpinnerProps) {
}
}, [rawState])
return <Spinner state={state} {...(size != null ? { size } : {})} {...spinnerProps} />
return <Spinner state={state} {...spinnerProps} />
}

View File

@ -28,7 +28,7 @@ export interface StepperProps {
| ((props: BaseRenderProps) => string | null | undefined)
| null
| undefined
readonly renderStep: (props: RenderStepProps) => React.ReactNode
readonly renderStep?: ((props: RenderStepProps) => React.ReactNode) | null
readonly style?:
| React.CSSProperties
| ((props: BaseRenderProps) => React.CSSProperties | undefined)
@ -101,29 +101,37 @@ export function Stepper(props: StepperProps) {
<stepperProvider.StepperProvider
value={{ totalSteps, currentStep, goToStep, nextStep, previousStep, state }}
>
<div className={styles.steps()}>
{Array.from({ length: totalSteps }).map((_, index) => {
const renderStepProps = {
index,
currentStep,
totalSteps,
isFirst: index === 0,
isLast: index === totalSteps - 1,
nextStep,
previousStep,
goToStep,
isCompleted: index < currentStep,
isCurrent: index === currentStep,
isDisabled: index > currentStep,
} satisfies RenderStepProps
{renderStep == null ? null : (
<div className={styles.steps()}>
{Array.from({ length: totalSteps }).map((_, index) => {
const renderStepProps = {
index,
currentStep,
totalSteps,
isFirst: index === 0,
isLast: index === totalSteps - 1,
nextStep,
previousStep,
goToStep,
isCompleted: index < currentStep,
isCurrent: index === currentStep,
isDisabled: index > currentStep,
} satisfies RenderStepProps
return (
<div key={index} className={styles.step({})}>
{renderStep(renderStepProps)}
</div>
)
})}
</div>
const nextRenderStep = renderStep(renderStepProps)
if (nextRenderStep == null) {
return null
}
return (
<div key={index} className={styles.step({})}>
{nextRenderStep}
</div>
)
})}
</div>
)}
<div className={styles.content()}>
<AnimatePresence initial={false} mode="sync" custom={direction}>

View File

@ -45,6 +45,7 @@ export interface UseStepperStateResult {
readonly percentComplete: number
readonly nextStep: () => void
readonly previousStep: () => void
readonly resetStepper: () => void
}
/**
@ -67,6 +68,10 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
const onStepChangeStableCallback = eventCallbackHooks.useEventCallback(onStepChange)
const onCompletedStableCallback = eventCallbackHooks.useEventCallback(onCompleted)
const resetStepper = eventCallbackHooks.useEventCallback(() => {
privateSetCurrentStep(() => ({ current: defaultStep, direction: 'initial' }))
})
const setCurrentStep = eventCallbackHooks.useEventCallback(
(step: number | ((current: number) => number)) => {
React.startTransition(() => {
@ -128,5 +133,6 @@ export function useStepperState(props: StepperStateProps): UseStepperStateResult
percentComplete,
nextStep,
previousStep,
resetStepper,
} satisfies UseStepperStateResult
}

View File

@ -9,14 +9,18 @@ import BlankIcon from '#/assets/blank.svg'
import * as dragAndDropHooks from '#/hooks/dragAndDropHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useDriveStore, useSetSelectedKeys } from '#/providers/DriveProvider'
import {
useDriveStore,
useSetSelectedKeys,
useToggleDirectoryExpansion,
} from '#/providers/DriveProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
import * as columnModule from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import FocusRing from '#/components/styled/FocusRing'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
@ -34,8 +38,9 @@ import { useCutAndPaste } from '#/events/assetListEvent'
import {
backendMutationOptions,
backendQueryOptions,
useAssetPassiveListenerStrict,
useAsset,
useBackendMutationState,
useUploadFiles,
} from '#/hooks/backendHooks'
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
@ -51,6 +56,7 @@ import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import Visibility from '#/utilities/Visibility'
import invariant from 'tiny-invariant'
// =================
// === Constants ===
@ -80,14 +86,16 @@ export interface AssetRowInnerProps {
/** Props for an {@link AssetRow}. */
export interface AssetRowProps {
readonly isOpened: boolean
readonly isPlaceholder: boolean
readonly visibility: Visibility | undefined
readonly id: backendModule.AssetId
readonly parentId: backendModule.DirectoryId
readonly type: backendModule.AssetType
readonly hidden: boolean
readonly path: string
readonly initialAssetEvents: readonly AssetEvent[] | null
readonly depth: number
readonly state: assetsTable.AssetsTableState
readonly hidden: boolean
readonly columns: columnUtils.Column[]
readonly isKeyboardSelected: boolean
readonly grabKeyboardFocus: (item: backendModule.AnyAsset) => void
@ -116,15 +124,162 @@ export interface AssetRowProps {
}
/** A row containing an {@link backendModule.AnyAsset}. */
// eslint-disable-next-line no-restricted-syntax
export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const { id, parentId, isKeyboardSelected, isOpened, select, state, columns, onClick } = props
const { type, columns, depth, id } = props
switch (type) {
case backendModule.AssetType.specialLoading:
case backendModule.AssetType.specialEmpty:
case backendModule.AssetType.specialError: {
return <AssetSpecialRow columnsLength={columns.length} depth={depth} type={type} />
}
default: {
// This is safe because we filter out special asset types in the switch statement above.
// eslint-disable-next-line no-restricted-syntax
return <RealAssetRow {...props} id={id as backendModule.RealAssetId} />
}
}
})
/**
* Props for a {@link AssetSpecialRow}.
*/
export interface AssetSpecialRowProps {
readonly type: backendModule.AssetType
readonly columnsLength: number
readonly depth: number
}
/**
* Renders a special asset row.
*/
// eslint-disable-next-line no-restricted-syntax
const AssetSpecialRow = React.memo(function AssetSpecialRow(props: AssetSpecialRowProps) {
const { type, columnsLength, depth } = props
const { getText } = textProvider.useText()
switch (type) {
case backendModule.AssetType.specialLoading: {
return (
<tr>
<td colSpan={columnsLength} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twJoin(
'flex h-table-row w-container items-center justify-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<StatelessSpinner size={24} state="loading-medium" />
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialEmpty: {
return (
<tr>
<td colSpan={columnsLength} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twJoin(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text className="px-name-column-x placeholder" disableLineHeightCompensation>
{getText('thisFolderIsEmpty')}
</Text>
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialError: {
return (
<tr>
<td colSpan={columnsLength} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twJoin(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text
className="px-name-column-x text-danger placeholder"
disableLineHeightCompensation
>
{getText('thisFolderFailedToFetch')}
</Text>
</div>
</td>
</tr>
)
}
default: {
invariant(false, 'Unsupported special asset type: ' + type)
}
}
})
/**
* Props for a {@link RealAssetRow}.
*/
type RealAssetRowProps = AssetRowProps & { readonly id: backendModule.RealAssetId }
/**
* Renders a real asset row.
*/
// eslint-disable-next-line no-restricted-syntax
const RealAssetRow = React.memo(function RealAssetRow(props: RealAssetRowProps) {
const { id, parentId, state } = props
const { category, backend } = state
const asset = useAsset({
backend,
parentId,
category,
assetId: id,
})
if (asset == null) {
return null
}
return <RealAssetInternalRow {...props} asset={asset} />
})
/**
* Internal props for a {@link RealAssetRow}.
*/
export interface RealAssetRowInternalProps extends AssetRowProps {
readonly asset: backendModule.AnyAsset
}
/**
* Internal implementation of a {@link RealAssetRow}.
*/
export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
const {
id,
parentId,
isKeyboardSelected,
isOpened,
select,
state,
columns,
onClick,
isPlaceholder,
type,
asset,
} = props
const { path, hidden: hiddenRaw, grabKeyboardFocus, visibility: visibilityRaw, depth } = props
const { initialAssetEvents } = props
const { nodeMap, doCopy, doCut, doPaste, doDelete: doDeleteRaw } = state
const { doRestore, doMove, category, scrollContainerRef, rootDirectoryId, backend } = state
const { doToggleDirectoryExpansion } = state
const asset = useAssetPassiveListenerStrict(backend.type, id, parentId, category)
const driveStore = useDriveStore()
const queryClient = useQueryClient()
const { user } = useFullUserSession()
@ -153,6 +308,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const [innerRowState, setRowState] = React.useState<assetsTable.AssetRowState>(
assetRowUtils.INITIAL_ROW_STATE,
)
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isNewlyCreated = useStore(driveStore, ({ newestFolderId }) => newestFolderId === asset.id)
const isEditingName = innerRowState.isEditingName || isNewlyCreated
@ -178,17 +334,21 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
const isCloud = isCloudCategory(category)
const { data: projectState } = useQuery({
...createGetProjectDetailsQuery.createPassiveListener(
// This is SAFE, as `isOpened` is only true for projects.
...createGetProjectDetailsQuery({
// This is safe because we disable the query when the asset is not a project.
// see `enabled` property below.
// eslint-disable-next-line no-restricted-syntax
asset.id as backendModule.ProjectId,
),
select: (data) => data?.state.type,
enabled: asset.type === backendModule.AssetType.project,
assetId: asset.id as backendModule.ProjectId,
parentId: asset.parentId,
backend,
}),
select: (data) => data.state.type,
enabled: asset.type === backendModule.AssetType.project && !isPlaceholder,
})
const toastAndLog = useToastAndLog()
const uploadFiles = useUploadFiles(backend, category)
const createPermissionMutation = useMutation(backendMutationOptions(backend, 'createPermission'))
const associateTagMutation = useMutation(backendMutationOptions(backend, 'associateTag'))
@ -320,11 +480,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
case backendModule.AssetType.project: {
try {
const details = await queryClient.fetchQuery(
backendQueryOptions(backend, 'getProjectDetails', [
asset.id,
asset.parentId,
asset.title,
]),
backendQueryOptions(backend, 'getProjectDetails', [asset.id, asset.parentId]),
)
if (details.url != null) {
await backend.download(details.url, `${asset.title}.enso-project`)
@ -489,7 +645,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
}, initialAssetEvents)
switch (asset.type) {
switch (type) {
case backendModule.AssetType.directory:
case backendModule.AssetType.project:
case backendModule.AssetType.file:
@ -557,7 +713,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
window.setTimeout(() => {
setSelected(false)
})
doToggleDirectoryExpansion(asset.id, asset.id)
toggleDirectoryExpansion(asset.id)
}
}}
onContextMenu={(event) => {
@ -602,7 +758,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
}
if (asset.type === backendModule.AssetType.directory) {
dragOverTimeoutHandle.current = window.setTimeout(() => {
doToggleDirectoryExpansion(asset.id, asset.id, true)
toggleDirectoryExpansion(asset.id, true)
}, DRAG_EXPAND_DELAY_MS)
}
// Required because `dragover` does not fire on `mouseenter`.
@ -650,7 +806,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
event.preventDefault()
event.stopPropagation()
unsetModal()
doToggleDirectoryExpansion(directoryId, directoryId, true)
toggleDirectoryExpansion(directoryId, true)
const ids = payload
.filter((payloadItem) => payloadItem.asset.parentId !== directoryId)
.map((dragItem) => dragItem.key)
@ -663,13 +819,8 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
} else if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
doToggleDirectoryExpansion(directoryId, directoryId, true)
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: directoryId,
parentId: directoryId,
files: Array.from(event.dataTransfer.files),
})
toggleDirectoryExpansion(directoryId, true)
void uploadFiles(Array.from(event.dataTransfer.files), directoryId, null)
}
}
}}
@ -679,6 +830,7 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
return (
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
<Render
isPlaceholder={isPlaceholder}
keyProp={id}
isOpened={isOpened}
backendType={backend.type}
@ -718,62 +870,12 @@ export const AssetRow = React.memo(function AssetRow(props: AssetRowProps) {
</>
)
}
case backendModule.AssetType.specialLoading: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row w-container items-center justify-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<StatelessSpinner size={24} state={statelessSpinner.SpinnerState.loadingMedium} />
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialEmpty: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text className="px-name-column-x placeholder" disableLineHeightCompensation>
{getText('thisFolderIsEmpty')}
</Text>
</div>
</td>
</tr>
)
}
case backendModule.AssetType.specialError: {
return hidden ? null : (
<tr>
<td colSpan={columns.length} className="border-r p-0 rounded-rows-skip-level">
<div
className={tailwindMerge.twMerge(
'flex h-table-row items-center rounded-full rounded-rows-child',
indent.indentClass(depth),
)}
>
<img src={BlankIcon} />
<Text
className="px-name-column-x text-danger placeholder"
disableLineHeightCompensation
>
{getText('thisFolderFailedToFetch')}
</Text>
</div>
</td>
</tr>
)
default: {
invariant(
false,
'Unsupported asset type, expected one of: directory, project, file, datalink, secret, but got: ' +
type,
)
}
}
})
}

View File

@ -6,7 +6,7 @@ import FolderArrowIcon from '#/assets/folder_arrow.svg'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { useDriveStore } from '#/providers/DriveProvider'
import { useDriveStore, useToggleDirectoryExpansion } from '#/providers/DriveProvider'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
@ -38,10 +38,11 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {
* This should never happen.
*/
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const { item, selected, state, rowState, setRowState, isEditable, depth } = props
const { backend, nodeMap, doToggleDirectoryExpansion, expandedDirectoryIds } = state
const { item, depth, selected, state, rowState, setRowState, isEditable } = props
const { backend, nodeMap, expandedDirectoryIds } = state
const { getText } = textProvider.useText()
const driveStore = useDriveStore()
const toggleDirectoryExpansion = useToggleDirectoryExpansion()
const isExpanded = expandedDirectoryIds.includes(item.id)
const updateDirectoryMutation = useMutation(backendMutationOptions(backend, 'updateDirectory'))
@ -98,7 +99,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
isExpanded && 'rotate-90',
)}
onPress={() => {
doToggleDirectoryExpansion(item.id, item.id)
toggleDirectoryExpansion(item.id)
}}
/>
<SvgMask src={FolderIcon} className="m-name-column-icon size-4 group-hover:hidden" />

View File

@ -11,8 +11,8 @@ import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import Spinner from '#/components/Spinner'
import StatelessSpinner, * as spinner from '#/components/StatelessSpinner'
import { Spinner } from '#/components/Spinner'
import { StatelessSpinner, type SpinnerState } from '#/components/StatelessSpinner'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
@ -21,6 +21,8 @@ import { useBackendQuery } from '#/hooks/backendHooks'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useMemo } from 'react'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
// =================
// === Constants ===
// =================
@ -28,34 +30,34 @@ import { useMemo } from 'react'
export const CLOSED_PROJECT_STATE = { type: backendModule.ProjectState.closed } as const
/**
* The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
* The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
* when using the remote backend.
*/
const REMOTE_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.SpinnerState>> = {
[backendModule.ProjectState.closed]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.closing]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.created]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.new]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.placeholder]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.provisioned]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.scheduled]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
const REMOTE_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, SpinnerState>> = {
[backendModule.ProjectState.closed]: 'loading-slow',
[backendModule.ProjectState.closing]: 'loading-medium',
[backendModule.ProjectState.created]: 'loading-slow',
[backendModule.ProjectState.new]: 'loading-slow',
[backendModule.ProjectState.placeholder]: 'loading-slow',
[backendModule.ProjectState.openInProgress]: 'loading-slow',
[backendModule.ProjectState.provisioned]: 'loading-slow',
[backendModule.ProjectState.scheduled]: 'loading-slow',
[backendModule.ProjectState.opened]: 'done',
}
/**
* The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
* The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
* when using the local backend.
*/
const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.SpinnerState>> = {
[backendModule.ProjectState.closed]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.closing]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.created]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.new]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.placeholder]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow,
[backendModule.ProjectState.provisioned]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.scheduled]: spinner.SpinnerState.loadingMedium,
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, SpinnerState>> = {
[backendModule.ProjectState.closed]: 'loading-slow',
[backendModule.ProjectState.closing]: 'loading-medium',
[backendModule.ProjectState.created]: 'loading-slow',
[backendModule.ProjectState.new]: 'loading-slow',
[backendModule.ProjectState.placeholder]: 'loading-medium',
[backendModule.ProjectState.openInProgress]: 'loading-slow',
[backendModule.ProjectState.provisioned]: 'loading-medium',
[backendModule.ProjectState.scheduled]: 'loading-medium',
[backendModule.ProjectState.opened]: 'done',
}
// ===================
@ -64,6 +66,7 @@ const LOCAL_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.S
/** Props for a {@link ProjectIcon}. */
export interface ProjectIconProps {
readonly isPlaceholder: boolean
readonly backend: Backend
readonly isDisabled: boolean
readonly isOpened: boolean
@ -72,7 +75,7 @@ export interface ProjectIconProps {
/** An interactive icon indicating the status of a project. */
export default function ProjectIcon(props: ProjectIconProps) {
const { backend, item, isOpened, isDisabled } = props
const { backend, item, isOpened, isDisabled, isPlaceholder } = props
const openProject = projectHooks.useOpenProject()
const closeProject = projectHooks.useCloseProject()
@ -84,10 +87,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const itemProjectState = item.projectState ?? CLOSED_PROJECT_STATE
const { data: projectState, isError } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id),
select: (data) => data?.state,
enabled: isOpened,
...projectHooks.createGetProjectDetailsQuery({
assetId: item.id,
parentId: item.parentId,
backend,
}),
select: (data) => data.state,
enabled: !isPlaceholder && isOpened,
})
const status = projectState?.type
const isRunningInBackground = projectState?.executeAsync ?? false
@ -109,27 +117,32 @@ export default function ProjectIcon(props: ProjectIconProps) {
userOpeningProject == null ? null : getText('xIsUsingTheProject', userOpeningProject.name)
const state = (() => {
if (!isOpened && !isPlaceholder) {
return backendModule.ProjectState.closed
}
// Project is closed, show open button
if (!isOpened) {
return (projectState ?? itemProjectState).type
} else if (status == null) {
}
if (status == null) {
// Project is opened, but not yet queried.
return backendModule.ProjectState.openInProgress
} else if (status === backendModule.ProjectState.closed) {
}
if (status === backendModule.ProjectState.closed) {
// Project is opened locally, but not on the backend yet.
return backendModule.ProjectState.openInProgress
} else {
return status
}
return status
})()
const spinnerState = (() => {
const spinnerState = ((): SpinnerState => {
if (!isOpened) {
return spinner.SpinnerState.initial
return 'initial'
} else if (isError) {
return spinner.SpinnerState.initial
return 'initial'
} else if (status == null) {
return spinner.SpinnerState.loadingSlow
return 'loading-slow'
} else {
return backend.type === backendModule.BackendType.remote ?
REMOTE_SPINNER_STATE[status]
@ -137,15 +150,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
})()
const doOpenProject = () => {
const doOpenProject = useEventCallback(() => {
openProject({ ...item, type: backend.type })
}
const doCloseProject = () => {
})
const doCloseProject = useEventCallback(() => {
closeProject({ ...item, type: backend.type })
}
const doOpenProjectTab = () => {
})
const doOpenProjectTab = useEventCallback(() => {
openProjectTab(item.id)
}
})
switch (state) {
case backendModule.ProjectState.new:
@ -207,7 +220,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
onPress={doCloseProject}
/>
<Spinner
state={spinner.SpinnerState.done}
state="done"
className={tailwindMerge.twMerge(
'pointer-events-none absolute inset-0',
isRunningInBackground && 'text-green',

View File

@ -38,7 +38,17 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {
* This should never happen.
*/
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const { item, selected, rowState, setRowState, state, isEditable, backendType, isOpened } = props
const {
item,
selected,
rowState,
setRowState,
state,
isEditable,
backendType,
isOpened,
isPlaceholder,
} = props
const { depth } = props
const { backend, nodeMap } = state
@ -114,7 +124,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
}}
>
<ProjectIcon isDisabled={!canExecute} isOpened={isOpened} backend={backend} item={item} />
<ProjectIcon
isDisabled={!canExecute}
isOpened={isOpened}
backend={backend}
item={item}
isPlaceholder={isPlaceholder}
/>
<EditableSpan
data-testid="asset-row-name"
editable={rowState.isEditingName}

View File

@ -1,6 +1,7 @@
/** @file Column types and column display modes. */
import type { Dispatch, JSX, SetStateAction } from 'react'
import type { SortableColumn } from '#/components/dashboard/column/columnUtils'
import { Column } from '#/components/dashboard/column/columnUtils'
import DocsColumn from '#/components/dashboard/column/DocsColumn'
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
@ -9,7 +10,9 @@ import NameColumn from '#/components/dashboard/column/NameColumn'
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import type { AssetRowState, AssetsTableState } from '#/layouts/AssetsTable'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import type { AnyAsset, Asset, AssetId, BackendType } from '#/services/Backend'
import type { SortInfo } from '#/utilities/sorting'
// ===================
// === AssetColumn ===
@ -29,11 +32,15 @@ export interface AssetColumnProps {
readonly rowState: AssetRowState
readonly setRowState: Dispatch<SetStateAction<AssetRowState>>
readonly isEditable: boolean
readonly isPlaceholder: boolean
}
/** Props for a {@link AssetColumn}. */
export interface AssetColumnHeadingProps {
readonly state: AssetsTableState
readonly category: Category
readonly hideColumn: (column: Column) => void
readonly sortInfo: SortInfo<SortableColumn> | null
readonly setSortInfo: (sortInfo: SortInfo<SortableColumn> | null) => void
}
/** Metadata describing how to render a column of the table. */

View File

@ -7,7 +7,7 @@ import type { AssetColumnProps } from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import { PaywallDialogButton } from '#/components/Paywall'
import AssetEventType from '#/events/AssetEventType'
import { useAssetPassiveListenerStrict } from '#/hooks/backendHooks'
import { useAssetStrict } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
@ -36,7 +36,12 @@ interface SharedWithColumnPropsInternal extends Pick<AssetColumnProps, 'item'> {
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, state, isReadonly = false } = props
const { backend, category, setQuery } = state
const asset = useAssetPassiveListenerStrict(backend.type, item.id, item.parentId, category)
const asset = useAssetStrict({
backend,
assetId: item.id,
parentId: item.parentId,
category,
})
const { user } = useFullUserSession()
const dispatchAssetEvent = useDispatchAssetEvent()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })

View File

@ -3,14 +3,18 @@ import AccessedByProjectsIcon from '#/assets/accessed_by_projects.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Accessed by projects" column. */
export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.accessedByProjects)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +22,7 @@ export default function AccessedByProjectsColumnHeading(props: AssetColumnHeadin
icon={AccessedByProjectsIcon}
aria-label={getText('accessedByProjectsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.accessedByProjects)
}}
onPress={hideThisColumn}
/>
<Text className="text-sm font-semibold">{getText('accessedByProjectsColumnName')}</Text>
</div>

View File

@ -3,14 +3,18 @@ import AccessedDataIcon from '#/assets/accessed_data.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Accessed data" column. */
export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.accessedData)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +22,7 @@ export default function AccessedDataColumnHeading(props: AssetColumnHeadingProps
icon={AccessedDataIcon}
aria-label={getText('accessedDataColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.accessedData)
}}
onPress={hideThisColumn}
/>
<Text className="text-sm font-semibold">{getText('accessedDataColumnName')}</Text>
</div>

View File

@ -3,14 +3,18 @@ import DocsIcon from '#/assets/docs.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Docs" column. */
export default function DocsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.docs)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +22,7 @@ export default function DocsColumnHeading(props: AssetColumnHeadingProps) {
icon={DocsIcon}
aria-label={getText('docsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.docs)
}}
onPress={hideThisColumn}
/>
<Text className="text-sm font-semibold">{getText('docsColumnName')}</Text>
</div>

View File

@ -3,14 +3,19 @@ import TagIcon from '#/assets/tag.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Labels" column. */
export default function LabelsColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { hideColumn } = props
const { getText } = useText()
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.labels)
})
return (
<div className="flex h-table-row w-full items-center gap-icon-with-text">
<Button
@ -18,9 +23,7 @@ export default function LabelsColumnHeading(props: AssetColumnHeadingProps) {
icon={TagIcon}
aria-label={getText('labelsColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.labels)
}}
onPress={hideThisColumn}
/>
<Text className="fond-semibold text-sm">{getText('labelsColumnName')}</Text>
</div>

View File

@ -5,18 +5,40 @@ import { Text } from '#/components/aria'
import { Button } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
import { twMerge } from '#/utilities/tailwindMerge'
import { twJoin } from '#/utilities/tailwindMerge'
/** A heading for the "Modified" column. */
export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { sortInfo, setSortInfo, hideColumn } = state
const { hideColumn, sortInfo, setSortInfo } = props
const { getText } = useText()
const isSortActive = sortInfo?.field === Column.modified
const isDescending = sortInfo?.direction === SortDirection.descending
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.modified)
})
const cycleSortDirection = useEventCallback(() => {
if (!sortInfo) {
setSortInfo({ field: Column.modified, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.modified, direction: nextDirection })
}
})
return (
<div
aria-label={
@ -32,35 +54,19 @@ export default function ModifiedColumnHeading(props: AssetColumnHeadingProps) {
icon={TimeIcon}
aria-label={getText('modifiedColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.modified)
}}
onPress={hideThisColumn}
/>
<Button
size="custom"
variant="custom"
className="flex grow justify-start gap-icon-with-text"
onPress={() => {
if (!sortInfo) {
setSortInfo({ field: Column.modified, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.modified, direction: nextDirection })
}
}}
onPress={cycleSortDirection}
>
<Text className="text-header">{getText('modifiedColumnName')}</Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={twMerge(
className={twJoin(
'transition-all duration-arrow',
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
isDescending && 'rotate-180',

View File

@ -3,18 +3,34 @@ import SortAscendingIcon from '#/assets/sort_ascending.svg'
import { Button, Text } from '#/components/AriaComponents'
import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useText } from '#/providers/TextProvider'
import { SortDirection, nextSortDirection } from '#/utilities/sorting'
import { twMerge } from '#/utilities/tailwindMerge'
import { twJoin } from '#/utilities/tailwindMerge'
/** A heading for the "Name" column. */
export default function NameColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { sortInfo, setSortInfo } = state
const { sortInfo, setSortInfo } = props
const { getText } = useText()
const isSortActive = sortInfo?.field === Column.name
const isDescending = sortInfo?.direction === SortDirection.descending
const cycleSortDirection = useEventCallback(() => {
if (!sortInfo) {
setSortInfo({ field: Column.name, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.name, direction: nextDirection })
}
})
return (
<Button
size="custom"
@ -26,26 +42,13 @@ export default function NameColumnHeading(props: AssetColumnHeadingProps) {
: getText('sortByNameDescending')
}
className="group flex h-table-row w-full items-center justify-start gap-icon-with-text px-name-column-x"
onPress={() => {
if (!sortInfo) {
setSortInfo({ field: Column.name, direction: SortDirection.ascending })
return
}
const nextDirection =
isSortActive ? nextSortDirection(sortInfo.direction) : SortDirection.ascending
if (nextDirection == null) {
setSortInfo(null)
} else {
setSortInfo({ field: Column.name, direction: nextDirection })
}
}}
onPress={cycleSortDirection}
>
<Text className="text-sm font-semibold">{getText('nameColumnName')}</Text>
<img
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={twMerge(
className={twJoin(
'transition-all duration-arrow',
isSortActive ? 'selectable active' : 'opacity-0 group-hover:selectable',
isDescending && 'rotate-180',

View File

@ -5,19 +5,22 @@ import type { AssetColumnHeadingProps } from '#/components/dashboard/column'
import { Column } from '#/components/dashboard/column/columnUtils'
import { PaywallDialogButton } from '#/components/Paywall'
import { usePaywall } from '#/hooks/billing'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useText } from '#/providers/TextProvider'
/** A heading for the "Shared with" column. */
export default function SharedWithColumnHeading(props: AssetColumnHeadingProps) {
const { state } = props
const { category, hideColumn } = state
const { hideColumn, category } = props
const { getText } = useText()
const { user } = useFullUserSession()
const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
const hideThisColumn = useEventCallback(() => {
hideColumn(Column.sharedWith)
})
const isUnderPaywall = isFeatureUnderPaywall('share')
return (
@ -27,9 +30,7 @@ export default function SharedWithColumnHeading(props: AssetColumnHeadingProps)
icon={PeopleIcon}
aria-label={getText('sharedWithColumnHide')}
tooltip={false}
onPress={() => {
hideColumn(Column.sharedWith)
}}
onPress={hideThisColumn}
/>
<div className="flex items-center gap-1">

View File

@ -117,9 +117,9 @@
"libraryName": { "const": "Standard.Base" },
"path": {
"title": "Path",
"description": "Must start with \"enso://<organization-name>/\".",
"description": "Must start with \"enso://Users/<username>/\" or \"enso://Teams/<team name>/\".",
"type": "string",
"pattern": "^enso://.+/.*$",
"pattern": "^enso://(?:Users|Teams)/.*/.*$",
"format": "enso-file"
},
"format": { "title": "Format", "$ref": "#/$defs/Format" }

View File

@ -2,13 +2,7 @@
/** Possible types of changes to the file list. */
enum AssetListEventType {
newFolder = 'new-folder',
newProject = 'new-project',
uploadFiles = 'upload-files',
newDatalink = 'new-datalink',
newSecret = 'new-secret',
duplicateProject = 'duplicate-project',
closeFolder = 'close-folder',
copy = 'copy',
move = 'move',
delete = 'delete',

View File

@ -20,13 +20,7 @@ interface AssetListBaseEvent<Type extends AssetListEventType> {
/** All possible events. */
interface AssetListEvents {
readonly newFolder: AssetListNewFolderEvent
readonly newProject: AssetListNewProjectEvent
readonly uploadFiles: AssetListUploadFilesEvent
readonly newSecret: AssetListNewSecretEvent
readonly newDatalink: AssetListNewDatalinkEvent
readonly duplicateProject: AssetListDuplicateProjectEvent
readonly closeFolder: AssetListCloseFolderEvent
readonly copy: AssetListCopyEvent
readonly move: AssetListMoveEvent
readonly delete: AssetListDeleteEvent
@ -45,46 +39,6 @@ type SanityCheck<
} = AssetListEvents,
> = [T]
/** A signal to create a new directory. */
interface AssetListNewFolderEvent extends AssetListBaseEvent<AssetListEventType.newFolder> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
}
/** A signal to create a new project. */
interface AssetListNewProjectEvent extends AssetListBaseEvent<AssetListEventType.newProject> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly templateId: string | null
readonly datalinkId: backend.DatalinkId | null
readonly preferredName: string | null
readonly onCreated?: (project: backend.CreatedProject) => void
readonly onError?: () => void
}
/** A signal to upload files. */
interface AssetListUploadFilesEvent extends AssetListBaseEvent<AssetListEventType.uploadFiles> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly files: File[]
}
/** A signal to create a new secret. */
interface AssetListNewDatalinkEvent extends AssetListBaseEvent<AssetListEventType.newDatalink> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly name: string
readonly value: unknown
}
/** A signal to create a new secret. */
interface AssetListNewSecretEvent extends AssetListBaseEvent<AssetListEventType.newSecret> {
readonly parentKey: backend.DirectoryId
readonly parentId: backend.DirectoryId
readonly name: string
readonly value: string
}
/** A signal to duplicate a project. */
interface AssetListDuplicateProjectEvent
extends AssetListBaseEvent<AssetListEventType.duplicateProject> {
@ -94,12 +48,6 @@ interface AssetListDuplicateProjectEvent
readonly versionId: backend.S3ObjectVersionId
}
/** A signal to close (collapse) a folder. */
interface AssetListCloseFolderEvent extends AssetListBaseEvent<AssetListEventType.closeFolder> {
readonly id: backend.DirectoryId
readonly key: backend.DirectoryId
}
/** A signal that files should be copied. */
interface AssetListCopyEvent extends AssetListBaseEvent<AssetListEventType.copy> {
readonly newParentKey: backend.DirectoryId

View File

@ -1,605 +0,0 @@
/** @file Hooks for interacting with the backend. */
import { useId, useMemo, useState } from 'react'
import {
queryOptions,
useMutation,
useMutationState,
useQuery,
type Mutation,
type MutationKey,
type UseMutationOptions,
type UseQueryOptions,
type UseQueryResult,
} from '@tanstack/react-query'
import { toast } from 'react-toastify'
import invariant from 'tiny-invariant'
import {
backendQueryOptions as backendQueryOptionsBase,
type BackendMethods,
} from 'enso-common/src/backendQuery'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useToastAndLog, useToastAndLogWithId } from '#/hooks/toastAndLogHooks'
import { CATEGORY_TO_FILTER_BY, type Category } from '#/layouts/CategorySwitcher/Category'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import {
AssetType,
BackendType,
type AnyAsset,
type AssetId,
type DirectoryAsset,
type DirectoryId,
type User,
type UserGroupInfo,
} from '#/services/Backend'
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import { usePreventNavigation } from '#/utilities/preventNavigation'
import { toRfc3339 } from 'enso-common/src/utilities/data/dateTime'
// The number of bytes in 1 megabyte.
const MB_BYTES = 1_000_000
const S3_CHUNK_SIZE_MB = Math.round(backendModule.S3_CHUNK_SIZE_BYTES / MB_BYTES)
// ============================
// === DefineBackendMethods ===
// ============================
/** Ensure that the given type contains only names of backend methods. */
type DefineBackendMethods<T extends keyof Backend> = T
// ======================
// === MutationMethod ===
// ======================
/** Names of methods corresponding to mutations. */
export type MutationMethod = DefineBackendMethods<
| 'acceptInvitation'
| 'associateTag'
| 'changeUserGroup'
| 'closeProject'
| 'copyAsset'
| 'createCheckoutSession'
| 'createDatalink'
| 'createDirectory'
| 'createPermission'
| 'createProject'
| 'createSecret'
| 'createTag'
| 'createUser'
| 'createUserGroup'
| 'declineInvitation'
| 'deleteAsset'
| 'deleteDatalink'
| 'deleteInvitation'
| 'deleteTag'
| 'deleteUser'
| 'deleteUserGroup'
| 'duplicateProject'
| 'inviteUser'
| 'logEvent'
| 'openProject'
| 'removeUser'
| 'resendInvitation'
| 'restoreUser'
| 'undoDeleteAsset'
| 'updateAsset'
| 'updateDirectory'
| 'updateFile'
| 'updateOrganization'
| 'updateProject'
| 'updateSecret'
| 'updateUser'
| 'uploadFileChunk'
| 'uploadFileEnd'
| 'uploadFileStart'
| 'uploadOrganizationPicture'
| 'uploadUserPicture'
>
// =======================
// === useBackendQuery ===
// =======================
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryOptions<Awaited<ReturnType<Backend[Method]>> | undefined>
/** Wrap a backend method call in a React Query. */
export function backendQueryOptions<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
// @ts-expect-error This call is generic over the presence or absence of `inputData`.
return queryOptions<Awaited<ReturnType<Backend[Method]>>>({
...options,
...backendQueryOptionsBase(backend, method, args, options?.queryKey),
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
queryFn: () => (backend?.[method] as any)?.(...args),
})
}
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<Awaited<ReturnType<Backend[Method]>>>
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
): UseQueryResult<Awaited<ReturnType<Backend[Method]>> | undefined>
/** Wrap a backend method call in a React Query. */
export function useBackendQuery<Method extends BackendMethods>(
backend: Backend | null,
method: Method,
args: Parameters<Backend[Method]>,
options?: Omit<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryFn' | 'queryKey'> &
Partial<Pick<UseQueryOptions<Awaited<ReturnType<Backend[Method]>>>, 'queryKey'>>,
) {
return useQuery(backendQueryOptions(backend, method, args, options))
}
// ==========================
// === useBackendMutation ===
// ==========================
const INVALIDATE_ALL_QUERIES = Symbol('invalidate all queries')
const INVALIDATION_MAP: Partial<
Record<MutationMethod, readonly (BackendMethods | typeof INVALIDATE_ALL_QUERIES)[]>
> = {
createUser: ['usersMe'],
updateUser: ['usersMe'],
deleteUser: ['usersMe'],
restoreUser: ['usersMe'],
uploadUserPicture: ['usersMe'],
updateOrganization: ['getOrganization'],
uploadOrganizationPicture: ['getOrganization'],
createUserGroup: ['listUserGroups'],
deleteUserGroup: ['listUserGroups'],
changeUserGroup: ['listUsers'],
createTag: ['listTags'],
deleteTag: ['listTags'],
associateTag: ['listDirectory'],
acceptInvitation: [INVALIDATE_ALL_QUERIES],
declineInvitation: ['usersMe'],
createProject: ['listDirectory'],
duplicateProject: ['listDirectory'],
createDirectory: ['listDirectory'],
createSecret: ['listDirectory'],
updateSecret: ['listDirectory'],
createDatalink: ['listDirectory', 'getDatalink'],
uploadFileEnd: ['listDirectory'],
copyAsset: ['listDirectory', 'listAssetVersions'],
deleteAsset: ['listDirectory', 'listAssetVersions'],
undoDeleteAsset: ['listDirectory'],
updateAsset: ['listDirectory', 'listAssetVersions'],
closeProject: ['listDirectory', 'listAssetVersions'],
updateDirectory: ['listDirectory'],
}
/** The type of the corresponding mutation for the given backend method. */
export type BackendMutation<Method extends MutationMethod> = Mutation<
Awaited<ReturnType<Backend[Method]>>,
Error,
Parameters<Backend[Method]>
>
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<
Awaited<ReturnType<Backend[Method]>> | undefined,
Error,
Parameters<Backend[Method]>
>
/** Wrap a backend method call in a React Query Mutation. */
export function backendMutationOptions<Method extends MutationMethod>(
backend: Backend | null,
method: Method,
options?: Omit<
UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>>,
'mutationFn'
>,
): UseMutationOptions<Awaited<ReturnType<Backend[Method]>>, Error, Parameters<Backend[Method]>> {
return {
...options,
mutationKey: [backend?.type, method, ...(options?.mutationKey ?? [])],
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return
mutationFn: (args) => (backend?.[method] as any)?.(...args),
networkMode: backend?.type === BackendType.local ? 'always' : 'online',
meta: {
invalidates: [
...(options?.meta?.invalidates ?? []),
...(INVALIDATION_MAP[method]?.map((queryMethod) =>
queryMethod === INVALIDATE_ALL_QUERIES ? [backend?.type] : [backend?.type, queryMethod],
) ?? []),
],
awaitInvalidates: options?.meta?.awaitInvalidates ?? true,
},
}
}
// ==================================
// === useListUserGroupsWithUsers ===
// ==================================
/** A user group, as well as the users that are a part of the user group. */
export interface UserGroupInfoWithUsers extends UserGroupInfo {
readonly users: readonly User[]
}
/** A list of user groups, taking into account optimistic state. */
export function useListUserGroupsWithUsers(
backend: Backend,
): readonly UserGroupInfoWithUsers[] | null {
const listUserGroupsQuery = useBackendQuery(backend, 'listUserGroups', [])
const listUsersQuery = useBackendQuery(backend, 'listUsers', [])
return useMemo(() => {
if (listUserGroupsQuery.data == null || listUsersQuery.data == null) {
return null
} else {
const result = listUserGroupsQuery.data.map((userGroup) => {
const usersInGroup: readonly User[] = listUsersQuery.data.filter((user) =>
user.userGroups?.includes(userGroup.id),
)
return { ...userGroup, users: usersInGroup }
})
return result
}
}, [listUserGroupsQuery.data, listUsersQuery.data])
}
/**
* Upload progress for {@link useUploadFileMutation}.
*/
export interface UploadFileMutationProgress {
/**
* Whether this is the first progress update.
* Useful to determine whether to create a new toast or to update an existing toast.
*/
readonly event: 'begin' | 'chunk' | 'end'
readonly sentMb: number
readonly totalMb: number
}
/** Data for a specific asset. */
export function useAssetPassiveListener(
backendType: BackendType,
assetId: AssetId | null | undefined,
parentId: DirectoryId | null | undefined,
category: Category,
) {
const { data: asset } = useQuery<readonly AnyAsset[] | undefined, Error, AnyAsset | undefined>({
queryKey: [
backendType,
'listDirectory',
parentId,
{
labels: null,
filterBy: CATEGORY_TO_FILTER_BY[category.type],
recentProjects: category.type === 'recent',
},
],
initialData: undefined,
select: (data) => data?.find((child) => child.id === assetId),
})
if (asset || !assetId || !parentId) {
return asset
}
const shared = {
parentId,
projectState: null,
extension: null,
description: '',
modifiedAt: toRfc3339(new Date()),
permissions: [],
labels: [],
parentsPath: '',
virtualParentsPath: '',
}
switch (true) {
case assetId === USERS_DIRECTORY_ID: {
return {
...shared,
id: assetId,
title: 'Users',
type: AssetType.directory,
} satisfies DirectoryAsset
}
case assetId === TEAMS_DIRECTORY_ID: {
return {
...shared,
id: assetId,
title: 'Teams',
type: AssetType.directory,
} satisfies DirectoryAsset
}
case backendModule.isLoadingAssetId(assetId): {
return {
...shared,
id: assetId,
title: '',
type: AssetType.specialLoading,
} satisfies backendModule.SpecialLoadingAsset
}
case backendModule.isEmptyAssetId(assetId): {
return {
...shared,
id: assetId,
title: '',
type: AssetType.specialEmpty,
} satisfies backendModule.SpecialEmptyAsset
}
case backendModule.isErrorAssetId(assetId): {
return {
...shared,
id: assetId,
title: '',
type: AssetType.specialError,
} satisfies backendModule.SpecialErrorAsset
}
default: {
return
}
}
}
/** Data for a specific asset. */
export function useAssetPassiveListenerStrict(
backendType: BackendType,
assetId: AssetId | null | undefined,
parentId: DirectoryId | null | undefined,
category: Category,
) {
const asset = useAssetPassiveListener(backendType, assetId, parentId, category)
invariant(asset, 'Asset not found')
return asset
}
/** Return matching in-flight mutations */
export function useBackendMutationState<Method extends MutationMethod, Result>(
backend: Backend,
method: Method,
options: {
mutationKey?: MutationKey
predicate?: (mutation: BackendMutation<Method>) => boolean
select?: (mutation: BackendMutation<Method>) => Result
} = {},
) {
const { mutationKey, predicate, select } = options
return useMutationState({
filters: {
...backendMutationOptions(backend, method, mutationKey ? { mutationKey } : {}),
predicate: (mutation: BackendMutation<Method>) =>
mutation.state.status === 'pending' && (predicate?.(mutation) ?? true),
},
// This is UNSAFE when the `Result` parameter is explicitly specified in the
// generic parameter list.
// eslint-disable-next-line no-restricted-syntax
select: select as (mutation: Mutation<unknown, Error, unknown, unknown>) => Result,
})
}
/** Options for {@link useUploadFileMutation}. */
export interface UploadFileMutationOptions {
/**
* Defaults to 3.
* Controls the default value of {@link UploadFileMutationOptions['chunkRetries']}
* and {@link UploadFileMutationOptions['endRetries']}.
*/
readonly retries?: number
/** Defaults to {@link UploadFileMutationOptions['retries']}. */
readonly chunkRetries?: number
/** Defaults to {@link UploadFileMutationOptions['retries']}. */
readonly endRetries?: number
/** Called for all progress updates (`onBegin`, `onChunkSuccess` and `onSuccess`). */
readonly onProgress?: (progress: UploadFileMutationProgress) => void
/** Called before any mutations are sent. */
readonly onBegin?: (progress: UploadFileMutationProgress) => void
/** Called after each successful chunk upload mutation. */
readonly onChunkSuccess?: (progress: UploadFileMutationProgress) => void
/** Called after the entire mutation succeeds. */
readonly onSuccess?: (progress: UploadFileMutationProgress) => void
/** Called after any mutations fail. */
readonly onError?: (error: unknown) => void
/** Called after `onSuccess` or `onError`, depending on whether the mutation succeeded. */
readonly onSettled?: (progress: UploadFileMutationProgress | null, error: unknown) => void
}
/**
* Call "upload file" mutations for a file.
* Always uses multipart upload for Cloud backend.
* Shows toasts to update progress.
*/
export function useUploadFileWithToastMutation(
backend: Backend,
options: UploadFileMutationOptions = {},
) {
const toastId = useId()
const { getText } = useText()
const toastAndLog = useToastAndLogWithId()
const { onBegin, onChunkSuccess, onSuccess, onError } = options
const mutation = useUploadFileMutation(backend, {
...options,
onBegin: (progress) => {
onBegin?.(progress)
const { sentMb, totalMb } = progress
toast.loading(getText('uploadLargeFileStatus', sentMb, totalMb), {
toastId,
position: 'bottom-right',
})
},
onChunkSuccess: (progress) => {
onChunkSuccess?.(progress)
const { sentMb, totalMb } = progress
const text = getText('uploadLargeFileStatus', sentMb, totalMb)
toast.update(toastId, { render: text })
},
onSuccess: (progress) => {
onSuccess?.(progress)
toast.update(toastId, {
type: 'success',
render: getText('uploadLargeFileSuccess'),
isLoading: false,
autoClose: null,
})
},
onError: (error) => {
onError?.(error)
toastAndLog(toastId, 'uploadLargeFileError', error)
},
})
usePreventNavigation({ message: getText('anUploadIsInProgress'), isEnabled: mutation.isPending })
return mutation
}
/**
* Call "upload file" mutations for a file.
* Always uses multipart upload for Cloud backend.
*/
export function useUploadFileMutation(backend: Backend, options: UploadFileMutationOptions = {}) {
const toastAndLog = useToastAndLog()
const {
retries = 3,
chunkRetries = retries,
endRetries = retries,
onError = (error) => {
toastAndLog('uploadLargeFileError', error)
},
} = options
const uploadFileStartMutation = useMutation(backendMutationOptions(backend, 'uploadFileStart'))
const uploadFileChunkMutation = useMutation(
backendMutationOptions(backend, 'uploadFileChunk', { retry: chunkRetries }),
)
const uploadFileEndMutation = useMutation(
backendMutationOptions(backend, 'uploadFileEnd', { retry: endRetries }),
)
const [variables, setVariables] =
useState<[params: backendModule.UploadFileRequestParams, file: File]>()
const [sentMb, setSentMb] = useState(0)
const [totalMb, setTotalMb] = useState(0)
const mutateAsync = useEventCallback(
async (body: backendModule.UploadFileRequestParams, file: File) => {
setVariables([body, file])
const fileSizeMb = Math.ceil(file.size / MB_BYTES)
options.onBegin?.({ event: 'begin', sentMb: 0, totalMb: fileSizeMb })
setSentMb(0)
setTotalMb(fileSizeMb)
try {
const { sourcePath, uploadId, presignedUrls } = await uploadFileStartMutation.mutateAsync([
body,
file,
])
const parts: backendModule.S3MultipartPart[] = []
for (const [url, i] of Array.from(
presignedUrls,
(presignedUrl, index) => [presignedUrl, index] as const,
)) {
parts.push(await uploadFileChunkMutation.mutateAsync([url, file, i]))
const newSentMb = Math.min((i + 1) * S3_CHUNK_SIZE_MB, fileSizeMb)
setSentMb(newSentMb)
options.onChunkSuccess?.({
event: 'chunk',
sentMb: newSentMb,
totalMb: fileSizeMb,
})
}
const result = await uploadFileEndMutation.mutateAsync([
{
parentDirectoryId: body.parentDirectoryId,
parts,
sourcePath: sourcePath,
uploadId: uploadId,
assetId: body.fileId,
fileName: body.fileName,
},
])
setSentMb(fileSizeMb)
const progress: UploadFileMutationProgress = {
event: 'end',
sentMb: fileSizeMb,
totalMb: fileSizeMb,
}
options.onSuccess?.(progress)
options.onSettled?.(progress, null)
return result
} catch (error) {
onError(error)
options.onSettled?.(null, error)
throw error
}
},
)
const mutate = useEventCallback((params: backendModule.UploadFileRequestParams, file: File) => {
void mutateAsync(params, file)
})
return {
sentMb,
totalMb,
variables,
mutate,
mutateAsync,
context: uploadFileEndMutation.context,
data: uploadFileEndMutation.data,
failureCount:
uploadFileEndMutation.failureCount +
uploadFileChunkMutation.failureCount +
uploadFileStartMutation.failureCount,
failureReason:
uploadFileEndMutation.failureReason ??
uploadFileChunkMutation.failureReason ??
uploadFileStartMutation.failureReason,
isError:
uploadFileStartMutation.isError ||
uploadFileChunkMutation.isError ||
uploadFileEndMutation.isError,
error:
uploadFileEndMutation.error ?? uploadFileChunkMutation.error ?? uploadFileStartMutation.error,
isPaused:
uploadFileStartMutation.isPaused ||
uploadFileChunkMutation.isPaused ||
uploadFileEndMutation.isPaused,
isPending:
uploadFileStartMutation.isPending ||
uploadFileChunkMutation.isPending ||
uploadFileEndMutation.isPending,
isSuccess: uploadFileEndMutation.isSuccess,
}
}

File diff suppressed because it is too large Load Diff

View File

@ -5,22 +5,21 @@
*/
import * as React from 'react'
import * as callbackHooks from './eventCallbackHooks'
import * as unmountEffect from './unmountHooks'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'
/** Wrap a callback into debounce function */
export function useDebouncedCallback<Fn extends (...args: never[]) => unknown>(
callback: Fn,
deps: React.DependencyList,
delay: number,
maxWait = 0,
maxWait: number | null = null,
): DebouncedFunction<Fn> {
const stableCallback = callbackHooks.useEventCallback(callback)
const stableCallback = useEventCallback(callback)
const timeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>()
const waitTimeoutIdRef = React.useRef<ReturnType<typeof setTimeout>>()
const lastCallRef = React.useRef<{ args: Parameters<Fn> }>()
const clear = () => {
const clear = useEventCallback(() => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
timeoutIdRef.current = undefined
@ -30,53 +29,50 @@ export function useDebouncedCallback<Fn extends (...args: never[]) => unknown>(
clearTimeout(waitTimeoutIdRef.current)
waitTimeoutIdRef.current = undefined
}
}
})
const execute = useEventCallback(() => {
if (!lastCallRef.current) {
return
}
const context = lastCallRef.current
lastCallRef.current = undefined
stableCallback(...context.args)
clear()
})
const wrapped = useEventCallback((...args: Parameters<Fn>) => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
}
lastCallRef.current = { args }
if (delay === 0) {
execute()
} else {
// plan regular execution
timeoutIdRef.current = setTimeout(execute, delay)
// plan maxWait execution if required
if (maxWait != null && !waitTimeoutIdRef.current) {
waitTimeoutIdRef.current = setTimeout(execute, maxWait)
}
}
})
Object.defineProperties(wrapped, {
length: { value: stableCallback.length },
name: { value: `${stableCallback.name || 'anonymous'}__debounced__${delay}` },
})
// cancel scheduled execution on unmount
unmountEffect.useUnmount(clear)
useUnmount(clear)
return React.useMemo(() => {
const execute = () => {
if (!lastCallRef.current) {
return
}
const context = lastCallRef.current
lastCallRef.current = undefined
stableCallback(...context.args)
clear()
}
const wrapped = (...args: Parameters<Fn>) => {
if (timeoutIdRef.current) {
clearTimeout(timeoutIdRef.current)
}
lastCallRef.current = { args }
if (delay === 0) {
execute()
} else {
// plan regular execution
timeoutIdRef.current = setTimeout(execute, delay)
// plan maxWait execution if required
if (maxWait > 0 && !waitTimeoutIdRef.current) {
waitTimeoutIdRef.current = setTimeout(execute, maxWait)
}
}
}
Object.defineProperties(wrapped, {
length: { value: stableCallback.length },
name: { value: `${stableCallback.name || 'anonymous'}__debounced__${delay}` },
})
return wrapped
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stableCallback, delay, maxWait, ...deps])
return wrapped
}
/** The type of a wrapped function that has been debounced. */

View File

@ -7,7 +7,7 @@
import * as React from 'react'
import * as debouncedCallback from './debounceCallbackHooks'
import * as eventCallbackHooks from './eventCallbackHooks'
import { useEventCallback } from './eventCallbackHooks'
/** A hook that returns a stateful value, and a function to update it that will debounce updates. */
export function useDebounceState<S>(
@ -17,21 +17,20 @@ export function useDebounceState<S>(
): [S, React.Dispatch<React.SetStateAction<S>>] {
const [state, setState] = React.useState(initialState)
const currentValueRef = React.useRef(state)
const [, startTransition] = React.useTransition()
const debouncedSetState = debouncedCallback.useDebouncedCallback<
React.Dispatch<React.SetStateAction<S>>
>(
(value) => {
startTransition(() => {
React.startTransition(() => {
setState(value)
})
},
[],
delay,
maxWait,
)
const setValue = eventCallbackHooks.useEventCallback((next: S | ((currentValue: S) => S)) => {
const setValue = useEventCallback((next: S | ((currentValue: S) => S)) => {
currentValueRef.current = next instanceof Function ? next(currentValueRef.current) : next
debouncedSetState(currentValueRef.current)

View File

@ -0,0 +1,259 @@
/**
* @file
*
* This file contains the useMeasure hook, which is used to measure the size and position of an element.
*/
import { frame } from 'framer-motion'
import { startTransition, useEffect, useRef, useState } from 'react'
import { unsafeMutable } from '../utilities/object'
import { useDebouncedCallback } from './debounceCallbackHooks'
import { useEventCallback } from './eventCallbackHooks'
import { useUnmount } from './unmountHooks'
/**
* A read-only version of the DOMRect object.
*/
export interface RectReadOnly {
readonly x: number
readonly y: number
readonly width: number
readonly height: number
readonly top: number
readonly right: number
readonly bottom: number
readonly left: number
}
/**
* A type that represents an HTML or SVG element.
*/
type HTMLOrSVGElement = HTMLElement | SVGElement
/**
* A type that represents the result of the useMeasure hook.
*/
type Result = [(element: HTMLOrSVGElement | null) => void, RectReadOnly, () => void]
/**
* A type that represents the state of the useMeasure hook.
*/
interface State {
readonly element: HTMLOrSVGElement | null
readonly scrollContainers: HTMLOrSVGElement[] | null
readonly lastBounds: RectReadOnly
}
/**
* A type that represents the options for the useMeasure hook.
*/
export interface Options {
readonly debounce?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
readonly scroll?: boolean
readonly offsetSize?: boolean
readonly onResize?: (bounds: RectReadOnly) => void
readonly maxWait?:
| number
| { readonly scroll: number; readonly resize: number; readonly frame: number }
}
/**
* Custom hook to measure the size and position of an element
*/
export function useMeasure(options: Options = {}): Result {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const { debounce = 0, scroll = false, offsetSize = false, onResize, maxWait = 500 } = options
const [bounds, set] = useState<RectReadOnly>(() => ({
left: 0,
top: 0,
width: 0,
height: 0,
bottom: 0,
right: 0,
x: 0,
y: 0,
}))
// keep all state in a ref
const state = useRef<State>({
element: null,
scrollContainers: null,
lastBounds: bounds,
})
const scrollMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.scroll
const resizeMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.resize
const frameMaxWait = typeof maxWait === 'number' ? maxWait : maxWait.frame
// set actual debounce values early, so effects know if they should react accordingly
const scrollDebounce = typeof debounce === 'number' ? debounce : debounce.scroll
const resizeDebounce = typeof debounce === 'number' ? debounce : debounce.resize
const frameDebounce = typeof debounce === 'number' ? debounce : debounce.frame
// make sure to update state only as long as the component is truly mounted
const mounted = useRef(false)
useUnmount(() => {
mounted.current = false
})
const callback = useEventCallback(() => {
frame.read(() => {
if (!state.current.element) return
const { left, top, width, height, bottom, right, x, y } =
state.current.element.getBoundingClientRect()
const size = { left, top, width, height, bottom, right, x, y }
if (state.current.element instanceof HTMLElement && offsetSize) {
size.height = state.current.element.offsetHeight
size.width = state.current.element.offsetWidth
}
if (mounted.current && !areBoundsEqual(state.current.lastBounds, size)) {
startTransition(() => {
set((unsafeMutable(state.current).lastBounds = size))
onResize?.(size)
})
}
})
})
const [resizeObserver] = useState(() => new ResizeObserver(callback))
const [mutationObserver] = useState(() => new MutationObserver(callback))
const frameDebounceCallback = useDebouncedCallback(callback, frameDebounce, frameMaxWait)
const resizeDebounceCallback = useDebouncedCallback(callback, resizeDebounce, resizeMaxWait)
const scrollDebounceCallback = useDebouncedCallback(callback, scrollDebounce, scrollMaxWait)
const forceRefresh = useDebouncedCallback(callback, 0)
// cleanup current scroll-listeners / observers
const removeListeners = useEventCallback(() => {
if (state.current.scrollContainers) {
state.current.scrollContainers.forEach((element) => {
element.removeEventListener('scroll', scrollDebounceCallback, true)
})
unsafeMutable(state.current).scrollContainers = null
}
resizeObserver.disconnect()
mutationObserver.disconnect()
})
const addListeners = useEventCallback(() => {
if (!state.current.element) return
resizeObserver.observe(state.current.element)
mutationObserver.observe(state.current.element, {
attributes: true,
attributeFilter: ['style', 'class'],
})
frame.read(frameDebounceCallback, true)
if (scroll && state.current.scrollContainers) {
state.current.scrollContainers.forEach((scrollContainer) => {
scrollContainer.addEventListener('scroll', scrollDebounceCallback, {
capture: true,
passive: true,
})
})
}
})
// the ref we expose to the user
const ref = useEventCallback((node: HTMLOrSVGElement | null) => {
mounted.current = node != null
if (!node || node === state.current.element) return
removeListeners()
unsafeMutable(state.current).element = node
unsafeMutable(state.current).scrollContainers = findScrollContainers(node)
addListeners()
})
// add general event listeners
useOnWindowScroll(scrollDebounceCallback, Boolean(scroll))
useOnWindowResize(resizeDebounceCallback)
// respond to changes that are relevant for the listeners
useEffect(() => {
removeListeners()
addListeners()
}, [scroll, scrollDebounceCallback, resizeDebounceCallback, removeListeners, addListeners])
useUnmount(removeListeners)
return [ref, bounds, forceRefresh]
}
/**
* Adds a window resize event listener
*/
function useOnWindowResize(onWindowResize: (event: Event) => void) {
useEffect(() => {
const cb = onWindowResize
window.addEventListener('resize', cb)
return () => {
window.removeEventListener('resize', cb)
}
}, [onWindowResize])
}
/**
* Adds a window scroll event listener
*/
function useOnWindowScroll(onScroll: () => void, enabled: boolean) {
useEffect(() => {
if (enabled) {
const cb = onScroll
window.addEventListener('scroll', cb, { capture: true, passive: true })
return () => {
window.removeEventListener('scroll', cb, true)
}
}
}, [onScroll, enabled])
}
// Returns a list of scroll offsets
/**
* Finds all scroll containers that have overflow set to 'auto' or 'scroll'
* @param element - The element to start searching from
* @returns An array of scroll containers
*/
function findScrollContainers(element: HTMLOrSVGElement | null): HTMLOrSVGElement[] {
const result: HTMLOrSVGElement[] = []
if (!element || element === document.body) return result
const { overflow, overflowX, overflowY } = window.getComputedStyle(element)
if ([overflow, overflowX, overflowY].some((prop) => prop === 'auto' || prop === 'scroll')) {
result.push(element)
}
return [...result, ...findScrollContainers(element.parentElement)]
}
// Checks if element boundaries are equal
const RECT_KEYS: readonly (keyof RectReadOnly)[] = [
'x',
'y',
'top',
'bottom',
'left',
'right',
'width',
'height',
]
/**
* Compares two RectReadOnly objects to check if their boundaries are equal
* @param a - First RectReadOnly object
* @param b - Second RectReadOnly object
* @returns boolean indicating whether the boundaries are equal
*/
function areBoundsEqual(a: RectReadOnly, b: RectReadOnly): boolean {
return RECT_KEYS.every((key) => a[key] === b[key])
}

View File

@ -1,6 +1,4 @@
/** @file Mutations related to project management. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
@ -22,9 +20,8 @@ import {
} from '#/providers/ProjectsProvider'
import { useFeatureFlag } from '#/providers/FeatureFlagsProvider'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
// ====================================
// === createGetProjectDetailsQuery ===
@ -44,12 +41,9 @@ const ACTIVE_SYNC_INTERVAL_MS = 100
/** Options for {@link createGetProjectDetailsQuery}. */
export interface CreateOpenedProjectQueryOptions {
readonly type: backendModule.BackendType
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
readonly remoteBackend: RemoteBackend
readonly localBackend: LocalBackend | null
readonly backend: Backend
}
/** Return a function to update a project asset in the TanStack Query cache. */
@ -84,15 +78,18 @@ function useSetProjectAsset() {
/** Project status query. */
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
const { assetId, parentId, backend } = options
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
const isLocal = type === backendModule.BackendType.local
const isLocal = backend.type === backendModule.BackendType.local
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
queryFn: () => backend.getProjectDetails(assetId, parentId),
meta: { persist: false },
gcTime: 0,
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
networkMode: backend.type === backendModule.BackendType.remote ? 'online' : 'always',
refetchInterval: ({ state }) => {
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
@ -115,22 +112,9 @@ export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOp
}
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
queryFn: async () => {
invariant(backend != null, 'Backend is null')
return await backend.getProjectDetails(assetId, parentId, title)
},
})
}
createGetProjectDetailsQuery.getQueryKey = (id: LaunchedProjectId) => ['project', id] as const
createGetProjectDetailsQuery.createPassiveListener = (id: LaunchedProjectId) =>
reactQuery.queryOptions<backendModule.Project | null>({
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
initialData: null,
})
// ==============================
// === useOpenProjectMutation ===
@ -302,6 +286,7 @@ export function useOpenProject() {
predicate: (mutation) => mutation.options.scope?.id === project.id,
})
const isOpeningTheSameProject = existingMutation?.state.status === 'pending'
if (!isOpeningTheSameProject) {
openProjectMutation.mutate(project)
const openingProjectMutation = client.getMutationCache().find({
@ -327,9 +312,7 @@ export function useOpenProject() {
export function useOpenEditor() {
const setPage = useSetPage()
return eventCallbacks.useEventCallback((projectId: LaunchedProjectId) => {
React.startTransition(() => {
setPage(projectId)
})
setPage(projectId)
})
}
@ -342,7 +325,6 @@ export function useCloseProject() {
const client = reactQuery.useQueryClient()
const closeProjectMutation = useCloseProjectMutation()
const removeLaunchedProject = useRemoveLaunchedProject()
const projectsStore = useProjectsStore()
const setPage = useSetPage()
return eventCallbacks.useEventCallback((project: LaunchedProject) => {
@ -356,7 +338,9 @@ export function useCloseProject() {
mutation.setOptions({ ...mutation.options, retry: false })
mutation.destroy()
})
closeProjectMutation.mutate(project)
client
.getMutationCache()
.findAll({
@ -368,13 +352,10 @@ export function useCloseProject() {
.forEach((mutation) => {
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
})
removeLaunchedProject(project.id)
// There is no shared enum type, but the other union member is the same type.
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (projectsStore.getState().page === project.id) {
setPage(TabType.drive)
}
setPage(TabType.drive)
})
}
@ -384,10 +365,13 @@ export function useCloseProject() {
/** A function to close all projects. */
export function useCloseAllProjects() {
const projectsStore = useProjectsStore()
const closeProject = useCloseProject()
const projectsStore = useProjectsStore()
return eventCallbacks.useEventCallback(() => {
for (const launchedProject of projectsStore.getState().launchedProjects) {
const launchedProjects = projectsStore.getState().launchedProjects
for (const launchedProject of launchedProjects) {
closeProject(launchedProject)
}
})

View File

@ -1,9 +1,11 @@
/** @file Hooks for showing an overlay with a cutout for a rectangular element. */
import { useEffect, useLayoutEffect, useState, type CSSProperties, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import { startTransition, useEffect, useLayoutEffect, useState, type CSSProperties } from 'react'
import { useDimensions } from '#/hooks/dimensionsHooks'
import Portal from '#/components/Portal'
import { convertCSSUnitString } from '#/utilities/convertCSSUnits'
import { useEventCallback } from './eventCallbackHooks'
import type { RectReadOnly } from './measureHooks'
import { useMeasure } from './measureHooks'
/** Default padding around the spotlight element. */
const DEFAULT_PADDING_PX = 8
@ -17,7 +19,6 @@ const BACKGROUND_ELEMENT = document.getElementsByClassName('enso-spotlight')[0]
/** Props for {@link useSpotlight}. */
export interface SpotlightOptions {
readonly enabled: boolean
readonly ref: RefObject<HTMLElement | SVGElement | null>
readonly close: () => void
readonly backgroundElement?: HTMLElement
readonly paddingPx?: number | undefined
@ -25,26 +26,36 @@ export interface SpotlightOptions {
/** A hook for showing an overlay with a cutout for a rectangular element. */
export function useSpotlight(options: SpotlightOptions) {
const { enabled, ref, close, backgroundElement: backgroundElementRaw } = options
const { enabled, close, backgroundElement: backgroundElementRaw } = options
const { paddingPx = DEFAULT_PADDING_PX } = options
const backgroundElement = backgroundElementRaw ?? BACKGROUND_ELEMENT
const [refElement, setRefElement] = useState<HTMLElement | SVGElement | null>(null)
const refCallback = useEventCallback((node: HTMLElement | SVGElement | null) => {
if (node) {
setRefElement(node)
} else {
setRefElement(null)
}
})
const spotlightElement =
!enabled || !backgroundElement ?
null
: <Spotlight
close={close}
element={ref}
element={refElement}
backgroundElement={backgroundElement}
paddingPx={paddingPx}
/>
const style = { position: 'relative', zIndex: 3 } satisfies CSSProperties
return { spotlightElement, props: { style } }
return { spotlightElement, props: { style, ref: refCallback } }
}
/** Props for a {@link Spotlight}. */
interface SpotlightProps {
readonly element: RefObject<HTMLElement | SVGElement | null>
readonly element: HTMLElement | SVGElement | null
readonly close: () => void
readonly backgroundElement: HTMLElement | SVGElement
readonly paddingPx?: number | undefined
@ -52,28 +63,45 @@ interface SpotlightProps {
/** A spotlight element. */
function Spotlight(props: SpotlightProps) {
const { element, close, backgroundElement, paddingPx = 0 } = props
const [dimensionsRef, { top: topRaw, left: leftRaw, height, width }] = useDimensions()
const top = topRaw - paddingPx
const left = leftRaw - paddingPx
const { element, close, paddingPx = 0 } = props
const [bounds, setBounds] = useState<RectReadOnly>()
const [borderRadius, setBorderRadius] = useState(0)
const r = Math.min(borderRadius, height / 2 + paddingPx, width / 2 + paddingPx)
const straightWidth = Math.max(0, width + paddingPx * 2 - borderRadius * 2)
const straightHeight = Math.max(0, height + paddingPx * 2 - borderRadius * 2)
const [dimensionsRef] = useMeasure({
onResize: (nextBounds) => {
startTransition(() => {
setBounds(nextBounds)
})
},
})
useEffect(() => {
if (element.current) {
dimensionsRef(element.current)
if (element) {
dimensionsRef(element)
}
}, [dimensionsRef, element])
useLayoutEffect(() => {
if (element.current) {
const sizeString = getComputedStyle(element.current).borderRadius
setBorderRadius(convertCSSUnitString(sizeString, 'px', element.current).number)
if (element) {
const sizeString = getComputedStyle(element).borderRadius
setBorderRadius(convertCSSUnitString(sizeString, 'px', element).number)
}
}, [element])
if (!bounds) {
return null
}
const { top: topRaw, left: leftRaw, height, width } = bounds
const top = topRaw - paddingPx
const left = leftRaw - paddingPx
const r = Math.min(borderRadius, height / 2 + paddingPx, width / 2 + paddingPx)
const straightWidth = Math.max(0, width + paddingPx * 2 - borderRadius * 2)
const straightHeight = Math.max(0, height + paddingPx * 2 - borderRadius * 2)
const clipPath =
// A rectangle covering the entire screen
'path(evenodd, "M0 0L3840 0 3840 2160 0 2160Z' +
@ -97,18 +125,13 @@ function Spotlight(props: SpotlightProps) {
(r !== 0 ? `a${r} ${r} 0 0 1 ${r} -${r}` : '') +
'Z")'
return createPortal(
<div
onClick={close}
style={{
position: 'absolute',
zIndex: 2,
height: '100vh',
width: '100vw',
backgroundColor: 'lch(0 0 0 / 25%)',
clipPath,
}}
/>,
backgroundElement,
return (
<Portal>
<div
onClick={close}
className="absolute inset-0 z-20 h-full w-full bg-primary/25 contain-strict"
style={{ clipPath }}
/>
</Portal>
)
}

View File

@ -28,9 +28,12 @@ import { Suspense } from '#/components/Suspense'
import UIProviders from '#/components/UIProviders'
import HttpClient from '#/utilities/HttpClient'
import { MotionGlobalConfig } from 'framer-motion'
export type { GraphEditorRunner } from '#/layouts/Editor'
MotionGlobalConfig.skipAnimations = window.DISABLE_ANIMATIONS ?? false
// =================
// === Constants ===
// =================

View File

@ -35,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import { useUploadFileWithToastMutation } from '#/hooks/backendHooks'
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
import {
usePasteData,
useSetAssetPanelProps,
@ -105,6 +105,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
const isUnderPaywall = isFeatureUnderPaywall('share')
const newProject = useNewProject(backend, category)
const systemApi = window.systemApi
const ownsThisAsset = !isCloud || self?.permission === permissions.PermissionAction.own
const managesThisAsset = ownsThisAsset || self?.permission === permissions.PermissionAction.admin
@ -145,11 +147,17 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
}
})
const { data } = reactQuery.useQuery(
asset.type === backendModule.AssetType.project ?
projectHooks.createGetProjectDetailsQuery.createPassiveListener(asset.id)
: { queryKey: ['__IGNORED__'] },
)
const { data } = reactQuery.useQuery({
...projectHooks.createGetProjectDetailsQuery({
// This is safe because we disable the query when the asset is not a project.
// see `enabled` property below.
// eslint-disable-next-line no-restricted-syntax
assetId: asset.id as backendModule.ProjectId,
parentId: asset.parentId,
backend,
}),
enabled: asset.type === backendModule.AssetType.project,
})
const isRunningProject =
(asset.type === backendModule.AssetType.project &&
@ -219,14 +227,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
hidden={hidden}
action="useInNewProject"
doAction={() => {
dispatchAssetListEvent({
type: AssetListEventType.newProject,
parentId: asset.parentId,
parentKey: asset.parentId,
templateId: null,
datalinkId: asset.id,
preferredName: asset.title,
})
void newProject(
{ templateName: asset.title, datalinkId: asset.id },
asset.parentId,
path,
)
}}
/>
)}
@ -507,9 +512,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
<GlobalContextMenu
hidden={hidden}
backend={backend}
category={category}
rootDirectoryId={rootDirectoryId}
directoryKey={asset.id}
directoryId={asset.id}
path={path}
doPaste={doPaste}
/>
)}

View File

@ -1,13 +1,11 @@
/** @file Diff view comparing `Main.enso` of two versions for a specific project. */
import * as monacoReact from '@monaco-editor/react'
import * as textProvider from '#/providers/TextProvider'
import Spinner, * as spinnerModule from '#/components/Spinner'
import { DiffEditor } from '@monaco-editor/react'
import { useSuspenseQueries } from '@tanstack/react-query'
import { Spinner } from '#/components/Spinner'
import { versionContentQueryOptions } from '#/layouts/AssetDiffView/useFetchVersionContent'
import type * as backendService from '#/services/Backend'
import type Backend from '#/services/Backend'
import { useFetchVersionContent } from './useFetchVersionContent'
// =====================
// === AssetDiffView ===
@ -15,8 +13,8 @@ import { useFetchVersionContent } from './useFetchVersionContent'
/** Props for an {@link AssetDiffView}. */
export interface AssetDiffViewProps {
readonly versionId: string
readonly latestVersionId: string
readonly versionId: backendService.S3ObjectVersionId
readonly latestVersionId: backendService.S3ObjectVersionId
readonly project: backendService.ProjectAsset
readonly backend: Backend
}
@ -24,49 +22,46 @@ export interface AssetDiffViewProps {
/** Diff view comparing `Main.enso` of two versions for a specific project. */
export function AssetDiffView(props: AssetDiffViewProps) {
const { versionId, project, backend, latestVersionId } = props
const { getText } = textProvider.useText()
const versionContent = useFetchVersionContent({
versionId,
project,
backend,
})
const headContent = useFetchVersionContent({
versionId: latestVersionId,
project,
backend,
const [versionContent, headContent] = useSuspenseQueries({
queries: [
versionContentQueryOptions({
versionId,
projectId: project.id,
backend,
}),
versionContentQueryOptions({
versionId: latestVersionId,
projectId: project.id,
backend,
}),
],
})
const loader = (
<div className="flex h-full w-full items-center justify-center">
<Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
<Spinner size={32} state="loading-medium" />
</div>
)
if (versionContent.isError || headContent.isError) {
return <div className="p-indent-8 text-center">{getText('loadFileError')}</div>
} else if (versionContent.isPending || headContent.isPending) {
return loader
} else {
return (
<monacoReact.DiffEditor
beforeMount={(monaco) => {
monaco.editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [],
// The name comes from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
colors: { 'editor.background': '#00000000' },
})
}}
original={versionContent.data}
modified={headContent.data}
language="enso"
options={{ readOnly: true }}
loading={loader}
theme={'myTheme'}
/>
)
}
return (
<DiffEditor
beforeMount={(monaco) => {
monaco.editor.defineTheme('myTheme', {
base: 'vs',
inherit: true,
rules: [],
// The name comes from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
colors: { 'editor.background': '#00000000' },
})
}}
original={versionContent.data}
modified={headContent.data}
language="enso"
options={{ readOnly: true }}
loading={loader}
theme={'myTheme'}
/>
)
}

View File

@ -4,6 +4,7 @@ import * as reactQuery from '@tanstack/react-query'
import type * as backendService from '#/services/Backend'
import type Backend from '#/services/Backend'
import { splitFileContents } from 'ydoc-shared/ensoFile'
// =================
// === Constants ===
@ -17,26 +18,41 @@ const TWO_MINUTES_MS = 120_000
/** Options for {@link useFetchVersionContent}. */
export interface FetchVersionContentOptions {
readonly project: backendService.ProjectAsset
readonly versionId: string
readonly projectId: backendService.ProjectId
readonly versionId?: backendService.S3ObjectVersionId
readonly backend: Backend
/** If `false`, the metadata is stripped out. Defaults to `false`. */
readonly metadata?: boolean
}
/** Fetch the content of a version. */
export function useFetchVersionContent(params: FetchVersionContentOptions) {
const { versionId, backend, project, metadata = false } = params
return reactQuery.useQuery({
queryKey: ['versionContent', versionId],
queryFn: () => backend.getFileContent(project.id, versionId, project.title),
select: (data) => (metadata ? data : omitMetadata(data)),
/**
* Return the query options for fetching the content of a version.
*/
export function versionContentQueryOptions(params: FetchVersionContentOptions) {
return reactQuery.queryOptions({
queryKey: [
params.backend.type,
{
method: 'getFileContent',
versionId: params.versionId,
projectId: params.projectId,
},
] as const,
queryFn: ({ queryKey }) => {
const [, { method, versionId, projectId }] = queryKey
return params.backend[method](projectId, versionId)
},
select: (data) => (params.metadata === true ? data : omitMetadata(data)),
staleTime: TWO_MINUTES_MS,
})
}
/** Fetch the content of a version. */
export function useFetchVersionContent(params: FetchVersionContentOptions) {
return reactQuery.useQuery(versionContentQueryOptions(params))
}
/** Remove the metadata from the content of a version. */
function omitMetadata(file: string): string {
return file.split('#### METADATA ####')[0]?.replace(/\n+$/, '') ?? file
return splitFileContents(file).code
}

View File

@ -0,0 +1,69 @@
/** @file Documentation display for an asset. */
import { MarkdownViewer } from '#/components/MarkdownViewer'
import { Result } from '#/components/Result'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import type { AnyAsset, Asset } from '#/services/Backend'
import { AssetType } from '#/services/Backend'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useCallback } from 'react'
import * as ast from 'ydoc-shared/ast'
import { splitFileContents } from 'ydoc-shared/ensoFile'
import { versionContentQueryOptions } from '../AssetDiffView/useFetchVersionContent'
/** Props for a {@link AssetDocs}. */
export interface AssetDocsProps {
readonly backend: Backend
readonly item: AnyAsset | null
}
/** Documentation display for an asset. */
export function AssetDocs(props: AssetDocsProps) {
const { backend, item } = props
const { getText } = useText()
if (item?.type !== AssetType.project) {
return <Result status="info" title={getText('assetDocs.notProject')} centered />
}
return <AssetDocsContent backend={backend} item={item} />
}
/** Props for an {@link AssetDocsContent}. */
interface AssetDocsContentProps {
readonly backend: Backend
readonly item: Asset<AssetType.project>
}
/** Documentation display for an asset. */
export function AssetDocsContent(props: AssetDocsContentProps) {
const { backend, item } = props
const { getText } = useText()
const { data: docs } = useSuspenseQuery({
...versionContentQueryOptions({ backend, projectId: item.id, metadata: false }),
select: (data) => {
const withoutMeta = splitFileContents(data)
const module = ast.parseModule(withoutMeta.code)
for (const statement of module.statements()) {
if (statement instanceof ast.MutableFunctionDef && statement.name.code() === 'main') {
return statement.documentationText() ?? ''
}
}
return ''
},
})
const resolveProjectAssetPath = useCallback(
(relativePath: string) => backend.resolveProjectAssetPath(item.id, relativePath),
[backend, item.id],
)
if (!docs) {
return <Result status="info" title={getText('assetDocs.noDocs')} centered />
}
return <MarkdownViewer text={docs} imgUrlResolver={resolveProjectAssetPath} />
}

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrels for the `AssetDocs`.
*/
export { AssetDocs } from './AssetDocs'

View File

@ -1,182 +0,0 @@
/** @file A panel containing the description and settings for an asset. */
import { useEffect, useRef, useState } from 'react'
import * as z from 'zod'
import { TabPanel, Tabs } from '#/components/aria'
import ProjectSessions from '#/layouts/AssetProjectSessions'
import AssetProperties, { type AssetPropertiesSpotlight } from '#/layouts/AssetProperties'
import AssetVersions from '#/layouts/AssetVersions/AssetVersions'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import TabBar, { Tab } from '#/layouts/TabBar'
import { useAssetPanelProps, useIsAssetPanelVisible } from '#/providers/DriveProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import { AssetType, BackendType, type AnyAsset } from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import { twMerge } from '#/utilities/tailwindMerge'
// =====================
// === AssetPanelTab ===
// =====================
const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules'] as const
const TABS_SCHEMA = z.enum(ASSET_PANEL_TABS)
/** Determines the content of the {@link AssetPanel}. */
type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly assetPanelTab: AssetPanelTab
readonly assetPanelWidth: number
}
}
LocalStorage.register({
assetPanelTab: { schema: z.enum(ASSET_PANEL_TABS) },
assetPanelWidth: { schema: z.number().int() },
})
// ==================
// === AssetPanel ===
// ==================
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly item: AnyAsset | null
readonly path: string | null
readonly spotlightOn?: AssetPropertiesSpotlight
}
/** Props for an {@link AssetPanel}. */
export interface AssetPanelProps {
readonly backendType: BackendType
readonly category: Category
}
/** A panel containing the description and settings for an asset. */
export default function AssetPanel(props: AssetPanelProps) {
const { backendType, category } = props
const contextPropsRaw = useAssetPanelProps()
const contextProps = backendType === contextPropsRaw?.backend?.type ? contextPropsRaw : null
const { backend, item, path } = contextProps ?? {}
const isReadonly = category.type === 'trash'
const isCloud = backend?.type === BackendType.remote
const isVisible = useIsAssetPanelVisible()
const { getText } = useText()
const { localStorage } = useLocalStorage()
const [initialized, setInitialized] = useState(false)
const initializedRef = useRef(initialized)
initializedRef.current = initialized
const [tabRaw, setTab] = useState(() => localStorage.get('assetPanelTab') ?? 'settings')
const tab = (() => {
if (!isCloud) {
return 'settings'
} else if (
(item?.type === AssetType.secret || item?.type === AssetType.directory) &&
tabRaw === 'versions'
) {
return 'settings'
} else if (item?.type !== AssetType.project && tabRaw === 'sessions') {
return 'settings'
} else {
return tabRaw
}
})()
useEffect(() => {
// This prevents secrets and directories always setting the tab to `properties`
// (because they do not support the `versions` tab).
if (initializedRef.current) {
localStorage.set('assetPanelTab', tabRaw)
}
}, [tabRaw, localStorage])
useEffect(() => {
setInitialized(true)
}, [])
return (
<div
className={twMerge(
'flex flex-col overflow-hidden transition-min-width duration-side-panel ease-in-out',
isVisible ? 'min-w-side-panel' : 'min-w-0',
)}
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<Tabs
data-testid="asset-panel"
className={twMerge(
'absolute flex h-full w-asset-panel flex-col bg-invert transition-[box-shadow] clip-path-left-shadow',
isVisible ? 'shadow-softer' : '',
)}
selectedKey={tab}
onSelectionChange={(newPage) => {
const validated = TABS_SCHEMA.safeParse(newPage)
if (validated.success) {
setTab(validated.data)
}
}}
>
{item == null || backend == null || path == null ?
<div className="grid grow place-items-center text-lg">
{getText('selectExactlyOneAssetToViewItsDetails')}
</div>
: <>
<div className="h-4 bg-primary/5" />
<TabBar className="grow-0">
<Tab id="settings" labelId="settings" isActive={tab === 'settings'} icon={null}>
{getText('settings')}
</Tab>
{isCloud && item.type !== AssetType.secret && item.type !== AssetType.directory && (
<Tab id="versions" labelId="versions" isActive={tab === 'versions'} icon={null}>
{getText('versions')}
</Tab>
)}
{isCloud && item.type === AssetType.project && (
<Tab
id="sessions"
labelId="projectSessions"
isActive={tab === 'sessions'}
icon={null}
>
{getText('projectSessions')}
</Tab>
)}
</TabBar>
<TabPanel id="settings" className="p-4 pl-asset-panel-l">
<AssetProperties
backend={backend}
isReadonly={isReadonly}
item={item}
path={path}
category={category}
spotlightOn={contextProps?.spotlightOn}
/>
</TabPanel>
<TabPanel id="versions" className="p-4 pl-asset-panel-l">
<AssetVersions backend={backend} item={item} />
</TabPanel>
{item.type === AssetType.project && (
<TabPanel id="sessions" className="p-4 pl-asset-panel-l">
<ProjectSessions backend={backend} item={item} />
</TabPanel>
)}
</>
}
</Tabs>
</div>
)
}

View File

@ -0,0 +1,267 @@
/**
* @file
* @description
* The asset panel is a sidebar that can be expanded or collapsed.
* It is used to view and interact with assets in the drive.
*/
import docsIcon from '#/assets/file_text.svg'
import sessionsIcon from '#/assets/group.svg'
import inspectIcon from '#/assets/inspect.svg'
import versionsIcon from '#/assets/versions.svg'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useBackend } from '#/providers/BackendProvider'
import {
useAssetPanelProps,
useAssetPanelSelectedTab,
useIsAssetPanelExpanded,
useIsAssetPanelHidden,
useSetAssetPanelSelectedTab,
useSetIsAssetPanelExpanded,
} from '#/providers/DriveProvider'
import { useText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import LocalStorage from '#/utilities/LocalStorage'
import type { AnyAsset, BackendType } from 'enso-common/src/services/Backend'
import type { Spring } from 'framer-motion'
import { AnimatePresence, motion } from 'framer-motion'
import { memo, startTransition } from 'react'
import { z } from 'zod'
import { AssetDocs } from '../AssetDocs'
import AssetProjectSessions from '../AssetProjectSessions'
import type { AssetPropertiesSpotlight } from '../AssetProperties'
import AssetProperties from '../AssetProperties'
import AssetVersions from '../AssetVersions/AssetVersions'
import type { Category } from '../CategorySwitcher/Category'
import { AssetPanelTabs } from './components/AssetPanelTabs'
import { AssetPanelToggle } from './components/AssetPanelToggle'
const ASSET_SIDEBAR_COLLAPSED_WIDTH = 48
const ASSET_PANEL_WIDTH = 480
const ASSET_PANEL_TOTAL_WIDTH = ASSET_PANEL_WIDTH + ASSET_SIDEBAR_COLLAPSED_WIDTH
/** Determines the content of the {@link AssetPanel}. */
const ASSET_PANEL_TABS = ['settings', 'versions', 'sessions', 'schedules', 'docs'] as const
/** Determines the content of the {@link AssetPanel}. */
type AssetPanelTab = (typeof ASSET_PANEL_TABS)[number]
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly isAssetPanelHidden: boolean
readonly assetPanelTab: AssetPanelTab
readonly assetPanelWidth: number
}
}
const ASSET_PANEL_TAB_SCHEMA = z.enum(ASSET_PANEL_TABS)
LocalStorage.register({
assetPanelTab: { schema: ASSET_PANEL_TAB_SCHEMA },
assetPanelWidth: { schema: z.number().int() },
isAssetPanelHidden: { schema: z.boolean() },
isAssetPanelVisible: { schema: z.boolean() },
})
/** Props supplied by the row. */
export interface AssetPanelContextProps {
readonly backend: Backend | null
readonly selectedTab: AssetPanelTab
readonly item: AnyAsset | null
readonly path: string | null
readonly spotlightOn: AssetPropertiesSpotlight | null
}
/**
* Props for an {@link AssetPanel}.
*/
export interface AssetPanelProps {
readonly backendType: BackendType
readonly category: Category
}
const DEFAULT_TRANSITION_OPTIONS: Spring = {
type: 'spring',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
stiffness: 200,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
damping: 30,
mass: 1,
velocity: 0,
}
/**
* The asset panel is a sidebar that can be expanded or collapsed.
* It is used to view and interact with assets in the drive.
*/
export function AssetPanel(props: AssetPanelProps) {
const isHidden = useIsAssetPanelHidden()
const isExpanded = useIsAssetPanelExpanded()
const panelWidth = isExpanded ? ASSET_PANEL_TOTAL_WIDTH : ASSET_SIDEBAR_COLLAPSED_WIDTH
const isVisible = !isHidden
return (
<AnimatePresence initial={!isVisible} mode="sync">
{isVisible && (
<motion.div
data-testid="asset-panel"
initial="initial"
animate="animate"
exit="exit"
custom={panelWidth}
variants={{
initial: { opacity: 0, width: 0 },
animate: (width: number) => ({ opacity: 1, width }),
exit: { opacity: 0, width: 0 },
}}
transition={DEFAULT_TRANSITION_OPTIONS}
className="relative flex h-full flex-col shadow-softer clip-path-left-shadow"
onClick={(event) => {
// Prevent deselecting Assets Table rows.
event.stopPropagation()
}}
>
<InternalAssetPanelTabs {...props} />
</motion.div>
)}
</AnimatePresence>
)
}
/**
* The internal implementation of the Asset Panel Tabs.
*/
const InternalAssetPanelTabs = memo(function InternalAssetPanelTabs(props: AssetPanelProps) {
const { category } = props
const { item, spotlightOn, path } = useAssetPanelProps()
const selectedTab = useAssetPanelSelectedTab()
const setSelectedTab = useSetAssetPanelSelectedTab()
const isHidden = useIsAssetPanelHidden()
const isReadonly = category.type === 'trash'
const { getText } = useText()
const isExpanded = useIsAssetPanelExpanded()
const setIsExpanded = useSetIsAssetPanelExpanded()
const expandTab = useEventCallback(() => {
setIsExpanded(true)
})
const backend = useBackend(category)
return (
<AssetPanelTabs
className="h-full"
orientation="vertical"
selectedKey={selectedTab}
defaultSelectedKey={selectedTab}
onSelectionChange={(key) => {
if (isHidden) {
return
}
startTransition(() => {
if (key === selectedTab && isExpanded) {
setIsExpanded(false)
} else {
// This is safe because we know the key is a valid AssetPanelTab.
// eslint-disable-next-line no-restricted-syntax
setSelectedTab(key as AssetPanelTab)
setIsExpanded(true)
}
})
}}
>
<AnimatePresence initial={!isExpanded} mode="sync">
{isExpanded && (
<div
className="min-h-full"
// We use clipPath to prevent the sidebar from being visible under tabs while expanding.
style={{ clipPath: `inset(0 ${ASSET_SIDEBAR_COLLAPSED_WIDTH}px 0 0)` }}
>
<motion.div
initial={{ filter: 'blur(8px)' }}
animate={{ filter: 'blur(0px)' }}
exit={{ filter: 'blur(8px)' }}
transition={DEFAULT_TRANSITION_OPTIONS}
className="absolute left-0 top-0 h-full w-full bg-background"
style={{ width: ASSET_PANEL_WIDTH }}
>
<AssetPanelTabs.TabPanel id="settings" resetKeys={[item?.id]}>
<AssetProperties
backend={backend}
item={item}
isReadonly={isReadonly}
category={category}
spotlightOn={spotlightOn}
path={path}
/>
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="versions" resetKeys={[item?.id]}>
<AssetVersions backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="sessions" resetKeys={[item?.id]}>
<AssetProjectSessions backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
<AssetPanelTabs.TabPanel id="docs" resetKeys={[item?.id]}>
<AssetDocs backend={backend} item={item} />
</AssetPanelTabs.TabPanel>
</motion.div>
</div>
)}
</AnimatePresence>
<div
className="absolute bottom-0 right-0 top-0 pt-2.5"
style={{ width: ASSET_SIDEBAR_COLLAPSED_WIDTH }}
>
<AssetPanelToggle
showWhen="expanded"
className="flex aspect-square w-full items-center justify-center"
/>
<AssetPanelTabs.TabList>
<AssetPanelTabs.Tab
id="settings"
icon={inspectIcon}
label={getText('properties')}
isExpanded={isExpanded}
onPress={expandTab}
/>
<AssetPanelTabs.Tab
id="versions"
icon={versionsIcon}
label={getText('versions')}
isExpanded={isExpanded}
isDisabled={isHidden}
onPress={expandTab}
/>
<AssetPanelTabs.Tab
id="sessions"
icon={sessionsIcon}
label={getText('projectSessions')}
isExpanded={isExpanded}
onPress={expandTab}
/>
<AssetPanelTabs.Tab
id="docs"
icon={docsIcon}
label={getText('docs')}
isExpanded={isExpanded}
onPress={expandTab}
/>
</AssetPanelTabs.TabList>
</div>
</AssetPanelTabs>
)
})

Some files were not shown because too many files have changed in this diff Show More