Playwright E2E (#7520)

* clean plate

* npm i

* log in e2e

* global setup login

* set license key

* Revert "set license key"

This reverts commit 86d5db2c48.

* create datatable test

* fix wrong pg_creds

* data table + db manager e2e test

* DbManagerPage class

* small refactor

* create resource test + improvements

* text db manager in resources

* Factor test logic in classes

* refactoring

* refacto

* alter table test

* alter table e2e test

* set schema in test

* nits

* fix wrong schema var

* Correct setup and parallelization

* reducedMotion

* tests passing headless !

* bigger timeout

* start e2e docker compose

* e2e runs on all databases

* nit test uid fix

* refactp

* stash

* Better Workspace Storage settings

* minio setup

* nit

* nit

* super nit

* Permission settings in modal

* badge indicator

* Fetch alter table metadata much faster

* Upgrade duckdb to 1.4.3

* Ducklake tests

* Disable transactional DDL for Ducklake (bug on their side)

* git ignore env

* bigquery tests passes

* getJsonEnv

* load coldef in parallel

* Make Bigquery schema fetching much faster

* makeLoadTableMetaDataQuery for entire db in bigquery

* refactor getDbSchemas to avoid assignment side effect

* fix col def

* Better loading state mgmt

* snowflake

* fix snowflake primary keys

* Test CI

* fix setTimeout type

* remove type node

* test e2e ci

* Revert "test e2e ci"

This reverts commit bf98a755dc.

* remove ci

* fix snowflake pk query in alternate schemas

* nit wait for coldefs

* nit snowflake

* Snowflake fk fix

* UNPROCESSABLE_ENTITY instead of INTERNAL_ERROR

* nits

* fix alter pk in snowflake

* yet other fixes

* snowflake tests pass

* nits
This commit is contained in:
Diego Imbert
2026-01-22 17:20:01 +01:00
committed by GitHub
parent 7385726741
commit a384b4c23d
58 changed files with 1694 additions and 784 deletions
-33
View File
@@ -167,39 +167,6 @@ jobs:
${{ steps.meta-ee-public.outputs.labels }}
org.opencontainers.image.licenses=Windmill-Enterprise-License
# disabled until we make it 100% reliable and add more meaningful tests
# playwright:
# runs-on: [self-hosted, new]
# needs: [build]
# services:
# postgres:
# image: postgres
# env:
# POSTGRES_DB: windmill
# POSTGRES_USER: admin
# POSTGRES_PASSWORD: changeme
# ports:
# - 5432:5432
# options: >-
# --health-cmd pg_isready
# --health-interval 10s
# --health-timeout 5s
# --health-retries 5
# steps:
# - uses: actions/checkout@v4
# - name: "Docker"
# run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest)"
# id: docker-container
# - uses: actions/setup-node@v3
# with:
# node-version: 16
# - name: "Playwright run"
# timeout-minutes: 2
# run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=http://localhost:8000 && npm run test
# - name: "Clean up"
# run: docker kill ${{ steps.docker-container.outputs.id }}
# if: always()
attach_amd64_binary_to_release:
needs: [build, build_ee]
runs-on: ubicloud
+20 -6
View File
@@ -64,7 +64,9 @@ use crate::{
concurrency_groups::join_concurrency_key,
db::{ApiAuthed, DB},
triggers::trigger_helpers::RunnableId,
users::{get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed},
users::{
get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed,
},
utils::{check_scopes, content_plain, require_super_admin},
};
use anyhow::Context;
@@ -3210,10 +3212,22 @@ pub async fn get_resume_urls_internal(
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
),
cancel: build_resume_url(
"cancel", &w_id, &target_job_id, &resume_id, &signature, &approver_query, &base_url,
"cancel",
&w_id,
&target_job_id,
&resume_id,
&signature,
&approver_query,
&base_url,
),
resume: build_resume_url(
"resume", &w_id, &target_job_id, &resume_id, &signature, &approver_query, &base_url,
"resume",
&w_id,
&target_job_id,
&resume_id,
&signature,
&approver_query,
&base_url,
),
};
@@ -5156,7 +5170,7 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
if success {
StatusCode::OK
} else {
StatusCode::INTERNAL_SERVER_ERROR
StatusCode::UNPROCESSABLE_ENTITY
},
Json(result),
)
@@ -5170,7 +5184,7 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
})
.unwrap_or_else(|| {
if !success {
Ok(StatusCode::INTERNAL_SERVER_ERROR)
Ok(StatusCode::UNPROCESSABLE_ENTITY)
} else if result_value.is_some() {
Ok(StatusCode::OK)
} else {
@@ -5220,7 +5234,7 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
if success {
StatusCode::OK
} else {
StatusCode::INTERNAL_SERVER_ERROR
StatusCode::UNPROCESSABLE_ENTITY
},
Json(result),
)
+9
View File
@@ -17,3 +17,12 @@ ui_builder_serve/
src/lib/components/copilot/chat/__tests__/flow/results/
src/lib/components/copilot/chat/__tests__/app/results/
.npmrc
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
e2e/auth.json
e2e/*.png
+519
View File
@@ -0,0 +1,519 @@
// Assume the db manager was already opened
import { expect, Locator, Page } from '@playwright/test'
import { getDbFeatures } from '../src/lib/components/apps/components/display/dbtable/dbFeatures'
import { ConfirmationModal, Dropdown, Toast } from './utils'
import { DbInput, DbType } from '../src/lib/components/dbTypes'
import { DB_TYPES } from '../src/lib/consts'
export async function runDbManagerSimpleCRUDTest(page: Page, dbType: _DbType) {
let dbManager = new DbManagerPage(page)
await dbManager.expectToBeVisible()
let friendTableName = identifier(dbType, `friend_${Date.now()}`)
// Create table
const tableEditor = await dbManager.openCreateTableDrawer()
await tableEditor.setTableName(friendTableName)
await tableEditor.addColumn(identifier(dbType, 'name'), getDbDatatype(dbType, 'TEXT'))
await tableEditor.getColumn('id').delete() // remove default id column
await tableEditor.createTable()
await Toast.expectSuccess(page, `${friendTableName} created`)
// Select and work with the table
await dbManager.selectTable(friendTableName)
// Insert a row
const insertDrawer = await dbManager.openInsertDrawer()
await insertDrawer.fillField(identifier(dbType, 'name'), 'Alice')
await insertDrawer.insert()
await Toast.expectSuccess(page, 'Row inserted')
// Verify and edit the cell
const dataGrid = dbManager.dataGrid()
await dataGrid.expectCellValue('Alice')
await dataGrid.editCell('Alice', 'Bob')
await Toast.expectSuccess(page, 'Value updated')
await dataGrid.expectCellValue('Bob')
// Delete the table
const actionsMenu = await dbManager.openActionsMenu(friendTableName)
await actionsMenu.deleteTable()
await Toast.expectSuccess(page, `Table '${friendTableName}' deleted successfully`)
}
export async function runDbManagerAlterTableTest(page: Page, dbType: _DbType) {
let dbFeatures = getDbFeatures(getDbInput(dbType))
let dbManager = new DbManagerPage(page)
let timestamp = Date.now()
await dbManager.expectToBeVisible()
if (dbFeatures.schemas) {
const schemaName = identifier(dbType, `schema_${timestamp}`)
await dbManager.setCurrentSchema(schemaName, { create: true })
}
// Create friend table
let friendTableName = identifier(dbType, `friend_${timestamp}`)
let tableEditor = await dbManager.openCreateTableDrawer()
await tableEditor.setTableName(friendTableName)
let friendIdCol = await tableEditor.getColumn('id') // deafult id column
await friendIdCol.setType(getDbDatatype(dbType, 'INT'))
if (dbFeatures.primaryKeys) {
friendIdCol.setPrimaryKey(true)
} else {
await expect(await friendIdCol.primaryKeyCheckbox()).toBeHidden()
}
await tableEditor.addColumn(identifier(dbType, 'name'), getDbDatatype(dbType, 'TEXT'))
await tableEditor.addColumn(identifier(dbType, 'created_at'), getDbDatatype(dbType, 'TIMESTAMP'))
await tableEditor.createTable()
await Toast.expectSuccess(page, `${friendTableName} created`)
await dbManager.selectTable(friendTableName) // Ensure the view refreshed
// Create message table
let messageTableName = identifier(dbType, `message_${timestamp}`)
tableEditor = await dbManager.openCreateTableDrawer()
await tableEditor.setTableName(messageTableName)
let messageIdCol = await tableEditor.getColumn('id') // deafult id column
await messageIdCol.setType(getDbDatatype(dbType, 'INT'))
if (dbFeatures.primaryKeys) messageIdCol.setPrimaryKey(true)
await tableEditor.addColumn(identifier(dbType, 'friend_id'), getDbDatatype(dbType, 'INT'))
let contentColumn = await tableEditor.addColumn(
identifier(dbType, 'content'),
getDbDatatype(dbType, 'TEXT')
)
await contentColumn.setSettings({ nullable: true })
await tableEditor.addColumn(identifier(dbType, 'created_at'), getDbDatatype(dbType, 'TIMESTAMP'))
if (dbFeatures.foreignKeys) {
await tableEditor.addForeignKey(
friendTableName,
identifier(dbType, 'friend_id'),
identifier(dbType, 'id'),
dbFeatures.enforcedForeignKeys ? { onDelete: 'Cascade', onUpdate: 'Cascade' } : undefined
)
} else {
await expect(tableEditor.foreignKeySection()).toBeHidden()
}
await tableEditor.createTable()
await Toast.expectSuccess(page, `${messageTableName} created`)
await page.waitForTimeout(100)
// Alter message table
await (await dbManager.openActionsMenu(messageTableName)).alterTable()
await tableEditor.expectNoChangesDetected()
let postsTableName = identifier(dbType, `posts_${timestamp}`)
let friendCol = tableEditor.getColumn(identifier(dbType, 'friend_id'))
let createdAtCol = tableEditor.getColumn(identifier(dbType, 'created_at'))
let idCol = tableEditor.getColumn(identifier(dbType, 'id'))
// Predicate checks
if (dbFeatures.primaryKeys) {
await idCol.checkPrimaryKeyIs(true)
await createdAtCol.checkPrimaryKeyIs(false)
await contentColumn.checkPrimaryKeyIs(false)
await friendCol.checkPrimaryKeyIs(false)
await friendCol.checkSettingsIs({
nullable: false,
defaultValue: dbFeatures.defaultValues ? '' : undefined
})
}
// Apply alterations
await tableEditor.setTableName(postsTableName)
await idCol.delete()
await friendCol.setName(identifier(dbType, 'person_id'))
if (dbType !== 'bigquery' && dbType !== 'snowflake') {
await friendCol.setType(getDbDatatype(dbType, 'BIGINT'))
}
if (dbType !== 'snowflake') {
// Snowflake does not support altering default values
await friendCol.setSettings({
defaultValue: dbFeatures.defaultValues ? '123' : undefined,
nullable: false
})
}
if (dbFeatures.primaryKeys && dbType !== 'bigquery') {
// Bigquery cannot rename a table with primary keys
await friendCol.setPrimaryKey(true)
await createdAtCol.setPrimaryKey(true)
}
if (dbFeatures.foreignKeys) await tableEditor.deleteForeignKey()
await tableEditor.alterTable()
await Toast.expectSuccess(page, `${messageTableName} updated`) // uses old table name
await page.waitForTimeout(100)
// Verify alterations
await dbManager.selectTable(postsTableName) // Ensure the view refreshed
await (await dbManager.openActionsMenu(postsTableName)).alterTable()
await tableEditor.expectNoChangesDetected()
tableEditor = new TableEditorDrawer(page)
await idCol.checkNotExists()
await friendCol.checkNameIs(identifier(dbType, 'person_id'))
if (dbType !== 'bigquery' && dbType !== 'snowflake') {
await friendCol.checkTypeIs(getDbDatatype(dbType, 'BIGINT'))
}
if (dbFeatures.defaultValues && dbType !== 'snowflake') {
await friendCol.checkSettingsIs({ defaultValue: /123/ })
}
await createdAtCol.checkTypeIs(
dbType === 'snowflake' ? 'TIMESTAMP_NTZ' : getDbDatatype(dbType, 'TIMESTAMP')
)
await createdAtCol.checkNameIs(identifier(dbType, 'created_at'))
if (dbFeatures.primaryKeys && dbType !== 'bigquery') {
await friendCol.checkPrimaryKeyIs(true)
await createdAtCol.checkPrimaryKeyIs(true)
await contentColumn.checkPrimaryKeyIs(false)
}
}
export class DbManagerPage {
page: Page
constructor(page: Page) {
this.page = page
}
async setCurrentSchema(schemaName: string, options?: { create?: boolean }) {
const schemaSelect = this.dbManager().locator('input[id="db-schema-select"]')
await schemaSelect.click()
await schemaSelect.fill(schemaName)
const option = Dropdown.getOption(this.page, schemaName).or(
Dropdown.getOption(this.page, 'Add new', { exact: false })
)
await option.click()
if (options?.create) {
await ConfirmationModal.confirm(this.page, '#db-create-schema-confirmation-modal', 'Create')
}
}
dbManager = () => this.page.locator('#db-manager-drawer')
expectToBeVisible = () => expect(this.dbManager()).toBeVisible()
async openCreateTableDrawer(): Promise<TableEditorDrawer> {
await this.dbManager().locator('button:has-text("New table")').click()
const tableEditor = new TableEditorDrawer(this.page)
await expect(tableEditor.tableEditor()).toBeVisible()
return tableEditor
}
async selectTable(tableName: string) {
const tableKey = this.page.locator('.db-manager-table-key', { hasText: tableName })
await expect(tableKey).toBeVisible()
await tableKey.click()
}
async openInsertDrawer(): Promise<InsertRowDrawer> {
await this.dbManager().locator('button:has-text("Insert")').click()
return new InsertRowDrawer(this.page)
}
dataGrid(): DataGrid {
return new DataGrid(this.page, this.dbManager())
}
async openActionsMenu(tableName: string): Promise<TableActionsMenu> {
const actionsBtn = this.dbManager().locator(`#db-manager-table-actions-${tableName}`)
await expect(actionsBtn).toBeVisible()
await actionsBtn.click()
return new TableActionsMenu(this.page)
}
}
class TableEditorDrawer {
page: Page
constructor(page: Page) {
this.page = page
}
tableEditor = () => this.page.locator('#db-table-editor-drawer')
columnsSection = () => this.tableEditor().locator('#columns-section')
foreignKeySection = () => this.page.locator('#foreign-keys-section')
async setTableName(name: string) {
const nameInput = this.tableEditor().locator('label:has-text("Name")').locator('input')
await nameInput.fill(name)
}
async addColumn(columnName: string, columnType: string) {
const columnsSection = this.columnsSection()
const addColumnButton = columnsSection.locator('button:has-text("Add")')
await addColumnButton.click()
// Set name before creating Column because it's identified by name
const newColRow = columnsSection.locator('tr').nth(-2)
const newColNameInput = newColRow.locator('td').nth(0).locator('input')
await newColNameInput.fill(columnName)
let column = this.getColumn(columnName)
await column.setType(columnType)
return column
}
async createTable() {
await this.tableEditor().locator('button:has-text("Create table")').click()
await ConfirmationModal.confirm(this.page, '#db-table-editor-confirmation-modal', 'Create')
}
async alterTable() {
await this.tableEditor().locator('button:has-text("Alter table")').click()
await ConfirmationModal.confirm(this.page, '#db-table-editor-confirmation-modal', 'Alter')
}
async deleteForeignKey() {
// TODO: do not assume a single foreign key
const fkSection = this.foreignKeySection()
const deleteBtn = fkSection.locator('.fk-delete-btn')
await deleteBtn.click()
}
async addForeignKey(
referencedTable: string,
fromCol: string,
toCol: string,
options?: { onDelete?: string; onUpdate?: string }
) {
const fkSection = this.foreignKeySection()
const addFkButton = fkSection.locator('button:has-text("Add")')
await addFkButton.click()
const lastFk = fkSection.locator('tr').nth(-2)
await Dropdown.selectOption(this.page, lastFk.locator('input.fk-table-select'), referencedTable)
await Dropdown.selectOption(this.page, lastFk.locator('.fk-source-col-select input'), fromCol)
await Dropdown.selectOption(this.page, lastFk.locator('.fk-target-col-select input'), toCol)
const fkSettings = lastFk.locator('.fk-settings-btn')
if (options?.onDelete || options?.onUpdate) {
await fkSettings.click()
if (options?.onDelete) {
const onDeleteSelect = this.page.locator('select.fk-on-delete-select')
await onDeleteSelect.selectOption({ label: options.onDelete })
}
if (options?.onUpdate) {
const onUpdateSelect = this.page.locator('select.fk-on-update-select')
await onUpdateSelect.selectOption({ label: options.onUpdate })
}
// Close the popover
await fkSettings.click()
}
}
async expectNoChangesDetected() {
const btn = this.tableEditor().locator(`button:has-text("No changes detected")`)
return await expect(btn).toBeVisible()
}
getColumn(columnName: string): Column {
return new Column(this.page, this.columnsSection(), columnName)
}
}
class Column {
columnsSection: Locator
page: Page
columnName: string
constructor(page: Page, columnsSection: Locator, columnName: string) {
this.page = page
this.columnsSection = columnsSection
this.columnName = columnName
}
async rowOrUndefined(): Promise<Locator | undefined> {
const rows = await this.columnsSection.locator('tr:has(input)').all()
for (const row of rows) {
const val = await row.locator('input').first().inputValue()
if (val === this.columnName) return row
}
}
row = async () => {
let row = await this.rowOrUndefined()
if (!row) {
await this.page.waitForTimeout(500)
row = await this.rowOrUndefined()
}
if (!row) throw new Error(`Column with name ${this.columnName} not found`)
return row
}
primaryKeyCheckbox = async () => (await this.row()).locator('input.primary-key-checkbox')
async setName(columnName: string) {
const newColNameInput = (await this.row()).locator('td').nth(0).locator('input')
await newColNameInput.fill(columnName)
this.columnName = columnName
}
async checkNameIs(columnName: string) {
const newColNameInput = (await this.row()).locator('td').nth(0).locator('input')
await expect(newColNameInput).toHaveValue(columnName)
}
async setType(columnType: string) {
await Dropdown.selectOption(
this.page,
(await this.row()).locator('td').nth(1).locator('input'),
columnType
)
}
async checkTypeIs(columnType: string) {
const newColTypeSelect = (await this.row()).locator('td').nth(1).locator('input')
await expect(newColTypeSelect).toHaveValue(new RegExp(`^${columnType}$`, 'i'))
}
async delete() {
const deleteBtn = (await this.row()).locator('button.delete-column-btn')
await deleteBtn.click()
await this.page.waitForTimeout(50)
}
async setPrimaryKey(isPrimaryKey: boolean) {
let primaryKeyCheckbox = await this.primaryKeyCheckbox()
const isChecked = await primaryKeyCheckbox.isChecked()
if (isChecked !== isPrimaryKey) {
await primaryKeyCheckbox.click()
}
}
async checkPrimaryKeyIs(isPrimaryKey: boolean) {
let primaryKeyCheckbox = await this.primaryKeyCheckbox()
const isChecked = await primaryKeyCheckbox.isChecked()
expect(isChecked).toBe(isPrimaryKey)
}
async setSettings(options: { nullable?: boolean; defaultValue?: string }) {
const settingsBtn = (await this.row()).locator('.settings-menu-btn')
await settingsBtn.click()
if (options.defaultValue !== undefined) {
const defaultValueInput = this.page.locator('input.default-value')
await defaultValueInput.fill(options.defaultValue)
}
if (options.nullable !== undefined) {
const nullableCheckbox = this.page.locator('input.nullable-checkbox')
const isChecked = await nullableCheckbox.isChecked()
if (isChecked !== options.nullable) {
await nullableCheckbox.click()
}
}
// Close the popover
await settingsBtn.click()
}
async checkSettingsIs(options: { nullable?: boolean; defaultValue?: string | RegExp }) {
const settingsBtn = (await this.row()).locator('.settings-menu-btn')
await settingsBtn.click()
if (options.defaultValue !== undefined) {
const defaultValueInput = this.page.locator('input.default-value')
await expect(defaultValueInput).toHaveValue(options.defaultValue)
}
if (options.nullable !== undefined) {
const nullableCheckbox = this.page.locator('input.nullable-checkbox')
const isChecked = await nullableCheckbox.isChecked()
expect(isChecked).toBe(options.nullable)
}
// Close the popover
await settingsBtn.click()
}
async checkNotExists() {
expect(await this.rowOrUndefined()).toBeUndefined()
}
}
class InsertRowDrawer {
page: Page
constructor(page: Page) {
this.page = page
}
drawer = () => this.page.locator('#insert-row-drawer')
async fillField(fieldName: string, value: string) {
await expect(this.drawer()).toBeVisible()
// For now, assumes single field - could be enhanced to handle multiple fields
await this.drawer().locator('textarea').fill(value, { force: true })
}
async insert() {
await this.drawer().locator('button:has-text("Insert")').click()
}
}
class DataGrid {
page: Page
dbManager: Locator
constructor(page: Page, dbManager: Locator) {
this.page = page
this.dbManager = dbManager
}
async expectCellValue(value: string) {
const cell = this.dbManager.locator('.ag-cell-value', { hasText: value })
await expect(cell).toBeVisible()
}
async editCell(oldValue: string, newValue: string) {
const cell = this.dbManager.locator('.ag-cell-value', { hasText: oldValue })
await cell.dblclick()
const cellEditor = this.dbManager.locator('.ag-cell-editor input')
await cellEditor.fill(newValue)
await cellEditor.press('Enter')
}
}
class TableActionsMenu {
page: Page
constructor(page: Page) {
this.page = page
}
async deleteTable() {
await this.page.locator('button:has-text("Delete table")').click()
await ConfirmationModal.confirm(
this.page,
'#db-manager-delete-table-confirmation-modal',
'Delete'
)
}
async alterTable() {
await this.page.locator('button:has-text("Alter table")').click()
}
}
type _DbType = Exclude<DbType, 'duckdb'> | 'ducklake'
function getDbInput(dbType: _DbType): DbInput {
if (dbType === 'ducklake') {
return { type: 'ducklake', ducklake: '' }
} else {
return { type: 'database', resourceType: dbType, resourcePath: '' }
}
}
// Ensure exact casing of datatype as per DB_TYPES
function getDbDatatype(dbType: _DbType, datatype: string): string {
if (dbType === 'ms_sql_server' && datatype.toLowerCase() === 'timestamp') datatype = 'datetime2'
if (dbType === 'bigquery' && datatype.toLowerCase() === 'text') datatype = 'string'
if (dbType === 'bigquery' && datatype.toLowerCase() === 'int') datatype = 'int64'
if (dbType === 'snowflake' && datatype.toLowerCase() === 'text') datatype = 'varchar'
const allDataTypes = DB_TYPES[dbType == 'ducklake' ? 'duckdb' : dbType] || []
return allDataTypes.find((dt) => dt.toLowerCase() === datatype.toLowerCase()) || datatype
}
function identifier(dbType: _DbType, baseName: string): string {
baseName = baseName.replace(/[^a-zA-Z0-9_]/g, '_').trim()
if (dbType === 'snowflake') return baseName.toUpperCase()
return baseName
}
+335
View File
@@ -0,0 +1,335 @@
import { test, expect, Page, Locator } from '@playwright/test'
import { runDbManagerAlterTableTest, runDbManagerSimpleCRUDTest } from './DbManagerPage'
import { DbType } from '../src/lib/components/dbTypes'
import { Dropdown, Toast, prettify } from './utils'
const resourceByDbType = {
postgresql: getJsonEnv('POSTGRESQL_RESOURCE') ?? {
host: 'postgres_e2e',
port: 5432,
dbname: 'test_db',
user: 'test_user',
password: 'postgres_password',
sslmode: 'disable'
},
mysql: getJsonEnv('MYSQL_RESOURCE') ?? {
host: 'mysql_e2e',
port: 3306,
user: 'test_user',
database: 'test_db',
password: 'test_password',
ssl: false
},
oracledb: getJsonEnv('ORACLEDB_RESOURCE') ?? {
user: 'test_user',
password: 'test_password',
database: 'oracle_e2e:1521/test_db'
},
ms_sql_server: getJsonEnv('MS_SQL_SERVER_RESOURCE') ?? {
host: 'mssql_e2e',
user: 'sa',
password: 'MsSql_Pass123!',
port: 1433,
dbname: 'master',
instance_name: '',
trust_cert: true,
ca_cert: '',
encrypt: true
},
bigquery: getJsonEnv('BIGQUERY_RESOURCE'),
snowflake: getJsonEnv('SNOWFLAKE_RESOURCE')
} as const
test.describe('Database resources', () => {
for (const dbType of ['postgresql', 'mysql', 'bigquery', 'ms_sql_server', 'snowflake'] as const) {
test.describe(prettify(dbType), () => {
test.skip(
resourceByDbType[dbType] === undefined,
`No resource config for ${dbType}, set ${dbType.toUpperCase()}_RESOURCE`
)
test(`simple CRUD with DB Manager`, async ({ page }) => {
await setupNewResourceAndOpenDbManager(page, dbType)
await runDbManagerSimpleCRUDTest(page, dbType)
})
test(`Alter table with DB Manager`, async ({ page }) => {
await setupNewResourceAndOpenDbManager(page, dbType)
await runDbManagerAlterTableTest(page, dbType)
})
})
}
})
test.describe('Data tables', () => {
test('simple CRUD with DB Manager', async ({ page }) => {
await openDataTableDbManager(page)
await runDbManagerSimpleCRUDTest(page, 'postgresql')
})
test('Alter table with DB Manager', async ({ page }) => {
await openDataTableDbManager(page)
await runDbManagerAlterTableTest(page, 'postgresql')
})
})
test.describe('Ducklake', () => {
// Avoid race condition when saving settings.
// Be careful when adding new tests here.
// TODO : find a way to run these tests in parallel (setup for all storage resources ?)
test.describe.configure({ mode: 'serial' })
test('simple CRUD with DB Manager', async ({ page }) => {
await openDucklakeDbManager(page, 's3')
await runDbManagerSimpleCRUDTest(page, 'ducklake')
})
test('Alter table with DB Manager', async ({ page }) => {
await openDucklakeDbManager(page, 's3')
await runDbManagerAlterTableTest(page, 'ducklake')
})
})
async function openDucklakeDbManager(page: Page, resource_type: StorageResourceType) {
let { storage } = await setupWsStorage(page, resource_type)
await page.goto('/workspace_settings?tab=windmill_lfs')
const timestamp = Date.now()
await page.locator('button:has-text("New Ducklake")').click()
let lastRow = page.locator('.ducklake-settings-table').locator('tr').nth(-2)
await lastRow.locator('input.ducklake-name').fill(`ducklake_${timestamp}`)
await lastRow.locator('.ducklake-workspace-storage-select').click()
await Dropdown.getOption(page, storage, { exact: false }).click()
await lastRow.locator('input.ducklake-storage-data-path').fill(`ducklake_${timestamp}`)
await setupCustomInstanceDb(lastRow, page, storage)
await page.locator('button:has-text("Save ducklake settings")').click()
if (await page.locator('text=Some databases are not setup').isVisible()) {
await page.locator('button:has-text("Save anyway")').click()
}
await Toast.expectSuccess(page, 'Ducklake settings saved successfully')
await lastRow.locator('button:has-text("Manage")').click()
}
async function openDataTableDbManager(page: Page) {
await page.goto('/workspace_settings?tab=windmill_data_tables')
await setupNewDataTable(page, `datatable_${process.env.TEST_UNIQUE_ID}`)
const datatableId = `datatable_${process.env.TEST_UNIQUE_ID}`
let table = page.locator('table')
await table.waitFor({ state: 'visible' })
let rows = await table.locator('tr:has(input[id="name"])').all()
for (const row of rows) {
const val = await row.locator('input[id="name"]').inputValue()
if (val === datatableId) {
await row.locator('button:has-text("Manage")').click()
return
}
}
throw new Error(`Could not find datatable row ${datatableId}`)
}
declare const process: any // ignore TS errors
async function setupNewDataTable(page: Page, datatableId: string) {
// Check if datatable already exists
let table = page.locator('table')
await table.waitFor({ state: 'visible' })
let rows = await table.locator('tr:has(input[id="name"])').all()
for (const row of rows) {
const val = await row.locator('input[id="name"]').inputValue()
// Don't setup again if it already exists (saving the settings creates race condition)
if (val === datatableId) return
}
// Click on 'New Data Table' button
const newDataTableButton = page.locator('button:has-text("New Data Table")')
await newDataTableButton.click()
// Find the second-to-last row in the table (last row contains the button)
const lastRow = table.locator('tr').nth(-2)
// Fill the name input with generated ID
const nameInput = lastRow.locator('input[id="name"]')
await nameInput.fill(datatableId)
// Verify database type is 'Instance'
const databaseTypeSelect = lastRow.locator('input[id="database-type-select"]')
await expect(databaseTypeSelect).toHaveValue('Instance')
await setupCustomInstanceDb(lastRow, page, datatableId)
const saveBtn = page.locator('button:has-text("Save")')
await saveBtn.click()
if (await page.locator('text=Some databases are not setup').isVisible()) {
await page.locator('button:has-text("Save anyway")').click()
}
// Verify success toast appears
await Toast.expectSuccess(page, 'saved successfully')
return { datatableId }
}
async function setupCustomInstanceDb(row: Locator, page: Page, name: string) {
// Click on custom instance DB select and add new database
const customInstanceDbSelect = row.locator('input[id="custom-instance-db-select"]')
await customInstanceDbSelect.waitFor({ state: 'visible' })
await customInstanceDbSelect.click()
await customInstanceDbSelect.fill(name)
// Check if the database already exists in the dropdown
if (await Dropdown.getOption(page, name).isVisible()) {
await Dropdown.getOption(page, name).click()
return
}
// Click on the "Add new:" button
const addNewButton = page.locator(`button:has-text("Add new: ")`)
await addNewButton.waitFor({ state: 'visible' })
await addNewButton.click()
// Click on Setup button
const setupButton = row.locator('button:has-text("Setup")')
await setupButton.waitFor({ state: 'visible' })
await setupButton.click()
// Wait for popover to appear and click run setup button
const runSetupButton = page.locator('button[id="run-custom-instance-db-setup-button"]')
await runSetupButton.waitFor({ state: 'visible' })
await runSetupButton.click()
const confirmBtn = page.locator('button:has-text("Setup database")')
await confirmBtn.waitFor({ state: 'visible' })
await confirmBtn.click()
// Verify success toast appears
await Toast.expectSuccess(page, 'Setup successful')
const closeModalBtn = page.locator('button[id="modal-close-button"]')
await closeModalBtn.click()
}
function getJsonEnv(name: string): object | undefined {
const env = process.env[name]
if (env) return JSON.parse(env)
}
const wsStorageResources = {
s3: {
bucket: 'test-bucket',
region: 'us-east-1',
useSSL: false,
endPoint: 'minio-e2e:9000',
accessKey: 'minioadmin',
pathStyle: true,
secretKey: 'minioadmin'
}
}
const storageResourceTypeDropdownLabels = {
s3: 'S3',
azure_blob: 'Azure Blob',
s3_aws_oidc: 'AWS OIDC',
azure_workload_identity: 'Azure Workload Identity',
gcloud_storage: 'Google Cloud Storage'
}
type StorageResourceType = keyof typeof wsStorageResources
async function setupWsStorage(
page: Page,
resource_type: StorageResourceType
): Promise<{ storage: string }> {
let { resourceName } = await setupNewResource(
page,
resource_type,
wsStorageResources[resource_type]
)
let storage = `${resource_type}_storage_e2e`
await page.goto('/workspace_settings?tab=windmill_lfs')
await page.locator('.storage-settings-table').waitFor({ state: 'visible' })
let rows = await page
.locator('.storage-settings-table tr:has(input.secondary-storage-name-input)')
.all()
for (const row of rows) {
const val = await row.locator('input.secondary-storage-name-input').inputValue()
if (val === storage) return { storage }
}
// Click on the Add Storage button
await page.locator('button:has-text("Add secondary storage")').click()
let lastRow = page.locator('.storage-settings-table tr').nth(-2)
// Fill the name input with generated ID
const nameInput = lastRow.locator('input.secondary-storage-name-input')
await nameInput.fill(storage)
// Select resource type
await lastRow.locator('#storage-resource-type-select').click()
const dropdownResourceTypeLabel =
storageResourceTypeDropdownLabels[resource_type] ?? resource_type
await Dropdown.getOption(page, dropdownResourceTypeLabel).click()
// Select resource
await lastRow.locator('#resource-picker-select').click()
await Dropdown.getOption(page, resourceName, { exact: false }).click()
await page.locator('button:has-text("Save storage settings")').click()
await Toast.expectSuccess(page, 'storage settings changed')
return { storage }
}
async function setupNewResource(
page: Page,
resourceType: string,
resourceObj: object
): Promise<{ resourceName: string }> {
const resourceName = `${resourceType}_e2e`
await page.goto('/resources')
await page.locator('table').waitFor({ state: 'visible' })
if (await page.locator(`table tr:has-text("${resourceName}")`).isVisible())
return { resourceName }
const newDataTableButton = page.locator('button:text-is("Add resource")')
await newDataTableButton.click()
const addResourceDrawer = page.locator('#add-resource-drawer')
await expect(addResourceDrawer).toBeVisible()
const searchInput = addResourceDrawer.locator('input#search-resource-type')
await searchInput.fill(resourceType)
const resourceTypeBtn = addResourceDrawer.locator(`button:has(:text-is("${resourceType}"))`)
await resourceTypeBtn.waitFor({ state: 'visible' })
await resourceTypeBtn.click()
const asJsonToggle = addResourceDrawer.locator('label.as-json-toggle')
await expect(asJsonToggle).toBeVisible()
await asJsonToggle.check()
await addResourceDrawer.locator('input#path').fill(resourceName)
const jsonEditor = page.locator('.simple-editor .view-lines')
await expect(jsonEditor).toBeVisible()
await jsonEditor.click({ clickCount: 4 }) // Select all existing text
console.log('Pasting resource config:', resourceObj)
await page.evaluate((c) => navigator.clipboard.writeText(c), JSON.stringify(resourceObj))
await page.keyboard.press('ControlOrMeta+V')
const saveButton = addResourceDrawer.locator('button:has-text("Save")')
await saveButton.click()
await Toast.expectSuccess(page, 'Saved resource')
return { resourceName }
}
async function setupNewResourceAndOpenDbManager(page: Page, dbType: DbType) {
let { resourceName } = await setupNewResource(page, dbType, resourceByDbType[dbType])
const resourceRow = page.locator(`table tr:has-text("${resourceName}")`)
await expect(resourceRow).toBeVisible()
const manageButton = resourceRow.locator('button:has-text("Manage")')
await manageButton.click()
}
+70
View File
@@ -0,0 +1,70 @@
// global-setup.ts
import { chromium, FullConfig } from '@playwright/test'
async function globalSetup(config: FullConfig) {
process.env.TEST_UNIQUE_ID = Date.now().toString()
const browser = await chromium.launch()
const context = await browser.newContext({
permissions: ['clipboard-read', 'clipboard-write']
})
const page = await context.newPage()
// Use baseURL from config
const baseURL = config.projects[0].use.baseURL || 'http://localhost:3000'
await page.goto(baseURL)
// Wait for and fill the email input
const emailInput = page.locator('input#email[type="email"]')
await emailInput.waitFor({ state: 'visible' })
await emailInput.fill('admin@windmill.dev')
// Wait for and fill the password input
const passwordInput = page.locator('input#password[type="password"]')
await passwordInput.waitFor({ state: 'visible' })
await passwordInput.fill('changeme')
// Click the sign-in button
const signInButton = page.locator('button:has-text("Sign in")')
await signInButton.click()
// Handle potential first-time user flow
await page.waitForURL(/\/user\/(first-time|workspaces)/)
if (page.url().includes('/user/first-time')) {
const skipButton = page.locator('button:has-text("Skip")')
await skipButton.click()
}
// Wait for navigation to workspaces page
await page.waitForURL('**/user/workspaces')
// Click on the "Admins" workspace
const adminsWorkspace = page.locator('text=Admins').first()
await adminsWorkspace.waitFor({ state: 'visible' })
await adminsWorkspace.click()
// Wait for workspace to load
await page.waitForURL('**/')
if (!process.env.SKIP_HUB_SYNC) {
await page.locator('#home-search-input').waitFor({ state: 'visible' })
await page.locator('#home-search-input').fill('u/admin/hub_sync')
const hubSyncBtn = page.locator('text=u/admin/hub_sync').first()
await hubSyncBtn.waitFor({ state: 'visible' })
await hubSyncBtn.click()
const runBtn = page.locator('#run-form-run-button')
await runBtn.waitFor({ state: 'visible' })
await runBtn.click()
await page.waitForURL(/\/run\/.+/)
await page.locator('text=Success').waitFor({ timeout: 30000, state: 'visible' })
}
// Save the authenticated state
await context.storageState({ path: './e2e/auth.json' })
await browser.close()
}
export default globalSetup
declare const process: any // ignore TS errors
+46
View File
@@ -0,0 +1,46 @@
import { expect, Locator, Page } from '@playwright/test'
export class Toast {
static async expectSuccess(page: Page, message: string) {
const toast = page.locator(`.toast-success:has-text("${message}")`)
await expect(toast).toBeVisible({ timeout: 10000 })
}
static async expectError(page: Page, message: string) {
const toast = page.locator(`.toast-error:has-text("${message}")`)
await expect(toast).toBeVisible({ timeout: 10000 })
}
}
export class ConfirmationModal {
static async confirm(page: Page, modalSelector: string, buttonText: string) {
const confirmBtn = page.locator(`${modalSelector} button:has-text("${buttonText}")`)
await confirmBtn.click()
await page.locator(modalSelector).waitFor({ state: 'hidden' })
}
static async expectText(page: Page, modalSelector: string, text: string) {
const modal = page.locator(modalSelector)
await expect(modal).toHaveText(text)
}
}
export const prettify = (s: string) => (s.charAt(0).toUpperCase() + s.slice(1)).replace(/_/g, ' ')
export class Dropdown {
static getOption(page: Page, optionText: string, { exact = true } = {}) {
if (exact) return page.locator(`.select-dropdown-open li:has(:text-is("${optionText}"))`)
else return page.locator(`.select-dropdown-open li:has-text("${optionText}")`)
}
static async selectOption(
page: Page,
selectInput: Locator,
optionText: string,
{ exact = true } = {}
) {
await selectInput.click()
await selectInput.fill(optionText)
const option = this.getOption(page, optionText, { exact })
await option.click()
}
}
+52 -176
View File
@@ -99,7 +99,7 @@
"@hey-api/openapi-ts": "^0.43.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.2",
"@playwright/test": "^1.34.3",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/package": "^2.3.7",
@@ -113,7 +113,6 @@
"@types/vscode": "^1.83.5",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.60.0",
"@vitest/browser-playwright": "^4.0.10",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.13",
"cssnano": "^6.0.1",
@@ -125,7 +124,6 @@
"json-refs": "^3.0.15",
"json-schema-to-zod": "^2.7.0",
"path-browserify": "^1.0.1",
"playwright": "^1.56.1",
"postcss": "^8.4.49",
"postcss-load-config": "^4.0.1",
"prettier": "^3.1.0",
@@ -260,7 +258,6 @@
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/helper-validator-identifier": "^7.27.1",
"js-tokens": "^4.0.0",
@@ -276,7 +273,6 @@
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -822,7 +818,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -1332,6 +1327,7 @@
"integrity": "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/core": "^1.3.1",
"@floating-ui/dom": "^1.4.5",
@@ -1456,13 +1452,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz",
"integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==",
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.56.0"
"playwright": "1.57.0"
},
"bin": {
"playwright": "cli.js"
@@ -1471,53 +1467,6 @@
"node": ">=18"
}
},
"node_modules/@playwright/test/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz",
"integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.56.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz",
"integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -2154,6 +2103,7 @@
"integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.5",
@@ -2231,6 +2181,7 @@
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
"debug": "^4.4.1",
@@ -2757,8 +2708,7 @@
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
@@ -2771,8 +2721,7 @@
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz",
"integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/semver": {
"version": "7.7.1",
@@ -2842,6 +2791,7 @@
"integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.62.0",
"@typescript-eslint/types": "5.62.0",
@@ -3009,6 +2959,7 @@
"integrity": "sha512-zedtczX688KehaIaAv7m25CeDLb0gBtAOa2Oi1G1cqvSO5aLSVfH6lpZMJLW8BKYuWMxLQc9/5GYoM+jgvGIrw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@vitest/mocker": "4.0.15",
"@vitest/utils": "4.0.15",
@@ -3032,6 +2983,7 @@
"integrity": "sha512-94yVpDbb+ykiT7mK6ToonGnq2GIHEQGBTZTAzGxBGQXcVNCh54YKC2/WkfaDzxy0m6Kgw05kq3FYHKHu+wRdIA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@vitest/browser": "4.0.15",
"@vitest/mocker": "4.0.15",
@@ -3056,6 +3008,7 @@
"integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@vitest/spy": "4.0.15",
"estree-walker": "^3.0.3",
@@ -3083,6 +3036,7 @@
"integrity": "sha512-CZ28GLfOEIFkvCFngN8Sfx5h+Se0zN+h4B7yOsPVCcgtiO7t5jt9xQh2E1UkFep+eb9fjyMfuC5gBypwb07fvQ==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@vitest/spy": "4.0.15",
"estree-walker": "^3.0.3",
@@ -3314,6 +3268,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3367,6 +3322,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -3512,7 +3468,6 @@
"integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3540,7 +3495,6 @@
"integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -3633,8 +3587,7 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
"integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
@@ -3761,6 +3714,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
@@ -3958,7 +3912,6 @@
"integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"camelcase": "^6.3.0",
"map-obj": "^4.1.0",
@@ -3978,7 +3931,6 @@
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -3992,7 +3944,6 @@
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4006,7 +3957,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4115,6 +4065,7 @@
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@kurkle/color": "^0.3.0"
},
@@ -4355,7 +4306,6 @@
"integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
@@ -4420,7 +4370,6 @@
"integrity": "sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12 || >=16"
}
@@ -4686,6 +4635,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -4739,6 +4689,7 @@
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.21.0"
},
@@ -4773,7 +4724,6 @@
"integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -4787,7 +4737,6 @@
"integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"decamelize": "^1.1.0",
"map-obj": "^1.0.0"
@@ -4805,7 +4754,6 @@
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4816,7 +4764,6 @@
"integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5302,7 +5249,6 @@
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"is-arrayish": "^0.2.1"
}
@@ -5390,6 +5336,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5936,7 +5883,6 @@
"integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.9.1"
}
@@ -6422,7 +6368,6 @@
"integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"global-prefix": "^3.0.0"
},
@@ -6436,7 +6381,6 @@
"integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ini": "^1.3.5",
"kind-of": "^6.0.2",
@@ -6452,7 +6396,6 @@
"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -6502,8 +6445,7 @@
"resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
"integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
@@ -6580,7 +6522,6 @@
"integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6"
}
@@ -6802,7 +6743,6 @@
"integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
@@ -6816,7 +6756,6 @@
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"yallist": "^4.0.0"
},
@@ -6829,8 +6768,7 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/html-tags": {
"version": "3.3.1",
@@ -6838,7 +6776,6 @@
"integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
},
@@ -6922,7 +6859,6 @@
"integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6943,7 +6879,6 @@
"integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7015,8 +6950,7 @@
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
@@ -7121,7 +7055,6 @@
"integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7132,7 +7065,6 @@
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7231,8 +7163,7 @@
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
@@ -7268,8 +7199,7 @@
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/json-refs": {
"version": "3.0.15",
@@ -7388,7 +7318,6 @@
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7967,8 +7896,7 @@
"resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
"integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/lodash.uniq": {
"version": "4.5.0",
@@ -8036,7 +7964,6 @@
"integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
},
@@ -8087,7 +8014,6 @@
"integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -8328,7 +8254,6 @@
"integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/minimist": "^1.2.2",
"camelcase-keys": "^7.0.0",
@@ -8356,7 +8281,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -9050,7 +8974,6 @@
"integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"arrify": "^1.0.1",
"is-plain-obj": "^1.1.0",
@@ -9129,6 +9052,7 @@
"resolved": "https://registry.npmjs.org/@codingame/monaco-vscode-editor-api/-/monaco-vscode-editor-api-25.0.0.tgz",
"integrity": "sha512-uiY06RTWFo2WZdh6OybkLlDhuG+8LlkjUDpr9/wW55uucqHo4X8fx4XKEtD98cscC+6FKQkbG2yyUiOJ/npHOw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codingame/monaco-vscode-api": "25.0.0"
}
@@ -9365,7 +9289,6 @@
"integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"hosted-git-info": "^4.0.1",
"is-core-module": "^2.5.0",
@@ -9711,7 +9634,6 @@
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
@@ -9922,6 +9844,7 @@
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dev": true,
"license": "ISC",
"optional": true,
"dependencies": {
"pngjs": "^7.0.0"
},
@@ -10010,6 +9933,7 @@
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14.19.0"
}
@@ -10034,6 +9958,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10222,6 +10147,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"lilconfig": "^3.0.0",
"yaml": "^2.3.4"
@@ -10611,8 +10537,7 @@
"resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz",
"integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/postcss-safe-parser": {
"version": "6.0.0",
@@ -10788,6 +10713,7 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -11092,7 +11018,6 @@
"integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/normalize-package-data": "^2.4.0",
"normalize-package-data": "^3.0.2",
@@ -11112,7 +11037,6 @@
"integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"find-up": "^5.0.0",
"read-pkg": "^6.0.0",
@@ -11131,7 +11055,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -11145,7 +11068,6 @@
"integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==",
"dev": true,
"license": "(MIT OR CC0-1.0)",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -11188,7 +11110,6 @@
"integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"indent-string": "^5.0.0",
"strip-indent": "^4.0.0"
@@ -11825,7 +11746,6 @@
"integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"astral-regex": "^2.0.0",
@@ -11902,7 +11822,6 @@
"integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"spdx-expression-parse": "^3.0.0",
"spdx-license-ids": "^3.0.0"
@@ -11913,8 +11832,7 @@
"resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz",
"integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==",
"dev": true,
"license": "CC-BY-3.0",
"peer": true
"license": "CC-BY-3.0"
},
"node_modules/spdx-expression-parse": {
"version": "3.0.1",
@@ -11922,7 +11840,6 @@
"integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"spdx-exceptions": "^2.1.0",
"spdx-license-ids": "^3.0.0"
@@ -11933,8 +11850,7 @@
"resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.22.tgz",
"integrity": "sha512-4PRT4nh1EImPbt2jASOKHX7PB7I+e4IWNLvkKFDxNhJlfjbYlleYQh285Z/3mPTHSAK/AvdMmw5BNNuYH8ShgQ==",
"dev": true,
"license": "CC0-1.0",
"peer": true
"license": "CC0-1.0"
},
"node_modules/sprintf-js": {
"version": "1.0.3",
@@ -12028,7 +11944,6 @@
"integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12054,8 +11969,7 @@
"resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
"integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==",
"dev": true,
"license": "ISC",
"peer": true
"license": "ISC"
},
"node_modules/style-to-object": {
"version": "0.4.4",
@@ -12104,7 +12018,6 @@
"integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^2.3.1",
"@csstools/css-tokenizer": "^2.2.0",
@@ -12187,7 +12100,6 @@
}
],
"license": "MIT-0",
"peer": true,
"engines": {
"node": "^14 || ^16 || >=18"
},
@@ -12201,7 +12113,6 @@
"integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"flat-cache": "^3.2.0"
},
@@ -12214,8 +12125,7 @@
"resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz",
"integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/stylelint/node_modules/postcss-selector-parser": {
"version": "6.1.2",
@@ -12238,7 +12148,6 @@
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -12373,7 +12282,6 @@
"integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"has-flag": "^4.0.0",
"supports-color": "^7.0.0"
@@ -12403,6 +12311,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.39.12.tgz",
"integrity": "sha512-CEzwxFuEycokU8K8CE/OuwVbmei+ivu2HvBGYIdASfMa1hCRSNr4RRkzNSvbAvu6h+BOig2CsZTAEY+WKvwZpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
@@ -12498,21 +12407,6 @@
}
}
},
"node_modules/svelte-check/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.43.0",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.43.0.tgz",
@@ -12700,8 +12594,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
"integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==",
"dev": true,
"peer": true
"dev": true
},
"node_modules/svgo": {
"version": "3.3.2",
@@ -12752,7 +12645,6 @@
"integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"ajv": "^8.0.1",
"lodash.truncate": "^4.4.2",
@@ -12780,6 +12672,7 @@
"integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -13022,6 +12915,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13084,7 +12978,6 @@
"integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13183,6 +13076,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13397,7 +13291,6 @@
"integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"spdx-correct": "^3.0.0",
"spdx-expression-parse": "^3.0.0"
@@ -13452,6 +13345,7 @@
"integrity": "sha512-5hI5NCJwKBGtzWtdKB3c2fOEpI77Iaa0z4mSzZPU1cJ/OqrGbFafm90edVCd7T9Snz+Sh09TMAv4EQqyVLzuEg==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/runtime": "0.101.0",
"fdir": "^6.5.0",
@@ -13564,6 +13458,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13577,6 +13472,7 @@
"integrity": "sha512-n1RxDp8UJm6N0IbJLQo+yzLZ2sQCDyl1o0LeugbPWf8+8Fttp29GghsQBjYJVmWq3gBFfe9Hs1spR44vovn2wA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.15",
"@vitest/mocker": "4.0.15",
@@ -14205,6 +14101,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14228,6 +14125,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -14727,7 +14625,6 @@
"integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
@@ -14736,28 +14633,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-utils": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz",
@@ -14945,7 +14820,6 @@
"integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
"dev": true,
"license": "ISC",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -14965,6 +14839,7 @@
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
@@ -15000,6 +14875,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+3 -5
View File
@@ -15,16 +15,16 @@
"generate-backend-client": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript --format false",
"generate-backend-client-mac": "openapi-ts --input ../backend/windmill-api/openapi.yaml --output ./src/lib/gen --useOptions --enums javascript",
"pretest": "tsc --incremental -p tests/tsconfig.json",
"test": "playwright test --config=tests-out/playwright.config.js && npm run test:unit -- --run",
"filter-classes": "node filterTailwindClasses.js",
"test:unit": "vitest"
"test:unit": "vitest",
"test:e2e": "playwright test"
},
"devDependencies": {
"@floating-ui/core": "^1.3.1",
"@hey-api/openapi-ts": "^0.43.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.2",
"@playwright/test": "^1.34.3",
"@playwright/test": "^1.57.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/package": "^2.3.7",
@@ -38,7 +38,6 @@
"@types/vscode": "^1.83.5",
"@typescript-eslint/eslint-plugin": "^5.59.8",
"@typescript-eslint/parser": "^5.60.0",
"@vitest/browser-playwright": "^4.0.10",
"@zerodevx/svelte-toast": "^0.9.6",
"autoprefixer": "^10.4.13",
"cssnano": "^6.0.1",
@@ -50,7 +49,6 @@
"json-refs": "^3.0.15",
"json-schema-to-zod": "^2.7.0",
"path-browserify": "^1.0.1",
"playwright": "^1.56.1",
"postcss": "^8.4.49",
"postcss-load-config": "^4.0.1",
"prettier": "^3.1.0",
+72
View File
@@ -0,0 +1,72 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
globalSetup: './e2e/global-setup',
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
timeout: 90 * 1000,
expect: { timeout: 25 * 1000 },
use: {
actionTimeout: 25 * 1000,
/* Base URL to use in actions like `await page.goto('')`. */
baseURL: process.env.BASE_URL || 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
storageState: './e2e/auth.json',
screenshot: 'only-on-failure',
contextOptions: {
reducedMotion: 'reduce'
}
},
testMatch: /.*\.spec\.ts/,
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
]
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
})
declare const process: any
@@ -145,6 +145,7 @@
options={{
right: 'As JSON'
}}
class="as-json-toggle"
/>
<TestConnection {resourceType} {args} />
{#if resourceType == 'postgresql'}
@@ -46,6 +46,7 @@
>
<DrawerContent
title="Add a resource"
id="add-resource-drawer"
on:close={drawer.closeDrawer}
tooltip="Resources represent connections to third party systems. Learn more on how to integrate external APIs."
documentationLink="https://www.windmill.dev/docs/integrations/integrations_on_windmill"
@@ -561,6 +561,7 @@
placeholder="Search resource type"
bind:value={filter}
class="text-2xl grow"
id="search-resource-type"
/>
</div>
+35 -44
View File
@@ -1,18 +1,10 @@
<script lang="ts">
import { type DBSchema } from '$lib/stores'
import {
ChevronDownIcon,
EditIcon,
Loader2,
MoreVertical,
Plus,
Table2,
Trash2Icon
} from 'lucide-svelte'
import { ChevronDownIcon, EditIcon, Loader2, Plus, Table2, Trash2Icon } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { ClearableInput, Drawer, DrawerContent } from './common'
import { sendUserToast } from '$lib/toast'
import { type ColumnDef, type DbFeatures } from './apps/components/display/dbtable/utils'
import { type ColumnDef } from './apps/components/display/dbtable/utils'
import DBTable from './DBTable.svelte'
import type { IDbSchemaOps, IDbTableOps } from './dbOps'
import DropdownV2 from './DropdownV2.svelte'
@@ -29,7 +21,8 @@
diffTableEditorValues
} from './apps/components/display/dbtable/queries/alterTable'
import { resource } from 'runed'
import { capitalize, pluralize } from '$lib/utils'
import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils'
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
/** Represents a selected table with its schema */
export interface SelectedTable {
@@ -41,7 +34,7 @@
dbType: DbType
dbSchema: DBSchema
dbSupportsSchemas: boolean
getColDefs: (tableKey: string) => Promise<ColumnDef[]>
colDefs: Record<string, ColumnDef[]> | undefined
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
dbSchemaOps: IDbSchemaOps
refresh?: () => void
@@ -63,8 +56,8 @@
dbSchema,
dbTableOpsFactory,
dbSchemaOps,
getColDefs,
dbSupportsSchemas,
colDefs,
refresh,
initialSchemaKey,
initialTableKey,
@@ -209,15 +202,16 @@
open: false
})
let dbTableEditorAlterTableData = resource(
() => dbTableEditorState.alterTableKey,
async (table) => {
[() => dbTableEditorState.alterTableKey, () => colDefs],
async ([table]) => {
if (!table) return
let tableKey2 =
dbSupportsSchemas && selected.schemaKey ? `${selected.schemaKey}.${table}` : table
if (!colDefs?.[tableKey2]) return
return await dbSchemaOps.onFetchTableEditorDefinition({
table: table,
schema: selected.schemaKey,
getColDefs: () => getColDefs(tableKey2)
colDefs: colDefs[tableKey2]
})
}
)
@@ -226,14 +220,14 @@
let newSchemaName = $state('')
// Check if the sanitized schema name already exists
const sanitizedNewSchemaName = $derived(
newSchemaName
.trim()
.toLowerCase()
.replace(/[^a-zA-Z0-9_]/g, '')
)
const sanitizedNewSchemaName = $derived.by(() => {
let s = newSchemaName.trim().replace(/[^a-zA-Z0-9_]/g, '')
if (dbType === 'snowflake') s = s.toUpperCase()
return s
})
const schemaAlreadyExists = $derived(
sanitizedNewSchemaName !== '' && schemaKeys.includes(sanitizedNewSchemaName)
sanitizedNewSchemaName !== '' &&
schemaKeys.map((s) => s.toLowerCase()).includes(sanitizedNewSchemaName.toLowerCase())
)
</script>
@@ -247,20 +241,20 @@
<Select
bind:value={selected.schemaKey}
items={safeSelectItems(schemaKeys)}
id="db-schema-select"
transformInputSelectedText={(s) => `Schema: ${s}`}
RightIcon={ChevronDownIcon}
placeholder="Search or create schema..."
showPlaceholderOnOpen
onCreateItem={(schema) => {
schema = schema
.trim()
.toLowerCase()
.replace(/[^a-zA-Z0-9_]/g, '')
schema = schema.trim().replace(/[^a-zA-Z0-9_]/g, '')
if (dbType === 'snowflake') schema = schema.toUpperCase()
askingForConfirmation = {
confirmationText: `Create ${schema}`,
type: 'reload',
title: `This will run 'CREATE SCHEMA ${schema}' on your database. Are you sure ?`,
open: true,
id: 'db-create-schema-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
@@ -451,7 +445,10 @@
onclick={() => (selected.tableKey = tableKey)}
>
<Table2 class="text-primary shrink-0" size={16} />
<p class="truncate text-ellipsis grow text-left text-emphasis text-xs">{tableKey}</p>
<p
class="db-manager-table-key truncate text-ellipsis grow text-left text-emphasis text-xs"
>{tableKey}</p
>
<DropdownV2
items={() => [
{
@@ -462,6 +459,7 @@
title: `Are you sure you want to delete ${tableKey} ? This action is irreversible`,
confirmationText: 'Delete permanently',
open: true,
id: 'db-manager-delete-table-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
@@ -489,14 +487,8 @@
}
]}
class="w-fit"
>
<svelte:fragment slot="buttonReplacement">
<MoreVertical
size={8}
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
/>
</svelte:fragment>
</DropdownV2>
btnId={'db-manager-table-actions-' + onlyAlphaNumAndUnderscore(tableKey)}
/>
</button>
{/each}
{/if}
@@ -513,13 +505,9 @@
{/if}
</Pane>
<Pane class="p-3 pt-1">
{#if tableKey}
{#await getColDefs(tableKey) then colDefs}
{#if colDefs && colDefs?.length}
{@const dbTableOps = dbTableOpsFactory({ colDefs, tableKey })}
<DBTable {dbTableOps} />
{/if}
{/await}
{#if tableKey && colDefs?.[tableKey]?.length}
{@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey })}
<DBTable {dbTableOps} />
{/if}
</Pane>
</Splitpanes>
@@ -538,6 +526,7 @@
on:close={() => (dbTableEditorState = { open: false })}
>
<DrawerContent
id="db-table-editor-drawer"
on:close={() => (dbTableEditorState = { open: false })}
title={dbTableEditorState.alterTableKey
? `Alter ${dbTableEditorState.alterTableKey}`
@@ -602,7 +591,7 @@
}
}}
/>
{:else if dbTableEditorAlterTableData.loading}
{:else if dbTableEditorAlterTableData.loading || !colDefs}
<Loader2 class="animate-spin" size={32} />
{:else}
<p class="text-sm text-tertiary">Failed to load table definition.</p>
@@ -643,6 +632,7 @@
type: 'reload',
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
open: true,
id: 'db-create-schema-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
@@ -680,6 +670,7 @@
type: 'reload',
title: `This will run 'CREATE SCHEMA ${sanitizedNewSchemaName}' on your database. Are you sure?`,
open: true,
id: 'db-create-schema-confirmation-modal',
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
@@ -2,11 +2,7 @@
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import { sendUserToast, sortArray } from '$lib/utils'
import { Loader2 } from 'lucide-svelte'
import {
dbSupportsSchemas,
getDbFeatures,
type TableMetadata
} from './apps/components/display/dbtable/utils'
import { dbSupportsSchemas } from './apps/components/display/dbtable/utils'
import DbManager from './DBManager.svelte'
import {
dbSchemaOpsWithPreviewScripts,
@@ -17,21 +13,18 @@
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { untrack, type Snippet } from 'svelte'
import { type Snippet } from 'svelte'
import type { DbInput } from './dbTypes'
import {
getDbSchemas,
loadAllTablesMetaData,
loadTableMetaData
} from './apps/components/display/dbtable/metadata'
import { getDbSchemas, loadAllTablesMetaData } from './apps/components/display/dbtable/metadata'
import type { SelectedTable } from './DBManager.svelte'
import { getDbFeatures } from './apps/components/display/dbtable/dbFeatures'
import { resource } from 'runed'
interface Props {
input?: DbInput
showRepl?: boolean
hasReplResult?: boolean
isRefreshing?: boolean
selectedSchemaKey?: string | undefined
selectedTableKey?: string | undefined
dbSelector?: Snippet<[]>
@@ -47,7 +40,6 @@
input,
showRepl = true,
hasReplResult = $bindable(false),
isRefreshing = $bindable(false),
selectedSchemaKey = $bindable(undefined),
selectedTableKey = $bindable(undefined),
dbSelector,
@@ -67,56 +59,39 @@
}
}
// `refreshCount` is a derived state. `refreshing` is the source of truth
let refreshCount = $state(0)
$effect(() => {
if (refreshing) untrack(() => (refreshCount += 1))
})
let refreshing = $state(false)
$effect(() => {
if (refreshing) getSchema()
})
// Sync refreshing state with bindable prop
$effect(() => {
isRefreshing = refreshing
})
export const refresh = () => !refreshing && (refreshing = true)
// Initial schema load
$effect(() => {
if (input) {
untrack(() => getSchema())
let colDefs = resource(
() => [input],
async () => {
if (!input) return
return await loadAllTablesMetaData($workspaceStore, input)
}
})
async function getSchema() {
if (!input) return
const dbSchemasPath = getDbSchemasPath(input)
if ($dbSchemas[dbSchemasPath] && !refreshing) return
const oldDbSchema = $dbSchemas[dbSchemasPath]
if (input.type == 'database') {
await getDbSchemas(
input.resourceType,
input.resourcePath,
$workspaceStore,
$dbSchemas,
(message: string) => {
sendUserToast(message, true)
}
)
} else if (input.type == 'ducklake') {
$dbSchemas[dbSchemasPath] = await getDucklakeSchema({
workspace: $workspaceStore!,
ducklake: input.ducklake
})
)
let dbSchemasPromise = resource(
() => [input],
async () => {
if (!input) return
const dbSchemasPath = getDbSchemasPath(input)
if (input.type == 'database') {
$dbSchemas[dbSchemasPath] = await getDbSchemas(
input.resourceType,
input.resourcePath,
$workspaceStore,
(message: string) => sendUserToast(message, true)
)
} else if (input.type == 'ducklake') {
$dbSchemas[dbSchemasPath] = await getDucklakeSchema({
workspace: $workspaceStore!,
ducklake: input.ducklake
})
}
}
// avoid infinite loop on error due to the way getDbSchemas is implemented
// and relying on an assignement side effect
if (oldDbSchema !== $dbSchemas[dbSchemasPath]) $dbSchemas = $dbSchemas
refreshing = false
)
export const refresh = () => {
colDefs.refetch()
dbSchemasPromise.refetch()
}
export function isLoading() {
return colDefs.loading || dbSchemasPromise.loading
}
let replPanelSize = $state(36)
@@ -129,30 +104,6 @@
hasReplResult = !!replResultData
})
let cachedColDefs: Record<string, TableMetadata> = {}
let cachedLastRefreshCount = 0
async function getColDefs(tableKey: string): Promise<TableMetadata> {
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
cachedLastRefreshCount = refreshCount
if (cachedColDefs[tableKey]) return cachedColDefs[tableKey]
if (!input) return []
try {
cachedColDefs = (await loadAllTablesMetaData($workspaceStore, input)) ?? cachedColDefs
return cachedColDefs[tableKey]
} catch (e) {
if (input?.type == 'ducklake')
throw 'Impossible that loadAllTablesMetaData fails for Ducklake'
// Query is not implemented for all dbs, need a fallback
const result = await loadTableMetaData(input, $workspaceStore, tableKey)
if (result) cachedColDefs[tableKey] = result
return result ?? []
}
}
// Export for parent components
export function clearReplResult() {
replResultData = undefined
@@ -197,7 +148,7 @@
<DbManager
dbSupportsSchemas={input?.type == 'database' && dbSupportsSchemas(input.resourceType)}
{dbSchema}
{getColDefs}
colDefs={colDefs.current}
dbTableOpsFactory={({ colDefs, tableKey }) =>
dbTableOpsWithPreviewScripts({
colDefs,
@@ -85,7 +85,6 @@
let dbManagerContent: DBManagerContent | undefined = $state()
let hasReplResult = $state(false)
let isRefreshing = $state(false)
</script>
<svelte:window bind:innerWidth={windowWidth} />
@@ -108,15 +107,11 @@
}}
CloseIcon={hasReplResult ? ArrowLeft : undefined}
noPadding
id="db-manager-drawer"
>
{#if effectiveInput && $workspaceStore}
{#key selectedDatatable}
<DBManagerContent
bind:this={dbManagerContent}
input={effectiveInput}
bind:hasReplResult
bind:isRefreshing
>
<DBManagerContent bind:this={dbManagerContent} input={effectiveInput} bind:hasReplResult>
{#snippet dbSelector()}
{#if isDatatableInput}
{#if datatables.loading}
@@ -140,7 +135,7 @@
{/if}
{#snippet actions()}
<Button
loading={isRefreshing}
loading={dbManagerContent?.isLoading() ?? false}
on:click={() => dbManagerContent?.refresh()}
startIcon={{ icon: RefreshCcw }}
size="xs"
@@ -39,20 +39,15 @@
</script>
<script lang="ts">
import { ArrowRight, ClipboardCopy, Info, Plus, Settings, X } from 'lucide-svelte'
import { ArrowRight, ClipboardCopy, Plus, Settings, X } from 'lucide-svelte'
import { Button } from './common'
import { Cell } from './table'
import DataTable from './table/DataTable.svelte'
import Head from './table/Head.svelte'
import {
datatypeHasLength,
dbSupportsSchemas,
type DbFeatures
} from './apps/components/display/dbtable/utils'
import { datatypeHasLength } from './apps/components/display/dbtable/utils'
import { DB_TYPES } from '$lib/consts'
import Popover from './meltComponents/Popover.svelte'
import Tooltip from './meltComponents/Tooltip.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard } from '$lib/utils'
@@ -70,6 +65,8 @@
datatypeDefaultLength
} from './apps/components/display/dbtable/tableEditor'
import Alert from './common/alert/Alert.svelte'
import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures'
import Label from './Label.svelte'
type Props = {
dbType: DbType
@@ -122,8 +119,7 @@
...(datatypeHasLength(defaultColumnType) && {
datatype_length: datatypeDefaultLength(defaultColumnType)
}),
...(primaryKey && { primaryKey }),
...(!features?.defaultToNotNull && { nullable: true })
...(primaryKey && { primaryKey })
})
}
if (!initialValues) {
@@ -158,7 +154,7 @@
/>
</label>
<div class="flex flex-col">
<div class="flex flex-col" id="columns-section">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>Columns</label>
<DataTable>
@@ -222,11 +218,19 @@
</Cell>
<Cell last class="flex items-center mt-1.5">
{#if features?.primaryKeys}
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.primaryKey} />
<input
type="checkbox"
class="!w-4 !h-4 primary-key-checkbox"
bind:checked={column.primaryKey}
/>
{/if}
<Popover class="ml-8" contentClasses="py-3 px-5 flex flex-col gap-6">
<Popover
class="ml-8"
contentClasses="py-3 px-5 flex flex-col gap-6"
enableFlyTransition
>
{#snippet trigger()}
<Settings size={18} />
<Settings size={18} class="settings-menu-btn" />
{/snippet}
{#snippet content()}
{#if datatypeHasLength(column.datatype)}
@@ -236,27 +240,26 @@
</label>
{/if}
{#if features?.defaultValues}
<label class="text-xs">
<span class="flex gap-1 mb-1">
Default value
<Tooltip>
<Info size={14} />
{#snippet text()}
Surround your expressions with curly brackets:
<code>
{'{NOW()}'}
</code>.
<br />
By default, it will be parsed as a literal
{/snippet}
</Tooltip>
</span>
<input type="text" placeholder="NULL" bind:value={column.defaultValue} />
</label>
<Label
class="flex gap-1 mb-1"
label="Default Value"
tooltip="Parsed as literal by default. Use curly brackets for expressions (e.g. {'{NOW()}'} )."
>
<input
class="default-value"
type="text"
placeholder="NULL"
bind:value={column.defaultValue}
/>
</Label>
{/if}
{#if !column.primaryKey}
<label class="flex gap-2 items-center text-xs">
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.nullable} />
<input
type="checkbox"
class="nullable-checkbox !w-4 !h-4"
bind:checked={column.nullable}
/>
Nullable
</label>
{/if}
@@ -266,7 +269,7 @@
color="light"
startIcon={{ icon: X }}
wrapperClasses="w-fit ml-2"
btnClasses="p-0"
btnClasses="delete-column-btn p-0"
on:click={() => values.columns.splice(i, 1)}
/>
</Cell>
@@ -288,7 +291,7 @@
</DataTable>
</div>
{#if features?.foreignKeys}
<div class="flex flex-col">
<div class="flex flex-col" id="foreign-keys-section">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>Foreign Keys</label>
<DataTable>
@@ -304,14 +307,15 @@
<tr>
<Cell first class="flex">
<Select
inputClass={twMerge('!w-48')}
inputClass={twMerge('fk-table-select !w-48')}
error={fkErrors?.emptyTarget}
placeholder=""
bind:value={foreignKey.targetTable}
items={getFlatTableNamesFromSchema(dbSchema).map((o) => ({
value: o,
label:
(currentSchema && o.startsWith(currentSchema)) || !dbSupportsSchemas(dbType)
!features?.schemas ||
(currentSchema && o.toLowerCase().startsWith(currentSchema.toLowerCase()))
? o.split('.')[1]
: o
}))}
@@ -326,7 +330,7 @@
from x-overflowing -->
<div class="grow h-[2rem] relative">
<Select
class="!absolute inset-0"
class="fk-source-col-select !absolute inset-0"
error={fkErrors?.nonExistingSourceColumns.includes(
column.sourceColumn
)}
@@ -341,7 +345,7 @@
<ArrowRight size={16} class="h-fit shrink-0" />
<div class="grow h-[2rem] relative">
<Select
class="!absolute inset-0"
class="fk-target-col-select !absolute inset-0"
error={fkErrors?.nonExistingTargetColumns.includes(
column.targetColumn
)}
@@ -357,26 +361,35 @@
</div>
</div>
<div class="ml-auto flex">
{#if columnIndex === 0}
<Popover contentClasses="py-3 px-5 w-52 flex flex-col gap-6">
{#if columnIndex === 0 && features.enforcedForeignKeys}
<Popover
contentClasses="py-3 px-5 w-52 flex flex-col gap-4"
enableFlyTransition
>
{#snippet trigger()}
<Settings size={18} />
<Settings class="fk-settings-btn" size={18} />
{/snippet}
{#snippet content()}
<span>
ON DELETE <select bind:value={foreignKey.onDelete}>
<option value="NO ACTION" selected>NO ACTION</option>
<option value="CASCADE" selected>CASCADE</option>
<option value="SET NULL" selected>SET NULL</option>
<Label label="On delete">
<select
class="fk-on-delete-select"
bind:value={foreignKey.onDelete}
>
<option value="NO ACTION" selected>No action</option>
<option value="CASCADE" selected>Cascade</option>
<option value="SET NULL" selected>Set null</option>
</select>
</span>
<span>
ON UPDATE <select bind:value={foreignKey.onUpdate}>
<option value="NO ACTION" selected>NO ACTION</option>
<option value="CASCADE" selected>CASCADE</option>
<option value="SET NULL" selected>SET NULL</option>
</Label>
<Label label="On update">
<select
class="fk-on-update-select"
bind:value={foreignKey.onUpdate}
>
<option value="NO ACTION" selected>No action</option>
<option value="CASCADE" selected>Cascade</option>
<option value="SET NULL" selected>Set null</option>
</select>
</span>
</Label>
{/snippet}
</Popover>
{/if}
@@ -384,7 +397,7 @@
color="light"
startIcon={{ icon: X }}
wrapperClasses="w-fit ml-2"
btnClasses="p-0"
btnClasses="fk-delete-btn p-0"
on:click={foreignKey.columns.length > 1
? () => foreignKey.columns.splice(columnIndex, 1)
: () => values.foreignKeys.splice(foreignKeyIndex, 1)}
@@ -454,6 +467,7 @@
<Portal>
<ConfirmationModal
id="db-table-editor-confirmation-modal"
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
on:canceled={() => (askingForConfirmation = undefined)}
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
@@ -35,6 +35,7 @@
customWidth?: number | undefined
customMenu?: boolean
class?: string | undefined
btnId?: string | undefined
enableFlyTransition?: boolean
size?: ButtonType.UnifiedSize
btnText?: string
@@ -60,6 +61,7 @@
enableFlyTransition = false,
size = 'md',
btnText = '',
btnId = undefined,
buttonReplacement,
menu,
maxHeight = undefined
@@ -142,6 +144,7 @@
}
}
}}
id={btnId}
data-menu
>
{#if buttonReplacement}
+6 -9
View File
@@ -591,15 +591,12 @@
const resourcePath = newSchemaRes.replace('$res:', '')
dbSchema = $dbSchemas[resourcePath]
if (dbSchema === undefined) {
if (lang === 'graphql') {
await getDbSchemas('graphql', resourcePath, $workspaceStore, $dbSchemas, (e) => {
console.error('error getting graphql db schema', e)
})
} else if (lang === 'sql') {
await getDbSchemas(scriptLang ?? '', resourcePath, $workspaceStore, $dbSchemas, (e) => {
console.error(`error getting SQL (${scriptLang}) db schema`, e)
})
}
$dbSchemas[resourcePath] = await getDbSchemas(
lang === 'graphql' ? 'graphql' : (scriptLang ?? ''),
resourcePath,
$workspaceStore,
(e) => console.error(`error getting ${lang} (${scriptLang}) db schema`, e)
)
}
dbSchema = $dbSchemas[resourcePath]
} else {
@@ -210,6 +210,7 @@
inputClass={selectInputClass}
placeholder={placeholder ?? `${resourceType ?? 'any'} resource`}
itemLabelWrapperClasses="flex-1"
id="resource-picker-select"
>
{#snippet endSnippet({ item, close })}
<Button
@@ -270,6 +270,7 @@
<div class="flex gap-2 items-start flex-wrap justify-between mt-2 md:mt-6">
<div class="flex-row-reverse flex-wrap flex w-full gap-4">
<Button
id="run-form-run-button"
{loading}
variant="accent"
unifiedSize="md"
+5 -1
View File
@@ -45,6 +45,7 @@
import { type ToastAction } from '$lib/toast'
import { processMessage } from './toast'
import { onDestroy, untrack } from 'svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
message: string
@@ -90,7 +91,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="pointer-events-auto w-full max-w-sm overflow-hidden bg-surface-tertiary drop-shadow-base shadow-lg ring-1 ring-black ring-opacity-5 border rounded-md"
class={twMerge(
'pointer-events-auto w-full max-w-sm overflow-hidden bg-surface-tertiary drop-shadow-base shadow-lg ring-1 ring-black ring-opacity-5 border rounded-md',
error ? 'toast-error' : 'toast-success'
)}
onmouseenter={() => state && (state.hover = true)}
onmouseleave={() => state && (state.hover = false)}
>
@@ -45,7 +45,7 @@
</Button>
<Drawer bind:this={insertDrawer} size="800px">
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer} id="insert-row-drawer">
{#snippet actions()}
<Button
variant="accent"
@@ -233,11 +233,11 @@
ducklake: dbPath.split('ducklake://')[1]
})
} else {
await getDbSchemas(
const resourcePath = dbPath.split('$res:')[1]
dbSchemas[resourcePath] = await getDbSchemas(
resolvedConfig?.type?.selected,
dbPath.split('$res:')[1],
resourcePath,
$workspaceStore,
dbSchemas,
() => {},
{ useLegacyScripts: true }
)
@@ -0,0 +1,47 @@
// Separate file to import in both frontend and e2e
import type { DbInput } from '../../../../dbTypes'
import { dbSupportsSchemas } from './utils'
export type DbFeatures = {
foreignKeys?: boolean
enforcedForeignKeys?: boolean
primaryKeys?: boolean
defaultValues?: boolean
schemas?: boolean
}
export function getDbFeatures(dbInput: DbInput): Required<DbFeatures> {
const def: Required<DbFeatures> = {
foreignKeys: true,
primaryKeys: true,
defaultValues: true,
enforcedForeignKeys: true,
schemas: dbInput.type !== 'ducklake' && dbSupportsSchemas(dbInput.resourceType)
}
if (dbInput.type == 'ducklake')
return {
...def,
foreignKeys: false,
enforcedForeignKeys: false,
primaryKeys: false
}
if (dbInput.resourceType == 'bigquery')
return {
...def,
foreignKeys: false,
primaryKeys: true,
defaultValues: false,
enforcedForeignKeys: false
}
if (dbInput.resourceType == 'snowflake')
return {
...def,
enforcedForeignKeys: false
}
return { ...def }
}
@@ -1,4 +1,4 @@
import { JobService, ResourceService } from '$lib/gen'
import { JobService, ResourceService, type ScriptLang } from '$lib/gen'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import type { DbInput } from '$lib/components/dbTypes'
@@ -11,9 +11,8 @@ import {
} from './utils'
import { type Preview } from '$lib/gen'
import type { DBSchema, DBSchemas, GraphqlSchema, SQLSchema } from '$lib/stores'
import type { DBSchema, GraphqlSchema, SQLSchema } from '$lib/stores'
import { tryEvery } from '$lib/utils'
import { stringifySchema } from '$lib/components/copilot/lib'
import type { DbType } from '$lib/components/dbTypes'
import { getDatabaseArg } from '$lib/components/dbOps'
@@ -25,16 +24,11 @@ export async function loadTableMetaData(
): Promise<TableMetadata | undefined> {
if (!input || !table || !workspace) return undefined
let language = input.type == 'ducklake' ? 'duckdb' : getLanguageByResourceType(input.resourceType)
let content = await makeLoadTableMetaDataQuery(input, workspace, table)
let { language, query } = await makeLoadTableMetaDataQuery(input, workspace, table)
const job = await JobService.runScriptPreview({
workspace: workspace,
requestBody: {
language,
content,
args: getDatabaseArg(input)
}
requestBody: { language, content: query, args: getDatabaseArg(input) }
})
const maxRetries = 8
@@ -51,7 +45,19 @@ export async function loadTableMetaData(
if (testResult.success) {
attempts = maxRetries
return testResult.result.map(lowercaseKeys)
const result = testResult.result.map(lowercaseKeys)
// For Snowflake, fetch primary keys separately
if (
input.type === 'database' &&
(input.resourceType === 'snowflake' || (input.resourceType as any) === 'snowflake_oauth')
) {
const map: Record<string, TableMetadata> = { [table]: result }
await fetchAndAddSnowflakePrimaryKeysInMap(map, input, workspace, table)
return map[table]
}
return result
} else {
attempts++
}
@@ -70,29 +76,22 @@ export async function loadAllTablesMetaData(
): Promise<Record<string, TableMetadata> | undefined> {
if (!input || !workspace) return undefined
let language = input.type == 'ducklake' ? 'duckdb' : getLanguageByResourceType(input.resourceType)
try {
let { language, query } = await makeLoadTableMetaDataQuery(input, workspace, undefined)
let result = (await runScriptAndPollResult({
workspace: workspace,
requestBody: {
language,
content: await makeLoadTableMetaDataQuery(input, workspace, undefined),
args: getDatabaseArg(input)
}
requestBody: { language, content: query, args: getDatabaseArg(input) }
})) as ({ table_name: string; schema_name?: string } & object)[]
const map: Record<string, TableMetadata> = {}
for (const _col of result) {
const col = lowercaseKeys(_col)
const tableKey = col.schema_name ? `${col.schema_name}.${col.table_name}` : col.table_name
if (!(tableKey in map)) {
map[tableKey] = []
}
map[tableKey] ??= []
map[tableKey].push(col)
}
fetchAndAddSnowflakePrimaryKeysInMap(map, input, workspace)
await fetchAndAddSnowflakePrimaryKeysInMap(map, input, workspace)
return map
} catch (e) {
@@ -104,9 +103,9 @@ async function makeLoadTableMetaDataQuery(
input: DbInput,
workspace: string,
table: string | undefined
): Promise<string> {
): Promise<{ query: string; language: ScriptLang }> {
if (input.type === 'ducklake') {
return `ATTACH 'ducklake://${input.ducklake}' AS __ducklake__;
const query = `ATTACH 'ducklake://${input.ducklake}' AS __ducklake__;
SELECT
COLUMN_NAME as field,
DATA_TYPE as DataType,
@@ -118,12 +117,13 @@ async function makeLoadTableMetaDataQuery(
TABLE_NAME as table_name
FROM information_schema.columns c
WHERE table_catalog = '__ducklake__' AND table_schema = current_schema()`
return { query, language: 'duckdb' }
} else if (input.resourceType === 'mysql') {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: input.resourcePath
})) as any
return `
const query = `
SELECT
COLUMN_NAME as field,
COLUMN_TYPE as DataType,
@@ -153,8 +153,9 @@ async function makeLoadTableMetaDataQuery(
TABLE_NAME,
ORDINAL_POSITION;
`
return { query, language: 'mysql' }
} else if (input.resourceType === 'postgresql') {
return `
const query = `
SELECT
a.attname as field,
pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType,
@@ -200,8 +201,9 @@ async function makeLoadTableMetaDataQuery(
ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'};
`
return { query, language: 'postgresql' }
} else if (input.resourceType === 'ms_sql_server') {
return `
const query = `
SELECT
c.COLUMN_NAME as field,
c.DATA_TYPE as DataType,
@@ -245,11 +247,12 @@ WHERE
ORDER BY
c.ORDINAL_POSITION;
`
return { query, language: 'mssql' }
} else if (
input.resourceType === 'snowflake' ||
(input.resourceType as any) === 'snowflake_oauth'
) {
return `
const query = `
select COLUMN_NAME as field,
DATA_TYPE as DataType,
COLUMN_DEFAULT as DefaultValue,
@@ -273,10 +276,10 @@ ORDER BY
}
order by ORDINAL_POSITION;
`
return { query, language: 'snowflake' }
} else if (input.resourceType === 'bigquery') {
// TODO: find a solution for this (query uses hardcoded dataset name)
if (!table) throw new Error('Table name is required for BigQuery')
return `SELECT
if (table) {
const query = `SELECT
c.COLUMN_NAME as field,
DATA_TYPE as DataType,
CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,
@@ -292,6 +295,40 @@ FROM
WHERE
c.TABLE_NAME = '${table.split('.')[1]}'
order by c.ORDINAL_POSITION;`
return { query, language: 'bigquery' }
} else {
const query = `import { BigQuery } from '@google-cloud/bigquery@7.5.0';
export async function main(database: bigquery) {
const bq = new BigQuery({
credentials: database
})
const [datasets] = await bq.getDatasets();
if (!datasets) return {}
const schema = {} as any
let queries = datasets.map(dataset => \`
(SELECT
c.COLUMN_NAME as field,
'\${dataset.id}' as schema_name,
c.TABLE_NAME as table_name,
DATA_TYPE as DataType,
CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,
CASE WHEN constraint_name is not null THEN true ELSE false END as IsPrimaryKey,
'No' as IsIdentity,
IS_NULLABLE as IsNullable,
false as IsEnum
FROM
\\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN
\\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME
ORDER BY c.ORDINAL_POSITION)\`
)
let query = queries.join('\\nUNION ALL \\n')
const [rows] = await bq.query(query)
return rows
}`
return { query, language: 'bun' }
}
} else {
throw new Error('Unsupported database type:' + input.resourceType)
}
@@ -311,14 +348,23 @@ async function fetchAndAddSnowflakePrimaryKeysInMap(
workspace: string,
tableKey?: string
) {
if (input.type == 'database' && input.resourceType === 'snowflake') {
if (
input.type == 'database' &&
(input.resourceType === 'snowflake' || (input.resourceType as any) === 'snowflake_oauth')
) {
let pkResult = await fetchSnowflakePrimaryKeys(workspace, getDatabaseArg(input), tableKey)
for (const pk of pkResult) {
const tableKey = `${pk.schema_name}.${pk.table_name}`.toUpperCase()
if (tableKey in map) {
for (const col of map[tableKey]) {
if (col.field.toLowerCase() === pk.column_name.toLowerCase()) {
col.isprimarykey = true
const pkTableKey = `${pk.schema_name}.${pk.table_name}`.toUpperCase()
// Also check the original casing and the provided tableKey
const keysToCheck = [pkTableKey, `${pk.schema_name}.${pk.table_name}`, tableKey].filter(
Boolean
) as string[]
for (const key of keysToCheck) {
if (key in map) {
for (const col of map[key]) {
if (col.field.toLowerCase() === pk.column_name.toLowerCase()) {
col.isprimarykey = true
}
}
}
}
@@ -336,7 +382,7 @@ async function fetchSnowflakePrimaryKeys(
requestBody: {
language: 'snowflake',
args: dbArg,
content: tableKey ? `SHOW PRIMARY KEYS IN TABLE ${tableKey}` : 'SHOW PRIMARY KEYS'
content: tableKey ? `SHOW PRIMARY KEYS IN TABLE ${tableKey}` : 'SHOW PRIMARY KEYS IN ACCOUNT'
}
})) as SnowflakeShowPrimaryKeysResult[]
}
@@ -353,25 +399,20 @@ export async function getDbSchemas(
resourceType: string,
resourcePath: string,
workspace: string | undefined,
dbSchemas: DBSchemas,
errorCallback: (message: string) => void,
options: {
useLegacyScripts?: boolean // To avoid breaking app policies
} = {}
): Promise<void> {
): Promise<DBSchema | undefined> {
let scripts = options.useLegacyScripts ? legacyScripts : scriptsV2
let sqlScript = scripts[getLanguageByResourceType(resourceType)]
if (!sqlScript) return
if (!resourceType || !resourcePath || !workspace || !sqlScript) return
return new Promise(async (resolve, reject) => {
if (!resourceType || !resourcePath || !workspace) {
resolve()
return
}
const job = await JobService.runScriptPreview({
workspace: workspace,
let result: unknown
try {
result = await JobService.runScriptPreviewAndWaitResult({
workspace,
requestBody: {
language: sqlScript.lang as Preview['language'],
content: sqlScript.code,
@@ -382,91 +423,43 @@ export async function getDbSchemas(
}
}
})
} catch (e) {
console.error(e)
errorCallback('Error fetching schema: ' + ((e as Error)?.message || e))
return
}
tryEvery({
tryCode: async () => {
if (resourcePath) {
const testResult = await JobService.getCompletedJob({
workspace,
id: job
})
if (!testResult.success) {
console.error(testResult.result?.['error']?.['message'])
} else {
if (testResult.result === 'WINDMILL_TOO_BIG') {
console.info('Result is too big, fetching result separately')
const data = await JobService.getCompletedJobResult({
workspace,
id: job
})
testResult.result = data
}
if (resourceType !== undefined) {
if (resourceType !== 'graphql') {
const { processingFn } = sqlScript
let schema: any
try {
schema =
processingFn !== undefined ? processingFn(testResult.result) : testResult.result
} catch (e) {
console.error(e)
errorCallback('Error processing schema')
resolve()
return
}
const dbSchema = {
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
schema,
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
}
dbSchemas[resourcePath] = {
...dbSchema,
stringified: stringifySchema(dbSchema)
}
} else {
if (
typeof testResult.result !== 'object' ||
!('__schema' in (testResult?.result ?? {}))
) {
console.error('Invalid GraphQL schema')
errorCallback('Invalid GraphQL schema')
} else {
const dbSchema = {
lang: 'graphql' as GraphqlSchema['lang'],
schema: testResult.result
}
dbSchemas[resourcePath] = {
...(dbSchema as any),
stringified: stringifySchema(dbSchema as any)
}
}
}
}
}
resolve()
if (resourceType !== undefined) {
if (resourceType !== 'graphql') {
const { processingFn } = sqlScript
let schema: any
try {
schema = processingFn !== undefined ? processingFn(result) : result
} catch (e) {
console.error(e)
errorCallback('Error processing schema')
return
}
const dbSchema = {
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
schema,
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
}
return { ...dbSchema, stringified: stringifySchema(dbSchema) }
} else {
if (typeof result !== 'object' || !('__schema' in (result ?? {}))) {
console.error('Invalid GraphQL schema')
errorCallback('Invalid GraphQL schema')
return
} else {
const dbSchema = {
lang: 'graphql' as GraphqlSchema['lang'],
schema: result
}
},
timeoutCode: async () => {
console.error('Could not query schema within 5s')
errorCallback('Could not query schema within 5s')
try {
await JobService.cancelQueuedJob({
workspace,
id: job,
requestBody: {
reason: 'Could not query schema within 5s'
}
})
} catch (err) {
console.error(err)
}
reject()
},
interval: 500,
timeout: 5000
})
})
return { ...(dbSchema as any), stringified: stringifySchema(dbSchema as any) }
}
}
}
}
export async function getTablesByResource(
@@ -48,7 +48,7 @@ describe('makeAlterTableQueries', () => {
'postgresql'
)
expect(normalize(queries[0])).toBe(normalize('ALTER TABLE users DROP COLUMN age;'))
expect(normalize(queries[0])).toBe(normalize('ALTER TABLE users DROP COLUMN "age";'))
})
it('renames a column', () => {
@@ -123,14 +123,23 @@ export function makeAlterTableQueries(
break
case 'renameTable':
queries.push(`ALTER TABLE ${tableRef} RENAME TO ${op.to};`)
const tableNewRef = dbType === 'snowflake' && schema ? `${schema.trim()}.${op.to}` : op.to
queries.push(`ALTER TABLE ${tableRef} RENAME TO ${tableNewRef};`)
break
case 'addPrimaryKey':
const notEnforced = dbType === 'bigquery' ? ' NOT ENFORCED' : ''
queries.push(
`ALTER TABLE ${tableRef} ADD PRIMARY KEY (${op.columns.join(', ')})${notEnforced};`
)
if (dbType === 'snowflake') {
// Snowflake requires CONSTRAINT syntax with explicit name
const constraintName = `${values.name}_pk`
queries.push(
`ALTER TABLE ${tableRef} ADD CONSTRAINT ${constraintName} PRIMARY KEY (${op.columns.join(', ')});`
)
} else {
const notEnforced = dbType === 'bigquery' ? ' NOT ENFORCED' : ''
queries.push(
`ALTER TABLE ${tableRef} ADD PRIMARY KEY (${op.columns.join(', ')})${notEnforced};`
)
}
break
case 'dropPrimaryKey':
@@ -218,7 +227,7 @@ function renderDropDefaultValue(
case 'bigquery':
return `ALTER TABLE ${tableRef} ALTER COLUMN ${columnName} DROP DEFAULT;`
case 'ms_sql_server':
return `ALTER TABLE ${tableRef} DROP CONSTRAINT ${defaultConstraintName};`
return `ALTER TABLE ${tableRef} DROP CONSTRAINT ${renderDbQuotedIdentifier(defaultConstraintName ?? '', dbType)};`
default:
throw new Error(`Unsupported database type: ${dbType}`)
}
@@ -295,7 +304,7 @@ function renderDropForeignKey(
): string {
if (dbType === 'mysql')
return `ALTER TABLE ${tableRef} DROP FOREIGN KEY ${renderDbQuotedIdentifier(fk_constraint_name, dbType)};`
return `ALTER TABLE ${tableRef} DROP CONSTRAINT ${fk_constraint_name};`
return `ALTER TABLE ${tableRef} DROP CONSTRAINT ${renderDbQuotedIdentifier(fk_constraint_name, dbType)};`
}
function renderDropPrimaryKey(
@@ -6,6 +6,8 @@ import type { TableEditorValues, TableEditorValuesColumn } from '../tableEditor'
export function makeCreateTableQuery(values: TableEditorValues, dbType: DbType, schema?: string) {
const pkCount = values.columns.reduce((p, c) => p + (c.primaryKey ? 1 : 0), 0)
if (dbType === 'snowflake') schema ??= 'PUBLIC'
function transformColumn(c: TableEditorValuesColumn): string {
const datatype = c.datatype_length ? `${c.datatype}(${c.datatype_length})` : c.datatype
const defValue = c.defaultValue && formatDefaultValue(c.defaultValue, datatype, dbType)
@@ -3,7 +3,6 @@ import { wrapDucklakeQuery } from '$lib/components/ducklake'
import { runScriptAndPollResult } from '$lib/components/jobs/utils'
import type { ScriptLang } from '$lib/gen'
import type { TableEditorForeignKey } from '../tableEditor'
import type { TableMetadata } from '../utils'
/**
* Raw foreign key result from database queries
@@ -267,32 +266,34 @@ WHERE
// Snowflake queries
function makeSnowflakeForeignKeysQuery(tableName: string, schemaName: string): string {
return `
SELECT
tc.constraint_name as fk_constraint_name,
kcu.column_name as source_column,
rc.unique_constraint_schema || '.' || ccu.table_name as target_table,
ccu.column_name as target_column,
COALESCE(rc.delete_rule, 'NO ACTION') as on_delete,
COALESCE(rc.update_rule, 'NO ACTION') as on_update
FROM
information_schema.table_constraints tc
JOIN information_schema.referential_constraints rc
ON tc.constraint_name = rc.constraint_name
AND tc.constraint_schema = rc.constraint_schema
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.constraint_schema = kcu.constraint_schema
JOIN information_schema.constraint_column_usage ccu
ON rc.unique_constraint_name = ccu.constraint_name
AND rc.unique_constraint_schema = ccu.constraint_schema
WHERE
tc.constraint_type = 'FOREIGN KEY'
AND tc.table_name = '${tableName}'
AND tc.table_schema = '${schemaName}'
ORDER BY
tc.constraint_name, kcu.ordinal_position;
`.trim()
return `SHOW IMPORTED KEYS IN TABLE ${schemaName}.${tableName}`
}
/**
* Transforms Snowflake SHOW IMPORTED KEYS result to RawForeignKey format
* Snowflake returns columns: fk_database_name, fk_schema_name, fk_table_name, fk_column_name,
* pk_database_name, pk_schema_name, pk_table_name, pk_column_name, key_sequence,
* update_rule, delete_rule, fk_name, pk_name, deferrability
*/
function transformSnowflakeForeignKeys(snowflakeResults: any[]): RawForeignKey[] {
if (!snowflakeResults || !Array.isArray(snowflakeResults)) {
return []
}
return snowflakeResults.map((row) => ({
fk_constraint_name: row.fk_name || row.FK_NAME || '',
source_column: row.fk_column_name || row.FK_COLUMN_NAME || '',
target_table: `${row.pk_schema_name || row.PK_SCHEMA_NAME || ''}.${row.pk_table_name || row.PK_TABLE_NAME || ''}`,
target_column: row.pk_column_name || row.PK_COLUMN_NAME || '',
on_delete: (row.delete_rule || row.DELETE_RULE || 'NO ACTION').toUpperCase() as
| 'CASCADE'
| 'SET NULL'
| 'NO ACTION',
on_update: (row.update_rule || row.UPDATE_RULE || 'NO ACTION').toUpperCase() as
| 'CASCADE'
| 'SET NULL'
| 'NO ACTION'
}))
}
function makeSnowflakePrimaryKeyQuery(tableName: string, schemaName: string): string {
@@ -403,12 +404,10 @@ export async function fetchTableRelationalKeys(
schema: string | undefined,
workspace: string,
dbArg: Record<string, any>,
language: ScriptLang,
getColDefs: () => Promise<TableMetadata>
language: ScriptLang
): Promise<{
foreignKeys: TableEditorForeignKey[]
pk_constraint_name?: string
colDefs: TableMetadata
}> {
let fkPromise = async () => {
try {
@@ -421,17 +420,27 @@ export async function fetchTableRelationalKeys(
requestBody: { args: dbArg, content: fkQuery, language }
})
let rawForeignKeys = fkResult as RawForeignKey[]
let rawForeignKeys: RawForeignKey[]
// Snowflake returns a different format from SHOW IMPORTED KEYS
if (dbType === 'snowflake') {
rawForeignKeys = transformSnowflakeForeignKeys(fkResult as any[])
} else {
rawForeignKeys = fkResult as RawForeignKey[]
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
// Lowercase keys for consistency
rawForeignKeys = rawForeignKeys.map((fk) => {
const lowerFk: any = {}
Object.keys(fk).forEach((key) => {
lowerFk[key.toLowerCase()] = fk[key]
})
return lowerFk
})
}
}
if (rawForeignKeys && Array.isArray(rawForeignKeys)) {
// Lowercase keys for consistency
rawForeignKeys = rawForeignKeys.map((fk) => {
const lowerFk: any = {}
Object.keys(fk).forEach((key) => {
lowerFk[key.toLowerCase()] = fk[key]
})
return lowerFk
})
return transformForeignKeys(rawForeignKeys)
}
}
@@ -466,11 +475,7 @@ export async function fetchTableRelationalKeys(
}
}
const [foreignKeys, pk_constraint_name, colDefs] = await Promise.all([
fkPromise(),
pkPromise(),
getColDefs()
])
const [foreignKeys, pk_constraint_name] = await Promise.all([fkPromise(), pkPromise()])
return { foreignKeys, pk_constraint_name, colDefs }
return { foreignKeys, pk_constraint_name }
}
@@ -75,6 +75,7 @@ export function columnDefToTableEditorValuesColumn(
} else {
datatype = colDef.datatype?.replace(/\s+/g, ' ').toUpperCase() || 'UNKNOWN'
}
datatype = normalizeDatatypeAlias(datatype)
const defaultValue = colDef.defaultvalue
? colDef.defaultvalue.startsWith("'") &&
@@ -95,3 +96,15 @@ export function columnDefToTableEditorValuesColumn(
default_constraint_name: colDef.default_constraint_name
}
}
function normalizeDatatypeAlias(datatype: string): string {
// Normalize some common datatype variations
const dt = datatype.toUpperCase().trim()
if (dt === 'INTEGER') return 'INT'
if (dt === 'DOUBLE PRECISION') return 'DOUBLE'
if (dt === 'TIMESTAMP WITHOUT TIME ZONE') return 'TIMESTAMP'
if (dt === 'TIMESTAMP WITH TIME ZONE') return 'TIMESTAMPTZ'
if (dt === 'CHARACTER VARYING') return 'VARCHAR'
if (dt === 'CHARACTER') return 'CHAR'
return datatype
}
@@ -7,7 +7,7 @@ import {
type IntrospectionQuery
} from 'graphql'
import type { DbInput, DbType } from '$lib/components/dbTypes'
import type { DbType } from '$lib/components/dbTypes'
export enum ColumnIdentity {
ByDefault = 'By Default',
@@ -157,23 +157,34 @@ const bq = new BigQuery({
credentials: args
})
const [datasets] = await bq.getDatasets();
const schema = {}
for (const dataset of datasets) {
schema[dataset.id] = {}
const query = "SELECT table_name, ARRAY_AGG(STRUCT(if(is_nullable = 'YES', true, false) AS required, column_name AS name, data_type AS type, if(column_default = 'NULL', null, column_default) AS \`default\`) ORDER BY ordinal_position) AS schema \
FROM \`{dataset.id}\`.INFORMATION_SCHEMA.COLUMNS \
GROUP BY table_name".replace('{dataset.id}', dataset.id)
const [rows] = await bq.query(query)
for (const row of rows) {
schema[dataset.id][row.table_name] = {}
for (const col of row.schema) {
const colName = col.name
delete col.name
if (col.default === null) {
delete col.default
}
schema[dataset.id][row.table_name][colName] = col
if (!datasets) return {}
const schema = {} as any
let queries = datasets.map(dataset => \`
SELECT
table_name,
'\${dataset.id}' as dataset,
ARRAY_AGG(STRUCT(
if(is_nullable = 'YES', true, false) AS required,
column_name AS name,
data_type AS type,
if(column_default = 'NULL', null, column_default) AS \\\`default\\\`
) ORDER BY ordinal_position)
AS schema
FROM \\\`\${dataset.id}\\\`.INFORMATION_SCHEMA.COLUMNS
GROUP BY table_name\`
)
let query = queries.join('\\nUNION ALL \\n')
const [rows] = await bq.query(query)
for (const row of rows) {
schema[row.dataset] ??= {}
schema[row.dataset][row.table_name] = {}
for (const col of row.schema) {
const colName = col.name
delete col.name
if (col.default === null) {
delete col.default
}
schema[row.dataset][row.table_name][colName] = col
}
}
return schema
@@ -428,31 +439,3 @@ export function sqlDataTypeToJsTypeHeuristic(datatype: string): string {
return 'any'
}
}
export type DbFeatures = {
foreignKeys?: boolean
primaryKeys?: boolean
defaultValues?: boolean
defaultToNotNull?: boolean
}
export function getDbFeatures(dbInput: DbInput): Required<DbFeatures> {
const def: Required<DbFeatures> = {
foreignKeys: true,
primaryKeys: true,
defaultValues: true,
defaultToNotNull: true
}
if (dbInput.type == 'ducklake') return { ...def, foreignKeys: false, primaryKeys: false }
if (dbInput.resourceType == 'bigquery')
return {
foreignKeys: false,
primaryKeys: true,
defaultValues: false,
defaultToNotNull: false
}
return { ...def }
}
@@ -9,10 +9,11 @@
small?: boolean
Icon?: any | undefined
class?: string
id?: string | undefined
onClick?: () => void | undefined | any
}
let { noBg = false, small = false, Icon, class: className, onClick }: Props = $props()
let { noBg = false, small = false, Icon, class: className, id, onClick }: Props = $props()
const dispatch = createEventDispatcher()
</script>
@@ -20,6 +21,7 @@
<Button
on:click={() => (dispatch('close'), onClick?.())}
on:pointerdown={(e) => e.stopPropagation()}
{id}
startIcon={{ icon: Icon ?? X }}
iconOnly
unifiedSize="sm"
@@ -100,7 +100,7 @@
}
let {
id = '',
id,
aiId = undefined,
aiDescription = undefined,
size = 'md',
@@ -14,6 +14,7 @@
open?: boolean
type?: 'danger' | 'reload'
showIcon?: boolean
id?: string
children?: Snippet
onConfirmed?: () => void | Promise<void>
onCanceled?: () => void
@@ -27,6 +28,7 @@
open = false,
type: _type,
showIcon = true,
id,
children,
onConfirmed,
onCanceled
@@ -84,6 +86,7 @@
transition:fadeFast|local
class={'fixed top-0 bottom-0 left-0 right-0 z-[5000]'}
role="dialog"
{id}
>
<div
class={classNames(
@@ -4,6 +4,7 @@
import Disposable from './Disposable.svelte'
import ConditionalPortal from './ConditionalPortal.svelte'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
interface Props {
open?: boolean
@@ -23,7 +24,7 @@
let {
open = $bindable(undefined),
duration = 0.3,
duration: _duration = 0.3,
placement = 'right',
size = '600px',
alwaysOpen = false,
@@ -43,6 +44,8 @@
let disposable: Disposable | undefined = $state(undefined)
let reducedMotion = useReducedMotion()
let duration = $derived(reducedMotion.val ? 0 : _duration)
let durationMs = $derived(duration * 1000)
export function toggleDrawer() {
@@ -19,6 +19,7 @@
CloseIcon?: any | undefined
fullScreen?: boolean
eeOnly?: boolean
id?: string | undefined
actions?: import('svelte').Snippet
titleExtra?: import('svelte').Snippet
children?: import('svelte').Snippet
@@ -36,6 +37,7 @@
CloseIcon = undefined,
fullScreen = true,
eeOnly = false,
id,
actions,
titleExtra,
children
@@ -44,7 +46,10 @@
const dispatch = createEventDispatcher()
</script>
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
<div
class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}
{id}
>
<div class="flex justify-between w-full items-center pl-2 pr-4 py-2 gap-2">
<div class="flex items-center gap-2 w-full truncate">
<div
@@ -56,7 +61,7 @@
}
}}
>
<CloseButton on:close Icon={CloseIcon} />
<CloseButton on:close Icon={CloseIcon} id="{id}-close-btn" />
</div>
<span class="font-semibold text-emphasis truncate text-lg max-w-sm"
>{title ?? ''}
@@ -91,6 +91,7 @@
<slot name="header-right" />
<div class="w-8">
<button
id="modal-close-button"
on:click={() => {
close()
}}
@@ -444,16 +444,16 @@ export function createDbSchemaTool<T>(): Tool<T> {
workspace: workspace,
path: args.resourcePath
})
const newDbSchemas = {}
await getDbSchemas(
resource.resource_type,
args.resourcePath,
workspace,
newDbSchemas,
(error) => {
console.error(error)
}
)
const newDbSchemas = {
[args.resourcePath]: await getDbSchemas(
resource.resource_type,
args.resourcePath,
workspace,
(error) => {
console.error(error)
}
)
}
dbSchemas.update((schemas) => ({ ...schemas, ...newDbSchemas }))
const dbs = get(dbSchemas)
const db = dbs[args.resourcePath]
+4 -5
View File
@@ -133,7 +133,7 @@ export type IDbSchemaOps = {
onFetchTableEditorDefinition: (params: {
table: string
schema?: string
getColDefs: () => Promise<TableMetadata>
colDefs: TableMetadata
}) => Promise<TableEditorValues>
}
@@ -192,16 +192,15 @@ export function dbSchemaOpsWithPreviewScripts({
requestBody: { args: { ...dbArg }, language, content: dropSchemaQuery }
})
},
onFetchTableEditorDefinition: async ({ table, schema, getColDefs }) => {
let { foreignKeys, pk_constraint_name, colDefs } = await fetchTableRelationalKeys(
onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => {
let { foreignKeys, pk_constraint_name } = await fetchTableRelationalKeys(
input,
dbType,
table,
schema,
workspace,
dbArg,
language,
getColDefs
language
)
return buildTableEditorValues({
@@ -410,7 +410,8 @@
<TextInput
inputProps={{
autofocus: true,
placeholder: HOME_SEARCH_PLACEHOLDER
placeholder: HOME_SEARCH_PLACEHOLDER,
id: 'home-search-input'
}}
size="md"
bind:value={filter}
@@ -28,7 +28,6 @@
// For DB manager
let dbManagerContent: DBManagerContent | undefined = $state()
let hasReplResult = $state(false)
let isRefreshing = $state(false)
let windowWidth = $state(window.innerWidth)
let expand = $state(false)
@@ -168,7 +167,6 @@
bind:this={dbManagerContent}
input={dbInput}
bind:hasReplResult
bind:isRefreshing
bind:selectedSchemaKey
bind:selectedTableKey
multiSelectMode={true}
@@ -216,7 +214,7 @@
</Button>
<Button
loading={isRefreshing}
loading={dbManagerContent?.isLoading() ?? false}
on:click={() => dbManagerContent?.refresh()}
startIcon={{ icon: RefreshCcw }}
size="xs"
@@ -37,7 +37,7 @@ export function createSchemasResource(getDatatable: () => string | undefined) {
if (!dbSchema) {
try {
await getDbSchemas('postgresql', resourcePath, workspace, schemas, (msg) =>
schemas[resourcePath] = await getDbSchemas('postgresql', resourcePath, workspace, (msg) =>
console.error('Schema error:', msg)
)
dbSchema = get(dbSchemas)[resourcePath]
@@ -5,6 +5,8 @@
import type { ProcessedItem } from './utils.svelte'
import { twMerge } from 'tailwind-merge'
import { PlusIcon } from 'lucide-svelte'
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
import { watch } from 'runed'
let {
processedItems: _processedItems,
@@ -58,6 +60,7 @@
let listEl: HTMLDivElement | undefined = $state()
let dropdownPos = $state(computeDropdownPos())
let keyArrowPos = $state<number | undefined>()
let reducedMotion = useReducedMotion()
function computeDropdownPos(): {
width: number
@@ -100,32 +103,42 @@
// Also CSS transitions are smoother because they do not rely on JS / animation frames
let uiState = $state({ domExists: open, visible: open, timeout: null as number | null })
let initial = true
$effect(() => {
let isOpen = open && !disabled
untrack(() => {
if (initial) {
initial = false
return
}
if (uiState.timeout) clearTimeout(uiState.timeout)
uiState = {
domExists: true,
visible: !isOpen,
timeout: setTimeout(() => {
if (isOpen) {
uiState.visible = true
uiState.timeout = null
} else if (!isOpen) {
uiState.visible = false
uiState.timeout = setTimeout(() => {
uiState.domExists = false
uiState.timeout = null
}, 500) // leave time for transition to finish
watch(
() => open && !disabled,
(isOpen) => {
untrack(() => {
if (initial) {
initial = false
return
}
if (reducedMotion.val) {
uiState = {
domExists: open && !disabled,
visible: open && !disabled,
timeout: null
}
}, 0) // We need the height to be 0 then change immediately for the transition to play
}
})
})
return
}
if (uiState.timeout) clearTimeout(uiState.timeout)
uiState = {
domExists: true,
visible: !isOpen,
timeout: setTimeout(() => {
if (isOpen) {
uiState.visible = true
uiState.timeout = null
} else if (!isOpen) {
uiState.visible = false
uiState.timeout = setTimeout(() => {
uiState.domExists = false
uiState.timeout = null
}, 500) // leave time for transition to finish
}
}, 0) // We need the height to be 0 then change immediately for the transition to play
}
})
}
)
</script>
<svelte:window
@@ -153,6 +166,7 @@
{#if uiState.domExists}
<div
class={twMerge(
open ? 'select-dropdown-open' : 'select-dropdown-closed',
disablePortal ? 'absolute z-[5002]' : 'fixed z-[10000]',
'text-primary text-sm select-none',
dropdownPos.isBelow ? '' : 'flex flex-col justify-end',
@@ -165,7 +179,8 @@
>
<div
class={twMerge(
'overflow-clip rounded-md drop-shadow-base transition-height',
'overflow-clip rounded-md drop-shadow-base',
!reducedMotion.val ? 'transition-height' : '',
dropdownPos.isBelow ? '' : 'flex flex-col justify-end'
)}
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
@@ -52,6 +52,7 @@
placeholder="Search or create..."
showPlaceholderOnOpen
items={onlySelectedTags}
id="custom-instance-db-select"
disabled={!$isCustomInstanceDbEnabled}
>
{#snippet endSnippet({ item })}
@@ -161,6 +161,7 @@
{/if}
<Button
size="sm"
id="run-custom-instance-db-setup-button"
variant={!status?.success ? 'accent' : 'default'}
endIcon={status?.success ? undefined : { icon: ArrowRight }}
disabled={!$isCustomInstanceDbEnabled}
@@ -192,7 +192,7 @@
{#each tempSettings.dataTables as dataTable, dataTableIndex}
<Row>
<Cell first class="w-48 relative">
<TextInput bind:value={dataTable.name} inputProps={{ placeholder: 'Name' }} />
<TextInput bind:value={dataTable.name} inputProps={{ placeholder: 'Name', id: 'name' }} />
</Cell>
<Cell>
<div class="flex gap-2">
@@ -221,6 +221,7 @@
}
}
}
id="database-type-select"
class="w-28"
/>
</div>
@@ -202,7 +202,7 @@
</div>
{/if}
<DataTable>
<DataTable containerClass="ducklake-settings-table">
<Head>
<tr>
{#each tableHeadNames as name, i}
@@ -235,7 +235,11 @@
<code class="px-1 py-0.5 border rounded-md">ATTACH 'ducklake' AS dl;</code> shorthand
</Tooltip>
{/if}
<TextInput bind:value={ducklake.name} inputProps={{ placeholder: 'Name' }} />
<TextInput
bind:value={ducklake.name}
inputProps={{ placeholder: 'Name' }}
class="ducklake-name"
/>
</Cell>
<Cell>
<div class="flex gap-2">
@@ -309,11 +313,12 @@
else delete ducklake.storage.storage
}
}
class="w-48"
class="ducklake-workspace-storage-select w-48"
inputClass="!placeholder-secondary"
/>
<TextInput
inputProps={{ placeholder: 'Data path (defaults to /)' }}
class="ducklake-storage-data-path"
bind:value={ducklake.storage.path}
/>
</div>
@@ -132,7 +132,7 @@
</Alert>
{/if}
{#if s3ResourceSettings}
<DataTable containerClass="mt-4">
<DataTable containerClass="storage-settings-table mt-4">
<Head>
<tr>
{#each tableHeadNames as name, i}
@@ -152,7 +152,11 @@
{#if tableRow[0] === null}
<TextInput inputProps={{ placeholder: 'Primary storage', disabled: true }} />
{:else}
<TextInput bind:value={tableRow[0]} inputProps={{ placeholder: 'Name' }} />
<TextInput
bind:value={tableRow[0]}
inputProps={{ placeholder: 'Name' }}
class="secondary-storage-name-input"
/>
{/if}
</Cell>
<Cell>
@@ -167,6 +171,7 @@
{ value: 'gcloud_storage', label: 'Google Cloud Storage' }
]}
bind:value={tableRow[1].resourceType}
id="storage-resource-type-select"
class="w-40"
/>
</div>
-2
View File
@@ -156,8 +156,6 @@ export const BIGQUERY_TYPES = [
'integer[]',
'int64',
'int64[]',
'float',
'float[]',
'float64',
'float64[]',
'numeric',
+19
View File
@@ -188,6 +188,25 @@ export class ChangeOnDeepInequality<T> {
}
}
export function useReducedMotion(): { val: boolean } {
if (typeof window === 'undefined') return { val: false }
const query = window.matchMedia('(prefers-reduced-motion: reduce)')
let s = $state(query.matches)
$effect(() => {
const handler = (event: MediaQueryListEvent) => {
s = event.matches
}
query.addEventListener('change', handler)
return () => query.removeEventListener('change', handler)
})
return {
get val() {
return s
}
}
}
// Prevents flickering when data is unloaded (undefined) then reloaded quickly
// But still becomes undefined if data is not reloaded within the timeout
// so the user has feedback that the data is not available anymore.
+4
View File
@@ -1957,6 +1957,10 @@ export function countChars(str: string, char: string): number {
return count
}
export function onlyAlphaNumAndUnderscore(str: string): string {
return str.replace(/[^a-zA-Z0-9_]/g, '')
}
export function buildReactiveObj<T extends object>(fields: {
[name in keyof T]: [() => T[name], (v: T[name]) => void]
}): T {
-27
View File
@@ -1,27 +0,0 @@
import { chromium } from '@playwright/test'
async function globalSetup() {
const browser = await chromium.launch()
// baseURL is set in the global config, but it doesn't affect the globalSetup script.
const page = await browser.newPage({ baseURL: process.env.BASE_URL || 'http://localhost' })
await page.goto('/user/login', { waitUntil: 'networkidle' })
if (await page.locator('#email').isHidden()) {
await page.locator('button', {
hasText: 'Log in without third-party'
}).click()
}
const email = page.locator('input[type="email"]')
await email.fill('user@windmill.dev')
const password = page.locator('input[type="password"]')
await password.fill('changeme')
await page.locator('#login2').click()
await page.waitForResponse('/api/auth/login')
await page.context().storageState({ path: 'storageState.json' })
await browser.close()
}
export default globalSetup
-13
View File
@@ -1,13 +0,0 @@
// playwright.config.ts
import type { PlaywrightTestConfig } from '@playwright/test'
const config: PlaywrightTestConfig = {
globalSetup: './global-setup',
use: {
// Tell all tests to load signed-in state from 'storageState.json'.
storageState: 'storageState.json',
// baseURL is set in the globalSetup script as well, because it's not affected by this config.
// Make sure to update it there as well when this is updated.
baseURL: process.env.BASE_URL || 'http://localhost'
}
}
export default config
-9
View File
@@ -1,9 +0,0 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"sourceMap": false,
"outDir": "../tests-out",
}
}
-6
View File
@@ -1,6 +0,0 @@
import { test, expect } from '@playwright/test'
test('can visit workspace selector page', async ({ page }) => {
await page.goto('/user/workspaces')
await expect(page.locator('h1', { hasText: 'Select a workspace' })).toBeVisible()
})
+1 -1
View File
@@ -29,7 +29,7 @@
"strictPropertyInitialization": true,
"useUnknownInCatchVariables": false,
"strict": true,
"types": []
"types": ["vite/client"]
},
"include": ["src/**/*.js", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.svelte"],
"exclude": ["src/lib/monaco_workers/*.js"],
-14
View File
@@ -1,4 +1,3 @@
import { playwright } from '@vitest/browser-playwright'
import { sveltekit } from '@sveltejs/kit/vite'
import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'
@@ -108,19 +107,6 @@ const config = {
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.js',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
},
{
extends: './vite.config.js',
test: {