mirror of
https://github.com/enso-org/enso.git
synced 2024-09-11 13:15:52 +03:00
Merge branch 'develop' into wip/frizi/bazel
This commit is contained in:
commit
272d4168bc
@ -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
1611
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@ -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
93
app/.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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
|
||||
})
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
54
app/dashboard/e2e/actions/ForgotPasswordPageActions.ts
Normal file
54
app/dashboard/e2e/actions/ForgotPasswordPageActions.ts
Normal 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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
92
app/dashboard/e2e/actions/RegisterPageActions.ts
Normal file
92
app/dashboard/e2e/actions/RegisterPageActions.ts
Normal 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()
|
||||
}
|
||||
}
|
@ -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) => {
|
@ -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())
|
||||
|
27
app/dashboard/e2e/authPreserveEmail.spec.ts
Normal file
27
app/dashboard/e2e/authPreserveEmail.spec.ts
Normal 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}`)
|
||||
}),
|
||||
)
|
@ -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))
|
||||
|
1106
app/dashboard/e2e/latestGithubReleases.json
Normal file
1106
app/dashboard/e2e/latestGithubReleases.json
Normal file
File diff suppressed because one or more lines are too long
@ -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()
|
||||
}),
|
||||
)
|
||||
|
@ -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()
|
||||
|
38
app/dashboard/e2e/signUp.spec.ts
Normal file
38
app/dashboard/e2e/signUp.spec.ts
Normal 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(),
|
||||
)
|
@ -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 () => {
|
||||
|
@ -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>
|
||||
|
5
app/dashboard/src/assets/github_color.svg
Normal file
5
app/dashboard/src/assets/github_color.svg
Normal 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 |
6
app/dashboard/src/assets/google_color.svg
Normal file
6
app/dashboard/src/assets/google_color.svg
Normal 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 |
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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>
|
||||
|
@ -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.
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* Barrel file for Password component.
|
||||
*/
|
||||
export * from './Password'
|
@ -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,
|
||||
|
@ -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}
|
||||
|
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
export * from './Input'
|
||||
export * from './Password'
|
||||
export * from './ResizableInput'
|
||||
export * from './Selector'
|
||||
export * from './variants'
|
||||
|
@ -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',
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
62
app/dashboard/src/layouts/Settings/SettingsAriaInput.tsx
Normal file
62
app/dashboard/src/layouts/Settings/SettingsAriaInput.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
@ -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">
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
32
app/dashboard/src/pages/authentication/schemas.ts
Normal file
32
app/dashboard/src/pages/authentication/schemas.ts
Normal 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'),
|
||||
)
|
||||
}
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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'>
|
||||
|
@ -5,6 +5,7 @@
|
||||
"e2e",
|
||||
"../types",
|
||||
"./src/**/*.json",
|
||||
"./e2e/**/*.json",
|
||||
"../../utils.ts",
|
||||
".prettierrc.cjs",
|
||||
"*.js",
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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 })`
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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) {
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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",
|
||||
|
@ -86,4 +86,5 @@
|
||||
--visualization-resize-handle-outside: 3px;
|
||||
--right-dock-default-width: 40%;
|
||||
--code-editor-default-height: 30%;
|
||||
--scrollbar-scrollable-opacity: 100%;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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)"
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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()
|
||||
})
|
@ -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 }
|
||||
}
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
270
app/gui2/src/components/TableVizToolbar.vue
Normal file
270
app/gui2/src/components/TableVizToolbar.vue
Normal 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 ·"
|
||||
@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>
|
@ -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 ·`"
|
||||
@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>
|
@ -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>
|
||||
|
206
app/gui2/src/components/widgets/AgGridTableView.vue
Normal file
206
app/gui2/src/components/widgets/AgGridTableView.vue
Normal 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>
|
@ -63,7 +63,6 @@ export function useRaf(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
console.log('onScopeDispose(unmountRaf)?')
|
||||
onScopeDispose(unmountRaf)
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
@ -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>> {
|
||||
|
@ -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))
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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),
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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]
|
||||
|
@ -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;
|
||||
|
@ -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>()
|
||||
))
|
||||
}
|
||||
}
|
@ -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 {}
|
||||
|
@ -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 {}
|
@ -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> {
|
||||
|
@ -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 {
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
@ -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?),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)?)
|
||||
}
|
||||
|
@ -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 _;
|
||||
|
@ -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"
|
||||
|
@ -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");
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user