Second iteration of Dashboard Fixes (#11781)

Fixes:

- Opening deleted folder
- Icons
- Diff view collapsed
- Password input for passwords in settings
- Save button appears only if form in settings is dirty
- Disable clear trash button if it's empty
- Disable D&D in the root folder
- Disable Create actions if user select a folder without sufficient permissions
- Many more
This commit is contained in:
Sergei Garin 2024-12-09 23:43:41 +03:00 committed by GitHub
parent dca68ac1c0
commit 3ad09a1537
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
90 changed files with 1968 additions and 1091 deletions

View File

@ -1787,3 +1787,17 @@ export default abstract class Backend {
/** Resolve the path of an asset relative to a project. */
abstract resolveProjectAssetPath(projectId: ProjectId, relativePath: string): Promise<string>
}
// ==============================
// ====== Custom Errors =========
// ==============================
/** Error thrown when a directory does not exist. */
export class DirectoryDoesNotExistError extends Error {
/**
* Create a new instance of the {@link DirectoryDoesNotExistError} class.
*/
constructor() {
super('Directory does not exist.')
}
}

View File

@ -176,6 +176,7 @@
"emptyStringError": "This value must not be empty.",
"directoryAssetType": "folder",
"directoryDoesNotExistError": "Unable to find directory. Does it exist?",
"projectAssetType": "project",
"fileAssetType": "file",
"datalinkAssetType": "Datalink",
@ -306,14 +307,14 @@
"andOtherProjects": "and $0 other projects",
"cloudCategory": "Cloud",
"myFilesCategory": "Me",
"myFilesCategory": "My Files",
"recentCategory": "Recent",
"trashCategory": "Trash",
"userCategory": "$0",
"teamCategory": "$0",
"localCategory": "Local",
"cloudCategoryButtonLabel": "Cloud",
"myFilesCategoryButtonLabel": "Me",
"myFilesCategoryButtonLabel": "My Files",
"recentCategoryButtonLabel": "Recent",
"trashCategoryButtonLabel": "Trash",
"userCategoryButtonLabel": "$0 (User)",
@ -321,7 +322,8 @@
"localCategoryButtonLabel": "Local",
"cloudCategoryDropZoneLabel": "Move to your organization's home directory",
"cloudCategoryBadgeContent": "Beta",
"myFilesCategoryDropZoneLabel": "Move to your home directory",
"uploadToCloudUnavailableForFreePlan": "",
"myFilesCategoryDropZoneLabel": "Move to My Files",
"recentCategoryDropZoneLabel": "Move to Recent category",
"trashCategoryDropZoneLabel": "Move to Trash category",
"userCategoryDropZoneLabel": "Move to $0's home directory",
@ -400,7 +402,7 @@
"deleteTheAssetTypeTitle": "delete the $0 '$1'",
"trashTheAssetTypeTitle": "move the $0 '$1' to Trash",
"notImplemetedYet": "Not implemented yet.",
"newLabelButtonLabel": "new label",
"newLabelButtonLabel": "New label",
"settingUsername": "Setting username...",
"loggingOut": "Logging out...",
"pleaseWait": "Please wait...",
@ -415,6 +417,7 @@
"version": "Version",
"build": "Build",
"errorColon": "Error: ",
"developerInfo": "Dev mode info",
"electronVersion": "Electron",
"chromeVersion": "Chrome",
"userAgent": "User Agent",
@ -423,7 +426,7 @@
"projectSessionX": "Session $0",
"onDateX": "on $0",
"xUsersAndGroupsSelected": "$0 users and groups selected",
"allTrashedItemsForever": "all trashed items forever",
"allTrashedItemsForever": "delete all trashed items forever",
"addShortcut": "Add shortcut",
"removeShortcut": "Remove shortcut",
"resetShortcut": "Reset shortcut",
@ -482,7 +485,7 @@
"disableAnimations": "Disable animations",
"disableAnimationsDescription": "Disable all animations in the app.",
"removeTheLocalDirectoryXFromFavorites": "remove the local folder '$0' from your favorites",
"changeLocalRootDirectoryInSettings": "Change your root folder in Settings.",
"changeLocalRootDirectoryInSettings": "Change the root folder",
"localStorage": "Local Storage",
"addLocalDirectory": "Add Folder",
"browseForNewLocalRootDirectory": "Browse for new Root Folder",
@ -821,6 +824,7 @@
"arbitraryFieldNotContainAny": "This field does not contain any of the fields",
"arbitraryErrorTitle": "An error occurred",
"somethingWentWrong": "Something went wrong",
"arbitraryErrorSubtitle": "Please try again or contact the administrators.",
"arbitraryFormErrorMessage": "Something went wrong while submitting the form. Please try again or contact the administrators.",
@ -942,6 +946,10 @@
"shareFullFeatureDescription": "Share unlimited assets with other users and manage shared assets. Assign shared assets to user groups to manage their permissions.",
"shareFullPaywallMessage": "You can only share assets with a single user group. Upgrade to share assets with multiple user groups and users.",
"uploadToCloudFeatureLabel": "Upload to Cloud",
"uploadToCloudFeatureBulletPoints": "Upload assets to the Cloud;Manage Cloud assets;Run projects in the Cloud",
"uploadToCloudFeatureDescription": "Upload assets to the cloud and manage them in the cloud. Run projects in the cloud.",
"ensoDevtoolsButtonLabel": "Open Enso Devtools",
"ensoDevtoolsPopoverHeading": "Enso Devtools",
"ensoDevtoolsPlanSelectSubtitle": "User Plan",

View File

@ -66,6 +66,11 @@ export function unsafeKeys<T extends object>(object: T): readonly (keyof T)[] {
return Object.keys(object)
}
/** Return the values of an object. UNSAFE only when it is possible for an object to have extra keys. */
export function unsafeValues<const T extends object>(object: T): readonly T[keyof T][] {
return Object.values(object)
}
/**
* Return the entries of an object. UNSAFE only when it is possible for an object to have
* extra keys.
@ -77,6 +82,17 @@ export function unsafeEntries<T extends object>(
return Object.entries(object)
}
/**
* Return an object from its entries. UNSAFE only when it is possible for an object to have
* extra keys.
*/
export function unsafeFromEntries<T extends object>(
entries: readonly { [K in keyof T]: readonly [K, T[K]] }[keyof T][],
): T {
// @ts-expect-error This is intentionally a wrapper function with a different type.
return Object.fromEntries(entries)
}
// =============================
// === unsafeRemoveUndefined ===
// =============================

View File

@ -380,4 +380,18 @@ export default class DrivePageActions extends PageActions {
await callback(locateContextMenus(page))
})
}
/** Close the "get started" modal. */
closeGetStartedModal() {
return this.step('Close "get started" modal', async (page) => {
await new StartModalActions(page).close()
})
}
/** Interact with the "start" modal. */
withStartModal(callback: baseActions.LocatorCallback) {
return this.step('Interact with start modal', async (page) => {
await callback(new StartModalActions(page).locateStartModal())
})
}
}

View File

@ -1,7 +1,7 @@
/** @file Actions for the "home" page. */
import * as test from '@playwright/test'
import * as actions from '.'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
import EditorPageActions from './EditorPageActions'
// =========================
@ -11,10 +11,31 @@ import EditorPageActions from './EditorPageActions'
/** Actions for the "start" modal. */
export default class StartModalActions extends BaseActions {
/** Close this modal and go back to the Drive page. */
close() {
return this.step('Close "start" modal', (page) => page.getByLabel('Close').click()).into(
DrivePageActions,
)
async close() {
const isOnScreen = await this.isStartModalShown()
if (isOnScreen) {
return test.test.step('Close start modal', async () => {
await this.locateStartModal().getByTestId('close-button').click()
})
}
}
/** Locate the "start" modal. */
locateStartModal() {
return this.page.getByTestId('start-modal')
}
/**
* Check if the Asset Panel is shown.
*/
isStartModalShown() {
return this.locateStartModal()
.isHidden()
.then(
(result) => !result,
() => false,
)
}
/** Create a project from the template at the given index. */

View File

@ -3,9 +3,11 @@ import * as test from '@playwright/test'
import { TEXTS } from 'enso-common/src/text'
import path from 'node:path'
import * as apiModule from '../api'
import DrivePageActions from './DrivePageActions'
import LoginPageActions from './LoginPageActions'
import StartModalActions from './StartModalActions'
// =================
// === Constants ===
@ -675,38 +677,69 @@ export async function press(page: test.Page, keyOrShortcut: string) {
// === Miscellaneous utilities ===
// ===============================
/** Get the path to the auth file. */
export function getAuthFilePath() {
const __dirname = path.dirname(new URL(import.meta.url).pathname)
return path.join(__dirname, '../../../playwright/.auth/user.json')
}
/** Perform a successful login. */
export async function login(
{ page, setupAPI }: MockParams,
email = 'email@example.com',
password = VALID_PASSWORD,
first = true,
) {
await test.test.step('Login', async () => {
const url = new URL(page.url())
const authFile = getAuthFilePath()
if (url.pathname !== '/login') {
return
}
await waitForLoaded(page)
const isLoggedIn = (await page.$('[data-testid="before-auth-layout"]')) === null
if (isLoggedIn) {
test.test.info().annotations.push({
type: 'skip',
description: 'Already logged in',
})
return
}
return test.test.step('Login', async () => {
test.test.info().annotations.push({
type: 'Login',
description: 'Performing login',
})
await locateEmailInput(page).fill(email)
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
if (first) {
await passAgreementsDialog({ page, setupAPI })
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
await page.context().storageState({ path: authFile })
})
}
/**
* Wait for the page to load.
*/
export async function waitForLoaded(page: test.Page) {
await page.waitForLoadState()
await test.expect(page.locator('[data-testid="spinner"]')).toHaveCount(0)
await test.expect(page.getByTestId('loading-app-message')).not.toBeVisible({ timeout: 30_000 })
}
/**
* Wait for the dashboard to load.
*/
export async function waitForDashboardToLoad(page: test.Page) {
await waitForLoaded(page)
await test.expect(page.getByTestId('after-auth-layout')).toBeAttached()
}
/** Reload. */
export async function reload({ page }: MockParams) {
await test.test.step('Reload', async () => {
await page.reload()
await test.expect(page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
await waitForLoaded(page)
})
}
@ -722,7 +755,7 @@ export async function relog(
.getByRole('button', { name: TEXT.signOutShortcut })
.getByText(TEXT.signOutShortcut)
.click()
await login({ page, setupAPI }, email, password, false)
await login({ page, setupAPI }, email, password)
})
}
@ -776,46 +809,49 @@ export const mockApi = apiModule.mockApi
/** Set up all mocks, without logging in. */
export function mockAll({ page, setupAPI }: MockParams) {
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
return new LoginPageActions(page)
.step('Execute all mocks', async () => {
await Promise.all([
mockApi({ page, setupAPI }),
mockDate({ page, setupAPI }),
mockAllAnimations({ page }),
mockUnneededUrls({ page }),
])
})
.step('Navigate to the Root page', async () => {
await page.goto('/')
await waitForLoaded(page)
})
}
/** Set up all mocks, and log in with dummy credentials. */
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
export function mockAllAndLogin({ page, setupAPI }: MockParams) {
return mockAll({ page, setupAPI })
.step('Login', async () => {
await login({ page, setupAPI })
})
.step('Wait for dashboard to load', async () => {
await waitForDashboardToLoad(page)
})
.step('Check if start modal is shown', async () => {
await new StartModalActions(page).close()
})
.into(DrivePageActions)
}
/**
* 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')
})
`,
await test.test.step('Mock all animations', async () => {
await page.addInitScript({
content: `
window.DISABLE_ANIMATIONS = true;
document.addEventListener('DOMContentLoaded', () => {
document.documentElement.classList.add('disable-animations')
})
`,
})
})
}
@ -826,27 +862,29 @@ 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()
}),
await test.test.step('Mock unneeded URLs', async () => {
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://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/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://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: '' })
}),
])
page.route('https://fonts.googleapis.com/css2*', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
}),
])
})
}
/**
@ -859,6 +897,9 @@ export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams
await mockDate({ page, setupAPI })
await page.goto('/')
await login({ page, setupAPI })
await waitForDashboardToLoad(page)
await new StartModalActions(page).close()
return api
})
}

View File

@ -1,16 +1,10 @@
import { test as setup } from '@playwright/test'
import path from 'node:path'
import fs from 'node:fs'
import * as actions from './actions'
const __dirname = path.dirname(new URL(import.meta.url).pathname)
const authFile = path.join(__dirname, '../../playwright/.auth/user.json')
setup('authenticate', ({ page }) => {
setup.slow()
return actions
.mockAll({ page })
.login()
.do(async () => {
await page.context().storageState({ path: authFile })
})
const authFilePath = actions.getAuthFilePath()
setup.skip(fs.existsSync(authFilePath), 'Already authenticated')
return actions.mockAllAndLogin({ page })
})

View File

@ -20,6 +20,10 @@ test.test('delete and restore', ({ page }) =>
.contextMenu.restoreFromTrash()
.driveTable.expectTrashPlaceholderRow()
.goToCategory.cloud()
.withStartModal(async (startModal) => {
await test.expect(startModal).toBeVisible()
})
.closeGetStartedModal()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
}),
@ -45,6 +49,10 @@ test.test('delete and restore (keyboard)', ({ page }) =>
.press('Mod+R')
.driveTable.expectTrashPlaceholderRow()
.goToCategory.cloud()
.withStartModal(async (startModal) => {
await test.expect(startModal).toBeVisible()
})
.closeGetStartedModal()
.driveTable.withRows(async (rows) => {
await test.expect(rows).toHaveCount(1)
}),

View File

@ -12,8 +12,7 @@ test.test.use({ storageState: { cookies: [], origins: [] } })
test.test('login and logout', ({ page }) =>
actions
.mockAll({ page })
.login()
.mockAllAndLogin({ page })
.do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()

View File

@ -110,6 +110,7 @@ export default defineConfig({
use: {
baseURL: `http://localhost:${ports.dashboard}`,
actionTimeout: TIMEOUT_MS,
offline: false,
},
},
{
@ -125,6 +126,7 @@ export default defineConfig({
use: {
baseURL: `http://localhost:${ports.dashboard}`,
actionTimeout: TIMEOUT_MS,
offline: false,
storageState: path.join(dirName, './playwright/.auth/user.json'),
},
},

View File

@ -247,7 +247,7 @@ export default function App(props: AppProps) {
closeOnClick={false}
draggable={false}
toastClassName="text-sm leading-cozy bg-selected-frame rounded-lg backdrop-blur-default"
transition={toastify.Zoom}
transition={toastify.Slide}
limit={3}
/>
<router.BrowserRouter basename={getMainPageUrl().pathname}>
@ -538,16 +538,14 @@ function AppRouter(props: AppRouterProps) {
{/* 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>
<LocalBackendPathSynchronizer />
<VersionChecker />
{routes}
<suspense.Suspense>
<errorBoundary.ErrorBoundary>
<devtools.EnsoDevtools />
</errorBoundary.ErrorBoundary>
</suspense.Suspense>
</DriveProvider>
</InputBindingsProvider>
</AuthProvider>

View File

@ -1,5 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.45" x="2" width="12" height="16" rx="2" fill="black" />
<path d="M7 5H5V7H7V9H9V7H11V5H9V3H7V5Z" fill="black" />
<rect x="5" y="11" width="6" 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="M7.5 21L4.56066 18.0607C3.97487 17.4749 3.97487 16.5251 4.56066 15.9393L7.5 13M16.5 11L19.4393 8.06066C20.0251 7.47487 20.0251 6.52513 19.4393 5.93934L16.5 3M5 17H20M4 7H19" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 294 B

After

Width:  |  Height:  |  Size: 367 B

View File

@ -1,10 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="12" height="9" rx="1" stroke="black" stroke-width="2" />
<path
d="M7.06963 13.5822L7.43037 11.4178C7.47055 11.1767 7.67914 11 7.92356 11H8.07644C8.32086 11 8.52945 11.1767 8.56963 11.4178L9 14H6.57644C6.82086 14 7.02945 13.8233 7.06963 13.5822Z"
fill="black" />
<path
d="M11.5 14H9M9 14H4.5H6.57644C6.82086 14 7.02945 13.8233 7.06963 13.5822L7.43037 11.4178C7.47055 11.1767 7.67914 11 7.92356 11H8.07644C8.32086 11 8.52945 11.1767 8.56963 11.4178L9 14Z"
stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<rect opacity="0.3" x="3" y="3" width="10" height="7" fill="black" />
</svg>
<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 6.25C3 4.45507 4.45507 3 6.25 3H17.75C19.5449 3 21 4.45507 21 6.25V16H22.25C22.6642 16 23 16.3358 23 16.75V17.75C23 19.5449 21.5449 21 19.75 21H4.25C2.45507 21 1 19.5449 1 17.75V16.75C1 16.3358 1.33579 16 1.75 16H3V6.25ZM2.5 17.5V17.75C2.5 18.7165 3.2835 19.5 4.25 19.5H19.75C20.7165 19.5 21.5 18.7165 21.5 17.75V17.5H2.5Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 745 B

After

Width:  |  Height:  |  Size: 494 B

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18 13C18.5523 13 19 13.4477 19 14V16H21C21.5523 16 22 16.4477 22 17C22 17.5523 21.5523 18 21 18H19V20C19 20.5523 18.5523 21 18 21C17.4477 21 17 20.5523 17 20V18H15C14.4477 18 14 17.5523 14 17C14 16.4477 14.4477 16 15 16H17V14C17 13.4477 17.4477 13 18 13Z" fill="black"/>
<path d="M8.73241 3C9.85868 3 10.9104 3.56288 11.5352 4.5C12.1599 5.43712 13.2117 6 14.338 6H18C20.2091 6 22 7.79086 22 10V12.5278C20.9385 11.5777 19.5367 11 18 11C14.6863 11 12 13.6863 12 17C12 18.0929 12.2922 19.1175 12.8027 20H6C3.79086 20 2 18.2091 2 16V7C2 4.79086 3.79086 3 6 3H8.73241Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 7C2 4.79086 3.79086 3 6 3H8.75736C9.81823 3 10.8356 3.42143 11.5858 4.17157L11.8284 4.41421C12.2035 4.78929 12.7122 5 13.2426 5H18C20.2091 5 22 6.79086 22 9V9.99963C21.1643 9.37194 20.1256 9 19 9H5C3.87439 9 2.83566 9.37194 2 9.99963V7Z" fill="black"/>
<path d="M2 14V16C2 18.2091 3.79086 20 6 20H18C20.2091 20 22 18.2091 22 16V14C22 12.3431 20.6569 11 19 11H5C3.34315 11 2 12.3431 2 14Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@ -1,3 +1,4 @@
<svg width="6" height="6" viewBox="0 0 6 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 0H2V2L0 2V4H2V6H4V4H6V2L4 2V0Z" fill="black" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="2" width="2" height="12" fill="black" rx="1" />
<rect x="2" y="7" width="12" height="2" fill="black" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 202 B

After

Width:  |  Height:  |  Size: 236 B

View File

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="7" y="2" width="2" height="12" fill="black" />
<rect x="2" y="7" width="12" height="2" fill="black" />
<rect x="7" y="2" width="2" height="12" fill="black" rx="1" />
<rect x="2" y="7" width="12" height="2" fill="black" rx="1" />
</svg>

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 236 B

View File

@ -1,13 +1,3 @@
<svg width="18" height="16" viewBox="0 0 18 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_22482_12043" fill="white">
<path
d="M2 8C2 6.14916 2.64175 4.35557 3.81592 2.92485C4.99008 1.49413 6.624 0.514799 8.43928 0.153718C10.2546 -0.207364 12.1389 0.0721488 13.7712 0.94463C15.4035 1.81711 16.6827 3.22858 17.391 4.93853C18.0993 6.64849 18.1928 8.55113 17.6555 10.3223C17.1183 12.0934 15.9835 13.6235 14.4446 14.6518C12.9056 15.68 11.0578 16.1429 9.21586 15.9615C7.37394 15.7801 5.65189 14.9656 4.34315 13.6569L5.76867 12.2313C6.74761 13.2103 8.0357 13.8195 9.41347 13.9552C10.7912 14.0909 12.1734 13.7447 13.3245 12.9755C14.4756 12.2064 15.3245 11.0619 15.7263 9.73706C16.1282 8.41225 16.0583 6.98907 15.5285 5.71002C14.9987 4.43098 14.0418 3.3752 12.8208 2.72258C11.5999 2.06997 10.1904 1.86089 8.83258 2.13098C7.47475 2.40107 6.25258 3.13361 5.37431 4.20379C4.49603 5.27397 4.016 6.61557 4.016 8H2Z" />
</mask>
<path
d="M2 8C2 6.14916 2.64175 4.35557 3.81592 2.92485C4.99008 1.49413 6.624 0.514799 8.43928 0.153718C10.2546 -0.207364 12.1389 0.0721488 13.7712 0.94463C15.4035 1.81711 16.6827 3.22858 17.391 4.93853C18.0993 6.64849 18.1928 8.55113 17.6555 10.3223C17.1183 12.0934 15.9835 13.6235 14.4446 14.6518C12.9056 15.68 11.0578 16.1429 9.21586 15.9615C7.37394 15.7801 5.65189 14.9656 4.34315 13.6569L5.76867 12.2313C6.74761 13.2103 8.0357 13.8195 9.41347 13.9552C10.7912 14.0909 12.1734 13.7447 13.3245 12.9755C14.4756 12.2064 15.3245 11.0619 15.7263 9.73706C16.1282 8.41225 16.0583 6.98907 15.5285 5.71002C14.9987 4.43098 14.0418 3.3752 12.8208 2.72258C11.5999 2.06997 10.1904 1.86089 8.83258 2.13098C7.47475 2.40107 6.25258 3.13361 5.37431 4.20379C4.49603 5.27397 4.016 6.61557 4.016 8H2Z"
stroke="black" stroke-width="4" mask="url(#path-1-inside-1_22482_12043)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M10 4C9.44772 4 9 4.44772 9 5V8C9 8.37712 9.20876 8.70549 9.51702 8.87584L11.7777 10.181C12.256 10.4572 12.8675 10.2933 13.1437 9.815C13.4198 9.33671 13.256 8.72512 12.7777 8.44898L11 7.42264V5C11 4.44772 10.5523 4 10 4Z"
fill="black" />
<path d="M6 8L3 12L0 8L6 8Z" fill="black" />
</svg>
<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="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22ZM13 8C13 7.44772 12.5523 7 12 7C11.4477 7 11 7.44772 11 8V12C11 12.2652 11.1054 12.5196 11.2929 12.7071L13.7929 15.2071C14.1834 15.5976 14.8166 15.5976 15.2071 15.2071C15.5976 14.8166 15.5976 14.1834 15.2071 13.7929L13 11.5858V8Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 511 B

View File

@ -1,7 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect opacity="0.45" x="2" width="12" height="16" rx="2" fill="black" />
<path d="M6.1 8L4.6 9.5L3.1 8L6.1 8Z" fill="black" />
<path
d="M4 8C4 7.20887 4.2346 6.43552 4.67412 5.77772C5.11365 5.11992 5.73836 4.60723 6.46927 4.30448C7.20017 4.00173 8.00444 3.92252 8.78036 4.07686C9.55629 4.2312 10.269 4.61216 10.8284 5.17157C11.3878 5.73098 11.7688 6.44372 11.9231 7.21964C12.0775 7.99556 11.9983 8.79983 11.6955 9.53073C11.3928 10.2616 10.8801 10.8864 10.2223 11.3259C9.56448 11.7654 8.79112 12 8 12L8 10.8C8.55379 10.8 9.09514 10.6358 9.5556 10.3281C10.0161 10.0204 10.3749 9.58315 10.5869 9.07151C10.7988 8.55988 10.8542 7.99689 10.7462 7.45375C10.6382 6.9106 10.3715 6.41169 9.9799 6.0201C9.58831 5.62851 9.0894 5.36184 8.54625 5.2538C8.00311 5.14576 7.44012 5.20121 6.92849 5.41314C6.41685 5.62506 5.97955 5.98395 5.67189 6.4444C5.36422 6.90486 5.2 7.44621 5.2 8L4 8Z"
fill="black" />
</svg>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.0275 12C19.0275 8.13401 15.8935 5 12.0275 5C10.0535 5 8.6248 5.67948 7.24107 7H9.00014C9.55242 7 10.0001 7.44772 10.0001 8C10.0001 8.55228 9.55242 9 9.00014 9H6.00014C4.89557 9 4.00014 8.10457 4.00014 7V4C4.00014 3.44772 4.44785 3 5.00014 3C5.55242 3 6.00014 3.44772 6.00014 4V5.42301C7.6223 3.9219 9.47602 3 12.0275 3C16.998 3 21.0275 7.02944 21.0275 12C21.0275 16.9706 16.998 21 12.0275 21C8.10726 21 4.77489 18.4941 3.53986 14.9999C3.35581 14.4792 3.62873 13.9079 4.14945 13.7238C4.67017 13.5398 5.24149 13.8127 5.42553 14.3334C6.38696 17.0536 8.9812 19 12.0275 19C15.8935 19 19.0275 15.866 19.0275 12Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 1005 B

After

Width:  |  Height:  |  Size: 738 B

View File

@ -1,9 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5.72361 0.552786C5.893 0.214002 6.23926 0 6.61803 0H9.38197C9.76074 0 10.107 0.214002 10.2764 0.552786L10.5 1H5.5L5.72361 0.552786Z"
fill="black" />
<path d="M2 2C2 1.44772 2.44772 1 3 1H13C13.5523 1 14 1.44772 14 2V3H2V2Z" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M14 4H2V14C2 15.1046 2.89543 16 4 16H12C13.1046 16 14 15.1046 14 14V4ZM4 6H5.5V14H4V6ZM8.75 6H7.25V14H8.75V6ZM12 6H10.5V14H12V6Z"
fill="black" />
</svg>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.4173 5C8.18876 3.23448 9.94971 2 12.0009 2C14.0522 2 15.8131 3.23448 16.5846 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H19.9356L19.1845 18.2661C19.0444 20.3673 17.2992 22 15.1933 22H8.80666C6.7008 22 4.9556 20.3673 4.81552 18.2661L4.06445 7H3C2.44772 7 2 6.55228 2 6C2 5.44772 2.44772 5 3 5H7.4173ZM9.76461 5C10.3143 4.38612 11.113 4 12.0009 4C12.8889 4 13.6876 4.38612 14.2373 5H9.76461ZM11 11C11 10.4477 10.5523 10 10 10C9.44772 10 9 10.4477 9 11V16C9 16.5523 9.44772 17 10 17C10.5523 17 11 16.5523 11 16V11ZM14 10C14.5523 10 15 10.4477 15 11V16C15 16.5523 14.5523 17 14 17C13.4477 17 13 16.5523 13 16V11C13 10.4477 13.4477 10 14 10Z" fill="black"/>
</svg>

Before

Width:  |  Height:  |  Size: 591 B

After

Width:  |  Height:  |  Size: 821 B

View File

@ -3,15 +3,40 @@ import type * as aria from '#/components/aria'
import { Text } from '#/components/AriaComponents'
import type { Meta, StoryObj } from '@storybook/react'
import { expect, userEvent, within } from '@storybook/test'
import { Badge } from '../../Badge'
import type { BaseButtonProps } from './Button'
import { Button } from './Button'
type Story = StoryObj<BaseButtonProps<aria.ButtonRenderProps>>
const variants = [
'primary',
'accent',
'delete',
'ghost-fading',
'ghost',
'link',
'submit',
'outline',
] as const
const sizes = ['hero', 'large', 'medium', 'small', 'xsmall', 'xxsmall'] as const
export default {
title: 'Components/AriaComponents/Button',
component: Button,
render: (props) => <Button {...props} />,
argTypes: {
variant: {
control: 'radio',
options: variants,
},
size: {
control: 'radio',
options: sizes,
},
addonStart: { control: false },
addonEnd: { control: false },
},
} as Meta<BaseButtonProps<aria.ButtonRenderProps>>
export const Variants: Story = {
@ -19,25 +44,20 @@ export const Variants: Story = {
<div className="flex flex-col gap-4">
<Text.Heading>Variants</Text.Heading>
<div className="grid grid-cols-4 place-content-start place-items-start gap-3">
<Button>Default</Button>
<Button variant="primary">Primary</Button>
<Button variant="accent">Accent</Button>
<Button variant="delete">Delete</Button>
<Button variant="ghost-fading">Ghost Fading</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
<Button variant="submit">Submit</Button>
<Button variant="outline">Outline</Button>
{variants.map((variant) => (
<Button key={variant} variant={variant}>
{variant}
</Button>
))}
</div>
<Text.Heading>Sizes</Text.Heading>
<div className="grid grid-cols-4 place-content-center place-items-start gap-3">
<Button size="hero">Hero</Button>
<Button size="large">Large</Button>
<Button size="medium">Medium</Button>
<Button size="small">Small</Button>
<Button size="xsmall">XSmall</Button>
<Button size="xxsmall">XXSmall</Button>
{sizes.map((size) => (
<Button key={size} size={size}>
{size}
</Button>
))}
</div>
<Text.Heading>Icons</Text.Heading>
@ -101,3 +121,49 @@ export const LoadingOnPress: Story = {
await expect(await findByTestId('spinner')).toBeInTheDocument()
},
}
export const Addons: Story = {
args: {
addonStart: (
<Badge color="error" variant="solid">
Test
</Badge>
),
addonEnd: (
<Badge color="error" variant="solid">
Test
</Badge>
),
},
render: (args) => (
<>
<div className="mb-8 grid grid-cols-[repeat(4,minmax(0,min-content))] items-center justify-items-center gap-4">
{sizes.map((size) => (
<Button key={size} size={size} {...args}>
{size}
</Button>
))}
{variants.map((variant) => (
<Button key={variant} variant={variant} {...args}>
{variant}
</Button>
))}
</div>
<div className="grid grid-cols-[repeat(4,minmax(0,min-content))] items-center justify-items-center gap-4">
{sizes.map((size) => (
<Button key={size} size={size} {...args}>
{size}
</Button>
))}
{variants.map((variant) => (
<Button key={variant} variant={variant} {...args}>
{variant}
</Button>
))}
</div>
</>
),
}

View File

@ -9,14 +9,13 @@ import {
type ReactNode,
} from 'react'
import { useFocusChild } from '#/hooks/focusHooks'
import * as aria from '#/components/aria'
import { StatelessSpinner } from '#/components/StatelessSpinner'
import SvgMask from '#/components/SvgMask'
import { TEXT_STYLE, useVisualTooltip } from '#/components/AriaComponents/Text'
import { Tooltip, TooltipTrigger } from '#/components/AriaComponents/Tooltip'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { forwardRef } from '#/utilities/react'
import type { ExtractFunction, VariantProps } from '#/utilities/tailwindVariants'
import { tv } from '#/utilities/tailwindVariants'
@ -65,6 +64,21 @@ export interface BaseButtonProps<Render>
*/
readonly loaderPosition?: 'full' | 'icon'
readonly styles?: ExtractFunction<typeof BUTTON_STYLES> | undefined
readonly addonStart?:
| ReactElement
| string
| false
| ((render: Render) => ReactElement | string | null)
| null
| undefined
readonly addonEnd?:
| ReactElement
| string
| false
| ((render: Render) => ReactElement | string | null)
| null
| undefined
}
export const BUTTON_STYLES = tv({
@ -101,7 +115,15 @@ export const BUTTON_STYLES = tv({
fullWidth: { true: 'w-full' },
size: {
custom: { base: '', extraClickZone: '', icon: 'h-full w-unset min-w-[1.906cap]' },
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
hero: {
base: TEXT_STYLE({
variant: 'subtitle',
color: 'custom',
weight: 'semibold',
className: 'flex px-[24px] py-5',
}),
text: 'mx-[1.5em]',
},
large: {
base: TEXT_STYLE({
variant: 'body',
@ -110,7 +132,7 @@ export const BUTTON_STYLES = tv({
className: 'flex px-[11px] py-[5.5px]',
}),
content: 'gap-2',
icon: 'mb-[-0.1cap] h-4 w-4',
icon: '-mb-0.5 h-4 w-4',
extraClickZone: 'after:inset-[-6px]',
},
medium: {
@ -118,9 +140,9 @@ export const BUTTON_STYLES = tv({
variant: 'body',
color: 'custom',
weight: 'semibold',
className: 'flex px-[9px] py-[3.5px]',
className: 'flex px-[7px] py-[3.5px]',
}),
icon: 'mb-[-0.1cap] h-4 w-4',
icon: '-mb-0.5 h-4 w-4',
content: 'gap-2',
extraClickZone: 'after:inset-[-8px]',
},
@ -129,9 +151,9 @@ export const BUTTON_STYLES = tv({
variant: 'body',
color: 'custom',
weight: 'medium',
className: 'flex px-[7px] py-[1.5px]',
className: 'flex px-[5px] py-[1.5px]',
}),
icon: 'mb-[-0.1cap] h-3.5 w-3.5',
icon: '-mb-0.5 h-3.5 w-3.5',
content: 'gap-1',
extraClickZone: 'after:inset-[-10px]',
},
@ -143,7 +165,7 @@ export const BUTTON_STYLES = tv({
disableLineHeightCompensation: true,
className: 'flex px-[5px] pt-[0.5px] pb-[2.5px]',
}),
icon: 'mb-[-0.2cap] h-3 w-3',
icon: '-mb-0.5 h-3 w-3',
content: 'gap-1',
extraClickZone: 'after:inset-[-12px]',
},
@ -247,9 +269,11 @@ export const BUTTON_STYLES = tv({
'flex relative after:absolute after:cursor-pointer group-disabled:after:cursor-not-allowed',
wrapper: 'relative block',
loader: 'absolute inset-0 flex items-center justify-center',
content: 'flex items-center gap-[0.5em]',
content: 'flex items-center',
text: 'inline-flex items-center justify-center gap-1 w-full',
icon: 'h-[1.906cap] w-[1.906cap] flex-none aspect-square flex items-center justify-center',
addonStart: 'flex items-center justify-center macos:-mb-0.5',
addonEnd: 'flex items-center justify-center macos:-mb-0.5',
},
defaultVariants: {
isActive: 'none',
@ -273,7 +297,11 @@ export const BUTTON_STYLES = tv({
{ size: 'large', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-4.5 h-4.5' } },
{ size: 'hero', iconOnly: true, class: { base: 'p-0 rounded-full', icon: 'w-12 h-12' } },
{ size: 'xsmall', class: { addonStart: '-ml-[3.5px]', addonEnd: '-mr-[3.5px]' } },
{ size: 'xxsmall', class: { addonStart: '-ml-[2.5px]', addonEnd: '-mr-[2.5px]' } },
{ variant: 'icon', class: { base: 'flex-none' } },
{ variant: 'icon', isDisabled: true, class: { base: 'opacity-50 cursor-not-allowed' } },
{ variant: 'link', isFocused: true, class: 'focus-visible:outline-offset-1' },
{ variant: 'link', size: 'xxsmall', class: 'font-medium' },
@ -287,6 +315,8 @@ export const BUTTON_STYLES = tv({
],
})
const ICON_LOADER_DELAY = 150
/** A button allows a user to perform an action, with mouse, touch, and keyboard interactions. */
export const Button = memo(
forwardRef(function Button(props: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
@ -310,11 +340,13 @@ export const Button = memo(
extraClickZone: extraClickZoneProp,
onPress = () => {},
variants = BUTTON_STYLES,
addonStart,
addonEnd,
...ariaProps
} = props
const focusChildProps = useFocusChild()
const [implicitlyLoading, setImplicitlyLoading] = useState(false)
const contentRef = useRef<HTMLSpanElement>(null)
const loaderRef = useRef<HTMLSpanElement>(null)
@ -328,6 +360,7 @@ export const Button = memo(
}
const isIconOnly = (children == null || children === '' || children === false) && icon != null
const shouldShowTooltip = (() => {
if (tooltip === false) {
return false
@ -337,6 +370,7 @@ export const Button = memo(
return tooltip != null
}
})()
const tooltipElement = shouldShowTooltip ? tooltip ?? ariaProps['aria-label'] : null
const isLoading = loading || implicitlyLoading
@ -345,7 +379,7 @@ export const Button = memo(
const extraClickZone = extraClickZoneProp ?? variant === 'icon'
useLayoutEffect(() => {
const delay = 350
const delay = ICON_LOADER_DELAY
if (isLoading) {
const loaderAnimation = loaderRef.current?.animate(
@ -371,18 +405,19 @@ export const Button = memo(
}
}, [isLoading, loaderPosition])
const handlePress = (event: aria.PressEvent): void => {
const handlePress = useEventCallback((event: aria.PressEvent): void => {
if (!isDisabled) {
const result = onPress?.(event)
if (result instanceof Promise) {
setImplicitlyLoading(true)
void result.finally(() => {
setImplicitlyLoading(false)
})
}
}
}
})
const styles = variants({
isDisabled,
@ -398,44 +433,6 @@ export const Button = memo(
iconOnly: isIconOnly,
})
const childrenFactory = (render: aria.ButtonRenderProps | aria.LinkRenderProps): ReactNode => {
const iconComponent = (() => {
if (isLoading && loaderPosition === 'icon') {
return (
<span className={styles.icon()}>
<StatelessSpinner state="loading-medium" size={16} />
</span>
)
} else if (icon == null) {
return null
} else {
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
const actualIcon = typeof icon === 'function' ? icon(render) : icon
if (typeof actualIcon === 'string') {
return <SvgMask src={actualIcon} className={styles.icon()} />
} else {
return <span className={styles.icon()}>{actualIcon}</span>
}
}
})()
// Icon only button
if (isIconOnly) {
return <span className={styles.extraClickZone()}>{iconComponent}</span>
} else {
// Default button
return (
<>
{iconComponent}
<span className={styles.text()}>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{typeof children === 'function' ? children(render) : children}
</span>
</>
)
}
}
const { tooltip: visualTooltip, targetProps } = useVisualTooltip({
targetRef: contentRef,
children: tooltipElement,
@ -448,7 +445,7 @@ export const Button = memo(
// @ts-expect-error ts errors are expected here because we are merging props with different types
ref={ref}
// @ts-expect-error ts errors are expected here because we are merging props with different types
{...aria.mergeProps<aria.ButtonProps>()(goodDefaults, ariaProps, focusChildProps, {
{...aria.mergeProps<aria.ButtonProps>()(goodDefaults, ariaProps, {
isDisabled,
// we use onPressEnd instead of onPress because for some reason react-aria doesn't trigger
// onPress on EXTRA_CLICK_ZONE, but onPress{start,end} are triggered
@ -469,8 +466,20 @@ export const Button = memo(
className={styles.content({ className: contentClassName })}
{...targetProps}
>
{}
{childrenFactory(render)}
<ButtonContent
isIconOnly={isIconOnly}
isLoading={isLoading}
loaderPosition={loaderPosition}
icon={icon}
styles={styles}
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
addonStart={typeof addonStart === 'function' ? addonStart(render) : addonStart}
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
addonEnd={typeof addonEnd === 'function' ? addonEnd(render) : addonEnd}
>
{/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */}
{typeof children === 'function' ? children(render) : children}
</ButtonContent>
</span>
{isLoading && loaderPosition === 'full' && (
@ -478,25 +487,135 @@ export const Button = memo(
<StatelessSpinner state="loading-medium" size={16} />
</span>
)}
{shouldShowTooltip && visualTooltip}
</span>
)}
</Tag>
)
return (
tooltipElement == null ? button
: shouldUseVisualTooltip ?
<>
{button}
{visualTooltip}
</>
: <TooltipTrigger delay={0} closeDelay={0}>
{button}
if (tooltipElement == null) {
return button
}
<Tooltip {...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}>
{tooltipElement}
</Tooltip>
</TooltipTrigger>
return (
<TooltipTrigger delay={0} closeDelay={0}>
{button}
<Tooltip {...(tooltipPlacement != null ? { placement: tooltipPlacement } : {})}>
{tooltipElement}
</Tooltip>
</TooltipTrigger>
)
}),
)
/**
* Props for {@link ButtonContent}.
*/
interface ButtonContentProps {
readonly isIconOnly: boolean
readonly isLoading: boolean
readonly loaderPosition: 'full' | 'icon'
readonly icon: ButtonProps['icon']
readonly styles: ReturnType<typeof BUTTON_STYLES>
readonly children: ReactNode
readonly addonStart?: ReactElement | string | false | null | undefined
readonly addonEnd?: ReactElement | string | false | null | undefined
}
/**
* Checks if an addon is present.
*/
function hasAddon(addon: ButtonContentProps['addonEnd']): boolean {
return addon != null && addon !== false && addon !== ''
}
/**
* Renders the content of a button.
*/
// eslint-disable-next-line no-restricted-syntax
const ButtonContent = memo(function ButtonContent(props: ButtonContentProps) {
const { isIconOnly, isLoading, loaderPosition, icon, styles, children, addonStart, addonEnd } =
props
// Icon only button
if (isIconOnly) {
return (
<span className={styles.extraClickZone()}>
{hasAddon(addonStart) && <div className={styles.addonStart()}>{addonStart}</div>}
<Icon isLoading={isLoading} loaderPosition={loaderPosition} icon={icon} styles={styles} />
{hasAddon(addonEnd) && <div className={styles.addonEnd()}>{addonEnd}</div>}
</span>
)
}
// Default button
return (
<>
{hasAddon(addonStart) && <div className={styles.addonStart()}>{addonStart}</div>}
<Icon isLoading={isLoading} loaderPosition={loaderPosition} icon={icon} styles={styles} />
<span className={styles.text()}>{children}</span>
{hasAddon(addonEnd) && <div className={styles.addonEnd()}>{addonEnd}</div>}
</>
)
})
/**
* Props for {@link Icon}.
*/
interface IconProps {
readonly isLoading: boolean
readonly loaderPosition: 'full' | 'icon'
readonly icon: ButtonProps['icon']
readonly styles: ReturnType<typeof BUTTON_STYLES>
}
/**
* Renders an icon for a button.
*/
const Icon = memo(function Icon(props: IconProps) {
const { isLoading, loaderPosition, icon, styles } = props
const [loaderIsVisible, setLoaderIsVisible] = useState(false)
useLayoutEffect(() => {
if (isLoading && loaderPosition === 'icon') {
const timeout = setTimeout(() => {
setLoaderIsVisible(true)
}, ICON_LOADER_DELAY)
return () => {
clearTimeout(timeout)
}
} else {
setLoaderIsVisible(false)
}
}, [isLoading, loaderPosition])
const shouldShowLoader = isLoading && loaderPosition === 'icon' && loaderIsVisible
if (icon == null && !shouldShowLoader) {
return null
}
const actualIcon = (() => {
/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const iconRender = typeof icon === 'function' ? icon(render) : icon
return typeof iconRender === 'string' ?
<SvgMask src={iconRender} className={styles.icon()} />
: <span className={styles.icon()}>{iconRender}</span>
})()
if (shouldShowLoader) {
return (
<div className={styles.icon()}>
<StatelessSpinner state="loading-medium" size={16} />
</div>
)
}
return actualIcon
})

View File

@ -8,10 +8,16 @@ import * as twv from '#/utilities/tailwindVariants'
// =================
const STYLES = twv.tv({
base: 'flex w-full flex-1 shrink-0',
base: 'flex flex-1 shrink-0',
variants: {
wrap: { true: 'flex-wrap' },
direction: { column: 'flex-col', row: 'flex-row' },
width: {
auto: 'w-auto',
full: 'w-full',
min: 'w-min',
max: 'w-max',
},
gap: {
custom: '',
large: 'gap-3.5',
@ -65,7 +71,9 @@ export const ButtonGroup = React.forwardRef(function ButtonGroup(
gap = 'medium',
wrap = false,
direction = 'row',
width = 'full',
align,
variants = STYLES,
verticalAlign,
...passthrough
} = props
@ -73,12 +81,13 @@ export const ButtonGroup = React.forwardRef(function ButtonGroup(
return (
<div
ref={ref}
className={STYLES({
className={variants({
gap,
wrap,
direction,
align,
verticalAlign,
width,
className,
})}
{...passthrough}

View File

@ -21,6 +21,7 @@ export const CloseButton = memo(function CloseButton(props: CloseButtonProps) {
icon = DismissIcon,
tooltip = false,
'aria-label': ariaLabel = getText('closeModalShortcut'),
testId = 'close-button',
...buttonProps
} = props
@ -45,6 +46,7 @@ export const CloseButton = memo(function CloseButton(props: CloseButtonProps) {
extraClickZone="medium"
icon={icon}
aria-label={ariaLabel}
testId={testId}
/* This is safe because we are passing all props to the button */
/* eslint-disable-next-line @typescript-eslint/no-explicit-any,no-restricted-syntax */
{...(buttonProps as any)}

View File

@ -1,9 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useLayoutEffect, useRef } from 'react'
import { DialogTrigger } from 'react-aria-components'
import { useLayoutEffect, useRef, useState } from 'react'
import { Button } from '../Button'
import { Text } from '../Text'
import { Dialog, type DialogProps } from './Dialog'
import { DialogTrigger } from './DialogTrigger'
type Story = StoryObj<DialogProps>
@ -65,26 +66,33 @@ export const Broken = {
},
}
const sizes = [600, 300, 150, 450]
function ResizableContent() {
const [sizeIndex, setSizeIndex] = useState(0)
const divRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const getRandomHeight = () => Math.floor(Math.random() * 250 + 100)
const interval = setTimeout(() => {
const nextSizeIndex = sizeIndex + 1
if (divRef.current) {
divRef.current.style.height = `${getRandomHeight()}px`
if (nextSizeIndex < sizes.length) {
setSizeIndex(nextSizeIndex)
}
}, 150)
setInterval(() => {
if (divRef.current) {
divRef.current.style.height = `${getRandomHeight()}px`
}
}, 2_000)
return () => {
clearTimeout(interval)
}
}, [])
}, [sizeIndex])
return (
<div ref={divRef} className="flex flex-none items-center justify-center text-center">
This dialog should resize with animation
<div
ref={divRef}
style={{ height: sizes[sizeIndex] }}
className="flex flex-none items-center justify-center text-center"
>
This dialog should resize with animation, and the content should be centered. Height:{' '}
{sizes[sizeIndex]}
</div>
)
}
@ -103,3 +111,18 @@ export const Fullscreen = {
type: 'fullscreen',
},
}
export const FullscreenWithStretchChildren: Story = {
args: {
type: 'fullscreen',
children: () => {
return (
<div className="flex h-full w-full flex-col items-center justify-center rounded-3xl bg-primary text-center">
<Text color="invert" variant="h1">
This dialog should stretch to fit the screen.
</Text>
</div>
)
},
},
}

View File

@ -116,16 +116,16 @@ const DIALOG_STYLES = tv({
xxxlarge: { content: 'p-20 pt-10 pb-16' },
},
scrolledToTop: { true: { header: 'border-transparent' } },
layout: { true: { measurerWrapper: 'h-auto' }, false: { measurerWrapper: 'h-full' } },
},
slots: {
header:
'sticky z-1 top-0 grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 transition-[border-color] duration-150',
closeButton: 'col-start-1 col-end-1 mr-auto',
heading: 'col-start-2 col-end-2 my-0 text-center',
scroller: 'flex flex-col overflow-y-auto max-h-[inherit]',
measurerWrapper: 'inline-grid h-fit max-h-fit min-h-fit w-full grid-rows-[auto]',
measurer: 'pointer-events-none block [grid-area:1/1]',
content: 'inline-block h-fit max-h-fit min-h-fit [grid-area:1/1] min-w-0',
scroller: 'flex flex-col h-full overflow-y-auto max-h-[inherit]',
measurerWrapper: 'inline-grid min-h-fit w-full grid-rows-1',
content: 'inline-block max-h-fit min-h-fit [grid-area:1/1] min-w-0',
},
compoundVariants: [
{ type: 'modal', size: 'small', class: 'max-w-sm' },
@ -135,8 +135,10 @@ const DIALOG_STYLES = tv({
{ type: 'modal', size: 'xxlarge', class: 'max-w-2xl' },
{ type: 'modal', size: 'xxxlarge', class: 'max-w-3xl' },
{ type: 'modal', size: 'xxxxlarge', class: 'max-w-4xl' },
{ type: 'fullscreen', class: { measurerWrapper: 'h-full' } },
],
defaultVariants: {
layout: true,
type: 'modal',
closeButton: 'normal',
hideCloseButton: false,
@ -239,6 +241,7 @@ function DialogContent(props: DialogContentProps) {
size,
padding: paddingRaw,
fitContent,
layout,
testId = 'dialog',
title,
children,
@ -247,15 +250,13 @@ function DialogContent(props: DialogContentProps) {
} = props
const dialogRef = React.useRef<HTMLDivElement>(null)
const scrollerRef = React.useRef<HTMLDivElement | null>()
const scrollerRef = React.useRef<HTMLDivElement | null>(null)
const dialogId = aria.useId()
const titleId = `${dialogId}-title`
const padding = paddingRaw ?? (type === 'modal' ? 'medium' : 'xlarge')
const isFullscreen = type === 'fullscreen'
const [isScrolledToTop, setIsScrolledToTop] = React.useState(true)
const [isLayoutDisabled, setIsLayoutDisabled] = React.useState(true)
const [contentDimensionsRef, dimensions] = useMeasure({
@ -283,22 +284,6 @@ function DialogContent(props: DialogContentProps) {
},
})
/** Handles the scroll event on the dialog content. */
const handleScroll = useEventCallback((ref: HTMLDivElement | null) => {
scrollerRef.current = ref
React.startTransition(() => {
if (ref && ref.scrollTop > 0) {
setIsScrolledToTop(false)
} else {
setIsScrolledToTop(true)
}
})
})
const handleScrollEvent = useEventCallback((event: React.UIEvent<HTMLDivElement>) => {
handleScroll(event.currentTarget)
})
React.useEffect(() => {
if (isFullscreen) {
return
@ -317,13 +302,13 @@ function DialogContent(props: DialogContentProps) {
rounded,
hideCloseButton,
closeButton,
scrolledToTop: isScrolledToTop,
size,
padding,
fitContent,
layout,
})
const dialogHeight = () => {
const getDialogHeight = () => {
if (isFullscreen) {
return ''
}
@ -340,7 +325,7 @@ function DialogContent(props: DialogContentProps) {
<MotionDialog
layout
transition={TRANSITION}
style={{ height: dialogHeight() }}
style={{ height: getDialogHeight() }}
id={dialogId}
onLayoutAnimationStart={() => {
if (scrollerRef.current) {
@ -362,7 +347,7 @@ function DialogContent(props: DialogContentProps) {
// This is a temporary solution until we refactor the Dialog component
// to use `useDialog` hook from the 'react-aria-components' library.
// this will allow us to set the `data-testid` attribute on the dialog
element.dataset.testId = testId
element.dataset.testid = testId
}
})(ref)
}}
@ -372,54 +357,44 @@ function DialogContent(props: DialogContentProps) {
>
{(opts) => (
<>
<dialogProvider.DialogProvider close={opts.close} dialogId={dialogId}>
<motion.div layout className="w-full" transition={{ duration: 0 }}>
<DialogHeader
closeButton={closeButton}
title={title}
titleId={titleId}
headerClassName={styles.header({ scrolledToTop: isScrolledToTop })}
closeButtonClassName={styles.closeButton()}
headingClassName={styles.heading()}
headerDimensionsRef={headerDimensionsRef}
/>
</motion.div>
<motion.div layout className="w-full" transition={{ duration: 0 }}>
<DialogHeader
closeButton={closeButton}
title={title}
titleId={titleId}
scrollerRef={scrollerRef}
fitContent={fitContent}
hideCloseButton={hideCloseButton}
padding={padding}
rounded={rounded}
size={size}
type={type}
headerDimensionsRef={headerDimensionsRef}
close={opts.close}
variants={variants}
/>
</motion.div>
<motion.div
layout
layoutScroll
className={styles.scroller()}
ref={handleScroll}
onScroll={handleScrollEvent}
transition={{ duration: 0 }}
<motion.div
layout
layoutScroll
className={styles.scroller()}
ref={scrollerRef}
transition={{ duration: 0 }}
>
<DialogBody
close={opts.close}
contentDimensionsRef={contentDimensionsRef}
dialogId={dialogId}
headerDimensionsRef={headerDimensionsRef}
scrollerRef={scrollerRef}
measurerWrapperClassName={styles.measurerWrapper()}
contentClassName={styles.content()}
type={type}
>
<div className={styles.measurerWrapper()}>
{/* eslint-disable jsdoc/check-alignment */}
{/**
* This div is used to measure the content dimensions.
* It's takes the same grid area as the content, thus
* resizes together with the content.
*
* We use grid + grid-area to avoid setting `position: relative`
* on the element, which would interfere with the layout.
*
* It's set to `pointer-events-none` so that it doesn't
* interfere with the layout.
*/}
{/* eslint-enable jsdoc/check-alignment */}
<div ref={contentDimensionsRef} className={styles.measurer()} />
<div className={styles.content()}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense
loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}
>
{typeof children === 'function' ? children(opts) : children}
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</div>
</motion.div>
</dialogProvider.DialogProvider>
{children}
</DialogBody>
</motion.div>
</>
)}
</MotionDialog>
@ -429,48 +404,143 @@ function DialogContent(props: DialogContentProps) {
)
}
/**
* Props for the {@link DialogBody} component.
*/
interface DialogBodyProps {
readonly dialogId: string
readonly contentDimensionsRef: (node: HTMLElement | null) => void
readonly headerDimensionsRef: (node: HTMLElement | null) => void
readonly scrollerRef: React.RefObject<HTMLDivElement>
readonly close: () => void
readonly measurerWrapperClassName: string
readonly contentClassName: string
readonly children: DialogProps['children']
readonly type: DialogProps['type']
}
/**
* The internals of a dialog. Exists only as a performance optimization.
*/
// eslint-disable-next-line no-restricted-syntax
const DialogBody = React.memo(function DialogBody(props: DialogBodyProps) {
const {
close,
contentDimensionsRef,
dialogId,
children,
measurerWrapperClassName,
contentClassName,
type,
} = props
return (
<div className={measurerWrapperClassName}>
<div ref={contentDimensionsRef} className={contentClassName}>
<errorBoundary.ErrorBoundary>
<suspense.Suspense loaderProps={{ minHeight: type === 'fullscreen' ? 'full' : 'h32' }}>
<dialogProvider.DialogProvider close={close} dialogId={dialogId}>
{typeof children === 'function' ? children({ close }) : children}
</dialogProvider.DialogProvider>
</suspense.Suspense>
</errorBoundary.ErrorBoundary>
</div>
</div>
)
})
/**
* Props for the {@link DialogHeader} component.
*/
interface DialogHeaderProps {
readonly headerClassName: string
readonly closeButtonClassName: string
readonly headingClassName: string
interface DialogHeaderProps extends Omit<VariantProps<typeof DIALOG_STYLES>, 'scrolledToTop'> {
readonly closeButton: DialogProps['closeButton']
readonly title: DialogProps['title']
readonly titleId: string
readonly headerDimensionsRef: (node: HTMLElement | null) => void
readonly scrollerRef: React.RefObject<HTMLDivElement>
readonly close: () => void
}
/**
* The header of a dialog.
* @internal
*/
// eslint-disable-next-line no-restricted-syntax
const DialogHeader = React.memo(function DialogHeader(props: DialogHeaderProps) {
const {
closeButton,
title,
titleId,
headerClassName,
closeButtonClassName,
headingClassName,
headerDimensionsRef,
scrollerRef,
fitContent,
hideCloseButton,
padding,
rounded,
size,
type,
variants = DIALOG_STYLES,
close,
layout,
} = props
const { close } = dialogProvider.useDialogStrictContext()
const styles = variants({
type,
closeButton,
fitContent,
hideCloseButton,
padding,
rounded,
size,
layout,
})
const [isScrolledToTop, privateSetIsScrolledToTop] = React.useState(true)
const setIsScrolledToTop = React.useCallback(
(value: boolean) => {
React.startTransition(() => {
privateSetIsScrolledToTop(value)
})
},
[privateSetIsScrolledToTop],
)
/** Handles the scroll event on the dialog content. */
const handleScrollEvent = useEventCallback(() => {
if (scrollerRef.current) {
setIsScrolledToTop(scrollerRef.current.scrollTop === 0)
} else {
setIsScrolledToTop(true)
}
})
React.useEffect(() => {
const scroller = scrollerRef.current
if (scroller) {
handleScrollEvent()
scroller.addEventListener('scroll', handleScrollEvent, { passive: true })
return () => {
scroller.removeEventListener('scroll', handleScrollEvent)
}
}
}, [handleScrollEvent, scrollerRef])
return (
<aria.Header ref={headerDimensionsRef} className={headerClassName}>
<aria.Header
ref={headerDimensionsRef}
className={styles.header({ scrolledToTop: isScrolledToTop })}
>
{closeButton !== 'none' && (
<ariaComponents.CloseButton className={closeButtonClassName} onPress={close} />
<ariaComponents.CloseButton className={styles.closeButton()} onPress={close} />
)}
{title != null && (
<ariaComponents.Text.Heading
id={titleId}
level={2}
className={headingClassName}
className={styles.heading()}
weight="semibold"
>
{title}

View File

@ -2,7 +2,7 @@
* @file
* Component that passes the value of a field to its children.
*/
import { useDeferredValue, type ReactNode } from 'react'
import { memo, useDeferredValue, type ReactNode } from 'react'
import { useWatch } from 'react-hook-form'
import { useFormContext } from './FormProvider'
import type { FieldPath, FieldValues, FormInstanceValidated, TSchema } from './types'
@ -26,11 +26,21 @@ export function FieldValue<Schema extends TSchema, TFieldName extends FieldPath<
const { form, name, children, disabled = false } = props
const formInstance = useFormContext(form)
const watchValue = useWatch({ control: formInstance.control, name, disabled })
const value = useWatch({ control: formInstance.control, name, disabled })
// We use deferred value here to rate limit the re-renders of the children.
// This is useful when the children are expensive to render, such as a component tree.
const deferredValue = useDeferredValue(watchValue)
const deferredValue = useDeferredValue(value)
return children(deferredValue)
return <MemoChildren children={children} value={deferredValue} />
}
// Wrap the childer to make the deferredValue to work
// see: https://react.dev/reference/react/useDeferredValue#deferring-re-rendering-for-a-part-of-the-ui
// eslint-disable-next-line no-restricted-syntax
const MemoChildren = memo(function MemoChildren<T>(props: {
children: (value: T) => ReactNode
value: T
}) {
return props.children(props.value)
}) as unknown as <T>(props: { children: (value: T) => ReactNode; value: T }) => ReactNode

View File

@ -3,7 +3,6 @@
*
* Hook to get the state of a field.
*/
import { useFormState } from 'react-hook-form'
import { useFormContext } from './FormProvider'
import type { FieldPath, FormInstanceValidated, TSchema } from './types'
@ -23,18 +22,10 @@ export function useFieldState<Schema extends TSchema, TFieldName extends FieldPa
const { name } = options
const form = useFormContext(options.form)
const { errors, dirtyFields, isValidating, touchedFields } = useFormState({
control: form.control,
name,
})
const isDirty = name in dirtyFields
const isTouched = name in touchedFields
const error = errors[name]?.message?.toString()
const { error, isDirty, isTouched, isValidating } = form.getFieldState(name)
return {
error,
error: error?.message?.toString(),
isDirty,
isTouched,
isValidating,

View File

@ -159,6 +159,8 @@ export function useForm<Schema extends types.TSchema, SubmitResult = void>(
closeRef.current()
}
formInstance.reset()
return result
} catch (error) {
const isJSError = errorUtils.isJSError(error)

View File

@ -6,12 +6,14 @@ import EyeIcon from '#/assets/eye.svg'
import EyeCrossedIcon from '#/assets/eye_crossed.svg'
import {
Button,
Form,
Input,
type FieldPath,
type FieldValues,
type InputProps,
type TSchema,
} from '#/components/AriaComponents'
import { AnimatePresence, motion } from 'framer-motion'
// ================
// === Password ===
@ -29,6 +31,8 @@ export function Password<Schema extends TSchema, TFieldName extends Path<FieldVa
) {
const [showPassword, setShowPassword] = useState(false)
const form = Form.useFormContext(props.form)
return (
<Input
{...props}
@ -37,15 +41,33 @@ export function Password<Schema extends TSchema, TFieldName extends Path<FieldVa
<>
{props.addonEnd}
<Button
size="medium"
variant="icon"
extraClickZone
icon={showPassword ? EyeIcon : EyeCrossedIcon}
onPress={() => {
setShowPassword(!showPassword)
}}
/>
<Form.FieldValue form={form} name={props.name}>
{(value) => (
<AnimatePresence>
{value != null && value.length > 0 && (
<motion.div
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
initial={{ opacity: 0, x: 10, rotateY: 30 }}
animate={{ opacity: 1, x: 0, rotateY: 0 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
exit={{ opacity: 0, x: 10, rotateY: 30 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
transition={{ duration: 0.2, ease: 'easeInOut' }}
>
<Button
size="medium"
variant="icon"
extraClickZone
icon={showPassword ? EyeIcon : EyeCrossedIcon}
onPress={() => {
setShowPassword(!showPassword)
}}
/>
</motion.div>
)}
</AnimatePresence>
)}
</Form.FieldValue>
</>
}
/>

View File

@ -71,7 +71,8 @@ export const SELECTOR_OPTION_STYLES = tv({
'relative flex flex-1 w-full items-center justify-center transition-colors duration-200',
variant: 'body',
}),
hover: 'absolute inset-x-0 inset-y-0 transition-colors duration-200',
hover:
'absolute inset-x-0 inset-y-0 transition-[background-color,transform] duration-200 isolate',
},
compoundSlots: [
{
@ -125,12 +126,12 @@ export const SELECTOR_OPTION_STYLES = tv({
variant: 'outline',
isHovered: true,
isSelected: false,
class: { hover: 'bg-primary/5' },
class: { hover: 'bg-invert/50' },
},
{
variant: 'outline',
isPressed: true,
class: { hover: 'bg-primary/10' },
class: { hover: 'bg-invert scale-x-[0.95] scale-y-[0.85]' },
},
{
variant: 'outline',
@ -190,8 +191,8 @@ export const SelectorOption = memo(
>
{({ isHovered, isSelected, isPressed }) => (
<>
{label}
<div className={styles.hover({ isHovered, isSelected, isPressed })} />
<span className="isolate">{label}</span>
</>
)}
</Radio>

View File

@ -76,7 +76,7 @@ export function useRadioGroupContext(props: UseRadioGroupContextProps) {
/**
* Tells if a sibling Radio element is being pressed
* It's not the same as selected value, instead it says if a user is clicking on a sibling Radio element at the moment
* It's not the same as selected value, instead it says if a user is clicking on a sibling Radio element now.
*/
const isSiblingPressed = context.pressedRadio != null && value !== context.pressedRadio

View File

@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/react'
import type { TextProps } from './Text'
import { Text } from './Text'
export default {
title: 'AriaComponents/Text',
component: Text,
args: {
children: 'Hello, world!',
},
} as Meta<TextProps>
type Story = StoryObj<TextProps>
export const Variants: Story = {
render: (args) => (
<section className="flex flex-col gap-4">
<Text {...args} variant="h1">
Lorem ipsum dolor sit amet h1.
</Text>
<Text {...args} variant="subtitle">
Lorem ipsum dolor sit amet subtitle.
</Text>
<Text {...args} variant="body">
Lorem ipsum dolor sit amet body.
</Text>
<Text {...args} variant="body-sm">
Lorem ipsum dolor sit amet body-sm.
</Text>
<Text {...args} variant="caption">
Lorem ipsum dolor sit amet caption.
</Text>
<Text {...args} variant="overline">
Lorem ipsum dolor sit amet overline.
</Text>
</section>
),
}
export const Colors: Story = {
render: (args) => (
<section className="flex flex-col gap-4">
<Text {...args} color="primary">
Lorem ipsum dolor sit amet primary.
</Text>
<Text {...args} color="danger">
Lorem ipsum dolor sit amet danger.
</Text>
<Text {...args} color="invert" className="bg-primary">
Lorem ipsum dolor sit amet invert.
</Text>
<Text {...args} color="success">
Lorem ipsum dolor sit amet success.
</Text>
<Text {...args} color="disabled">
Lorem ipsum dolor sit amet disabled.
</Text>
<Text {...args} color="custom" className="text-youtube">
Lorem ipsum dolor sit amet custom.
</Text>
</section>
),
}
export const Rest: Story = {
render: (args) => (
<>
<Text {...args} balance className="block w-48">
Lorem ipsum dolor sit amet slkdmflkasd Balance.
</Text>
<Text {...args} truncate="1" className="block w-48">
Text truncate 1. Should display tooltip on hover.
</Text>
<Text {...args} variant="h1" truncate="2" className="w-48">
Text truncate 2. Should display tooltip on hover. Does not work with custom display.
</Text>
<Text {...args} truncate="custom" lineClamp={2} className="w-48">
Text truncate custom. Should display tooltip on hover. Does not work with custom display.
</Text>
<Text {...args} weight="thin" className="w-48">
Text weight thin.
</Text>
<Text {...args} weight="normal" className="w-48">
Text weight normal.
</Text>
<Text {...args} weight="medium" className="w-48">
Text weight medium.
</Text>
<Text {...args} weight="semibold" className="w-48">
Text weight semibold.
</Text>
<Text {...args} weight="bold" className="w-48">
Text weight bold.
</Text>
<Text {...args} weight="extraBold" className="w-48">
Text weight extraBold.
</Text>
</>
),
}

View File

@ -2,10 +2,12 @@
* @file
* This file provides a zustand store that contains the state of the Enso devtools.
*/
import type { PaywallFeatureName } from '#/hooks/billing'
import { type PaywallFeatureName, PAYWALL_FEATURES } from '#/hooks/billing'
import { unsafeEntries, unsafeFromEntries } from '#/utilities/object'
import * as zustand from '#/utilities/zustand'
import { IS_DEV_MODE } from 'enso-common/src/detect'
import { MotionGlobalConfig } from 'framer-motion'
import { persist } from 'zustand/middleware'
/** Configuration for a paywall feature. */
export interface PaywallDevtoolsFeatureConfiguration {
@ -29,46 +31,52 @@ interface EnsoDevtoolsStore {
readonly setAnimationsDisabled: (animationsDisabled: boolean) => void
}
export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>((set) => ({
showDevtools: IS_DEV_MODE,
setShowDevtools: (showDevtools) => {
set({ showDevtools })
},
toggleDevtools: () => {
set(({ showDevtools }) => ({ showDevtools: !showDevtools }))
},
showVersionChecker: false,
paywallFeatures: {
share: { isForceEnabled: null },
shareFull: { isForceEnabled: null },
userGroups: { isForceEnabled: null },
userGroupsFull: { isForceEnabled: null },
inviteUser: { isForceEnabled: null },
inviteUserFull: { isForceEnabled: null },
},
setPaywallFeature: (feature, isForceEnabled) => {
set((state) => ({
paywallFeatures: { ...state.paywallFeatures, [feature]: { isForceEnabled } },
}))
},
setEnableVersionChecker: (showVersionChecker) => {
set({ showVersionChecker })
},
animationsDisabled: localStorage.getItem('disableAnimations') === 'true',
setAnimationsDisabled: (animationsDisabled) => {
if (animationsDisabled) {
localStorage.setItem('disableAnimations', 'true')
MotionGlobalConfig.skipAnimations = true
document.documentElement.classList.add('disable-animations')
} else {
localStorage.setItem('disableAnimations', 'false')
MotionGlobalConfig.skipAnimations = false
document.documentElement.classList.remove('disable-animations')
}
export const ensoDevtoolsStore = zustand.createStore<EnsoDevtoolsStore>()(
persist(
(set) => ({
showDevtools: IS_DEV_MODE,
setShowDevtools: (showDevtools) => {
set({ showDevtools })
},
toggleDevtools: () => {
set(({ showDevtools }) => ({ showDevtools: !showDevtools }))
},
showVersionChecker: false,
paywallFeatures: unsafeFromEntries(
unsafeEntries(PAYWALL_FEATURES).map(([feature]) => [feature, { isForceEnabled: null }]),
),
setPaywallFeature: (feature, isForceEnabled) => {
set((state) => ({
paywallFeatures: { ...state.paywallFeatures, [feature]: { isForceEnabled } },
}))
},
setEnableVersionChecker: (showVersionChecker) => {
set({ showVersionChecker })
},
animationsDisabled: localStorage.getItem('disableAnimations') === 'true',
setAnimationsDisabled: (animationsDisabled) => {
if (animationsDisabled) {
localStorage.setItem('disableAnimations', 'true')
MotionGlobalConfig.skipAnimations = true
document.documentElement.classList.add('disable-animations')
} else {
localStorage.setItem('disableAnimations', 'false')
MotionGlobalConfig.skipAnimations = false
document.documentElement.classList.remove('disable-animations')
}
set({ animationsDisabled })
},
}))
set({ animationsDisabled })
},
}),
{
name: 'ensoDevtools',
partialize: (state) => ({
showDevtools: state.showDevtools,
animationsDisabled: state.animationsDisabled,
}),
},
),
)
// ===============================
// === useEnableVersionChecker ===

View File

@ -14,12 +14,20 @@ import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import * as result from '#/components/Result'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as errorUtils from '#/utilities/error'
// =====================
// === ErrorBoundary ===
// =====================
/** Arguments for the {@link ErrorBoundaryProps.onBeforeFallbackShown} callback. */
export interface OnBeforeFallbackShownArgs {
readonly error: unknown
readonly resetErrorBoundary: () => void
readonly resetQueries: () => void
}
/** Props for an {@link ErrorBoundary}. */
export interface ErrorBoundaryProps
extends Readonly<React.PropsWithChildren>,
@ -28,7 +36,12 @@ export interface ErrorBoundaryProps
errorBoundary.ErrorBoundaryProps,
'FallbackComponent' | 'onError' | 'onReset' | 'resetKeys'
>
> {}
> {
/** Called before the fallback is shown. */
readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void
readonly title?: string
readonly subtitle?: string
}
/**
* Catches errors in child components
@ -40,6 +53,9 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
FallbackComponent = ErrorDisplay,
onError = () => {},
onReset = () => {},
onBeforeFallbackShown = () => {},
title,
subtitle,
...rest
} = props
@ -47,7 +63,15 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
<reactQuery.QueryErrorResetBoundary>
{({ reset }) => (
<errorBoundary.ErrorBoundary
FallbackComponent={FallbackComponent}
FallbackComponent={(fallbackProps) => (
<FallbackComponent
{...fallbackProps}
onBeforeFallbackShown={onBeforeFallbackShown}
resetQueries={reset}
title={title}
subtitle={subtitle}
/>
)}
onError={(error, info) => {
sentry.captureException(error, { extra: { info } })
onError(error, info)
@ -66,8 +90,10 @@ export function ErrorBoundary(props: ErrorBoundaryProps) {
/** Props for a {@link ErrorDisplay}. */
export interface ErrorDisplayProps extends errorBoundary.FallbackProps {
readonly status?: result.ResultProps['status']
readonly title?: string
readonly subtitle?: string
readonly onBeforeFallbackShown?: (args: OnBeforeFallbackShownArgs) => void
readonly resetQueries?: () => void
readonly title?: string | undefined
readonly subtitle?: string | undefined
readonly error: unknown
}
@ -79,48 +105,63 @@ export function ErrorDisplay(props: ErrorDisplayProps): React.JSX.Element {
const {
error,
resetErrorBoundary,
title = getText('appErroredMessage'),
title = getText('somethingWentWrong'),
subtitle = isOffline ? getText('offlineErrorMessage') : getText('arbitraryErrorSubtitle'),
status = isOffline ? 'info' : 'error',
onBeforeFallbackShown,
resetQueries = () => {},
} = props
const message = errorUtils.getMessageOrToString(error)
const stack = errorUtils.tryGetStack(error)
onBeforeFallbackShown?.({ error, resetErrorBoundary, resetQueries })
const onReset = useEventCallback(() => {
resetErrorBoundary()
})
return (
<result.Result className="h-full" status={status} title={title} subtitle={subtitle}>
<ariaComponents.Text color="danger" variant="body">
{getText('errorColon')}
{message}
</ariaComponents.Text>
<ariaComponents.ButtonGroup align="center">
<ariaComponents.Button
variant="submit"
size="small"
rounded="full"
className="w-24"
onPress={() => {
resetErrorBoundary()
}}
onPress={onReset}
>
{getText('tryAgain')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
{detect.IS_DEV_MODE && stack != null && (
<ariaComponents.Alert
className="mx-auto mt-4 max-h-[80vh] max-w-screen-lg overflow-auto"
variant="neutral"
>
<ariaComponents.Text
elementType="pre"
className="whitespace-pre-wrap text-left"
color="primary"
variant="body"
>
{stack}
<div className="mt-6">
<ariaComponents.Separator className="my-2" />
<ariaComponents.Text color="primary" variant="h1" className="text-start">
{getText('developerInfo')}
</ariaComponents.Text>
</ariaComponents.Alert>
<ariaComponents.Text color="danger" variant="body">
{getText('errorColon')}
{message}
</ariaComponents.Text>
<ariaComponents.Alert
className="mx-auto mt-2 max-h-[80vh] max-w-screen-lg overflow-auto"
variant="neutral"
>
<ariaComponents.Text
elementType="pre"
className="whitespace-pre-wrap text-left"
color="primary"
variant="body"
>
{stack}
</ariaComponents.Text>
</ariaComponents.Alert>
</div>
)}
</result.Result>
)

View File

@ -13,18 +13,23 @@ const STYLES = twv.tv({
variants: {
minHeight: {
full: 'h-full',
h6: 'h-6',
h8: 'h-8',
h10: 'h-10',
h12: 'h-12',
h16: 'h-16',
h20: 'h-20',
h24: 'h-24',
h32: 'h-32',
h40: 'h-40',
h48: 'h-48',
h56: 'h-56',
h64: 'h-64',
h6: 'min-h-6',
h8: 'min-h-8',
h10: 'min-h-10',
h12: 'min-h-12',
h16: 'min-h-16',
h20: 'min-h-20',
h24: 'min-h-24',
h32: 'min-h-32',
h40: 'min-h-40',
h48: 'min-h-48',
h56: 'min-h-56',
h64: 'min-h-64',
screen: 'min-h-screen',
custom: '',
},
height: {
full: 'h-full',
screen: 'h-screen',
custom: '',
},
@ -70,12 +75,13 @@ export const Loader = memo(function Loader(props: LoaderProps) {
state = 'loading-fast',
minHeight = 'full',
color = 'primary',
height = 'full',
} = props
const size = typeof sizeRaw === 'number' ? sizeRaw : SIZE_MAP[sizeRaw]
return (
<div className={STYLES({ minHeight, className, color })}>
<div className={STYLES({ minHeight, className, color, height })}>
<StatelessSpinner size={size} state={state} className="text-current" />
</div>
)

View File

@ -88,7 +88,7 @@ export interface MenuEntryProps extends tailwindVariants.VariantProps<typeof MEN
readonly action: inputBindings.DashboardBindingKey
/** Overrides the text for the menu entry. */
readonly label?: string | undefined
readonly tooltip?: string | undefined
readonly tooltip?: string | null | undefined
/** When true, the button is not clickable. */
readonly isDisabled?: boolean | undefined
readonly title?: string | undefined

View File

@ -15,30 +15,36 @@ import * as modalProvider from '#/providers/ModalProvider'
import type * as contextMenuEntry from '#/components/ContextMenuEntry'
import ContextMenuEntryBase from '#/components/ContextMenuEntry'
import { useText } from '#/providers/TextProvider'
import * as paywallDialog from './PaywallDialog'
/** Props for {@link ContextMenuEntry}. */
export interface ContextMenuEntryProps
extends Omit<contextMenuEntry.ContextMenuEntryProps, 'doAction' | 'isDisabled'> {
extends Omit<contextMenuEntry.ContextMenuEntryProps, 'isDisabled'> {
readonly feature: billingHooks.PaywallFeatureName
readonly isUnderPaywall: boolean
}
/** A context menu entry that opens a paywall dialog. */
export function ContextMenuEntry(props: ContextMenuEntryProps) {
const { feature, ...rest } = props
const { feature, isUnderPaywall, doAction, icon, ...rest } = props
const { setModal } = modalProvider.useSetModal()
const { getText } = useText()
return (
<>
<ContextMenuEntryBase
{...rest}
icon={LockIcon}
doAction={() => {
<ContextMenuEntryBase
{...rest}
icon={isUnderPaywall ? LockIcon : icon}
tooltip={isUnderPaywall ? getText('upgradeToUseCloud') : null}
doAction={() => {
if (isUnderPaywall) {
setModal(
<paywallDialog.PaywallDialog modalProps={{ defaultOpen: true }} feature={feature} />,
)
}}
/>
</>
} else {
doAction()
}
}}
/>
)
}

View File

@ -38,12 +38,7 @@ export function PaywallDialog(props: PaywallDialogProps) {
<components.PaywallBulletPoints bulletPointsTextId={bulletPointsTextId} className="my-2" />
<upgradeButton.UpgradeButton
feature={feature}
rounded="xlarge"
className="mt-2"
size="large"
/>
<upgradeButton.UpgradeButton feature={feature} className="mt-2" size="large" />
</div>
</ariaComponents.Dialog>
)

View File

@ -64,6 +64,6 @@ const VARIANT_BY_LEVEL: Record<
> = {
free: 'primary',
enterprise: 'primary',
solo: 'outline',
solo: 'accent',
team: 'submit',
}

View File

@ -149,7 +149,7 @@ export function Step(props: StepProps) {
}}
transition={{
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
rotate: { type: 'spring', stiffness: 500, damping: 100, bounce: 0, duration: 0.2 },
rotate: { type: 'spring', stiffness: 2000, damping: 25, mass: 1 },
}}
>
{(() => {

View File

@ -169,7 +169,7 @@ export function Stepper(props: StepperProps) {
}}
transition={{
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
x: { type: 'spring', stiffness: 500, damping: 50, duration: 0.2 },
x: { type: 'spring', stiffness: 500, damping: 50, mass: 2 },
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
opacity: { duration: 0.2 },
}}

View File

@ -35,9 +35,31 @@ const OFFLINE_FETCHING_TOGGLE_DELAY_MS = 250
* And handles offline scenarios.
*/
export function Suspense(props: SuspenseProps) {
const { children } = props
const { children, loaderProps, fallback, offlineFallback, offlineFallbackProps } = props
return <React.Suspense fallback={<Loader {...props} />}>{children}</React.Suspense>
return (
<React.Suspense
fallback={
<Loader
{...loaderProps}
fallback={fallback}
offlineFallback={offlineFallback}
offlineFallbackProps={offlineFallbackProps}
/>
}
>
{children}
</React.Suspense>
)
}
/**
* Props for {@link Loader} component.
*/
interface LoaderProps extends loader.LoaderProps {
readonly fallback?: SuspenseProps['fallback']
readonly offlineFallback?: SuspenseProps['offlineFallback']
readonly offlineFallbackProps?: SuspenseProps['offlineFallbackProps']
}
/**
@ -51,8 +73,8 @@ export function Suspense(props: SuspenseProps) {
* We check the fetching status in fallback component because
* we want to know if there are ongoing requests once React renders the fallback in suspense
*/
export function Loader(props: SuspenseProps) {
const { loaderProps, fallback, offlineFallbackProps, offlineFallback } = props
export function Loader(props: LoaderProps) {
const { fallback, offlineFallbackProps, offlineFallback, ...loaderProps } = props
const { getText } = textProvider.useText()

View File

@ -26,8 +26,8 @@ import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import { isCloudCategory, isLocalCategory } from '#/layouts/CategorySwitcher/Category'
import * as eventListProvider from '#/layouts/Drive/EventListProvider'
import * as localBackend from '#/services/LocalBackend'
import * as backendModule from '#/services/Backend'
@ -44,7 +44,7 @@ import {
import { createGetProjectDetailsQuery } from '#/hooks/projectHooks'
import { useSyncRef } from '#/hooks/syncRefHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import { useAsset } from '#/layouts/AssetsTable/assetsTableItemsHooks'
import { useAsset } from '#/layouts/Drive/assetsTableItemsHooks'
import { useFullUserSession } from '#/providers/AuthProvider'
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import { download } from '#/utilities/download'
@ -669,7 +669,7 @@ export function RealAssetInternalRow(props: RealAssetRowInternalProps) {
return (
<>
{!hidden && (
<FocusRing>
<FocusRing placement="outset">
<tr
data-testid="asset-row"
tabIndex={0}

View File

@ -6,7 +6,7 @@ import { Button } from '#/components/AriaComponents'
import type { AssetColumnProps } from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
import AssetEventType from '#/events/AssetEventType'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useDispatchAssetEvent } from '#/layouts/Drive/EventListProvider'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useSetModal } from '#/providers/ModalProvider'

View File

@ -90,7 +90,6 @@ export function getColumnList(
): readonly Column[] {
const isCloud = backendType === backend.BackendType.remote
const isEnterprise = user.plan === backend.Plan.enterprise
const isTeam = user.plan === backend.Plan.team
const isTrash = category.type === 'trash'
const isRecent = category.type === 'recent'
@ -100,7 +99,7 @@ export function getColumnList(
if (isTrash) return false
if (isRecent) return false
if (isRoot) return false
return isCloud && (isEnterprise || isTeam) && Column.sharedWith
return isCloud && isEnterprise && Column.sharedWith
}
const columns = [

View File

@ -1,8 +1,8 @@
/** @file Events related to changes in the asset list. */
import AssetListEventType from '#/events/AssetListEventType'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useDispatchAssetListEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useTransferBetweenCategories, type Category } from '#/layouts/CategorySwitcher/Category'
import { useDispatchAssetListEvent } from '#/layouts/Drive/EventListProvider'
import type { DrivePastePayload } from '#/providers/DriveProvider'
import type * as backend from '#/services/Backend'

View File

@ -334,8 +334,12 @@ export function listDirectoryQueryOptions(options: ListDirectoryQueryOptions) {
},
parentId,
)
} catch {
throw Object.assign(new Error(), { parentId })
} catch (e) {
if (e instanceof Error) {
throw Object.assign(e, { parentId })
} else {
throw e
}
}
},

View File

@ -16,6 +16,7 @@ export const PAYWALL_FEATURES = {
inviteUserFull: 'inviteUserFull',
share: 'share',
shareFull: 'shareFull',
uploadToCloud: 'uploadToCloud',
} as const
/** Paywall features. */
@ -64,6 +65,7 @@ const PAYWALL_FEATURES_LABELS: Record<PaywallFeatureName, text.TextId> = {
inviteUserFull: 'inviteUserFullFeatureLabel',
share: 'shareFeatureLabel',
shareFull: 'shareFullFeatureLabel',
uploadToCloud: 'uploadToCloudFeatureLabel',
} satisfies { [K in PaywallFeatureName]: `${K}FeatureLabel` }
const PAYWALL_FEATURE_META = {
@ -74,6 +76,7 @@ const PAYWALL_FEATURE_META = {
userGroupsFull: undefined,
share: undefined,
shareFull: undefined,
uploadToCloud: undefined,
} satisfies { [K in PaywallFeatureName]: unknown }
/** Basic feature configuration. */
@ -92,6 +95,11 @@ export type FeatureConfiguration<Key extends PaywallFeatureName = PaywallFeature
}
const PAYWALL_CONFIGURATION: Record<PaywallFeatureName, BasicFeatureConfiguration> = {
uploadToCloud: {
level: PAYWALL_LEVELS.solo,
bulletPointsTextId: 'uploadToCloudFeatureBulletPoints',
descriptionTextId: 'uploadToCloudFeatureDescription',
},
userGroups: {
level: PAYWALL_LEVELS.team,
bulletPointsTextId: 'userGroupsFeatureBulletPoints',

View File

@ -4,7 +4,7 @@
* Barrel file for billing hooks.
*/
export { PAYWALL_LEVELS } from './FeaturesConfiguration'
export { PAYWALL_FEATURES, PAYWALL_LEVELS } from './FeaturesConfiguration'
export type { PaywallFeatureName, PaywallLevel, PaywallLevelName } from './FeaturesConfiguration'
export * from './paywallFeaturesHooks'
export * from './paywallHooks'

View File

@ -127,8 +127,8 @@ export function run(props: DashboardProps) {
reactDOM.createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<ErrorBoundary>
<ErrorBoundary>
<UIProviders locale="en-US" portalRoot={portalRoot}>
<Suspense fallback={<LoadingScreen />}>
<OfflineNotificationManager>
<LoggerProvider logger={logger}>
@ -138,10 +138,10 @@ export function run(props: DashboardProps) {
</LoggerProvider>
</OfflineNotificationManager>
</Suspense>
</ErrorBoundary>
<ReactQueryDevtools />
</UIProviders>
<ReactQueryDevtools />
</UIProviders>
</ErrorBoundary>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -17,15 +17,14 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as categoryModule from '#/layouts/CategorySwitcher/Category'
import * as eventListProvider from '#/layouts/Drive/EventListProvider'
import { GlobalContextMenu } from '#/layouts/GlobalContextMenu'
import ContextMenu from '#/components/ContextMenu'
import ContextMenuEntry from '#/components/ContextMenuEntry'
import ContextMenus from '#/components/ContextMenus'
import type * as assetRow from '#/components/dashboard/AssetRow'
import * as paywall from '#/components/Paywall'
import Separator from '#/components/styled/Separator'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
@ -35,6 +34,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import * as backendModule from '#/services/Backend'
import * as localBackendModule from '#/services/LocalBackend'
import { ContextMenuEntry as PaywallContextMenuEntry } from '#/components/Paywall'
import { useNewProject, useUploadFileWithToastMutation } from '#/hooks/backendHooks'
import { usePasteData } from '#/providers/DriveProvider'
import { normalizePath } from '#/utilities/fileInfo'
@ -186,6 +186,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/>
)
const canUploadToCloud = user.plan !== backendModule.Plan.free
return (
category.type === 'trash' ?
!ownsThisAsset ? null
@ -297,8 +299,10 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/>
)}
{asset.type === backendModule.AssetType.project && !isCloud && (
<ContextMenuEntry
<PaywallContextMenuEntry
hidden={hidden}
isUnderPaywall={!canUploadToCloud}
feature="uploadToCloud"
action="uploadToCloud"
doAction={async () => {
try {
@ -429,35 +433,29 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
{isCloud && <Separator hidden={hidden} />}
{isCloud && managesThisAsset && self != null && (
<>
{isUnderPaywall && (
<paywall.ContextMenuEntry feature="share" action="share" hidden={hidden} />
)}
{!isUnderPaywall && (
<ContextMenuEntry
hidden={hidden}
action="share"
doAction={() => {
setModal(
<ManagePermissionsModal
backend={backend}
category={category}
item={asset}
self={self}
eventTarget={eventTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
id: asset.id,
})
}}
/>,
)
}}
/>
)}
</>
<PaywallContextMenuEntry
feature="share"
isUnderPaywall={isUnderPaywall}
action="share"
hidden={hidden}
doAction={() => {
setModal(
<ManagePermissionsModal
backend={backend}
category={category}
item={asset}
self={self}
eventTarget={eventTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
id: asset.id,
})
}}
/>,
)
}}
/>
)}
{isCloud && (

View File

@ -2,10 +2,10 @@
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 { StatelessSpinner } from '../../components/StatelessSpinner'
// =====================
// === AssetDiffView ===
@ -40,7 +40,7 @@ export function AssetDiffView(props: AssetDiffViewProps) {
const loader = (
<div className="flex h-full w-full items-center justify-center">
<Spinner size={32} state="loading-medium" />
<StatelessSpinner size={32} state="loading-medium" />
</div>
)

View File

@ -47,7 +47,7 @@ export interface AssetPanelProps {
* 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) {
export const AssetPanel = memo(function AssetPanel(props: AssetPanelProps) {
const isHidden = useStore(assetPanelStore, (state) => state.isAssetPanelHidden, {
unsafeEnableTransition: true,
})
@ -90,7 +90,7 @@ export function AssetPanel(props: AssetPanelProps) {
</AnimatePresence>
</div>
)
}
})
/**
* The internal implementation of the Asset Panel Tabs.

View File

@ -335,6 +335,8 @@ function AssetSearchBar(props: AssetSearchBarProps) {
}
})
const deferredSuggestions = React.useDeferredValue(suggestions)
return (
<FocusArea direction="horizontal">
{(innerProps) => (
@ -369,7 +371,7 @@ function AssetSearchBar(props: AssetSearchBarProps) {
querySource={querySource}
query={query}
setQuery={setQuery}
suggestions={suggestions}
suggestions={deferredSuggestions}
selectedIndex={selectedIndex}
setAreSuggestionsVisible={setAreSuggestionsVisible}
baseQuery={baseQuery}
@ -471,7 +473,9 @@ interface AssetSearchBarPopoverProps {
/**
* Renders the popover containing suggestions.
*/
function AssetSearchBarPopover(props: AssetSearchBarPopoverProps) {
const AssetSearchBarPopover = React.memo(function AssetSearchBarPopover(
props: AssetSearchBarPopoverProps,
) {
const {
areSuggestionsVisible,
isCloud,
@ -545,7 +549,7 @@ function AssetSearchBarPopover(props: AssetSearchBarPopoverProps) {
</AnimatePresence>
</>
)
}
})
/**
* Props for a {@link SuggestionRenderer}.

View File

@ -10,13 +10,14 @@ import * as textProvider from '#/providers/TextProvider'
import AssetListEventType from '#/events/AssetListEventType'
import * as assetDiffView from '#/layouts/AssetDiffView'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as eventListProvider from '#/layouts/Drive/EventListProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type Backend from '#/services/Backend'
import * as backendService from '#/services/Backend'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import * as dateTime from '#/utilities/dateTime'
import * as tailwindMerge from '#/utilities/tailwindMerge'
@ -42,7 +43,7 @@ export default function AssetVersion(props: AssetVersionProps) {
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const isProject = item.type === backendService.AssetType.project
const doDuplicate = () => {
const doDuplicate = useEventCallback(() => {
if (isProject) {
dispatchAssetListEvent({
type: AssetListEventType.duplicateProject,
@ -52,7 +53,7 @@ export default function AssetVersion(props: AssetVersionProps) {
versionId: version.versionId,
})
}
}
})
return (
<div
@ -71,58 +72,50 @@ export default function AssetVersion(props: AssetVersionProps) {
</time>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-3">
{isProject && (
<ariaComponents.DialogTrigger>
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
aria-label={getText('compareWithLatest')}
icon={CompareIcon}
isDisabled={version.isLatest || placeholder}
/>
<ariaComponents.Tooltip>{getText('compareWithLatest')}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
aria-label={getText('compareWithLatest')}
icon={CompareIcon}
isDisabled={version.isLatest || placeholder}
/>
<ariaComponents.Dialog
type="fullscreen"
title={getText('compareVersionXWithLatest', number)}
padding="none"
>
{(opts) => (
<div className="flex h-full flex-col gap-3">
<ariaComponents.ButtonGroup>
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
aria-label={getText('restoreThisVersion')}
icon={RestoreIcon}
isDisabled={version.isLatest || placeholder}
onPress={async () => {
await doRestore()
opts.close()
}}
/>
<ariaComponents.Tooltip>
{getText('restoreThisVersion')}
</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
aria-label={getText('duplicateThisVersion')}
icon={DuplicateIcon}
isDisabled={placeholder}
onPress={() => {
doDuplicate()
opts.close()
}}
/>
<ariaComponents.Tooltip>
{getText('duplicateThisVersion')}
</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
<div className="flex h-full flex-col">
<ariaComponents.ButtonGroup className="px-4 py-4" gap="large">
<ariaComponents.Button
size="medium"
variant="icon"
loaderPosition="icon"
icon={RestoreIcon}
isDisabled={version.isLatest || placeholder}
onPress={async () => {
await doRestore()
opts.close()
}}
>
{getText('restoreThisVersion')}
</ariaComponents.Button>
<ariaComponents.Button
size="medium"
variant="icon"
loaderPosition="icon"
icon={DuplicateIcon}
isDisabled={placeholder}
onPress={() => {
doDuplicate()
opts.close()
}}
>
{getText('duplicateThisVersion')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
<assetDiffView.AssetDiffView
latestVersionId={latestVersion.versionId}

View File

@ -65,16 +65,16 @@ import {
} from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import { useSetSuggestions } from '#/layouts/AssetSearchBar'
import { useAssetsTableItems } from '#/layouts/AssetsTable/assetsTableItemsHooks'
import { useAssetTree, type DirectoryQuery } from '#/layouts/AssetsTable/assetTreeHooks'
import { useDirectoryIds } from '#/layouts/AssetsTable/directoryIdsHooks'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import AssetsTableContextMenu from '#/layouts/AssetsTableContextMenu'
import {
canTransferBetweenCategories,
isLocalCategory,
type Category,
} from '#/layouts/CategorySwitcher/Category'
import { useAssetsTableItems } from '#/layouts/Drive/assetsTableItemsHooks'
import { useAssetTree, type DirectoryQuery } from '#/layouts/Drive/assetTreeHooks'
import { useDirectoryIds } from '#/layouts/Drive/directoryIdsHooks'
import * as eventListProvider from '#/layouts/Drive/EventListProvider'
import DragModal from '#/modals/DragModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useFullUserSession } from '#/providers/AuthProvider'
@ -304,7 +304,7 @@ export interface AssetManagementApi {
}
/** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) {
function AssetsTable(props: AssetsTableProps) {
const { hidden, query, setQuery, category, assetManagementApiRef } = props
const { initialProjectName } = props
@ -1226,7 +1226,7 @@ export default function AssetsTable(props: AssetsTableProps) {
if (
pasteData?.data.backendType === backend.type &&
canTransferBetweenCategories(pasteData.data.category, category)
canTransferBetweenCategories(pasteData.data.category, category, user)
) {
if (pasteData.data.ids.has(newParentKey)) {
toast.error('Cannot paste a folder into itself.')
@ -2030,3 +2030,5 @@ const HiddenColumn = memo(function HiddenColumn(props: HiddenColumnProps) {
/>
)
})
export default memo(AssetsTable)

View File

@ -26,7 +26,7 @@ import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useDispatchAssetEvent } from '#/layouts/Drive/EventListProvider'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useSetModal } from '#/providers/ModalProvider'
import { useText } from '#/providers/TextProvider'
@ -82,7 +82,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
const effectivePasteData =
(
pasteData?.data.backendType === backend.type &&
canTransferBetweenCategories(pasteData.data.category, category)
canTransferBetweenCategories(pasteData.data.category, category, user)
) ?
pasteData
: null

View File

@ -7,28 +7,27 @@ import * as z from 'zod'
import { SEARCH_PARAMS_PREFIX } from '#/appUtils'
import CloudIcon from '#/assets/cloud.svg'
import ComputerIcon from '#/assets/computer.svg'
import FolderIcon from '#/assets/folder.svg'
import FolderAddIcon from '#/assets/folder_add.svg'
import FolderFilledIcon from '#/assets/folder_filled.svg'
import Minus2Icon from '#/assets/minus2.svg'
import PeopleIcon from '#/assets/people.svg'
import PersonIcon from '#/assets/person.svg'
import PlusIcon from '#/assets/plus.svg'
import RecentIcon from '#/assets/recent.svg'
import SettingsIcon from '#/assets/settings.svg'
import Trash2Icon from '#/assets/trash2.svg'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import { Badge } from '#/components/Badge'
import SvgMask from '#/components/SvgMask'
import * as mimeTypes from '#/data/mimeTypes'
import { useBackendQuery } from '#/hooks/backendHooks'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import {
areCategoriesEqual,
canTransferBetweenCategories,
useTransferBetweenCategories,
type Category,
} from '#/layouts/CategorySwitcher/Category'
import * as eventListProvider from '#/layouts/Drive/EventListProvider'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
@ -41,8 +40,10 @@ import { newDirectoryId } from '#/services/LocalBackend'
import { TEAMS_DIRECTORY_ID, USERS_DIRECTORY_ID } from '#/services/remoteBackendPaths'
import { getFileName } from '#/utilities/fileInfo'
import LocalStorage from '#/utilities/LocalStorage'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { twMerge } from 'tailwind-merge'
import { tv } from '#/utilities/tailwindVariants'
import { twJoin } from 'tailwind-merge'
import { AnimatedBackground } from '../components/AnimatedBackground'
import { useEventCallback } from '../hooks/eventCallbackHooks'
// ============================
// === Global configuration ===
@ -84,11 +85,22 @@ interface InternalCategorySwitcherItemProps extends CategoryMetadata {
readonly badgeContent?: React.ReactNode
}
const CATEGORY_SWITCHER_VARIANTS = tv({
extend: ariaComponents.BUTTON_STYLES,
base: 'group opacity-50 transition-opacity group-hover:opacity-100 w-auto max-w-full',
slots: {
wrapper: 'w-full',
text: 'flex-1 min-w-0 w-auto items-start justify-start',
},
})
/** An entry in a {@link CategorySwitcher}. */
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
const { currentCategory, setCategory, badgeContent } = props
const { isNested = false, category, icon, label, buttonLabel, dropZoneLabel } = props
const { iconClassName } = props
const [isTransitioning, startTransition] = React.useTransition()
const { user } = authProvider.useFullUserSession()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
@ -96,7 +108,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
const { isOffline } = offlineHooks.useOffline()
const isCurrent = areCategoriesEqual(currentCategory, category)
const transferBetweenCategories = useTransferBetweenCategories(currentCategory)
const getCategoryError = (otherCategory: Category) => {
const getCategoryError = useEventCallback((otherCategory: Category) => {
switch (otherCategory.type) {
case 'local':
case 'local-directory': {
@ -120,23 +132,28 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
}
}
}
}
})
const error = getCategoryError(category)
const isDisabled = error != null
const tooltip = error ?? false
const isDropTarget =
!areCategoriesEqual(currentCategory, category) &&
canTransferBetweenCategories(currentCategory, category)
canTransferBetweenCategories(currentCategory, category, user)
const acceptedDragTypes = isDropTarget ? [mimeTypes.ASSETS_MIME_TYPE] : []
const onPress = () => {
const onPress = useEventCallback(() => {
if (error == null && !areCategoriesEqual(category, currentCategory)) {
setCategory(category)
// We use startTransition to trigger a background transition between categories.
// and to not invoke the Suspense boundary.
// This makes the transition feel more responsive and natural.
startTransition(() => {
setCategory(category)
})
}
}
})
const onDrop = (event: aria.DropEvent) => {
const onDrop = useEventCallback((event: aria.DropEvent) => {
unsetModal()
void Promise.all(
event.items.flatMap(async (item) => {
@ -158,7 +175,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
).then((keys) => {
transferBetweenCategories(currentCategory, category, keys.flat(1))
})
}
})
const element = (
<aria.DropZone
@ -166,41 +183,45 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
getDropOperation={(types) =>
acceptedDragTypes.some((type) => types.has(type)) ? 'move' : 'cancel'
}
className="group relative flex min-w-0 flex-auto items-center rounded-full drop-target-after"
className="group relative flex w-full min-w-0 flex-auto items-start rounded-full drop-target-after"
onDrop={onDrop}
>
<ariaComponents.Button
size="custom"
variant="custom"
tooltip={tooltip}
tooltipPlacement="right"
className={tailwindMerge.twJoin(
'min-w-0 flex-auto grow-0',
isCurrent && 'focus-default',
isDisabled && 'cursor-not-allowed hover:bg-transparent',
)}
aria-label={buttonLabel}
onPress={onPress}
<AnimatedBackground.Item
isSelected={isCurrent}
className="w-auto max-w-[calc(100%-24px)]"
animationClassName="bg-invert rounded-full"
>
<div
className={tailwindMerge.twJoin(
'group flex h-row min-w-0 flex-auto items-center gap-icon-with-text rounded-full px-button-x selectable',
isCurrent && 'disabled active',
!isCurrent && !isDisabled && 'hover:bg-selected-frame',
)}
<ariaComponents.Button
size="medium"
variant="custom"
tooltip={tooltip}
tooltipPlacement="right"
variants={CATEGORY_SWITCHER_VARIANTS}
isDisabled={isDisabled}
aria-label={buttonLabel}
onPress={onPress}
loaderPosition="icon"
loading={isTransitioning}
className={twJoin(isCurrent && 'opacity-100')}
icon={icon}
addonEnd={
badgeContent != null && (
<Badge color="accent" variant="solid">
{badgeContent}
</Badge>
)
}
>
<SvgMask src={icon} className={twMerge('shrink-0', iconClassName)} />
<ariaComponents.Text slot="description" truncate="1" className="flex-auto">
<ariaComponents.Text
disableLineHeightCompensation
weight="semibold"
color="current"
truncate="1"
>
{label}
</ariaComponents.Text>
{badgeContent != null && (
<Badge color="accent" variant="solid">
{badgeContent}
</Badge>
)}
</div>
</ariaComponents.Button>
</ariaComponents.Button>
</AnimatedBackground.Item>
<div className="absolute left-full ml-2 hidden group-focus-visible:block">
{getText('drop')}
</div>
@ -208,8 +229,8 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
)
return isNested ?
<div className="flex min-w-0 flex-auto">
<div className="ml-[15px] mr-1 border-r border-primary/20" />
<div className="flex w-full min-w-0 max-w-full flex-1">
<div className="ml-[15px] mr-1.5 rounded-full border-r border-primary/20" />
{element}
</div>
: element
@ -294,194 +315,197 @@ function CategorySwitcher(props: CategorySwitcherProps) {
return (
<div className="flex flex-col gap-2 py-1">
<ariaComponents.Text variant="subtitle" className="px-2 font-bold">
{getText('category')}
</ariaComponents.Text>
<AnimatedBackground>
<ariaComponents.Text variant="subtitle" weight="semibold" className="px-2">
{getText('category')}
</ariaComponents.Text>
<div
aria-label={getText('categorySwitcherMenuLabel')}
role="grid"
className="flex flex-col items-start"
>
<CategorySwitcherItem
{...itemProps}
category={{ type: 'cloud' }}
icon={CloudIcon}
label={getText('cloudCategory')}
buttonLabel={getText('cloudCategoryButtonLabel')}
dropZoneLabel={getText('cloudCategoryDropZoneLabel')}
badgeContent={getText('cloudCategoryBadgeContent')}
/>
{(user.plan === backend.Plan.team || user.plan === backend.Plan.enterprise) && (
<div
aria-label={getText('categorySwitcherMenuLabel')}
role="grid"
className="flex flex-col items-start"
>
<CategorySwitcherItem
{...itemProps}
category={{ type: 'cloud' }}
icon={CloudIcon}
label={getText('cloudCategory')}
buttonLabel={getText('cloudCategoryButtonLabel')}
dropZoneLabel={getText('cloudCategoryDropZoneLabel')}
badgeContent={getText('cloudCategoryBadgeContent')}
/>
{(user.plan === backend.Plan.team || user.plan === backend.Plan.enterprise) && (
<CategorySwitcherItem
{...itemProps}
isNested
category={{
type: 'user',
rootPath: backend.Path(`enso://Users/${user.name}`),
homeDirectoryId: selfDirectoryId,
}}
icon={PersonIcon}
label={getText('myFilesCategory')}
buttonLabel={getText('myFilesCategoryButtonLabel')}
dropZoneLabel={getText('myFilesCategoryDropZoneLabel')}
/>
)}
{usersDirectoryQuery.data?.map((userDirectory) => {
if (userDirectory.type !== backend.AssetType.directory) {
return null
} else {
const otherUser = usersById.get(userDirectory.id)
return !otherUser || otherUser.userId === user.userId ?
null
: <CategorySwitcherItem
key={otherUser.userId}
{...itemProps}
isNested
category={{
type: 'user',
rootPath: backend.Path(`enso://Users/${otherUser.name}`),
homeDirectoryId: userDirectory.id,
}}
icon={PersonIcon}
label={getText('userCategory', otherUser.name)}
buttonLabel={getText('userCategoryButtonLabel', otherUser.name)}
dropZoneLabel={getText('userCategoryDropZoneLabel', otherUser.name)}
/>
}
})}
{teamsDirectoryQuery.data?.map((teamDirectory) => {
if (teamDirectory.type !== backend.AssetType.directory) {
return null
} else {
const team = teamsById.get(teamDirectory.id)
return !team ? null : (
<CategorySwitcherItem
key={team.id}
{...itemProps}
isNested
category={{
type: 'team',
team,
rootPath: backend.Path(`enso://Teams/${team.groupName}`),
homeDirectoryId: teamDirectory.id,
}}
icon={PeopleIcon}
label={getText('teamCategory', team.groupName)}
buttonLabel={getText('teamCategoryButtonLabel', team.groupName)}
dropZoneLabel={getText('teamCategoryDropZoneLabel', team.groupName)}
/>
)
}
})}
<CategorySwitcherItem
{...itemProps}
isNested
category={{
type: 'user',
rootPath: backend.Path(`enso://Users/${user.name}`),
homeDirectoryId: selfDirectoryId,
}}
icon={PersonIcon}
label={getText('myFilesCategory')}
buttonLabel={getText('myFilesCategoryButtonLabel')}
dropZoneLabel={getText('myFilesCategoryDropZoneLabel')}
category={{ type: 'recent' }}
icon={RecentIcon}
label={getText('recentCategory')}
buttonLabel={getText('recentCategoryButtonLabel')}
dropZoneLabel={getText('recentCategoryDropZoneLabel')}
/>
)}
{usersDirectoryQuery.data?.map((userDirectory) => {
if (userDirectory.type !== backend.AssetType.directory) {
return null
} else {
const otherUser = usersById.get(userDirectory.id)
return !otherUser || otherUser.userId === user.userId ?
null
: <CategorySwitcherItem
key={otherUser.userId}
{...itemProps}
isNested
category={{
type: 'user',
rootPath: backend.Path(`enso://Users/${otherUser.name}`),
homeDirectoryId: userDirectory.id,
}}
icon={PersonIcon}
label={getText('userCategory', otherUser.name)}
buttonLabel={getText('userCategoryButtonLabel', otherUser.name)}
dropZoneLabel={getText('userCategoryDropZoneLabel', otherUser.name)}
/>
}
})}
{teamsDirectoryQuery.data?.map((teamDirectory) => {
if (teamDirectory.type !== backend.AssetType.directory) {
return null
} else {
const team = teamsById.get(teamDirectory.id)
return !team ? null : (
<CategorySwitcherItem
key={team.id}
{...itemProps}
isNested
category={{
type: 'team',
team,
rootPath: backend.Path(`enso://Teams/${team.groupName}`),
homeDirectoryId: teamDirectory.id,
}}
icon={PeopleIcon}
label={getText('teamCategory', team.groupName)}
buttonLabel={getText('teamCategoryButtonLabel', team.groupName)}
dropZoneLabel={getText('teamCategoryDropZoneLabel', team.groupName)}
/>
)
}
})}
<CategorySwitcherItem
{...itemProps}
isNested
category={{ type: 'recent' }}
icon={RecentIcon}
label={getText('recentCategory')}
buttonLabel={getText('recentCategoryButtonLabel')}
dropZoneLabel={getText('recentCategoryDropZoneLabel')}
iconClassName="-ml-0.5"
/>
<CategorySwitcherItem
{...itemProps}
isNested
category={{ type: 'trash' }}
icon={Trash2Icon}
label={getText('trashCategory')}
buttonLabel={getText('trashCategoryButtonLabel')}
dropZoneLabel={getText('trashCategoryDropZoneLabel')}
/>
{localBackend && (
<div className="group flex items-center justify-between self-stretch">
<CategorySwitcherItem
{...itemProps}
category={{ type: 'local' }}
icon={ComputerIcon}
label={getText('localCategory')}
buttonLabel={getText('localCategoryButtonLabel')}
dropZoneLabel={getText('localCategoryDropZoneLabel')}
/>
<ariaComponents.Button
size="medium"
variant="icon"
extraClickZone={false}
icon={SettingsIcon}
aria-label={getText('changeLocalRootDirectoryInSettings')}
className="opacity-0 transition-opacity group-hover:opacity-100"
onPress={() => {
setSearchParams({
[`${SEARCH_PARAMS_PREFIX}SettingsTab`]: JSON.stringify('local'),
[`${SEARCH_PARAMS_PREFIX}page`]: JSON.stringify(TabType.settings),
})
}}
/>
</div>
)}
{localBackend &&
localRootDirectories?.map((directory) => (
<div key={directory} className="group flex items-center self-stretch">
<CategorySwitcherItem
{...itemProps}
isNested
category={{ type: 'trash' }}
icon={Trash2Icon}
label={getText('trashCategory')}
buttonLabel={getText('trashCategoryButtonLabel')}
dropZoneLabel={getText('trashCategoryDropZoneLabel')}
/>
{localBackend && (
<div className="group flex items-center gap-2 self-stretch drop-target-after">
<CategorySwitcherItem
{...itemProps}
isNested
category={{
type: 'local-directory',
rootPath: backend.Path(directory),
homeDirectoryId: newDirectoryId(backend.Path(directory)),
}}
icon={FolderIcon}
label={getFileName(directory)}
category={{ type: 'local' }}
icon={ComputerIcon}
label={getText('localCategory')}
buttonLabel={getText('localCategoryButtonLabel')}
dropZoneLabel={getText('localCategoryDropZoneLabel')}
/>
<div className="grow" />
<ariaComponents.DialogTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
extraClickZone={false}
icon={Minus2Icon}
aria-label={getText('removeDirectoryFromFavorites')}
className="hidden group-hover:block"
/>
<ConfirmDeleteModal
actionText={getText(
'removeTheLocalDirectoryXFromFavorites',
getFileName(directory),
)}
actionButtonLabel={getText('remove')}
doDelete={() => {
setLocalRootDirectories(
localRootDirectories.filter((otherDirectory) => otherDirectory !== directory),
)
}}
/>
</ariaComponents.DialogTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
extraClickZone="small"
icon={SettingsIcon}
aria-label={getText('changeLocalRootDirectoryInSettings')}
className="my-auto opacity-0 transition-opacity group-hover:opacity-100"
onPress={() => {
setSearchParams({
[`${SEARCH_PARAMS_PREFIX}SettingsTab`]: JSON.stringify('local'),
[`${SEARCH_PARAMS_PREFIX}page`]: JSON.stringify(TabType.settings),
})
}}
/>
</div>
))}
{localBackend && window.fileBrowserApi && (
<div className="flex">
<div className="ml-[15px] mr-1 border-r border-primary/20" />
<ariaComponents.Button
size="xsmall"
variant="outline"
icon={PlusIcon}
loaderPosition="icon"
className="ml-0.5 rounded-full px-2 selectable"
onPress={async () => {
const [newDirectory] =
(await window.fileBrowserApi?.openFileBrowser('directory')) ?? []
if (newDirectory != null) {
setLocalRootDirectories([...(localRootDirectories ?? []), newDirectory])
}
}}
>
<div className="ml-1.5">{getText('addLocalDirectory')}</div>
</ariaComponents.Button>
</div>
)}
</div>
)}
{localBackend &&
localRootDirectories?.map((directory) => (
<div key={directory} className="group flex items-center gap-2 self-stretch">
<CategorySwitcherItem
{...itemProps}
isNested
category={{
type: 'local-directory',
rootPath: backend.Path(directory),
homeDirectoryId: newDirectoryId(backend.Path(directory)),
}}
icon={FolderFilledIcon}
label={getFileName(directory)}
buttonLabel={getText('localCategoryButtonLabel')}
dropZoneLabel={getText('localCategoryDropZoneLabel')}
/>
<ariaComponents.DialogTrigger>
<ariaComponents.Button
size="medium"
variant="icon"
extraClickZone={false}
icon={Minus2Icon}
aria-label={getText('removeDirectoryFromFavorites')}
className="hidden group-hover:block"
/>
<ConfirmDeleteModal
actionText={getText(
'removeTheLocalDirectoryXFromFavorites',
getFileName(directory),
)}
actionButtonLabel={getText('remove')}
doDelete={() => {
setLocalRootDirectories(
localRootDirectories.filter(
(otherDirectory) => otherDirectory !== directory,
),
)
}}
/>
</ariaComponents.DialogTrigger>
</div>
))}
{localBackend && window.fileBrowserApi && (
<div className="flex">
<div className="ml-[15px] mr-1.5 rounded-full border-r border-primary/20" />
<ariaComponents.Button
size="medium"
variant="icon"
icon={FolderAddIcon}
loaderPosition="icon"
onPress={async () => {
const [newDirectory] =
(await window.fileBrowserApi?.openFileBrowser('directory')) ?? []
if (newDirectory != null) {
setLocalRootDirectories([...(localRootDirectories ?? []), newDirectory])
}
}}
>
{getText('addLocalDirectory')}
</ariaComponents.Button>
</div>
)}
</div>
</AnimatedBackground>
</div>
)
}

View File

@ -6,14 +6,16 @@ import * as z from 'zod'
import AssetEventType from '#/events/AssetEventType'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import { useDispatchAssetEvent } from '#/layouts/Drive/EventListProvider'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useBackend, useLocalBackend, useRemoteBackend } from '#/providers/BackendProvider'
import {
FilterBy,
Plan,
type AssetId,
type DirectoryId,
type Path,
type User,
type UserGroupInfo,
} from '#/services/Backend'
import { newDirectoryId } from '#/services/LocalBackend'
@ -169,12 +171,15 @@ export function areCategoriesEqual(a: Category, b: Category) {
}
/** Whether an asset can be transferred between categories. */
export function canTransferBetweenCategories(from: Category, to: Category) {
export function canTransferBetweenCategories(from: Category, to: Category, user: User) {
switch (from.type) {
case 'cloud':
case 'recent':
case 'team':
case 'user': {
if (user.plan === Plan.enterprise || user.plan === Plan.team) {
return to.type !== 'cloud'
}
return to.type === 'trash' || to.type === 'cloud' || to.type === 'team' || to.type === 'user'
}
case 'trash': {

View File

@ -15,19 +15,30 @@ import AssetListEventType from '#/events/AssetListEventType'
import { AssetPanel } from '#/layouts/AssetPanel'
import type * as assetsTable from '#/layouts/AssetsTable'
import AssetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import CategorySwitcher from '#/layouts/CategorySwitcher'
import * as categoryModule from '#/layouts/CategorySwitcher/Category'
import * as eventListProvider from '#/layouts/Drive/EventListProvider'
import DriveBar from '#/layouts/DriveBar'
import Labels from '#/layouts/Labels'
import * as ariaComponents from '#/components/AriaComponents'
import * as result from '#/components/Result'
import { ErrorBoundary, useErrorBoundary } from '#/components/ErrorBoundary'
import { listDirectoryQueryOptions } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useTargetDirectory } from '#/providers/DriveProvider'
import { DirectoryDoesNotExistError, Plan } from '#/services/Backend'
import AssetQuery from '#/utilities/AssetQuery'
import * as download from '#/utilities/download'
import * as github from '#/utilities/github'
import { tryFindSelfPermission } from '#/utilities/permissions'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useDeferredValue, useEffect } from 'react'
import { toast } from 'react-toastify'
import { Suspense } from '../components/Suspense'
import { useDirectoryIds } from './Drive/directoryIdsHooks'
// =============
// === Drive ===
@ -37,24 +48,25 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
export interface DriveProps {
readonly category: categoryModule.Category
readonly setCategory: (category: categoryModule.Category) => void
readonly resetCategory: () => void
readonly hidden: boolean
readonly initialProjectName: string | null
readonly assetsManagementApiRef: React.Ref<assetsTable.AssetManagementApi>
}
const CATEGORIES_TO_DISPLAY_START_MODAL = ['cloud', 'local', 'local-directory']
/** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function Drive(props: DriveProps) {
const { category, setCategory, hidden, initialProjectName, assetsManagementApiRef } = props
function Drive(props: DriveProps) {
const { category, resetCategory } = props
const { isOffline } = offlineHooks.useOffline()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useFullUserSession()
const localBackend = backendProvider.useLocalBackend()
const backend = backendProvider.useBackend(category)
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const isCloud = categoryModule.isCloudCategory(category)
const supportLocalBackend = localBackend != null
const status =
@ -62,10 +74,6 @@ export default function Drive(props: DriveProps) {
: isCloud && !user.isEnabled ? 'not-enabled'
: 'ok'
const doEmptyTrash = React.useCallback(() => {
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
}, [dispatchAssetListEvent])
switch (status) {
case 'not-enabled': {
return (
@ -104,66 +112,192 @@ export default function Drive(props: DriveProps) {
case 'offline':
case 'ok': {
return (
<div className={tailwindMerge.twMerge('relative flex grow', hidden && 'hidden')}>
<div
data-testid="drive-view"
className="mt-4 flex flex-1 flex-col gap-4 overflow-visible px-page-x"
>
<DriveBar
backend={backend}
query={query}
setQuery={setQuery}
category={category}
doEmptyTrash={doEmptyTrash}
/>
<div className="flex flex-1 gap-drive overflow-hidden">
<div className="flex w-36 flex-col gap-drive-sidebar overflow-y-auto py-drive-sidebar-y">
<CategorySwitcher category={category} setCategory={setCategory} />
{isCloud && (
<Labels
backend={backend}
draggable={category.type !== 'trash'}
query={query}
setQuery={setQuery}
/>
)}
</div>
{status === 'offline' ?
<result.Result
status="info"
className="my-12"
centered="horizontal"
title={getText('cloudUnavailableOffline')}
subtitle={`${getText('cloudUnavailableOfflineDescription')} ${supportLocalBackend ? getText('cloudUnavailableOfflineDescriptionOfferLocal') : ''}`}
>
{supportLocalBackend && (
<ariaComponents.Button
variant="primary"
size="small"
className="mx-auto"
onPress={() => {
setCategory({ type: 'local' })
}}
>
{getText('switchToLocal')}
</ariaComponents.Button>
)}
</result.Result>
: <AssetsTable
assetManagementApiRef={assetsManagementApiRef}
hidden={hidden}
query={query}
setQuery={setQuery}
category={category}
initialProjectName={initialProjectName}
/>
}
</div>
</div>
<AssetPanel backendType={backend.type} category={category} />
</div>
<ErrorBoundary
onBeforeFallbackShown={({ resetErrorBoundary, error, resetQueries }) => {
if (error instanceof DirectoryDoesNotExistError) {
toast.error(getText('directoryDoesNotExistError'), {
toastId: 'directory-does-not-exist-error',
})
resetCategory()
resetQueries()
resetErrorBoundary()
}
}}
>
<Suspense>
<DriveAssetsView {...props} />
</Suspense>
</ErrorBoundary>
)
}
}
}
/**
* The assets view of the Drive.
*/
function DriveAssetsView(props: DriveProps) {
const {
category,
setCategory,
hidden = false,
initialProjectName,
assetsManagementApiRef,
} = props
const deferredCategory = useDeferredValue(category)
const { showBoundary } = useErrorBoundary()
const { isOffline } = offlineHooks.useOffline()
const { user } = authProvider.useFullUserSession()
const localBackend = backendProvider.useLocalBackend()
const backend = backendProvider.useBackend(category)
const { getText } = textProvider.useText()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [shouldForceHideStartModal, setShouldForceHideStartModal] = React.useState(false)
const isCloud = categoryModule.isCloudCategory(category)
const supportLocalBackend = localBackend != null
const targetDirectory = useTargetDirectory()
const status =
isCloud && isOffline ? 'offline'
: isCloud && !user.isEnabled ? 'not-enabled'
: 'ok'
const doEmptyTrash = useEventCallback(() => {
dispatchAssetListEvent({ type: AssetListEventType.emptyTrash })
})
const { rootDirectoryId } = useDirectoryIds({ category })
const queryClient = useQueryClient()
const rootDirectoryQuery = listDirectoryQueryOptions({
backend,
category,
parentId: rootDirectoryId,
})
const {
data: isEmpty,
error,
isFetching,
} = useSuspenseQuery({
...rootDirectoryQuery,
refetchOnMount: 'always',
staleTime: ({ state }) => (state.error ? 0 : Infinity),
select: (data) => data.length === 0,
})
// Show the error boundary if the query failed, but has data.
if (error != null && !isFetching) {
showBoundary(error)
// Remove the query from the cache.
// This will force the query to be refetched when the user navigates again.
queryClient.removeQueries({ queryKey: rootDirectoryQuery.queryKey })
}
// When the directory is no longer empty, we need to hide the start modal.
// This includes the cases when the directory wasn't empty before, but it's now empty
// (e.g. when the user deletes the last asset).
useEffect(() => {
if (!isEmpty) {
setShouldForceHideStartModal(true)
}
}, [isEmpty])
// When the root directory changes, we need to show the start modal
// if the directory is empty.
useEffect(() => {
setShouldForceHideStartModal(false)
}, [category.type])
const hasPermissionToCreateAssets = tryFindSelfPermission(
user,
targetDirectory?.item.permissions ?? [],
)
const shouldDisplayStartModal =
isEmpty &&
CATEGORIES_TO_DISPLAY_START_MODAL.includes(category.type) &&
!shouldForceHideStartModal
const shouldDisableActions =
category.type === 'cloud' &&
(user.plan === Plan.enterprise || user.plan === Plan.team) &&
!hasPermissionToCreateAssets
return (
<div className={tailwindMerge.twMerge('relative flex grow', hidden && 'hidden')}>
<div
data-testid="drive-view"
className="mt-4 flex flex-1 flex-col gap-4 overflow-visible px-page-x"
>
<DriveBar
key={rootDirectoryId}
backend={backend}
query={query}
setQuery={setQuery}
category={category}
doEmptyTrash={doEmptyTrash}
isEmpty={isEmpty}
shouldDisplayStartModal={shouldDisplayStartModal}
isDisabled={shouldDisableActions}
/>
<div className="flex flex-1 gap-drive overflow-hidden">
<div className="flex w-36 flex-none flex-col gap-drive-sidebar overflow-y-auto overflow-x-hidden py-drive-sidebar-y">
<CategorySwitcher category={category} setCategory={setCategory} />
{isCloud && (
<Labels
backend={backend}
draggable={category.type !== 'trash'}
query={query}
setQuery={setQuery}
/>
)}
</div>
{status === 'offline' ?
<result.Result
status="info"
className="my-12"
centered="horizontal"
title={getText('cloudUnavailableOffline')}
subtitle={`${getText('cloudUnavailableOfflineDescription')} ${supportLocalBackend ? getText('cloudUnavailableOfflineDescriptionOfferLocal') : ''}`}
>
{supportLocalBackend && (
<ariaComponents.Button
variant="primary"
size="small"
className="mx-auto"
onPress={() => {
setCategory({ type: 'local' })
}}
>
{getText('switchToLocal')}
</ariaComponents.Button>
)}
</result.Result>
: <AssetsTable
assetManagementApiRef={assetsManagementApiRef}
hidden={hidden}
query={query}
setQuery={setQuery}
category={deferredCategory}
initialProjectName={initialProjectName}
/>
}
</div>
</div>
<AssetPanel backendType={backend.type} category={deferredCategory} />
</div>
)
}
export default React.memo(Drive)

View File

@ -1,7 +1,7 @@
/** @file A hook to return the asset tree. */
import { useMemo } from 'react'
import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import { useIsFetching, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import type { DirectoryId } from 'enso-common/src/services/Backend'
import {
@ -15,6 +15,7 @@ import {
} from 'enso-common/src/services/Backend'
import { listDirectoryQueryOptions } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import type { Category } from '#/layouts/CategorySwitcher/Category'
import { useFullUserSession } from '#/providers/AuthProvider'
import { useBackend } from '#/providers/BackendProvider'
@ -36,8 +37,11 @@ export interface UseAssetTreeOptions {
/** A hook to return the asset tree. */
export function useAssetTree(options: UseAssetTreeOptions) {
const { hidden, category, rootDirectory, expandedDirectoryIds } = options
const { user } = useFullUserSession()
const backend = useBackend(category)
const enableAssetsTableBackgroundRefresh = useFeatureFlag('enableAssetsTableBackgroundRefresh')
const assetsTableBackgroundRefreshInterval = useFeatureFlag(
'assetsTableBackgroundRefreshInterval',
@ -84,7 +88,7 @@ export function useAssetTree(options: UseAssetTreeOptions) {
// We use a different query to refetch the directory data in the background.
// This reduces the amount of rerenders by batching them together, so they happen less often.
useQuery(
const { refetch } = useQuery(
useMemo(
() => ({
queryKey: [backend.type, 'refetchListDirectory'],
@ -113,6 +117,28 @@ export function useAssetTree(options: UseAssetTreeOptions) {
),
)
const refetchAllDirectories = useEventCallback(() => {
return refetch()
})
/**
* Refetch the directory data for a given directory.
*/
const refetchDirectory = useEventCallback((directoryId: DirectoryId) => {
return queryClient.refetchQueries({
queryKey: listDirectoryQueryOptions({
backend,
parentId: directoryId,
category,
}).queryKey,
type: 'active',
})
})
const isFetching = useIsFetching({
queryKey: [backend.type, 'listDirectory'],
})
const rootDirectoryContent = directories.rootDirectory.data
const isError = directories.rootDirectory.isError
const isLoading = directories.rootDirectory.isLoading && !isError
@ -258,5 +284,12 @@ export function useAssetTree(options: UseAssetTreeOptions) {
user,
])
return { isLoading, isError, assetTree } as const
return {
isLoading,
isError,
assetTree,
isFetching,
refetchDirectory,
refetchAllDirectories,
} as const
}

View File

@ -22,10 +22,12 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) {
const { category } = options
const backend = useBackend(category)
const { user } = useFullUserSession()
const organizationQuery = useSuspenseQuery({
queryKey: [backend.type, 'getOrganization'],
queryFn: () => backend.getOrganization(),
})
const organization = organizationQuery.data
/**
@ -37,6 +39,7 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) {
const setExpandedDirectoryIds = useSetExpandedDirectoryIds()
const [localRootDirectory] = useLocalStorageState('localRootDirectory')
const rootDirectoryId = useMemo(() => {
const localRootPath = localRootDirectory != null ? Path(localRootDirectory) : null
const id =
@ -46,7 +49,9 @@ export function useDirectoryIds(options: UseDirectoryIdsOptions) {
invariant(id, 'Missing root directory')
return id
}, [category, backend, user, organization, localRootDirectory])
const rootDirectory = useMemo(() => createRootDirectoryAsset(rootDirectoryId), [rootDirectoryId])
const expandedDirectoryIds = useMemo(
() => [rootDirectoryId].concat(privateExpandedDirectoryIds),
[privateExpandedDirectoryIds, rootDirectoryId],

View File

@ -30,14 +30,13 @@ import {
} from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useOffline } from '#/hooks/offlineHooks'
import { useSearchParamsState } from '#/hooks/searchParamsStateHooks'
import AssetSearchBar from '#/layouts/AssetSearchBar'
import { useDispatchAssetEvent } from '#/layouts/AssetsTable/EventListProvider'
import {
canTransferBetweenCategories,
isCloudCategory,
type Category,
} from '#/layouts/CategorySwitcher/Category'
import { useDispatchAssetEvent } from '#/layouts/Drive/EventListProvider'
import StartModal from '#/layouts/StartModal'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
@ -55,6 +54,7 @@ import type Backend from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
import { inputFiles } from '#/utilities/input'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import { useFullUserSession } from '../providers/AuthProvider'
import { AssetPanelToggle } from './AssetPanel'
// ================
@ -68,6 +68,9 @@ export interface DriveBarProps {
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
readonly category: Category
readonly doEmptyTrash: () => void
readonly isEmpty: boolean
readonly shouldDisplayStartModal: boolean
readonly isDisabled: boolean
}
/**
@ -75,12 +78,16 @@ export interface DriveBarProps {
* and a column display mode switcher.
*/
export default function DriveBar(props: DriveBarProps) {
const { backend, query, setQuery, category, doEmptyTrash } = props
const [startModalDefaultOpen, , resetStartModalDefaultOpen] = useSearchParamsState(
'startModalDefaultOpen',
false,
)
const {
backend,
query,
setQuery,
category,
doEmptyTrash,
isEmpty,
shouldDisplayStartModal,
isDisabled,
} = props
const { unsetModal } = useSetModal()
const { getText } = useText()
@ -91,8 +98,11 @@ export default function DriveBar(props: DriveBarProps) {
const createAssetButtonsRef = React.useRef<HTMLDivElement>(null)
const isCloud = isCloudCategory(category)
const { isOffline } = useOffline()
const { user } = useFullUserSession()
const canDownload = useCanDownload()
const shouldBeDisabled = (isCloud && isOffline) || !canCreateAssets
const shouldBeDisabled = (isCloud && isOffline) || !canCreateAssets || isDisabled
const error =
!shouldBeDisabled ? null
: isCloud && isOffline ? getText('youAreOffline')
@ -107,7 +117,7 @@ export default function DriveBar(props: DriveBarProps) {
const effectivePasteData =
(
pasteData?.data.backendType === backend.type &&
canTransferBetweenCategories(pasteData.data.category, category)
canTransferBetweenCategories(pasteData.data.category, category, user)
) ?
pasteData
: null
@ -207,9 +217,10 @@ export default function DriveBar(props: DriveBarProps) {
return (
<ButtonGroup className="my-0.5 grow-0">
<DialogTrigger>
<Button size="medium" variant="outline" isDisabled={shouldBeDisabled}>
<Button size="medium" variant="outline" isDisabled={shouldBeDisabled || isEmpty}>
{getText('clearTrash')}
</Button>
<ConfirmDeleteModal
actionText={getText('allTrashedItemsForever')}
doDelete={doEmptyTrash}
@ -233,12 +244,7 @@ export default function DriveBar(props: DriveBarProps) {
className="grow-0"
{...createAssetsVisualTooltip.targetProps}
>
<DialogTrigger
defaultOpen={startModalDefaultOpen}
onClose={() => {
resetStartModalDefaultOpen(true)
}}
>
<DialogTrigger defaultOpen={shouldDisplayStartModal}>
<Button
size="medium"
variant="accent"

View File

@ -80,7 +80,7 @@ export interface EditorProps {
}
/** The container that launches the IDE. */
export default function Editor(props: EditorProps) {
function Editor(props: EditorProps) {
const { project, hidden, startProject, isOpeningFailed, openingError } = props
const backend = backendProvider.useBackendForProjectType(project.type)
@ -130,7 +130,7 @@ export default function Editor(props: EditorProps) {
case projectQuery.isLoading ||
projectQuery.data?.state.type !== backendModule.ProjectState.opened:
return <suspense.Loader loaderProps={{ minHeight: 'full' }} />
return <suspense.Loader minHeight="full" />
default:
return (
@ -232,3 +232,5 @@ function EditorInternal(props: EditorInternalProps) {
// this is no longer necessary, the `key` could be removed.
return AppRunner == null ? null : <AppRunner key={appProps.projectId} {...appProps} />
}
export default React.memo(Editor)

View File

@ -9,7 +9,6 @@ import * as ariaComponents from '#/components/AriaComponents'
import Label from '#/components/dashboard/Label'
import FocusArea from '#/components/styled/FocusArea'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { backendMutationOptions, useBackendQuery } from '#/hooks/backendHooks'
import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal'
import DragModal from '#/modals/DragModal'
@ -128,8 +127,7 @@ export default function Labels(props: LabelsProps) {
size="xsmall"
variant="outline"
className="mt-1 self-start pl-1 pr-2"
/* eslint-disable-next-line no-restricted-syntax */
icon={<SvgMask src={PlusIcon} alt="" className="ml-auto size-[8px]" />}
icon={PlusIcon}
>
{getText('newLabelButtonLabel')}
</ariaComponents.Button>

View File

@ -1,8 +1,8 @@
/** @file A styled input for settings pages. */
import {
Form,
INPUT_STYLES,
Input,
Password,
TEXT_STYLE,
type FieldPath,
type InputProps,
@ -10,32 +10,13 @@ import {
} from '#/components/AriaComponents'
import { tv } from '#/utilities/tailwindVariants'
const SETTINGS_INPUT_STYLES = tv({
extend: INPUT_STYLES,
variants: {
readOnly: {
true: {
base: 'opacity-100 focus-within:outline-0 border-transparent focus-within:border-transparent',
},
},
},
slots: {
base: 'p-0 transition-[border-color,outline] outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-0 focus-within:outline-primary border-0.5 border-primary/20 rounded-2xl',
inputContainer: TEXT_STYLE({ disableLineHeightCompensation: true }),
addonStart: 'px-1',
textArea: 'h-6 rounded-full px-1',
addonEnd: 'px-1',
description: 'px-1',
},
})
const SETTINGS_FIELD_STYLES = tv({
extend: Form.FIELD_STYLES,
slots: {
base: 'flex-row flex-wrap',
labelContainer: 'flex min-h-row items-center gap-5 w-full',
labelContainer: 'flex min-h-row items-center gap-1.5 w-full',
label: TEXT_STYLE({
className: 'text self-start w-40 shrink-0',
className: 'flex justify-center self-start w-40 h-10 shrink-0',
variant: 'body',
}),
error: 'ml-[180px]',
@ -53,17 +34,24 @@ export type SettingsAriaInputProps<
> = Omit<InputProps<Schema, TFieldName>, 'fieldVariants' | 'size' | 'variant' | 'variants'>
/** A styled input for settings pages. */
export default function SettingsAriaInput<
export function SettingsAriaInput<Schema extends TSchema, TFieldName extends FieldPath<Schema>>(
props: SettingsAriaInputProps<Schema, TFieldName>,
) {
return <Input fieldVariants={SETTINGS_FIELD_STYLES} {...props} />
}
/** A styled password input for settings pages. */
export function SettingsAriaInputPassword<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: SettingsAriaInputProps<Schema, TFieldName>) {
return (
<Input
variant="custom"
size="custom"
variants={SETTINGS_INPUT_STYLES}
fieldVariants={SETTINGS_FIELD_STYLES}
{...props}
/>
)
return <Password fieldVariants={SETTINGS_FIELD_STYLES} {...props} />
}
/** A styled email input for settings pages. */
export function SettingsAriaInputEmail<
Schema extends TSchema,
TFieldName extends FieldPath<Schema>,
>(props: SettingsAriaInputProps<Schema, TFieldName>) {
return <Input fieldVariants={SETTINGS_FIELD_STYLES} type="email" {...props} />
}

View File

@ -1,6 +1,7 @@
/** @file Rendering for an {@link SettingsFormEntryData}. */
import { ButtonGroup, Form } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useMemo, useRef, useState } from 'react'
import SettingsInput from './Input'
import type { SettingsContext, SettingsFormEntryData } from './data'
@ -21,11 +22,15 @@ export function SettingsFormEntry<T extends Record<keyof T, string>>(
) {
const { context, data } = props
const { schema: schemaRaw, getValue, inputs, onSubmit, getVisible } = data
const { getText } = useText()
const visible = getVisible?.(context) ?? true
const value = getValue(context)
const [initialValueString] = useState(() => JSON.stringify(value))
const valueStringRef = useRef(initialValueString)
const schema = useMemo(
() => (typeof schemaRaw === 'function' ? schemaRaw(context) : schemaRaw),
[context, schemaRaw],
@ -50,24 +55,41 @@ export function SettingsFormEntry<T extends Record<keyof T, string>>(
useEffect(() => {
const newValueString = JSON.stringify(value)
if (newValueString !== valueStringRef.current) {
form.reset(value)
valueStringRef.current = newValueString
}
}, [form, value])
return !visible ? null : (
<Form form={form} gap="none">
{inputs.map((input) => (
<SettingsInput key={input.name} context={context} data={input} />
))}
{isEditable && (
<ButtonGroup>
<Form.Submit isDisabled={!form.formState.isDirty}>{getText('save')}</Form.Submit>
<Form.Reset>{getText('cancel')}</Form.Reset>
</ButtonGroup>
if (!visible) return null
const shouldShowSaveButton = isEditable && form.formState.isDirty
return (
<Form form={form}>
{inputs.map((input) => (
<SettingsInput key={input.name} context={context} data={input} />
))}
<AnimatePresence>
{shouldShowSaveButton && (
<motion.div
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
exit={{ opacity: 0, y: -10 }}
>
<ButtonGroup>
<Form.Submit>{getText('save')}</Form.Submit>
<Form.Reset>{getText('cancel')}</Form.Reset>
</ButtonGroup>
</motion.div>
)}
<Form.FormError />
</Form>
)
</AnimatePresence>
<Form.FormError />
</Form>
)
}

View File

@ -1,7 +1,13 @@
/** @file Rendering for an {@link SettingsInputData}. */
import type { FieldPath, TSchema } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import SettingsAriaInput from './AriaInput'
import type { SettingsContext, SettingsInputData } from './data'
import {
SettingsAriaInput,
SettingsAriaInputEmail,
SettingsAriaInputPassword,
type SettingsAriaInputProps,
} from './AriaInput'
import type { SettingsContext, SettingsInputData, SettingsInputType } from './data'
/** Props for a {@link SettingsInput}. */
export interface SettingsInputProps<T extends Record<keyof T, string>> {
@ -14,13 +20,24 @@ export default function SettingsInput<T extends Record<keyof T, string>>(
props: SettingsInputProps<T>,
) {
const { context, data } = props
const { name, nameId, autoComplete, hidden: hiddenRaw, editable, descriptionId } = data
const {
name,
nameId,
autoComplete,
hidden: hiddenRaw,
editable,
descriptionId,
type = 'text',
} = data
const { getText } = useText()
const isEditable = typeof editable === 'function' ? editable(context) : editable ?? true
const hidden = typeof hiddenRaw === 'function' ? hiddenRaw(context) : hiddenRaw ?? false
const Input = INPUT_TYPE_MAP[type]
return (
<SettingsAriaInput
<Input
readOnly={!isEditable}
label={getText(nameId)}
name={name}
@ -32,3 +49,12 @@ export default function SettingsInput<T extends Record<keyof T, string>>(
/>
)
}
const INPUT_TYPE_MAP: Record<
SettingsInputType,
React.ComponentType<SettingsAriaInputProps<TSchema, FieldPath<TSchema>>>
> = {
email: SettingsAriaInputEmail,
password: SettingsAriaInputPassword,
text: SettingsAriaInput,
}

View File

@ -1,7 +1,7 @@
/** @file Rendering for a settings section. */
import { Text } from '#/components/AriaComponents'
import FocusArea from '#/components/styled/FocusArea'
import { useText } from '#/providers/TextProvider'
import { memo } from 'react'
import type { SettingsContext, SettingsSectionData } from './data'
import SettingsEntry from './Entry'
@ -16,30 +16,33 @@ export interface SettingsSectionProps {
}
/** Rendering for a settings section. */
export default function SettingsSection(props: SettingsSectionProps) {
function SettingsSection(props: SettingsSectionProps) {
const { context, data } = props
const { nameId, focusArea = true, heading = true, entries } = data
const { nameId, heading = true, entries } = data
const { getText } = useText()
const isVisible = entries.some((entry) =>
'getVisible' in entry ? entry.getVisible(context) : true,
)
return !isVisible ? null : (
<FocusArea active={focusArea} direction="vertical">
{(innerProps) => (
<div className="flex w-full flex-col gap-2.5" {...innerProps}>
{!heading ? null : (
<Text.Heading level={2} weight="bold">
{getText(nameId)}
</Text.Heading>
)}
<div className="flex flex-col gap-2 overflow-auto">
{entries.map((entry, i) => (
<SettingsEntry key={i} context={context} data={entry} />
))}
</div>
</div>
)}
</FocusArea>
)
if (!isVisible) {
return null
}
return (
<div className="flex w-full flex-col gap-2.5">
{!heading ? null : (
<Text.Heading level={2} weight="bold">
{getText(nameId)}
</Text.Heading>
)}
<div className="flex flex-col gap-2">
{entries.map((entry, i) => (
<SettingsEntry key={i} context={context} data={entry} />
))}
</div>
</div>
)
}
export default memo(SettingsSection)

View File

@ -188,7 +188,7 @@ function TwoFa() {
return (
<>
<div className="flex w-full flex-col gap-4 overflow-hidden">
<div className="flex w-full flex-col gap-4">
<Selector name="display" items={['QR', 'Text']} aria-label={getText('display')} />
<Form.FieldValue name="display">

View File

@ -1,5 +1,5 @@
/** @file A panel to switch between settings tabs. */
import type { Dispatch, SetStateAction } from 'react'
import { memo, type Dispatch, type SetStateAction } from 'react'
import { Header } from '#/components/aria'
import { ButtonGroup } from '#/components/AriaComponents'
@ -25,7 +25,7 @@ export interface SettingsSidebarProps {
}
/** A panel to switch between settings tabs. */
export default function SettingsSidebar(props: SettingsSidebarProps) {
function SettingsSidebar(props: SettingsSidebarProps) {
const { context, tabsToShow, isMenu = false, tab, setTab } = props
const { onClickCapture } = props
const { getText } = useText()
@ -91,3 +91,5 @@ export default function SettingsSidebar(props: SettingsSidebarProps) {
</FocusArea>
)
}
export default memo(SettingsSidebar)

View File

@ -1,5 +1,5 @@
/** @file Metadata for rendering each settings section. */
import type { HTMLInputAutoCompleteAttribute, ReactNode } from 'react'
import type { HTMLInputAutoCompleteAttribute, HTMLInputTypeAttribute, ReactNode } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import * as z from 'zod'
@ -18,7 +18,7 @@ import { ACTION_TO_TEXT_ID } from '#/components/MenuEntry'
import { BINDINGS } from '#/configurations/inputBindings'
import type { PaywallFeatureName } from '#/hooks/billing'
import type { ToastAndLogCallback } from '#/hooks/toastAndLogHooks'
import { passwordSchema, passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { passwordWithPatternSchema } from '#/pages/authentication/schemas'
import type { GetText } from '#/providers/TextProvider'
import type Backend from '#/services/Backend'
import {
@ -103,7 +103,8 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
z
.object({
username: z.string().email(getText('invalidEmailValidationError')),
currentPassword: passwordSchema(getText),
// We don't want to validate the current password.
currentPassword: z.string(),
newPassword: passwordWithPatternSchema(getText),
confirmNewPassword: z.string(),
})
@ -140,17 +141,20 @@ export const SETTINGS_TAB_DATA: Readonly<Record<SettingsTabType, SettingsTabData
nameId: 'userCurrentPasswordSettingsInput',
name: 'currentPassword',
autoComplete: 'current-assword',
type: 'password',
},
{
nameId: 'userNewPasswordSettingsInput',
name: 'newPassword',
autoComplete: 'new-password',
descriptionId: 'passwordValidationMessage',
type: 'password',
},
{
nameId: 'userConfirmNewPasswordSettingsInput',
name: 'confirmNewPassword',
autoComplete: 'new-password',
type: 'password',
},
],
getVisible: (context) => {
@ -522,6 +526,13 @@ export interface SettingsContext {
// === SettingsInputEntryData ===
// ==============================
/**
* Possible values for the `type` property of {@link SettingsInputData}.
*
* TODO: Add support for other types.
*/
export type SettingsInputType = Extract<HTMLInputTypeAttribute, 'email' | 'password' | 'text'>
/** Metadata describing an input in a {@link SettingsFormEntryData}. */
export interface SettingsInputData<T extends Record<keyof T, string>> {
readonly nameId: TextId & `${string}SettingsInput`
@ -532,6 +543,7 @@ export interface SettingsInputData<T extends Record<keyof T, string>> {
/** Defaults to `true`. */
readonly editable?: boolean | ((context: SettingsContext) => boolean)
readonly descriptionId?: TextId
readonly type?: SettingsInputType
}
/** Metadata describing a settings entry that is a form. */

View File

@ -189,6 +189,16 @@ export default function Settings() {
}
}, [isQueryBlank, doesEntryMatchQuery, getText, isMatch, effectiveTab])
const hideSidebarPopover = useEventCallback(() => {
setIsSidebarPopoverOpen(false)
})
const changeTab = useEventCallback(() => {
if (tab !== effectiveTab) {
setTab(tab)
}
})
return (
<div className="flex flex-1 flex-col gap-4 overflow-hidden pl-page-x pt-4">
<Heading level={1} className="flex items-center px-heading-x">
@ -201,9 +211,7 @@ export default function Settings() {
tabsToShow={tabsToShow}
tab={effectiveTab}
setTab={setTab}
onClickCapture={() => {
setIsSidebarPopoverOpen(false)
}}
onClickCapture={hideSidebarPopover}
/>
</Popover>
</MenuTrigger>
@ -241,15 +249,7 @@ export default function Settings() {
/>
</aside>
<main className="flex flex-1 flex-col overflow-y-auto pb-12 pl-1 scrollbar-gutter-stable">
<SettingsTab
context={context}
data={data}
onInteracted={() => {
if (effectiveTab !== tab) {
setTab(effectiveTab)
}
}}
/>
<SettingsTab context={context} data={data} onInteracted={changeTab} />
</main>
</div>
</div>

View File

@ -23,7 +23,7 @@ export default function StartModal(props: StartModalProps) {
const { getText } = textProvider.useText()
return (
<ariaComponents.Dialog type="fullscreen" title={getText('selectTemplate')}>
<ariaComponents.Dialog type="fullscreen" title={getText('selectTemplate')} testId="start-modal">
{(opts) => (
<div className="mb-4 flex flex-1 flex-col gap-home text-xs text-primary">
<WhatsNew />

View File

@ -1,37 +1,12 @@
/** @file Modal for confirming delete of any type of asset. */
import {
ButtonGroup,
Dialog,
DialogDismiss,
Form,
INPUT_STYLES,
Input,
} from '#/components/AriaComponents'
import { ButtonGroup, Dialog, DialogDismiss, Form, Input } from '#/components/AriaComponents'
import { useText } from '#/providers/TextProvider'
import type { SecretId } from '#/services/Backend'
import { tv } from '#/utilities/tailwindVariants'
// =========================
// === UpsertSecretModal ===
// =========================
const CLASSIC_INPUT_STYLES = tv({
extend: INPUT_STYLES,
slots: {
base: '',
textArea: 'rounded-full border-0.5 border-primary/20 px-1.5',
inputContainer: 'before:h-0 after:h-0.5',
},
})
const CLASSIC_FIELD_STYLES = tv({
extend: Form.FIELD_STYLES,
slots: {
base: '',
label: 'px-2',
},
})
/** Props for a {@link UpsertSecretModal}. */
export interface UpsertSecretModalProps {
readonly noDialog?: boolean
@ -70,31 +45,23 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
<Input
form={form}
name="name"
size="custom"
rounded="full"
autoFocus={isNameEditable}
autoComplete="off"
isDisabled={!isNameEditable}
label={getText('name')}
placeholder={getText('secretNamePlaceholder')}
variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES}
/>
)}
<Input
form={form}
name="value"
type="password"
size="custom"
rounded="full"
autoFocus={!isNameEditable}
autoComplete="off"
label={getText('value')}
placeholder={
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
}
variants={CLASSIC_INPUT_STYLES}
fieldVariants={CLASSIC_FIELD_STYLES}
/>
<ButtonGroup className="mt-2">
<Form.Submit>{isCreatingSecret ? getText('create') : getText('update')}</Form.Submit>

View File

@ -314,6 +314,7 @@ const BASE_STEPS: Step[] = [
/** Final setup step. */
component: function AllSetStep({ goToPreviousStep }) {
const { getText } = textProvider.useText()
const navigate = useNavigate()
const queryClient = useQueryClient()

View File

@ -29,10 +29,10 @@ import ProjectsProvider, {
import AssetListEventType from '#/events/AssetListEventType'
import type * as assetTable from '#/layouts/AssetsTable'
import EventListProvider, * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import * as categoryModule from '#/layouts/CategorySwitcher/Category'
import Chat from '#/layouts/Chat'
import ChatPlaceholder from '#/layouts/ChatPlaceholder'
import EventListProvider, * as eventListProvider from '#/layouts/Drive/EventListProvider'
import type * as editor from '#/layouts/Editor'
import UserBar from '#/layouts/UserBar'
@ -113,7 +113,7 @@ function DashboardInner(props: DashboardProps) {
initialProjectNameRaw != null ? fileURLToPath(initialProjectNameRaw) : null
const initialProjectName = initialLocalProjectPath != null ? null : initialProjectNameRaw
const [category, setCategoryRaw] =
const [category, setCategoryRaw, resetCategory] =
searchParamsState.useSearchParamsState<categoryModule.Category>(
'driveCategory',
() => (localBackend != null ? { type: 'local' } : { type: 'cloud' }),
@ -295,6 +295,7 @@ function DashboardInner(props: DashboardProps) {
assetManagementApiRef={assetManagementApiRef}
category={category}
setCategory={setCategory}
resetCategory={resetCategory}
/>
</aria.Tabs>

View File

@ -2,6 +2,8 @@
import * as aria from '#/components/aria'
import { ErrorBoundary } from '#/components/ErrorBoundary'
import { Suspense } from '#/components/Suspense'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useOpenProjectMutation, useRenameProjectMutation } from '#/hooks/projectHooks'
import type { AssetManagementApi } from '#/layouts/AssetsTable'
@ -22,12 +24,20 @@ export interface DashboardTabPanelsProps {
readonly assetManagementApiRef: React.RefObject<AssetManagementApi> | null
readonly category: Category
readonly setCategory: (category: Category) => void
readonly resetCategory: () => void
}
/** The tab panels for the dashboard page. */
export function DashboardTabPanels(props: DashboardTabPanelsProps) {
const { appRunner, initialProjectName, ydocUrl, assetManagementApiRef, category, setCategory } =
props
const {
appRunner,
initialProjectName,
ydocUrl,
assetManagementApiRef,
category,
setCategory,
resetCategory,
} = props
const page = usePage()
@ -48,13 +58,13 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) {
const tabPanels = [
{
id: TabType.drive,
shouldForceMount: true,
className: 'flex min-h-0 grow [&[data-inert]]:hidden',
children: (
<Drive
assetsManagementApiRef={assetManagementApiRef}
category={category}
setCategory={setCategory}
resetCategory={resetCategory}
hidden={page !== TabType.drive}
initialProjectName={initialProjectName}
/>
@ -91,7 +101,13 @@ export function DashboardTabPanels(props: DashboardTabPanelsProps) {
return (
<Collection items={tabPanels}>
{(tabPanelProps) => <aria.TabPanel {...tabPanelProps} />}
{(tabPanelProps) => (
<Suspense>
<ErrorBoundary>
<aria.TabPanel {...tabPanelProps} />
</ErrorBoundary>
</Suspense>
)}
</Collection>
)
}

View File

@ -255,17 +255,6 @@ export default function AuthProvider(props: AuthProviderProps) {
meta: { invalidates: [usersMeQueryOptions.queryKey], awaitInvalidates: true },
})
/**
* Wrap a function returning a {@link Promise} to display a loading toast notification
* until the returned {@link Promise} finishes loading.
*/
const withLoadingToast =
<T extends unknown[], R>(action: (...args: T) => Promise<R>) =>
async (...args: T) => {
toast.toast.loading(getText('pleaseWait'), { toastId })
return await action(...args)
}
const toastSuccess = (message: string) => {
toast.toast.update(toastId, {
isLoading: null,
@ -278,18 +267,6 @@ export default function AuthProvider(props: AuthProviderProps) {
})
}
const toastError = (message: string) => {
toast.toast.update(toastId, {
isLoading: null,
autoClose: null,
closeOnClick: null,
closeButton: null,
draggable: null,
type: toast.toast.TYPE.ERROR,
render: message,
})
}
const signUp = useEventCallback(
async (username: string, password: string, organizationId: string | null) => {
gtagEvent('cloud_sign_up')
@ -426,10 +403,8 @@ export default function AuthProvider(props: AuthProviderProps) {
const changePassword = useEventCallback(async (oldPassword: string, newPassword: string) => {
const result = await cognito.changePassword(oldPassword, newPassword)
if (result.ok) {
toastSuccess(getText('changePasswordSuccess'))
} else {
toastError(result.val.message)
if (result.err) {
throw new Error(result.val.message)
}
return result.ok
@ -525,7 +500,7 @@ export default function AuthProvider(props: AuthProviderProps) {
signInWithPassword,
forgotPassword,
resetPassword,
changePassword: withLoadingToast(changePassword),
changePassword,
refetchSession,
session: userData,
signOut: logoutMutation.mutateAsync,
@ -582,7 +557,14 @@ export function ProtectedLayout() {
} else if (session.type === UserSessionType.partial) {
return <router.Navigate to={appUtils.SETUP_PATH} />
} else {
return <router.Outlet context={session} />
return (
<>
{/* This div is used as a flag to indicate that the dashboard has been loaded and the user is authenticated. */}
{/* also it guarantees that the top-level suspense boundary is already resolved */}
<div data-testid="after-auth-layout" aria-hidden />
<router.Outlet context={session} />
</>
)
}
}
@ -635,7 +617,14 @@ export function GuestLayout() {
return <router.Navigate to={appUtils.DASHBOARD_PATH} />
}
} else {
return <router.Outlet />
return (
<>
{/* This div is used as a flag to indicate that the user is not logged in. */}
{/* also it guarantees that the top-level suspense boundary is already resolved */}
<div data-testid="before-auth-layout" aria-hidden />
<router.Outlet />
</>
)
}
}

View File

@ -153,13 +153,13 @@ export function useDriveStore() {
/** The category of the Asset Table. */
export function useCategory() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.category)
return zustand.useStore(store, (state) => state.category, { unsafeEnableTransition: true })
}
/** A function to set the category of the Asset Table. */
export function useSetCategory() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setCategory)
return zustand.useStore(store, (state) => state.setCategory, { unsafeEnableTransition: true })
}
/** The target directory of the Asset Table selection. */
@ -231,7 +231,16 @@ export function useExpandedDirectoryIds() {
/** A function to set the expanded directoyIds in the Asset Table. */
export function useSetExpandedDirectoryIds() {
const store = useDriveStore()
return zustand.useStore(store, (state) => state.setExpandedDirectoryIds)
const privateSetExpandedDirectoryIds = zustand.useStore(
store,
(state) => state.setExpandedDirectoryIds,
{ unsafeEnableTransition: true },
)
return useEventCallback((expandedDirectoryIds: readonly DirectoryId[]) => {
React.startTransition(() => {
privateSetExpandedDirectoryIds(expandedDirectoryIds)
})
})
}
/** The selected keys in the Asset Table. */

View File

@ -4,14 +4,11 @@
* Feature flags provider.
* Feature flags are used to enable or disable certain features in the application.
*/
import { useMount } from '#/hooks/mountHooks'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import LocalStorage from '#/utilities/LocalStorage'
import { unsafeEntries } from '#/utilities/object'
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import { z } from 'zod'
import { createStore, useStore } from 'zustand'
import { persist } from 'zustand/middleware'
declare module '#/utilities/LocalStorage' {
/** Local storage data structure. */
@ -42,17 +39,22 @@ export interface FeatureFlags {
) => void
}
const flagsStore = createStore<FeatureFlags>((set) => ({
featureFlags: {
enableMultitabs: false,
enableAssetsTableBackgroundRefresh: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
assetsTableBackgroundRefreshInterval: 3_000,
},
setFeatureFlags: (key, value) => {
set(({ featureFlags }) => ({ featureFlags: { ...featureFlags, [key]: value } }))
},
}))
const flagsStore = createStore<FeatureFlags>()(
persist(
(set) => ({
featureFlags: {
enableMultitabs: false,
enableAssetsTableBackgroundRefresh: true,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
assetsTableBackgroundRefreshInterval: 3_000,
},
setFeatureFlags: (key, value) => {
set(({ featureFlags }) => ({ featureFlags: { ...featureFlags, [key]: value } }))
},
}),
{ name: 'featureFlags' },
),
)
/** Hook to get all feature flags. */
export function useFeatureFlags() {
@ -77,28 +79,5 @@ export function useSetFeatureFlags() {
* Also saves feature flags to local storage when they change.
*/
export function FeatureFlagsProvider({ children }: { children: ReactNode }) {
const { localStorage } = useLocalStorage()
const setFeatureFlags = useSetFeatureFlags()
useMount(() => {
const storedFeatureFlags = localStorage.get('featureFlags')
if (storedFeatureFlags) {
for (const [key, value] of unsafeEntries(storedFeatureFlags)) {
setFeatureFlags(key, value)
}
}
})
useEffect(
() =>
flagsStore.subscribe((state, prevState) => {
if (state.featureFlags !== prevState.featureFlags) {
localStorage.set('featureFlags', state.featureFlags)
}
}),
[localStorage],
)
return <>{children}</>
}

View File

@ -221,9 +221,10 @@ export default class LocalBackend extends Backend {
if (parentIdRaw === this.projectManager.rootDirectory) {
// Auto create the root directory
await this.projectManager.createDirectory(this.projectManager.rootDirectory)
result = []
} else {
throw new Error('Directory does not exist.')
throw new backend.DirectoryDoesNotExistError()
}
}
}

View File

@ -565,7 +565,9 @@ export default class ProjectManager {
...entry,
path: normalizeSlashes(entry.path),
}))
this.internalDirectories.set(parentId, result)
for (const entry of result) {
if (entry.type === FileSystemEntryType.ProjectEntry) {
this.internalProjectPaths.set(entry.metadata.id, entry.path)

View File

@ -397,10 +397,11 @@
--sidebar-section-heading-padding-y: 0.125rem;
/* The vertical gap between the section heading and its content. */
--sidebar-section-heading-margin-b: 0.375rem;
}
.Toastify--animate {
animation-duration: 200ms;
/* Toast colors. */
--toastify-color-light: var(--color-background);
--toastify-icon-color-success: var(--color-accent);
--toastify-icon-color-error: var(--color-danger);
}
/* These styles MUST still be copied
@ -563,3 +564,11 @@ html.disable-animations * {
animation: none !important;
animation-play-state: paused !important;
}
.Toastify__toast {
@apply rounded-2xl;
}
.Toastify--animate {
/* animation-duration: 200ms; */
}

View File

@ -208,6 +208,18 @@ export default class AssetTreeNode<Item extends backendModule.AnyAsset = backend
return backendModule.isPlaceholderId(this.item.id)
}
/** Check whether the asset doesn't have any children. */
isEmpty(): boolean {
if (this.item.type === backendModule.AssetType.directory) {
if (this.children == null) {
return true
}
return this.children.length === 0
}
return true
}
/** Check whether a pending rename is valid. */
isNewTitleValid(newTitle: string, siblings?: readonly AssetTreeNode[] | null) {
siblings ??= []

View File

@ -18,6 +18,30 @@ const CONFIG = (await import('./vite.config')).default
export default mergeConfig(
CONFIG,
defineConfig({
plugins: [
{
name: 'load-svg',
enforce: 'pre',
transform(_, id) {
// Mock out SVGs that are used in the dashboard.
if (id.endsWith('.svg')) {
const svgContent = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 100 100">
<defs>
<pattern id="checkerboard" width="20" height="20" patternUnits="userSpaceOnUse">
<rect width="10" height="10" fill="white"/>
<rect x="10" y="0" width="10" height="10" fill="black"/>
<rect x="0" y="10" width="10" height="10" fill="black"/>
<rect x="10" y="10" width="10" height="10" fill="white"/>
</pattern>
</defs>
<rect width="100" height="100" fill="url(#checkerboard)"/>
</svg>`
const encodedSvg = `data:image/svg+xml,${encodeURIComponent(svgContent)}`
return `export default \`${encodedSvg}\``
}
},
},
],
resolve: {
alias: {
'@stripe/stripe-js/pure': fileURLToPath(

View File

@ -6,6 +6,7 @@ const config = mergeConfig(
viteConfig,
defineConfig({
test: {
reporters: process.env.CI ? ['dot', 'github-actions'] : ['default'],
environment: 'jsdom',
includeSource: ['./src/**/*.{ts,tsx,vue}'],
exclude: [...configDefaults.exclude, 'integration-test/**/*'],