From 3ad09a15376e2232e958cad15772011736f784fe Mon Sep 17 00:00:00 2001 From: Sergei Garin Date: Mon, 9 Dec 2024 23:43:41 +0300 Subject: [PATCH] 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 --- app/common/src/services/Backend.ts | 14 + app/common/src/text/english.json | 20 +- app/common/src/utilities/data/object.ts | 16 + .../dashboard/actions/DrivePageActions.ts | 14 + .../dashboard/actions/StartModalActions.ts | 31 +- .../dashboard/actions/index.ts | 165 ++++--- .../integration-test/dashboard/auth.setup.ts | 16 +- .../integration-test/dashboard/delete.spec.ts | 8 + .../dashboard/loginLogout.spec.ts | 3 +- app/gui/playwright.config.ts | 2 + app/gui/src/dashboard/App.tsx | 20 +- app/gui/src/dashboard/assets/compare.svg | 8 +- app/gui/src/dashboard/assets/computer.svg | 13 +- app/gui/src/dashboard/assets/folder_add.svg | 4 + .../src/dashboard/assets/folder_filled.svg | 4 + app/gui/src/dashboard/assets/plus.svg | 5 +- app/gui/src/dashboard/assets/plus2.svg | 4 +- app/gui/src/dashboard/assets/recent.svg | 16 +- app/gui/src/dashboard/assets/restore.svg | 10 +- app/gui/src/dashboard/assets/trash2.svg | 12 +- .../AriaComponents/Button/Button.stories.tsx | 96 +++- .../AriaComponents/Button/Button.tsx | 255 +++++++--- .../AriaComponents/Button/ButtonGroup.tsx | 13 +- .../AriaComponents/Button/CloseButton.tsx | 2 + .../AriaComponents/Dialog/Dialog.stories.tsx | 49 +- .../AriaComponents/Dialog/Dialog.tsx | 242 +++++---- .../Form/components/FieldValue.tsx | 18 +- .../Form/components/useFieldState.ts | 13 +- .../AriaComponents/Form/components/useForm.ts | 2 + .../Inputs/Password/Password.tsx | 40 +- .../Inputs/Selector/SelectorOption.tsx | 9 +- .../Radio/RadioGroupContext.tsx | 2 +- .../AriaComponents/Text/Text.stories.tsx | 110 +++++ .../Devtools/EnsoDevtoolsProvider.tsx | 88 ++-- .../dashboard/components/ErrorBoundary.tsx | 89 +++- app/gui/src/dashboard/components/Loader.tsx | 32 +- .../src/dashboard/components/MenuEntry.tsx | 2 +- .../components/Paywall/ContextMenuEntry.tsx | 26 +- .../components/Paywall/PaywallDialog.tsx | 7 +- .../components/Paywall/UpgradeButton.tsx | 2 +- .../src/dashboard/components/Stepper/Step.tsx | 2 +- .../dashboard/components/Stepper/Stepper.tsx | 2 +- app/gui/src/dashboard/components/Suspense.tsx | 30 +- .../components/dashboard/AssetRow.tsx | 6 +- .../dashboard/column/SharedWithColumn.tsx | 2 +- .../dashboard/column/columnUtils.ts | 3 +- .../src/dashboard/events/assetListEvent.ts | 2 +- app/gui/src/dashboard/hooks/backendHooks.tsx | 8 +- .../hooks/billing/FeaturesConfiguration.ts | 8 + app/gui/src/dashboard/hooks/billing/index.ts | 2 +- app/gui/src/dashboard/index.tsx | 10 +- .../dashboard/layouts/AssetContextMenu.tsx | 62 ++- .../layouts/AssetDiffView/AssetDiffView.tsx | 4 +- .../layouts/AssetPanel/AssetPanel.tsx | 4 +- .../src/dashboard/layouts/AssetSearchBar.tsx | 10 +- .../layouts/AssetVersions/AssetVersion.tsx | 89 ++-- app/gui/src/dashboard/layouts/AssetsTable.tsx | 14 +- .../layouts/AssetsTableContextMenu.tsx | 4 +- .../dashboard/layouts/CategorySwitcher.tsx | 460 +++++++++--------- .../layouts/CategorySwitcher/Category.ts | 9 +- app/gui/src/dashboard/layouts/Drive.tsx | 270 +++++++--- .../EventListProvider.tsx | 0 .../{AssetsTable => Drive}/assetTreeHooks.tsx | 39 +- .../assetsTableItemsHooks.tsx | 0 .../directoryIdsHooks.tsx | 5 + app/gui/src/dashboard/layouts/DriveBar.tsx | 40 +- app/gui/src/dashboard/layouts/Editor.tsx | 6 +- app/gui/src/dashboard/layouts/Labels.tsx | 4 +- .../dashboard/layouts/Settings/AriaInput.tsx | 52 +- .../dashboard/layouts/Settings/FormEntry.tsx | 48 +- .../src/dashboard/layouts/Settings/Input.tsx | 34 +- .../dashboard/layouts/Settings/Section.tsx | 45 +- .../layouts/Settings/SetupTwoFaForm.tsx | 2 +- .../dashboard/layouts/Settings/Sidebar.tsx | 6 +- .../src/dashboard/layouts/Settings/data.tsx | 18 +- .../src/dashboard/layouts/Settings/index.tsx | 24 +- app/gui/src/dashboard/layouts/StartModal.tsx | 2 +- .../dashboard/modals/UpsertSecretModal.tsx | 35 +- .../pages/authentication/Setup/Setup.tsx | 1 + .../dashboard/pages/dashboard/Dashboard.tsx | 5 +- .../pages/dashboard/DashboardTabPanels.tsx | 24 +- .../src/dashboard/providers/AuthProvider.tsx | 49 +- .../src/dashboard/providers/DriveProvider.tsx | 15 +- .../providers/FeatureFlagsProvider.tsx | 55 +-- .../src/dashboard/services/LocalBackend.ts | 3 +- .../src/dashboard/services/ProjectManager.ts | 2 + app/gui/src/dashboard/tailwind.css | 15 +- .../src/dashboard/utilities/AssetTreeNode.ts | 12 + app/gui/vite.test.config.ts | 24 + app/gui/vitest.config.ts | 1 + 90 files changed, 1963 insertions(+), 1086 deletions(-) create mode 100644 app/gui/src/dashboard/assets/folder_add.svg create mode 100644 app/gui/src/dashboard/assets/folder_filled.svg create mode 100644 app/gui/src/dashboard/components/AriaComponents/Text/Text.stories.tsx rename app/gui/src/dashboard/layouts/{AssetsTable => Drive}/EventListProvider.tsx (100%) rename app/gui/src/dashboard/layouts/{AssetsTable => Drive}/assetTreeHooks.tsx (90%) rename app/gui/src/dashboard/layouts/{AssetsTable => Drive}/assetsTableItemsHooks.tsx (100%) rename app/gui/src/dashboard/layouts/{AssetsTable => Drive}/directoryIdsHooks.tsx (99%) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index b0deb3c76536..ae80d71aa390 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -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 } + +// ============================== +// ====== 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.') + } +} diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 2eaad0409544..a89c0135bc24 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -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", diff --git a/app/common/src/utilities/data/object.ts b/app/common/src/utilities/data/object.ts index 8dd1679e8263..963cafed7b4e 100644 --- a/app/common/src/utilities/data/object.ts +++ b/app/common/src/utilities/data/object.ts @@ -66,6 +66,11 @@ export function unsafeKeys(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(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( 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( + 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 === // ============================= diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 3a3e3fb23dca..462cabcd271f 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -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()) + }) + } } diff --git a/app/gui/integration-test/dashboard/actions/StartModalActions.ts b/app/gui/integration-test/dashboard/actions/StartModalActions.ts index 9202fe4b8b2e..7d51704046d6 100644 --- a/app/gui/integration-test/dashboard/actions/StartModalActions.ts +++ b/app/gui/integration-test/dashboard/actions/StartModalActions.ts @@ -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. */ diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index 6581a4bb1445..3c14d8c4f585 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -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 }) } diff --git a/app/gui/integration-test/dashboard/auth.setup.ts b/app/gui/integration-test/dashboard/auth.setup.ts index 0e35546491ed..1cd6c08f7803 100644 --- a/app/gui/integration-test/dashboard/auth.setup.ts +++ b/app/gui/integration-test/dashboard/auth.setup.ts @@ -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 }) }) diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index 67eec35feaeb..d7752874af20 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -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) }), diff --git a/app/gui/integration-test/dashboard/loginLogout.spec.ts b/app/gui/integration-test/dashboard/loginLogout.spec.ts index a11ae467eee3..157865d096fd 100644 --- a/app/gui/integration-test/dashboard/loginLogout.spec.ts +++ b/app/gui/integration-test/dashboard/loginLogout.spec.ts @@ -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() diff --git a/app/gui/playwright.config.ts b/app/gui/playwright.config.ts index 3281ae3f9bb7..9bf71c4c9949 100644 --- a/app/gui/playwright.config.ts +++ b/app/gui/playwright.config.ts @@ -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'), }, }, diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index d8e4e1cdecb0..fe8d68ee04e0 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -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} /> @@ -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`. */} - - - - {routes} - - - - - - + + + {routes} + + + + + diff --git a/app/gui/src/dashboard/assets/compare.svg b/app/gui/src/dashboard/assets/compare.svg index db103c7461ac..527bf57c7aff 100644 --- a/app/gui/src/dashboard/assets/compare.svg +++ b/app/gui/src/dashboard/assets/compare.svg @@ -1,5 +1,3 @@ - - - - - \ No newline at end of file + + + diff --git a/app/gui/src/dashboard/assets/computer.svg b/app/gui/src/dashboard/assets/computer.svg index d351baa8b6d9..584bdcc6c46f 100644 --- a/app/gui/src/dashboard/assets/computer.svg +++ b/app/gui/src/dashboard/assets/computer.svg @@ -1,10 +1,3 @@ - - - - - - \ No newline at end of file + + + diff --git a/app/gui/src/dashboard/assets/folder_add.svg b/app/gui/src/dashboard/assets/folder_add.svg new file mode 100644 index 000000000000..5d8caa2ccb40 --- /dev/null +++ b/app/gui/src/dashboard/assets/folder_add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/gui/src/dashboard/assets/folder_filled.svg b/app/gui/src/dashboard/assets/folder_filled.svg new file mode 100644 index 000000000000..d696948f024d --- /dev/null +++ b/app/gui/src/dashboard/assets/folder_filled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/gui/src/dashboard/assets/plus.svg b/app/gui/src/dashboard/assets/plus.svg index 59cf224cc70d..71bd2b5b91cf 100644 --- a/app/gui/src/dashboard/assets/plus.svg +++ b/app/gui/src/dashboard/assets/plus.svg @@ -1,3 +1,4 @@ - - + + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/plus2.svg b/app/gui/src/dashboard/assets/plus2.svg index 3a219baf6a56..71bd2b5b91cf 100644 --- a/app/gui/src/dashboard/assets/plus2.svg +++ b/app/gui/src/dashboard/assets/plus2.svg @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/recent.svg b/app/gui/src/dashboard/assets/recent.svg index 39c01010e877..bc63a6389403 100644 --- a/app/gui/src/dashboard/assets/recent.svg +++ b/app/gui/src/dashboard/assets/recent.svg @@ -1,13 +1,3 @@ - - - - - - - - \ No newline at end of file + + + diff --git a/app/gui/src/dashboard/assets/restore.svg b/app/gui/src/dashboard/assets/restore.svg index 436017de9528..6b671e46c089 100644 --- a/app/gui/src/dashboard/assets/restore.svg +++ b/app/gui/src/dashboard/assets/restore.svg @@ -1,7 +1,3 @@ - - - - - \ No newline at end of file + + + diff --git a/app/gui/src/dashboard/assets/trash2.svg b/app/gui/src/dashboard/assets/trash2.svg index dcf6b4f04b41..2060847b9d53 100644 --- a/app/gui/src/dashboard/assets/trash2.svg +++ b/app/gui/src/dashboard/assets/trash2.svg @@ -1,9 +1,3 @@ - - - - - \ No newline at end of file + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx index f4238680ac84..e643addfc517 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.stories.tsx @@ -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> +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) => , + argTypes: { + variant: { + control: 'radio', + options: variants, + }, + size: { + control: 'radio', + options: sizes, + }, + addonStart: { control: false }, + addonEnd: { control: false }, + }, } as Meta> export const Variants: Story = { @@ -19,25 +44,20 @@ export const Variants: Story = {
Variants
- - - - - - - - - + {variants.map((variant) => ( + + ))}
Sizes
- - - - - - + {sizes.map((size) => ( + + ))}
Icons @@ -101,3 +121,49 @@ export const LoadingOnPress: Story = { await expect(await findByTestId('spinner')).toBeInTheDocument() }, } + +export const Addons: Story = { + args: { + addonStart: ( + + Test + + ), + addonEnd: ( + + Test + + ), + }, + render: (args) => ( + <> +
+ {sizes.map((size) => ( + + ))} + + {variants.map((variant) => ( + + ))} +
+ +
+ {sizes.map((size) => ( + + ))} + + {variants.map((variant) => ( + + ))} +
+ + ), +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx index e398bc020bc5..4a3d8dc5a59a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/Button.tsx @@ -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 */ readonly loaderPosition?: 'full' | 'icon' readonly styles?: ExtractFunction | 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) { @@ -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(null) const loaderRef = useRef(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 ( - - - - ) - } 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 - } else { - return {actualIcon} - } - } - })() - // Icon only button - if (isIconOnly) { - return {iconComponent} - } else { - // Default button - return ( - <> - {iconComponent} - - {/* @ts-expect-error any here is safe because we transparently pass it to the children, and ts infer the type outside correctly */} - {typeof children === 'function' ? children(render) : children} - - - ) - } - } - 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()(goodDefaults, ariaProps, focusChildProps, { + {...aria.mergeProps()(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)} + + {/* @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} + {isLoading && loaderPosition === 'full' && ( @@ -478,25 +487,135 @@ export const Button = memo( )} + + {shouldShowTooltip && visualTooltip} )} ) + if (tooltipElement == null) { + return button + } + return ( - tooltipElement == null ? button - : shouldUseVisualTooltip ? - <> - {button} - {visualTooltip} - - : - {button} - - - {tooltipElement} - - + + {button} + + + {tooltipElement} + + ) }), ) + +/** + * Props for {@link ButtonContent}. + */ +interface ButtonContentProps { + readonly isIconOnly: boolean + readonly isLoading: boolean + readonly loaderPosition: 'full' | 'icon' + readonly icon: ButtonProps['icon'] + readonly styles: ReturnType + 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 ( + + {hasAddon(addonStart) &&
{addonStart}
} + + {hasAddon(addonEnd) &&
{addonEnd}
} +
+ ) + } + + // Default button + return ( + <> + {hasAddon(addonStart) &&
{addonStart}
} + + {children} + {hasAddon(addonEnd) &&
{addonEnd}
} + + ) +}) + +/** + * Props for {@link Icon}. + */ +interface IconProps { + readonly isLoading: boolean + readonly loaderPosition: 'full' | 'icon' + readonly icon: ButtonProps['icon'] + readonly styles: ReturnType +} + +/** + * 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' ? + + : {iconRender} + })() + + if (shouldShowLoader) { + return ( +
+ +
+ ) + } + + return actualIcon +}) diff --git a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx index e5a89c239dfb..1a60155914c0 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Button/ButtonGroup.tsx @@ -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 (
@@ -65,26 +66,33 @@ export const Broken = { }, } +const sizes = [600, 300, 150, 450] function ResizableContent() { + const [sizeIndex, setSizeIndex] = useState(0) const divRef = useRef(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 ( -
- This dialog should resize with animation +
+ This dialog should resize with animation, and the content should be centered. Height:{' '} + {sizes[sizeIndex]}
) } @@ -103,3 +111,18 @@ export const Fullscreen = { type: 'fullscreen', }, } + +export const FullscreenWithStretchChildren: Story = { + args: { + type: 'fullscreen', + children: () => { + return ( +
+ + This dialog should stretch to fit the screen. + +
+ ) + }, + }, +} diff --git a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx index c97964915356..bec43019b7b4 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Dialog/Dialog.tsx @@ -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(null) - const scrollerRef = React.useRef() + const scrollerRef = React.useRef(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) => { - 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) { { 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) => ( <> - - - - - - + + + + + -
- {/* 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 */} -
-
- - - {typeof children === 'function' ? children(opts) : children} - - -
-
- - + {children} + + )} @@ -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 + 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 ( +
+
+ + + + {typeof children === 'function' ? children({ close }) : children} + + + +
+
+ ) +}) + /** * Props for the {@link DialogHeader} component. */ -interface DialogHeaderProps { - readonly headerClassName: string - readonly closeButtonClassName: string - readonly headingClassName: string +interface DialogHeaderProps extends Omit, 'scrolledToTop'> { readonly closeButton: DialogProps['closeButton'] readonly title: DialogProps['title'] readonly titleId: string readonly headerDimensionsRef: (node: HTMLElement | null) => void + readonly scrollerRef: React.RefObject + 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 ( - + {closeButton !== 'none' && ( - + )} {title != null && ( {title} diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx index 4f3639c32882..6f73cb8acd98 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/FieldValue.tsx @@ -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 } + +// 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(props: { + children: (value: T) => ReactNode + value: T +}) { + return props.children(props.value) +}) as unknown as (props: { children: (value: T) => ReactNode; value: T }) => ReactNode diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts b/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts index a83cf45db11d..3b91c4a7623f 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/useFieldState.ts @@ -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( closeRef.current() } + formInstance.reset() + return result } catch (error) { const isJSError = errorUtils.isJSError(error) diff --git a/app/gui/src/dashboard/components/AriaComponents/Inputs/Password/Password.tsx b/app/gui/src/dashboard/components/AriaComponents/Inputs/Password/Password.tsx index d6f971f2ee5d..36119cec738a 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Inputs/Password/Password.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Inputs/Password/Password.tsx @@ -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 {props.addonEnd} - + - { - resetStartModalDefaultOpen(true) - }} - > +