Merge branch 'develop' into wip/akirathan/11326-more-mini-passes
1
.github/workflows/gui.yml
vendored
@ -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 }}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
7
LICENSE
@ -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.
|
||||
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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."
|
||||
}
|
||||
|
@ -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
@ -25,6 +25,7 @@ mockDist
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/
|
||||
|
||||
src/project-view/util/iconList.json
|
||||
src/project-view/util/iconName.ts
|
||||
|
@ -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. */
|
||||
|
@ -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). */
|
||||
|
@ -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: '' })
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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) => {
|
||||
|
@ -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.
|
||||
|
29
app/gui/e2e/dashboard/auth.setup.ts
Normal 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 })
|
||||
})
|
||||
})
|
@ -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)
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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()
|
||||
}),
|
||||
|
@ -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 ?? ''])
|
||||
})
|
@ -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({
|
||||
|
@ -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()
|
||||
|
@ -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.
|
||||
|
@ -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()
|
||||
|
75
app/gui/e2e/project-view/nodeComments.spec.ts
Normal 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)
|
||||
})
|
@ -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`)
|
||||
|
@ -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
@ -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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
4
app/gui/src/dashboard/assets/download.svg
Normal 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 |
3
app/gui/src/dashboard/assets/file_text.svg
Normal 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 |
3
app/gui/src/dashboard/assets/group.svg
Normal 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 |
3
app/gui/src/dashboard/assets/inspect.svg
Normal 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 |
3
app/gui/src/dashboard/assets/new_tab.svg
Normal 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 |
@ -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 |
3
app/gui/src/dashboard/assets/snooze.svg
Normal 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 |
3
app/gui/src/dashboard/assets/versions.svg
Normal 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 |
@ -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,
|
||||
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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)}</>
|
||||
}
|
@ -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'
|
||||
|
@ -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'),
|
||||
}
|
||||
|
@ -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>>
|
||||
|
@ -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 (
|
||||
|
@ -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,
|
||||
})
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
// =================================
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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
|
||||
|
@ -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 }} />
|
||||
}
|
6
app/gui/src/dashboard/components/MarkdownViewer/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel export for MarkdownViewer
|
||||
*/
|
||||
export { MarkdownViewer, type MarkdownViewerProps } from './MarkdownViewer'
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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],
|
||||
)}
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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}>
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -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" />
|
||||
|
@ -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',
|
||||
|
@ -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}
|
||||
|
@ -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. */
|
||||
|
@ -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 })
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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">
|
||||
|
@ -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" }
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
1276
app/gui/src/dashboard/hooks/backendHooks.tsx
Normal 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. */
|
||||
|
@ -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)
|
||||
|
259
app/gui/src/dashboard/hooks/measureHooks.ts
Normal 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])
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 ===
|
||||
// =================
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
@ -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'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
69
app/gui/src/dashboard/layouts/AssetDocs/AssetDocs.tsx
Normal 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} />
|
||||
}
|
6
app/gui/src/dashboard/layouts/AssetDocs/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrels for the `AssetDocs`.
|
||||
*/
|
||||
export { AssetDocs } from './AssetDocs'
|
@ -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>
|
||||
)
|
||||
}
|
267
app/gui/src/dashboard/layouts/AssetPanel/AssetPanel.tsx
Normal 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>
|
||||
)
|
||||
})
|