mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 00:02:03 +00:00
Playwright E2E (#7520)
* clean plate * npm i * log in e2e * global setup login * set license key * Revert "set license key" This reverts commit86d5db2c48. * 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 commitbf98a755dc. * 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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Generated
+52
-176
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+50
-45
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -156,8 +156,6 @@ export const BIGQUERY_TYPES = [
|
||||
'integer[]',
|
||||
'int64',
|
||||
'int64[]',
|
||||
'float',
|
||||
'float[]',
|
||||
'float64',
|
||||
'float64[]',
|
||||
'numeric',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": false,
|
||||
"outDir": "../tests-out",
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user