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
@ -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.')
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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 ===
|
||||
// =============================
|
||||
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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. */
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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 })
|
||||
})
|
||||
|
@ -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)
|
||||
}),
|
||||
|
@ -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()
|
||||
|
@ -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'),
|
||||
},
|
||||
},
|
||||
|
@ -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>
|
||||
|
@ -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 |
@ -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 |
4
app/gui/src/dashboard/assets/folder_add.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="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 |
4
app/gui/src/dashboard/assets/folder_filled.svg
Normal 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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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>
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
|
@ -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}
|
||||
|
@ -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)}
|
||||
|
@ -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>
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
</>
|
||||
),
|
||||
}
|
@ -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 ===
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -64,6 +64,6 @@ const VARIANT_BY_LEVEL: Record<
|
||||
> = {
|
||||
free: 'primary',
|
||||
enterprise: 'primary',
|
||||
solo: 'outline',
|
||||
solo: 'accent',
|
||||
team: 'submit',
|
||||
}
|
||||
|
@ -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 },
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
|
@ -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 },
|
||||
}}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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'
|
||||
|
@ -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 = [
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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'
|
||||
|
@ -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>,
|
||||
)
|
||||
|
@ -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 && (
|
||||
|
@ -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>
|
||||
)
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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}.
|
||||
|
@ -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}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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': {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
@ -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],
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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">
|
||||
|
@ -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)
|
||||
|
@ -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. */
|
||||
|
@ -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>
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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. */
|
||||
|
@ -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}</>
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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; */
|
||||
}
|
||||
|
@ -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 ??= []
|
||||
|
@ -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(
|
||||
|
@ -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/**/*'],
|
||||
|