Merge branch 'develop' into wip/frizi/bazel

This commit is contained in:
Paweł Grabarz 2024-08-14 15:23:46 +02:00
commit 272d4168bc
358 changed files with 8638 additions and 4799 deletions

View File

@ -1,5 +1,11 @@
# Next Release
#### Enso IDE
- [Table Editor Widget][10774] displayed in `Table.new` component.
[10774]: https://github.com/enso-org/enso/pull/10774
#### Enso Standard Library
- [Implemented in-memory and database mixed `Decimal` column

1611
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -69,7 +69,6 @@ opt-level = 2
inherits = "dev"
opt-level = 1
lto = false
debug = "line-tables-only"
debug-assertions = true
[workspace.lints.rust]
@ -102,6 +101,8 @@ directories = { version = "5.0.1" }
dirs = { version = "5.0.1" }
flate2 = { version = "1.0.28" }
indicatif = { version = "0.17.7", features = ["tokio"] }
mime = "0.3.16"
new_mime_guess = "4.0.1"
multimap = { version = "0.9.1" }
native-windows-gui = { version = "1.0.13" }
nix = { version = "0.27.1" }
@ -109,10 +110,9 @@ octocrab = { git = "https://github.com/enso-org/octocrab", default-features = fa
"rustls",
] }
path-absolutize = "3.1.1"
platforms = { version = "3.2.0", features = ["serde"] }
portpicker = { version = "0.1.1" }
regex = { version = "1.6.0" }
serde = { version = "1.0.130", features = ["derive", "rc"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_yaml = { version = "0.9.16" }
sha2 = { version = "0.10.8" }
sysinfo = { version = "0.30.7" }
@ -123,25 +123,22 @@ tokio-util = { version = "0.7.10", features = ["full"] }
tracing = { version = "0.1.40" }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
walkdir = { version = "2.5.0" }
wasm-bindgen = { version = "0.2.92", features = [] }
wasm-bindgen = { version = "0.2.92", default-features = false }
wasm-bindgen-test = { version = "0.3.34" }
windows = { version = "0.52.0", features = ["Win32", "Win32_UI", "Win32_UI_Shell", "Win32_System", "Win32_System_LibraryLoader", "Win32_Foundation", "Win32_System_Com"] }
windows = { version = "0.52.0", features = ["Win32_UI", "Win32_UI_Shell", "Win32_System_LibraryLoader", "Win32_System_Com"] }
winreg = { version = "0.52.0" }
anyhow = { version = "1.0.66" }
derive_more = { version = "0.99" }
derive_more = { version = "1.0", features = ["index", "index_mut", "deref", "deref_mut", "display", "from", "into", "as_ref", "add", "add_assign"] }
boolinator = { version = "2.4.0" }
derivative = { version = "2.2" }
futures = { version = "0.3" }
futures = { version = "0.3", default-features = false, features = ["std", "executor"]}
futures-util = { version = "0.3", default-features = false }
itertools = { version = "0.12.1" }
lazy_static = { version = "1.4" }
serde_json = { version = "1.0", features = ["raw_value"] }
owned_ttf_parser = { version = "0.15.1" }
convert_case = { version = "0.6.0" }
rustybuzz = { version = "0.5.1" }
bincode = { version = "2.0.0-rc.1" }
byte-unit = { version = "5.1.4", features = ["serde"] }
bytes = { version = "1.1.0" }
matches = { version = "0.1" }
console_error_panic_hook = { version = "0.1.6" }
reqwest = { version = "0.11.27", default-features = false, features = [
"rustls-tls",

93
app/.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,93 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Dashboard",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "dev"]
},
{
"type": "node",
"request": "launch",
"name": "GUI",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "dev"]
},
{
"type": "node",
"request": "launch",
"name": "GUI (Storybook)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "story:dev"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Build)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "build:cloud"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (E2E UI)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:e2e:debug"]
},
{
"type": "node",
"request": "launch",
"name": "GUI (E2E UI)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:e2e", "--", "--ui"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (All tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test"]
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (E2E tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:e2e"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "Dashboard (Unit tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-dashboard", "test:unit"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI (All tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test"]
},
{
"type": "node",
"request": "launch",
"name": "GUI (E2E tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:e2e"],
"outputCapture": "std"
},
{
"type": "node",
"request": "launch",
"name": "GUI (Unit tests)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "--workspace=enso-gui2", "test:unit", "--", "run"],
"outputCapture": "std"
}
]
}

View File

@ -2,6 +2,8 @@
/** @file Various actions, locators, and constants used in end-to-end tests. */
import * as test from '@playwright/test'
import * as text from 'enso-common/src/text'
import DrivePageActions from './actions/DrivePageActions'
import LoginPageActions from './actions/LoginPageActions'
import * as apiModule from './api'
@ -18,6 +20,7 @@ export const INVALID_PASSWORD = 'password'
export const VALID_PASSWORD = 'Password0!'
/** An example valid email address. */
export const VALID_EMAIL = 'email@example.com'
export const TEXT = text.TEXTS.english
// ================
// === Locators ===
@ -499,17 +502,21 @@ export namespace settings {
/** Find a "current password" input in the "user account" settings section. */
export function locateCurrentPasswordInput(page: test.Page) {
return locate(page).getByLabel('Current password')
return locate(page).getByRole('group', { name: 'Current password' }).getByRole('textbox')
}
/** Find a "new password" input in the "user account" settings section. */
export function locateNewPasswordInput(page: test.Page) {
return locate(page).getByLabel('New password', { exact: true })
return locate(page)
.getByRole('group', { name: /^New password/, exact: true })
.getByRole('textbox')
}
/** Find a "confirm new password" input in the "user account" settings section. */
export function locateConfirmNewPasswordInput(page: test.Page) {
return locate(page).getByLabel('Confirm new password')
return locate(page)
.getByRole('group', { name: /^Confirm new password/, exact: true })
.getByRole('textbox')
}
/** Find a "change" button. */
@ -706,34 +713,6 @@ export async function expectNotOnScreen(locator: test.Locator) {
})
}
// =======================
// === Mouse utilities ===
// =======================
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
/** Click an asset row. The center must not be clicked as that is the button for adding a label. */
export async function clickAssetRow(assetRow: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await assetRow.click({ position: ASSET_ROW_SAFE_POSITION })
}
/** Drag an asset row. The center must not be clicked as that is the button for adding a label. */
export async function dragAssetRowToAssetRow(from: test.Locator, to: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await from.dragTo(to, {
sourcePosition: ASSET_ROW_SAFE_POSITION,
targetPosition: ASSET_ROW_SAFE_POSITION,
})
}
/** Drag an asset row. The center must not be clicked as that is the button for adding a label. */
export async function dragAssetRow(from: test.Locator, to: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await from.dragTo(to, { sourcePosition: ASSET_ROW_SAFE_POSITION })
}
// ==========================
// === Keyboard utilities ===
// ==========================
@ -781,7 +760,6 @@ export async function login(
first = true,
) {
await test.test.step('Login', async () => {
await page.goto('/')
await locateEmailInput(page).fill(email)
await locatePasswordInput(page).fill(password)
await locateLoginButton(page).click()
@ -890,6 +868,7 @@ export function mockAll({ page, setupAPI }: MockParams) {
return new LoginPageActions(page).step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await page.goto('/')
})
}
@ -905,6 +884,7 @@ export function mockAllAndLogin({ page, setupAPI }: MockParams) {
.step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await page.goto('/')
})
.do((thePage) => login({ page: thePage, setupAPI }))
}
@ -921,6 +901,7 @@ export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams
return await test.test.step('Execute all mocks and login', async () => {
const api = await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await page.goto('/')
await login({ page, setupAPI })
return api
})

View File

@ -3,7 +3,7 @@ import * as test from '@playwright/test'
import type * as inputBindings from '#/utilities/inputBindings'
import * as actions from '../actions'
import { modModifier, TEXT } from '../actions'
// ====================
// === PageCallback ===
@ -149,10 +149,30 @@ export default class BaseActions implements Promise<void> {
withModPressed<R extends BaseActions>(callback: (actions: this) => R) {
return callback(
this.step('Press "Mod"', async (page) => {
await page.keyboard.down(await actions.modModifier(page))
await page.keyboard.down(await modModifier(page))
}),
).step('Release "Mod"', async (page) => {
await page.keyboard.up(await actions.modModifier(page))
await page.keyboard.up(await modModifier(page))
})
}
/** Expect an input to have an error (or no error if the expected value is `null`).
* If the expected value is `undefined`, the assertion is skipped. */
expectInputError(testId: string, description: string, expected: string | null | undefined) {
if (expected === undefined) {
return this
} else if (expected != null) {
return this.step(`Expect ${description} error to be '${expected}'`, async (page) => {
await test
.expect(page.getByTestId(testId).getByLabel(TEXT.fieldErrorLabel))
.toHaveText(expected)
})
} else {
return this.step(`Expect no ${description} error`, async (page) => {
await test
.expect(page.getByTestId(testId).getByLabel(TEXT.fieldErrorLabel))
.not.toBeVisible()
})
}
}
}

View File

@ -1,7 +1,18 @@
/** @file Actions for the "drive" page. */
import * as test from 'playwright/test'
import * as actions from '../actions'
import {
locateAssetPanel,
locateAssetsTable,
locateContextMenus,
locateCreateButton,
locateDriveView,
locateNewSecretIcon,
locateNonAssetRows,
locateSecretNameInput,
locateSecretValueInput,
TEXT,
} from '../actions'
import type * as baseActions from './BaseActions'
import * as contextMenuActions from './contextMenuActions'
import EditorPageActions from './EditorPageActions'
@ -17,6 +28,15 @@ import StartModalActions from './StartModalActions'
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
// =======================
// === locateAssetRows ===
// =======================
/** Find all assets table rows (if any). */
function locateAssetRows(page: test.Page) {
return locateAssetsTable(page).getByTestId('asset-row')
}
// ========================
// === DrivePageActions ===
// ========================
@ -41,25 +61,37 @@ export default class DrivePageActions extends PageActions {
/** Switch to the "cloud" category. */
cloud() {
return self.step('Go to "Cloud" category', (page) =>
page.getByRole('button', { name: 'Cloud' }).getByText('Cloud').click(),
page
.getByRole('button', { name: TEXT.cloudCategory })
.getByText(TEXT.cloudCategory)
.click(),
)
},
/** Switch to the "local" category. */
local() {
return self.step('Go to "Local" category', (page) =>
page.getByRole('button', { name: 'Local' }).getByText('Local').click(),
page
.getByRole('button', { name: TEXT.localCategory })
.getByText(TEXT.localCategory)
.click(),
)
},
/** Switch to the "recent" category. */
recent() {
return self.step('Go to "Recent" category', (page) =>
page.getByRole('button', { name: 'Recent' }).getByText('Recent').click(),
page
.getByRole('button', { name: TEXT.recentCategory })
.getByText(TEXT.recentCategory)
.click(),
)
},
/** Switch to the "trash" category. */
trash() {
return self.step('Go to "Trash" category', (page) =>
page.getByRole('button', { name: 'Trash' }).getByText('Trash').click(),
page
.getByRole('button', { name: TEXT.trashCategory })
.getByText(TEXT.trashCategory)
.click(),
)
},
}
@ -73,44 +105,37 @@ export default class DrivePageActions extends PageActions {
/** Click the column heading for the "name" column to change its sort order. */
clickNameColumnHeading() {
return self.step('Click "name" column heading', (page) =>
page.getByLabel('Sort by name').or(page.getByLabel('Stop sorting by name')).click(),
page.getByLabel(TEXT.sortByName).or(page.getByLabel(TEXT.stopSortingByName)).click(),
)
},
/** Click the column heading for the "modified" column to change its sort order. */
clickModifiedColumnHeading() {
return self.step('Click "modified" column heading', (page) =>
page
.getByLabel('Sort by modification date')
.or(page.getByLabel('Stop sorting by modification date'))
.getByLabel(TEXT.sortByModificationDate)
.or(page.getByLabel(TEXT.stopSortingByModificationDate))
.click(),
)
},
/** Click to select a specific row. */
clickRow(index: number) {
return self.step(`Click drive table row #${index}`, (page) =>
actions
.locateAssetRows(page)
.nth(index)
.click({ position: actions.ASSET_ROW_SAFE_POSITION }),
locateAssetRows(page).nth(index).click({ position: ASSET_ROW_SAFE_POSITION }),
)
},
/** Right click a specific row to bring up its context menu, or the context menu for multiple
* assets when right clicking on a selected asset when multiple assets are selected. */
rightClickRow(index: number) {
return self.step(`Right click drive table row #${index}`, (page) =>
actions
.locateAssetRows(page)
locateAssetRows(page)
.nth(index)
.click({ button: 'right', position: actions.ASSET_ROW_SAFE_POSITION }),
.click({ button: 'right', position: ASSET_ROW_SAFE_POSITION }),
)
},
/** Double click a row. */
doubleClickRow(index: number) {
return self.step(`Double dlick drive table row #${index}`, (page) =>
actions
.locateAssetRows(page)
.nth(index)
.dblclick({ position: actions.ASSET_ROW_SAFE_POSITION }),
locateAssetRows(page).nth(index).dblclick({ position: ASSET_ROW_SAFE_POSITION }),
)
},
/** Interact with the set of all rows in the Drive table. */
@ -118,13 +143,13 @@ export default class DrivePageActions extends PageActions {
callback: (assetRows: test.Locator, nonAssetRows: test.Locator) => Promise<void> | void,
) {
return self.step('Interact with drive table rows', async (page) => {
await callback(actions.locateAssetRows(page), actions.locateNonAssetRows(page))
await callback(locateAssetRows(page), locateNonAssetRows(page))
})
},
/** Drag a row onto another row. */
dragRowToRow(from: number, to: number) {
return self.step(`Drag drive table row #${from} to row #${to}`, async (page) => {
const rows = actions.locateAssetRows(page)
const rows = locateAssetRows(page)
await rows.nth(from).dragTo(rows.nth(to), {
sourcePosition: ASSET_ROW_SAFE_POSITION,
targetPosition: ASSET_ROW_SAFE_POSITION,
@ -134,8 +159,7 @@ export default class DrivePageActions extends PageActions {
/** Drag a row onto another row. */
dragRow(from: number, to: test.Locator, force?: boolean) {
return self.step(`Drag drive table row #${from} to custom locator`, (page) =>
actions
.locateAssetRows(page)
locateAssetRows(page)
.nth(from)
.dragTo(to, {
sourcePosition: ASSET_ROW_SAFE_POSITION,
@ -147,8 +171,8 @@ export default class DrivePageActions extends PageActions {
* placeholder row displayed when there are no assets to show. */
expectPlaceholderRow() {
return self.step('Expect placeholder row', async (page) => {
await test.expect(actions.locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = actions.locateNonAssetRows(page)
await test.expect(locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = locateNonAssetRows(page)
await test.expect(nonAssetRows).toHaveCount(1)
await test.expect(nonAssetRows).toHaveText(/You have no files/)
})
@ -157,8 +181,8 @@ export default class DrivePageActions extends PageActions {
* placeholder row displayed when there are no assets in Trash. */
expectTrashPlaceholderRow() {
return self.step('Expect trash placeholder row', async (page) => {
await test.expect(actions.locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = actions.locateNonAssetRows(page)
await test.expect(locateAssetRows(page)).toHaveCount(0)
const nonAssetRows = locateNonAssetRows(page)
await test.expect(nonAssetRows).toHaveCount(1)
await test.expect(nonAssetRows).toHaveText(/Your trash is empty/)
})
@ -168,38 +192,38 @@ export default class DrivePageActions extends PageActions {
return {
/** Toggle visibility for the "modified" column. */
modified() {
return self.step('Expect trash placeholder row', (page) =>
page.getByAltText('Modified').click(),
return self.step('Toggle "modified" column', (page) =>
page.getByAltText(TEXT.modifiedColumnName).click(),
)
},
/** Toggle visibility for the "shared with" column. */
sharedWith() {
return self.step('Expect trash placeholder row', (page) =>
page.getByAltText('Shared With').click(),
return self.step('Toggle "shared with" column', (page) =>
page.getByAltText(TEXT.sharedWithColumnName).click(),
)
},
/** Toggle visibility for the "labels" column. */
labels() {
return self.step('Expect trash placeholder row', (page) =>
page.getByAltText('Labels').click(),
return self.step('Toggle "labels" column', (page) =>
page.getByAltText(TEXT.labelsColumnName).click(),
)
},
/** Toggle visibility for the "accessed by projects" column. */
accessedByProjects() {
return self.step('Expect trash placeholder row', (page) =>
page.getByAltText('Accessed By Projects').click(),
return self.step('Toggle "accessed by projects" column', (page) =>
page.getByAltText(TEXT.accessedByProjectsColumnName).click(),
)
},
/** Toggle visibility for the "accessed data" column. */
accessedData() {
return self.step('Expect trash placeholder row', (page) =>
page.getByAltText('Accessed Data').click(),
return self.step('Toggle "accessed data" column', (page) =>
page.getByAltText(TEXT.accessedDataColumnName).click(),
)
},
/** Toggle visibility for the "docs" column. */
docs() {
return self.step('Expect trash placeholder row', (page) =>
page.getByAltText('Docs').click(),
return self.step('Toggle "docs" column', (page) =>
page.getByAltText(TEXT.docsColumnName).click(),
)
},
}
@ -210,26 +234,26 @@ export default class DrivePageActions extends PageActions {
/** Open the "start" modal. */
openStartModal() {
return this.step('Open "start" modal', (page) =>
page.getByText('Start with a template').click(),
page.getByText(TEXT.startWithATemplate).click(),
).into(StartModalActions)
}
/** Create a new empty project. */
newEmptyProject() {
return this.step('Create empty project', (page) =>
page.getByText('New Empty Project').click(),
page.getByText(TEXT.newEmptyProject).click(),
).into(EditorPageActions)
}
/** Interact with the drive view (the main container of this page). */
withDriveView(callback: baseActions.LocatorCallback) {
return this.step('Interact with drive view', (page) => callback(actions.locateDriveView(page)))
return this.step('Interact with drive view', (page) => callback(locateDriveView(page)))
}
/** Create a new folder using the icon in the Drive Bar. */
createFolder() {
return this.step('Create folder', (page) =>
page.getByRole('button', { name: 'New Folder', exact: true }).click(),
page.getByRole('button', { name: TEXT.newFolder, exact: true }).click(),
)
}
@ -241,7 +265,7 @@ export default class DrivePageActions extends PageActions {
) {
return this.step(`Upload file '${name}'`, async (page) => {
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('button', { name: 'Import' }).click()
await page.getByRole('button', { name: TEXT.uploadFiles }).click()
const fileChooser = await fileChooserPromise
await fileChooser.setFiles([{ name, buffer: Buffer.from(contents), mimeType }])
})
@ -250,10 +274,10 @@ export default class DrivePageActions extends PageActions {
/** Create a new secret using the icon in the Drive Bar. */
createSecret(name: string, value: string) {
return this.step(`Create secret '${name}' = '${value}'`, async (page) => {
await actions.locateNewSecretIcon(page).click()
await actions.locateSecretNameInput(page).fill(name)
await actions.locateSecretValueInput(page).fill(value)
await actions.locateCreateButton(page).click()
await locateNewSecretIcon(page).click()
await locateSecretNameInput(page).fill(name)
await locateSecretValueInput(page).fill(value)
await locateCreateButton(page).click()
})
}
@ -267,28 +291,28 @@ export default class DrivePageActions extends PageActions {
/** Interact with the container element of the assets table. */
withAssetsTable(callback: baseActions.LocatorCallback) {
return this.step('Interact with drive table', async (page) => {
await callback(actions.locateAssetsTable(page))
await callback(locateAssetsTable(page))
})
}
/** Interact with the Asset Panel. */
withAssetPanel(callback: baseActions.LocatorCallback) {
return this.step('Interact with asset panel', async (page) => {
await callback(actions.locateAssetPanel(page))
await callback(locateAssetPanel(page))
})
}
/** Open the Data Link creation modal by clicking on the Data Link icon. */
openDataLinkModal() {
return this.step('Open "new data link" modal', (page) =>
page.getByRole('button', { name: 'New Datalink' }).click(),
page.getByRole('button', { name: TEXT.newDatalink }).click(),
).into(NewDataLinkModalActions)
}
/** Interact with the context menus (the context menus MUST be visible). */
withContextMenus(callback: baseActions.LocatorCallback) {
return this.step('Interact with context menus', async (page) => {
await callback(actions.locateContextMenus(page))
await callback(locateContextMenus(page))
})
}
}

View File

@ -0,0 +1,54 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import { TEXT, VALID_EMAIL } from '../actions'
import BaseActions, { type LocatorCallback } from './BaseActions'
import LoginPageActions from './LoginPageActions'
// =================================
// === ForgotPasswordPageActions ===
// =================================
/** Available actions for the login page. */
export default class ForgotPasswordPageActions extends BaseActions {
/** Actions for navigating to another page. */
get goToPage() {
return {
login: (): LoginPageActions =>
this.step("Go to 'login' page", async (page) =>
page.getByRole('link', { name: TEXT.goBackToLogin, exact: true }).click(),
).into(LoginPageActions),
}
}
/** Perform a successful login. */
forgotPassword(email = VALID_EMAIL) {
return this.step('Forgot password', () => this.forgotPasswordInternal(email)).into(
LoginPageActions,
)
}
/** Fill the email input. */
fillEmail(email: string) {
return this.step(`Fill email with '${email}'`, (page) =>
page.getByPlaceholder(TEXT.emailPlaceholder).fill(email),
)
}
/** Interact with the email input. */
withEmailInput(callback: LocatorCallback) {
return this.step('Interact with email input', async (page) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder))
})
}
/** Internal login logic shared between all public methods. */
private async forgotPasswordInternal(email: string) {
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
await this.page
.getByRole('button', { name: TEXT.login, exact: true })
.getByText(TEXT.login)
.click()
await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
}

View File

@ -1,10 +1,12 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import * as actions from '../actions'
import BaseActions from './BaseActions'
import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '../actions'
import BaseActions, { type LocatorCallback } from './BaseActions'
import DrivePageActions from './DrivePageActions'
import SetUsernamePageActions from './SetUsernamePageActions'
import ForgotPasswordPageActions from './ForgotPasswordPageActions'
import RegisterPageActions from './RegisterPageActions'
import SetupPageActions from './SetupPageActions'
// ========================
// === LoginPageActions ===
@ -12,29 +14,85 @@ import SetUsernamePageActions from './SetUsernamePageActions'
/** Available actions for the login page. */
export default class LoginPageActions extends BaseActions {
/** Actions for navigating to another page. */
get goToPage() {
return {
register: (): RegisterPageActions =>
this.step("Go to 'register' page", async (page) =>
page.getByRole('link', { name: TEXT.dontHaveAnAccount, exact: true }).click(),
).into(RegisterPageActions),
forgotPassword: (): ForgotPasswordPageActions =>
this.step("Go to 'forgot password' page", async (page) =>
page.getByRole('link', { name: TEXT.forgotYourPassword, exact: true }).click(),
).into(ForgotPasswordPageActions),
}
}
/** Perform a successful login. */
login(email = 'email@example.com', password = actions.VALID_PASSWORD) {
login(email = VALID_EMAIL, password = VALID_PASSWORD) {
return this.step('Login', () => this.loginInternal(email, password)).into(DrivePageActions)
}
/** Perform a login as a new user (a user that does not yet have a username). */
loginAsNewUser(email = 'email@example.com', password = actions.VALID_PASSWORD) {
loginAsNewUser(email = VALID_EMAIL, password = VALID_PASSWORD) {
return this.step('Login (as new user)', () => this.loginInternal(email, password)).into(
SetUsernamePageActions,
SetupPageActions,
)
}
/** Perform a failing login. */
loginThatShouldFail(email = 'email@example.com', password = actions.VALID_PASSWORD) {
return this.step('Login (should fail)', () => this.loginInternal(email, password))
loginThatShouldFail(
email = VALID_EMAIL,
password = VALID_PASSWORD,
{
assert = {},
}: {
assert?: {
emailError?: string | null
passwordError?: string | null
formError?: string | null
}
} = {},
) {
const { emailError, passwordError, formError } = assert
const next = this.step('Login (should fail)', () => this.loginInternal(email, password))
.expectInputError('email-input', 'email', emailError)
.expectInputError('password-input', 'password', passwordError)
if (formError === undefined) {
return next
} else if (formError != null) {
return next.step(`Expect form error to be '${formError}'`, async (page) => {
await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError)
})
} else {
return next.step('Expect no form error', async (page) => {
await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible()
})
}
}
/** Fill the email input. */
fillEmail(email: string) {
return this.step(`Fill email with '${email}'`, (page) =>
page.getByPlaceholder(TEXT.emailPlaceholder).fill(email),
)
}
/** Interact with the email input. */
withEmailInput(callback: LocatorCallback) {
return this.step('Interact with email input', async (page) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder))
})
}
/** Internal login logic shared between all public methods. */
private async loginInternal(email: string, password: string) {
await this.page.goto('/')
await this.page.getByPlaceholder('Enter your email').fill(email)
await this.page.getByPlaceholder('Enter your password').fill(password)
await this.page.getByRole('button', { name: 'Login', exact: true }).getByText('Login').click()
await test.expect(this.page.getByText('Logging in to Enso...')).not.toBeVisible()
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
await this.page
.getByRole('button', { name: TEXT.login, exact: true })
.getByText(TEXT.login)
.click()
await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
}

View File

@ -0,0 +1,92 @@
/** @file Available actions for the login page. */
import * as test from '@playwright/test'
import { TEXT, VALID_EMAIL, VALID_PASSWORD } from '../actions'
import BaseActions, { type LocatorCallback } from './BaseActions'
import LoginPageActions from './LoginPageActions'
// ========================
// === LoginPageActions ===
// ========================
/** Available actions for the login page. */
export default class RegisterPageActions extends BaseActions {
/** Actions for navigating to another page. */
get goToPage() {
return {
login: (): LoginPageActions =>
this.step("Go to 'login' page", async (page) =>
page.getByRole('link', { name: TEXT.alreadyHaveAnAccount, exact: true }).click(),
).into(LoginPageActions),
}
}
/** Perform a successful login. */
register(email = VALID_EMAIL, password = VALID_PASSWORD, confirmPassword = password) {
return this.step('Reegister', () =>
this.registerInternal(email, password, confirmPassword),
).into(LoginPageActions)
}
/** Perform a failing login. */
registerThatShouldFail(
email = VALID_EMAIL,
password = VALID_PASSWORD,
confirmPassword = password,
{
assert = {},
}: {
assert?: {
emailError?: string | null
passwordError?: string | null
confirmPasswordError?: string | null
formError?: string | null
}
} = {},
) {
const { emailError, passwordError, confirmPasswordError, formError } = assert
const next = this.step('Register (should fail)', () =>
this.registerInternal(email, password, confirmPassword),
)
.expectInputError('email-input', 'email', emailError)
.expectInputError('password-input', 'password', passwordError)
.expectInputError('confirm-password-input', 'confirmPassword', confirmPasswordError)
if (formError === undefined) {
return next
} else if (formError != null) {
return next.step(`Expect form error to be '${formError}'`, async (page) => {
await test.expect(page.getByTestId('form-submit-error')).toHaveText(formError)
})
} else {
return next.step('Expect no form error', async (page) => {
await test.expect(page.getByTestId('form-submit-error')).not.toBeVisible()
})
}
}
/** Fill the email input. */
fillEmail(email: string) {
return this.step(`Fill email with '${email}'`, (page) =>
page.getByPlaceholder(TEXT.emailPlaceholder).fill(email),
)
}
/** Interact with the email input. */
withEmailInput(callback: LocatorCallback) {
return this.step('Interact with email input', async (page) => {
await callback(page.getByPlaceholder(TEXT.emailPlaceholder))
})
}
/** Internal login logic shared between all public methods. */
private async registerInternal(email: string, password: string, confirmPassword: string) {
await this.page.getByPlaceholder(TEXT.emailPlaceholder).fill(email)
await this.page.getByPlaceholder(TEXT.passwordPlaceholder).fill(password)
await this.page.getByPlaceholder(TEXT.confirmPasswordPlaceholder).fill(confirmPassword)
await this.page
.getByRole('button', { name: TEXT.register, exact: true })
.getByText(TEXT.register)
.click()
await test.expect(this.page.getByText(TEXT.loadingAppMessage)).not.toBeVisible()
}
}

View File

@ -1,4 +1,4 @@
/** @file Actions for the "set username" page. */
/** @file Actions for the "setup" page. */
import * as actions from '../actions'
import BaseActions from './BaseActions'
import DrivePageActions from './DrivePageActions'
@ -8,7 +8,7 @@ import DrivePageActions from './DrivePageActions'
// ==============================
/** Actions for the "set username" page. */
export default class SetUsernamePageActions extends BaseActions {
export default class SetupPageActions extends BaseActions {
/** Set the userame for a new user that does not yet have a username. */
setUsername(username: string) {
return this.step(`Set username to '${username}'`, async (page) => {

View File

@ -12,6 +12,8 @@ import * as uniqueString from '#/utilities/uniqueString'
import * as actions from './actions'
import LATEST_GITHUB_RELEASES from './latestGithubReleases.json' with { type: 'json' }
// =================
// === Constants ===
// =================
@ -357,6 +359,67 @@ async function mockApiInternal({ page, setupAPI }: MockParams) {
await page.route('https://www.googletagmanager.com/gtag/js*', (route) =>
route.fulfill({ contentType: 'text/javascript', body: 'export {};' }),
)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (process.env.MOCK_ALL_URLS === 'true') {
await page.route('https://fonts.googleapis.com/css2*', async (route) => {
await route.fulfill({ contentType: 'text/css', body: '' })
})
await page.route('https://ensoanalytics.com/eula.json', async (route) => {
await route.fulfill({
json: {
path: '/eula.md',
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
size: 9472,
modified: '2024-06-26T10:44:04.939Z',
hash: '1c8a655202e59f0efebf5a83a703662527aa97247052964f959a8488382604b8',
},
})
})
await page.route(
'https://api.github.com/repos/enso-org/enso/releases/latest',
async (route) => {
await route.fulfill({ json: LATEST_GITHUB_RELEASES })
},
)
await page.route('https://github.com/enso-org/enso/releases/download/**', async (route) => {
await route.fulfill({
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
status: 302,
headers: { location: 'https://objects.githubusercontent.com/foo/bar' },
})
})
await page.route('https://objects.githubusercontent.com/**', async (route) => {
await route.fulfill({
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
status: 200,
headers: {
/* eslint-disable @typescript-eslint/naming-convention */
'content-type': 'application/octet-stream',
'last-modified': 'Wed, 24 Jul 2024 17:22:47 GMT',
etag: '"0x8DCAC053D058EA5"',
server: 'Windows-Azure-Blob/1.0 Microsoft-HTTPAPI/2.0',
'x-ms-request-id': '20ab2b4e-c01e-0068-7dfa-dd87c5000000',
'x-ms-version': '2020-10-02',
'x-ms-creation-time': 'Wed, 24 Jul 2024 17:22:47 GMT',
'x-ms-lease-status': 'unlocked',
'x-ms-lease-state': 'available',
'x-ms-blob-type': 'BlockBlob',
'content-disposition': 'attachment; filename=enso-linux-x86_64-2024.3.1-rc3.AppImage',
'x-ms-server-encrypted': 'true',
via: '1.1 varnish, 1.1 varnish',
'accept-ranges': 'bytes',
age: '1217',
date: 'Mon, 29 Jul 2024 09:40:09 GMT',
'x-served-by': 'cache-iad-kcgs7200163-IAD, cache-bne12520-BNE',
'x-cache': 'HIT, HIT',
'x-cache-hits': '48, 0',
'x-timer': 'S1722246008.269342,VS0,VE895',
'content-length': '1030383958',
/* eslint-enable @typescript-eslint/naming-convention */
},
})
})
}
const isActuallyOnline = await page.evaluate(() => navigator.onLine)
if (!isActuallyOnline) {
await page.route('https://fonts.googleapis.com/*', (route) => route.abort())

View File

@ -0,0 +1,27 @@
/** @file Test that emails are preserved when navigating between auth pages. */
import * as test from '@playwright/test'
import { VALID_EMAIL, mockAll } from './actions'
test.test('preserve email input when changing pages', ({ page }) =>
mockAll({ page })
.fillEmail(VALID_EMAIL)
.goToPage.register()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(VALID_EMAIL)
})
.fillEmail(`2${VALID_EMAIL}`)
.goToPage.login()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`2${VALID_EMAIL}`)
})
.fillEmail(`3${VALID_EMAIL}`)
.goToPage.forgotPassword()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`3${VALID_EMAIL}`)
})
.fillEmail(`4${VALID_EMAIL}`)
.goToPage.login()
.withEmailInput(async (emailInput) => {
await test.expect(emailInput).toHaveValue(`4${VALID_EMAIL}`)
}),
)

View File

@ -5,6 +5,15 @@ import * as backend from '#/services/Backend'
import * as actions from './actions'
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
export const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
/** Click an asset row. The center must not be clicked as that is the button for adding a label. */
export async function clickAssetRow(assetRow: test.Locator) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
await assetRow.click({ position: ASSET_ROW_SAFE_POSITION })
}
test.test('drag labels onto single row', async ({ page }) => {
const label = 'aaaa'
await actions.mockAllAndLogin({
@ -51,8 +60,9 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
const labelEl = actions.locateLabelsPanelLabels(page, label)
await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0))
await actions.clickAssetRow(assetRows.nth(2))
await test.expect(assetRows).toHaveCount(4)
await clickAssetRow(assetRows.nth(0))
await clickAssetRow(assetRows.nth(2))
await test.expect(labelEl).toBeVisible()
await labelEl.dragTo(assetRows.nth(2))
await page.keyboard.up(await actions.modModifier(page))

File diff suppressed because one or more lines are too long

View File

@ -1,35 +1,34 @@
/** @file Test the login flow. */
import * as test from '@playwright/test'
import * as actions from './actions'
test.test.beforeEach(({ page }) => actions.mockAll({ page }))
import {
INVALID_PASSWORD,
mockAll,
passTermsAndConditionsDialog,
TEXT,
VALID_EMAIL,
VALID_PASSWORD,
} from './actions'
// =============
// === Tests ===
// =============
test.test('login screen', async ({ page }) => {
await page.goto('/')
// Invalid email
await actions.locateEmailInput(page).fill('invalid email')
test
.expect(
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
'form should reject invalid email',
)
.toBe(false)
await actions.locateLoginButton(page).click()
// Invalid password
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
await actions.locatePasswordInput(page).fill(actions.INVALID_PASSWORD)
test
.expect(
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
'form should accept invalid password',
)
.toBe(true)
await actions.locateLoginButton(page).click()
})
test.test('login screen', ({ page }) =>
mockAll({ page })
.loginThatShouldFail('invalid email', VALID_PASSWORD, {
assert: {
emailError: TEXT.invalidEmailValidationError,
passwordError: null,
formError: null,
},
})
// Technically it should not be allowed, but
.login(VALID_EMAIL, INVALID_PASSWORD)
.do(async (thePage) => {
await passTermsAndConditionsDialog({ page: thePage })
})
.withDriveView(async (driveView) => {
await test.expect(driveView).toBeVisible()
}),
)

View File

@ -8,6 +8,10 @@ test.test('page switcher', ({ page }) =>
.mockAllAndLogin({ page })
// Create a new project so that the editor page can be switched to.
.newEmptyProject()
.do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
await test.expect(actions.locateEditor(thePage)).toBeVisible()
})
.goToPage.drive()
.do(async (thePage) => {
await test.expect(actions.locateDriveView(thePage)).toBeVisible()

View File

@ -0,0 +1,38 @@
/** @file Test the login flow. */
import * as test from '@playwright/test'
import { INVALID_PASSWORD, mockAll, TEXT, VALID_EMAIL, VALID_PASSWORD } from './actions'
// =============
// === Tests ===
// =============
test.test('sign up without organization id', ({ page }) =>
mockAll({ page })
.goToPage.register()
.registerThatShouldFail('invalid email', VALID_PASSWORD, VALID_PASSWORD, {
assert: {
emailError: TEXT.invalidEmailValidationError,
passwordError: null,
confirmPasswordError: null,
formError: null,
},
})
.registerThatShouldFail(VALID_EMAIL, INVALID_PASSWORD, INVALID_PASSWORD, {
assert: {
emailError: null,
passwordError: TEXT.passwordValidationError,
confirmPasswordError: null,
formError: null,
},
})
.registerThatShouldFail(VALID_EMAIL, VALID_PASSWORD, INVALID_PASSWORD, {
assert: {
emailError: null,
passwordError: null,
confirmPasswordError: TEXT.passwordMismatchError,
formError: null,
},
})
.register(),
)

View File

@ -25,45 +25,37 @@ test.test('change password form', async ({ page }) => {
test.expect(api.currentPassword()).toBe(actions.VALID_PASSWORD)
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await test
.expect(localActions.locateChangeButton(page), 'incomplete form should be rejected')
.toBeDisabled()
await test.test.step('Invalid new password', async () => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
test
.expect(
await localActions
.locateNewPasswordInput(page)
.evaluate((element: HTMLInputElement) => element.validity.valid),
'invalid new password should be rejected',
)
.toBe(false)
await localActions.locateChangeButton(page).click()
await test
.expect(localActions.locateChangeButton(page), 'invalid new password should be rejected')
.toBeDisabled()
.expect(
localActions
.locate(page)
.getByRole('group', { name: /^New password/, exact: true })
.locator('.text-danger')
.last(),
)
.toHaveText(actions.TEXT.passwordValidationError)
})
await test.test.step('Invalid new password confirmation', async () => {
await localActions.locateCurrentPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
await localActions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD + 'a')
test
.expect(
await localActions
.locateConfirmNewPasswordInput(page)
.evaluate((element: HTMLInputElement) => element.validity.valid),
'invalid new password confirmation should be rejected',
)
.toBe(false)
await localActions.locateChangeButton(page).click()
await test
.expect(
localActions.locateChangeButton(page),
'invalid new password confirmation should be rejected',
localActions
.locate(page)
.getByRole('group', { name: /^Confirm new password/, exact: true })
.locator('.text-danger')
.last(),
)
.toBeDisabled()
.toHaveText(actions.TEXT.passwordMismatchError)
})
await test.test.step('Successful password change', async () => {

View File

@ -501,9 +501,11 @@ function AppRouter(props: AppRouterProps) {
<errorBoundary.ErrorBoundary>
<VersionChecker />
{routes}
<suspense.Suspense>
<devtools.EnsoDevtools />
</suspense.Suspense>
{detect.IS_DEV_MODE && (
<suspense.Suspense>
<devtools.EnsoDevtools />
</suspense.Suspense>
)}
</errorBoundary.ErrorBoundary>
</DriveProvider>
</InputBindingsProvider>

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M7.976 0C3.566 0 0 3.592 0 8.035c0 3.552 2.285 6.559 5.454 7.623 .396.08 .541-.173 .541-.386 0-.186-.013-.825-.013-1.49-2.219 .479-2.681-.958-2.681-.958-.357-.931-.885-1.171-.885-1.171-.726-.492.053-.492.053-.492 .806.053 1.228 .825 1.228 .825 .713 1.224 1.862 .878 2.324 .665.066-.519 .277-.878 .502-1.078-1.77-.186-3.632-.878-3.632-3.965 0-.878 .317-1.596 .819-2.155-.079-.2-.357-1.024.079-2.129 0 0 .673-.213 2.192 .825a7.669 7.669 0 0 1 1.994-.266c.673 0 1.36.093 1.994 .266 1.519-1.038 2.192-.825 2.192-.825 .436 1.104.158 1.929.079 2.129 .515 .559 .819 1.277 .819 2.155 0 3.087-1.862 3.765-3.645 3.965 .291 .253 .541 .732 .541 1.49 0 1.078-.013 1.942-.013 2.208 0 .213.145 .466 .541 .386 3.169-1.064 5.454-4.071 5.454-7.623C15.952 3.592 12.374 0 7.976 0z"
fill="#171515" />
</svg>

After

Width:  |  Height:  |  Size: 936 B

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.66636 15.8245C10.9925 15.5421 12.2172 14.9291 13.2316 14.0523L10.6177 12.0013C10.127 12.3224 9.57781 12.5527 8.99597 12.6766C7.92483 12.9048 6.80784 12.7583 5.83175 12.2616C4.85566 11.765 4.07963 10.9484 3.6334 9.94824C3.57221 9.81108 3.51772 9.6716 3.46999 9.5303L0.842987 11.5745C1.60697 13.1043 2.84283 14.352 4.37227 15.1302C6.00537 15.9611 7.87422 16.2062 9.66636 15.8245Z" fill="#34A853"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.46999 9.53031L0.842985 11.5745C0.7912 11.4708 0.741583 11.3658 0.69419 11.2596C-0.0523962 9.58629 -0.201934 7.70738 0.270576 5.93702C0.410984 5.41095 0.603788 4.90395 0.844315 4.42282L3.46958 6.4709C3.43669 6.56836 3.40687 6.6671 3.38021 6.76698C3.13653 7.67999 3.16965 8.64131 3.46999 9.53031Z" fill="#FBBC05"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.29011 1.53339C2.24911 2.2916 1.41321 3.28482 0.844299 4.42282L3.46957 6.4709C3.7851 5.53597 4.38327 4.71888 5.18495 4.13498C6.0702 3.49022 7.14995 3.16889 8.24369 3.22471C9.33743 3.28053 10.3789 3.71011 11.1939 4.44165L13.3437 2.04647C11.9801 0.822539 10.2377 0.103794 8.40773 0.0104031C6.57778 -0.0829874 4.77123 0.454637 3.29011 1.53339Z" fill="#EA4335"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 6.50002H16V8.00002C16 9.83234 15.371 11.6091 14.2182 13.0333C13.9184 13.4036 13.588 13.7443 13.2316 14.0523L10.6177 12.0013C11.0304 11.7313 11.4018 11.3972 11.7165 11.0084C12.0481 10.5988 12.3071 10.1404 12.4867 9.653C12.4932 9.63538 12.4996 9.61772 12.5059 9.60002H8V8.00002V6.50002Z" fill="#4285F4"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -100,7 +100,6 @@ export const BUTTON_STYLES = twv.tv({
},
loading: { true: { base: 'cursor-wait' } },
fullWidth: { true: 'w-full' },
fullWidthText: { true: { text: 'w-full' } },
size: {
custom: { base: '', extraClickZone: '', icon: 'h-full' },
hero: { base: 'px-8 py-4 text-lg font-bold', content: 'gap-[0.75em]' },
@ -112,7 +111,7 @@ export const BUTTON_STYLES = twv.tv({
className: 'flex px-[11px] py-[5.5px]',
}),
content: 'gap-2',
icon: 'mb-[-0.1cap] h-4.5 w-4.5',
icon: 'mb-[-0.1cap] h-4 w-4',
extraClickZone: 'after:inset-[-6px]',
},
medium: {
@ -218,7 +217,7 @@ export const BUTTON_STYLES = twv.tv({
extraClickZone: 'flex relative after:absolute after:cursor-pointer',
},
false: {
extraClickZone: '',
extraClickZone: 'after:inset-0',
},
xxsmall: {
extraClickZone: 'after:inset-[-2px]',
@ -295,7 +294,6 @@ export const Button = React.forwardRef(function Button(
iconPosition,
size,
fullWidth,
fullWidthText,
rounded,
tooltip,
tooltipPlacement,
@ -316,8 +314,7 @@ export const Button = React.forwardRef(function Button(
const Tag = isLink ? aria.Link : aria.Button
const goodDefaults = {
...(isLink ? { rel: 'noopener noreferrer', ref } : {}),
...(isLink ? {} : { type: 'button' as const }),
...(isLink ? { rel: 'noopener noreferrer' } : { type: 'button' as const }),
'data-testid': testId ?? (isLink ? 'link' : 'button'),
}
@ -389,7 +386,6 @@ export const Button = React.forwardRef(function Button(
isActive,
loading: isLoading,
fullWidth,
fullWidthText,
size,
rounded,
variant,
@ -443,7 +439,8 @@ export const Button = React.forwardRef(function Button(
<Tag
// @ts-expect-error ts errors are expected here because we are merging props with different types
{...aria.mergeProps<aria.ButtonProps>()(goodDefaults, ariaProps, focusChildProps, {
isDisabled: isDisabled,
ref,
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
onPressEnd: handlePress,

View File

@ -13,11 +13,23 @@ import * as aria from '#/components/aria'
import * as errorUtils from '#/utilities/error'
import type { Mutable } from 'enso-common/src/utilities/data/object'
import * as dialog from '../Dialog'
import * as components from './components'
import * as styles from './styles'
import type * as types from './types'
/**
* Maps the value to the event object.
*/
function mapValueOnEvent(value: unknown) {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return value
} else {
return { target: { value } }
}
}
/** Form component. It wraps a `form` and provides form context.
* It also handles form submission.
* Provides better error handling and form state management and better UX out of the box. */
@ -147,30 +159,13 @@ export const Form = React.forwardRef(function Form<
register: (name, options) => {
const registered = register(name, options)
/**
* Maps the value to the event object.
*/
function mapValueOnEvent(value: unknown) {
if (typeof value === 'object' && value != null && 'target' in value && 'type' in value) {
return value
} else {
return { target: { value } }
}
}
const onChange: types.UseFormRegisterReturn<Schema, TFieldValues>['onChange'] = (value) =>
registered.onChange(mapValueOnEvent(value))
const onBlur: types.UseFormRegisterReturn<Schema, TFieldValues>['onBlur'] = (value) =>
registered.onBlur(mapValueOnEvent(value))
const result: types.UseFormRegisterReturn<Schema, TFieldValues, typeof name> = {
...registered,
...(registered.disabled != null ? { isDisabled: registered.disabled } : {}),
...(registered.required != null ? { isRequired: registered.required } : {}),
isInvalid: !!formState.errors[name],
onChange,
onBlur,
isDisabled: registered.disabled ?? false,
isRequired: registered.required ?? false,
isInvalid: Boolean(formState.errors[name]),
onChange: (value) => registered.onChange(mapValueOnEvent(value)),
onBlur: (value) => registered.onBlur(mapValueOnEvent(value)),
}
return result
@ -226,26 +221,29 @@ export const Form = React.forwardRef(function Form<
</aria.FormValidationContext.Provider>
</form>
)
}) as unknown as (<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>(
props: React.RefAttributes<HTMLFormElement> &
types.FormProps<Schema, TFieldValues, TTransformedValues>,
// eslint-disable-next-line no-restricted-syntax
) => React.JSX.Element) & {
/* eslint-disable @typescript-eslint/naming-convention */
schema: typeof components.schema
useForm: typeof components.useForm
useField: typeof components.useField
Submit: typeof components.Submit
Reset: typeof components.Reset
Field: typeof components.Field
FormError: typeof components.FormError
useFormSchema: typeof components.useFormSchema
/* eslint-enable @typescript-eslint/naming-convention */
}
}) as unknown as Mutable<
Pick<
typeof components,
| 'FIELD_STYLES'
| 'Field'
| 'FormError'
| 'Reset'
| 'schema'
| 'Submit'
| 'useField'
| 'useForm'
| 'useFormSchema'
>
> &
(<
Schema extends components.TSchema,
TFieldValues extends components.FieldValues<Schema>,
TTransformedValues extends components.FieldValues<Schema> | undefined = undefined,
>(
props: React.RefAttributes<HTMLFormElement> &
types.FormProps<Schema, TFieldValues, TTransformedValues>,
// eslint-disable-next-line no-restricted-syntax
) => React.JSX.Element)
Form.schema = components.schema
Form.useForm = components.useForm
@ -255,3 +253,4 @@ Form.Submit = components.Submit
Form.Reset = components.Reset
Form.FormError = components.FormError
Form.Field = components.Field
Form.FIELD_STYLES = components.FIELD_STYLES

View File

@ -8,8 +8,8 @@ import * as React from 'react'
import * as aria from '#/components/aria'
import * as twv from '#/utilities/tailwindVariants'
import { useText } from '#/providers/TextProvider'
import { type ExtractFunction, tv, type VariantProps } from '#/utilities/tailwindVariants'
import * as text from '../../Text'
import type * as types from './types'
import * as formContext from './useFormContext'
@ -17,9 +17,8 @@ import * as formContext from './useFormContext'
/**
* Props for Field component
*/
export interface FieldComponentProps
extends twv.VariantProps<typeof FIELD_STYLES>,
types.FieldProps {
export interface FieldComponentProps extends VariantProps<typeof FIELD_STYLES>, types.FieldProps {
readonly 'data-testid'?: string | undefined
readonly name: string
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly form?: types.FormInstance<any, any, any>
@ -27,6 +26,7 @@ export interface FieldComponentProps
readonly className?: string | undefined
readonly children?: React.ReactNode | ((props: FieldChildrenRenderProps) => React.ReactNode)
readonly style?: React.CSSProperties | undefined
readonly variants?: ExtractFunction<typeof FIELD_STYLES> | undefined
}
/**
@ -40,11 +40,12 @@ export interface FieldChildrenRenderProps {
readonly error?: string | undefined
}
export const FIELD_STYLES = twv.tv({
export const FIELD_STYLES = tv({
base: 'flex flex-col gap-0.5 items-start',
variants: {
fullWidth: { true: 'w-full' },
isInvalid: { true: { label: 'text-danger' } },
isHidden: { true: { base: 'hidden' } },
},
slots: {
labelContainer: 'contents',
@ -53,7 +54,9 @@ export const FIELD_STYLES = twv.tv({
description: text.TEXT_STYLE({ variant: 'body', color: 'disabled' }),
error: text.TEXT_STYLE({ variant: 'body', color: 'danger' }),
},
defaultVariants: { fullWidth: true },
defaultVariants: {
fullWidth: true,
},
})
/**
@ -73,8 +76,11 @@ export const Field = React.forwardRef(function Field(
fullWidth,
error,
name,
isHidden,
isRequired = false,
variants,
} = props
const { getText } = useText()
const fieldState = form.getFieldState(name)
@ -84,9 +90,10 @@ export const Field = React.forwardRef(function Field(
const invalid = isInvalid === true || fieldState.invalid
const classes = FIELD_STYLES({
const classes = (variants ?? FIELD_STYLES)({
fullWidth,
isInvalid: invalid,
isHidden,
})
const hasError = (error ?? fieldState.error?.message) != null
@ -95,6 +102,7 @@ export const Field = React.forwardRef(function Field(
<fieldset
ref={ref}
className={classes.base({ className })}
data-testid={props['data-testid']}
aria-invalid={invalid}
aria-label={props['aria-label']}
aria-labelledby={labelId}
@ -137,7 +145,7 @@ export const Field = React.forwardRef(function Field(
)}
{hasError && (
<span id={errorId} className={classes.error()}>
<span aria-label={getText('fieldErrorLabel')} id={errorId} className={classes.error()}>
{error ?? fieldState.error?.message}
</span>
)}

View File

@ -65,7 +65,12 @@ export function FormError(props: FormErrorProps) {
const submitErrorAlert =
errorMessage != null ?
<reactAriaComponents.Alert size={size} variant={variant} rounded={rounded} {...alertProps}>
<reactAriaComponents.Text variant="body" truncate="3" color="primary">
<reactAriaComponents.Text
data-testid="form-submit-error"
variant="body"
truncate="3"
color="primary"
>
{errorMessage}
</reactAriaComponents.Text>
</reactAriaComponents.Alert>

View File

@ -14,7 +14,7 @@ import type * as schemaModule from './schema'
*/
// eslint-disable-next-line no-restricted-syntax
export type FieldValues<Schema extends TSchema | undefined> =
Schema extends z.AnyZodObject ? z.infer<Schema> : reactHookForm.FieldValues
Schema extends TSchema ? z.infer<Schema> : reactHookForm.FieldValues
/**
* Field path type.
@ -28,7 +28,7 @@ export type FieldPath<
/**
* Schema type
*/
export type TSchema = z.AnyZodObject
export type TSchema = z.AnyZodObject | z.ZodEffects<z.AnyZodObject>
/**
* Props for the useForm hook.

View File

@ -12,8 +12,10 @@ import * as ariaComponents from '#/components/AriaComponents'
import * as mergeRefs from '#/utilities/mergeRefs'
import SvgMask from '#/components/SvgMask'
import type { ExtractFunction } from '#/utilities/tailwindVariants'
import { omit } from 'enso-common/src/utilities/data/object'
import * as variants from '../variants'
import { INPUT_STYLES } from '../variants'
/**
* Props for the Input component.
@ -31,13 +33,18 @@ export interface InputProps<
TTransformedValues
>,
ariaComponents.FieldProps,
Omit<twv.VariantProps<typeof variants.INPUT_STYLES>, 'disabled' | 'invalid'> {
Omit<twv.VariantProps<typeof INPUT_STYLES>, 'disabled' | 'invalid'> {
readonly 'data-testid'?: string | undefined
readonly className?: string
readonly style?: React.CSSProperties
readonly inputRef?: React.Ref<HTMLInputElement>
readonly addonStart?: React.ReactNode
readonly addonEnd?: React.ReactNode
readonly placeholder?: string
/** The icon to display in the input. */
readonly icon?: React.ReactElement | string | null
readonly variants?: ExtractFunction<typeof INPUT_STYLES> | undefined
readonly fieldVariants?: ariaComponents.FieldComponentProps['variants']
}
/**
@ -68,7 +75,11 @@ export const Input = React.forwardRef(function Input<
isRequired = false,
min,
max,
icon,
type = 'text',
variant,
variants,
fieldVariants,
...inputProps
} = props
@ -81,7 +92,8 @@ export const Input = React.forwardRef(function Input<
defaultValue,
})
const classes = variants.INPUT_STYLES({
const classes = (variants ?? INPUT_STYLES)({
variant,
size,
rounded,
invalid: fieldState.invalid,
@ -116,9 +128,11 @@ export const Input = React.forwardRef(function Input<
return (
<ariaComponents.Form.Field
data-testid={props['data-testid']}
form={formInstance}
name={name}
fullWidth
isHidden={inputProps.hidden}
label={label}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
@ -129,22 +143,27 @@ export const Input = React.forwardRef(function Input<
ref={ref}
style={props.style}
className={props.className}
variants={fieldVariants}
>
<div
className={classes.base()}
onClick={() => privateInputRef.current?.focus({ preventScroll: true })}
>
<div className={classes.inputContainer()}>
<div className={classes.content()}>
{addonStart != null && <div className={classes.addonStart()}>{addonStart}</div>}
{icon != null &&
(typeof icon === 'string' ? <SvgMask src={icon} className={classes.icon()} /> : icon)}
<aria.Input
ref={mergeRefs.mergeRefs(inputRef, privateInputRef, fieldRef)}
{...aria.mergeProps<aria.InputProps>()(
{ className: classes.textArea(), type, name, min, max, isRequired, isDisabled },
inputProps,
omit(field, 'required', 'disabled'),
)}
/>
<div className={classes.inputContainer()}>
<aria.Input
ref={mergeRefs.mergeRefs(inputRef, privateInputRef, fieldRef)}
{...aria.mergeProps<aria.InputProps>()(
{ className: classes.textArea(), type, name, min, max, isRequired, isDisabled },
inputProps,
omit(field, 'required', 'disabled'),
)}
/>
</div>
{addonEnd != null && <div className={classes.addonEnd()}>{addonEnd}</div>}
</div>

View File

@ -0,0 +1,57 @@
/** @file A component wrapping {@link Input} with the ability to show and hide password. */
import { useState } from 'react'
import type { Path } from 'react-hook-form'
import EyeIcon from '#/assets/eye.svg'
import EyeCrossedIcon from '#/assets/eye_crossed.svg'
import {
Button,
Input,
type FieldValues,
type InputProps,
type TSchema,
} from '#/components/AriaComponents'
// ================
// === Password ===
// ================
/** Props for a {@link Password}. */
export interface PasswordProps<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends Path<TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
> extends Omit<InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>, 'type'> {}
/** A component wrapping {@link Input} with the ability to show and hide password. */
export function Password<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends Path<TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
>(props: PasswordProps<Schema, TFieldValues, TFieldName, TTransformedValues>) {
const [showPassword, setShowPassword] = useState(false)
return (
<Input
{...props}
type={showPassword ? 'text' : 'password'}
addonEnd={
<>
{props.addonEnd}
<Button
size="medium"
variant="icon"
extraClickZone
icon={showPassword ? EyeIcon : EyeCrossedIcon}
onPress={() => {
setShowPassword(!showPassword)
}}
/>
</>
}
/>
)
}

View File

@ -0,0 +1,6 @@
/**
* @file
*
* Barrel file for Password component.
*/
export * from './Password'

View File

@ -33,7 +33,7 @@ export interface ResizableContentEditableInputProps<
TFieldName,
TTransformedValues
>,
ariaComponents.FieldProps,
Omit<ariaComponents.FieldProps, 'variant'>,
Omit<twv.VariantProps<typeof variants.INPUT_STYLES>, 'disabled' | 'invalid'> {
/**
* onChange is called when the content of the input changes.
@ -70,6 +70,7 @@ export const ResizableContentEditableInput = React.forwardRef(
defaultValue,
size,
rounded,
variant,
...textFieldProps
} = props
@ -103,6 +104,7 @@ export const ResizableContentEditableInput = React.forwardRef(
textArea,
placeholder: placeholderClass,
} = CONTENT_EDITABLE_STYLES({
variant,
invalid: fieldState.invalid,
disabled: isDisabled || formInstance.formState.isSubmitting,
rounded,

View File

@ -125,18 +125,6 @@ export const Selector = React.forwardRef(function Selector<
disabled: isDisabled || formInstance.formState.isSubmitting,
})
// const { ref: fieldRef, ...field } = formInstance.register(name, {
// disabled: isDisabled,
// required: isRequired,
// ...(inputProps.onBlur && { onBlur: inputProps.onBlur }),
// ...(inputProps.onChange && { onChange: inputProps.onChange }),
// setValueAs: (value) => {
// console.log('WHAT', value)
// // eslint-disable-next-line @typescript-eslint/no-unsafe-return
// return items[Number(value)]
// },
// })
return (
<Form.Field
form={formInstance}

View File

@ -5,6 +5,7 @@
*/
export * from './Input'
export * from './Password'
export * from './ResizableInput'
export * from './Selector'
export * from './variants'

View File

@ -3,7 +3,6 @@
*
* Variants for the ResizableInput component.
*/
import { tv } from '#/utilities/tailwindVariants'
import { TEXT_STYLE } from '../Text'
@ -23,8 +22,9 @@ export const INPUT_STYLES = tv({
false: 'cursor-text',
},
size: {
medium: { base: 'px-[11px] pb-1.5 pt-2' },
small: { base: 'px-[11px] pb-0.5 pt-1' },
medium: { base: 'px-[11px] pb-[6.5px] pt-[8.5px]', icon: 'size-4' },
small: { base: 'px-[11px] pb-0.5 pt-1', icon: 'size-3' },
custom: {},
},
rounded: {
none: 'rounded-none',
@ -37,22 +37,25 @@ export const INPUT_STYLES = tv({
full: 'rounded-full',
},
variant: {
custom: {},
outline: {
base: 'border-[0.5px] outline-offset-2 border-primary/20 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[-1px] focus-within:outline-primary focus-within:border-primary/50',
base: 'border-[0.5px] border-primary/20 outline-offset-2 focus-within:border-primary/50 focus-within:outline focus-within:outline-2 focus-within:outline-offset-[-1px] focus-within:outline-primary',
textArea: 'border-transparent focus-within:border-transparent',
},
},
},
slots: {
addonStart: '',
addonEnd: '',
icon: 'flex-none',
addonStart: 'mt-[-1px] flex flex-none items-center gap-1',
addonEnd: 'mt-[-1px] flex flex-none items-center gap-1',
content: 'flex items-center gap-2',
inputContainer: TEXT_STYLE({
className: 'flex w-full items-center max-h-32 min-h-6 relative overflow-auto',
className: 'relative flex max-h-32 min-h-6 w-full items-center overflow-clip',
variant: 'body',
}),
selectorContainer: 'flex',
description: 'block select-none pointer-events-none opacity-80',
textArea: 'block h-auto w-full max-h-full resize-none bg-transparent',
description: 'pointer-events-none block select-none opacity-80',
textArea: 'block h-auto max-h-full w-full resize-none bg-transparent',
resizableSpan: TEXT_STYLE({
className:
'pointer-events-none invisible absolute block max-h-32 min-h-10 overflow-y-auto break-all',

View File

@ -1,16 +1,13 @@
/** @file A styled colored link with an icon. */
import * as React from 'react'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import * as focusHooks from '#/hooks/focusHooks'
import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria'
import FocusRing from '#/components/styled/FocusRing'
import SvgMask from '#/components/SvgMask'
import { useFocusChild } from '#/hooks/focusHooks'
import { useText } from '#/providers/TextProvider'
import { twMerge } from 'tailwind-merge'
// ============
// === Link ===
@ -24,46 +21,34 @@ export interface LinkProps {
readonly text: string
}
/** A styled colored link with an icon. */
export default function Link(props: LinkProps) {
const { openInBrowser = false, to, icon, text } = props
const { getText } = textProvider.useText()
const focusChildProps = focusHooks.useFocusChild()
const className =
'flex items-center gap-auth-link rounded-full px-auth-link-x py-auth-link-y text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700'
export default React.forwardRef(Link)
const contents = (
<>
<SvgMask src={icon} />
{text}
</>
)
/** A styled colored link with an icon. */
function Link(props: LinkProps, ref: React.ForwardedRef<HTMLAnchorElement>) {
const { openInBrowser = false, to, icon, text } = props
const { getText } = useText()
const { className: focusChildClassName, ...focusChildProps } = useFocusChild()
return (
<FocusRing>
{openInBrowser ?
<aria.Link
{...aria.mergeProps<aria.LinkProps>()(focusChildProps, {
href: to,
className,
target: '_blank',
onPress: () => {
toastify.toast.success(getText('openedLinkInBrowser'))
},
})}
>
{contents}
</aria.Link>
: <router.Link
{...aria.mergeProps<router.LinkProps>()(focusChildProps, {
to,
className,
target: '',
})}
>
{contents}
</router.Link>
}
<aria.Link
ref={ref}
href={to}
rel="noopener noreferrer"
className={twMerge(
'flex items-center gap-auth-link rounded-full px-auth-link-x py-auth-link-y text-center text-xs font-bold text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700',
focusChildClassName,
)}
onPress={() => {
if (openInBrowser) {
toastify.toast.success(getText('openedLinkInBrowser'))
}
}}
{...focusChildProps}
>
<SvgMask src={icon} />
{text}
</aria.Link>
</FocusRing>
)
}

View File

@ -1,16 +1,38 @@
/** @file A form for changing the user's password. */
import * as React from 'react'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import * as z from 'zod'
import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents'
import SettingsInput from '#/components/styled/SettingsInput'
import { ButtonGroup, Form, Input } from '#/components/AriaComponents'
import { useAuth, useNonPartialUserSession } from '#/providers/AuthProvider'
import { type GetText, useText } from '#/providers/TextProvider'
import * as eventModule from '#/utilities/event'
import * as uniqueString from '#/utilities/uniqueString'
import * as validation from '#/utilities/validation'
import SettingsAriaInput from '#/layouts/Settings/SettingsAriaInput'
import { passwordSchema, passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { PASSWORD_REGEX } from '#/utilities/validation'
/** Create the schema for this form. */
function createChangePasswordFormSchema(getText: GetText) {
return z
.object({
username: z.string().email(getText('invalidEmailValidationError')),
currentPassword: passwordSchema(getText),
newPassword: passwordWithPatternSchema(getText),
confirmNewPassword: z.string(),
})
.superRefine((object, context) => {
if (
PASSWORD_REGEX.test(object.newPassword) &&
object.newPassword !== object.confirmNewPassword
) {
context.addIssue({
path: ['confirmNewPassword'],
code: 'custom',
message: getText('passwordMismatchError'),
})
}
})
}
// ==========================
// === ChangePasswordForm ===
@ -18,102 +40,48 @@ import * as validation from '#/utilities/validation'
/** A form for changing the user's password. */
export default function ChangePasswordForm() {
const { user } = authProvider.useNonPartialUserSession()
const { changePassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const [key, setKey] = React.useState('')
const [currentPassword, setCurrentPassword] = React.useState('')
const [newPassword, setNewPassword] = React.useState('')
const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
const canSubmitPassword =
currentPassword !== '' &&
newPassword !== '' &&
confirmNewPassword !== '' &&
newPassword === confirmNewPassword &&
validation.PASSWORD_REGEX.test(newPassword)
const canCancel = currentPassword !== '' || newPassword !== '' || confirmNewPassword !== ''
const { user } = useNonPartialUserSession()
const { changePassword } = useAuth()
const { getText } = useText()
return (
<aria.Form
key={key}
onSubmit={(event) => {
event.preventDefault()
setKey(uniqueString.uniqueString())
setCurrentPassword('')
setNewPassword('')
setConfirmNewPassword('')
void changePassword(currentPassword, newPassword)
}}
<Form
schema={createChangePasswordFormSchema(getText)}
gap="none"
onSubmit={({ currentPassword, newPassword }) => changePassword(currentPassword, newPassword)}
>
<aria.Input hidden autoComplete="username" value={user.email} readOnly />
<aria.TextField className="flex h-row gap-settings-entry" onChange={setCurrentPassword}>
<aria.Label className="text my-auto w-change-password-settings-label">
{getText('currentPasswordLabel')}
</aria.Label>
<SettingsInput
type="password"
autoComplete="current-password"
placeholder={getText('currentPasswordPlaceholder')}
/>
</aria.TextField>
<aria.TextField
className="flex h-row gap-settings-entry"
onChange={setNewPassword}
validate={(value) =>
validation.PASSWORD_REGEX.test(value) ? true
: value === '' ? ''
: getText('passwordValidationError')
}
>
<aria.Label className="text my-auto w-change-password-settings-label">
{getText('newPasswordLabel')}
</aria.Label>
<SettingsInput
type="password"
placeholder={getText('newPasswordPlaceholder')}
autoComplete="new-password"
/>
</aria.TextField>
<aria.TextField
className="flex h-row gap-settings-entry"
onChange={setConfirmNewPassword}
validate={(value) =>
value === newPassword ? true
: value === '' ? ''
: getText('passwordMismatchError')
}
>
<aria.Label className="text my-auto w-change-password-settings-label">
{getText('confirmNewPasswordLabel')}
</aria.Label>
<SettingsInput
type="password"
placeholder={getText('confirmNewPasswordPlaceholder')}
autoComplete="new-password"
/>
</aria.TextField>
<ariaComponents.ButtonGroup>
<ariaComponents.Button
variant="submit"
isDisabled={!canSubmitPassword}
onPress={eventModule.submitForm}
>
{getText('change')}
</ariaComponents.Button>
<ariaComponents.Button
variant="cancel"
isDisabled={!canCancel}
onPress={() => {
setKey(uniqueString.uniqueString())
setCurrentPassword('')
setNewPassword('')
setConfirmNewPassword('')
}}
>
{getText('cancel')}
</ariaComponents.Button>
</ariaComponents.ButtonGroup>
</aria.Form>
<Input hidden name="username" autoComplete="username" value={user.email} readOnly />
<SettingsAriaInput
data-testid="current-password-input"
name="currentPassword"
type="password"
autoComplete="current-password"
label={getText('currentPasswordLabel')}
placeholder={getText('currentPasswordPlaceholder')}
/>
<SettingsAriaInput
data-testid="new-password-input"
name="newPassword"
type="password"
label={getText('newPasswordLabel')}
placeholder={getText('newPasswordPlaceholder')}
autoComplete="new-password"
description={getText('passwordValidationMessage')}
/>
<SettingsAriaInput
data-testid="confirm-new-password-input"
name="confirmNewPassword"
type="password"
label={getText('confirmNewPasswordLabel')}
placeholder={getText('confirmNewPasswordPlaceholder')}
autoComplete="new-password"
/>
<Form.FormError />
<ButtonGroup>
<Form.Submit>{getText('change')}</Form.Submit>
<Form.Reset>{getText('cancel')}</Form.Reset>
</ButtonGroup>
</Form>
)
}

View File

@ -0,0 +1,62 @@
/** @file A styled input for settings pages. */
import {
Form,
INPUT_STYLES,
Input,
type FieldPath,
type FieldValues,
type InputProps,
type TSchema,
} from '#/components/AriaComponents'
import { tv } from '#/utilities/tailwindVariants'
const SETTINGS_INPUT_STYLES = tv({
extend: INPUT_STYLES,
slots: {
base: 'p-0',
textArea: 'rounded-2xl border-0.5 border-primary/20 px-1',
},
})
const SETTINGS_FIELD_STYLES = tv({
extend: Form.FIELD_STYLES,
slots: {
base: 'flex-row flex-wrap',
labelContainer: 'flex min-h-row items-center gap-5 w-full',
label: 'text mb-auto w-40 shrink-0',
error: 'ml-[180px]',
},
})
// =========================
// === SettingsAriaInput ===
// =========================
/** Props for a {@link SettingsAriaInput}. */
export interface SettingsAriaInputProps<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
> extends Omit<
InputProps<Schema, TFieldValues, TFieldName, TTransformedValues>,
'fieldVariants' | 'size' | 'variant' | 'variants'
> {}
/** A styled input for settings pages. */
export default function SettingsAriaInput<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TFieldName extends FieldPath<Schema, TFieldValues>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
>(props: SettingsAriaInputProps<Schema, TFieldValues, TFieldName, TTransformedValues>) {
return (
<Input
{...props}
variant="custom"
size="custom"
variants={SETTINGS_INPUT_STYLES}
fieldVariants={SETTINGS_FIELD_STYLES}
/>
)
}

View File

@ -75,7 +75,7 @@ export default function SettingsInputEntry(props: SettingsInputEntryProps) {
key={value}
name={FIELD_NAME}
defaultValue={value}
className="flex h-row gap-settings-entry"
className="flex h-row items-center gap-settings-entry"
{...(validate ? { validate: (newValue) => validate(newValue, context) } : {})}
>
<aria.Label className="text my-auto w-organization-settings-label">

View File

@ -212,88 +212,81 @@ function Summary(props: SummaryProps) {
const billingPeriodText = billingPeriodToString(getText, period)
if (isError) {
// eslint-disable-next-line no-restricted-syntax
return (
return isError ?
<ErrorDisplay
error={error}
title={getText('asyncHookError')}
resetErrorBoundary={() => refetch()}
/>
)
}
: <div className="flex flex-col">
<Text variant="subtitle">{getText('summary')}</Text>
return (
<div className="flex flex-col">
<Text variant="subtitle">{getText('summary')}</Text>
<div
className={twMerge(
'-ml-4 table table-auto border-spacing-x-4 transition-[filter] duration-200',
(isLoading || isInvalid) && 'pointer-events-none blur-[4px]',
isLoading && 'animate-pulse duration-1000',
)}
>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('priceMonthly')}
</Text>
{data && (
<Text className="table-cell " variant="body">
{formatter.format(data.monthlyPrice)}
</Text>
<div
className={twMerge(
'-ml-4 table table-auto border-spacing-x-4 transition-[filter] duration-200',
(isLoading || isInvalid) && 'pointer-events-none blur-[4px]',
isLoading && 'animate-pulse duration-1000',
)}
</div>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('billingPeriod')}
</Text>
{data && (
<Text className="table-cell" variant="body">
{billingPeriodText}
>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('priceMonthly')}
</Text>
)}
</div>
{data && (
<Text className="table-cell " variant="body">
{formatter.format(data.monthlyPrice)}
</Text>
)}
</div>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('originalPrice')}
</Text>
{data && (
<Text className="table-cell" variant="body">
{formatter.format(data.fullPrice)}
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('billingPeriod')}
</Text>
)}
</div>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('youSave')}
</Text>
{data && (
<Text
className="table-cell"
color={data.discount > 0 ? 'success' : 'primary'}
variant="body"
>
{formatter.format(data.discount)}
</Text>
)}
</div>
{data && (
<Text className="table-cell" variant="body">
{billingPeriodText}
</Text>
)}
</div>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('subtotalPrice')}
</Text>
{data && (
<Text className="table-cell" variant="body">
{formatter.format(data.totalPrice)}
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('originalPrice')}
</Text>
)}
{data && (
<Text className="table-cell" variant="body">
{formatter.format(data.fullPrice)}
</Text>
)}
</div>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('youSave')}
</Text>
{data && (
<Text
className="table-cell"
color={data.discount > 0 ? 'success' : 'primary'}
variant="body"
>
{formatter.format(data.discount)}
</Text>
)}
</div>
<div className="table-row">
<Text className="table-cell w-[0%]" variant="body" nowrap>
{getText('subtotalPrice')}
</Text>
{data && (
<Text className="table-cell" variant="body">
{formatter.format(data.totalPrice)}
</Text>
)}
</div>
</div>
</div>
</div>
)
}

View File

@ -1,47 +1,64 @@
/** @file A styled authentication page.
* This is a component, NOT a page, but it is here because it is related to the authentication pages
* and nothing else. */
import * as React from 'react'
import type { ReactNode } from 'react'
import * as offlineHooks from '#/hooks/offlineHooks'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import {
DIALOG_BACKGROUND,
type FieldValues,
Form,
type FormProps,
type TSchema,
Text,
} from '#/components/AriaComponents'
import Page from '#/components/Page'
import { useOffline } from '#/hooks/offlineHooks'
import { useText } from '#/providers/TextProvider'
import invariant from 'tiny-invariant'
// ==========================
// === AuthenticationPage ===
// ==========================
/** Props for an {@link AuthenticationPage}. */
export interface AuthenticationPageProps extends Readonly<React.PropsWithChildren> {
interface AuthenticationPagePropsBase {
readonly supportsOffline?: boolean
readonly 'data-testid'?: string
readonly isNotForm?: boolean
readonly title: string
readonly footer?: React.ReactNode
readonly onSubmit?: (event: React.FormEvent<HTMLFormElement>) => void
readonly footer?: ReactNode
}
/** A styled authentication page. */
export default function AuthenticationPage(props: AuthenticationPageProps) {
const { isNotForm = false, title, onSubmit, children, footer, supportsOffline = false } = props
/** Props for an {@link AuthenticationPage}. */
export type AuthenticationPageProps<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
> = AuthenticationPagePropsBase & Partial<FormProps<Schema, TFieldValues, TTransformedValues>>
const { getText } = textProvider.useText()
const { isOffline } = offlineHooks.useOffline()
/** A styled authentication page. */
export default function AuthenticationPage<
Schema extends TSchema,
TFieldValues extends FieldValues<Schema>,
TTransformedValues extends FieldValues<Schema> | undefined = undefined,
>(props: AuthenticationPageProps<Schema, TFieldValues, TTransformedValues>) {
const { title, children, footer, supportsOffline = false, ...formProps } = props
const { form, schema, onSubmit } = formProps
const isForm = onSubmit != null && (form != null || schema != null)
const { getText } = useText()
const { isOffline } = useOffline()
const heading = (
<ariaComponents.Text.Heading level={1} className="self-center" weight="medium">
<Text.Heading level={1} className="self-center" weight="medium">
{title}
</ariaComponents.Text.Heading>
</Text.Heading>
)
const containerClasses = ariaComponents.DIALOG_BACKGROUND({
className: 'flex w-full flex-col gap-auth rounded-4xl p-12',
const containerClasses = DIALOG_BACKGROUND({
className: 'flex w-full flex-col gap-4 rounded-4xl p-12',
})
const offlineAlertClasses = ariaComponents.DIALOG_BACKGROUND({
const offlineAlertClasses = DIALOG_BACKGROUND({
className: 'flex mt-auto rounded-sm items-center justify-center p-4 px-12 rounded-4xl',
})
@ -54,29 +71,38 @@ export default function AuthenticationPage(props: AuthenticationPageProps) {
>
{isOffline && (
<div className={offlineAlertClasses}>
<ariaComponents.Text className="text-center" balance elementType="p">
<Text className="text-center" balance elementType="p">
{getText('loginUnavailableOffline')}{' '}
{supportsOffline && getText('loginUnavailableOfflineLocal')}
</ariaComponents.Text>
</Text>
</div>
)}
<div className="row-start-2 row-end-3 flex w-full flex-col items-center gap-auth">
{isNotForm ?
{!isForm ?
<div className={containerClasses}>
{heading}
{children}
{(() => {
invariant(
typeof children !== 'function',
'Non-forms should not have a function as a child.',
)
return children
})()}
</div>
: <form
: <Form
// This is SAFE, as the props type of this type extends `FormProps`.
// eslint-disable-next-line no-restricted-syntax
{...(formProps as FormProps<Schema, TFieldValues, TTransformedValues>)}
className={containerClasses}
onSubmit={(event) => {
event.preventDefault()
onSubmit?.(event)
}}
>
{heading}
{children}
</form>
{(innerProps) => (
<>
{heading}
{typeof children === 'function' ? children(innerProps) : children}
</>
)}
</Form>
}
{footer}
</div>

View File

@ -1,22 +1,28 @@
/** @file Container responsible for rendering and interactions in first half of forgot password
* flow. */
import * as React from 'react'
import { useState } from 'react'
import isEmail from 'validator/lib/isEmail'
import * as z from 'zod'
import { LOGIN_PATH } from '#/appUtils'
import ArrowRightIcon from '#/assets/arrow_right.svg'
import AtIcon from '#/assets/at.svg'
import GoBackIcon from '#/assets/go_back.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import Input from '#/components/Input'
import { Form, Input } from '#/components/AriaComponents'
import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { type GetText, useText } from '#/providers/TextProvider'
import { useLocation } from 'react-router'
/** Create the schema for this form. */
function createForgotPasswordFormSchema(getText: GetText) {
return z.object({
email: z.string().refine(isEmail, getText('invalidEmailValidationError')),
})
}
// ======================
// === ForgotPassword ===
@ -24,34 +30,51 @@ import SubmitButton from '#/components/SubmitButton'
/** A form for users to request for their password to be reset. */
export default function ForgotPassword() {
const { forgotPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const [email, setEmail] = React.useState('')
const localBackend = backendProvider.useLocalBackend()
const { forgotPassword } = useAuth()
const location = useLocation()
const { getText } = useText()
const localBackend = useLocalBackend()
const supportsOffline = localBackend != null
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email')
const [emailInput, setEmailInput] = useState(initialEmail ?? '')
return (
<AuthenticationPage
title={getText('forgotYourPassword')}
footer={<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('goBackToLogin')} />}
schema={createForgotPasswordFormSchema(getText)}
footer={
<Link
to={`${LOGIN_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
icon={GoBackIcon}
text={getText('goBackToLogin')}
/>
}
supportsOffline={supportsOffline}
onSubmit={async (event) => {
event.preventDefault()
await forgotPassword(email)
}}
onSubmit={({ email }) => forgotPassword(email)}
>
<Input
autoFocus
required
validate
data-testid="email-input"
name="email"
label={getText('emailLabel')}
type="email"
autoComplete="email"
icon={AtIcon}
placeholder={getText('emailPlaceholder')}
value={email}
setValue={setEmail}
defaultValue={initialEmail ?? undefined}
onChange={(event) => {
setEmailInput(event.currentTarget.value)
}}
/>
<SubmitButton text={getText('sendLink')} icon={ArrowRightIcon} />
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
{getText('sendLink')}
</Form.Submit>
<Form.FormError />
</AuthenticationPage>
)
}

View File

@ -1,30 +1,23 @@
/** @file Login component responsible for rendering and interactions in sign in flow. */
import * as React from 'react'
import * as router from 'react-router-dom'
import * as common from 'enso-common'
import { CLOUD_DASHBOARD_DOMAIN } from 'enso-common'
import { FORGOT_PASSWORD_PATH, REGISTRATION_PATH } from '#/appUtils'
import ArrowRightIcon from '#/assets/arrow_right.svg'
import AtIcon from '#/assets/at.svg'
import CreateAccountIcon from '#/assets/create_account.svg'
import GithubIcon from '#/assets/github.svg'
import GoogleIcon from '#/assets/google.svg'
import GithubIcon from '#/assets/github_color.svg'
import GoogleIcon from '#/assets/google_color.svg'
import LockIcon from '#/assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import * as ariaComponents from '#/components/AriaComponents'
import Input from '#/components/Input'
import { Button, Form, Input, Password } from '#/components/AriaComponents'
import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton'
import TextLink from '#/components/TextLink'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useText } from '#/providers/TextProvider'
import { useState } from 'react'
// =============
// === Login ===
@ -33,119 +26,111 @@ import TextLink from '#/components/TextLink'
/** A form for users to log in. */
export default function Login() {
const location = router.useLocation()
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = useAuth()
const { getText } = useText()
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email')
const [email, setEmail] = React.useState(initialEmail ?? '')
const [password, setPassword] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
const shouldReportValidityRef = React.useRef(true)
const formRef = React.useRef<HTMLFormElement>(null)
const localBackend = backendProvider.useLocalBackend()
const [emailInput, setEmailInput] = useState(initialEmail ?? '')
const localBackend = useLocalBackend()
const supportsOffline = localBackend != null
return (
<AuthenticationPage
isNotForm
title={getText('loginToYourAccount')}
supportsOffline={supportsOffline}
footer={
<>
<Link
openInBrowser={localBackend != null}
to={
localBackend != null ?
'https://' + common.CLOUD_DASHBOARD_DOMAIN + appUtils.REGISTRATION_PATH
: appUtils.REGISTRATION_PATH
}
icon={CreateAccountIcon}
text={getText('dontHaveAnAccount')}
/>
</>
<Link
openInBrowser={localBackend != null}
to={(() => {
const newQuery = new URLSearchParams({ email: emailInput }).toString()
return localBackend != null ?
`https://${CLOUD_DASHBOARD_DOMAIN}${REGISTRATION_PATH}?${newQuery}`
: `${REGISTRATION_PATH}?${newQuery}`
})()}
icon={CreateAccountIcon}
text={getText('dontHaveAnAccount')}
/>
}
>
<div className="flex flex-col gap-auth">
<ariaComponents.Button
size="custom"
variant="custom"
fullWidthText
icon={GoogleIcon}
className="bg-primary/5 px-3 py-2 hover:bg-primary/10 focus:bg-primary/10"
onPress={() => {
shouldReportValidityRef.current = false
void signInWithGoogle()
setIsSubmitting(true)
<Button
size="large"
variant="outline"
icon={<img src={GoogleIcon} />}
onPress={async () => {
await signInWithGoogle()
}}
>
{getText('signUpOrLoginWithGoogle')}
</ariaComponents.Button>
<ariaComponents.Button
size="custom"
variant="custom"
fullWidthText
icon={GithubIcon}
className="bg-primary/5 px-3 py-2 hover:bg-primary/10 focus:bg-primary/10"
onPress={() => {
shouldReportValidityRef.current = false
void signInWithGitHub()
setIsSubmitting(true)
</Button>
<Button
size="large"
variant="outline"
icon={<img src={GithubIcon} />}
onPress={async () => {
await signInWithGitHub()
}}
>
{getText('signUpOrLoginWithGitHub')}
</ariaComponents.Button>
</Button>
</div>
<div />
<form
ref={formRef}
className="flex flex-col gap-auth"
onSubmit={async (event) => {
event.preventDefault()
setIsSubmitting(true)
await signInWithPassword(email, password)
shouldReportValidityRef.current = true
setIsSubmitting(false)
}}
<Form
schema={(z) =>
z.object({
email: z
.string()
.min(1, getText('arbitraryFieldRequired'))
.email(getText('invalidEmailValidationError')),
password: passwordSchema(getText),
})
}
gap="medium"
onSubmit={({ email, password }) => signInWithPassword(email, password)}
>
<Input
autoFocus
required
validate
data-testid="email-input"
name="email"
label={getText('email')}
type="email"
autoComplete="email"
icon={AtIcon}
defaultValue={initialEmail ?? undefined}
placeholder={getText('emailPlaceholder')}
value={email}
setValue={setEmail}
shouldReportValidityRef={shouldReportValidityRef}
onChange={(event) => {
setEmailInput(event.currentTarget.value)
}}
/>
<div className="flex flex-col">
<Input
<div className="flex w-full flex-col">
<Password
required
validate
allowShowingPassword
type="password"
data-testid="password-input"
name="password"
label={getText('password')}
autoComplete="current-password"
icon={LockIcon}
placeholder={getText('passwordPlaceholder')}
error={getText('passwordValidationError')}
value={password}
setValue={setPassword}
shouldReportValidityRef={shouldReportValidityRef}
/>
<TextLink to={appUtils.FORGOT_PASSWORD_PATH} text={getText('forgotYourPassword')} />
<Button
variant="link"
href={`${FORGOT_PASSWORD_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
size="small"
className="self-end"
>
{getText('forgotYourPassword')}
</Button>
</div>
<SubmitButton
isDisabled={isSubmitting}
isLoading={isSubmitting}
text={getText('login')}
icon={ArrowRightIcon}
/>
</form>
<Form.Submit size="large" icon={ArrowRightIcon} iconPosition="end" fullWidth>
{getText('login')}
</Form.Submit>
<Form.FormError />
</Form>
</AuthenticationPage>
)
}

View File

@ -1,30 +1,24 @@
/** @file Registration container responsible for rendering and interactions in sign up flow. */
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router-dom'
import * as router from 'react-router-dom'
import * as z from 'zod'
import { LOGIN_PATH } from '#/appUtils'
import AtIcon from '#/assets/at.svg'
import CreateAccountIcon from '#/assets/create_account.svg'
import GoBackIcon from '#/assets/go_back.svg'
import LockIcon from '#/assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import Input from '#/components/Input'
import { Form, Input, Password } from '#/components/AriaComponents'
import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { useLocalStorage } from '#/providers/LocalStorageProvider'
import { type GetText, useText } from '#/providers/TextProvider'
import LocalStorage from '#/utilities/LocalStorage'
import * as string from '#/utilities/string'
import * as validation from '#/utilities/validation'
import { PASSWORD_REGEX } from '#/utilities/validation'
// ============================
// === Global configuration ===
@ -42,30 +36,45 @@ LocalStorage.registerKey('loginRedirect', {
schema: z.string(),
})
/** Create the schema for this form. */
function createRegistrationFormSchema(getText: GetText) {
return z
.object({
email: z.string().email(getText('invalidEmailValidationError')),
password: passwordWithPatternSchema(getText),
confirmPassword: z.string(),
})
.superRefine((object, context) => {
if (PASSWORD_REGEX.test(object.password) && object.password !== object.confirmPassword) {
context.addIssue({
path: ['confirmPassword'],
code: 'custom',
message: getText('passwordMismatchError'),
})
}
})
}
// ====================
// === Registration ===
// ====================
/** A form for users to register an account. */
export default function Registration() {
const auth = authProvider.useAuth()
const location = router.useLocation()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const localBackend = backendProvider.useLocalBackend()
const { signUp } = useAuth()
const location = useLocation()
const { localStorage } = useLocalStorage()
const { getText } = useText()
const localBackend = useLocalBackend()
const supportsOffline = localBackend != null
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email')
const organizationId = query.get('organization_id')
const redirectTo = query.get('redirect_to')
const [emailInput, setEmailInput] = useState(initialEmail ?? '')
const [email, setEmail] = React.useState(initialEmail ?? '')
const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
const [isSubmitting, setIsSubmitting] = React.useState(false)
React.useEffect(() => {
useEffect(() => {
if (redirectTo != null) {
localStorage.set('loginRedirect', redirectTo)
} else {
@ -75,56 +84,58 @@ export default function Registration() {
return (
<AuthenticationPage
schema={createRegistrationFormSchema(getText)}
title={getText('createANewAccount')}
supportsOffline={supportsOffline}
footer={
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('alreadyHaveAnAccount')} />
<Link
to={`${LOGIN_PATH}?${new URLSearchParams({ email: emailInput }).toString()}`}
icon={GoBackIcon}
text={getText('alreadyHaveAnAccount')}
/>
}
onSubmit={async (event) => {
event.preventDefault()
setIsSubmitting(true)
await auth.signUp(email, password, organizationId)
setIsSubmitting(false)
}}
onSubmit={({ email, password }) => signUp(email, password, organizationId)}
>
<Input
autoFocus
required
validate
data-testid="email-input"
name="email"
label={getText('emailLabel')}
type="email"
autoComplete="email"
icon={AtIcon}
placeholder={getText('emailPlaceholder')}
value={email}
setValue={setEmail}
defaultValue={initialEmail ?? undefined}
onChange={(event) => {
setEmailInput(event.currentTarget.value)
}}
/>
<Input
<Password
required
validate
allowShowingPassword
type="password"
data-testid="password-input"
name="password"
label={getText('passwordLabel')}
autoComplete="new-password"
icon={LockIcon}
placeholder={getText('passwordPlaceholder')}
pattern={validation.PASSWORD_PATTERN}
error={getText('passwordValidationError')}
value={password}
setValue={setPassword}
description={getText('passwordValidationMessage')}
/>
<Input
<Password
required
validate
allowShowingPassword
type="password"
data-testid="confirm-password-input"
name="confirmPassword"
label={getText('confirmPasswordLabel')}
autoComplete="new-password"
icon={LockIcon}
placeholder={getText('confirmPasswordPlaceholder')}
pattern={string.regexEscape(password)}
error={getText('passwordMismatchError')}
value={confirmPassword}
setValue={setConfirmPassword}
/>
<SubmitButton isDisabled={isSubmitting} text={getText('register')} icon={CreateAccountIcon} />
<Form.Submit size="large" icon={CreateAccountIcon} className="w-full">
{getText('register')}
</Form.Submit>
<Form.FormError />
</AuthenticationPage>
)
}

View File

@ -1,30 +1,47 @@
/** @file Container responsible for rendering and interactions in second half of forgot password
* flow. */
import * as React from 'react'
import * as router from 'react-router-dom'
import isEmail from 'validator/lib/isEmail'
import * as z from 'zod'
import { LOGIN_PATH } from '#/appUtils'
import ArrowRightIcon from '#/assets/arrow_right.svg'
import GoBackIcon from '#/assets/go_back.svg'
import LockIcon from '#/assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import * as aria from '#/components/aria'
import Input from '#/components/Input'
import { Form, Input, Password } from '#/components/AriaComponents'
import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import AuthenticationPage from '#/pages/authentication/AuthenticationPage'
import { passwordWithPatternSchema } from '#/pages/authentication/schemas'
import { useAuth } from '#/providers/AuthProvider'
import { useLocalBackend } from '#/providers/BackendProvider'
import { type GetText, useText } from '#/providers/TextProvider'
import { PASSWORD_REGEX } from '#/utilities/validation'
import * as string from '#/utilities/string'
import * as validation from '#/utilities/validation'
/** Create the schema for this form. */
function createResetPasswordFormSchema(getText: GetText) {
return z
.object({
email: z.string().refine(isEmail, getText('invalidEmailValidationError')),
verificationCode: z.string(),
newPassword: passwordWithPatternSchema(getText),
confirmNewPassword: z.string(),
})
.superRefine((object, context) => {
if (
PASSWORD_REGEX.test(object.newPassword) &&
object.newPassword !== object.confirmNewPassword
) {
context.addIssue({
path: ['confirmNewPassword'],
code: 'custom',
message: getText('passwordMismatchError'),
})
}
})
}
// =====================
// === ResetPassword ===
@ -32,97 +49,91 @@ import * as validation from '#/utilities/validation'
/** A form for users to reset their password. */
export default function ResetPassword() {
const { resetPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const { resetPassword } = useAuth()
const { getText } = useText()
const location = router.useLocation()
const navigate = router.useNavigate()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const localBackend = backendProvider.useLocalBackend()
const toastAndLog = useToastAndLog()
const localBackend = useLocalBackend()
const supportsOffline = localBackend != null
const query = new URLSearchParams(location.search)
const email = query.get('email')
const verificationCode = query.get('verification_code')
const [newPassword, setNewPassword] = React.useState('')
const [newPasswordConfirm, setNewPasswordConfirm] = React.useState('')
const defaultEmail = query.get('email')
const defaultVerificationCode = query.get('verification_code')
React.useEffect(() => {
if (email == null) {
if (defaultEmail == null) {
toastAndLog('missingEmailError')
navigate(appUtils.LOGIN_PATH)
} else if (verificationCode == null) {
navigate(LOGIN_PATH)
} else if (defaultVerificationCode == null) {
toastAndLog('missingVerificationCodeError')
navigate(appUtils.LOGIN_PATH)
navigate(LOGIN_PATH)
}
}, [email, navigate, verificationCode, getText, toastAndLog])
const doSubmit = () => {
if (newPassword !== newPasswordConfirm) {
toastAndLog('passwordMismatchError')
return Promise.resolve()
} else {
// These should never be nullish, as the effect should immediately navigate away.
return resetPassword(email ?? '', verificationCode ?? '', newPassword)
}
}
}, [defaultEmail, navigate, defaultVerificationCode, getText, toastAndLog])
return (
<AuthenticationPage
supportsOffline={supportsOffline}
title={getText('resetYourPassword')}
footer={<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('goBackToLogin')} />}
onSubmit={async (event) => {
event.preventDefault()
await doSubmit()
}}
schema={createResetPasswordFormSchema(getText)}
footer={
<Link
to={`${LOGIN_PATH}?${new URLSearchParams({ email: defaultEmail ?? '' }).toString()}`}
icon={GoBackIcon}
text={getText('goBackToLogin')}
/>
}
onSubmit={({ email, verificationCode, newPassword }) =>
resetPassword(email, verificationCode, newPassword)
}
>
<aria.Input
<Input
required
readOnly
hidden
data-testid="email-input"
name="email"
type="email"
autoComplete="email"
placeholder={getText('emailPlaceholder')}
value={email ?? ''}
value={defaultEmail ?? ''}
/>
<aria.Input
<Input
required
readOnly
hidden
data-testid="verification-code-input"
name="verificationCode"
type="text"
autoComplete="one-time-code"
placeholder={getText('confirmationCodePlaceholder')}
value={verificationCode ?? ''}
value={defaultVerificationCode ?? ''}
/>
<Input
<Password
autoFocus
required
validate
allowShowingPassword
type="password"
data-testid="new-password-input"
name="newPassword"
label={getText('newPasswordLabel')}
autoComplete="new-password"
icon={LockIcon}
placeholder={getText('newPasswordPlaceholder')}
pattern={validation.PASSWORD_PATTERN}
error={getText('passwordValidationError')}
value={newPassword}
setValue={setNewPassword}
description={getText('passwordValidationMessage')}
/>
<Input
<Password
required
validate
allowShowingPassword
type="password"
data-testid="confirm-new-password-input"
name="confirmNewPassword"
label={getText('confirmNewPasswordLabel')}
autoComplete="new-password"
icon={LockIcon}
placeholder={getText('confirmNewPasswordPlaceholder')}
pattern={string.regexEscape(newPassword)}
error={getText('passwordMismatchError')}
value={newPasswordConfirm}
setValue={setNewPasswordConfirm}
/>
<SubmitButton text={getText('reset')} icon={ArrowRightIcon} />
<Form.FormError />
<Form.Submit size="large" icon={ArrowRightIcon} className="w-full">
{getText('reset')}
</Form.Submit>
</AuthenticationPage>
)
}

View File

@ -0,0 +1,32 @@
/**
* @file
*
* This file contains common schemas for authentication.
*/
import type { GetText } from '#/providers/TextProvider'
import { PASSWORD_REGEX } from '#/utilities/validation'
import { z } from 'zod'
/**
* A schema for validating passwords.
*/
export function passwordSchema(getText: GetText) {
return (
z
.string()
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
.min(6, { message: getText('passwordLengthError') })
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
.max(256, { message: getText('passwordLengthError') })
)
}
/**
* A schema for validating passwords that match the required pattern.
*/
export function passwordWithPatternSchema(getText: GetText) {
return passwordSchema(getText).refine(
(password) => PASSWORD_REGEX.test(password),
getText('passwordValidationError'),
)
}

View File

@ -86,20 +86,16 @@ export type UserSession = FullUserSession | PartialUserSession
*
* See `Cognito` for details on each of the authentication functions. */
interface AuthContextType {
readonly signUp: (
email: string,
password: string,
organizationId: string | null,
) => Promise<boolean>
readonly signUp: (email: string, password: string, organizationId: string | null) => Promise<void>
readonly authQueryKey: reactQuery.QueryKey
readonly confirmSignUp: (email: string, code: string) => Promise<boolean>
readonly setUsername: (username: string) => Promise<boolean>
readonly signInWithGoogle: () => Promise<boolean>
readonly signInWithGitHub: () => Promise<boolean>
readonly signInWithPassword: (email: string, password: string) => Promise<boolean>
readonly forgotPassword: (email: string) => Promise<boolean>
readonly signInWithPassword: (email: string, password: string) => Promise<void>
readonly forgotPassword: (email: string) => Promise<void>
readonly changePassword: (oldPassword: string, newPassword: string) => Promise<boolean>
readonly resetPassword: (email: string, code: string, password: string) => Promise<boolean>
readonly resetPassword: (email: string, code: string, password: string) => Promise<void>
readonly signOut: () => Promise<void>
/**
* @deprecated Never use this function. Prefer particular functions like `setUsername` or `deleteUser`.
@ -293,18 +289,15 @@ export default function AuthProvider(props: AuthProviderProps) {
const signUp = useEventCallback(
async (username: string, password: string, organizationId: string | null) => {
if (cognito == null) {
return false
} else {
if (cognito != null) {
gtagEvent('cloud_sign_up')
const result = await cognito.signUp(username, password, organizationId)
if (result.ok) {
toastSuccess(getText('signUpSuccess'))
navigate(appUtils.LOGIN_PATH)
} else {
toastError(result.val.message)
// eslint-disable-next-line no-restricted-syntax
throw new Error(result.val.message)
}
return result.ok
}
},
)
@ -337,23 +330,16 @@ export default function AuthProvider(props: AuthProviderProps) {
})
const signInWithPassword = useEventCallback(async (email: string, password: string) => {
if (cognito == null) {
return false
} else {
if (cognito != null) {
gtagEvent('cloud_sign_in', { provider: 'Email' })
const result = await cognito.signInWithPassword(email, password)
if (result.ok) {
toastSuccess(getText('signInWithPasswordSuccess'))
void queryClient.invalidateQueries({ queryKey: sessionQueryKey })
navigate(appUtils.DASHBOARD_PATH)
return
} else {
if (result.val.type === cognitoModule.CognitoErrorType.userNotFound) {
// It may not be safe to pass the user's password in the URL.
navigate(`${appUtils.REGISTRATION_PATH}?${new URLSearchParams({ email }).toString()}`)
}
toastError(result.val.message)
throw new Error(result.val.message)
}
return result.ok
}
})
@ -422,32 +408,26 @@ export default function AuthProvider(props: AuthProviderProps) {
})
const forgotPassword = useEventCallback(async (email: string) => {
if (cognito == null) {
return false
} else {
if (cognito != null) {
const result = await cognito.forgotPassword(email)
if (result.ok) {
toastSuccess(getText('forgotPasswordSuccess'))
navigate(appUtils.LOGIN_PATH)
return
} else {
toastError(result.val.message)
throw new Error(result.val.message)
}
return result.ok
}
})
const resetPassword = useEventCallback(async (email: string, code: string, password: string) => {
if (cognito == null) {
return false
} else {
if (cognito != null) {
const result = await cognito.forgotPasswordSubmit(email, code, password)
if (result.ok) {
toastSuccess(getText('resetPasswordSuccess'))
navigate(appUtils.LOGIN_PATH)
return
} else {
toastError(result.val.message)
throw new Error(result.val.message)
}
return result.ok
}
})
@ -521,7 +501,7 @@ export default function AuthProvider(props: AuthProviderProps) {
}, [userData, onAuthenticated])
const value: AuthContextType = {
signUp: withLoadingToast(signUp),
signUp,
confirmSignUp: withLoadingToast(confirmSignUp),
setUsername,
isUserMarkedForDeletion,
@ -557,9 +537,9 @@ export default function AuthProvider(props: AuthProviderProps) {
)
}
}),
signInWithPassword: signInWithPassword,
forgotPassword: withLoadingToast(forgotPassword),
resetPassword: withLoadingToast(resetPassword),
signInWithPassword,
forgotPassword,
resetPassword,
changePassword: withLoadingToast(changePassword),
refetchSession: usersMeQuery.refetch,
session: userData,

View File

@ -290,7 +290,7 @@
/* The gap between the header and contents of a section in a settings page. */
--settings-section-header-gap: 0.625rem;
/* The gap between the label and value of a settings entry. */
--settings-entry-gap: 1.1875rem;
--settings-entry-gap: 1.25rem;
--settings-sidebar-width: 12.875rem;
/* The gap between each section in the settings sidebar. */
--settings-sidebar-gap: 1rem;

View File

@ -1,7 +1,7 @@
/** @file `tailwind-variants` with a custom configuration. */
import * as tailwindVariants from 'tailwind-variants'
import { createTV } from 'tailwind-variants'
import * as tailwindMerge from '#/utilities/tailwindMerge'
import { TAILWIND_MERGE_CONFIG } from '#/utilities/tailwindMerge'
export * from 'tailwind-variants'
@ -11,4 +11,11 @@ export * from 'tailwind-variants'
// This is a function, even though it does not contain function syntax.
// eslint-disable-next-line no-restricted-syntax
export const tv = tailwindVariants.createTV({ twMergeConfig: tailwindMerge.TAILWIND_MERGE_CONFIG })
export const tv = createTV({ twMergeConfig: TAILWIND_MERGE_CONFIG })
/** Extract function signatures from a type. */
export type ExtractFunction<T> =
T extends (...args: infer Args) => infer Ret ? (...args: Args) => Ret : never
/** A `tailwind-variants` type, without restrictions onn the `extends` key. */
export type TVWithoutExtends<T> = ExtractFunction<T> & Omit<T, 'extend'>

View File

@ -5,6 +5,7 @@
"e2e",
"../types",
"./src/**/*.json",
"./e2e/**/*.json",
"../../utils.ts",
".prettierrc.cjs",
"*.js",

View File

@ -57,7 +57,13 @@ export async function dragNodeByBinding(page: Page, nodeBinding: string, x: numb
}
/// Move mouse away to avoid random hover events and wait for any circular menus to disappear.
export async function ensureNoCircularMenusVisible(page: Page) {
export async function ensureNoCircularMenusVisibleDueToHovering(page: Page) {
await page.mouse.move(-1000, 0)
await expect(locate.circularMenu(page)).toBeHidden()
}
/// Ensure no nodes are selected.
export async function deselectNodes(page: Page) {
await page.mouse.click(0, 0)
await expect(locate.selectedNodes(page)).toHaveCount(0)
}

View File

@ -86,7 +86,7 @@ test('Collapsing nodes', async ({ page }) => {
await mockCollapsedFunctionInfo(page, 'prod', 'collapsed')
await locate.graphNodeIcon(collapsedNode).dblclick()
await actions.ensureNoCircularMenusVisible(page)
await actions.ensureNoCircularMenusVisibleDueToHovering(page)
await expect(locate.graphNode(page)).toHaveCount(4)
await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
await expect(locate.graphNodeByBinding(page, 'sum')).toExist()

View File

@ -285,6 +285,8 @@ test('Component browser handling of overridden record-mode', async ({ page }) =>
await locate.graphNodeIcon(node).hover()
await expect(recordModeToggle).toHaveClass(/toggledOff/)
await recordModeToggle.click()
await expect(recordModeToggle).toHaveClass(/toggledOn/)
await page.keyboard.press('Escape')
// TODO[ao]: The simple move near top-left corner not always works i.e. not always
// `pointerleave` event is emitted. Investigated in https://github.com/enso-org/enso/issues/9478
// once fixed, remember to change the second `await page.mouse.move(700, 1200, { steps: 20 })`

View File

@ -39,6 +39,7 @@ test('Existence of edges between nodes', async ({ page }) => {
async function initGraph(page: Page) {
await actions.goToGraph(page)
await actions.dragNodeByBinding(page, 'ten', 400, 0)
await actions.deselectNodes(page)
}
/**

View File

@ -45,7 +45,7 @@ function or(a: (page: Locator | Page) => Locator, b: (page: Locator | Page) => L
}
export function toggleVisualizationButton(page: Locator | Page) {
return page.getByLabel('Visualization')
return page.getByLabel('Visualization', { exact: true })
}
export function toggleVisualizationSelectorButton(page: Locator | Page) {

View File

@ -18,6 +18,8 @@ async function assertTypeLabelOnNode(
const targetLabel = node.locator('.node-type').first()
await expect(targetLabel).toHaveText(type.short)
await expect(targetLabel).toHaveAttribute('title', type.full)
await locate.toggleVisualizationButton(node).click()
await actions.deselectNodes(page)
}
async function assertTypeLabelOnNodeByBinding(

View File

@ -533,3 +533,65 @@ test('Autoscoped constructors', async ({ page }) => {
await expect(groupBy).toBeVisible()
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
})
test('Table widget', async ({ page }) => {
await actions.goToGraph(page)
// Adding `Table.new` component will display the widget
await locate.addNewNodeButton(page).click()
await expect(locate.componentBrowser(page)).toBeVisible()
await page.keyboard.type('Table.new')
// Wait for CB entry to appear; this way we're sure about node name (binding).
await expect(locate.componentBrowserSelectedEntry(page)).toHaveCount(1)
await expect(locate.componentBrowserSelectedEntry(page)).toHaveText('Table.new')
await page.keyboard.press('Enter')
const node = locate.selectedNodes(page)
await expect(node).toHaveCount(1)
await expect(node).toBeVisible()
await mockMethodCallInfo(
page,
{ binding: 'table1', expr: 'Table.new' },
{
methodPointer: {
module: 'Standard.Table.Table',
definedOnType: 'Standard.Table.Table.Table',
name: 'new',
},
notAppliedArguments: [0],
},
)
const widget = node.locator('.WidgetTableEditor')
await expect(widget).toBeVisible()
await expect(widget.locator('.ag-header-cell-text')).toHaveText('New Column')
await expect(widget.locator('.ag-header-cell-text')).toHaveClass(/(?<=^| )virtualColumn(?=$| )/)
// There's one empty cell, allowing creating first row and column
await expect(widget.locator('.ag-cell')).toHaveCount(1)
// Putting first value
await widget.locator('.ag-cell').dblclick()
await page.keyboard.type('Value')
await page.keyboard.press('Enter')
// There will be new blank column and new blank row allowing adding new columns and rows
// (so 4 cells in total)
await expect(widget.locator('.ag-header-cell-text')).toHaveText(['New Column', 'New Column'])
await expect(widget.locator('.ag-cell')).toHaveText(['Value', '', '', ''])
// Renaming column
await widget.locator('.ag-header-cell-text').first().dblclick()
await page.keyboard.type('Header')
await page.keyboard.press('Enter')
await expect(widget.locator('.ag-header-cell-text')).toHaveText(['Header', 'New Column'])
// Switching edit between cells and headers - check we will never edit two things at once.
await expect(widget.locator('.ag-text-field-input')).toHaveCount(0)
await widget.locator('.ag-header-cell-text').first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-cell').first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-header-cell-text').first().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await widget.locator('.ag-header-cell-text').last().dblclick()
await expect(widget.locator('.ag-text-field-input')).toHaveCount(1)
await page.keyboard.press('Escape')
await expect(widget.locator('.ag-text-field-input')).toHaveCount(0)
})

View File

@ -8,7 +8,7 @@
"email": "contact@enso.org"
},
"scripts": {
"dev": "echo DEPRECATED! Use `pnpm dev:gui` in workspace root directory.",
"dev": "echo DEPRECATED! Use `pnpm -w dev:gui` instead.",
"dev:vite": "vite",
"build": "corepack pnpm -r --filter enso-dashboard run compile && corepack pnpm run build:vite",
"build-cloud": "cross-env CLOUD_BUILD=true corepack pnpm run build",
@ -67,6 +67,7 @@
"@vueuse/core": "^10.4.1",
"ag-grid-community": "^30.2.1",
"ag-grid-enterprise": "^30.2.1",
"ag-grid-vue3": "^30.2.1",
"codemirror": "^6.0.1",
"culori": "^3.2.0",
"enso-dashboard": "workspace:*",
@ -85,6 +86,7 @@
"sucrase": "^3.34.0",
"veaury": "^2.3.18",
"vue": "^3.4.19",
"vue-component-type-helpers": "^2.0.29",
"y-codemirror.next": "^0.3.2",
"y-protocols": "^1.0.5",
"y-textarea": "^1.0.0",

View File

@ -86,4 +86,5 @@
--visualization-resize-handle-outside: 3px;
--right-dock-default-width: 40%;
--code-editor-default-height: 30%;
--scrollbar-scrollable-opacity: 100%;
}

View File

@ -1,8 +1,6 @@
<script setup lang="ts">
import { graphBindings } from '@/bindings'
import ColorRing from '@/components/ColorRing.vue'
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
import SmallPlusButton from '@/components/SmallPlusButton.vue'
import SvgButton from '@/components/SvgButton.vue'
import ToggleIcon from '@/components/ToggleIcon.vue'
import { ref } from 'vue'
@ -11,7 +9,6 @@ const nodeColor = defineModel<string | undefined>('nodeColor')
const props = defineProps<{
isRecordingEnabledGlobally: boolean
isRecordingOverridden: boolean
isDocsVisible: boolean
isVisualizationEnabled: boolean
isFullMenuVisible: boolean
isRemovable: boolean
@ -20,13 +17,11 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
'update:isRecordingOverridden': [isRecordingOverridden: boolean]
'update:isDocsVisible': [isDocsVisible: boolean]
'update:isVisualizationEnabled': [isVisualizationEnabled: boolean]
startEditing: []
startEditingComment: []
openFullMenu: []
delete: []
createNodes: [options: NodeCreationOptions[]]
}>()
const showColorPicker = ref(false)
@ -108,11 +103,6 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
@close="showColorPicker = false"
/>
</div>
<SmallPlusButton
v-if="!isVisualizationEnabled"
class="below-slot5"
@createNodes="emit('createNodes', $event)"
/>
</div>
</template>
@ -249,12 +239,6 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
top: 80px;
}
.below-slot5 {
position: absolute;
top: calc(var(--outer-diameter) - 64px);
pointer-events: all;
}
.slot6 {
position: absolute;
top: 69.46px;

View File

@ -644,12 +644,6 @@ provideNodeColors(graphStore, (variable) =>
const showColorPicker = ref(false)
function setSelectedNodesColor(color: string | undefined) {
graphStore.transact(() =>
nodeSelection.selected.forEach((id) => graphStore.overrideNodeColor(id, color)),
)
}
const groupColors = computed(() => {
const styles: { [key: string]: string } = {}
for (let group of suggestionDb.groups) {
@ -681,7 +675,6 @@ const groupColors = computed(() => {
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
@nodeDoubleClick="(id) => stackNavigator.enterNode(id)"
@createNodes="createNodesFromSource"
@setNodeColor="setSelectedNodesColor"
/>
<GraphEdges :navigator="graphNavigator" @createNodeFromEdge="handleEdgeDrop" />
<ComponentBrowser

View File

@ -209,10 +209,25 @@ watch(menuVisible, (visible) => {
function openFullMenu() {
menuFull.value = true
setSelected()
}
function setSelected() {
nodeSelection?.setSelection(new Set([nodeId.value]))
}
const isDocsVisible = ref(false)
function onAnyClick(e: MouseEvent) {
if (isUnmodifiedPrimaryButtonClick(e)) {
setSelected()
}
}
function isUnmodifiedPrimaryButtonClick(e: MouseEvent) {
const isModified = e.ctrlKey || e.altKey || e.shiftKey || e.metaKey
const isPrimaryButton = e.button === 0
return isPrimaryButton && !isModified
}
const outputHovered = ref(false)
const keyboard = injectKeyboard()
const visualizationWidth = computed(() => props.node.vis?.width ?? null)
@ -414,6 +429,7 @@ watchEffect(() => {
@pointerenter="(nodeHovered = true), updateNodeHover($event)"
@pointerleave="(nodeHovered = false), updateNodeHover(undefined)"
@pointermove="updateNodeHover"
@click.capture="onAnyClick"
>
<Teleport v-if="navigator && !edited" :to="graphNodeSelections">
<GraphNodeSelection
@ -444,7 +460,6 @@ watchEffect(() => {
<CircularMenu
v-if="menuVisible"
v-model:isRecordingOverridden="isRecordingOverridden"
v-model:isDocsVisible="isDocsVisible"
:isRecordingEnabledGlobally="projectStore.isRecordingEnabled"
:isVisualizationEnabled="isVisualizationEnabled"
:isFullMenuVisible="menuVisible && menuFull"
@ -457,7 +472,6 @@ watchEffect(() => {
@startEditingComment="editingComment = true"
@openFullMenu="openFullMenu"
@delete="emit('delete')"
@createNodes="emit('createNodes', $event)"
@pointerenter="menuHovered = true"
@pointerleave="menuHovered = false"
@update:nodeColor="emit('setNodeColor', $event)"
@ -532,8 +546,8 @@ watchEffect(() => {
/>
</svg>
<SmallPlusButton
v-if="menuVisible && isVisualizationVisible"
class="afterNode"
v-if="menuVisible"
:class="isVisualizationVisible ? 'afterNode' : 'belowMenu'"
@createNodes="emit('createNodes', $event)"
/>
</div>
@ -638,6 +652,11 @@ watchEffect(() => {
transform: translateY(var(--viz-below-node));
}
.belowMenu {
position: absolute;
top: calc(100% + 40px);
}
.messageWithMenu {
left: 40px;
}

View File

@ -19,7 +19,6 @@ const emit = defineEmits<{
nodeOutputPortDoubleClick: [portId: AstId]
nodeDoubleClick: [nodeId: NodeId]
createNodes: [source: NodeId, options: NodeCreationOptions[]]
setNodeColor: [color: string | undefined]
}>()
const projectStore = useProjectStore()
@ -75,7 +74,7 @@ const graphNodeSelections = shallowRef<HTMLElement>()
@outputPortDoubleClick="(_event, port) => emit('nodeOutputPortDoubleClick', port)"
@doubleClick="emit('nodeDoubleClick', id)"
@createNodes="emit('createNodes', id, $event)"
@setNodeColor="emit('setNodeColor', $event)"
@setNodeColor="graphStore.overrideNodeColor(id, $event)"
@update:edited="graphStore.setEditedNode(id, $event)"
@update:rect="graphStore.updateNodeRect(id, $event)"
@update:hoverAnim="graphStore.updateNodeHoverAnim(id, $event)"

View File

@ -1,13 +1,131 @@
<script setup lang="ts">
import { WidgetInputIsSpecificMethodCall } from '@/components/GraphEditor/widgets/WidgetFunction.vue'
import TableHeader from '@/components/GraphEditor/widgets/WidgetTableEditor/TableHeader.vue'
import {
tableNewCallMayBeHandled,
useTableNewArgument,
type RowData,
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
import ResizeHandles from '@/components/ResizeHandles.vue'
import AgGridTableView from '@/components/widgets/AgGridTableView.vue'
import { injectGraphNavigator } from '@/providers/graphNavigator'
import { Score, defineWidget, widgetProps } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore } from '@/stores/graph'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type { CellEditingStartedEvent, CellEditingStoppedEvent } from 'ag-grid-community'
import type { Column } from 'ag-grid-enterprise'
import { computed, ref } from 'vue'
import { WidgetInputIsSpecificMethodCall } from './WidgetFunction.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
const size = ref(new Vec2(200, 50))
const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore()
const suggestionDb = useSuggestionDbStore()
const grid = ref<ComponentExposed<typeof AgGridTableView<RowData, any>>>()
const { rowData, columnDefs } = useTableNewArgument(
() => props.input,
graph,
suggestionDb.entries,
props.onUpdate,
)
// === Edit Handlers ===
class CellEditing {
handler: WidgetEditHandler
editedCell: { rowIndex: number; colKey: Column<RowData> } | undefined
supressNextStopEditEvent: boolean = false
constructor() {
this.handler = WidgetEditHandler.New('WidgetTableEditor.cellEditHandler', props.input, {
cancel() {
grid.value?.gridApi?.stopEditing(true)
},
end() {
grid.value?.gridApi?.stopEditing(false)
},
suspend: () => {
return {
resume: () => {
this.editedCell && grid.value?.gridApi?.startEditingCell(this.editedCell)
},
}
},
})
}
cellEditedInGrid(event: CellEditingStartedEvent) {
this.editedCell =
event.rowIndex != null ? { rowIndex: event.rowIndex, colKey: event.column } : undefined
if (!this.handler.isActive()) {
this.handler.start()
}
}
cellEditingStoppedInGrid(event: CellEditingStoppedEvent) {
if (!this.handler.isActive()) return
if (this.supressNextStopEditEvent && this.editedCell) {
this.supressNextStopEditEvent = false
// If row data changed, the editing will be stopped, but we want to continue it.
grid.value?.gridApi?.startEditingCell(this.editedCell)
} else {
this.handler.end()
}
}
rowDataChanged() {
if (this.handler.isActive()) {
this.supressNextStopEditEvent = true
}
}
}
const cellEditHandler = new CellEditing()
class HeaderEditing {
handler: WidgetEditHandler
stopEditingCallback: ((cancel: boolean) => void) | undefined
constructor() {
this.handler = WidgetEditHandler.New('WidgetTableEditor.headerEditHandler', props.input, {
cancel: () => {
this.stopEditingCallback?.(true)
},
end: () => {
this.stopEditingCallback?.(false)
},
})
}
headerEditedInGrid(stopCb: (cancel: boolean) => void) {
// If another header is edited, stop it (with the old callback).
if (this.handler.isActive()) {
this.stopEditingCallback?.(false)
}
this.stopEditingCallback = stopCb
if (!this.handler.isActive()) {
this.handler.start()
}
}
headerEditingStoppedInGrid() {
this.stopEditingCallback = undefined
if (this.handler.isActive()) {
this.handler.end()
}
}
}
const headerEditHandler = new HeaderEditing()
// === Resizing ===
const size = ref(new Vec2(200, 150))
const graphNav = injectGraphNavigator()
const clientBounds = computed({
@ -26,7 +144,16 @@ const widgetStyle = computed(() => {
}
})
const _props = defineProps(widgetProps(widgetDefinition))
// === Column Default Definition ===
const defaultColDef = {
editable: true,
resizable: true,
headerComponentParams: {
onHeaderEditingStarted: headerEditHandler.headerEditedInGrid.bind(headerEditHandler),
onHeaderEditingStopped: headerEditHandler.headerEditingStoppedInGrid.bind(headerEditHandler),
},
}
</script>
<script lang="ts">
@ -38,9 +165,10 @@ export const widgetDefinition = defineWidget(
}),
{
priority: 999,
// TODO[#10293]: This widget is not yet fully implemented, so it is temporarily disabled.
// Change this to `Score.Perfect` or implement appropriate score method as part of next task.
score: Score.Mismatch,
score: (props) => {
if (!tableNewCallMayBeHandled(props.input.value)) return Score.Mismatch
return Score.Perfect
},
},
import.meta.hot,
)
@ -48,18 +176,40 @@ export const widgetDefinition = defineWidget(
<template>
<div class="WidgetTableEditor" :style="widgetStyle">
<div>WidgetTableEditor</div>
<Suspense>
<AgGridTableView
ref="grid"
class="grid"
:defaultColDef="defaultColDef"
:columnDefs="columnDefs"
:rowData="rowData"
:getRowId="(row) => `${row.data.index}`"
:components="{ agColumnHeader: TableHeader }"
:singleClickEdit="true"
:stopEditingWhenCellsLoseFocus="true"
@keydown.enter.stop
@cellEditingStarted="cellEditHandler.cellEditedInGrid($event)"
@cellEditingStopped="cellEditHandler.cellEditingStoppedInGrid($event)"
@rowDataUpdated="cellEditHandler.rowDataChanged()"
@pointerdown.stop
@click.stop
/>
</Suspense>
<ResizeHandles v-model="clientBounds" bottom right />
</div>
</template>
<style scoped>
.WidgetTableEditor {
color: yellow;
display: flex;
align-items: center;
justify-content: center;
background: #00ff0055;
border-radius: var(--node-port-border-radius);
position: relative;
}
.grid {
width: 100%;
height: 100%;
}
</style>

View File

@ -0,0 +1,87 @@
<script lang="ts">
import type { IHeaderParams } from 'ag-grid-community'
import { ref, watch } from 'vue'
/** Parameters recognized by this header component.
*
* They are set through `headerComponentParams` option in AGGrid column definition.
*/
export interface HeaderParams {
/** Setter called when column name is changed by the user. */
nameSetter?: (newName: string) => void
/** Column is virtual if it is not represented in the AST. Such column might be used
* to create new one.
*/
virtualColumn?: boolean
onHeaderEditingStarted?: (stop: (cancel: boolean) => void) => void
onHeaderEditingStopped?: () => void
}
</script>
<script setup lang="ts">
const props = defineProps<{
params: IHeaderParams & HeaderParams
}>()
const editing = ref(false)
const inputElement = ref<HTMLInputElement>()
watch(editing, (newVal, oldVal) => {
if (newVal) {
props.params.onHeaderEditingStarted?.((cancel: boolean) => {
if (cancel) editing.value = false
else acceptNewName()
})
} else {
props.params.onHeaderEditingStopped?.()
}
})
watch(inputElement, (newVal, oldVal) => {
if (newVal != null && oldVal == null) {
// Whenever input field appears, focus and select text
newVal.focus()
newVal.select()
}
})
function acceptNewName() {
if (inputElement.value == null) {
console.error('Tried to accept header new name without input element!')
return
}
props.params.nameSetter?.(inputElement.value.value)
editing.value = false
}
</script>
<template>
<div class="ag-cell-label-container" role="presentation" @pointerdown.stop @click.stop>
<div class="ag-header-cell-label" role="presentation">
<input
v-if="editing"
ref="inputElement"
class="ag-input-field-input ag-text-field-input"
:value="params.displayName"
@change="acceptNewName()"
@keydown.arrow-left.stop
@keydown.arrow-right.stop
@keydown.arrow-up.stop
@keydown.arrow-down.stop
/>
<span
v-else
class="ag-header-cell-text"
:class="{ virtualColumn: params.virtualColumn === true }"
@click="editing = params.nameSetter != null"
>{{ params.displayName }}</span
>
</div>
</div>
</template>
<style>
.virtualColumn {
color: rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,241 @@
import {
tableNewCallMayBeHandled,
useTableNewArgument,
} from '@/components/GraphEditor/widgets/WidgetTableEditor/tableNewArgument'
import { WidgetInput } from '@/providers/widgetRegistry'
import { SuggestionDb } from '@/stores/suggestionDatabase'
import { makeType } from '@/stores/suggestionDatabase/entry'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { expect, test, vi } from 'vitest'
function suggestionDbWithNothing() {
const db = new SuggestionDb()
db.set(1, makeType('Standard.Base.Nothing.Nothing'))
return db
}
test.each([
{
code: 'Table.new [["a", [1, 2, 3]], ["b", [4, 5, "six"]], ["empty", [Nothing, Standard.Base.Nothing, Nothing]]]',
expectedColumnDefs: [
{ headerName: 'a' },
{ headerName: 'b' },
{ headerName: 'empty' },
{ headerName: 'New Column' },
],
expectedRows: [
{ a: 1, b: 4, empty: null, 'New Column': null },
{ a: 2, b: 5, empty: null, 'New Column': null },
{ a: 3, b: 'six', empty: null, 'New Column': null },
{ a: null, b: null, empty: null, 'New Column': null },
],
},
{
code: 'Table.new []',
expectedColumnDefs: [{ headerName: 'New Column' }],
expectedRows: [{ 'New Column': null }],
},
{
code: 'Table.new',
expectedColumnDefs: [{ headerName: 'New Column' }],
expectedRows: [{ 'New Column': null }],
},
{
code: 'Table.new _',
expectedColumnDefs: [{ headerName: 'New Column' }],
expectedRows: [{ 'New Column': null }],
},
{
code: 'Table.new [["a", []]]',
expectedColumnDefs: [{ headerName: 'a' }, { headerName: 'New Column' }],
expectedRows: [{ a: null, 'New Column': null }],
},
{
code: 'Table.new [["a", [1,,2]], ["b", [3, 4,]], ["c", [, 5, 6]], ["d", [,,]]]',
expectedColumnDefs: [
{ headerName: 'a' },
{ headerName: 'b' },
{ headerName: 'c' },
{ headerName: 'd' },
{ headerName: 'New Column' },
],
expectedRows: [
{ a: 1, b: 3, c: null, d: null, 'New Column': null },
{ a: null, b: 4, c: 5, d: null, 'New Column': null },
{ a: 2, b: null, c: 6, d: null, 'New Column': null },
{ a: null, b: null, c: null, d: null, 'New Column': null },
],
},
])('Reading table from $code', ({ code, expectedColumnDefs, expectedRows }) => {
const ast = Ast.parse(code)
expect(tableNewCallMayBeHandled(ast)).toBeTruthy()
const input = WidgetInput.FromAst(ast)
const startEdit = vi.fn()
const addMissingImports = vi.fn()
const onUpdate = vi.fn()
const tableNewArgs = useTableNewArgument(
input,
{ startEdit, addMissingImports },
suggestionDbWithNothing(),
onUpdate,
)
expect(tableNewArgs.columnDefs.value).toEqual(
Array.from(expectedColumnDefs, (colDef) => expect.objectContaining(colDef)),
)
const resolvedRow = Array.from(tableNewArgs.rowData.value, (row) =>
Object.fromEntries(
tableNewArgs.columnDefs.value.map((col) => [col.headerName, col.valueGetter({ data: row })]),
),
)
expect(resolvedRow).toEqual(expectedRows)
function* expectedIndices() {
for (let i = 0; i < expectedRows.length; ++i) {
yield expect.objectContaining({ index: i })
}
}
expect(tableNewArgs.rowData.value).toEqual([...expectedIndices()])
expect(startEdit).not.toHaveBeenCalled()
expect(onUpdate).not.toHaveBeenCalled()
expect(addMissingImports).not.toHaveBeenCalled()
})
test.each([
'Table.new 14',
'Table.new array1',
"Table.new ['a', [123]]",
"Table.new [['a', [123]], ['b', [124], []]]",
"Table.new [['a', [123]], ['a'.repeat 170, [123]]]",
"Table.new [['a', [1, 2, 3, 3 + 1]]]",
])('"%s" is not valid input for Table Editor Widget', (code) => {
const ast = Ast.parse(code)
expect(tableNewCallMayBeHandled(ast)).toBeFalsy()
})
test.each([
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Editing number',
edit: { column: 0, row: 1, value: -22 },
expected: "Table.new [['a', [1, -22, 3]], ['b', [4, 5, 6]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Editing string',
edit: { column: 0, row: 1, value: 'two' },
expected: "Table.new [['a', [1, 'two', 3]], ['b', [4, 5, 6]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Putting blank value',
edit: { column: 1, row: 1, value: '' },
expected: "Table.new [['a', [1, 2, 3]], ['b', [4, Nothing, 6]]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Adding new column',
edit: { column: 2, row: 1, value: 8 },
expected:
"Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]], ['New Column', [Nothing, 8, Nothing]]]",
importExpected: true,
},
{
code: 'Table.new []',
description: 'Adding first column',
edit: { column: 0, row: 0, value: 8 },
expected: "Table.new [['New Column', [8]]]",
},
{
code: 'Table.new',
description: 'Adding parameter',
edit: { column: 0, row: 0, value: 8 },
expected: "Table.new [['New Column', [8]]]",
},
{
code: 'Table.new _',
description: 'Update parameter',
edit: { column: 0, row: 0, value: 8 },
expected: "Table.new [['New Column', [8]]]",
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Adding new row',
edit: { column: 0, row: 3, value: 4.5 },
expected: "Table.new [['a', [1, 2, 3, 4.5]], ['b', [4, 5, 6, Nothing]]]",
importExpected: true,
},
{
code: "Table.new [['a', []], ['b', []]]",
description: 'Adding first row',
edit: { column: 1, row: 0, value: 'val' },
expected: "Table.new [['a', [Nothing]], ['b', ['val']]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, 2, 3]], ['b', [4, 5, 6]]]",
description: 'Adding new row and column (the cell in the corner)',
edit: { column: 2, row: 3, value: 7 },
expected:
"Table.new [['a', [1, 2, 3, Nothing]], ['b', [4, 5, 6, Nothing]], ['New Column', [Nothing, Nothing, Nothing, 7]]]",
importExpected: true,
},
{
code: "Table.new [['a', [1, ,3]]]",
description: 'Setting missing value',
edit: { column: 0, row: 1, value: 2 },
expected: "Table.new [['a', [1, 2 ,3]]]",
},
{
code: "Table.new [['a', [, 2, 3]]]",
description: 'Setting missing value at first row',
edit: { column: 0, row: 0, value: 1 },
expected: "Table.new [['a', [1, 2, 3]]]",
},
{
code: "Table.new [['a', [1, 2,]]]",
description: 'Setting missing value at last row',
edit: { column: 0, row: 2, value: 3 },
expected: "Table.new [['a', [1, 2, 3]]]",
},
{
code: "Table.new [['a', [1, 2]], ['a', [3, 4]]]",
description: 'Editing with duplicated column name',
edit: { column: 0, row: 1, value: 5 },
expected: "Table.new [['a', [1, 5]], ['a', [3, 4]]]",
},
])('Editing table $code: $description', ({ code, edit, expected, importExpected }) => {
const ast = Ast.parseBlock(code)
const inputAst = [...ast.statements()][0]
assert(inputAst != null)
const input = WidgetInput.FromAst(inputAst)
const onUpdate = vi.fn((update) => {
const inputAst = [...update.edit.getVersion(ast).statements()][0]
expect(inputAst?.code()).toBe(expected)
})
const addMissingImports = vi.fn((_, imports) => {
expect(imports).toEqual([
{
kind: 'Unqualified',
from: 'Standard.Base.Nothing',
import: 'Nothing',
},
])
})
const tableNewArgs = useTableNewArgument(
input,
{ startEdit: () => ast.module.edit(), addMissingImports },
suggestionDbWithNothing(),
onUpdate,
)
const editedRow = tableNewArgs.rowData.value[edit.row]
assert(editedRow != null)
tableNewArgs.columnDefs.value[edit.column]?.valueSetter?.({
data: editedRow,
newValue: edit.value,
})
expect(onUpdate).toHaveBeenCalledOnce()
if (importExpected) expect(addMissingImports).toHaveBeenCalled()
else expect(addMissingImports).not.toHaveBeenCalled()
})

View File

@ -0,0 +1,301 @@
import type { HeaderParams } from '@/components/GraphEditor/widgets/WidgetTableEditor/TableHeader.vue'
import type { WidgetInput, WidgetUpdate } from '@/providers/widgetRegistry'
import { requiredImportsByFQN, type RequiredImport } from '@/stores/graph/imports'
import type { SuggestionDb } from '@/stores/suggestionDatabase'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { tryEnsoToNumber, tryNumberToEnso } from '@/util/ast/abstract'
import { Err, Ok, transposeResult, unwrapOrWithLog, type Result } from '@/util/data/result'
import { qnLastSegment, type QualifiedName } from '@/util/qualifiedName'
import type { ToValue } from '@/util/reactivity'
import type { ColDef } from 'ag-grid-community'
import { computed, toValue } from 'vue'
const NEW_COLUMN_ID = 'NewColumn'
const NEW_COLUMN_HEADER = 'New Column'
const NOTHING_PATH = 'Standard.Base.Nothing.Nothing' as QualifiedName
const NOTHING_NAME = qnLastSegment(NOTHING_PATH)
export type RowData = {
index: number
/* Column id to given row's cell id. */
cells: Record<Ast.AstId, Ast.AstId>
}
/** A more specialized version of AGGrid's `ColDef` to simplify testing (the tests need to provide
* only values actually used by the composable) */
interface ColumnDef {
colId?: string
headerName: string
valueGetter: ({ data }: { data: RowData | undefined }) => any
valueSetter: ({ data, newValue }: { data: RowData; newValue: any }) => boolean
headerComponentParams?: HeaderParams
}
namespace cellValueConversion {
export function astToAgGrid(ast: Ast.Ast) {
if (ast instanceof Ast.TextLiteral) return Ok(ast.rawTextContent)
else if (ast instanceof Ast.Ident && ast.code() === NOTHING_NAME) return Ok(null)
else if (ast instanceof Ast.PropertyAccess && ast.rhs.code() === NOTHING_NAME) return Ok(null)
else {
const asNumber = tryEnsoToNumber(ast)
if (asNumber != null) return Ok(asNumber)
else return Err('Ast is not convertible to AGGrid value')
}
}
export function agGridToAst(
value: unknown,
module: Ast.MutableModule,
): { ast: Ast.Owned; requireNothingImport: boolean } {
if (value == null || value === '') {
return { ast: Ast.Ident.new(module, 'Nothing' as Ast.Identifier), requireNothingImport: true }
} else if (typeof value === 'number') {
return {
ast: tryNumberToEnso(value, module) ?? Ast.TextLiteral.new(`${value}`, module),
requireNothingImport: false,
}
} else {
return {
ast:
Ast.NumericLiteral.tryParseWithSign(`${value}`, module) ??
Ast.TextLiteral.new(`${value}`, module),
requireNothingImport: false,
}
}
}
}
function retrieveColumnsAst(call: Ast.Ast) {
if (!(call instanceof Ast.App)) return Ok(undefined)
if (call.argument instanceof Ast.Vector) return Ok(call.argument)
if (call.argument instanceof Ast.Wildcard) return Ok(undefined)
return Err('Expected Table.new argument to be a vector of columns or placeholder')
}
function readColumn(ast: Ast.Ast): Result<{ name: Ast.TextLiteral; data: Ast.Vector }> {
const errormsg = () => `${ast.code} is not a vector of two elements`
if (!(ast instanceof Ast.Vector)) return Err(errormsg())
const elements = ast.values()
const first = elements.next()
if (first.done) return Err(errormsg())
const second = elements.next()
if (second.done) return Err(errormsg())
if (!elements.next().done) return Err(errormsg())
if (!(first.value instanceof Ast.TextLiteral))
return Err(
`First element in column definition is ${first.value.code()} instead of a text literal`,
)
if (!(second.value instanceof Ast.Vector))
return Err(`Second element in column definition is ${second.value.code()} instead of a vector`)
return Ok({ name: first.value, data: second.value })
}
function retrieveColumnsDefinitions(columnsAst: Ast.Vector) {
return transposeResult(Array.from(columnsAst.values(), readColumn))
}
export function tableNewCallMayBeHandled(call: Ast.Ast) {
const columnsAst = retrieveColumnsAst(call)
if (!columnsAst.ok) return false
if (!columnsAst.value) return true // We can handle lack of the argument
const columns = retrieveColumnsDefinitions(columnsAst.value)
if (!columns.ok) return false
for (const col of columns.value) {
for (const val of col.data.values()) {
if (!cellValueConversion.astToAgGrid(val).ok) return false
}
}
return true
}
/**
* A composable responsible for interpreting `Table.new` expressions, creating AGGrid column
* definitions allowing also editing AST through AGGrid editing.
*
* @param input the widget's input
* @param graph the graph store
* @param onUpdate callback called when AGGrid was edited by user, resulting in AST change.
*/
export function useTableNewArgument(
input: ToValue<WidgetInput & { value: Ast.Ast }>,
graph: {
startEdit(): Ast.MutableModule
addMissingImports(edit: Ast.MutableModule, newImports: RequiredImport[]): void
},
suggestions: SuggestionDb,
onUpdate: (update: WidgetUpdate) => void,
) {
const errorMessagePreamble = 'Table Editor Widget should not have been matched'
const columnsAst = computed(() => retrieveColumnsAst(toValue(input).value))
const columns = computed(() => {
if (!columnsAst.value.ok) return []
if (columnsAst.value.value == null) return []
const cols = retrieveColumnsDefinitions(columnsAst.value.value)
return unwrapOrWithLog(cols, [], errorMessagePreamble)
})
const rowCount = computed(() =>
columns.value.reduce((soFar, col) => Math.max(soFar, col.data.length), 0),
)
const undersizedColumns = computed(() =>
columns.value.filter((col) => col.data.length < rowCount.value),
)
function fixColumns(edit: Ast.MutableModule) {
for (const column of undersizedColumns.value) {
const data = edit.getVersion(column.data)
while (data.length < rowCount.value) {
data.push(convertWithImport(null, edit))
}
while (data.length > rowCount.value) {
data.pop()
}
}
}
function addRow(edit: Ast.MutableModule, columnWithValue?: Ast.AstId, value?: unknown) {
for (const column of columns.value) {
const editedCol = edit.getVersion(column.data)
if (column.data.id === columnWithValue) {
editedCol.push(convertWithImport(value, edit))
} else {
editedCol.push(convertWithImport(null, edit))
}
}
}
function addColumn(
edit: Ast.MutableModule,
name: string,
rowWithValue?: number,
value?: unknown,
) {
const newColumnSize = Math.max(rowCount.value, rowWithValue != null ? rowWithValue + 1 : 0)
function* cellsGenerator() {
for (let i = 0; i < newColumnSize; ++i) {
if (i === rowWithValue) yield convertWithImport(value, edit)
else yield convertWithImport(null, edit)
}
}
const cells = Ast.Vector.new(edit, Array.from(cellsGenerator()))
const newCol = Ast.Vector.new(edit, [Ast.TextLiteral.new(name), cells])
const ast = unwrapOrWithLog(columnsAst.value, undefined, errorMessagePreamble)
if (ast) {
edit.getVersion(ast).push(newCol)
} else {
const inputAst = edit.getVersion(toValue(input).value)
const newArg = Ast.Vector.new(edit, [newCol])
if (inputAst instanceof Ast.MutableApp) {
inputAst.setArgument(newArg)
} else {
inputAst.updateValue((func) => Ast.App.new(edit, func, undefined, newArg))
}
}
}
const newColumnDef = computed<ColumnDef>(() => ({
colId: NEW_COLUMN_ID,
headerName: NEW_COLUMN_HEADER,
valueGetter: () => null,
valueSetter: ({ data, newValue }: { data: RowData; newValue: any }) => {
const edit = graph.startEdit()
if (data.index === rowCount.value) {
addRow(edit)
}
addColumn(edit, NEW_COLUMN_HEADER, data.index, newValue)
onUpdate({ edit })
return true
},
headerComponentParams: {
nameSetter: (newName: string) => {
const edit = graph.startEdit()
fixColumns(edit)
addColumn(edit, newName)
onUpdate({ edit })
},
virtualColumn: true,
},
}))
const columnDefs = computed(() => {
const cols: ColumnDef[] = Array.from(
columns.value,
(col) =>
({
colId: col.data.id,
headerName: col.name.rawTextContent,
valueGetter: ({ data }: { data: RowData | undefined }) => {
if (data == null) return undefined
const ast = toValue(input).value.module.tryGet(data.cells[col.data.id])
if (ast == null) return null
const value = cellValueConversion.astToAgGrid(ast)
if (!value.ok) {
console.error(
`Cannot read \`${ast.code}\` as value in Table Widget; the Table widget should not be matched here!`,
)
return null
}
return value.value
},
valueSetter: ({ data, newValue }: { data: RowData; newValue: any }): boolean => {
const astId = data?.cells[col.data.id]
const edit = graph.startEdit()
fixColumns(edit)
if (data.index === rowCount.value) {
addRow(edit, col.data.id, newValue)
} else {
const newValueAst = convertWithImport(newValue, edit)
if (astId != null) edit.replaceValue(astId, newValueAst)
else edit.getVersion(col.data).set(data.index, newValueAst)
}
onUpdate({ edit })
return true
},
headerComponentParams: {
nameSetter: (newName: string) => {
const edit = graph.startEdit()
fixColumns(edit)
edit.getVersion(col.name).setRawTextContent(newName)
onUpdate({ edit })
},
},
}) satisfies ColDef<RowData>,
)
cols.push(newColumnDef.value)
return cols
})
const rowData = computed(() => {
const rows: RowData[] = []
for (const col of columns.value) {
for (const [rowIndex, value] of col.data.enumerate()) {
const row: RowData = rows.at(rowIndex) ?? { index: rowIndex, cells: {} }
assert(rowIndex <= rows.length)
if (rowIndex === rows.length) {
rows.push(row)
}
if (value?.id) {
row.cells[col.data.id] = value?.id
}
}
}
rows.push({ index: rows.length, cells: {} })
return rows
})
const nothingImport = computed(() => requiredImportsByFQN(suggestions, NOTHING_PATH, true))
function convertWithImport(value: unknown, edit: Ast.MutableModule) {
const { ast, requireNothingImport } = cellValueConversion.agGridToAst(value, edit)
if (requireNothingImport) {
graph.addMissingImports(edit, nothingImport.value)
}
return ast
}
return { columnDefs, rowData }
}

View File

@ -9,6 +9,7 @@ import { ref } from 'vue'
export interface BreadcrumbItem {
label: string
active: boolean
isCurrentTop: boolean
}
const renameError = useToast.error()
const projectNameEdited = ref(false)
@ -36,6 +37,7 @@ async function renameBreadcrumb(index: number, newName: string) {
v-if="index > 0"
name="arrow_right_head_only"
:disabled="!breadcrumb.active"
:class="{ nonInteractive: breadcrumb.isCurrentTop }"
class="arrow"
/>
<NavBreadcrumb
@ -43,6 +45,7 @@ async function renameBreadcrumb(index: number, newName: string) {
:active="breadcrumb.active"
:editing="index === 0 && projectNameEdited"
:title="index === 0 ? 'Project Name' : ''"
:class="{ nonInteractive: breadcrumb.isCurrentTop }"
class="clickable"
@click.stop="stackNavigator.handleBreadcrumbClick(index)"
@update:modelValue="renameBreadcrumb(index, $event)"
@ -63,8 +66,6 @@ async function renameBreadcrumb(index: number, newName: string) {
gap: 12px;
padding-left: 8px;
padding-right: 10px;
padding-top: 4px;
padding-bottom: 4px;
}
.NavBreadcrumbs {
@ -77,7 +78,7 @@ async function renameBreadcrumb(index: number, newName: string) {
color: #666666;
}
.inactive {
opacity: 0.4;
.nonInteractive {
pointer-events: none;
}
</style>

View File

@ -121,10 +121,10 @@ export default {}
<template>
<div ref="element" class="ScrollBar" :style="scrollBarStyles">
<div class="track vertical" @pointerdown.stop="clickTrack.y">
<div class="track vertical" :class="{ scrollable: !yFull }" @pointerdown.stop="clickTrack.y">
<div class="bar vertical" v-on.stop="dragSlider.y" />
</div>
<div class="track horizontal" @pointerdown.stop="clickTrack.x">
<div class="track horizontal" :class="{ scrollable: !xFull }" @pointerdown.stop="clickTrack.x">
<div class="bar horizontal" v-on.stop="dragSlider.x" />
</div>
</div>
@ -190,8 +190,10 @@ export default {}
background-color: rgba(150 150 150 / 15%);
transition: opacity 0.2s ease-in;
opacity: 0;
&.scrollable {
opacity: var(--scrollbar-scrollable-opacity);
}
&:hover {
transition: opacity 0.2s ease-in;
opacity: 1;
}
&:active {

View File

@ -19,7 +19,7 @@ function addNode() {
.SmallPlusButton {
width: var(--node-base-height);
height: var(--node-base-height);
margin: 0px;
margin: 0;
backdrop-filter: var(--blur-app-bg);
background: var(--color-app-bg);
@ -32,12 +32,4 @@ function addNode() {
background: rgb(158, 158, 255);
}
}
.icon {
display: inline-flex;
background: none;
margin: 8px;
padding: 0;
border: none;
}
</style>

View File

@ -0,0 +1,270 @@
<script setup lang="ts">
import DropdownMenu from '@/components/DropdownMenu.vue'
import MenuButton from '@/components/MenuButton.vue'
import SvgButton from '@/components/SvgButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { useVisualizationConfig } from '@/providers/visualizationConfig'
import { Ast } from '@/util/ast'
import { Pattern } from '@/util/ast/match'
import { computed, ref, watch } from 'vue'
import type { NodeCreationOptions } from './GraphEditor/nodeCreation'
import { TextFormatOptions } from './visualizations/TableVisualization.vue'
type SortDirection = 'asc' | 'desc'
export type SortModel = {
columnName: string
sortDirection: SortDirection
sortIndex: number
}
const props = defineProps<{
filterModel: {
[key: string]: {
values: any[]
filterType: string
}
}
sortModel: SortModel[]
isDisabled: boolean
}>()
const emit = defineEmits<{
changeFormat: [formatValue: TextFormatOptions]
}>()
const textFormatterSelected = ref(TextFormatOptions.Partial)
watch(textFormatterSelected, (selected) => emit('changeFormat', selected))
const config = useVisualizationConfig()
const sortPatternPattern = computed(() => Pattern.parse('(..Name __ __ )'))
const sortDirection = computed(() => ({
asc: '..Ascending',
desc: '..Descending',
}))
const makeSortPattern = (module: Ast.MutableModule) => {
const columnSortExpressions = props.sortModel
.filter((sort) => sort?.columnName)
.sort((a, b) => a.sortIndex - b.sortIndex)
.map((sort) =>
sortPatternPattern.value.instantiateCopied([
Ast.TextLiteral.new(sort.columnName),
Ast.parse(sortDirection.value[sort.sortDirection as SortDirection]),
]),
)
return Ast.Vector.new(module, columnSortExpressions)
}
const filterPattern = computed(() => Pattern.parse('__ (__ __)'))
const makeFilterPattern = (module: Ast.MutableModule, columnName: string, items: string[]) => {
if (
(items?.length === 1 && items.indexOf('true') != -1) ||
(items?.length === 1 && items.indexOf('false') != -1)
) {
const boolToInclude = items.indexOf('false') != -1 ? Ast.parse('False') : Ast.parse('True')
return filterPattern.value.instantiateCopied([
Ast.TextLiteral.new(columnName),
Ast.parse('..Equal'),
boolToInclude,
])
}
const itemList = items.map((i) => Ast.TextLiteral.new(i))
return filterPattern.value.instantiateCopied([
Ast.TextLiteral.new(columnName),
Ast.parse('..Is_In'),
Ast.Vector.new(module, itemList),
])
}
function getAstPatternSort() {
return Pattern.new((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('sort')!),
makeSortPattern(ast.module),
),
)
}
function getAstPatternFilter(columnName: string, items: string[]) {
return Pattern.new((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
makeFilterPattern(ast.module, columnName, items),
),
)
}
function getAstPatternFilterAndSort(columnName: string, items: string[]) {
return Pattern.new((ast) =>
Ast.OprApp.new(
ast.module,
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('filter')!),
makeFilterPattern(ast.module, columnName, items),
),
'.',
Ast.App.positional(
Ast.Ident.new(ast.module, Ast.identifier('sort')!),
makeSortPattern(ast.module),
),
),
)
}
const createNewNodes = () => {
let patterns = new Array<any>()
if (Object.keys(props.filterModel).length && props.sortModel.length) {
for (const index in Object.keys(props.filterModel)) {
const columnName = Object.keys(props.filterModel)[index]!
const items = props.filterModel[columnName || '']?.values.map((item) => `${item}`)!
const filterPatterns = getAstPatternFilterAndSort(columnName, items)
patterns.push(filterPatterns)
}
} else if (Object.keys(props.filterModel).length) {
for (const index in Object.keys(props.filterModel)) {
const columnName = Object.keys(props.filterModel)[index]!
const items = props.filterModel[columnName || '']?.values.map((item) => `${item}`)!
const filterPatterns = getAstPatternFilter(columnName, items)
patterns.push(filterPatterns)
}
} else if (props.sortModel.length) {
const patSort = getAstPatternSort()
patterns.push(patSort)
}
config.createNodes(
...patterns.map(
(pattern) => ({ content: pattern, commit: true }) satisfies NodeCreationOptions,
),
)
}
const buttonClass = computed(() => {
return {
full: isFormatOptionSelected(TextFormatOptions.On),
partial: isFormatOptionSelected(TextFormatOptions.Partial),
strikethrough: isFormatOptionSelected(TextFormatOptions.Off),
}
})
const isFormatOptionSelected = (option: TextFormatOptions): boolean =>
option === textFormatterSelected.value
const open = ref(false)
const toggleOpen = () => {
open.value = !open.value
}
const changeFormat = (option: TextFormatOptions) => {
textFormatterSelected.value = option
toggleOpen()
}
</script>
<template>
<div>
<DropdownMenu v-model:open="open" class="TextFormattingSelector" title="Text Display Options">
<template #button
><div :class="buttonClass">
<SvgIcon name="paragraph" /></div
></template>
<template #entries>
<MenuButton
class="full"
title="Text displayed in monospace font and all whitespace characters displayed as symbols"
@click="() => changeFormat(TextFormatOptions.On)"
>
<SvgIcon name="paragraph" />
<div class="title">Full whitespace rendering</div>
</MenuButton>
<MenuButton
class="partial"
title="Text displayed in monospace font, only multiple spaces displayed with &#183;"
@click="() => changeFormat(TextFormatOptions.Partial)"
>
<SvgIcon name="paragraph" />
<div class="title">Partial whitespace rendering</div>
</MenuButton>
<MenuButton
class="off"
title="No formatting applied to text"
@click="() => changeFormat(TextFormatOptions.Off)"
>
<div class="strikethrough">
<SvgIcon name="paragraph" />
</div>
<div class="title">No whitespace rendering</div>
</MenuButton>
</template>
</DropdownMenu>
</div>
<SvgButton
name="add"
title="Create new component(s) with the current grid's sort and filters applied to the workflow"
:disabled="props.isDisabled"
@click="createNewNodes()"
/>
</template>
<style scoped>
.TextFormattingSelector {
background: var(--color-frame-bg);
border-radius: 16px;
}
:deep(.DropdownMenuContent) {
margin-top: 10px;
padding: 4px;
> * {
display: flex;
padding-left: 8px;
padding-right: 8px;
}
}
.strikethrough {
position: relative;
margin-right: 4px;
}
.strikethrough:before {
position: absolute;
content: '';
left: 0;
top: 50%;
right: 0;
border-top: 1px solid;
border-color: black;
-webkit-transform: rotate(-20deg);
-moz-transform: rotate(-20deg);
-ms-transform: rotate(-20deg);
-o-transform: rotate(-20deg);
transform: rotate(-20deg);
}
.partial {
stroke: grey;
fill: #808080;
}
.off {
justify-content: flex-start;
}
.full {
stroke: black;
fill: #000000;
justify-content: flex-start;
}
.title {
padding-left: 2px;
}
</style>

View File

@ -1,132 +0,0 @@
<script setup lang="ts">
import DropdownMenu from '@/components/DropdownMenu.vue'
import MenuButton from '@/components/MenuButton.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import { computed, ref, watch } from 'vue'
import { TextFormatOptions } from './visualizations/TableVisualization.vue'
const emit = defineEmits<{
changeFormat: [formatValue: TextFormatOptions]
}>()
const textFormatterSelected = ref(TextFormatOptions.Partial)
watch(textFormatterSelected, (selected) => emit('changeFormat', selected))
const buttonClass = computed(() => {
return {
full: isFormatOptionSelected(TextFormatOptions.On),
partial: isFormatOptionSelected(TextFormatOptions.Partial),
strikethrough: isFormatOptionSelected(TextFormatOptions.Off),
}
})
const isFormatOptionSelected = (option: TextFormatOptions): boolean =>
option === textFormatterSelected.value
const open = ref(false)
const toggleOpen = () => {
open.value = !open.value
}
const changeFormat = (option: TextFormatOptions) => {
textFormatterSelected.value = option
toggleOpen()
}
</script>
<template>
<DropdownMenu v-model:open="open" class="TextFormattingSelector" title="Text Display Options">
<template #button
><div :class="buttonClass">
<SvgIcon name="paragraph" /></div
></template>
<template #entries>
<MenuButton
class="full"
:title="`Text displayed in monospace font and all whitespace characters displayed as symbols`"
@click="() => changeFormat(TextFormatOptions.On)"
>
<SvgIcon name="paragraph" />
<div class="title">Full whitespace rendering</div>
</MenuButton>
<MenuButton
class="partial"
:title="`Text displayed in monospace font, only multiple spaces displayed with &#183;`"
@click="() => changeFormat(TextFormatOptions.Partial)"
>
<SvgIcon name="paragraph" />
<div class="title">Partial whitespace rendering</div>
</MenuButton>
<MenuButton
class="off"
title="`No formatting applied to text`"
@click="() => changeFormat(TextFormatOptions.Off)"
>
<div class="strikethrough">
<SvgIcon name="paragraph" />
</div>
<div class="title">No whitespace rendering</div>
</MenuButton>
</template>
</DropdownMenu>
</template>
<style scoped>
.TextFormattingSelector {
background: var(--color-frame-bg);
border-radius: 16px;
}
:deep(.DropdownMenuContent) {
margin-top: 10px;
padding: 4px;
> * {
display: flex;
padding-left: 8px;
padding-right: 8px;
}
}
.strikethrough {
position: relative;
margin-right: 4px;
}
.strikethrough:before {
position: absolute;
content: '';
left: 0;
top: 50%;
right: 0;
border-top: 1px solid;
border-color: black;
-webkit-transform: rotate(-20deg);
-moz-transform: rotate(-20deg);
-ms-transform: rotate(-20deg);
-o-transform: rotate(-20deg);
transform: rotate(-20deg);
}
.partial {
stroke: grey;
fill: #808080;
}
.off {
justify-content: flex-start;
}
.full {
stroke: black;
fill: #000000;
justify-content: flex-start;
}
.title {
padding-left: 2px;
}
</style>

View File

@ -1,35 +1,20 @@
<script lang="ts">
import icons from '@/assets/icons.svg'
import {
clipboardNodeData,
tsvTableToEnsoExpression,
writeClipboard,
} from '@/components/GraphEditor/clipboard'
import TextFormattingSelector from '@/components/TextFormattingSelector.vue'
import { default as TableVizToolbar, type SortModel } from '@/components/TableVizToolbar.vue'
import AgGridTableView from '@/components/widgets/AgGridTableView.vue'
import { Ast } from '@/util/ast'
import { Pattern } from '@/util/ast/match'
import { useAutoBlur } from '@/util/autoBlur'
import { VisualizationContainer, useVisualizationConfig } from '@/util/visualizationBuiltins'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type {
CellClassParams,
CellClickedEvent,
ColumnResizedEvent,
ICellRendererParams,
RowHeightParams,
SortChangedEvent,
} from 'ag-grid-community'
import type { ColDef, GridOptions } from 'ag-grid-enterprise'
import {
computed,
onMounted,
onUnmounted,
reactive,
ref,
shallowRef,
watchEffect,
type Ref,
} from 'vue'
import type { ColDef } from 'ag-grid-enterprise'
import { computed, onMounted, ref, shallowRef, watchEffect, type Ref } from 'vue'
export const name = 'Table'
export const icon = 'table'
@ -60,6 +45,7 @@ interface Matrix {
all_rows_count: number
json: unknown[][]
value_type: ValueType[]
get_child_node: string
}
interface Excel_Workbook {
@ -68,6 +54,7 @@ interface Excel_Workbook {
all_rows_count: number
sheet_names: string[]
json: unknown[][]
get_child_node: string
}
interface ObjectMatrix {
@ -76,6 +63,7 @@ interface ObjectMatrix {
all_rows_count: number
json: object[]
value_type: ValueType[]
get_child_node: string
}
interface UnknownTable {
@ -90,6 +78,7 @@ interface UnknownTable {
value_type: ValueType[]
has_index_col: boolean | undefined
links: string[] | undefined
get_child_node: string
}
export enum TextFormatOptions {
@ -97,23 +86,9 @@ export enum TextFormatOptions {
On,
Off,
}
declare module 'ag-grid-enterprise' {
// These type parameters are defined on the original interface.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface AbstractColDef<TData, TValue> {
field: string
}
}
if (typeof import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY !== 'string') {
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
}
</script>
<script setup lang="ts">
const { LicenseManager, Grid } = await import('ag-grid-enterprise')
const props = defineProps<{ data: Data }>()
const emit = defineEmits<{
'update:preprocessor': [module: string, method: string, ...args: string[]]
@ -131,7 +106,8 @@ const SQLITE_CONNECTIONS_NODE_TYPE =
'Standard.Database.Internal.SQLite.SQLite_Connection.SQLite_Connection'
const POSTGRES_CONNECTIONS_NODE_TYPE =
'Standard.Database.Internal.Postgres.Postgres_Connection.Postgres_Connection'
const DEFAULT_ROW_HEIGHT = 22
const SNOWFLAKE_CONNECTIONS_NODE_TYPE =
'Standard.Snowflake.Snowflake_Connection.Snowflake_Connection'
const rowLimit = ref(0)
const page = ref(0)
@ -139,11 +115,11 @@ const pageLimit = ref(0)
const rowCount = ref(0)
const showRowCount = ref(true)
const isTruncated = ref(false)
const tableNode = ref<HTMLElement>()
const isCreateNodeEnabled = ref(false)
const filterModel = ref({})
const sortModel = ref<SortModel[]>([])
const dataGroupingMap = shallowRef<Map<string, boolean>>()
useAutoBlur(tableNode)
const widths = reactive(new Map<string, number>())
const defaultColDef = {
const defaultColDef: Ref<ColDef> = ref({
editable: false,
sortable: true,
filter: true,
@ -151,22 +127,12 @@ const defaultColDef = {
minWidth: 25,
cellRenderer: cellRenderer,
cellClass: cellClass,
}
const agGridOptions: Ref<GridOptions & Required<Pick<GridOptions, 'defaultColDef'>>> = ref({
headerHeight: 26,
getRowHeight: getRowHeight,
rowData: [],
columnDefs: [],
defaultColDef: defaultColDef as typeof defaultColDef & { manuallySized: boolean },
onFirstDataRendered: updateColumnWidths,
onRowDataUpdated: updateColumnWidths,
onColumnResized: lockColumnSize,
sendToClipboard: ({ data }: { data: string }) => sendToClipboard(data),
suppressFieldDotNotation: true,
enableRangeSelection: true,
popupParent: document.body,
})
} satisfies ColDef)
const rowData = ref<Record<string, any>[]>([])
const columnDefs: Ref<ColDef[]> = ref([])
const textFormatterSelected = ref<TextFormatOptions>(TextFormatOptions.Partial)
const updateTextFormat = (option: TextFormatOptions) => {
textFormatterSelected.value = option
}
@ -187,32 +153,21 @@ const selectableRowLimits = computed(() => {
})
const newNodeSelectorValues = computed(() => {
let selector
let identifierAction
let tooltipValue
let headerName
switch (config.nodeType) {
case COLUMN_NODE_TYPE:
case VECTOR_NODE_TYPE:
selector = INDEX_FIELD_NAME
identifierAction = 'at'
tooltipValue = 'value'
break
case ROW_NODE_TYPE:
selector = 'column'
identifierAction = 'at'
tooltipValue = 'value'
break
case EXCEL_WORKBOOK_NODE_TYPE:
selector = 'Value'
identifierAction = 'read'
tooltipValue = 'sheet'
headerName = 'Sheets'
break
case SQLITE_CONNECTIONS_NODE_TYPE:
case POSTGRES_CONNECTIONS_NODE_TYPE:
selector = 'Value'
identifierAction = 'query'
case SNOWFLAKE_CONNECTIONS_NODE_TYPE:
tooltipValue = 'table'
headerName = 'Tables'
break
@ -221,8 +176,6 @@ const newNodeSelectorValues = computed(() => {
tooltipValue = 'row'
}
return {
selector,
identifierAction,
tooltipValue,
headerName,
}
@ -317,29 +270,6 @@ function escapeHTML(str: string) {
return str.replace(/[&<>"']/g, (m) => mapping[m]!)
}
function getRowHeight(params: RowHeightParams): number {
if (textFormatterSelected.value === TextFormatOptions.Off) {
return DEFAULT_ROW_HEIGHT
}
const rowData = Object.values(params.data)
const textValues = rowData.filter((r): r is string => typeof r === 'string')
if (!textValues.length) {
return DEFAULT_ROW_HEIGHT
}
const returnCharsCount = textValues.map((text: string) => {
const crlfCount = (text.match(/\r\n/g) || []).length
const crCount = (text.match(/\r/g) || []).length
const lfCount = (text.match(/\n/g) || []).length
return crCount + lfCount - crlfCount
})
const maxReturnCharsCount = Math.max(...returnCharsCount)
return (maxReturnCharsCount + 1) * DEFAULT_ROW_HEIGHT
}
function cellClass(params: CellClassParams) {
if (params.colDef.field === '#') return null
if (typeof params.value === 'number' || params.value === null) return 'ag-right-aligned-cell'
@ -413,7 +343,7 @@ function toField(name: string, valueType?: ValueType | null | undefined): ColDef
`
const template =
icon ?
`<span style='display:flex; flex-direction:row; justify-content:space-between; width:inherit;'><span ref="eLabel" class="ag-header-cell-label" role="presentation" style='display:flex; flex-direction:row; justify-content:space-between; width:inherit;'> ${name} ${menu}</span> ${sort} ${svgTemplate}</span>`
`<span style='display:flex; flex-direction:row; justify-content:space-between; width:inherit;'><span ref="eLabel" class="ag-header-cell-label" role="presentation" style='display:flex; flex-direction:row; justify-content:space-between; width:inherit;'> ${name} </span> ${menu} ${sort} ${svgTemplate}</span>`
: `<span ref="eLabel" style='display:flex; flex-direction:row; justify-content:space-between; width:inherit;'>${name} ${menu} ${sort}</span>`
return {
field: name,
@ -432,61 +362,37 @@ function toRowField(name: string, valueType?: ValueType | null | undefined) {
}
}
function getAstPattern(selector: string | number, action: string) {
return Pattern.new((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier(action)!),
typeof selector === 'number' ?
Ast.tryNumberToEnso(selector, ast.module)!
: Ast.TextLiteral.new(selector, ast.module),
),
)
function getAstPattern(selector: string | number, action?: string) {
const identifierAction =
config.nodeType === (COLUMN_NODE_TYPE || VECTOR_NODE_TYPE) ? 'at' : action
if (identifierAction) {
return Pattern.new((ast) =>
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier(identifierAction)!),
typeof selector === 'number' ?
Ast.tryNumberToEnso(selector, ast.module)!
: Ast.TextLiteral.new(selector, ast.module),
),
)
}
}
const getTablePattern = (index: number) =>
Pattern.new((ast) =>
Ast.OprApp.new(
ast.module,
Ast.App.positional(
Ast.PropertyAccess.new(ast.module, ast, Ast.identifier('rows')!),
Ast.parse('(..All_Rows)'),
),
'.',
Ast.App.positional(
Ast.Ident.new(ast.module, Ast.identifier('get')!),
Ast.tryNumberToEnso(index, ast.module)!,
),
),
)
function createNode(params: CellClickedEvent) {
if (config.nodeType === TABLE_NODE_TYPE || config.nodeType === DB_TABLE_NODE_TYPE) {
function createNode(params: CellClickedEvent, selector: string, action?: string) {
const pattern = getAstPattern(params.data[selector], action)
if (pattern) {
config.createNodes({
content: getTablePattern(params.data[INDEX_FIELD_NAME]),
commit: true,
})
}
if (
newNodeSelectorValues.value.selector !== undefined &&
newNodeSelectorValues.value.selector !== null &&
newNodeSelectorValues.value.identifierAction
) {
config.createNodes({
content: getAstPattern(
params.data[newNodeSelectorValues.value.selector],
newNodeSelectorValues.value.identifierAction,
),
content: pattern,
commit: true,
})
}
}
function toLinkField(fieldName: string): ColDef {
function toLinkField(fieldName: string, getChildAction?: string): ColDef {
return {
headerName:
newNodeSelectorValues.value.headerName ? newNodeSelectorValues.value.headerName : fieldName,
field: fieldName,
onCellDoubleClicked: (params) => createNode(params),
onCellDoubleClicked: (params) => createNode(params, fieldName, getChildAction),
tooltipValueGetter: () => {
return `Double click to view this ${newNodeSelectorValues.value.tooltipValue} in a separate component`
},
@ -515,55 +421,51 @@ watchEffect(() => {
// eslint-disable-next-line camelcase
has_index_col: false,
links: undefined,
// eslint-disable-next-line camelcase
get_child_node: undefined,
}
const options = agGridOptions.value
if (options.api == null) {
return
}
let columnDefs: ColDef[] = []
let rowData: object[] = []
if ('error' in data_) {
columnDefs = [
columnDefs.value = [
{
field: 'Error',
cellStyle: { 'white-space': 'normal' },
},
]
rowData = [{ Error: data_.error }]
rowData.value = [{ Error: data_.error }]
} else if (data_.type === 'Matrix') {
columnDefs.push(toLinkField(INDEX_FIELD_NAME))
columnDefs.value = [toLinkField(INDEX_FIELD_NAME, data_.get_child_node)]
for (let i = 0; i < data_.column_count; i++) {
columnDefs.push(toField(i.toString()))
columnDefs.value.push(toField(i.toString()))
}
rowData = addRowIndex(data_.json)
rowData.value = addRowIndex(data_.json)
isTruncated.value = data_.all_rows_count !== data_.json.length
} else if (data_.type === 'Object_Matrix') {
columnDefs.push(toLinkField(INDEX_FIELD_NAME))
columnDefs.value = [toLinkField(INDEX_FIELD_NAME, data_.get_child_node)]
let keys = new Set<string>()
for (const val of data_.json) {
if (val != null) {
Object.keys(val).forEach((k) => {
if (!keys.has(k)) {
keys.add(k)
columnDefs.push(toField(k))
columnDefs.value.push(toField(k))
}
})
}
}
rowData = addRowIndex(data_.json)
rowData.value = addRowIndex(data_.json)
isTruncated.value = data_.all_rows_count !== data_.json.length
} else if (data_.type === 'Excel_Workbook') {
columnDefs = [toLinkField('Value')]
rowData = data_.sheet_names.map((name) => ({ Value: name }))
columnDefs.value = [toLinkField('Value', data_.get_child_node)]
rowData.value = data_.sheet_names.map((name) => ({ Value: name }))
} else if (Array.isArray(data_.json)) {
columnDefs = [toLinkField(INDEX_FIELD_NAME), toField('Value')]
rowData = data_.json.map((row, i) => ({ [INDEX_FIELD_NAME]: i, Value: toRender(row) }))
columnDefs.value = [toLinkField(INDEX_FIELD_NAME, data_.get_child_node), toField('Value')]
rowData.value = data_.json.map((row, i) => ({ [INDEX_FIELD_NAME]: i, Value: toRender(row) }))
isTruncated.value = data_.all_rows_count ? data_.all_rows_count !== data_.json.length : false
} else if (data_.json !== undefined) {
columnDefs = data_.links ? [toLinkField('Value')] : [toField('Value')]
rowData =
columnDefs.value =
data_.links ? [toLinkField('Value', data_.get_child_node)] : [toField('Value')]
rowData.value =
data_.links ?
data_.links.map((link) => ({
Value: link,
@ -574,17 +476,20 @@ watchEffect(() => {
('header' in data_ ? data_.header : [])?.map((v, i) => {
const valueType = data_.value_type ? data_.value_type[i] : null
if (config.nodeType === ROW_NODE_TYPE) {
return v === 'column' ? toLinkField(v) : toRowField(v, valueType)
return v === 'column' ? toLinkField(v, data_.get_child_node) : toRowField(v, valueType)
}
return toField(v, valueType)
}) ?? []
columnDefs = data_.has_index_col ? [toLinkField(INDEX_FIELD_NAME), ...dataHeader] : dataHeader
columnDefs.value =
data_.has_index_col ?
[toLinkField(INDEX_FIELD_NAME, data_.get_child_node), ...dataHeader]
: dataHeader
const rows = data_.data && data_.data.length > 0 ? data_.data[0]?.length ?? 0 : 0
rowData = Array.from({ length: rows }, (_, i) => {
rowData.value = Array.from({ length: rows }, (_, i) => {
const shift = data_.has_index_col ? 1 : 0
return Object.fromEntries(
columnDefs.map((h, j) => {
columnDefs.value.map((h, j) => {
return [
h.field,
toRender(h.field === INDEX_FIELD_NAME ? i : data_.data?.[j - shift]?.[i]),
@ -592,7 +497,7 @@ watchEffect(() => {
}),
)
})
isTruncated.value = data_.all_rows_count !== rowData.length
isTruncated.value = data_.all_rows_count !== rowData.value.length
}
// Update paging
@ -605,11 +510,11 @@ watchEffect(() => {
page.value = newPageLimit
}
if (rowData.length) {
const headers = Object.keys(rowData[0] as object)
if (rowData.value[0]) {
const headers = Object.keys(rowData.value[0])
const headerGroupingMap = new Map()
headers.forEach((header) => {
const needsGrouping = rowData.some((row: any) => {
const needsGrouping = rowData.value.some((row) => {
if (header in row && row[header] != null) {
const value = typeof row[header] === 'object' ? row[header].value : row[header]
return value > 9999
@ -620,114 +525,61 @@ watchEffect(() => {
dataGroupingMap.value = headerGroupingMap
}
// If an existing grid, merge width from manually sized columns.
const newWidths = new Map<string, number>()
const mergedColumnDefs = columnDefs.map((columnDef) => {
if (!columnDef.field) return columnDef
const width = widths.get(columnDef.field)
if (width != null) newWidths.set(columnDef.field, (columnDef.width = width))
return columnDef
})
widths.clear()
for (const [key, value] of newWidths) widths.set(key, value)
// If data is truncated, we cannot rely on sorting/filtering so will disable.
options.defaultColDef.filter = !isTruncated.value
options.defaultColDef.sortable = !isTruncated.value
options.api.setColumnDefs(mergedColumnDefs)
options.api.setRowData(rowData)
defaultColDef.value.filter = !isTruncated.value
defaultColDef.value.sortable = !isTruncated.value
})
function updateColumnWidths() {
const columnApi = agGridOptions.value.columnApi
if (columnApi == null) {
function checkSortAndFilter(e: SortChangedEvent) {
const gridApi = e.api
const columnApi = e.columnApi
if (gridApi == null || columnApi == null) {
console.warn('AG Grid column API does not exist.')
isCreateNodeEnabled.value = false
return
}
const cols = columnApi.getAllGridColumns().filter((c) => {
const field = c.getColDef().field
return field && !widths.has(field)
})
columnApi.autoSizeColumns(cols)
}
function lockColumnSize(e: ColumnResizedEvent) {
// Check if the resize is finished, and it's not from the API (which is triggered by us).
if (!e.finished || e.source === 'api') return
// If the user manually resized (or manually autosized) a column, we don't want to auto-size it
// on a resize.
const manuallySized = e.source !== 'autosizeColumns'
for (const column of e.columns ?? []) {
const field = column.getColDef().field
if (field && manuallySized) widths.set(field, column.getActualWidth())
const colState = columnApi.getColumnState()
const filter = gridApi.getFilterModel()
const sort = colState
.map((cs) => {
if (cs.sort) {
return {
columnName: cs.colId,
sortDirection: cs.sort,
sortIndex: cs.sortIndex,
} as SortModel
}
})
.filter((sort) => sort)
if (sort.length || Object.keys(filter).length) {
isCreateNodeEnabled.value = true
sortModel.value = sort as SortModel[]
filterModel.value = filter
} else {
isCreateNodeEnabled.value = false
sortModel.value = []
filterModel.value = {}
}
}
/** Copy the provided TSV-formatted table data to the clipboard.
*
* The data will be copied as `text/plain` TSV data for spreadsheet applications, and an Enso-specific MIME section for
* pasting as a new table node.
*
* By default, AG Grid writes only `text/plain` TSV data to the clipboard. This is sufficient to paste into spreadsheet
* applications, which are liberal in what they try to interpret as tabular data; however, when pasting into Enso, the
* application needs to be able to distinguish tabular clipboard contents to choose the correct paste action.
*
* Our heuristic to identify clipboard data from applications like Excel and Google Sheets is to check for a <table> tag
* in the clipboard `text/html` data. If we were to add a `text/html` section to the data so that it could be recognized
* like other spreadsheets, when pasting into other applications some applications might use the `text/html` data in
* preference to the `text/plain` content--so we would need to construct an HTML table that fully represents the
* content.
*
* To avoid that complexity, we bypass our table-data detection by including application-specific data in the clipboard
* content. This data contains a ready-to-paste node that constructs an Enso table from the provided TSV.
*/
function sendToClipboard(tsvData: string) {
return writeClipboard({
...clipboardNodeData([{ expression: tsvTableToEnsoExpression(tsvData) }]),
'text/plain': tsvData,
})
}
// ===============
// === Updates ===
// ===============
onMounted(() => {
setRowLimit(1000)
const agGridLicenseKey = import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY
if (typeof agGridLicenseKey === 'string') {
LicenseManager.setLicenseKey(agGridLicenseKey)
} else if (import.meta.env.DEV) {
// Hide annoying license validation errors in dev mode when the license is not defined. The
// missing define warning is still displayed to not forget about it, but it isn't as obnoxious.
const origValidateLicense = LicenseManager.prototype.validateLicense
LicenseManager.prototype.validateLicense = function (this) {
if (!('licenseManager' in this))
Object.defineProperty(this, 'licenseManager', {
configurable: true,
set(value: any) {
Object.getPrototypeOf(value).validateLicense = () => {}
delete this.licenseManager
this.licenseManager = value
},
})
origValidateLicense.call(this)
}
}
// TODO: consider using Vue component instead: https://ag-grid.com/vue-data-grid/getting-started/
new Grid(tableNode.value!, agGridOptions.value)
updateColumnWidths()
})
onUnmounted(() => {
agGridOptions.value.api?.destroy()
})
</script>
<template>
<VisualizationContainer :belowToolbar="true" :overflow="true" :toolbarOverflow="true">
<template #toolbar>
<TextFormattingSelector @changeFormat="(i) => updateTextFormat(i)" />
<TableVizToolbar
:filterModel="filterModel"
:sortModel="sortModel"
:isDisabled="!isCreateNodeEnabled"
@changeFormat="(i) => updateTextFormat(i)"
/>
</template>
<div ref="rootNode" class="TableVisualization" @wheel.stop @pointerdown.stop>
<div class="table-visualization-status-bar">
@ -752,7 +604,17 @@ onUnmounted(() => {
<span v-else v-text="`${rowCount} rows.`"></span>
</template>
</div>
<div ref="tableNode" class="scrollable ag-theme-alpine"></div>
<!-- TODO[ao]: Suspence in theory is not needed here (the entire visualization is inside
suspense), but for some reason it causes reactivity loop - see https://github.com/enso-org/enso/issues/10782 -->
<Suspense>
<AgGridTableView
class="scrollable grid"
:columnDefs="columnDefs"
:rowData="rowData"
:defaultColDef="defaultColDef"
@sortOrFilterUpdated="(e) => checkSortAndFilter(e)"
/>
</Suspense>
</div>
</VisualizationContainer>
</template>
@ -765,11 +627,8 @@ onUnmounted(() => {
height: 100%;
}
.ag-theme-alpine {
--ag-grid-size: 3px;
--ag-list-item-height: 20px;
.grid {
flex-grow: 1;
font-family: var(--font-mono);
}
.table-visualization-status-bar {
@ -796,4 +655,9 @@ onUnmounted(() => {
:deep(.link):hover {
color: darkblue;
}
.button-wrappers {
display: flex;
flex-direction: row;
}
</style>

View File

@ -0,0 +1,206 @@
<script setup lang="ts" generic="TData, TValue">
/**
* Component adding some useful logic to AGGrid table component (like keeping track of colum sizes),
* and using common style for tables in our application.
*/
import {
clipboardNodeData,
tsvTableToEnsoExpression,
writeClipboard,
} from '@/components/GraphEditor/clipboard'
import { useAutoBlur } from '@/util/autoBlur'
import '@ag-grid-community/styles/ag-grid.css'
import '@ag-grid-community/styles/ag-theme-alpine.css'
import type {
CellEditingStartedEvent,
CellEditingStoppedEvent,
ColumnResizedEvent,
FirstDataRenderedEvent,
GridReadyEvent,
RowDataUpdatedEvent,
RowEditingStartedEvent,
RowEditingStoppedEvent,
SortChangedEvent,
} from 'ag-grid-community'
import type {
ColDef,
ColGroupDef,
GetRowIdFunc,
GridApi,
RowHeightParams,
} from 'ag-grid-enterprise'
import { type ComponentInstance, reactive, ref, shallowRef } from 'vue'
const DEFAULT_ROW_HEIGHT = 22
const _props = defineProps<{
rowData: TData[]
columnDefs: (ColDef<TData, TValue> | ColGroupDef<TData>)[] | null
defaultColDef: ColDef<TData>
getRowId?: GetRowIdFunc<TData>
components?: Record<string, unknown>
singleClickEdit?: boolean
stopEditingWhenCellsLoseFocus?: boolean
}>()
const emit = defineEmits<{
cellEditingStarted: [event: CellEditingStartedEvent]
cellEditingStopped: [event: CellEditingStoppedEvent]
rowEditingStarted: [event: RowEditingStartedEvent]
rowEditingStopped: [event: RowEditingStoppedEvent]
rowDataUpdated: [event: RowDataUpdatedEvent]
sortOrFilterUpdated: [event: SortChangedEvent]
}>()
const widths = reactive(new Map<string, number>())
const grid = ref<ComponentInstance<typeof AgGridVue>>()
const gridApi = shallowRef<GridApi<TData>>()
const popupParent = document.body
useAutoBlur(() => grid.value?.$el)
function onGridReady(event: GridReadyEvent) {
gridApi.value = event.api
}
function getRowHeight(params: RowHeightParams): number {
const rowData = Object.values(params.data)
const textValues = rowData.filter((r): r is string => typeof r === 'string')
if (!textValues.length) {
return DEFAULT_ROW_HEIGHT
}
const returnCharsCount = textValues.map((text: string) => {
const crlfCount = (text.match(/\r\n/g) || []).length
const crCount = (text.match(/\r/g) || []).length
const lfCount = (text.match(/\n/g) || []).length
return crCount + lfCount - crlfCount
})
const maxReturnCharsCount = Math.max(...returnCharsCount)
return (maxReturnCharsCount + 1) * DEFAULT_ROW_HEIGHT
}
function updateColumnWidths(event: FirstDataRenderedEvent | RowDataUpdatedEvent) {
if (event.columnApi == null) {
console.warn('AG Grid column API does not exist.')
return
}
const cols = event.columnApi.getAllGridColumns().filter((c) => {
const id = c.getColId()
return id && !widths.has(id)
})
event.columnApi.autoSizeColumns(cols)
}
function lockColumnSize(e: ColumnResizedEvent) {
// Check if the resize is finished, and it's not from the API (which is triggered by us).
if (!e.finished || e.source === 'api') return
// If the user manually resized (or manually autosized) a column, we don't want to auto-size it
// on a resize.
if (e.source !== 'autosizeColumns') {
for (const column of e.columns ?? []) {
const id = column.getColDef().colId
if (id) widths.set(id, column.getActualWidth())
}
}
}
/** Copy the provided TSV-formatted table data to the clipboard.
*
* The data will be copied as `text/plain` TSV data for spreadsheet applications, and an Enso-specific MIME section for
* pasting as a new table node.
*
* By default, AG Grid writes only `text/plain` TSV data to the clipboard. This is sufficient to paste into spreadsheet
* applications, which are liberal in what they try to interpret as tabular data; however, when pasting into Enso, the
* application needs to be able to distinguish tabular clipboard contents to choose the correct paste action.
*
* Our heuristic to identify clipboard data from applications like Excel and Google Sheets is to check for a <table> tag
* in the clipboard `text/html` data. If we were to add a `text/html` section to the data so that it could be recognized
* like other spreadsheets, when pasting into other applications some applications might use the `text/html` data in
* preference to the `text/plain` content--so we would need to construct an HTML table that fully represents the
* content.
*
* To avoid that complexity, we bypass our table-data detection by including application-specific data in the clipboard
* content. This data contains a ready-to-paste node that constructs an Enso table from the provided TSV.
*/
function sendToClipboard({ data }: { data: string }) {
return writeClipboard({
...clipboardNodeData([{ expression: tsvTableToEnsoExpression(data) }]),
'text/plain': data,
})
}
defineExpose({ gridApi })
// === Loading AGGrid and its license ===
const { LicenseManager } = await import('ag-grid-enterprise')
if (typeof import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY !== 'string') {
console.warn('The AG_GRID_LICENSE_KEY is not defined.')
if (import.meta.env.DEV) {
// Hide annoying license validation errors in dev mode when the license is not defined. The
// missing define warning is still displayed to not forget about it, but it isn't as obnoxious.
const origValidateLicense = LicenseManager.prototype.validateLicense
LicenseManager.prototype.validateLicense = function (this) {
if (!('licenseManager' in this))
Object.defineProperty(this, 'licenseManager', {
configurable: true,
set(value: any) {
Object.getPrototypeOf(value).validateLicense = () => {}
delete this.licenseManager
this.licenseManager = value
},
})
origValidateLicense.call(this)
}
}
} else {
const agGridLicenseKey = import.meta.env.VITE_ENSO_AG_GRID_LICENSE_KEY
LicenseManager.setLicenseKey(agGridLicenseKey)
}
const { AgGridVue } = await import('ag-grid-vue3')
</script>
<template>
<AgGridVue
v-bind="$attrs"
ref="grid"
class="grid ag-theme-alpine"
:headerHeight="26"
:getRowHeight="getRowHeight"
:rowData="rowData"
:columnDefs="columnDefs"
:defaultColDef="defaultColDef"
:sendToClipboard="sendToClipboard"
:suppressFieldDotNotation="true"
:enableRangeSelection="true"
:popupParent="popupParent"
:components="components"
:singleClickEdit="singleClickEdit"
:stopEditingWhenCellsLoseFocus="stopEditingWhenCellsLoseFocus"
@gridReady="onGridReady"
@firstDataRendered="updateColumnWidths"
@rowDataUpdated="updateColumnWidths($event), emit('rowDataUpdated', $event)"
@columnResized="lockColumnSize"
@cellEditingStarted="emit('cellEditingStarted', $event)"
@cellEditingStopped="emit('cellEditingStopped', $event)"
@rowEditingStarted="emit('rowEditingStarted', $event)"
@rowEditingStopped="emit('rowEditingStopped', $event)"
@sortChanged="emit('sortOrFilterUpdated', $event)"
@filterChanged="emit('sortOrFilterUpdated', $event)"
/>
</template>
<style scoped>
.ag-theme-alpine {
--ag-grid-size: 3px;
--ag-list-item-height: 20px;
font-family: var(--font-mono);
}
.TableVisualization > .ag-theme-alpine > :deep(.ag-root-wrapper.ag-layout-normal) {
border-radius: 0 0 var(--radius-default) var(--radius-default);
}
</style>

View File

@ -63,7 +63,6 @@ export function useRaf(
},
{ immediate: true },
)
console.log('onScopeDispose(unmountRaf)?')
onScopeDispose(unmountRaf)
}

View File

@ -284,17 +284,32 @@ export function useNavigator(
scale.skip()
}
/**
* Translate the viewport as necessary to ensure that a particular client point corresponds to the same scene point
* before and after running the provided function.
*/
function maintainingScenePosAtClientPoint<T>(clientPos: Vec2, f: () => T): T {
const scenePos0 = clientToScenePos(clientPos)
const result = f()
const scenePos1 = clientToScenePos(clientPos)
targetCenter.value = center.value.add(scenePos0.sub(scenePos1))
center.skip()
return result
}
const { events: wheelEvents, captureEvents: wheelEventsCapture } = useWheelActions(
keyboard,
WHEEL_CAPTURE_DURATION_MS,
(e, inputType) => {
if (inputType === 'trackpad') {
// OS X trackpad events provide usable rate-of-change information.
updateScale((oldValue: number) => oldValue * Math.exp(-e.deltaY / 100))
} else {
// Mouse wheel rate information is unreliable. We just step in the direction of the sign.
stepZoom(-Math.sign(e.deltaY))
}
maintainingScenePosAtClientPoint(eventScreenPos(e), () => {
if (inputType === 'trackpad') {
// OS X trackpad events provide usable rate-of-change information.
updateScale((oldValue: number) => oldValue * Math.exp(-e.deltaY / 100))
} else {
// Mouse wheel rate information is unreliable. We just step in the direction of the sign.
stepZoom(-Math.sign(e.deltaY))
}
})
},
(e) => {
const delta = new Vec2(e.deltaX, e.deltaY)

View File

@ -13,7 +13,8 @@ export function useStackNavigator(projectStore: ProjectStore, graphStore: GraphS
return breadcrumbs.value.map((item, index) => {
const label = stackItemToLabel(item, index === 0)
const isActive = index < activeStackLength
return { label, active: isActive } satisfies BreadcrumbItem
const isCurrentTop = index == activeStackLength - 1
return { label, active: isActive, isCurrentTop } satisfies BreadcrumbItem
})
})

View File

@ -8,6 +8,8 @@ import {
substituteIdentifier,
substituteQualifiedName,
subtrees,
tryEnsoToNumber,
tryNumberToEnso,
unescapeTextLiteral,
type Identifier,
} from '@/util/ast/abstract'
@ -1105,22 +1107,92 @@ test.each([
}).toEqual(expected)
})
test('Vector modifications', () => {
const vector = Ast.Vector.tryParse('[1, 2]')
expect(vector).toBeDefined()
test.each`
initial | pushed | expected
${'[1, 2]'} | ${'"Foo"'} | ${'[1, 2, "Foo"]'}
${'[]'} | ${'3'} | ${'[3]'}
${'[,]'} | ${'1'} | ${'[,, 1]'}
`('Pushing $pushed to vector $initial', ({ initial, pushed, expected }) => {
const vector = Ast.Vector.tryParse(initial)
assertDefined(vector)
vector.push(Ast.parse('"Foo"', vector.module))
expect(vector.code()).toBe('[1, 2, "Foo"]')
vector.keep((ast) => ast instanceof Ast.NumericLiteral)
expect(vector.code()).toBe('[1, 2]')
vector.push(Ast.parse('3', vector.module))
expect(vector.code()).toBe('[1, 2, 3]')
vector.keep((ast) => ast.code() !== '4')
expect(vector.code()).toBe('[1, 2, 3]')
vector.keep((ast) => ast.code() !== '2')
expect(vector.code()).toBe('[1, 3]')
vector.keep((ast) => ast.code() !== '1')
expect(vector.code()).toBe('[3]')
vector.keep(() => false)
expect(vector.code()).toBe('[]')
vector.push(Ast.parse(pushed, vector.module))
expect(vector.code()).toBe(expected)
})
// TODO[ao]: Fix cases where expected spacing feels odd.
test.each`
initial | predicate | expected
${'[1, 2, "Foo"]'} | ${(ast: Ast.Ast) => ast instanceof Ast.NumericLiteral} | ${'[1, 2]'}
${'[1, "Foo", 3]'} | ${(ast: Ast.Ast) => ast instanceof Ast.NumericLiteral} | ${'[1, 3]'}
${'["Foo", 2, 3]'} | ${(ast: Ast.Ast) => ast instanceof Ast.NumericLiteral} | ${'[ 2, 3]'}
${'[1, 2, "Foo"]'} | ${(ast: Ast.Ast) => !(ast instanceof Ast.NumericLiteral)} | ${'[ "Foo"]'}
${'[1, "Foo", 3]'} | ${(ast: Ast.Ast) => !(ast instanceof Ast.NumericLiteral)} | ${'[ "Foo"]'}
${'["Foo", 2, 3]'} | ${(ast: Ast.Ast) => !(ast instanceof Ast.NumericLiteral)} | ${'["Foo"]'}
${'[]'} | ${(ast: Ast.Ast) => ast instanceof Ast.NumericLiteral} | ${'[]'}
${'[1, 2, 3]'} | ${(ast: Ast.Ast) => ast.code() != '4'} | ${'[1, 2, 3]'}
${'[1, 2, 3]'} | ${() => false} | ${'[]'}
`('Keeping elements in vector ($initial -> $expected)', ({ initial, predicate, expected }) => {
const vector = Ast.Vector.tryParse(initial)
assertDefined(vector)
vector.keep(predicate)
expect(vector.code()).toBe(expected)
})
// TODO[ao]: Fix cases where expected spacing feels odd.
test.each`
initial | expectedVector | expectedValue
${'[1, 2, 3]'} | ${'[1, 2]'} | ${'3'}
${'[1, 2, ]'} | ${'[1, 2 ]'} | ${undefined}
${'[]'} | ${'[]'} | ${undefined}
`('Popping elements from vector $initial', ({ initial, expectedVector, expectedValue }) => {
const vector = Ast.Vector.tryParse(initial)
assertDefined(vector)
const value = vector.pop()
expect(value?.code()).toBe(expectedValue)
expect(vector.code()).toBe(expectedVector)
})
test.each`
initial | index | value | expected
${'[1, 2, 3]'} | ${0} | ${'4'} | ${'[4, 2, 3]'}
${'[1, 2, 3]'} | ${1} | ${'4'} | ${'[1, 4, 3]'}
${'[1, 2, 3]'} | ${2} | ${'4'} | ${'[1, 2, 4]'}
${'[,,]'} | ${0} | ${'4'} | ${'[4,,]'}
${'[,,]'} | ${1} | ${'4'} | ${'[, 4,]'}
${'[,,]'} | ${2} | ${'4'} | ${'[,, 4]'}
`(
'Setting vector elements: in $initial on index $index to $value',
({ initial, index, value, expected }) => {
const vector = Ast.Vector.tryParse(initial)
assertDefined(vector)
vector.set(index, Ast.parse(value, vector.module))
expect(vector.code()).toBe(expected)
},
)
test.each`
ensoNumber | jsNumber | expectedEnsoNumber
${'0'} | ${0} | ${'0'}
${'12345'} | ${12345} | ${'12345'}
${'123_456'} | ${123456} | ${'123456'}
${'-12345'} | ${-12345} | ${'-12345'}
${'-123_456'} | ${-123456} | ${'-123456'}
${'0b101'} | ${0b101} | ${'5'}
${'0o444'} | ${0o444} | ${'292'}
${'0xabcdef'} | ${0xabcdef} | ${'11259375'}
${`1${'0'.repeat(300)}`} | ${1e300} | ${undefined /*Not yet implemented*/}
${`1${'0'.repeat(309)}`} | ${Infinity /*Limitation of IEEE 754-1985 double format*/} | ${undefined}
${undefined} | ${NaN} | ${undefined}
`(
'Conversions between enso literals and js numbers: $ensoNumber',
({ ensoNumber, jsNumber, expectedEnsoNumber }) => {
if (ensoNumber != null) {
const literal = Ast.parse(ensoNumber)
expect(tryEnsoToNumber(literal)).toBe(jsNumber)
}
if (jsNumber != null) {
const convertedToAst = tryNumberToEnso(jsNumber, MutableModule.Transient())
expect(convertedToAst?.code()).toBe(expectedEnsoNumber)
}
},
)

View File

@ -19,6 +19,7 @@ import {
MutableIdent,
MutableModule,
MutablePropertyAccess,
NegationApp,
NumericLiteral,
OprApp,
PropertyAccess,
@ -224,10 +225,22 @@ export function substituteQualifiedName(
*/
export function tryNumberToEnso(value: number, module: MutableModule) {
if (!Number.isFinite(value)) return
const literal = NumericLiteral.tryParse(value.toString(), module)
const literal = NumericLiteral.tryParse(Math.abs(value).toString(), module)
if (!literal)
console.warn(`Not implemented: Converting scientific-notation number to Enso value`, value)
return literal
if (literal && value < 0) {
return NegationApp.new(module, literal)
} else {
return literal
}
}
export function tryEnsoToNumber(ast: Ast) {
const [sign, literal] = ast instanceof NegationApp ? [-1, ast.argument] : [1, ast]
if (!(literal instanceof NumericLiteral)) return
// JS parsing is accidentally the same as our rules for literals: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number#number_coercion
// except `_` separators: https://stackoverflow.com/questions/72548282/why-does-number-constructor-fail-to-parse-numbers-with-separators
return sign * Number(literal.code().replace(/_/g, ''))
}
export function copyIntoNewModule<T extends Ast>(ast: T): Owned<Mutable<T>> {

View File

@ -1,14 +1,15 @@
import { unrefElement, useEvent } from '@/composables/events'
import { injectInteractionHandler, type Interaction } from '@/providers/interactionHandler'
import type { ToValue } from '@/util/reactivity'
import type { VueInstance } from '@vueuse/core'
import { watchEffect, type Ref } from 'vue'
import { toValue, watchEffect, type Ref } from 'vue'
import type { Opt } from 'ydoc-shared/util/data/opt'
/** Automatically `blur` the currently active element on any mouse click outside of `root`.
* It is useful when other elements may capture pointer events, preventing default browser behavior for focus change. */
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | undefined>) {
export function useAutoBlur(root: ToValue<HTMLElement | SVGElement | undefined>) {
watchEffect((onCleanup) => {
const element = root.value
const element = toValue(root)
if (element) {
autoBlurRoots.add(element)
onCleanup(() => autoBlurRoots.delete(element))

View File

@ -5,7 +5,6 @@ import { ref } from 'vue'
import CircularMenu from '@/components/CircularMenu.vue'
const isRecordingOverridden = ref(false)
const isDocsVisible = ref(false)
const isVisualizationVisible = ref(false)
const emptySet = new Set<string>()
@ -22,7 +21,6 @@ const emptySet = new Set<string>()
<div style="position: absolute; left: 32px">
<CircularMenu
v-model:isRecordingOverridden="isRecordingOverridden"
v-model:isDocsVisible="isDocsVisible"
v-model:isVisualizationEnabled="isVisualizationVisible"
:isRecordingEnabledGlobally="true"
:isFullMenuVisible="true"
@ -30,7 +28,6 @@ const emptySet = new Set<string>()
:matchableNodeColors="emptySet"
:documentationUrl="undefined"
@update:isRecordingOverridden="logEvent('update:isRecordingOverridden', [$event])"
@update:isDocsVisible="logEvent('update:isDocsVisible', [$event])"
@update:isVisualizationVisible="logEvent('update:isVisualizationVisible', [$event])"
/>
</div>
@ -38,7 +35,6 @@ const emptySet = new Set<string>()
<template #controls>
<HstCheckbox v-model="isRecordingOverridden" title="isRecordingOverridden" />
<HstCheckbox v-model="isDocsVisible" title="isDocsVisible" />
<HstCheckbox v-model="isVisualizationVisible" title="isVisualizationVisible" />
</template>
</Story>

View File

@ -15,7 +15,9 @@ const projectManagerUrl =
process.env.E2E === 'true' ? 'ws://127.0.0.1:30536' : 'ws://127.0.0.1:30535'
const IS_CLOUD_BUILD = process.env.CLOUD_BUILD === 'true'
const YDOC_SERVER_URL = process.env.ENSO_POLYGLOT_YDOC_SERVER ?? 'ws://localhost:5976'
const YDOC_SERVER_URL =
process.env.ENSO_POLYGLOT_YDOC_SERVER ??
(process.env.NODE_ENV === 'development' ? 'ws://localhost:5976' : undefined)
await readEnvironmentFromFile()

View File

@ -51,7 +51,7 @@
"otherUserIsUsingProjectError": "Someone else is using this project",
"localBackendNotDetectedError": "Could not detect the local backend",
"invalidEmailValidationError": "Invalid email",
"invalidEmailValidationError": "Please enter a valid email address",
"projectHasNoSourceFilesPhrase": "project has no source files",
"fileNotFoundPhrase": "file not found",
@ -61,8 +61,10 @@
"registrationError": "Something went wrong! Please try again or contact the administrators.",
"missingEmailError": "Missing email address",
"missingVerificationCodeError": "Missing verification code",
"passwordMismatchError": "Passwords do not match",
"passwordValidationError": "Your password must include numbers, letters (both lowercase and uppercase) and symbols, and must be between 6 and 256 characters long.",
"passwordMismatchError": "Passwords do not match.",
"passwordLengthError": "Your password must be between 6 and 256 characters long.",
"passwordValidationMessage": "Your password must include numbers, letters (both lowercase and uppercase) and symbols.",
"passwordValidationError": "Your password does not meet the security requirements.",
"confirmSignUpError": "Incorrect email or confirmation code.",
"setUsernameError": "Could not set your username.",
@ -287,6 +289,7 @@
"stopExecution": "Stop execution",
"openInEditor": "Open in editor",
"fieldErrorLabel": "Error",
"unknownPlaceholder": "unknown",
"expand": "Expand",
"collapse": "Collapse",
@ -473,6 +476,8 @@
"emailPlaceholder": "Enter your email",
"confirmationCodePlaceholder": "Enter the confirmation code",
"emailLabel": "Email",
"passwordLabel": "Password",
"changePassword": "Change Password",
"currentPasswordLabel": "Current password",
"currentPasswordPlaceholder": "Enter your current password",

View File

@ -300,6 +300,7 @@ class Abstractor {
const left = this.abstractToken(tree.left)
const elements = []
if (tree.first) elements.push({ value: this.abstractTree(tree.first) })
else if (!tree.rest.next().done) elements.push({ value: undefined })
for (const rawElement of tree.rest) {
elements.push({
delimiter: this.abstractToken(rawElement.operator),

View File

@ -877,8 +877,9 @@ export class NegationApp extends Ast {
return asOwned(new MutableNegationApp(module, fields))
}
static new(module: MutableModule, operator: Token, argument: Owned) {
return this.concrete(module, unspaced(operator), autospaced(argument))
static new(module: MutableModule, argument: Owned) {
const minus = Token.new('-', RawAst.Token.Type.Operator)
return this.concrete(module, unspaced(minus), unspaced(argument))
}
get operator(): Token {
@ -1803,6 +1804,18 @@ export class NumericLiteral extends Ast {
if (parsed instanceof MutableNumericLiteral) return parsed
}
static tryParseWithSign(
source: string,
module?: MutableModule,
): Owned<MutableNumericLiteral | MutableNegationApp> | undefined {
const parsed = parse(source, module)
if (
parsed instanceof MutableNumericLiteral ||
(parsed instanceof MutableNegationApp && parsed.argument instanceof MutableNumericLiteral)
)
return parsed
}
static concrete(module: MutableModule, tokens: NodeChild<Token>[]) {
const base = module.baseObject('NumericLiteral')
const fields = composeFieldData(base, { tokens })
@ -2383,8 +2396,8 @@ export class Vector extends Ast {
yield ensureUnspaced(open, verbatim)
let isFirst = true
for (const { delimiter, value } of elements) {
if (isFirst && value) {
yield preferUnspaced(value)
if (isFirst) {
if (value) yield preferUnspaced(value)
} else {
yield preferUnspaced(delimiter)
if (value) yield preferSpaced(value)
@ -2398,6 +2411,16 @@ export class Vector extends Ast {
for (const element of this.fields.get('elements'))
if (element.value) yield this.module.get(element.value.node)
}
*enumerate(): IterableIterator<[number, Ast | undefined]> {
for (const [index, element] of this.fields.get('elements').entries()) {
yield [index, this.module.get(element.value?.node)]
}
}
get length() {
return this.fields.get('elements').length
}
}
export class MutableVector extends Vector implements MutableAst {
declare readonly module: MutableModule
@ -2412,6 +2435,28 @@ export class MutableVector extends Vector implements MutableAst {
this.fields.set('elements', [...elements, element])
}
pop(): Owned | undefined {
const elements = this.fields.get('elements')
const last = elements[elements.length - 1]?.value?.node
this.fields.set('elements', elements.slice(0, -1))
const lastNode = this.module.get(last)
if (lastNode != null) {
lastNode.fields.set('parent', undefined)
return lastNode as Owned
} else {
return undefined
}
}
set<T extends MutableAst>(index: number, value: Owned<T>) {
const elements = [...this.fields.get('elements')]
elements[index] = {
delimiter: elements[index]!.delimiter,
value: autospaced(this.claimChild(value)),
}
this.fields.set('elements', elements)
}
keep(predicate: (ast: Ast) => boolean) {
const elements = this.fields.get('elements')
const filtered = elements.filter(

View File

@ -3,40 +3,113 @@
import { isSome, type Opt } from './opt'
/**
* A type representing result of a function where errors are expected and recoverable.
*
* Usage:
* ```ts
* function mayFail() {
* // ....
* if (allIsGood) return Ok()
* else return Err("Something bad happened")
* }
*
* const result = mayFail()
* if (result.ok) console.log('Operation succesfull:', result.value)
* else result.error.log('Operation failed')
* ```
*
* In more complex cases, adding contextual information to errors may be useful - see
* {@link withContext}.
*/
export type Result<T = undefined, E = unknown> =
| { ok: true; value: T }
| { ok: false; error: ResultError<E> }
/**
* Constructor of success {@link Result}.
*/
export function Ok(): Result<undefined, never>
export function Ok<T>(data: T): Result<T, never>
export function Ok<T>(data?: T): Result<T | undefined, never> {
return { ok: true, value: data }
}
/**
* Constructor of error {@link Result}.
*/
export function Err<E>(error: E): Result<never, E> {
return { ok: false, error: new ResultError(error) }
}
/**
* Helper function for converting optional value to {@link Result}.
*/
export function okOr<T, E>(data: Opt<T>, error: E): Result<T, E> {
if (isSome(data)) return Ok(data)
else return Err(error)
}
/**
* Unwraps the {@link Result} value. If the result is error, it is thrown.
*/
export function unwrap<T, E>(result: Result<T, E>): T {
if (result.ok) return result.value
else throw result.error
}
/**
* Unwraps the {@link Result} value. If the result is error, an alternative is returned.
*/
export function unwrapOr<T, A>(result: Result<T, unknown>, alternative: A): T | A {
if (result.ok) return result.value
else return alternative
}
/**
* Unwraps the {@link Result} value. If the result is error, it is logged and alternative is returned.
*/
export function unwrapOrWithLog<T, A>(
result: Result<T, unknown>,
alternative: A,
preamble?: string,
): T | A {
if (result.ok) return result.value
else {
result.error.log(preamble)
return alternative
}
}
/**
* Maps the {@link Result} value.
*/
export function mapOk<T, U, E>(result: Result<T, E>, f: (value: T) => U): Result<U, E> {
if (result.ok) return Ok(f(result.value))
else return result
}
/**
* If the value is nullish, returns {@link Ok} with it.
*/
export function transposeResult<T, E>(value: Opt<Result<T, E>>): Result<Opt<T>, E>
/**
* If any of the values is an error, the first error is returned.
*/
export function transposeResult<T, E>(value: Result<T, E>[]): Result<T[], E>
export function transposeResult<T, E>(value: Opt<Result<T, E>> | Result<T, E>[]) {
if (value == null) return Ok(value)
if (value instanceof Array) {
const error = value.find(x => !x.ok)
if (error) return error
else return Ok(Array.from(value, x => (x as { ok: true; value: T }).value))
}
return value
}
/**
* Check if given value is {@link Result}.
*/
export function isResult(v: unknown): v is Result {
return (
v != null &&
@ -47,8 +120,12 @@ export function isResult(v: unknown): v is Result {
)
}
/**
* A class containing information about {@link Result} error.
*/
export class ResultError<E = unknown> {
payload: E
/** All contexts attached by {@link withContext} function */
context: (() => string)[]
constructor(payload: E) {
@ -56,10 +133,17 @@ export class ResultError<E = unknown> {
this.context = []
}
/**
* Log the error with context information and given preable.
*/
log(preamble: string = 'Error') {
console.error(this.message(preamble))
}
/**
* Returns the error message: the given preamble, the payload coerced to string and all context
* information.
*/
message(preamble: string = 'error') {
const ctx =
this.context.length > 0 ? `\n${Array.from(this.context, ctx => ctx()).join('\n')}` : ''
@ -67,6 +151,43 @@ export class ResultError<E = unknown> {
}
}
/**
* Adds a context information to any error returned by `f`
*
* It it useful for making helpful error messages in complex operations, where result errors may be
* propagated through several calls.
*
* A simplified example:
* ```ts
*
* function parse(x: unknown): Result<number> {
* const parsed = parseFloat(x)
* if (isNan(parsed)) return Err(`Cannot parse ${x} as number`)
* return parsed
* }
*
* function parseAndAdd(a: unknown, b: unknown): Result<number> {
* const parsedA = withContext(
* () => 'When parsing left operand',
* () => parse(a)
* )
* if (!parsedA.ok) return parsedA
* const parsedB = withContext(
* () => 'When parsing right operand',
* () => parse(b)
* )
* if (!parsedB.ok) return parsedB
* return parsedA + parsedB
* }
*
* parseAndAdd('2', '3') // returns Ok(5)
*
* // Will print:
* // Error: Cannot parse x as number
* // When parsing right operand
* const result = parseAndAdd('2', 'x')
* result.ok || result.error.log()
*/
export function withContext<T, E>(context: () => string, f: () => Result<T, E>): Result<T, E>
export function withContext<T, E>(
context: () => string,

View File

@ -637,6 +637,7 @@ lazy val `text-buffer` = project
.configs(Test)
.settings(
frgaalJavaCompilerSetting,
commands += WithDebugCommand.withDebug,
libraryDependencies ++= Seq(
"org.scalatest" %% "scalatest" % scalatestVersion % Test,
"org.scalacheck" %% "scalacheck" % scalacheckVersion % Test
@ -2598,6 +2599,7 @@ lazy val `engine-runner` = project
"org.jline" % "jline" % jlineVersion,
"junit" % "junit" % junitVersion % Test,
"com.github.sbt" % "junit-interface" % junitIfVersion % Test,
"org.hamcrest" % "hamcrest-all" % hamcrestVersion % Test,
"org.scala-lang.modules" %% "scala-collection-compat" % scalaCollectionCompatVersion
),
run / connectInput := true

View File

@ -7,11 +7,10 @@ edition = "2021"
[dependencies]
anyhow = { workspace = true }
fn-error-context = "0.2.0"
futures-util = "0.3.24"
futures-util = { workspace = true }
futures = { workspace = true }
serde = "1.0.145"
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
tracing = { workspace = true }
[lints]

View File

@ -5,13 +5,10 @@
// === Export ===
// ==============
pub mod from_string;
pub mod future;
pub mod iterator;
pub mod maps;
pub mod option;
pub mod os_str;
pub mod path;
pub mod pathbuf;
pub mod result;
pub mod str;

View File

@ -1,41 +0,0 @@
//!Module with utilities for converting string-like values into other types.
use crate::prelude::*;
use anyhow::Context;
use std::any::type_name;
/// An equivalent of standard's library `std::str::FromStr` trait, but with nice error messages.
pub trait FromString: Sized {
/// Parse a string into a value of this type. See: [`std::str::FromStr::from_str`].
fn from_str(s: &str) -> Result<Self>;
/// Parse a string into a value of this type and then convert it to `R`.
fn parse_into<R>(text: impl AsRef<str>) -> Result<R>
where
Self: TryInto<R>,
<Self as TryInto<R>>::Error: Into<anyhow::Error>, {
let value = Self::from_str(text.as_ref())?;
value.try_into().anyhow_err().context(format!(
"Failed to convert {} => {}.",
type_name::<Self>(),
type_name::<R>(),
))
}
}
impl<T> FromString for T
where
T: std::str::FromStr,
T::Err: Into<anyhow::Error>,
{
fn from_str(text: &str) -> Result<Self> {
text.parse::<T>().anyhow_err().context(format!(
r#"Failed to parse "{}" as {}."#,
text,
type_name::<T>()
))
}
}

View File

@ -2,30 +2,17 @@
use crate::prelude::*;
use futures_util::future::ErrInto;
use futures_util::future::Map;
use futures_util::future::MapErr;
use futures_util::future::MapOk;
use futures_util::stream;
use futures_util::FutureExt as _;
use futures_util::TryFutureExt as _;
/// Extension methods for [`Future`].
pub trait FutureExt: Future {
/// Discard the result of this future.
fn void(self) -> Map<Self, fn(Self::Output) -> ()>
where Self: Sized {
self.map(drop)
}
}
pub trait FutureExt: Future {}
impl<T: ?Sized> FutureExt for T where T: Future {}
type FlattenResultFn<T, E> =
fn(std::result::Result<std::result::Result<T, E>, E>) -> std::result::Result<T, E>;
/// Extension methods for [`TryFuture`], i.e. the Result-yielding [`Future`]
pub trait TryFutureExt: TryFuture {
/// Discard the result of successful future.
@ -55,42 +42,6 @@ pub trait TryFutureExt: TryFuture {
C: Display + Send + Sync + 'static, {
self.into_future().map(|res| res.with_context(context)).boxed()
}
/// Convert the error type of this future to [`anyhow::Error`].
fn anyhow_err(self) -> MapErr<Self, fn(Self::Error) -> anyhow::Error>
where
Self: Sized,
// TODO: we should rely on `into` rather than `from`
anyhow::Error: From<Self::Error>, {
self.map_err(anyhow::Error::from)
}
/// If the future is successful, apply the function to the result and return the new future.
fn and_then_sync<T2, E2, F>(
self,
f: F,
) -> Map<MapOk<ErrInto<Self, E2>, F>, FlattenResultFn<T2, E2>>
where
Self: Sized,
F: FnOnce(Self::Ok) -> std::result::Result<T2, E2>,
Self::Error: Into<E2>,
{
self.err_into().map_ok(f).map(std::result::Result::flatten)
}
}
impl<T: ?Sized> TryFutureExt for T where T: TryFuture {}
/// Extension methods for [`TryStream`], i.e. a [`Stream`] that produces [`Result`]s.
pub trait TryStreamExt: TryStream {
/// Wrap all the errors into [`anyhow::Error`].
fn anyhow_err(self) -> stream::MapErr<Self, fn(Self::Error) -> anyhow::Error>
where
Self: Sized,
// TODO: we should rely on `into` rather than `from`
anyhow::Error: From<Self::Error>, {
self.map_err(anyhow::Error::from)
}
}
impl<T: ?Sized> TryStreamExt for T where T: TryStream {}

View File

@ -1,66 +0,0 @@
//! Extension methods for `Iterator` and `Iterator`-like types.
use crate::prelude::*;
use std::iter::Rev;
use std::iter::Take;
/// Extension methods for `Iterator` and `Iterator`-like types.
pub trait IteratorExt: Iterator {
/// try_filter
/// Transforms an [Iterator]'s items into `Result`s, and filters out the `Err` variants.
fn try_filter<R>(mut self, mut f: impl FnMut(&Self::Item) -> Result<bool>) -> Result<R>
where
Self: Sized,
R: Default + Extend<Self::Item> + Sized, {
self.try_fold(default(), |mut acc: R, item| {
acc.extend(f(&item)?.then_some(item));
Ok(acc)
})
}
/// Transforms an [Iterator]'s items into `Result`s, and filters out the `Err` variants.
fn try_map<R, U>(mut self, mut f: impl FnMut(Self::Item) -> Result<U>) -> Result<R>
where
Self: Sized,
R: Default + Extend<U> + Sized, {
self.try_fold(default(), |mut acc: R, item| {
acc.extend_one(f(item)?);
Ok(acc)
})
}
}
impl<I: Iterator> IteratorExt for I {}
/// Extension methods for `Iterator` and `Iterator`-like types.s
pub trait TryIteratorExt: Iterator {
/// The result of successful iteration.
type Ok;
/// Collects the results of the iterator into a `Result<Vec<_>>`.
fn try_collect_vec(self) -> Result<Vec<Self::Ok>>;
}
impl<T, U, E> TryIteratorExt for T
where
T: Iterator<Item = std::result::Result<U, E>>,
E: Into<anyhow::Error>,
{
type Ok = U;
fn try_collect_vec(self) -> Result<Vec<U>> {
self.map(|i| i.anyhow_err()).collect::<Result<Vec<_>>>()
}
}
#[allow(missing_docs)]
pub trait ExactDoubleEndedIteratorExt: ExactSizeIterator + DoubleEndedIterator + Sized {
/// Take the last n elements of the iterator.
fn take_last_n(self, n: usize) -> Rev<Take<Rev<Self>>> {
self.rev().take(n).rev()
}
}
impl<T> ExactDoubleEndedIteratorExt for T where T: ExactSizeIterator + DoubleEndedIterator {}

View File

@ -62,20 +62,7 @@ pub trait PathExt: AsRef<Path> {
fn write_as_json<T: Serialize>(&self, value: &T) -> Result {
trace!("Writing JSON to {}.", self.as_ref().display());
let file = crate::fs::create(self)?;
serde_json::to_writer(file, value).anyhow_err()
}
/// Parse this file's contents as a YAML-serialized value.
fn read_to_yaml<T: DeserializeOwned>(&self) -> Result<T> {
let content = crate::fs::read_to_string(self)?;
serde_yaml::from_str(&content).anyhow_err()
}
/// Write this file with a YAML-serialized value.
fn write_as_yaml<T: Serialize>(&self, value: &T) -> Result {
trace!("Writing YAML to {}.", self.as_ref().display());
let file = crate::fs::create(self)?;
serde_yaml::to_writer(file, value).anyhow_err()
Ok(serde_json::to_writer(file, value)?)
}
/// Get the path as `str`.
@ -163,25 +150,6 @@ pub trait PathExt: AsRef<Path> {
impl<T: AsRef<Path>> PathExt for T {}
/// A method that outputs a path to a formatter using [`Path::display`].
///
/// This is useful in combination with macros like `Derivative`, as demonstrated in the example
/// below.
///
/// # Example
/// ```ignore
/// #[derive(Derivative)]
/// #[derivative(Debug)]
/// pub struct Foo {
/// #[derivative(Debug(format_with = "display_fmt"))]
/// path: PathBuf,
/// }
/// ```
pub fn display_fmt(path: &Path, f: &mut Formatter) -> std::fmt::Result {
Display::fmt(&path.display(), f)
}
/// A result of splitting a path into its filename components.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SplitFilename<'a> {

View File

@ -9,19 +9,6 @@ use std::future::Ready;
/// Extension methods for [`Result`].
pub trait ResultExt<T, E>: Sized {
/// Maps the value and wraps it as a [`Future`].
#[allow(clippy::type_complexity)]
fn map_async<'a, T2, F, Fut>(
self,
f: F,
) -> Either<
futures::future::Map<Fut, fn(T2) -> std::result::Result<T2, E>>,
Ready<std::result::Result<T2, E>>,
>
where
F: FnOnce(T) -> Fut,
Fut: Future<Output = T2> + 'a;
/// Maps the `Ok` value to a [`Future`] value. If the result is `Err`, the error is returned
/// as a [`std::future::Ready`] future.
fn and_then_async<'a, T2, E2, F, Fut>(
@ -35,63 +22,12 @@ pub trait ResultExt<T, E>: Sized {
T2: Send + 'a,
E2: Send + 'a;
/// Executes another future if this is an error. The error value is passed to a closure to
/// create this subsequent future.
fn or_else_async<F, Fut>(self, f: F) -> Either<Ready<Self>, futures::future::IntoFuture<Fut>>
where
F: FnOnce(E) -> Fut,
Fut: TryFuture<Ok = T, Error = E>;
/// Convert the error type to [`anyhow::Error`].
///
/// If there are additional context-specific information, use [`context`] instead.
fn anyhow_err(self) -> Result<T>
where E: Into<anyhow::Error>;
/// Convert the `[Result]<[Future]>` to `Future<Result>`.
fn flatten_fut(
self,
) -> Either<Ready<std::result::Result<T::Ok, T::Error>>, futures::future::IntoFuture<T>>
where T: TryFuture<Error: From<E>>;
/// Checks if the result is `Ok` and contains the given value.
fn contains<U>(&self, x: &U) -> bool
where U: PartialEq<T>;
}
impl<T, E> ResultExt<T, E> for std::result::Result<T, E> {
fn map_async<'a, T2, F, Fut>(
self,
f: F,
) -> Either<
futures::future::Map<Fut, fn(T2) -> std::result::Result<T2, E>>,
Ready<std::result::Result<T2, E>>,
>
where
F: FnOnce(T) -> Fut,
Fut: Future<Output = T2> + 'a,
{
match self {
Ok(v) => f(v).map(Ok as fn(T2) -> std::result::Result<T2, E>).left_future(),
Err(e) => ready(Err(e)).right_future(),
}
}
fn or_else_async<'a, F, Fut>(
self,
f: F,
) -> Either<Ready<Self>, futures::future::IntoFuture<Fut>>
where
F: FnOnce(E) -> Fut,
Fut: TryFuture<Ok = T, Error = E>,
{
match self {
Ok(v) => ready(Ok(v)).left_future(),
Err(e) => f(e).into_future().right_future(),
}
}
fn and_then_async<'a, T2, E2, F, Fut>(
self,
f: F,
@ -109,21 +45,6 @@ impl<T, E> ResultExt<T, E> for std::result::Result<T, E> {
}
}
fn anyhow_err(self) -> Result<T>
where E: Into<anyhow::Error> {
self.map_err(E::into)
}
fn flatten_fut(
self,
) -> Either<Ready<std::result::Result<T::Ok, T::Error>>, futures::future::IntoFuture<T>>
where T: TryFuture<Error: From<E>> {
match self {
Ok(fut) => fut.into_future().right_future(),
Err(e) => ready(Err(T::Error::from(e))).left_future(),
}
}
fn contains<U>(&self, x: &U) -> bool
where U: PartialEq<T> {
match self {

View File

@ -1,34 +0,0 @@
//! Extensions fot string-like types.
use crate::prelude::*;
/// Extension methods for strings and similar types.
pub trait StrLikeExt {
/// Convenience variant of `FromString::from_str`.
///
/// Should be preferred over [`str::parse`] due to better error messages.
// FIXME: This needs better name! However, we cannot use `parse` as it conflicts with
// `str::parse`. As a method on `str`, it would take priority over an extension trait.
fn parse2<T: FromString>(&self) -> Result<T>;
/// Convenience variant of `FromString::parse_into`.
fn parse_through<T, R>(&self) -> Result<R>
where
T: FromString + TryInto<R>,
<T as TryInto<R>>::Error: Into<anyhow::Error>;
}
impl<S: AsRef<str>> StrLikeExt for S {
fn parse2<U: FromString>(&self) -> Result<U> {
U::from_str(self.as_ref())
}
fn parse_through<T, R>(&self) -> Result<R>
where
T: FromString + TryInto<R>,
<T as TryInto<R>>::Error: Into<anyhow::Error>, {
T::parse_into(self.as_ref())
}
}

View File

@ -49,12 +49,6 @@ pub fn create(path: impl AsRef<Path>) -> Result<std::fs::File> {
wrappers::create(&path)
}
/// Read the file content and parse it using [`FromString`].
#[context("Failed to read the file: {}", path.as_ref().display())]
pub fn read_string_into<T: FromString>(path: impl AsRef<Path>) -> Result<T> {
read_to_string(&path)?.parse2()
}
/// Create a directory (and all missing parent directories),
///
/// Does not fail when a directory already exists.
@ -63,7 +57,7 @@ pub fn create_dir_if_missing(path: impl AsRef<Path>) -> Result {
let result = std::fs::create_dir_all(&path);
match result {
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
result => result.anyhow_err(),
result => Ok(result?),
}
}
@ -89,7 +83,7 @@ pub fn remove_dir_if_exists(path: impl AsRef<Path>) -> Result {
let result = std::fs::remove_dir_all(&path);
match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
result => result.anyhow_err(),
result => Ok(result?),
}
}
@ -102,7 +96,7 @@ pub fn remove_file_if_exists(path: impl AsRef<Path>) -> Result<()> {
let result = std::fs::remove_file(&path);
match result {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
result => result.anyhow_err(),
result => Ok(result?),
}
}

View File

@ -18,31 +18,31 @@ use std::fs::Metadata;
/// See [std::fs::metadata].
#[context("Failed to obtain metadata for file: {}", path.as_ref().display())]
pub fn metadata<P: AsRef<Path>>(path: P) -> Result<Metadata> {
std::fs::metadata(&path).anyhow_err()
Ok(std::fs::metadata(&path)?)
}
/// See [std::fs::symlink_metadata].
#[context("Failed to obtain symlink metadata for file: {}", path.as_ref().display())]
pub fn symlink_metadata<P: AsRef<Path>>(path: P) -> Result<Metadata> {
std::fs::symlink_metadata(&path).anyhow_err()
Ok(std::fs::symlink_metadata(&path)?)
}
/// See [std::fs::copy].
#[context("Failed to copy file from {} to {}", from.as_ref().display(), to.as_ref().display())]
pub fn copy(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<u64> {
std::fs::copy(&from, &to).anyhow_err()
Ok(std::fs::copy(&from, &to)?)
}
/// See [std::fs::rename].
#[context("Failed to rename file from {} to {}", from.as_ref().display(), to.as_ref().display())]
pub fn rename(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result {
std::fs::rename(&from, &to).anyhow_err()
Ok(std::fs::rename(&from, &to)?)
}
/// See [std::fs::read].
#[context("Failed to read the file: {}", path.as_ref().display())]
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
std::fs::read(&path).anyhow_err()
Ok(std::fs::read(&path)?)
}
/// See [std::fs::read_dir].
@ -60,41 +60,41 @@ pub fn read_dir(path: impl AsRef<Path>) -> Result<impl Iterator<Item = Result<Di
/// See [std::fs::read_to_string].
#[context("Failed to read the file: {}", path.as_ref().display())]
pub fn read_to_string(path: impl AsRef<Path>) -> Result<String> {
std::fs::read_to_string(&path).anyhow_err()
Ok(std::fs::read_to_string(&path)?)
}
/// See [std::fs::write].
#[context("Failed to write path: {}", path.as_ref().display())]
pub fn write(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Result {
std::fs::write(&path, contents).anyhow_err()
Ok(std::fs::write(&path, contents)?)
}
/// See [std::fs::File::open].
#[context("Failed to open path for reading: {}", path.as_ref().display())]
pub fn open(path: impl AsRef<Path>) -> Result<File> {
File::open(&path).anyhow_err()
Ok(File::open(&path)?)
}
/// See [std::fs::File::create].
#[context("Failed to open path for writing: {}", path.as_ref().display())]
pub fn create(path: impl AsRef<Path>) -> Result<File> {
File::create(&path).anyhow_err()
Ok(File::create(&path)?)
}
/// See [std::fs::canonicalize].
#[context("Failed to canonicalize path: {}", path.as_ref().display())]
pub fn canonicalize(path: impl AsRef<Path>) -> Result<PathBuf> {
std::fs::canonicalize(&path).anyhow_err()
Ok(std::fs::canonicalize(&path)?)
}
/// See [std::fs::create_dir_all].
#[context("Failed to create missing directories no path: {}", path.as_ref().display())]
pub fn create_dir_all(path: impl AsRef<Path>) -> Result {
std::fs::create_dir_all(&path).anyhow_err()
Ok(std::fs::create_dir_all(&path)?)
}
/// See [std::fs::set_permissions].
#[context("Failed to permissions on file: {}", path.as_ref().display())]
pub fn set_permissions(path: impl AsRef<Path>, perm: std::fs::Permissions) -> Result {
std::fs::set_permissions(&path, perm).anyhow_err()
Ok(std::fs::set_permissions(&path, perm)?)
}

View File

@ -3,10 +3,6 @@
//!
//! Currently it is employed by the native build scripts code.
// === Features ===
#![feature(result_flattening)]
#![feature(associated_type_bounds)]
#![feature(extend_one)]
// === Non-Standard Linter Configuration ===
#![warn(missing_docs)]
@ -54,19 +50,17 @@ pub mod prelude {
pub use std::path::PathBuf;
pub use std::pin::pin;
pub use std::pin::Pin;
pub use std::str::FromStr;
pub use std::sync::Arc;
pub use crate::extensions::from_string::FromString;
// pub use crate::extensions::from_string::FromString;
pub use crate::extensions::future::FutureExt as _;
pub use crate::extensions::future::TryFutureExt as _;
pub use crate::extensions::iterator::IteratorExt as _;
pub use crate::extensions::iterator::TryIteratorExt as _;
pub use crate::extensions::option::OptionExt as _;
pub use crate::extensions::os_str::OsStrExt as _;
pub use crate::extensions::path::PathExt as _;
pub use crate::extensions::pathbuf::PathBufExt as _;
pub use crate::extensions::result::ResultExt as _;
pub use crate::extensions::str::StrLikeExt as _;
pub use anyhow::anyhow;
pub use anyhow::bail;
@ -77,7 +71,6 @@ pub mod prelude {
pub use futures_util::select;
pub use futures_util::stream::BoxStream;
pub use futures_util::try_join;
pub use futures_util::AsyncWrite;
pub use futures_util::FutureExt as _;
pub use futures_util::Stream;
pub use futures_util::StreamExt as _;

View File

@ -13,7 +13,7 @@ base64 = "0.13.0"
bytes = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true }
derivative = { workspace = true }
derive-where = { workspace = true }
derive_more = { workspace = true }
dirs = { workspace = true }
futures = { workspace = true }
@ -25,8 +25,8 @@ enso-enso-font = { path = "../../lib/rust/enso-font" }
enso-font = { path = "../../lib/rust/font" }
enso-install-config = { path = "../install/config" }
ide-ci = { path = "../ci_utils" }
mime = "0.3.16"
new_mime_guess = "4.0.1"
mime = { workspace = true }
new_mime_guess = { workspace = true }
octocrab = { workspace = true }
path-slash = "0.2.1"
port_check = "0.1.5"

View File

@ -25,10 +25,10 @@ pub async fn client_from_env() -> aws_sdk_s3::Client {
}
/// Everything we need to get/put files to S3.
#[derive(Clone, Derivative)]
#[derivative(Debug)]
#[derive(Clone)]
#[derive_where(Debug)]
pub struct BucketContext {
#[derivative(Debug = "ignore")]
#[derive_where(skip)]
pub client: aws_sdk_s3::Client,
pub bucket: String,
pub upload_acl: ObjectCannedAcl,
@ -127,7 +127,7 @@ impl BucketContext {
pub async fn get_yaml<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
let text = self.get(path).await?.collect().await?;
serde_yaml::from_reader(text.reader()).anyhow_err()
Ok(serde_yaml::from_reader(text.reader())?)
}
pub async fn put_yaml(&self, path: &str, data: &impl Serialize) -> Result<PutObjectOutput> {
@ -226,8 +226,8 @@ mod tests {
assert_eq!(headers.content_type.to_string().as_str(), expected_type);
}
case("wasm_imports.js.gz", Some("gzip"), "application/javascript");
case("index.js", None, "application/javascript");
case("wasm_imports.js.gz", Some("gzip"), "text/javascript");
case("index.js", None, "text/javascript");
case("style.css", None, "text/css");
case("ide.wasm", None, "application/wasm");
case("ide.wasm.gz", Some("gzip"), "application/wasm");

View File

@ -234,8 +234,7 @@ pub fn cleaning_step(
}
/// Data needed to generate a typical sequence of CI steps invoking `./run` script.
#[derive(Derivative)]
#[derivative(Debug)]
#[derive_where(Debug)]
pub struct RunStepsBuilder {
/// The command passed to `./run` script.
pub run_command: String,
@ -244,7 +243,7 @@ pub struct RunStepsBuilder {
/// Customize the step that runs the command.
///
/// Allows replacing the run step with one or more custom steps.
#[derivative(Debug = "ignore")]
#[derive_where(skip)]
pub customize: Option<Box<dyn FnOnce(Step) -> Vec<Step>>>,
}
@ -339,7 +338,6 @@ pub fn runs_on(os: OS, runner_type: RunnerType) -> Vec<RunnerLabel> {
(OS::Linux, RunnerType::GitHubHosted) => vec![RunnerLabel::LinuxLatest],
(OS::MacOS, RunnerType::SelfHosted) => vec![RunnerLabel::SelfHosted, RunnerLabel::MacOS],
(OS::MacOS, RunnerType::GitHubHosted) => vec![RunnerLabel::MacOSLatest],
_ => panic!("Unsupported OS and runner type combination: {os} {runner_type}."),
}
}

Some files were not shown because too many files have changed in this diff Show More