diff --git a/.github/workflows/gui-changed-files.yml b/.github/workflows/gui-changed-files.yml index 7c88edd9ffb3..cc6d950115cb 100644 --- a/.github/workflows/gui-changed-files.yml +++ b/.github/workflows/gui-changed-files.yml @@ -37,7 +37,6 @@ jobs: .prettierignore vitest.workspace.ts .github/workflows/gui* - .github/workflows/ide* .github/workflows/storybook.yml files_ignore: | app/ide-desktop/** diff --git a/.github/workflows/ide-changed-files.yml b/.github/workflows/ide-changed-files.yml new file mode 100644 index 000000000000..c366fedf06ff --- /dev/null +++ b/.github/workflows/ide-changed-files.yml @@ -0,0 +1,44 @@ +# This file is not auto-generated. Feel free to edit it. + +name: IDE Changed Files + +on: + workflow_call: + outputs: + all_changed_files: + description: "Returns all changed files" + value: ${{ jobs.ide-changed-files.outputs.all_changed_files }} + any_changed: + description: "Returns `true` when any of the filenames have changed" + value: ${{ jobs.ide-changed-files.outputs.any_changed }} + +jobs: + ide-changed-files: + runs-on: ubuntu-latest + name: Changed Files + outputs: + all_changed_files: ${{ steps.ide-changed-files.outputs.all_changed_files }} + any_changed: ${{ steps.ide-changed-files.outputs.any_changed }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Get changed files + id: ide-changed-files + uses: tj-actions/changed-files@v45 + with: + files: | + app/ide-desktop/** + package.json + pnpm-lock.yaml + .github/workflows/ide* + - name: List all changed files + env: + ALL_CHANGED_FILES: ${{ steps.ide-changed-files.outputs.all_changed_files }} + run: | + if [[ "${{ steps.ide-changed-files.outputs.any_changed }}" == "true" ]]; then + echo "Files changed:" + fi + for file in ${ALL_CHANGED_FILES}; do + echo "$file" + done diff --git a/.github/workflows/ide-pull-request.yml b/.github/workflows/ide-pull-request.yml index f21d23266998..81284a46636a 100644 --- a/.github/workflows/ide-pull-request.yml +++ b/.github/workflows/ide-pull-request.yml @@ -18,6 +18,11 @@ jobs: uses: ./.github/workflows/gui-changed-files.yml secrets: inherit + ide-changed-files: + name: 🔍 IDE Files Changed + uses: ./.github/workflows/ide-changed-files.yml + secrets: inherit + engine-changed-files: name: 🔍 Engine Files Changed uses: ./.github/workflows/engine-changed-files.yml @@ -27,14 +32,14 @@ jobs: name: 📦 Package uses: ./.github/workflows/ide-packaging.yml needs: [gui-changed-files, engine-changed-files] - if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit ide-packaging-optional: name: 📦 Package (Optional) uses: ./.github/workflows/ide-packaging-optional.yml needs: [gui-changed-files, engine-changed-files] - if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' + if: needs.gui-changed-files.outputs.any_changed == 'true' || needs.ide-changed-files.outputs.any_changed == 'true' || needs.engine-changed-files.outputs.any_changed == 'true' || github.ref == 'refs/heads/develop' secrets: inherit required-checks: diff --git a/app/common/package.json b/app/common/package.json index b193595dc5b9..1d90e3d03a1a 100644 --- a/app/common/package.json +++ b/app/common/package.json @@ -33,12 +33,12 @@ "lint": "eslint ./src --cache --max-warnings=0" }, "peerDependencies": { - "@tanstack/query-core": "5.54.1", - "@tanstack/vue-query": ">= 5.54.0 < 5.56.0" + "@tanstack/query-core": "5.59.20", + "@tanstack/vue-query": "5.59.20" }, "dependencies": { - "@tanstack/query-persist-client-core": "^5.54.0", - "@tanstack/vue-query": ">= 5.54.0 < 5.56.0", + "@tanstack/query-persist-client-core": "5.59.20", + "@tanstack/vue-query": "5.59.20", "lib0": "^0.2.85", "react": "^18.3.1", "vitest": "^1.3.1", diff --git a/app/common/src/queryClient.ts b/app/common/src/queryClient.ts index 9c8829c8f736..4ad6802a177a 100644 --- a/app/common/src/queryClient.ts +++ b/app/common/src/queryClient.ts @@ -135,6 +135,13 @@ export function createQueryClient( networkMode: 'always', refetchOnReconnect: 'always', staleTime: DEFAULT_QUERY_STALE_TIME_MS, + // This allows to prefetch queries in the render phase. Enables returning + // a promise from the `useQuery` hook, which is useful for the `Await` component, + // which needs to prefetch the query in the render phase to be able to display + // the error boundary/suspense fallback. + // @see [experimental_prefetchInRender](https://tanstack.com/query/latest/docs/framework/react/guides/suspense#using-usequerypromise-and-reactuse-experimental) + // eslint-disable-next-line camelcase + experimental_prefetchInRender: true, retry: (failureCount, error: unknown) => { const statusesToIgnore = [403, 404] const errorStatus = diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 118b795c5567..d39984bae353 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -970,9 +970,7 @@ export function createSpecialLoadingAsset(directoryId: DirectoryId): SpecialLoad return { type: AssetType.specialLoading, title: '', - id: LoadingAssetId( - createPlaceholderId(`${AssetType.specialLoading}-${uniqueString.uniqueString()}`), - ), + id: LoadingAssetId(createPlaceholderId(`${AssetType.specialLoading}-${directoryId}`)), modifiedAt: dateTime.toRfc3339(new Date()), parentId: directoryId, permissions: [], @@ -998,7 +996,7 @@ export function createSpecialEmptyAsset(directoryId: DirectoryId): SpecialEmptyA return { type: AssetType.specialEmpty, title: '', - id: EmptyAssetId(`${AssetType.specialEmpty}-${uniqueString.uniqueString()}`), + id: EmptyAssetId(`${AssetType.specialEmpty}-${directoryId}`), modifiedAt: dateTime.toRfc3339(new Date()), parentId: directoryId, permissions: [], @@ -1024,7 +1022,7 @@ export function createSpecialErrorAsset(directoryId: DirectoryId): SpecialErrorA return { type: AssetType.specialError, title: '', - id: ErrorAssetId(`${AssetType.specialError}-${uniqueString.uniqueString()}`), + id: ErrorAssetId(`${AssetType.specialError}-${directoryId}`), modifiedAt: dateTime.toRfc3339(new Date()), parentId: directoryId, permissions: [], @@ -1514,18 +1512,28 @@ export function isNewTitleValid( item: AnyAsset, newTitle: string, siblings?: readonly AnyAsset[] | null, +) { + return newTitle !== '' && newTitle !== item.title && isNewTitleUnique(item, newTitle, siblings) +} + +/** + * Check whether a new title is unique among the siblings. + */ +export function isNewTitleUnique( + item: AnyAsset, + newTitle: string, + siblings?: readonly AnyAsset[] | null, ) { siblings ??= [] - return ( - newTitle !== '' && - newTitle !== item.title && - siblings.every(sibling => { - const isSelf = sibling.id === item.id - const hasSameType = sibling.type === item.type - const hasSameTitle = sibling.title === newTitle - return !(!isSelf && hasSameType && hasSameTitle) - }) - ) + + return siblings.every(sibling => { + if (sibling.id === item.id) { + return true + } + + const hasSameTitle = sibling.title.toLowerCase() === newTitle.toLowerCase() + return !hasSameTitle + }) } /** Network error class. */ diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index b2e332c1836b..ee0cb1004315 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -59,6 +59,9 @@ "otherUserIsUsingProjectError": "Someone else is using this project", "localBackendNotDetectedError": "Could not detect the local backend", + "invalidInput": "Invalid input", + "nameShouldBeUnique": "Name must be unique", + "nameShouldNotContainInvalidCharacters": "Name should not contain invalid characters", "invalidEmailValidationError": "Please enter a valid email address", "projectHasNoSourceFilesPhrase": "project has no source files", diff --git a/app/common/src/text/index.ts b/app/common/src/text/index.ts index 8c532137d50e..31a63e87bdbb 100644 --- a/app/common/src/text/index.ts +++ b/app/common/src/text/index.ts @@ -1,5 +1,6 @@ /** @file Functions related to displaying text. */ +import { unsafeKeys } from '../utilities/data/object' import ENGLISH from './english.json' with { type: 'json' } // ============= @@ -159,3 +160,54 @@ export interface Replacements export const TEXTS: Readonly> = { [Language.english]: ENGLISH, } +/** + * A function that gets localized text for a given key, with optional replacements. + * @param key - The key of the text to get. + * @param replacements - The replacements to insert into the text. + * If the text contains placeholders like `$0`, `$1`, etc., + * they will be replaced with the corresponding replacement. + */ +export type GetText = ( + dictionary: Texts, + key: K, + ...replacements: Replacements[K] +) => string + +/** + * Resolves the language texts based on the user's preferred language. + */ +export function resolveUserLanguage() { + const locale = navigator.language + const language = + unsafeKeys(LANGUAGE_TO_LOCALE).find(language => locale === LANGUAGE_TO_LOCALE[language]) ?? + Language.english + + return language +} + +/** + * Gets the dictionary for a given language. + * @param language - The language to get the dictionary for. + * @returns The dictionary for the given language. + */ +export function getDictionary(language: Language) { + return TEXTS[language] +} + +/** + * Gets the text for a given key, with optional replacements. + * @param dictionary - The dictionary to get the text from. + * @param key - The key of the text to get. + * @param replacements - The replacements to insert into the text. + * If the text contains placeholders like `$0`, `$1`, etc., + * they will be replaced with the corresponding replacement. + */ +export const getText: GetText = (dictionary, key, ...replacements) => { + const template = dictionary[key] + + return replacements.length === 0 ? + template + : template.replace(/[$]([$]|\d+)/g, (_match, placeholder: string) => + placeholder === '$' ? '$' : String(replacements[Number(placeholder)] ?? `$${placeholder}`), + ) +} diff --git a/app/gui/env.d.ts b/app/gui/env.d.ts index 2babbb394e40..01bee4b4999f 100644 --- a/app/gui/env.d.ts +++ b/app/gui/env.d.ts @@ -184,6 +184,13 @@ declare global { * ATM only affects the framer-motion animations. */ readonly DISABLE_ANIMATIONS?: boolean + readonly featureFlags: FeatureFlags + readonly setFeatureFlags: (flags: Partial) => void + /** + * Feature flags that override the default or stored feature flags. + * This is used by integration tests to set feature flags. + */ + readonly overrideFeatureFlags: Partial } namespace NodeJS { diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 83122470e5a2..bea14460c749 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -41,7 +41,9 @@ function locateAssetRows(page: Page) { /** Find assets table placeholder rows. */ function locateNonAssetRows(page: Page) { - return locateAssetsTable(page).locator('tbody tr:not([data-testid="asset-row"])') + return locateAssetsTable(page).locator( + 'tbody tr:not([data-testid="asset-row"]):not([data-testid="dummy-row"])', + ) } /** Find a "new secret" icon. */ diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 09f7c15cbb5f..975fac1e9b39 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -12,11 +12,16 @@ import * as uniqueString from 'enso-common/src/utilities/uniqueString' import * as actions from '.' +import type { FeatureFlags } from '#/providers/FeatureFlagsProvider' import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import invariant from 'tiny-invariant' +// ================= +// === Constants === +// ================= + const __dirname = dirname(fileURLToPath(import.meta.url)) const MOCK_SVG = ` @@ -111,6 +116,7 @@ const INITIAL_CALLS_OBJECT = { createDirectory: array(), getProjectContent: array<{ projectId: backend.ProjectId }>(), getProjectAsset: array<{ projectId: backend.ProjectId }>(), + updateProject: array(), } const READONLY_INITIAL_CALLS_OBJECT: TrackedCallsInternal = INITIAL_CALLS_OBJECT @@ -185,7 +191,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { const assetMap = new Map() const deletedAssets = new Set() - const assets: backend.AnyAsset[] = [] + let assets: backend.AnyAsset[] = [] const labels: backend.Label[] = [] const labelsByValue = new Map() const labelMap = new Map() @@ -232,8 +238,8 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } const addAsset = (asset: T) => { - assets.push(asset) assetMap.set(asset.id, asset) + assets = Array.from(assetMap.values()) return asset } @@ -250,6 +256,20 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return wasDeleted } + const editAsset = (assetId: backend.AssetId, rest: Partial) => { + const asset = assetMap.get(assetId) + + if (asset == null) { + throw new Error(`Asset ${assetId} not found`) + } + + const updated = object.merge(asset, rest) + + addAsset(updated) + + return updated + } + const createDirectory = (rest: Partial = {}): backend.DirectoryAsset => { const directoryTitles = new Set( assets @@ -363,19 +383,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { color, }) - const addDirectory = (rest: Partial) => { + const addDirectory = (rest: Partial = {}) => { return addAsset(createDirectory(rest)) } - const addProject = (rest: Partial) => { + const addProject = (rest: Partial = {}) => { return addAsset(createProject(rest)) } - const addFile = (rest: Partial) => { + const addFile = (rest: Partial = {}) => { return addAsset(createFile(rest)) } - const addSecret = (rest: Partial) => { + const addSecret = (rest: Partial = {}) => { return addAsset(createSecret(rest)) } @@ -873,15 +893,20 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } }, ) - await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (_route, request) => { + + await patch(remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID), (route, request) => { const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] - if (!maybeId) return + + if (!maybeId) throw new Error('updateAssetPath: Missing asset ID in path') // This could be an id for an arbitrary asset, but pretend it's a // `DirectoryId` to make TypeScript happy. const assetId = backend.DirectoryId(maybeId) const body: backend.UpdateAssetRequestBody = request.postDataJSON() + called('updateAsset', { ...body, assetId }) + const asset = assetMap.get(assetId) + if (asset != null) { if (body.description != null) { object.unsafeMutable(asset).description = body.description @@ -891,7 +916,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { object.unsafeMutable(asset).parentId = body.parentDirectoryId } } + + return route.fulfill({ json: asset }) }) + await patch(remoteBackendPaths.associateTagPath(GLOB_ASSET_ID), async (_route, request) => { const maybeId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] if (!maybeId) return @@ -917,6 +945,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } return json }) + await put(remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID), async (route, request) => { const maybeId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] if (!maybeId) return @@ -937,6 +966,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) } }) + await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] if (!maybeId) return @@ -947,6 +977,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { deleteAsset(assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) + await patch(remoteBackendPaths.UNDO_DELETE_ASSET_PATH, async (route, request) => { /** The type for the JSON request payload for this endpoint. */ interface Body { @@ -957,13 +988,39 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { undeleteAsset(body.assetId) await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) + + await put(remoteBackendPaths.projectUpdatePath(GLOB_PROJECT_ID), async (route, request) => { + const maybeId = request.url().match(/[/]projects[/]([^?/]+)/)?.[1] + + if (!maybeId) return route.fulfill({ status: HTTP_STATUS_NOT_FOUND }) + + const projectId = backend.ProjectId(maybeId) + + const body: backend.UpdateProjectRequestBody = await request.postDataJSON() + + called('updateProject', body) + + const newTitle = body.projectName + + if (newTitle == null) { + return route.fulfill({ status: HTTP_STATUS_BAD_REQUEST }) + } + + return route.fulfill({ + json: editAsset(projectId, { title: newTitle }), + }) + }) + await post(remoteBackendPaths.CREATE_USER_PATH + '*', async (_route, request) => { const body: backend.CreateUserRequestBody = await request.postDataJSON() + const organizationId = body.organizationId ?? defaultUser.organizationId const rootDirectoryId = backend.DirectoryId( organizationId.replace(/^organization-/, 'directory-'), ) + called('createUser', body) + currentUser = { email: body.userEmail, name: body.userName, @@ -976,12 +1033,14 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { } return currentUser }) + await post(remoteBackendPaths.CREATE_USER_GROUP_PATH + '*', async (_route, request) => { const body: backend.CreateUserGroupRequestBody = await request.postDataJSON() called('createUserGroup', body) const userGroup = addUserGroup(body.name) return userGroup }) + await put( remoteBackendPaths.changeUserGroupPath(GLOB_USER_ID) + '*', async (route, request) => { @@ -1086,7 +1145,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { await post(remoteBackendPaths.CREATE_DIRECTORY_PATH + '*', (_route, request) => { const body: backend.CreateDirectoryRequestBody = request.postDataJSON() + called('createDirectory', body) + const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`) const parentId = body.parentId ?? defaultDirectoryId @@ -1196,6 +1257,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { currentOrganizationProfilePicture: () => currentOrganizationProfilePicture, addAsset, deleteAsset, + editAsset, undeleteAsset, createDirectory, createProject, @@ -1213,6 +1275,19 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { deleteUser, addUserGroup, deleteUserGroup, + setFeatureFlags: (flags: Partial) => { + return page.addInitScript((flags: Partial) => { + const currentOverrideFeatureFlags = + 'overrideFeatureFlags' in window && typeof window.overrideFeatureFlags === 'object' ? + window.overrideFeatureFlags + : {} + + Object.defineProperty(window, 'overrideFeatureFlags', { + value: { ...currentOverrideFeatureFlags, ...flags }, + writable: false, + }) + }, flags) + }, // TODO: // addPermission, // deletePermission, diff --git a/app/gui/integration-test/dashboard/actions/index.ts b/app/gui/integration-test/dashboard/actions/index.ts index cda9f1a49627..a83e3e37fad2 100644 --- a/app/gui/integration-test/dashboard/actions/index.ts +++ b/app/gui/integration-test/dashboard/actions/index.ts @@ -1,10 +1,11 @@ /** @file Various actions, locators, and constants used in end-to-end tests. */ + +import { TEXTS, getText as baseGetText, type Replacements, type TextId } from 'enso-common/src/text' + import path from 'node:path' import { expect, test, type Page } from '@playwright/test' -import { TEXTS } from 'enso-common/src/text' - import { INITIAL_CALLS_OBJECT, mockApi, @@ -25,6 +26,10 @@ export const VALID_PASSWORD = 'Password0!' export const VALID_EMAIL = 'email@example.com' export const TEXT = TEXTS.english +export const getText = (key: TextId, ...replacements: Replacements[TextId]) => { + return baseGetText(TEXT, key, ...replacements) +} + /** Get the path to the auth file. */ export function getAuthFilePath() { const __dirname = path.dirname(new URL(import.meta.url).pathname) @@ -67,8 +72,7 @@ async function login({ page }: MockParams, email = 'email@example.com', password async function waitForLoaded(page: Page) { await page.waitForLoadState() - await expect(page.getByTestId('spinner')).toHaveCount(0) - await expect(page.getByTestId('loading-app-message')).not.toBeVisible({ timeout: 30_000 }) + await expect(page.getByTestId('loading-screen')).toHaveCount(0, { timeout: 30_000 }) } /** Wait for the dashboard to load. */ diff --git a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts index 2bf8a3bc4aea..3be7e5461dbc 100644 --- a/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts +++ b/app/gui/integration-test/dashboard/assetsTableFeatures.spec.ts @@ -1,7 +1,8 @@ /** @file Test the drive view. */ import { expect, test, type Locator, type Page } from '@playwright/test' -import { mockAllAndLogin, TEXT } from './actions' +import { EmailAddress, ProjectState } from '#/services/Backend' +import { getText, mockAllAndLogin, TEXT } from './actions' /** Find an extra columns button panel. */ function locateExtraColumns(page: Page) { @@ -53,7 +54,7 @@ test('extra columns should stick to right side of assets table', ({ page }) => const assetsTableRight = await assetsTable.evaluate( (element) => element.getBoundingClientRect().right, ) - expect(extraColumnsRight).toEqual(assetsTableRight - 12) + expect(extraColumnsRight).toEqual(assetsTableRight - 8) }).toPass({ timeout: PASS_TIMEOUT }) })) @@ -115,3 +116,52 @@ test('can drop onto root directory dropzone', ({ page }) => const secondLeft = await getAssetRowLeftPx(rows.nth(1)) expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft) })) + +test("can't run a project in browser by default", ({ page }) => + mockAllAndLogin({ + page, + setupAPI: async (api) => { + api.addProject({ title: 'a' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.first() + + const startProjectButton = row.getByTestId('open-project') + await expect(startProjectButton).toBeDisabled() + })) + +test("can't start an already running by another user", ({ page }) => + mockAllAndLogin({ + page, + setupAPI: async (api) => { + await api.setFeatureFlags({ enableCloudExecution: true }) + + const userGroup = api.addUserGroup('Test Group') + + api.addUserGroupToUser(api.defaultUser.userId, userGroup.id) + + const peer = api.addUser('Test User', { + email: EmailAddress('test@test.com'), + userGroups: [userGroup.id], + }) + + api.addProject({ + title: 'a', + projectState: { + type: ProjectState.opened, + volumeId: '123', + openedBy: peer.email, + }, + }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.first() + const startProjectButton = row.getByTestId('open-project') + const stopProjectButton = row.getByTestId('stop-project') + + await expect(row).toBeVisible() + await expect(row.getByTestId('switch-to-project')).not.toBeVisible() + await expect(startProjectButton).not.toBeVisible() + await expect(stopProjectButton).toBeDisabled() + await expect(stopProjectButton).toHaveAccessibleName(getText('xIsUsingTheProject', 'Test User')) + })) diff --git a/app/gui/integration-test/dashboard/copy.spec.ts b/app/gui/integration-test/dashboard/copy.spec.ts index b12431b9e76e..7d489deefa60 100644 --- a/app/gui/integration-test/dashboard/copy.spec.ts +++ b/app/gui/integration-test/dashboard/copy.spec.ts @@ -29,18 +29,24 @@ test('copy', ({ page }) => .createFolder() // Assets: [0: Folder 2, 1: Folder 1] .createFolder() - .driveTable.rightClickRow(0) + .driveTable.rightClickRow(1) // Assets: [0: Folder 2 , 1: Folder 1] .contextMenu.copy() - .driveTable.rightClickRow(1) + .driveTable.rightClickRow(0) // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] .contextMenu.paste() .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(3) - await expect(rows.nth(2)).toBeVisible() - await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) - const parentLeft = await getAssetRowLeftPx(rows.nth(1)) - const childLeft = await getAssetRowLeftPx(rows.nth(2)) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 1 [(]copy[)]*/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) })) @@ -50,18 +56,24 @@ test('copy (keyboard)', ({ page }) => .createFolder() // Assets: [0: Folder 2, 1: Folder 1] .createFolder() - .driveTable.clickRow(0) + .driveTable.clickRow(1) // Assets: [0: Folder 2 , 1: Folder 1] .press('Mod+C') - .driveTable.clickRow(1) + .driveTable.clickRow(0) // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] .press('Mod+V') .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(3) - await expect(rows.nth(2)).toBeVisible() - await expect(rows.nth(2)).toHaveText(/^New Folder 1 [(]copy[)]*/) - const parentLeft = await getAssetRowLeftPx(rows.nth(1)) - const childLeft = await getAssetRowLeftPx(rows.nth(2)) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 1 [(]copy[)]*/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) })) @@ -79,39 +91,58 @@ test('move', ({ page }) => .contextMenu.paste() .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(2) - await expect(rows.nth(1)).toBeVisible() - await expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await getAssetRowLeftPx(rows.nth(0)) - const childLeft = await getAssetRowLeftPx(rows.nth(1)) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 2/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) })) test('move (drag)', ({ page }) => - mockAllAndLogin({ page }) - // Assets: [0: Folder 1] - .createFolder() - // Assets: [0: Folder 2, 1: Folder 1] - .createFolder() - // Assets: [0: Folder 1, 1: Folder 2 ] + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ + title: 'New Folder 1', + }) + api.addDirectory({ + title: 'New Folder 2', + }) + }, + }) .driveTable.dragRowToRow(0, 1) .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(2) - await expect(rows.nth(1)).toBeVisible() - await expect(rows.nth(1)).toHaveText(/^New Folder 1/) - const parentLeft = await getAssetRowLeftPx(rows.nth(0)) - const childLeft = await getAssetRowLeftPx(rows.nth(1)) + + const child = rows.nth(1) + const parent = rows.nth(0) + + await expect(child).toBeVisible() + await expect(child).toHaveText(/^New Folder 1/) + + const parentLeft = await getAssetRowLeftPx(parent) + const childLeft = await getAssetRowLeftPx(child) + expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) })) test('move to trash', ({ page }) => - mockAllAndLogin({ page }) - // Assets: [0: Folder 1] - .createFolder() - // Assets: [0: Folder 2, 1: Folder 1] - .createFolder() + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory() + api.addDirectory() + }, + }) // NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still // held. - .withModPressed((modActions) => modActions.driveTable.clickRow(0).driveTable.clickRow(1)) + .withModPressed((modActions) => modActions.driveTable.clickRow(1).driveTable.clickRow(0)) .driveTable.dragRow(0, locateTrashCategory(page)) .driveTable.expectPlaceholderRow() .goToCategory.trash() @@ -134,7 +165,7 @@ test('move (keyboard)', ({ page }) => .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(2) await expect(rows.nth(1)).toBeVisible() - await expect(rows.nth(1)).toHaveText(/^New Folder 1/) + await expect(rows.nth(1)).toHaveText(/^New Folder 2/) const parentLeft = await getAssetRowLeftPx(rows.nth(0)) const childLeft = await getAssetRowLeftPx(rows.nth(1)) expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) diff --git a/app/gui/integration-test/dashboard/editAssetName.spec.ts b/app/gui/integration-test/dashboard/editAssetName.spec.ts index bca1059965fa..6e952642f361 100644 --- a/app/gui/integration-test/dashboard/editAssetName.spec.ts +++ b/app/gui/integration-test/dashboard/editAssetName.spec.ts @@ -1,7 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ import { expect, test, type Locator, type Page } from '@playwright/test' -import { TEXT, mockAllAndLogin } from './actions' +import { TEXT, getText, mockAllAndLogin } from './actions' const NEW_NAME = 'foo bar baz' const NEW_NAME_2 = 'foo bar baz quux' @@ -17,6 +17,10 @@ function locateAssetRowName(locator: Locator) { return locator.getByTestId('asset-row-name') } +function locateInput(nameLocator: Locator) { + return nameLocator.getByRole('textbox') +} + /** Find a tick button. */ function locateEditingTick(page: Locator) { return page.getByLabel(TEXT.confirmEdit) @@ -35,7 +39,7 @@ test('edit name (double click)', ({ page }) => const nameEl = locateAssetRowName(row) await nameEl.click() await nameEl.click() - await nameEl.fill(NEW_NAME) + await locateInput(nameEl).fill(NEW_NAME) const calls = api.trackCalls() await locateEditingTick(row).click() await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) @@ -52,10 +56,10 @@ test('edit name (context menu)', ({ page }) => .getByText(/Rename/) .click() const nameEl = locateAssetRowName(row) - await expect(nameEl).toBeVisible() - await expect(nameEl).toBeFocused() - await nameEl.fill(NEW_NAME) - await expect(nameEl).toHaveValue(NEW_NAME) + await expect(locateInput(nameEl)).toBeVisible() + await expect(locateInput(nameEl)).toBeFocused() + await locateInput(nameEl).fill(NEW_NAME) + await expect(locateInput(nameEl)).toHaveValue(NEW_NAME) const calls = api.trackCalls() await nameEl.press('Enter') await expect(row).toHaveText(new RegExp('^' + NEW_NAME)) @@ -72,7 +76,7 @@ test('edit name (keyboard)', ({ page }) => .driveTable.withRows(async (rows, _, { api }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) - await nameEl.fill(NEW_NAME_2) + await locateInput(nameEl).fill(NEW_NAME_2) const calls = api.trackCalls() await nameEl.press('Enter') await expect(row).toHaveText(new RegExp('^' + NEW_NAME_2)) @@ -88,7 +92,7 @@ test('cancel editing name (double click)', ({ page }) => const oldName = (await nameEl.textContent()) ?? '' await nameEl.click() await nameEl.click() - await nameEl.fill(NEW_NAME) + await nameEl.getByTestId('input').fill(NEW_NAME) const calls = api.trackCalls() await locateEditingCross(row).click() await expect(row).toHaveText(new RegExp('^' + oldName)) @@ -107,7 +111,7 @@ test('cancel editing name (keyboard)', ({ page }) => { const row = rows.nth(0) const nameEl = locateAssetRowName(row) oldName = (await nameEl.textContent()) ?? '' - await nameEl.fill(NEW_NAME_2) + await nameEl.getByTestId('input').fill(NEW_NAME_2) const calls = api.trackCalls() await nameEl.press('Escape') await expect(row).toHaveText(new RegExp('^' + oldName)) @@ -124,8 +128,8 @@ test('change to blank name (double click)', ({ page }) => const oldName = (await nameEl.textContent()) ?? '' await nameEl.click() await nameEl.click() - await nameEl.fill('') - await expect(locateEditingTick(row)).not.toBeVisible() + await nameEl.getByTestId('input').fill('') + await expect(locateEditingTick(row)).toBeVisible() const calls = api.trackCalls() await locateEditingCross(row).click() await expect(row).toHaveText(new RegExp('^' + oldName)) @@ -143,9 +147,189 @@ test('change to blank name (keyboard)', ({ page }) => const row = rows.nth(0) const nameEl = locateAssetRowName(row) const oldName = (await nameEl.textContent()) ?? '' - await nameEl.fill('') + await nameEl.getByTestId('input').fill('') const calls = api.trackCalls() await nameEl.press('Enter') await expect(row).toHaveText(new RegExp('^' + oldName)) expect(calls.updateDirectory).toMatchObject([]) })) + +test('edit name, error message is visible', ({ page }) => { + return mockAllAndLogin({ + page, + setupAPI: (api) => { + for (let i = 0; i < 100; i++) { + api.addProject({ title: 'Some Project ' + i }) + } + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('') + + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorOutline).toBeVisible() + // Clicking the element to be sure it's not overlapped by another element. + await errorText.click() + + await inputEl.fill('Another Project') + await locateEditingTick(row).click() + + await expect(errorOutline).not.toBeAttached() + await expect(errorText).not.toBeAttached() + }) +}) + +test('edit name (empty name)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addProject({ title: 'Some Project' }) + api.addProject({ title: 'Other Project' }) + api.addProject({ title: 'Yet Another Project' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('') + + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorContainer = formElement.getByTestId('error-message-container') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorOutline).toBeVisible() + await expect(errorContainer).toBeVisible() + await expect(errorText).toHaveText(getText('arbitraryFieldRequired')) + + await inputEl.fill('Another Project') + await locateEditingTick(row).click() + + await expect(row).toHaveText(/^Another Project/) + await expect(errorOutline).not.toBeAttached() + await expect(errorContainer).not.toBeAttached() + })) + +test('edit name (invalid name)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ title: 'Some Directory' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('../') + + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorContainer = formElement.getByTestId('error-message-container') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorOutline).toBeVisible() + await expect(errorContainer).toBeVisible({ + visible: true, + }) + await expect(errorText).toHaveText(getText('nameShouldNotContainInvalidCharacters')) + + await inputEl.fill('Other Directory') + await locateEditingTick(row).click() + + await expect(row).toHaveText(/^Other Directory/) + })) + +test('edit name (duplicate name)', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory({ title: 'Some Directory' }) + api.addProject({ title: 'Some Project' }) + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(0) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('Some Project') + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorText).toHaveText(getText('nameShouldBeUnique')) + + await inputEl.fill('Other Directory') + await locateEditingTick(row).click() + + await expect(row).toHaveText(/^Other Directory/) + })) + +test('error should not overlay the table header', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + for (let i = 0; i < 100; i++) { + api.addProject({ title: 'Some Project ' + i }) + } + }, + }).driveTable.withRows(async (rows) => { + const row = rows.nth(1) + await locateAssetRowName(row).click() + + const nameEl = locateAssetRowName(row) + await nameEl.click() + await nameEl.click() + + const inputEl = locateInput(nameEl) + + await inputEl.fill('Some Project 0') + await locateEditingTick(row).click() + + const formElement = row.getByTestId('editable-span-form') + const errorOutline = formElement.getByTestId('error-message-outline') + const errorContainer = formElement.getByTestId('error-message-container') + const errorText = formElement.getByTestId('error-message-text') + + await expect(errorText).toHaveText(getText('nameShouldBeUnique')) + + await rows.nth(51).scrollIntoViewIfNeeded() + + await expect(errorOutline).not.toBeInViewport() + await expect(errorContainer).not.toBeInViewport() + await expect(errorText).not.toBeInViewport() + })) diff --git a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts index f59e9cf3da20..de26fe3bded7 100644 --- a/app/gui/integration-test/dashboard/pageSwitcher.spec.ts +++ b/app/gui/integration-test/dashboard/pageSwitcher.spec.ts @@ -15,10 +15,11 @@ function locateDriveView(page: Page) { return page.getByTestId('drive-view') } -// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Unskip once cloud execution in the browser is re-enabled. -test.skip('page switcher', ({ page }) => - mockAllAndLogin({ page }) +test('page switcher', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => api.setFeatureFlags({ enableCloudExecution: true }), + }) // Create a new project so that the editor page can be switched to. .newEmptyProjectTest() .do(async (thePage) => { diff --git a/app/gui/integration-test/dashboard/sort.spec.ts b/app/gui/integration-test/dashboard/sort.spec.ts index ebcfdbdac550..16483dcb6672 100644 --- a/app/gui/integration-test/dashboard/sort.spec.ts +++ b/app/gui/integration-test/dashboard/sort.spec.ts @@ -80,14 +80,14 @@ test('sort', ({ page }) => // By default, assets should be grouped by type. // Assets in each group are ordered by insertion order. await expect(rows).toHaveText([ - /^a directory/, /^G directory/, + /^a directory/, /^C project/, /^b project/, /^d file/, /^e file/, - /^H secret/, /^f secret/, + /^H secret/, ]) }) // Sort by name ascending. @@ -135,14 +135,14 @@ test('sort', ({ page }) => }) .driveTable.withRows(async (rows) => { await expect(rows).toHaveText([ - /^a directory/, /^G directory/, + /^a directory/, /^C project/, /^b project/, /^d file/, /^e file/, - /^H secret/, /^f secret/, + /^H secret/, ]) }) // Sort by date ascending. @@ -190,13 +190,13 @@ test('sort', ({ page }) => }) .driveTable.withRows(async (rows) => { await expect(rows).toHaveText([ - /^a directory/, /^G directory/, + /^a directory/, /^C project/, /^b project/, /^d file/, /^e file/, - /^H secret/, /^f secret/, + /^H secret/, ]) })) diff --git a/app/gui/integration-test/dashboard/startModal.spec.ts b/app/gui/integration-test/dashboard/startModal.spec.ts index 411825adb944..3c1ef1b6b8cb 100644 --- a/app/gui/integration-test/dashboard/startModal.spec.ts +++ b/app/gui/integration-test/dashboard/startModal.spec.ts @@ -21,12 +21,9 @@ function locateSamples(page: Page) { return locateSamplesList(page).getByRole('button') } -// FIXME[sb]: https://github.com/enso-org/cloud-v2/issues/1615 -// Unskip once cloud execution in the browser is re-enabled. - -test.skip('create project from template', ({ page }) => - mockAllAndLogin({ page }) - .expectStartModal() +test('create project from template', ({ page }) => + mockAllAndLogin({ page, setupAPI: (api) => api.setFeatureFlags({ enableCloudExecution: true }) }) + .openStartModal() .createProjectFromTemplate(0) .do(async (thePage) => { await expect(locateEditor(thePage)).toBeAttached() diff --git a/app/gui/package.json b/app/gui/package.json index 4a9e45f7245f..4640ab6faa8b 100644 --- a/app/gui/package.json +++ b/app/gui/package.json @@ -65,8 +65,8 @@ "@sentry/vite-plugin": "^2.22.7", "@stripe/react-stripe-js": "^2.7.1", "@stripe/stripe-js": "^3.5.0", - "@tanstack/react-query": "5.55.0", - "@tanstack/vue-query": ">= 5.54.0 < 5.56.0", + "@tanstack/react-query": "5.59.20", + "@tanstack/vue-query": "5.59.20", "@vueuse/core": "^10.4.1", "@vueuse/gesture": "^2.0.0", "ag-grid-community": "^32.3.3", @@ -155,7 +155,7 @@ "@storybook/test": "^8.4.2", "@storybook/vue3": "^8.4.2", "@storybook/vue3-vite": "^8.4.2", - "@tanstack/react-query-devtools": "5.45.1", + "@tanstack/react-query-devtools": "5.59.20", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.0.1", "@testing-library/react-hooks": "8.0.1", diff --git a/app/gui/src/dashboard/App.tsx b/app/gui/src/dashboard/App.tsx index 1b7123965687..46293fc1f2b4 100644 --- a/app/gui/src/dashboard/App.tsx +++ b/app/gui/src/dashboard/App.tsx @@ -88,7 +88,6 @@ import LocalBackend from '#/services/LocalBackend' import ProjectManager, * as projectManager from '#/services/ProjectManager' import RemoteBackend from '#/services/RemoteBackend' -import { FeatureFlagsProvider } from '#/providers/FeatureFlagsProvider' import * as appBaseUrl from '#/utilities/appBaseUrl' import * as eventModule from '#/utilities/event' import LocalStorage from '#/utilities/LocalStorage' @@ -542,40 +541,38 @@ function AppRouter(props: AppRouterProps) { ) return ( - - - - - - - {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here - * due to modals being in `TheModal`. */} - - - - {routes} - - - - - - - - - - - - + + + + + + {/* Ideally this would be in `Drive.tsx`, but it currently must be all the way out here + * due to modals being in `TheModal`. */} + + + + {routes} + + + + + + + + + + + ) } diff --git a/app/gui/src/dashboard/assets/cross.svg b/app/gui/src/dashboard/assets/cross.svg index 3190ad3d1e04..a9e60f676baa 100644 --- a/app/gui/src/dashboard/assets/cross.svg +++ b/app/gui/src/dashboard/assets/cross.svg @@ -1,4 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/cross2.svg b/app/gui/src/dashboard/assets/cross2.svg index 920ef74ad3d0..0dc9486b155b 100644 --- a/app/gui/src/dashboard/assets/cross2.svg +++ b/app/gui/src/dashboard/assets/cross2.svg @@ -1,7 +1,7 @@ - - + + \ No newline at end of file diff --git a/app/gui/src/dashboard/assets/error_filled.svg b/app/gui/src/dashboard/assets/error_filled.svg new file mode 100644 index 000000000000..7012571bae24 --- /dev/null +++ b/app/gui/src/dashboard/assets/error_filled.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/error_outline.svg b/app/gui/src/dashboard/assets/error_outline.svg new file mode 100644 index 000000000000..550619be3a84 --- /dev/null +++ b/app/gui/src/dashboard/assets/error_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/assets/warning.svg b/app/gui/src/dashboard/assets/warning.svg new file mode 100644 index 000000000000..ff83234b968b --- /dev/null +++ b/app/gui/src/dashboard/assets/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx index 2651eb2f4601..968674b6609b 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/Form.tsx @@ -130,6 +130,7 @@ export const Form = forwardRef(function Form< Field: typeof components.Field FormError: typeof components.FormError FieldValue: typeof components.FieldValue + Provider: typeof components.FormProvider useFormSchema: typeof components.useFormSchema Controller: typeof components.Controller FIELD_STYLES: typeof components.FIELD_STYLES @@ -138,6 +139,7 @@ export const Form = forwardRef(function Form< useWatch: typeof components.useWatch useFieldRegister: typeof components.useFieldRegister useFieldState: typeof components.useFieldState + useFormError: typeof components.useFormError /* eslint-enable @typescript-eslint/naming-convention */ } @@ -153,7 +155,9 @@ Form.useFormContext = components.useFormContext Form.useOptionalFormContext = components.useOptionalFormContext Form.Field = components.Field Form.Controller = components.Controller +Form.Provider = components.FormProvider Form.useWatch = components.useWatch Form.FIELD_STYLES = components.FIELD_STYLES Form.useFieldRegister = components.useFieldRegister Form.useFieldState = components.useFieldState +Form.useFormError = components.useFormError diff --git a/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx b/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx index 950b32c89696..eccad3d50ef5 100644 --- a/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx +++ b/app/gui/src/dashboard/components/AriaComponents/Form/components/Field.tsx @@ -39,7 +39,7 @@ export interface FieldChildrenRenderProps { readonly isTouched: boolean readonly isValidating: boolean readonly hasError: boolean - readonly error?: string | undefined + readonly error?: string | null | undefined } export const FIELD_STYLES = tv({ @@ -88,7 +88,7 @@ export const Field = forwardRef(function Field( const classes = variants({ fullWidth, isInvalid: invalid, isHidden }) - const hasError = (error ?? fieldState.error) != null + const hasError = (error !== undefined ? error : fieldState.error) != null return (
( {label} {isRequired && ( -