feat(frontend): DB Explorer (#2892)

* feat(frontend): Make table cell editable

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): wip

* feat(frontend): clean up

* fix(frontend): extract db schemas

* feat(frontend): v0 done

* feat(frontend): v0 done

* feat(frontend): v0 done

* feat(frontend): fix insert

* feat(frontend): fix insert

* feat(frontend): remove temp data

* feat(frontend): align insert button to the right

* feat(frontend): rework columns

* feat(frontend): rework insert

* feat(frontend): rework insert

* feat(frontend): rework insert

* feat(frontend): rework insert

* feat(frontend): fix jsonb display

* feat(frontend): fix reloading issues

* feat(frontend): fix reloading issues

* feat(frontend): fix reacticity issue

* feat(frontend): fix insert

* feat(frontend): support nullable default value

* feat(frontend): fix build

* update

* update

* db studio v0

---------

Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
Faton Ramadani
2024-01-06 17:27:15 +01:00
committed by GitHub
parent 3c2f753eac
commit fffc5338ce
44 changed files with 3275 additions and 583 deletions
+7 -2
View File
@@ -322,7 +322,13 @@ fn convert_val(value: &Value, arg_t: &String) -> windmill_common::error::Result<
Value::Number(n) if n.is_i64() && (arg_t == "smallint" || arg_t == "smallserial") => {
Ok(PgType::I16(n.as_i64().unwrap() as i16))
}
Value::Number(n) if n.is_i64() && (arg_t == "int" || arg_t == "serial") => {
Value::Number(n)
if n.is_i64()
&& (arg_t == "int"
|| arg_t == "integer"
|| arg_t == "int4"
|| arg_t == "serial") =>
{
Ok(PgType::I32(n.as_i64().unwrap() as i32))
}
Value::Number(n) if n.is_i64() && (arg_t == "numeric" || arg_t == "decimal") => Ok(
@@ -429,7 +435,6 @@ pub fn pg_cell_to_json_value(
Type::TS_VECTOR => get_basic(row, column, column_i, |a: StringCollector| {
Ok(JSONValue::String(a.0))
})?,
// array types
Type::BOOL_ARRAY => get_array(row, column, column_i, |a: bool| Ok(JSONValue::Bool(a)))?,
Type::INT2_ARRAY => get_array(row, column, column_i, |a: i16| {
+6 -3
View File
@@ -14,6 +14,7 @@ import {
writeAllSync,
yamlParse,
} from "./deps.ts";
import { deepEqual } from "./utils.ts";
export interface ScriptFile {
parent_hash?: string;
@@ -66,7 +67,7 @@ export async function handleFile(
path: string,
workspace: string,
alreadySynced: string[],
message?: string,
message?: string
): Promise<boolean> {
if (
!path.includes(".inline_script.") &&
@@ -125,8 +126,9 @@ export async function handleFile(
typed.is_template === remote.is_template &&
typed.kind == remote.kind &&
!remote.archived &&
(remote?.lock ?? "") == (typed.lock?.join("\n") ?? "") &&
JSON.stringify(typed.schema) == JSON.stringify(remote.schema) &&
(remote?.lock ?? "").trim() ==
(typed.lock?.join("\n") ?? "").trim() &&
deepEqual(typed.schema, remote.schema) &&
typed.tag == remote.tag &&
(typed.ws_error_handler_muted ?? false) ==
remote.ws_error_handler_muted &&
@@ -140,6 +142,7 @@ export async function handleFile(
return true;
}
}
log.info(
colors.yellow.bold(`Creating script with a parent ${remotePath}`)
);
+14 -15
View File
@@ -478,7 +478,7 @@ async function pull(
!opts.json
);
const local = opts.raw
? undefined
? await FSFSElement(Deno.cwd())
: await FSFSElement(path.join(Deno.cwd(), ".wmill"));
const changes = await compareDynFSElement(
remote,
@@ -685,19 +685,18 @@ async function push(
"Computing the files to update on the remote to match local (taking .wmillignore into account)"
)
);
const remote = opts.raw
? undefined
: ZipFSElement(
(await downloadZip(
workspace,
opts.plainSecrets,
opts.skipVariables,
opts.skipResources,
opts.skipSecrets,
opts.includeSchedules
))!,
!opts.json
);
const remote = ZipFSElement(
(await downloadZip(
workspace,
opts.plainSecrets,
opts.skipVariables,
opts.skipResources,
opts.skipSecrets,
opts.includeSchedules
))!,
!opts.json
);
const local = await FSFSElement(path.join(Deno.cwd(), ""));
const changes = await compareDynFSElement(
local,
@@ -850,7 +849,7 @@ async function push(
case "flow":
await FlowService.deleteFlowByPath({
workspace: workspaceId,
path: removeSuffix(change.path, ".flow.json"),
path: removeSuffix(change.path, ".flow/flow.json"),
});
break;
case "app":
@@ -460,7 +460,7 @@
{/if}
</div>
<h2 class="mt-8 mb-4" />
<div class="mt-8 mb-4" />
<div class="grid sm:grid-cols-2 md:grid-cols-3 gap-x-2 gap-y-1 items-center mb-2">
{#if filteredConnectsManual}
{#each filteredConnectsManual as [key, _]}
@@ -1,22 +1,21 @@
<script lang="ts">
import { JobService, Preview } from '$lib/gen'
import {
dbSchemas,
workspaceStore,
type DBSchema,
type GraphqlSchema,
type SQLSchema
} from '$lib/stores'
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { sendUserToast, tryEvery } from '$lib/utils'
import { sendUserToast } from '$lib/utils'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql'
import GraphqlSchemaViewer from './GraphqlSchemaViewer.svelte'
import { RefreshCcw } from 'lucide-svelte'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import {
formatGraphqlSchema,
formatSchema,
getDbSchemas,
scripts
} from './apps/components/display/dbtable/utils'
import Alert from './common/alert/Alert.svelte'
export let resourceType: string | undefined
export let resourcePath: string | undefined = undefined
@@ -25,292 +24,42 @@
let drawer: Drawer | undefined
const scripts: Record<
string,
{
code: string
lang: string
processingFn?: (any: any) => SQLSchema['schema']
argName: string
}
> = {
postgresql: {
code: `SELECT table_name, column_name, udt_name, column_default, is_nullable, table_schema FROM information_schema.columns WHERE table_schema != 'pg_catalog' AND table_schema != 'information_schema'`,
processingFn: (rows) => {
const schemas = rows.reduce((acc, a) => {
const table_schema = a.table_schema
delete a.table_schema
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
return acc
}, {})
const data = {}
for (const key in schemas) {
data[key] = schemas[key].reduce((acc, a) => {
const table_name = a.table_name
delete a.table_name
acc[table_name] = acc[table_name] || {}
const p: {
type: string
required: boolean
default?: string
} = {
type: a.udt_name,
required: a.is_nullable === 'NO'
}
if (a.column_default) {
p.default = a.column_default
}
acc[table_name][a.column_name] = p
return acc
}, {})
}
return data
},
lang: 'postgresql',
argName: 'database'
},
mysql: {
code: "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'information_schema'",
processingFn: (rows) => {
const schemas = rows.reduce((acc, a) => {
const table_schema = a.TABLE_SCHEMA
delete a.TABLE_SCHEMA
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
return acc
}, {})
const data = {}
for (const key in schemas) {
data[key] = schemas[key].reduce((acc, a) => {
const table_name = a.TABLE_NAME
delete a.TABLE_NAME
acc[table_name] = acc[table_name] || {}
const p: {
type: string
required: boolean
default?: string
} = {
type: a.DATA_TYPE,
required: a.is_nullable === 'NO'
}
if (a.column_default) {
p.default = a.COLUMN_DEFAULT
}
acc[table_name][a.COLUMN_NAME] = p
return acc
}, {})
}
return data
},
lang: 'mysql',
argName: 'database'
},
graphql: {
code: getIntrospectionQuery(),
lang: 'graphql',
argName: 'api'
},
bigquery: {
code: `import { BigQuery } from 'npm:@google-cloud/bigquery@7.2.0';
export async function main(args: bigquery) {
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
}
}
}
return schema
}`, // nested template literals
lang: 'deno',
argName: 'args'
},
snowflake: {
code: `select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE from information_schema.columns where table_schema != 'INFORMATION_SCHEMA'`,
lang: 'snowflake',
processingFn: (rows) => {
const schema = {}
for (const row of rows) {
if (!(row.TABLE_SCHEMA in schema)) {
schema[row.TABLE_SCHEMA] = {}
}
if (!(row.TABLE_NAME in schema[row.TABLE_SCHEMA])) {
schema[row.TABLE_SCHEMA][row.TABLE_NAME] = {}
}
schema[row.TABLE_SCHEMA][row.TABLE_NAME][row.COLUMN_NAME] = {
type: row.DATA_TYPE,
required: row.IS_NULLABLE === 'YES'
}
if (row.COLUMN_DEFAULT !== null) {
schema[row.TABLE_SCHEMA][row.TABLE_NAME][row.COLUMN_NAME]['default'] =
row.COLUMN_DEFAULT
}
}
return schema
},
argName: 'database'
},
ms_sql_server: {
argName: 'database',
code: `select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'sys'`,
lang: 'mssql',
processingFn: (rows) => {
const schemas = rows[0].reduce((acc, a) => {
const table_schema = a.TABLE_SCHEMA
delete a.TABLE_SCHEMA
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
return acc
}, {})
const data = {}
for (const key in schemas) {
data[key] = schemas[key].reduce((acc, a) => {
const table_name = a.TABLE_NAME
delete a.TABLE_NAME
acc[table_name] = acc[table_name] || {}
const p: {
type: string
required: boolean
default?: string
} = {
type: a.DATA_TYPE,
required: a.is_nullable === 'NO'
}
if (a.column_default) {
p.default = a.COLUMN_DEFAULT
}
acc[table_name][a.COLUMN_NAME] = p
return acc
}, {})
}
return data
}
}
}
function resourceTypeToLang(rt: string) {
if (rt === 'ms_sql_server') {
return 'mssql'
} else {
return rt
}
}
async function getSchema() {
if (!resourceType || !resourcePath) return
if ($dbSchemas[resourcePath]) return
loading = true
const job = await JobService.runScriptPreview({
workspace: $workspaceStore!,
requestBody: {
language: scripts[resourceType].lang as Preview.language,
content: scripts[resourceType].code,
args: {
[scripts[resourceType].argName]: '$res:' + resourcePath
}
}
})
tryEvery({
tryCode: async () => {
if (resourcePath) {
const testResult = await JobService.getCompletedJob({
workspace: $workspaceStore!,
id: job
})
if (!testResult.success) {
console.error(testResult.result?.['error']?.['message'])
} else {
if (resourceType !== undefined) {
if (resourceType !== 'graphql') {
const { processingFn } = scripts[resourceType]
const schema =
processingFn !== undefined ? processingFn(testResult.result) : testResult.result
$dbSchemas[resourcePath] = {
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
schema,
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
}
} else {
if (typeof testResult.result !== 'object' || !('__schema' in testResult.result)) {
console.error('Invalid GraphQL schema')
if (drawer?.isOpen()) {
sendUserToast('Invalid GraphQL schema', true)
}
} else {
$dbSchemas[resourcePath] = {
lang: 'graphql',
schema: testResult.result
}
}
}
}
try {
await getDbSchemas(
resourceType,
resourcePath,
$workspaceStore,
$dbSchemas,
(message: string) => {
if (drawer?.isOpen()) {
sendUserToast(message, true)
}
}
loading = false
},
timeoutCode: async () => {
loading = false
console.error('Could not query schema within 5s')
if (drawer?.isOpen()) {
sendUserToast('Could not query schema within 5s', true)
}
try {
await JobService.cancelQueuedJob({
workspace: $workspaceStore!,
id: job,
requestBody: {
reason: 'Could not query schema within 5s'
}
})
} catch (err) {
console.error(err)
}
},
interval: 500,
timeout: 5000
})
}
function formatSchema(dbSchema: DBSchema) {
if (dbSchema.lang !== 'graphql' && dbSchema.publicOnly) {
return dbSchema.schema.public || dbSchema.schema.PUBLIC || dbSchema.schema.dbo || dbSchema
} else if (dbSchema.lang === 'mysql' && Object.keys(dbSchema.schema).length === 1) {
return dbSchema.schema[Object.keys(dbSchema.schema)[0]]
} else {
return dbSchema.schema
)
$dbSchemas = $dbSchemas
} catch (e) {
console.error(e)
}
loading = false
}
function formatGraphqlSchema(dbSchema: GraphqlSchema) {
return printSchema(buildClientSchema(dbSchema.schema))
}
$: resourcePath &&
Object.keys(scripts).includes(resourceType || '') &&
!$dbSchemas[resourcePath] &&
getSchema()
$: resourcePath && Object.keys(scripts).includes(resourceType || '') && getSchema()
$: dbSchema = resourcePath && resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
$: shouldDisplayError = resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
</script>
{#if loading}
<Loader2 size={14} class="animate-spin " />
{/if}
{#if dbSchema}
<Button
size="xs"
@@ -350,4 +99,8 @@ GROUP BY table_name".replace('{dataset.id}', dataset.id)
{/if}
</DrawerContent>
</Drawer>
{:else if shouldDisplayError}
<Alert type="error" size="xs" title="Schema not available" class="mt-2">
Schema could not be loaded. Please check the permissions of the resource.
</Alert>
{/if}
@@ -11,6 +11,7 @@
export let workspaceOverride: string | undefined = undefined
export let notfound = false
export let isEditor = false
export let allowConcurentRequests = false
const dispatch = createEventDispatcher()
@@ -27,22 +28,28 @@
$: isLoading = currentId !== undefined
type Callbacks = { done: (x: any[]) => void; cancel: () => void; error: () => void }
let running = false
export async function abstractRun(fn: () => Promise<string>) {
let lastCallbacks: Callbacks | undefined = undefined
let finished: string[] = []
export async function abstractRun(fn: () => Promise<string>, callbacks?: Callbacks) {
try {
running = false
isLoading = true
clearCurrentJob()
const startedAt = Date.now()
const testId = await fn()
if (lastStartedAt < startedAt) {
lastCallbacks = callbacks
if (lastStartedAt < startedAt || allowConcurentRequests) {
lastStartedAt = startedAt
if (testId) {
dispatch('started', testId)
try {
await watchJob(testId)
await watchJob(testId, callbacks)
} catch {
callbacks?.cancel()
dispatch('cancel', testId)
if (currentId === testId) {
currentId = undefined
@@ -52,6 +59,7 @@
}
return testId
} catch (err) {
callbacks?.error()
// if error happens on submitting the job, reset UI state so the user can try again
isLoading = false
currentId = undefined
@@ -110,6 +118,9 @@
export async function cancelJob() {
const id = currentId
if (id) {
lastCallbacks?.cancel()
lastCallbacks = undefined
// console.log('cancel', 2)
dispatch('cancel', id)
currentId = undefined
try {
@@ -125,29 +136,32 @@
}
export async function clearCurrentJob() {
if (currentId) {
if (currentId && !allowConcurentRequests) {
lastCallbacks?.cancel()
// console.log('cancel', 3)
dispatch('cancel', currentId)
lastCallbacks = undefined
job = undefined
await cancelJob()
}
}
export async function watchJob(testId: string) {
export async function watchJob(testId: string, callbacks?: Callbacks) {
syncIteration = 0
errorIteration = 0
currentId = testId
job = undefined
const isCompleted = await loadTestJob(testId)
const isCompleted = await loadTestJob(testId, callbacks)
if (!isCompleted) {
setTimeout(() => {
syncer(testId)
syncer(testId, callbacks)
}, 50)
}
}
async function loadTestJob(id: string): Promise<boolean> {
async function loadTestJob(id: string, callbacks?: Callbacks): Promise<boolean> {
let isCompleted = false
if (currentId === id) {
if (currentId === id || allowConcurentRequests) {
try {
let maybe_job = await JobService.getCompletedJobResultMaybe({
workspace: workspace ?? '',
@@ -160,19 +174,26 @@
}
if (maybe_job.completed) {
isCompleted = true
if (currentId === id) {
if (currentId === id || allowConcurentRequests) {
job = { ...maybe_job, id }
await tick()
if ('error' in job ?? {}) {
if ('error' in job.result ?? {}) {
callbacks?.error()
dispatch('doneError', {
id,
error: job.result.error
})
} else {
callbacks?.done(job.result)
dispatch('done', job)
}
currentId = undefined
finished.push(id)
if (!allowConcurentRequests) {
currentId = undefined
}
} else {
// console.log('cancel', 3)
callbacks?.cancel()
dispatch('cancel', id)
}
}
@@ -188,25 +209,32 @@
}
return isCompleted
} else {
// console.log('cancel', 6)
callbacks?.cancel()
dispatch('cancel', id)
return true
}
}
async function syncer(id: string): Promise<void> {
if (currentId != id) {
async function syncer(id: string, callbacks?: Callbacks): Promise<void> {
if ((currentId != id && !allowConcurentRequests) || finished.includes(id)) {
callbacks?.cancel()
// console.log('cancel', 7)
dispatch('cancel', id)
return
}
syncIteration++
await loadTestJob(id)
let r = await loadTestJob(id, callbacks)
if (r) {
return
}
let nextIteration = 50
if (syncIteration > ITERATIONS_BEFORE_SLOW_REFRESH) {
nextIteration = 500
} else if (syncIteration > ITERATIONS_BEFORE_SUPER_SLOW_REFRESH) {
nextIteration = 2000
}
setTimeout(() => syncer(id), nextIteration)
setTimeout(() => syncer(id, callbacks), nextIteration)
}
onDestroy(async () => {
@@ -195,7 +195,9 @@
return
}
syncIteration++
await loadTestJob(id)
if (await loadTestJob(id)) {
return
}
let nextIteration = 50
if (syncIteration > ITERATIONS_BEFORE_SLOW_REFRESH) {
nextIteration = 500
@@ -34,6 +34,7 @@
export let errorHandledByComponent: boolean | undefined = false
export let extraKey: string | undefined = undefined
export let isMenuItem: boolean = false
export let noInitialize = false
export let controls: { left: () => boolean; right: () => boolean | string } | undefined =
undefined
@@ -156,6 +157,7 @@
<!-- gotoNewTab={resolvedConfig.onSuccess.selected == 'goto'} -->
<RunnableWrapper
{noInitialize}
bind:this={runnableWrapper}
{recomputeIds}
bind:runnableComponent
@@ -0,0 +1,460 @@
<script lang="ts">
import type {
AppEditorContext,
AppViewerContext,
ComponentCustomCSS,
RichConfigurations
} from '../../../types'
import { components } from '$lib/components/apps/editor/component'
import ResolveConfig from '../../helpers/ResolveConfig.svelte'
import { findGridItem, initConfig, initOutput } from '$lib/components/apps/editor/appUtils'
import {
createPostgresInput,
getDbSchemas,
loadTableMetaData,
type ColumnMetadata,
type TableMetadata,
getPrimaryKeys
} from './utils'
import { getContext, tick } from 'svelte'
import UpdateCell from './UpdateCell.svelte'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import Button from '$lib/components/common/button/Button.svelte'
import { Plus } from 'lucide-svelte'
import { Drawer, DrawerContent } from '$lib/components/common'
import InsertRow from './InsertRow.svelte'
import Portal from 'svelte-portal'
import { sendUserToast } from '$lib/toast'
import type { AppInput, StaticInput } from '$lib/components/apps/inputType'
import DbExplorerCount from './DbExplorerCount.svelte'
import AppAggridExplorerTable from '../table/AppAggridExplorerTable.svelte'
import type { IDatasource } from 'ag-grid-community'
import { RunnableWrapper } from '../../helpers'
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import InsertRowRunnable from './InsertRowRunnable.svelte'
import DeleteRow from './DeleteRow.svelte'
export let id: string
export let configuration: RichConfigurations
export let customCss: ComponentCustomCSS<'dbexplorercomponent'> | undefined = undefined
export let render: boolean
export let initializing: boolean = true
const resolvedConfig = initConfig(
components['dbexplorercomponent'].initialData.configuration,
configuration
)
const { app, worldStore, mode, selectedComponent } =
getContext<AppViewerContext>('AppViewerContext')
const editorContext = getContext<AppEditorContext>('AppEditorContext')
let input: AppInput | undefined = undefined
let quicksearch = ''
let aggrid: AppAggridExplorerTable
$: computeInput(
resolvedConfig.columnDefs,
resolvedConfig.whereClause,
resolvedConfig.type.configuration.postgresql.resource
)
function computeInput(columnDefs: any, whereClause: string | undefined, resource: any) {
aggrid?.clearRows()
input = createPostgresInput(
resource,
resolvedConfig.type.configuration.postgresql.table,
columnDefs,
whereClause
)
}
$: editorContext != undefined && $mode == 'dnd' && resolvedConfig.type && listTableIfAvailable()
$: editorContext != undefined &&
$mode == 'dnd' &&
resolvedConfig.type.configuration?.postgresql?.table &&
listColumnsIfAvailable()
$: if (quicksearch) {
aggrid?.clearRows()
}
initializing = false
let updateCell: UpdateCell
let renderCount = 0
let insertDrawer: Drawer | undefined = undefined
let componentContainerHeight: number | undefined = undefined
let buttonContainerHeight: number | undefined = undefined
function onUpdate(
e: CustomEvent<{
row: number
columnDef: ColumnMetadata
column: string
value: any
data: any
oldValue: string | undefined
}>
) {
const { columnDef, value, data, oldValue } = e.detail
updateCell?.triggerUpdate(
resolvedConfig.type.configuration.postgresql.resource,
resolvedConfig.type.configuration.postgresql.table ?? 'unknown',
columnDef,
resolvedConfig.columnDefs,
value,
data,
oldValue
)
}
let args: Record<string, any> = {}
let outputs = initOutput($worldStore, id, {
selectedRowIndex: 0,
selectedRow: {},
selectedRows: [] as any[],
result: [] as any[],
loading: false,
page: 0,
newChange: { row: 0, column: '', value: undefined },
ready: undefined as boolean | undefined
})
let lastResource: string | undefined = undefined
async function listTableIfAvailable() {
let resource = resolvedConfig.type.configuration?.postgresql?.resource
if (lastResource === resource) return
lastResource = resource
const gridItem = findGridItem($app, id)
if (!gridItem) {
return
}
if (
'configuration' in gridItem.data?.configuration?.type &&
'selectOptions' in gridItem.data?.configuration?.type?.configuration?.postgresql?.table
) {
gridItem.data.configuration.type.configuration.postgresql.table.selectOptions = []
}
if (!resolvedConfig.type?.configuration?.postgresql?.resource) {
$app = {
...$app
}
return
}
if (
'configuration' in gridItem.data?.configuration?.type &&
gridItem.data.configuration.type.configuration.postgresql.table
) {
gridItem.data.configuration.type.configuration.postgresql.table.loading = true
}
try {
const dbSchemas: DBSchemas = {}
await getDbSchemas(
'postgresql',
resolvedConfig.type.configuration.postgresql.resource.split(':')[1],
$workspaceStore,
dbSchemas,
(message: string) => {}
)
if ('configuration' in gridItem.data.configuration.type) {
gridItem.data.configuration.type.configuration.postgresql.table['selectOptions'] = dbSchemas
? // @ts-ignore
Object.keys(Object.values(dbSchemas)?.[0]?.schema?.public ?? {})
: []
}
$app = {
...$app
}
} catch (e) {}
if (
'configuration' in gridItem.data?.configuration?.type &&
gridItem.data.configuration.type.configuration.postgresql.table
) {
gridItem.data.configuration.type.configuration.postgresql.table.loading = false
}
}
let datasource: IDatasource = {
rowCount: 0,
getRows: async function (params) {
refreshCount++
let uuid = await runnableComponent?.runComponent(
undefined,
undefined,
undefined,
{
offset: params.startRow,
limit: params.endRow - params.startRow,
quicksearch,
orderBy: params.sortModel?.[0]?.colId ?? resolvedConfig.columnDefs?.[0]?.field,
is_desc: params.sortModel?.[0]?.sort === 'desc'
},
{
done: (x) => {
if (x && Array.isArray(x)) {
params.successCallback(
x.map((x) => {
let primaryKeys = getPrimaryKeys(resolvedConfig.columnDefs)
let o = {}
primaryKeys.forEach((pk) => {
o[pk] = x[pk]
})
x['__index'] = JSON.stringify(o)
return x
}),
datasource.rowCount
)
} else {
params.failCallback()
}
},
cancel: () => {
console.log('cancel datasource request')
params.failCallback()
},
error: () => {
console.log('error datasource request')
params.failCallback()
}
}
)
console.log('asking for ' + params.startRow + ' to ' + params.endRow, uuid)
}
}
let lastTable: string | undefined = undefined
async function listColumnsIfAvailable() {
let table = resolvedConfig.type.configuration?.postgresql?.table
if (lastTable === table) return
lastTable = table
let tableMetadata = await loadTableMetaData(
resolvedConfig.type.configuration.postgresql.resource,
$workspaceStore,
resolvedConfig.type.configuration.postgresql.table
)
if (!tableMetadata) return
const gridItem = findGridItem($app, id)
if (!gridItem) return
let columnDefs = gridItem.data.configuration.columnDefs as StaticInput<TableMetadata>
let old: TableMetadata = (columnDefs?.value as TableMetadata) ?? []
if (!Array.isArray(old)) {
console.log('old is not an array RESET')
old = []
}
// console.log('OLD', old)
// console.log(tableMetadata)
const oldMap = Object.fromEntries(old.filter((x) => x != undefined).map((x) => [x.field, x]))
const newMap = Object.fromEntries(tableMetadata?.map((x) => [x.field, x]) ?? [])
let ncols: any[] = []
Object.entries(oldMap).forEach(([key, value]) => {
if (newMap[key]) {
ncols.push({
...value,
...newMap[key]
})
}
})
Object.entries(newMap).forEach(([key, value]) => {
if (!oldMap[key]) {
ncols.push(value)
}
})
state = undefined
//@ts-ignore
gridItem.data.configuration.columnDefs = { value: ncols, type: 'static' }
gridItem.data = gridItem.data
$app = $app
let oldS = $selectedComponent
$selectedComponent = []
await tick()
$selectedComponent = oldS
//@ts-ignore
resolvedConfig.columnDefs = ncols
renderCount += 1
}
let isInsertable: boolean = false
$: $worldStore && connectToComponents()
function connectToComponents() {
if ($worldStore) {
const outputs = $worldStore.outputsById[`${id}_count`]
if (outputs) {
outputs.result.subscribe(
{
id: 'dbexplorer-count-' + id,
next: (value) => {
datasource.rowCount = value?.[0]?.count
}
},
datasource.rowCount
)
}
}
}
async function insert() {
try {
await insertRowRunnable?.insertRow(
resolvedConfig.type.configuration.postgresql.resource,
$workspaceStore,
resolvedConfig.type.configuration.postgresql.table,
resolvedConfig.columnDefs,
args
)
insertDrawer?.closeDrawer()
renderCount++
} catch (e) {
sendUserToast(e.message, true)
}
args = {}
}
let runnableComponent: RunnableComponent
let state: any = undefined
let insertRowRunnable: InsertRowRunnable
let deleteRow: DeleteRow
function onDelete(e) {
const data = { ...e.detail }
delete data['__index']
let primaryColumns = getPrimaryKeys(resolvedConfig.columnDefs)
let getPrimaryKeysresolvedConfig = resolvedConfig.columnDefs?.filter((x) =>
primaryColumns.includes(x.field)
)
deleteRow?.triggerDelete(
resolvedConfig.type.configuration.postgresql.resource,
resolvedConfig.type.configuration.postgresql.table ?? 'unknown',
getPrimaryKeysresolvedConfig,
data
)
}
let refreshCount = 0
</script>
{#each Object.keys(components['dbexplorercomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
extraKey="db_explorer"
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
<InsertRowRunnable
on:insert={() => {
aggrid?.clearRows()
refreshCount++
}}
{id}
bind:this={insertRowRunnable}
/>
{#if resolvedConfig.allowDelete}
<DeleteRow
on:deleted={() => {
aggrid?.clearRows()
refreshCount++
}}
{id}
bind:this={deleteRow}
/>
{/if}
<UpdateCell {id} bind:this={updateCell} />
<DbExplorerCount
renderCount={refreshCount}
{id}
{quicksearch}
table={resolvedConfig?.type?.configuration?.postgresql?.table ?? ''}
resource={resolvedConfig?.type?.configuration?.postgresql?.resource ?? ''}
/>
<RunnableWrapper
allowConcurentRequests
noInitialize
bind:runnableComponent
componentInput={input}
autoRefresh={false}
{render}
{id}
{outputs}
>
<div class="h-full" bind:clientHeight={componentContainerHeight}>
<div class="flex p-2 justify-between gap-4" bind:clientHeight={buttonContainerHeight}>
<input
on:pointerdown|stopPropagation
on:keydown|stopPropagation
class="w-full max-w-[300px]"
type="text"
bind:value={quicksearch}
placeholder="Quicksearch"
/>
<Button
startIcon={{ icon: Plus }}
color="dark"
size="xs2"
on:click={() => {
args = {}
insertDrawer?.openDrawer()
}}
>
Insert
</Button>
</div>
{#if resolvedConfig.type.configuration?.postgresql?.resource && resolvedConfig.type.configuration?.postgresql?.table}
<!-- {JSON.stringify(lastInput)} -->
<!-- <span class="text-xs">{JSON.stringify(configuration.columnDefs)}</span> -->
{#key renderCount}
<!-- {JSON.stringify(resolvedConfig.columnDefs)} -->
<AppAggridExplorerTable
bind:this={aggrid}
bind:state
{id}
{datasource}
{resolvedConfig}
{customCss}
{outputs}
allowDelete={resolvedConfig.allowDelete ?? false}
containerHeight={componentContainerHeight - buttonContainerHeight}
on:update={onUpdate}
on:delete={onDelete}
/>
{/key}
{/if}
</div>
</RunnableWrapper>
<Portal>
<Drawer bind:this={insertDrawer} size="800px">
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
<svelte:fragment slot="actions">
<Button color="dark" size="xs" on:click={insert} disabled={!isInsertable}>Insert</Button>
</svelte:fragment>
<InsertRow bind:args bind:isInsertable columnDefs={resolvedConfig.columnDefs} />
</DrawerContent>
</Drawer>
</Portal>
@@ -0,0 +1,71 @@
<script lang="ts">
import { getContext, tick } from 'svelte'
import type { AppInput } from '../../../inputType'
import type { AppViewerContext } from '../../../types'
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import { initOutput } from '../../../editor/appUtils'
import { getCountPostgresql } from './utils'
export let id: string
export let table: string
export let resource: string
export let renderCount: number
export let quicksearch: string
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
let outputs = initOutput($worldStore, `${id}_count`, {
result: undefined,
loading: false,
jobId: undefined
})
let runnableComponent: RunnableComponent
let loading = false
let input: AppInput | undefined = undefined
$: table && renderCount != undefined && quicksearch != undefined && computeCount()
let lastTableCount = ''
let renderCountLast = -1
let quicksearchLast: string | undefined = undefined
async function computeCount() {
if (
lastTableCount === table &&
renderCount == renderCountLast &&
quicksearch == quicksearchLast
)
return
if (table != '' && resource != '') {
renderCountLast = renderCount
lastTableCount = table
quicksearchLast = quicksearch
await getCount(resource, table, quicksearch)
}
}
async function getCount(resource: string, table: string, quicksearch: string) {
input = getCountPostgresql(resource, table)
await tick()
if (runnableComponent) {
await runnableComponent?.runComponent(undefined, undefined, undefined, {
quicksearch
})
}
}
</script>
<RunnableWrapper
noInitialize
bind:runnableComponent
bind:loading
componentInput={input}
autoRefresh={false}
render={false}
id={`${id}_count`}
{outputs}
/>
@@ -0,0 +1,76 @@
<script lang="ts">
import { createEventDispatcher, getContext, tick } from 'svelte'
import type { AppInput } from '../../../inputType'
import type { AppViewerContext } from '../../../types'
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import { initOutput } from '../../../editor/appUtils'
import { type ColumnMetadata, createDeletePostgresInput } from './utils'
import { sendUserToast } from '$lib/toast'
export let id: string
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
let outputs = initOutput($worldStore, `${id}_delete`, {
result: undefined,
loading: false,
jobId: undefined
})
let runnableComponent: RunnableComponent
let loading = false
let input: AppInput | undefined = undefined
const dispatch = createEventDispatcher()
export async function triggerDelete(
resource: string,
table: string,
columns: ColumnMetadata[],
data: Record<string, any>
) {
// const datatype = tableMetaData?.find((column) => column.isprimarykey)?.datatype
input = createDeletePostgresInput(resource, table, columns)
await tick()
if (runnableComponent) {
let ndata = {}
columns.forEach((x) => {
ndata[x.field] = data[x.field]
})
await runnableComponent?.runComponent(
undefined,
undefined,
undefined,
{ ...ndata },
{
done: (x) => {
sendUserToast('Row deleted', false)
dispatch('deleted')
},
cancel: () => {
sendUserToast('Error deleting row', true)
},
error: () => {
sendUserToast('Error updating row', true)
}
}
)
}
}
</script>
<RunnableWrapper
noInitialize
bind:runnableComponent
bind:loading
componentInput={input}
autoRefresh={false}
render={false}
id={`${id}_delete`}
{outputs}
/>
@@ -0,0 +1,130 @@
<script lang="ts">
import type { Schema, SchemaProperty } from '$lib/common'
import LightweightSchemaForm from '$lib/components/LightweightSchemaForm.svelte'
import { ColumnIdentity, type ColumnMetadata } from './utils'
export let args: Record<string, any> = {}
export let columnDefs: Array<
{
field: string
ignored: boolean
hideInsert: boolean
overrideDefaultValue: boolean
defaultUserValue: any
defaultValueNull: boolean
} & ColumnMetadata
> = []
type FieldMetadata = {
type: string
name: string
isPrimaryKey: boolean
defaultValue: string | undefined
fieldType: 'text' | 'number' | 'checkbox' | 'date'
identity: ColumnIdentity
nullable: 'YES' | 'NO'
}
$: fields = columnDefs
?.filter((t) => {
const shouldFilter = t.isidentity !== ColumnIdentity.Always && t?.hideInsert === true
return !shouldFilter
})
.map((column) => {
const type = column.datatype
const name = column.field
const isPrimaryKey = column.isprimarykey
const defaultValue = column.defaultValueNull ? null : column.defaultUserValue
const baseType = type.split('(')[0]
const validTextTypes = ['character varying', 'text']
const validNumberTypes = ['integer', 'bigint', 'numeric', 'double precision']
const validDateTypes = ['date', 'timestamp without time zone', 'timestamp with time zone']
const fieldType = validTextTypes.includes(baseType)
? 'text'
: validNumberTypes.includes(baseType)
? 'number'
: baseType === 'boolean'
? 'checkbox'
: validDateTypes.includes(baseType)
? 'date'
: 'text'
return {
type,
name,
isPrimaryKey,
defaultValue,
fieldType,
identity: column.isidentity,
nullable: column.isnullable
}
}) as FieldMetadata[] | undefined
function builtSchema(fields: FieldMetadata[]): Schema {
const properties: { [name: string]: SchemaProperty } = {}
const required: string[] = []
fields.forEach((field) => {
const schemaProperty: SchemaProperty = {
type: field.fieldType
}
switch (field.fieldType) {
case 'number':
schemaProperty.type = 'number'
const extractedDefaultValue = field.defaultValue
schemaProperty.default = extractedDefaultValue ? Number(extractedDefaultValue) : undefined
break
case 'checkbox':
schemaProperty.type = 'boolean'
schemaProperty.default = field.defaultValue?.toLocaleLowerCase() === 'true'
break
case 'date':
schemaProperty.type = 'string'
schemaProperty.format = 'date-time'
schemaProperty.default = field.defaultValue
break
case 'text':
default:
schemaProperty.type = 'string'
schemaProperty.default = field.defaultValue
break
}
properties[field.name] = schemaProperty
const isRequired =
(field.isPrimaryKey || field.defaultValue === undefined || field.defaultValue === null) &&
field.nullable !== 'YES' &&
![ColumnIdentity.Always, ColumnIdentity.ByDefault].includes(field.identity)
if (isRequired) {
required.push(field.name)
}
})
return {
$schema: 'http://json-schema.org/draft-07/schema#',
type: 'object',
properties,
required
}
}
$: schema = builtSchema(fields ?? []) as Schema
export let isInsertable: boolean = false
$: if (schema) {
const requiredFields = schema.required ?? []
const filledFields = Object.keys(args).filter(
(key) => args[key] !== undefined && args[key] !== null
)
isInsertable = requiredFields.every((field) => filledFields.includes(field))
}
</script>
<LightweightSchemaForm {schema} bind:args />
@@ -0,0 +1,70 @@
<script lang="ts">
import { createEventDispatcher, getContext, tick } from 'svelte'
import type { AppInput } from '../../../inputType'
import type { AppViewerContext } from '../../../types'
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import { initOutput } from '../../../editor/appUtils'
import { type ColumnDef, createPostgresInsert } from './utils'
import { sendUserToast } from '$lib/toast'
export let id: string
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
let outputs = initOutput($worldStore, `${id}_insert`, {
result: undefined,
loading: false,
jobId: undefined
})
let runnableComponent: RunnableComponent
let loading = false
let input: AppInput | undefined = undefined
const dispatch = createEventDispatcher()
export async function insertRow(
resource: string,
workspace: string | undefined,
table: string | undefined,
columns: ColumnDef[],
values: Record<string, any>
): Promise<boolean> {
if (!resource || !table || !workspace) {
return false
}
input = createPostgresInsert(table, columns, resource)
await tick()
if (runnableComponent) {
await runnableComponent?.runComponent(undefined, undefined, undefined, values, {
done: (x) => {
dispatch('insert')
sendUserToast('Row inserted', false)
},
cancel: () => {
sendUserToast('Error inserting row', true)
},
error: () => {
sendUserToast('Error inserting row', true)
}
})
}
return false
}
</script>
<RunnableWrapper
noInitialize
bind:runnableComponent
bind:loading
componentInput={input}
autoRefresh={false}
render={false}
id={`${id}_insert`}
{outputs}
/>
@@ -0,0 +1,80 @@
<script lang="ts">
import { getContext, tick } from 'svelte'
import type { AppInput } from '../../../inputType'
import type { AppViewerContext } from '../../../types'
import type RunnableComponent from '../../helpers/RunnableComponent.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import { initOutput } from '../../../editor/appUtils'
import { createUpdatePostgresInput, type ColumnMetadata, getPrimaryKeys } from './utils'
import { sendUserToast } from '$lib/toast'
export let id: string
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
let outputs = initOutput($worldStore, `${id}_update`, {
result: undefined,
loading: false,
jobId: undefined
})
let runnableComponent: RunnableComponent
let loading = false
let input: AppInput | undefined = undefined
export async function triggerUpdate(
resource: string,
table: string,
column: ColumnMetadata,
allColumns: ColumnMetadata[],
valueToUpdate: string,
data: Record<string, any>,
oldValue: string | undefined = undefined
) {
// const datatype = tableMetaData?.find((column) => column.isprimarykey)?.datatype
let primaryColumns = getPrimaryKeys(allColumns)
let columns = allColumns?.filter((x) => primaryColumns.includes(x.field))
input = createUpdatePostgresInput(resource, table, column, columns)
await tick()
if (runnableComponent) {
let ndata = {}
columns.forEach((x) => {
ndata[x.field] = data[x.field]
})
ndata[column.field] = oldValue
await runnableComponent?.runComponent(
undefined,
undefined,
undefined,
{ valueToUpdate, ...ndata },
{
done: (x) => {
sendUserToast('Value updated', false)
},
cancel: () => {
sendUserToast('Error updating value', true)
},
error: () => {
sendUserToast('Error updating value', true)
}
}
)
}
}
</script>
<RunnableWrapper
noInitialize
bind:runnableComponent
bind:loading
componentInput={input}
autoRefresh={false}
render={false}
id={`${id}_update`}
{outputs}
/>
@@ -0,0 +1,783 @@
import type { AppInput, RunnableByName } from '../../../inputType'
import { JobService, Preview } from '$lib/gen'
import type { DBSchema, DBSchemas, GraphqlSchema, SQLSchema } from '$lib/stores'
import { buildClientSchema, getIntrospectionQuery, printSchema } from 'graphql'
import { tryEvery } from '$lib/utils'
export function makeQuery(
table: string,
tableMetadata: TableMetadata,
whereClause: string | undefined
) {
if (!table) throw new Error('Table name is required')
const filteredColumns = tableMetadata
.filter((x) => x != undefined)
.map((column) => `${column?.field}::text`)
let selectClause = filteredColumns.join(', ')
let orderBy = `
${tableMetadata
.map(
(column) =>
`
(CASE WHEN $4 = '${column.field}' AND $5 IS false THEN ${column.field}::text END),
(CASE WHEN $4 = '${column.field}' AND $5 IS true THEN ${column.field}::text END) DESC`
)
.join(',\n')}`
let query = `
-- $1 limit
-- $2 offset
-- $3 quicksearch
-- $4 orderBy
-- $5 is_desc
SELECT ${selectClause} FROM ${table} WHERE `
if (whereClause) {
query += ` ${whereClause} AND `
}
query += ` ($3 = '' OR ${table}::text ILIKE '%' || $3 || '%')`
query += ` ORDER BY ${orderBy}`
query += ` LIMIT $1::INT OFFSET $2::INT`
return query
}
export function createPostgresInsert(
table: string,
columns: ColumnDef[],
resource: string
): AppInput {
return {
runnable: {
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: makeInsertQuery(table, columns),
language: Preview.language.POSTGRESQL,
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: ['database'],
type: 'object'
}
}
},
fields: {
database: {
type: 'static',
value: resource,
fieldType: 'object',
format: 'resource-postgresql'
}
},
type: 'runnable',
fieldType: 'object'
}
}
export function makeInsertQuery(table: string, columns: ColumnDef[]) {
if (!table) throw new Error('Table name is required')
const columnsInsert = columns.filter((x) => !x.hideInsert)
const columnsDefault = columns.filter(
(x) => x.hideInsert && (x.overrideDefaultValue || x.defaultvalue === null)
)
const allInsertColumns = columnsInsert.concat(columnsDefault)
// Constructing the query
const query = `
${columnsInsert.map((column, i) => `-- $${i + 1} ${column.field}`).join('\n')}
INSERT INTO ${table} (${allInsertColumns.map((c) => c.field).join(', ')})
VALUES (${columnsInsert.map((c, i) => `$${i + 1}::${c.datatype}`).join(', ')}${
columnsDefault.length > 0 ? ',' : ''
} ${columnsDefault
.map((c) => (c.defaultValueNull ? 'NULL' : `${c.defaultUserValue}::${c.datatype}`))
.join(', ')})`
return query
}
export function getPrimaryKeys(tableMetadata?: TableMetadata): string[] {
let r = tableMetadata?.filter((x) => x.isprimarykey)?.map((x) => x.field) ?? []
if (r?.length === 0) {
r = tableMetadata?.map((x) => x.field) ?? []
}
return r ?? []
}
export function createPostgresInput(
resource: string,
table: string | undefined,
columns: TableMetadata,
whereClause: string | undefined
): AppInput | undefined {
if (!resource || !table || !columns) {
// Return undefined if resource or table is not defined
return undefined
}
const getRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: makeQuery(table, columns, whereClause),
language: Preview.language.POSTGRESQL
}
}
const getQuery: AppInput = {
runnable: getRunnable,
fields: {
database: {
type: 'static',
value: resource,
fieldType: 'object',
format: 'resource-postgresql'
}
},
type: 'runnable',
fieldType: 'object'
}
return getQuery
}
export function createUpdatePostgresInput(
resource: string,
table: string,
column: ColumnMetadata,
columns: ColumnMetadata[]
): AppInput | undefined {
if (!resource || !table) {
return undefined
}
const query = updateWithAllValues()
function updateWithAllValues() {
let query = `
-- $1 valueToUpdate
${columns.map((c, i) => `-- $${i + 2} ${c.field}`).join('\n')}
UPDATE ${table} SET ${column.field} = $1::text::${column.datatype} WHERE
${columns.map((c, i) => `${c.field} = $${i + 2}::text::${c.datatype} `).join(' AND ')}
RETURNING 1`
return query
}
const updateRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: query,
language: Preview.language.POSTGRESQL,
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: ['database'],
type: 'object'
}
}
}
const updateQuery: AppInput = {
runnable: updateRunnable,
fields: {
database: {
type: 'static',
value: resource,
fieldType: 'object',
format: 'resource-postgresql'
}
},
type: 'runnable',
fieldType: 'object'
}
return updateQuery
}
export function createDeletePostgresInput(
resource: string,
table: string,
columns: ColumnMetadata[]
): AppInput | undefined {
if (!resource || !table) {
return undefined
}
const query = updateWithAllValues()
function updateWithAllValues() {
let query = `
${columns.map((c, i) => `-- $${i + 1} ${c.field}`).join('\n')}
DELETE FROM ${table} WHERE ${columns
.map((c, i) => `${c.field} = $${i + 1}::text::${c.datatype}`)
.join(' AND ')} RETURNING 1;`
return query
}
const updateRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: query,
language: Preview.language.POSTGRESQL,
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: ['database'],
type: 'object'
}
}
}
const updateQuery: AppInput = {
runnable: updateRunnable,
fields: {
database: {
type: 'static',
value: resource,
fieldType: 'object',
format: 'resource-postgresql'
}
},
type: 'runnable',
fieldType: 'object'
}
return updateQuery
}
export function getCountPostgresql(resource: string, table: string): AppInput | undefined {
if (!resource || !table) {
return undefined
}
const query = `
-- $1 quicksearch
SELECT COUNT(*) FROM ${table} WHERE ($1 = '' OR ${table}::text ILIKE '%' || $1 || '%')`
const updateRunnable: RunnableByName = {
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: query,
language: Preview.language.POSTGRESQL,
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {
database: {
description: 'Database name',
type: 'object',
format: 'resource-postgresql'
}
},
required: ['database'],
type: 'object'
}
}
}
const updateQuery: AppInput = {
runnable: updateRunnable,
fields: {
database: {
type: 'static',
value: resource,
fieldType: 'object',
format: 'resource-postgresql'
}
},
type: 'runnable',
fieldType: 'object'
}
return updateQuery
}
export enum ColumnIdentity {
ByDefault = 'By Default',
Always = 'Always',
No = 'No'
}
export type ColumnMetadata = {
field: string
datatype: string
defaultvalue: string
isprimarykey: boolean
isidentity: ColumnIdentity
isnullable: 'YES' | 'NO'
isenum: boolean
}
export type TableMetadata = ColumnMetadata[]
export type ColumnDef = {
minWidth: number
hide: boolean
flex: number
sort: 'asc' | 'desc'
sortIndex: number
aggFunc: string
pivot: boolean
pivotIndex: number
pinned: 'left' | 'right' | boolean
rowGroup: boolean
rowGroupIndex: number
valueFormatter: string
valueParser: string
field: string
headerName: string
// DBExplorer
ignored: boolean
hideInsert: boolean
editable: boolean
overrideDefaultValue: boolean
defaultUserValue: any
defaultValueNull: boolean
} & ColumnMetadata
export async function loadTableMetaData(
resource: string,
workspace: string | undefined,
table: string | undefined
): Promise<TableMetadata | undefined> {
if (!resource || !table || !workspace) {
return undefined
}
const code = `
SELECT
a.attname as field,
pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType,
(SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128)
FROM pg_catalog.pg_attrdef d
WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum AND a.atthasdef) as DefaultValue,
(SELECT CASE WHEN i.indisprimary THEN true ELSE 'NO' END
FROM pg_catalog.pg_class tbl, pg_catalog.pg_class idx, pg_catalog.pg_index i, pg_catalog.pg_attribute att
WHERE tbl.oid = a.attrelid AND idx.oid = i.indexrelid AND att.attrelid = tbl.oid
AND i.indrelid = tbl.oid AND att.attnum = any(i.indkey) AND att.attname = a.attname LIMIT 1) as IsPrimaryKey,
CASE a.attidentity
WHEN 'd' THEN 'By Default'
WHEN 'a' THEN 'Always'
ELSE 'No'
END as IsIdentity,
CASE a.attnotnull
WHEN false THEN 'YES'
ELSE 'NO'
END as IsNullable,
(SELECT true
FROM pg_catalog.pg_enum e
WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum
FROM pg_catalog.pg_attribute a
WHERE a.attrelid = (SELECT oid FROM pg_catalog.pg_class WHERE relname = '${table}')
AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum;
`
const maxRetries = 3
let attempts = 0
while (attempts < maxRetries) {
try {
const job = await JobService.runScriptPreview({
workspace: workspace,
requestBody: {
language: Preview.language.POSTGRESQL,
content: code,
args: {
database: resource
}
}
})
await new Promise((resolve) => setTimeout(resolve, 3000))
const testResult = await JobService.getCompletedJob({
workspace: workspace,
id: job
})
if (testResult.success) {
attempts = maxRetries
return testResult.result
} else {
attempts++
}
} catch (error) {
attempts++
}
// Exponential back-off
await new Promise((resolve) => setTimeout(resolve, 2000 * attempts))
}
console.error('Failed to load table metadata after maximum retries.')
return undefined
}
export function resourceTypeToLang(rt: string) {
if (rt === 'ms_sql_server') {
return 'mssql'
} else {
return rt
}
}
const scripts: Record<
string,
{
code: string
lang: string
processingFn?: (any: any) => SQLSchema['schema']
argName: string
}
> = {
postgresql: {
code: `SELECT table_name, column_name, udt_name, column_default, is_nullable, table_schema FROM information_schema.columns WHERE table_schema != 'pg_catalog' AND table_schema != 'information_schema'`,
processingFn: (rows) => {
const schemas = rows.reduce((acc, a) => {
const table_schema = a.table_schema
delete a.table_schema
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
return acc
}, {})
const data = {}
for (const key in schemas) {
data[key] = schemas[key].reduce((acc, a) => {
const table_name = a.table_name
delete a.table_name
acc[table_name] = acc[table_name] || {}
const p: {
type: string
required: boolean
default?: string
} = {
type: a.udt_name,
required: a.is_nullable === 'NO'
}
if (a.column_default) {
p.default = a.column_default
}
acc[table_name][a.column_name] = p
return acc
}, {})
}
return data
},
lang: 'postgresql',
argName: 'database'
},
mysql: {
code: "select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'information_schema'",
processingFn: (rows) => {
const schemas = rows.reduce((acc, a) => {
const table_schema = a.TABLE_SCHEMA
delete a.TABLE_SCHEMA
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
return acc
}, {})
const data = {}
for (const key in schemas) {
data[key] = schemas[key].reduce((acc, a) => {
const table_name = a.TABLE_NAME
delete a.TABLE_NAME
acc[table_name] = acc[table_name] || {}
const p: {
type: string
required: boolean
default?: string
} = {
type: a.DATA_TYPE,
required: a.is_nullable === 'NO'
}
if (a.column_default) {
p.default = a.COLUMN_DEFAULT
}
acc[table_name][a.COLUMN_NAME] = p
return acc
}, {})
}
return data
},
lang: 'mysql',
argName: 'database'
},
graphql: {
code: getIntrospectionQuery(),
lang: 'graphql',
argName: 'api'
},
bigquery: {
code: `import { BigQuery } from 'npm:@google-cloud/bigquery@7.2.0';
export async function main(args: bigquery) {
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
}
}
}
return schema
}`, // nested template literals
lang: 'deno',
argName: 'args'
},
snowflake: {
code: `select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT, IS_NULLABLE from information_schema.columns where table_schema != 'INFORMATION_SCHEMA'`,
lang: 'snowflake',
processingFn: (rows) => {
const schema = {}
for (const row of rows) {
if (!(row.TABLE_SCHEMA in schema)) {
schema[row.TABLE_SCHEMA] = {}
}
if (!(row.TABLE_NAME in schema[row.TABLE_SCHEMA])) {
schema[row.TABLE_SCHEMA][row.TABLE_NAME] = {}
}
schema[row.TABLE_SCHEMA][row.TABLE_NAME][row.COLUMN_NAME] = {
type: row.DATA_TYPE,
required: row.IS_NULLABLE === 'YES'
}
if (row.COLUMN_DEFAULT !== null) {
schema[row.TABLE_SCHEMA][row.TABLE_NAME][row.COLUMN_NAME]['default'] = row.COLUMN_DEFAULT
}
}
return schema
},
argName: 'database'
},
ms_sql_server: {
argName: 'database',
code: `select TABLE_SCHEMA, TABLE_NAME, DATA_TYPE, COLUMN_NAME, COLUMN_DEFAULT from information_schema.columns where table_schema != 'sys'`,
lang: 'mssql',
processingFn: (rows) => {
const schemas = rows[0].reduce((acc, a) => {
const table_schema = a.TABLE_SCHEMA
delete a.TABLE_SCHEMA
acc[table_schema] = acc[table_schema] || []
acc[table_schema].push(a)
return acc
}, {})
const data = {}
for (const key in schemas) {
data[key] = schemas[key].reduce((acc, a) => {
const table_name = a.TABLE_NAME
delete a.TABLE_NAME
acc[table_name] = acc[table_name] || {}
const p: {
type: string
required: boolean
default?: string
} = {
type: a.DATA_TYPE,
required: a.is_nullable === 'NO'
}
if (a.column_default) {
p.default = a.COLUMN_DEFAULT
}
acc[table_name][a.COLUMN_NAME] = p
return acc
}, {})
}
return data
}
}
}
export { scripts }
export async function getDbSchemas(
resourceType: string,
resourcePath: string,
workspace: string | undefined,
dbSchemas: DBSchemas,
errorCallback: (message: string) => void
): Promise<void> {
return new Promise(async (resolve, reject) => {
if (!resourceType || !resourcePath || !workspace) {
resolve()
return
}
const job = await JobService.runScriptPreview({
workspace: workspace,
requestBody: {
language: scripts[resourceType].lang as Preview.language,
content: scripts[resourceType].code,
args: {
[scripts[resourceType].argName]: '$res:' + resourcePath
}
}
})
tryEvery({
tryCode: async () => {
if (resourcePath) {
const testResult = await JobService.getCompletedJob({
workspace,
id: job
})
if (!testResult.success) {
console.error(testResult.result?.['error']?.['message'])
} else {
if (resourceType !== undefined) {
if (resourceType !== 'graphql') {
const { processingFn } = scripts[resourceType]
const schema =
processingFn !== undefined ? processingFn(testResult.result) : testResult.result
dbSchemas[resourcePath] = {
lang: resourceTypeToLang(resourceType) as SQLSchema['lang'],
schema,
publicOnly: !!schema.public || !!schema.PUBLIC || !!schema.dbo
}
} else {
if (typeof testResult.result !== 'object' || !('__schema' in testResult.result)) {
console.error('Invalid GraphQL schema')
errorCallback('Invalid GraphQL schema')
} else {
dbSchemas[resourcePath] = {
lang: 'graphql',
schema: testResult.result
}
}
}
}
}
resolve()
}
},
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
})
})
}
export function formatSchema(dbSchema: DBSchema) {
if (dbSchema.lang !== 'graphql' && dbSchema.publicOnly) {
return dbSchema.schema.public || dbSchema.schema.PUBLIC || dbSchema.schema.dbo || dbSchema
} else if (dbSchema.lang === 'mysql' && Object.keys(dbSchema.schema).length === 1) {
return dbSchema.schema[Object.keys(dbSchema.schema)[0]]
} else {
return dbSchema.schema
}
}
export function formatGraphqlSchema(dbSchema: GraphqlSchema): string {
return printSchema(buildClientSchema(dbSchema.schema))
}
/**
* Base class for embedding a svelte component within an AGGrid call.
* See: https://stackoverflow.com/a/72608215
*/
import type { ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
/**
* Class for defining a cell renderer.
* If you don't need to define a separate class you could use cellRendererFactory
* to create a component with the column definitions.
*/
export abstract class AbstractCellRenderer implements ICellRendererComp {
eGui: any
protected value: any
protected params: any
constructor(parentElement = 'span') {
// create empty span (or other element) to place svelte component in
this.eGui = document.createElement(parentElement)
}
init(params: ICellRendererParams & { onClick?: (data: any) => void }) {
this.value = params.value
this.createComponent(params)
this.eGui.addEventListener('click', () => params.onClick?.(params.data))
this.params = params
}
getGui() {
return this.eGui
}
refresh(params: ICellRendererParams) {
this.value = params.value
this.eGui.innerHTML = ''
return true
}
/**
* Define and create the svelte component to use in the cell
* @example
* // This is all you need to do within this method: create the component with new, specify the target
* // is the class, and pass in props via the params.
* new CampusIcon({
* target: this.eGui,
* props: {
* color: params.data?.color,
* name: params.data?.name
* }
* @param params params for rendering the call, including the value for the cell
*/
abstract createComponent(params: ICellRendererParams): void
}
/**
* Creates a cell renderer using the given callback for how to initialise a svelte component.
* See AbstractCellRenderer.createComponent
* @param svelteComponent function for how to create the svelte component
* @returns
*/
export function cellRendererFactory(
svelteComponent: (cell: AbstractCellRenderer, params: ICellRendererParams) => void
) {
class Renderer extends AbstractCellRenderer {
createComponent(params: ICellRendererParams<any, any>): void {
svelteComponent(this, params)
}
}
return Renderer
}
@@ -0,0 +1,316 @@
<script lang="ts">
import { GridApi, createGrid, type IDatasource } from 'ag-grid-community'
import { isObject } from '$lib/utils'
import { createEventDispatcher, getContext } from 'svelte'
import type { AppViewerContext, ComponentCustomCSS } from '../../../types'
import type { components } from '$lib/components/apps/editor/component'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { deepEqual } from 'fast-equals'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import { twMerge } from 'tailwind-merge'
import { initCss } from '$lib/components/apps/utils'
import ResolveStyle from '../../helpers/ResolveStyle.svelte'
import type { RunnableComponent } from '../..'
import type { Output } from '$lib/components/apps/rx'
import type { InitConfig } from '$lib/components/apps/editor/appUtils'
import { Button } from '$lib/components/common'
import { cellRendererFactory } from '../dbtable/utils'
import { Trash2 } from 'lucide-svelte'
// import 'ag-grid-community/dist/styles/ag-theme-alpine-dark.css'
export let id: string
export let customCss: ComponentCustomCSS<'aggridcomponent'> | undefined = undefined
export let containerHeight: number | undefined = undefined
export let resolvedConfig: InitConfig<
(typeof components)['dbexplorercomponent']['initialData']['configuration']
>
export let datasource: IDatasource
export let state: any = undefined
export let outputs: Record<string, Output<any>>
export let allowDelete: boolean
const { app, selectedComponent, componentControl, darkMode } =
getContext<AppViewerContext>('AppViewerContext')
let css = initCss($app.css?.aggridcomponent, customCss)
// let result: any[] | undefined = undefined
// $: result && setValues()
// let value: any[] = Array.isArray(result)
// ? (result as any[]).map((x, i) => ({ ...x, __index: i.toString() }))
// : [{ error: 'input was not an array' }]
// let loaded = false
// async function setValues() {
// value = Array.isArray(result)
// ? (result as any[]).map((x, i) => ({ ...x, __index: i.toString() }))
// : [{ error: 'input was not an array' }]
// if (api && loaded) {
// let selected = api.getSelectedNodes()
// if (selected && selected.length > 0 && resolvedConfig?.selectFirstRowByDefault != false) {
// let data = { ...selected[0].data }
// delete data['__index']
// outputs?.selectedRow?.set(data)
// }
// }
// if (!loaded) {
// loaded = true
// }
// }
let selectedRowIndex = -1
function toggleRow(row: any) {
if (row) {
let rowIndex = row.rowIndex
let data = { ...row.data }
delete data['__index']
if (selectedRowIndex !== rowIndex) {
selectedRowIndex = rowIndex
outputs?.selectedRowIndex.set(rowIndex)
}
if (!deepEqual(outputs?.selectedRow?.peak(), data)) {
outputs?.selectedRow.set(data)
}
}
}
function toggleRows(rows: any[]) {
if (rows.length === 0) {
outputs?.selectedRows.set([])
}
toggleRow(rows[0])
outputs?.selectedRows.set(
rows.map((x) => {
let data = { ...x.data }
delete data['__index']
return data
})
)
}
let clientHeight
let clientWidth
const dispatch = createEventDispatcher()
function onCellValueChanged(event) {
let dataCell = event.newValue
outputs?.newChange?.set({
row: event.node.rowIndex,
column: event.colDef.field,
value: dataCell
})
// result[event.node.rowIndex][event.colDef.field] = dataCell
// let data = { ...result[event.node.rowIndex] }
// outputs?.selectedRow?.set(data)
dispatch('update', {
row: event.node.rowIndex,
column: event.colDef.field,
value: dataCell,
data: event.node.data,
oldValue: event.oldValue,
columnDef: event.colDef
})
}
let api: GridApi<any> | undefined = undefined
let eGui: HTMLDivElement
$: eGui && mountGrid()
function transformColumnDefs(columnDefs: any[]) {
let r = columnDefs?.filter((x) => x && !x.ignored) ?? []
if (allowDelete) {
r.push({
field: 'delete',
headerName: 'Delete',
cellRenderer: cellRendererFactory((c, p) => {
new Button({
target: c.eGui,
props: {
btnClasses: 'mt-1',
color: 'red',
variant: 'border',
iconOnly: true,
endIcon: { icon: Trash2 },
nonCaptureEvent: true
}
})
}),
cellRendererParams: {
onClick: (e) => {
dispatch('delete', e)
}
},
lockPosition: 'right',
editable: false,
flex: 0,
width: 100
})
}
return r
}
let firstRow = 0
let lastRow = 0
function mountGrid() {
if (eGui) {
createGrid(
eGui,
{
rowModelType: 'infinite',
datasource,
columnDefs: transformColumnDefs(resolvedConfig?.columnDefs),
pagination: false,
defaultColDef: {
flex: resolvedConfig.flex ? 1 : 0,
editable: resolvedConfig?.allEditable,
onCellValueChanged
},
suppressColumnMoveAnimation: true,
rowSelection: resolvedConfig?.multipleSelectable ? 'multiple' : 'single',
rowMultiSelectWithClick: resolvedConfig?.multipleSelectable
? resolvedConfig.rowMultiselectWithClick
: false,
initialState: state,
suppressRowDeselection: true,
...(resolvedConfig?.extraConfig ?? {}),
onViewportChanged: (e) => {
firstRow = e.firstRow
lastRow = e.lastRow
},
onStateUpdated: (e) => {
state = e?.api?.getState()
resolvedConfig?.extraConfig?.['onStateUpdated']?.(e)
},
onGridReady: (e) => {
outputs?.ready.set(true)
$componentControl[id] = {
agGrid: { api: e.api, columnApi: e.columnApi },
setSelectedIndex: (index) => {
e.api.getRowNode(index.toString())?.setSelected(true)
}
}
api = e.api
resolvedConfig?.extraConfig?.['onGridReady']?.(e)
},
onSelectionChanged: (e) => {
onSelectionChanged(e.api)
resolvedConfig?.extraConfig?.['onSelectionChanged']?.(e)
},
getRowId: (data) => {
return (data as any).data['__index']
}
},
{}
)
}
}
$: resolvedConfig && updateOptions()
$: datasource && api?.updateGridOptions({ datasource })
let extraConfig = resolvedConfig.extraConfig
$: if (!deepEqual(extraConfig, resolvedConfig.extraConfig)) {
extraConfig = resolvedConfig.extraConfig
if (extraConfig) {
api?.updateGridOptions(extraConfig)
}
}
export function clearRows() {
api?.purgeInfiniteCache()
}
function onSelectionChanged(api: GridApi<any>) {
if (resolvedConfig?.multipleSelectable) {
const rows = api.getSelectedNodes()
if (rows != undefined) {
toggleRows(rows)
}
} else {
const row = api.getSelectedNodes()?.[0]
if (row != undefined) {
toggleRow(row)
}
}
}
function updateOptions() {
api?.updateGridOptions({
columnDefs: transformColumnDefs(resolvedConfig?.columnDefs),
defaultColDef: {
flex: resolvedConfig.flex ? 1 : 0,
editable: resolvedConfig?.allEditable,
onCellValueChanged
},
rowSelection: resolvedConfig?.multipleSelectable ? 'multiple' : 'single',
rowMultiSelectWithClick: resolvedConfig?.multipleSelectable
? resolvedConfig.rowMultiselectWithClick
: false,
...(resolvedConfig?.extraConfig ?? {})
})
}
let runnableComponent: RunnableComponent
export function recompute() {
runnableComponent?.runComponent()
}
</script>
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.tablecomponent}
/>
{/each}
{#if Array.isArray(resolvedConfig.columnDefs) && resolvedConfig.columnDefs.every(isObject)}
<div
class={twMerge(
'border shadow-sm divide-y flex flex-col h-full',
css?.container?.class,
'wm-aggrid-container'
)}
style={containerHeight ? `height: ${containerHeight}px;` : css?.container?.style}
bind:clientHeight
bind:clientWidth
>
<div
on:pointerdown|stopPropagation={() => {
$selectedComponent = [id]
}}
style:height="{clientHeight}px"
style:width="{clientWidth}px"
class="ag-theme-alpine"
class:ag-theme-alpine-dark={$darkMode}
>
<div bind:this={eGui} style:height="100%" />
</div>
</div>
<div class="flex gap-1 absolute bottom-1 right-2 text-sm text-secondary"
>{firstRow}{'->'}{lastRow + 1} of {datasource?.rowCount} rows</div
>
{:else if resolvedConfig.columnDefs != undefined}
<Alert title="Parsing issues" type="error" size="xs">
The columnDefs should be an array of objects, received:
<pre class="overflow-auto">
{JSON.stringify(resolvedConfig.columnDefs)}
</pre>
</Alert>
{:else}
<Alert title="Parsing issues" type="error" size="xs">The columnDefs are undefined</Alert>
{/if}
@@ -1,31 +1,94 @@
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
export let type: 'text' | 'badge' | 'link' = 'text'
export let value: any
import { createEventDispatcher, tick } from 'svelte'
import { writable } from 'svelte/store'
import { twMerge } from 'tailwind-merge'
type LinkObject = {
href: string
label: string
}
export let type: 'text' | 'badge' | 'link' = 'text'
export let value: any
export let width: number
let isEditable = writable(false)
let tempValue = value
const dispatch = createEventDispatcher()
function isLinkObject(value: any): value is LinkObject {
return value && typeof value === 'object' && 'href' in value && 'label' in value
}
async function toggleEdit() {
$isEditable = !$isEditable
if ($isEditable) {
await tick()
const input = document.getElementById('cell') as HTMLInputElement
input?.focus()
input?.setSelectionRange(0, 9999)
}
}
function handleInput(event: Event) {
tempValue = (event.target as HTMLInputElement).value
}
function saveEdit() {
value = tempValue
dispatch('update', {
value
})
toggleEdit()
}
</script>
{#if type === 'badge'}
<Badge>
{value}
</Badge>
{:else if type === 'link'}
{#if isLinkObject(value)}
<a href={value.href} class="underline" target="_blank">
{value.label}
</a>
<td
on:keydown
on:click
class={twMerge(
'p-4 whitespace-pre-wrap truncate text-xs text-primary',
$isEditable && 'bg-gray-100'
)}
style={'width: ' + width + 'px'}
>
{#if type === 'badge'}
<Badge>
{value}
</Badge>
{:else if type === 'link'}
{#if isLinkObject(value)}
<a href={value.href} class="underline" target="_blank">
{value.label}
</a>
{:else}
<a href={value} class="underline" target="_blank">{value}</a>
{/if}
{:else if $isEditable}
<input
type="text"
value={tempValue}
on:input={handleInput}
on:blur={saveEdit}
id="cell"
class="!appearance-none !bg-transparent !border-none !p-0 !m-0 leading-normal !text-xs"
style="outline: none; box-shadow: none; height: auto; resize: none;"
on:keypress={(e) => {
if (e.key === 'Enter') {
saveEdit()
}
}}
/>
{:else}
<a href={value} class="underline" target="_blank">{value}</a>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div on:dblclick={toggleEdit}>
{value}
</div>
{/if}
{:else}
{value}
{/if}
</td>
@@ -70,7 +70,7 @@
selectedRowIndex: 0,
selectedRow: undefined,
loading: false,
result: [],
result: [] as Record<string, any>[],
inputs: {},
search: '',
page: 1
@@ -289,6 +289,16 @@
}
$: $table && updateTable(resolvedConfig, searchValue)
function updateCellValue(rowIndex: number, columnIndex: number, newCellValue: string) {
if (result && rowIndex < result.length) {
const updatedRow = { ...result[rowIndex] }
const columnName = Object.keys(updatedRow)[columnIndex]
updatedRow[columnName] = newCellValue
result[rowIndex] = updatedRow
outputs?.result.set([result])
}
}
</script>
{#each Object.keys(components['tablecomponent'].initialData.configuration) as key (key)}
@@ -396,21 +406,20 @@
{#if cell?.column?.columnDef?.cell}
{@const context = cell?.getContext()}
{#if context}
<td
<AppCell
on:keydown={() => toggleRow(row)}
on:click={() => toggleRow(row)}
class="p-4 whitespace-pre-wrap truncate text-xs text-primary"
style={'width: ' + cell.column.getSize() + 'px'}
>
<AppCell
type={resolvedConfig.columnDefs?.find(
// TS types are wrong here
// @ts-ignore
(c) => c.field === cell.column.columnDef.accessorKey
)?.type ?? 'text'}
value={cell.getValue()}
/>
</td>
type={resolvedConfig.columnDefs?.find(
// TS types are wrong here
// @ts-ignore
(c) => c.field === cell.column.columnDef.accessorKey
)?.type ?? 'text'}
value={cell.getValue()}
width={cell.column.getSize()}
on:update={(event) => {
updateCellValue(rowIndex, index, event.detail.value)
}}
/>
{/if}
{/if}
{/each}
@@ -546,6 +555,7 @@
}}
{#if actionButton.type == 'buttoncomponent'}
<AppButton
noInitialize
extraKey={'idx' + rowIndex}
{render}
noWFull
@@ -562,6 +572,7 @@
/>
{:else if actionButton.type == 'checkboxcomponent'}
<AppCheckbox
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
@@ -576,6 +587,7 @@
{:else if actionButton.type == 'selectcomponent'}
<div class="w-40">
<AppSelect
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
@@ -591,6 +603,7 @@
{/if}
{:else if actionButton.type == 'buttoncomponent'}
<AppButton
noInitialize
extraKey={'idx' + rowIndex}
{render}
noWFull
@@ -606,6 +619,7 @@
/>
{:else if actionButton.type == 'checkboxcomponent'}
<AppCheckbox
noInitialize
extraKey={'idx' + rowIndex}
{render}
id={actionButton.id}
@@ -619,6 +633,7 @@
{:else if actionButton.type == 'selectcomponent'}
<div class="w-40">
<AppSelect
noInitialize
--font-size="10px"
extraKey={'idx' + rowIndex}
{render}
@@ -11,6 +11,7 @@
export let result: any
export let render: boolean
export let hasChildrens: boolean
export let noInitialize
// Sync the result to the output
const { worldStore } = getContext<AppViewerContext>('AppViewerContext')
@@ -27,7 +28,9 @@
$: result != undefined && outputs && setOutput(result)
</script>
<InitializeComponent {id} />
{#if !noInitialize}
<InitializeComponent {id} />
{/if}
{#if componentInput.type !== 'runnable'}
<InputValue key="nonrunnable" {id} input={componentInput} bind:value={result} />
@@ -51,6 +51,7 @@
export let errorHandledByComponent: boolean = false
export let hideRefreshButton: boolean = false
export let hasChildrens: boolean
export let allowConcurentRequests = false
const {
worldStore,
@@ -97,6 +98,7 @@
function setDebouncedExecute() {
executeTimeout && clearTimeout(executeTimeout)
executeTimeout = setTimeout(() => {
console.debug('debounce execute')
executeComponent(true)
}, 200)
}
@@ -132,7 +134,7 @@
const refreshEnabled =
autoRefresh && ((recomputeOnInputChanged ?? true) || refreshOn?.length > 0)
if (refreshEnabled && $initialized.initialized) {
// console.debug(`Refreshing ${id} because ${_src} (enabled)`)
console.debug(`Refreshing ${id} because ${_src} (enabled)`)
setDebouncedExecute()
}
}
@@ -216,11 +218,15 @@
return njobs
})
}
async function executeComponent(
noToast = false,
inlineScriptOverride?: InlineScript,
setRunnableJobEditorPanel?: boolean
) {
setRunnableJobEditorPanel?: boolean,
dynamicArgsOverride?: Record<string, any>,
callbacks?: Callbacks
): Promise<string | undefined> {
let jobId: string | undefined
console.debug(`Executing ${id}`)
if (iterContext && $iterContext.disabled) {
console.debug(`Skipping execution of ${id} because it is part of a disabled list`)
@@ -288,8 +294,8 @@
}
try {
const jobId = await resultJobLoader?.abstractRun(async () => {
const nonStaticRunnableInputs = {}
jobId = await resultJobLoader?.abstractRun(async () => {
const nonStaticRunnableInputs = dynamicArgsOverride ?? {}
const staticRunnableInputs = {}
for (const k of Object.keys(fields ?? {})) {
let field = fields[k]
@@ -338,31 +344,51 @@
addJob(uuid)
}
return uuid
})
}, callbacks)
if (setRunnableJobEditorPanel && editorContext) {
editorContext.runnableJobEditorPanel.update((p) => {
return {
...p,
jobs: { ...p.jobs, [id]: jobId }
jobs: { ...p.jobs, [id]: jobId as string }
}
})
}
return jobId
} catch (e) {
updateResult({ error: e.body ?? e.message })
let error = e.body ?? e.message
updateResult({ error })
$errorByComponent[id] = { error }
loading = false
}
}
type Callbacks = { done: (x: any[]) => void; cancel: () => void; error: () => void }
export async function runComponent() {
export async function runComponent(
noToast = false,
inlineScriptOverride?: InlineScript,
setRunnableJobEditorPanel?: boolean,
dynamicArgsOverride?: Record<string, any>,
callbacks?: Callbacks
): Promise<string | undefined> {
try {
if (cancellableRun) {
if (cancellableRun && !dynamicArgsOverride) {
console.log('runComponent cancellable Run')
await cancellableRun()
} else {
console.log('Run component')
executeComponent()
return await executeComponent(
noToast,
inlineScriptOverride,
setRunnableJobEditorPanel,
dynamicArgsOverride,
callbacks
)
}
} catch (e) {
updateResult({ error: e.body ?? e.message })
let error = e.body ?? e.message
updateResult({ error })
$errorByComponent[id] = { error }
}
}
@@ -453,7 +479,7 @@
jobId: string | undefined,
setRunnableJobEditorPanel?: boolean
) {
dispatch('done')
dispatch('resultSet')
const errors = getResultErrors(res)
if (errors) {
@@ -504,6 +530,7 @@
onMount(() => {
cancellableRun = (inlineScript?: InlineScript, setRunnableJobEditorPanel?: boolean) => {
console.log('cancellableRun', inlineScript)
let rejectCb: (err: Error) => void
let p: Partial<CancelablePromise<any>> = new Promise<void>((resolve, reject) => {
rejectCb = reject
@@ -580,6 +607,7 @@
{/if}
<ResultJobLoader
{allowConcurentRequests}
{isEditor}
on:started={(e) => {
console.log('started', e.detail)
@@ -592,9 +620,11 @@
lastJobId = e.detail.id
setResult(e.detail.result, e.detail.id)
loading = false
dispatch('done', { id: e.detail.id, result: e.detail.result })
}}
on:cancel={(e) => {
let jobId = e.detail
console.debug('cancel', jobId)
let job = $jobsById[jobId]
if (job && job.created_at && !job.duration_ms) {
$jobsById[jobId] = {
@@ -603,6 +633,7 @@
duration_ms: Date.now() - (job.started_at ?? job.created_at)
}
}
dispatch('cancel', { id: e.detail })
}}
on:running={(e) => {
let jobId = e.detail
@@ -614,6 +645,7 @@
on:doneError={(e) => {
setResult({ error: e.detail.error }, e.detail.id)
loading = false
dispatch('doneError', { id: e.detail.id, result: e.detail.result })
}}
bind:this={resultJobLoader}
/>
@@ -652,19 +684,21 @@
<span slot="text">
<div class="bg-surface">
<Alert type="error" title="Error during execution">
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2 overflow-auto">
An error occured, please contact the app author.
{#if lastJobId && $errorByComponent[id].error}
{#if $errorByComponent?.[id]?.error}
<div class="font-bold">{$errorByComponent[id].error}</div>
{/if}
<a
href={`/run/${lastJobId}?workspace=${workspace}`}
class="font-semibold text-red-800 underline"
target="_blank"
>
Job id: {lastJobId}
</a>
{#if lastJobId}
<a
href={`/run/${lastJobId}?workspace=${workspace}`}
class="font-semibold text-red-800 underline"
target="_blank"
>
Job id: {lastJobId}
</a>
{/if}
</div>
</Alert>
</div>
@@ -10,6 +10,7 @@
import InitializeComponent from './InitializeComponent.svelte'
export let componentInput: AppInput | undefined
export let noInitialize = false
type SideEffectAction =
| {
@@ -76,6 +77,7 @@
export let refreshOnStart: boolean = false
export let errorHandledByComponent: boolean = false
export let hasChildrens: boolean = false
export let allowConcurentRequests = false
export function setArgs(value: any) {
runnableComponent?.setArgs(value)
@@ -208,10 +210,13 @@
</script>
{#if componentInput === undefined}
<InitializeComponent {id} />
{#if !noInitialize}
<InitializeComponent {id} />
{/if}
<slot />
{:else if componentInput.type === 'runnable' && isRunnableDefined(componentInput)}
<RunnableComponent
{allowConcurentRequests}
{refreshOnStart}
{extraKey}
{hasChildrens}
@@ -233,7 +238,10 @@
wrapperStyle={runnableStyle}
{render}
on:started
on:done={() => (initializing = false)}
on:done
on:doneError
on:cancel
on:setResult={() => (initializing = false)}
on:success={() => handleSideEffect(true)}
on:handleError={(e) => handleSideEffect(false, e.detail)}
{outputs}
@@ -242,7 +250,7 @@
<slot />
</RunnableComponent>
{:else}
<NonRunnableComponent {hasChildrens} {render} bind:result {id} {componentInput}>
<NonRunnableComponent {noInitialize} {hasChildrens} {render} bind:result {id} {componentInput}>
<slot />
</NonRunnableComponent>
{/if}
@@ -26,6 +26,7 @@
export let render: boolean
export let extraKey: string | undefined = undefined
export let preclickAction: (() => Promise<void>) | undefined = undefined
export let noInitialize = false
export let controls: { left: () => boolean; right: () => boolean | string } | undefined =
undefined
@@ -111,7 +112,10 @@
/>
{/each}
<InitializeComponent {id} />
{#if !noInitialize}
<InitializeComponent {id} />
{/if}
<AlignWrapper
{render}
{horizontalAlignment}
@@ -30,6 +30,7 @@
export let extraKey: string | undefined = undefined
export let preclickAction: (() => Promise<void>) | undefined = undefined
export let recomputeIds: string[] | undefined = undefined
export let noInitialize = false
export let controls: { left: () => boolean; right: () => boolean | string } | undefined =
undefined
@@ -189,7 +190,9 @@
/>
{/each}
<InitializeComponent {id} />
{#if !noInitialize}
<InitializeComponent {id} />
{/if}
<AlignWrapper {render} {verticalAlignment}>
<div
@@ -83,6 +83,15 @@
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { cloneDeep } from 'lodash'
import AppReportsDrawer from './AppReportsDrawer.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import {
type ColumnDef,
getCountPostgresql,
createPostgresInput,
createPostgresInsert,
createUpdatePostgresInput,
getPrimaryKeys
} from '../components/display/dbtable/utils'
async function hash(message) {
try {
@@ -187,6 +196,47 @@
if (c.type === 'menucomponent') {
r.push(...c.menuItems.map((x) => ({ input: x.componentInput, id: x.id })))
}
if (c.type === 'dbexplorercomponent') {
let nr: { id: string; input: AppInput }[] = []
let config = c.configuration as any
let pg = config?.type?.configuration?.postgresql
if (pg) {
const { table, resource } = pg
const tableValue = table.value
const resourceValue = resource.value
const columnDefs = (c.configuration.columnDefs as any).value as ColumnDef[]
const whereClause = (c.configuration.whereClause as any).value as unknown as
| string
| undefined
console.log(columnDefs)
if (tableValue && resourceValue && columnDefs) {
r.push({
input: createPostgresInput(resourceValue, tableValue, columnDefs, whereClause),
id: x.id
})
r.push({
input: getCountPostgresql(resourceValue, tableValue),
id: x.id + '_count'
})
r.push({
input: createPostgresInsert(tableValue, columnDefs, resourceValue),
id: x.id + '_insert'
})
let primaryColumns = getPrimaryKeys(columnDefs)
let columns = columnDefs?.filter((x) => primaryColumns.includes(x.field))
columnDefs
.filter((col) => col.editable || config.allEditable.value)
.forEach((column) => {
r.push({
input: createUpdatePostgresInput(resourceValue, tableValue, column, columns),
id: x.id + '_update'
})
})
}
}
r.push(...nr)
}
return r
.filter((x) => x.input)
.map(async (o) => {
@@ -213,13 +263,16 @@
): Promise<[string, Record<string, any>] | undefined> {
const staticInputs = collectStaticFields(fields)
if (runnable?.type == 'runnableByName') {
console.log(runnable.inlineScript?.content)
let hex = await hash(runnable.inlineScript?.content)
console.log('hex', hex, id)
return [`${id}:rawscript/${hex}`, staticInputs]
} else if (runnable?.type == 'runnableByPath') {
let prefix = runnable.runType !== 'hubscript' ? runnable.runType : 'script'
return [`${id}:${prefix}/${runnable.path}`, staticInputs]
}
}
async function createApp(path: string) {
await computeTriggerables()
try {
@@ -731,7 +784,6 @@
<div class="w-full pt-2">
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
type="text"
placeholder="Optional deployment message"
class="text-sm w-full"
@@ -928,123 +980,108 @@
</PanelSection>
</Pane>
<Pane size={75}>
<Tabs bind:selected={rightColumnSelect}>
<Tab value="timeline"><span class="font-semibold text-md">Timeline</span></Tab>
<Tab value="detail"><span class="font-semibold">Details</span></Tab>
</Tabs>
{#if rightColumnSelect == 'timeline'}
<div class="p-2">
<AppTimeline />
</div>
{:else if rightColumnSelect == 'detail'}
<div class="h-full flex flex-col w-full overflow-auto">
{#if selectedJobId}
{#if selectedJobId?.includes('Frontend')}
{@const jobResult = $jobsById[selectedJobId]}
{#if jobResult?.error !== undefined}
<Splitpanes horizontal class="grow border w-full">
<Pane size={10} minSize={10}>
<LogViewer
content={`Logs are avaiable in the browser console directly`}
isLoading={false}
tag={undefined}
/>
</Pane>
<Pane size={90} minSize={10} class="text-sm text-secondary">
<div class="relative h-full px-2">
<DisplayResult
result={{
error: { name: 'Frontend execution error', message: jobResult.error }
}}
/>
</div>
</Pane>
</Splitpanes>
{:else if jobResult !== undefined}
<Splitpanes horizontal class="grow border w-full">
<Pane size={10} minSize={10}>
<LogViewer
content={`Logs are avaiable in the browser console directly`}
isLoading={false}
tag={undefined}
/>
</Pane>
<Pane size={90} minSize={10} class="text-sm text-secondary">
<div class="relative h-full px-2">
<DisplayResult
workspaceId={$workspaceStore}
jobId={selectedJobId}
result={jobResult.result}
/>
</div>
</Pane>
</Splitpanes>
{:else}
<Loader2 class="animate-spin" />
{/if}
{:else}
<div class="flex flex-col h-full w-full gap-4 mb-4">
{#if job?.['running']}
<div class="flex flex-row-reverse w-full">
<Button
color="red"
variant="border"
on:click={() => testJobLoader?.cancelJob()}
>
<Loader2 size={14} class="animate-spin mr-2" />
Cancel
</Button>
</div>
{/if}
{#if job?.args}
<div class="p-2">
<JobArgs args={job?.args} />
</div>
{/if}
{#if job?.job_kind !== 'flow' && job?.job_kind !== 'flowpreview'}
{@const jobResult = $jobsById[selectedJobId]}
<div class="w-full h-full flex flex-col">
<Tabs bind:selected={rightColumnSelect}>
<Tab value="timeline"><span class="font-semibold text-md">Timeline</span></Tab>
<Tab value="detail"><span class="font-semibold">Details</span></Tab>
</Tabs>
{#if rightColumnSelect == 'timeline'}
<div class="p-2">
<AppTimeline />
</div>
{:else if rightColumnSelect == 'detail'}
<div class="grow flex flex-col w-full overflow-auto">
{#if selectedJobId}
{#if selectedJobId?.includes('Frontend')}
{@const jobResult = $jobsById[selectedJobId]}
{#if jobResult?.error !== undefined}
<Splitpanes horizontal class="grow border w-full">
<Pane size={50} minSize={10}>
<Pane size={10} minSize={10}>
<LogViewer
duration={job?.['duration_ms']}
jobId={job?.id}
content={job?.logs}
isLoading={testIsLoading}
tag={job?.tag}
content={`Logs are avaiable in the browser console directly`}
isLoading={false}
tag={undefined}
/>
</Pane>
<Pane size={50} minSize={10} class="text-sm text-secondary">
{#if job != undefined && 'result' in job && job.result != undefined}
<div class="relative h-full px-2">
<DisplayResult
workspaceId={$workspaceStore}
jobId={selectedJobId}
result={job.result}
/></div
>
{:else if testIsLoading}
<div class="p-2"><Loader2 class="animate-spin" /> </div>
{:else if job != undefined && 'result' in job && job?.['result'] == undefined}
<div class="p-2 text-tertiary">Result is undefined</div>
{:else}
<div class="p-2 text-tertiary">
<Loader2 size={14} class="animate-spin mr-2" />
</div>
{/if}
<Pane size={90} minSize={10} class="text-sm text-secondary">
<div class="relative h-full px-2">
<DisplayResult
result={{
error: { name: 'Frontend execution error', message: jobResult.error }
}}
/>
</div>
</Pane>
{#if jobResult?.transformer}
<Pane size={50} minSize={10} class="text-sm text-secondary p-2">
<div class="font-bold">Transformer results</div>
</Splitpanes>
{:else if jobResult !== undefined}
<Splitpanes horizontal class="grow border w-full">
<Pane size={10} minSize={10}>
<LogViewer
content={`Logs are avaiable in the browser console directly`}
isLoading={false}
tag={undefined}
/>
</Pane>
<Pane size={90} minSize={10} class="text-sm text-secondary">
<div class="relative h-full px-2">
<DisplayResult
workspaceId={$workspaceStore}
jobId={selectedJobId}
result={jobResult.result}
/>
</div>
</Pane>
</Splitpanes>
{:else}
<Loader2 class="animate-spin" />
{/if}
{:else}
<div class="flex flex-col h-full w-full mb-4">
{#if job?.['running']}
<div class="flex flex-row-reverse w-full">
<Button
color="red"
variant="border"
on:click={() => testJobLoader?.cancelJob()}
>
<Loader2 size={14} class="animate-spin mr-2" />
Cancel
</Button>
</div>
{/if}
{#if job?.args}
<div class="p-2">
<JobArgs args={job?.args} />
</div>
{/if}
{#if job?.raw_code}
<div class="pb-2 pl-2 pr-2 w-full overflow-auto h-full max-h-[80px]">
<HighlightCode language={job?.language} code={job?.raw_code} />
</div>
{/if}
{#if job?.job_kind !== 'flow' && job?.job_kind !== 'flowpreview'}
{@const jobResult = $jobsById[selectedJobId]}
<Splitpanes horizontal class="grow border w-full">
<Pane size={50} minSize={10}>
<LogViewer
duration={job?.['duration_ms']}
jobId={job?.id}
content={job?.logs}
isLoading={testIsLoading}
tag={job?.tag}
/>
</Pane>
<Pane size={50} minSize={10} class="text-sm text-secondary">
{#if job != undefined && 'result' in job && job.result != undefined}
<div class="relative h-full px-2">
<DisplayResult
workspaceId={$workspaceStore}
jobId={selectedJobId}
result={jobResult?.transformer}
/>
</div>
result={job.result}
/></div
>
{:else if testIsLoading}
<div class="p-2"><Loader2 class="animate-spin" /> </div>
{:else if job != undefined && 'result' in job && job?.['result'] == undefined}
@@ -1055,27 +1092,49 @@
</div>
{/if}
</Pane>
{/if}
</Splitpanes>
{:else}
<div class="mt-10" />
<FlowProgressBar {job} class="py-4" />
<div class="w-full mt-10 mb-20">
<FlowStatusViewer
jobId={job.id}
on:jobsLoaded={({ detail }) => {
job = detail
}}
/>
</div>
{/if}
</div>
{#if jobResult?.transformer}
<Pane size={50} minSize={10} class="text-sm text-secondary p-2">
<div class="font-bold">Transformer results</div>
{#if job != undefined && 'result' in job && job.result != undefined}
<div class="relative h-full px-2">
<DisplayResult
workspaceId={$workspaceStore}
jobId={selectedJobId}
result={jobResult?.transformer}
/>
</div>
{:else if testIsLoading}
<div class="p-2"><Loader2 class="animate-spin" /> </div>
{:else if job != undefined && 'result' in job && job?.['result'] == undefined}
<div class="p-2 text-tertiary">Result is undefined</div>
{:else}
<div class="p-2 text-tertiary">
<Loader2 size={14} class="animate-spin mr-2" />
</div>
{/if}
</Pane>
{/if}
</Splitpanes>
{:else}
<div class="mt-10" />
<FlowProgressBar {job} class="py-4" />
<div class="w-full mt-10 mb-20">
<FlowStatusViewer
jobId={job.id}
on:jobsLoaded={({ detail }) => {
job = detail
}}
/>
</div>
{/if}
</div>
{/if}
{:else}
<div class="text-sm p-2 text-tertiary">Select a job to see its details</div>
{/if}
{:else}
<div class="text-sm p-2 text-tertiary">Select a job to see its details</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</Pane>
</Splitpanes>
<svelte:fragment slot="actions">
@@ -1247,13 +1306,13 @@
size="xs"
dropdownItems={appPath != ''
? () => [
{
label: 'Fork',
onClick: () => {
window.open(`/apps/add?template=${appPath}`)
{
label: 'Fork',
onClick: () => {
window.open(`/apps/add?template=${appPath}`)
}
}
}
]
]
: undefined}
>
Deploy
@@ -63,6 +63,7 @@
}
loading = true
console.log('refresh all')
const promises = Object.keys($runnableComponents)
.flatMap((id) => {
if (
@@ -72,6 +73,7 @@
return
}
console.log('refresh start', id)
return $runnableComponents?.[id]?.cb?.map((f) =>
f().then(() => console.log('refreshed', id))
)
@@ -106,7 +108,8 @@
]
</script>
<!-- {$initialized.initializedComponents?.join(', ')} -->
<!-- {$initialized.initializedComponents?.join(', ')}
{allItems($app.grid, $app.subgrids).length + $app.hiddenInlineScripts.length} -->
<!-- {allItems($app.grid, $app.subgrids).length + $app.hiddenInlineScripts.length}
{$initialized.initializedComponents}
{allItems($app.grid, $app.subgrids)
@@ -597,6 +597,35 @@ export function initOutput<I extends Record<string, any>>(
) as Outputtable<I>
}
export type InitConfig<
T extends Record<
string,
| StaticAppInput
| EvalAppInput
| {
type: 'oneOf'
selected: string
configuration: Record<string, Record<string, StaticAppInput | EvalAppInput>>
}
>
> = {
[Property in keyof T]: T[Property] extends StaticAppInput
? T[Property]['value'] | undefined
: T[Property] extends { type: 'oneOf' }
? {
type: 'oneOf'
selected: keyof T[Property]['configuration']
configuration: {
[Choice in keyof T[Property]['configuration']]: {
[IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput
? T[Property]['configuration'][Choice][IT]['value'] | undefined
: undefined
}
}
}
: undefined
}
export function initConfig<
T extends Record<
string,
@@ -620,23 +649,7 @@ export function initConfig<
}
| any
>
): {
[Property in keyof T]: T[Property] extends StaticAppInput
? T[Property]['value'] | undefined
: T[Property] extends { type: 'oneOf' }
? {
type: 'oneOf'
selected: keyof T[Property]['configuration']
configuration: {
[Choice in keyof T[Property]['configuration']]: {
[IT in keyof T[Property]['configuration'][Choice]]: T[Property]['configuration'][Choice][IT] extends StaticAppInput
? T[Property]['configuration'][Choice][IT]['value'] | undefined
: undefined
}
}
}
: undefined
} {
): InitConfig<T> {
return JSON.parse(
JSON.stringify(
Object.fromEntries(
@@ -66,6 +66,7 @@
import AppStatCard from '../../components/display/AppStatCard.svelte'
import AppMenu from '../../components/display/AppMenu.svelte'
import AppDecisionTree from '../../components/layout/AppDecisionTree.svelte'
import AppDbExplorer from '../../components/display/dbtable/AppDbExplorer.svelte'
export let component: AppComponent
export let selected: boolean
@@ -305,6 +306,14 @@
actionButtons={component.actionButtons}
{render}
/>
{:else if component.type === 'dbexplorercomponent'}
<AppDbExplorer
configuration={component.configuration}
id={component.id}
customCss={component.customCss}
bind:initializing
{render}
/>
{:else if component.type === 'aggridcomponent'}
<AppAggridTable
id={component.id}
@@ -43,7 +43,8 @@ import {
Heading1,
FileBarChart,
Menu,
Network
Network,
Database
} from 'lucide-svelte'
import type {
Aligned,
@@ -170,6 +171,10 @@ export type MenuComponent = BaseComponent<'menucomponent'> & {
menuItems: (BaseAppComponent & ButtonComponent & GridItem)[]
}
export type DBExplorerComponent = BaseComponent<'dbexplorercomponent'> & {
columns: RichConfiguration
}
export type DecisionTreeNode = {
id: string
label: string
@@ -185,6 +190,7 @@ export type DecisionTreeComponent = BaseComponent<'decisiontreecomponent'> & {
}
export type TypedComponent =
| DBExplorerComponent
| DisplayComponent
| LogComponent
| JobIdLogComponent
@@ -569,7 +575,7 @@ const aggridcomponentconst = {
type: 'static',
fieldType: 'boolean',
value: false,
hide: true,
tooltip: 'Configure all columns as Editable by users'
},
multipleSelectable: {
@@ -589,7 +595,7 @@ const aggridcomponentconst = {
pagination: {
type: 'static',
fieldType: 'boolean',
value: false
value: false as boolean | undefined
},
selectFirstRowByDefault: {
type: 'static',
@@ -2205,7 +2211,8 @@ This is a paragraph.
selectOptions: selectOptions.animationTimingFunctionOptions,
value: 'linear',
tooltip: 'Sets how an animation progresses through the duration of each cycle, see https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function',
tooltip:
'Sets how an animation progresses through the duration of each cycle, see https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timing-function'
}
},
componentInput: {
@@ -2947,6 +2954,102 @@ This is a paragraph.
}
] as DecisionTreeNode[]
}
},
dbexplorercomponent: {
name: 'Database Studio',
icon: Database,
documentationLink: `${documentationBaseUrl}/dbexplorer`,
dims: '2:8-6:8' as AppComponentDimensions,
customCss: {
container: { class: '', style: '' }
},
initialData: {
configuration: {
type: {
type: 'oneOf',
selected: 'postgresql',
labels: {
postgresql: 'PostgreSQL',
msql: 'MySQL'
},
configuration: {
postgresql: {
resource: {
type: 'static',
fieldType: 'resource',
value: ''
} as StaticAppInput,
table: {
fieldType: 'select',
subfieldType: 'db-table',
type: 'static',
selectOptions: [],
value: undefined
}
}
}
} as const,
columnDefs: {
type: 'static',
fieldType: 'array',
subFieldType: 'db-explorer',
value: []
} as StaticAppInput,
whereClause: {
type: 'static',
fieldType: 'text',
value: ''
},
flex: {
type: 'static',
fieldType: 'boolean',
value: true,
tooltip: 'default col flex is 1 (see ag-grid docs)'
},
allEditable: {
type: 'static',
fieldType: 'boolean',
value: false,
hide: true,
tooltip: 'Configure all columns as Editable by users'
},
allowDelete: {
type: 'static',
fieldType: 'boolean',
value: false,
hide: true,
tooltip: 'Allow deleting rows'
},
multipleSelectable: {
type: 'static',
fieldType: 'boolean',
value: false,
tooltip: 'Make multiple rows selectable at once'
},
rowMultiselectWithClick: {
type: 'static',
fieldType: 'boolean',
value: true,
tooltip: 'If multiple selectable, allow multiselect with click'
},
selectFirstRowByDefault: {
type: 'static',
fieldType: 'boolean',
value: true as boolean,
tooltip: 'Select the first row by default on start'
},
extraConfig: {
type: 'static',
fieldType: 'object',
value: {},
tooltip: 'any configuration that can be passed to ag-grid top level'
}
},
componentInput: undefined
}
}
} as const
@@ -72,7 +72,7 @@ const display: ComponentSet = {
const tables: ComponentSet = {
title: 'Tables',
components: ['tablecomponent', 'aggridcomponent', 'aggridcomponentee']
components: ['tablecomponent', 'aggridcomponent', 'aggridcomponentee', 'dbexplorercomponent']
} as const
const charts: ComponentSet = {
@@ -751,5 +751,6 @@ export const quickStyleProperties: Record<
},
decisiontreecomponent: {
container: containerDefaultProps
}
},
dbexplorercomponent: {}
}
@@ -10,13 +10,12 @@
import Toggle from '$lib/components/Toggle.svelte'
import QuickAddColumn from './QuickAddColumn.svelte'
const flipDurationMs = 200
export let componentInput: StaticInput<any[]>
export let subFieldType: InputType | undefined = undefined
export let selectOptions: StaticOptions['selectOptions'] | undefined = undefined
const dispatch = createEventDispatcher()
const flipDurationMs = 200
function addElementByType() {
if (!Array.isArray(componentInput.value)) {
@@ -82,10 +81,13 @@
componentInput = componentInput
if (componentInput.value) {
items.push({
value: componentInput.value[componentInput.value.length - 1],
id: generateRandomString()
})
let value = componentInput.value[componentInput.value.length - 1]
if (value) {
items.push({
value,
id: generateRandomString()
})
}
}
}
@@ -140,16 +142,16 @@
if ((e.key === 'Enter' || e.key === ' ') && dragDisabled) dragDisabled = false
}
let items = (Array.isArray(componentInput.value) ? componentInput.value : []).map(
(item, index) => {
let items = (Array.isArray(componentInput.value) ? componentInput.value : [])
.filter((x) => x != undefined)
.map((item, index) => {
return { value: item, id: generateRandomString() }
}
)
})
$: items != undefined && handleItemsChange()
function handleItemsChange() {
componentInput.value = items.map((item) => item.value)
componentInput.value = items.map((item) => item.value).filter((item) => item != undefined)
}
let raw: boolean = false
@@ -205,41 +207,84 @@
>
<GripVertical size={16} />
</div>
<button
class="z-10 rounded-full p-1 duration-200 hover:bg-gray-200"
aria-label="Remove item"
on:click|preventDefault|stopPropagation={() => deleteElementByType(index)}
>
<X size={14} />
</button>
{#if subFieldType !== 'db-explorer'}
<button
class="z-10 rounded-full p-1 duration-200 hover:bg-gray-200"
aria-label="Remove item"
on:click|preventDefault|stopPropagation={() => deleteElementByType(index)}
>
<X size={14} />
</button>
{/if}
</div>
</div>
</div>
{/each}
</section>
{/if}
<Button size="xs" color="light" startIcon={{ icon: Plus }} on:click={() => addElementByType()}>
Add
</Button>
{#if subFieldType === 'table-column' || subFieldType == 'ag-grid'}
<QuickAddColumn
{#if subFieldType !== 'db-explorer'}
<Button size="xs" color="light" startIcon={{ icon: Plus }} on:click={() => addElementByType()}>
Add
</Button>
{#if subFieldType === 'table-column' || subFieldType == 'ag-grid'}
<QuickAddColumn
columns={componentInput.value?.map((item) => item.field)}
on:add={({ detail }) => {
if (!componentInput.value) componentInput.value = []
if (subFieldType === 'table-column') {
componentInput.value.push({ field: detail, headerName: detail, type: 'text' })
} else if (subFieldType === 'ag-grid') {
componentInput.value.push({ field: detail, headerName: detail, flex: 1 })
}
componentInput = componentInput
if (componentInput.value) {
let value = componentInput.value[componentInput.value.length - 1]
if (value) {
items.push({
value,
id: generateRandomString()
})
}
}
}}
/>
{/if}
{/if}
<!-- {#if subFieldType === 'db-explorer'}
<SynchronizeColumns
columns={componentInput.value?.map((item) => item.field)}
on:add={({ detail }) => {
if (!componentInput.value) componentInput.value = []
if (subFieldType === 'table-column') {
componentInput.value.push({ field: detail, headerName: detail, type: 'text' })
} else if (subFieldType === 'ag-grid') {
componentInput.value.push({ field: detail, headerName: detail, flex: 1 })
if (!Array.isArray(detail)) {
return
}
componentInput = componentInput
if (componentInput.value) {
items.push({
value: componentInput.value[componentInput.value.length - 1],
id: generateRandomString()
})
if (detail.length === 0) {
return
}
componentInput.value = []
items = []
detail.forEach((col) => {
if (!componentInput.value) componentInput.value = []
if (subFieldType === 'table-column') {
componentInput.value.push({ field: col, headerName: col, type: 'text' })
} else if (subFieldType === 'ag-grid') {
componentInput.value.push({ field: col, headerName: col, flex: 1 })
} else if (subFieldType === 'db-explorer') {
componentInput.value.push({ field: col, headerName: col, flex: 1 })
}
componentInput = componentInput
if (componentInput.value) {
items.push({
value: componentInput.value[componentInput.value.length - 1],
id: generateRandomString()
})
}
})
}}
/>
{/if}
{/if} -->
</div>
@@ -373,7 +373,6 @@
{ccomponents[component.type].name} has no configuration
</div>
{/if}
{#if (`recomputeIds` in componentSettings.item.data && Array.isArray(componentSettings.item.data.recomputeIds)) || componentSettings.item.data.type === 'buttoncomponent' || componentSettings.item.data.type === 'formcomponent' || componentSettings.item.data.type === 'formbuttoncomponent' || componentSettings.item.data.type === 'checkboxcomponent'}
<Recompute
bind:recomputeIds={componentSettings.item.data.recomputeIds}
@@ -0,0 +1,13 @@
<script lang="ts">
import PanelSection from './common/PanelSection.svelte'
import { Button } from '$lib/components/common'
import { RefreshCcw } from 'lucide-svelte'
</script>
<PanelSection title={'Helpers'}>
<div>
<Button size="xs" startIcon={{ icon: RefreshCcw }} color="dark">
Sync columns definitions with table
</Button>
</div>
</PanelSection>
@@ -182,6 +182,7 @@
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
tabindex={dragDisabled ? 0 : -1}
class="w-4 h-4"
@@ -12,7 +12,7 @@
import type { InputConnection, InputType, UploadAppInput } from '../../inputType'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { FunctionSquare, Pen, Plug, Plug2, Upload, User } from 'lucide-svelte'
import { FunctionSquare, Loader2, Pen, Plug, Plug2, Upload, User } from 'lucide-svelte'
import { fieldTypeToTsType } from '../../utils'
import EvalV2InputEditor from './inputEditor/EvalV2InputEditor.svelte'
import { Button } from '$lib/components/common'
@@ -35,6 +35,7 @@
export let allowTypeChange: boolean = true
export let shouldFormatExpression: boolean = false
export let fixedOverflowWidgets: boolean = true
export let loading: boolean = false
const { connectingInput, app } = getContext<AppViewerContext>('AppViewerContext')
@@ -73,6 +74,9 @@
? capitalize(addWhitespaceBeforeCapitals(key))
: key}
</span>
{#if loading}
<Loader2 size={14} class="animate-spin ml-2" />
{/if}
{#if tooltip}
<Tooltip small>
{tooltip}
@@ -51,6 +51,7 @@
fileUpload={meta?.['fileUpload']}
placeholder={meta?.['placeholder']}
customTitle={meta?.['customTitle']}
loading={meta?.['loading']}
{displayType}
/>
{#if deletable}
@@ -73,7 +73,11 @@
{/if}
<div class="flex flex-col gap-4">
{#each Object.keys(inputSpecsConfiguration?.[oneOf.selected] ?? {}) as nestedKey}
{@const config = inputSpecsConfiguration?.[oneOf.selected]?.[nestedKey]}
{@const config = {
...inputSpecsConfiguration?.[oneOf.selected]?.[nestedKey],
...oneOf.configuration?.[oneOf.selected]?.[nestedKey]
}}
{#if config && oneOf.configuration[oneOf.selected]}
<InputsSpecEditor
key={nestedKey}
@@ -90,6 +94,7 @@
customTitle={config?.['customTitle']}
tooltip={config?.['tooltip']}
fileUpload={config?.['fileUpload']}
loading={config?.['loading']}
/>
{/if}
{/each}
@@ -0,0 +1,86 @@
<script lang="ts">
import { createEventDispatcher, getContext } from 'svelte'
import type { Output } from '../../rx'
import type { AppViewerContext } from '../../types'
import Button from '$lib/components/common/button/Button.svelte'
import { Columns } from 'lucide-svelte'
import { isObject } from '$lib/utils'
export let columns: string[] = []
let remainingColumns: string[] = []
const { worldStore, selectedComponent } = getContext<AppViewerContext>('AppViewerContext')
const dispatch = createEventDispatcher()
let result = []
function subscribeToAllOutputs(observableOutputs: Record<string, Output<any>> | undefined) {
if (observableOutputs) {
Object.entries(observableOutputs).forEach(([k, output]) => {
output?.subscribe(
{
id: 'alloutputs-quickadd' + $selectedComponent?.[0] + '-' + k,
next: (value) => {
if (k === 'result') {
result = value
}
}
},
result
)
})
}
}
function updateRemainingColumns(result: any[], columns: string[]) {
if (Array.isArray(result) && result?.length > 0) {
const allKeysSet: Set<string> = result.reduce((acc, obj) => {
if (isObject(obj)) {
Object.keys(obj).forEach((key) => acc.add(key))
}
return acc
}, new Set<string>())
remainingColumns = Array.from(allKeysSet).filter((x: string) => !columns?.includes(x) ?? true)
}
}
$: $selectedComponent?.[0] &&
subscribeToAllOutputs($worldStore?.outputsById?.[$selectedComponent?.[0]])
$: updateRemainingColumns(result, columns)
function haveSameStringElements(arr1: string[], arr2: string[]): boolean {
if (arr1.length !== arr2.length) {
return false
}
const sortedArr1 = [...arr1].sort()
const sortedArr2 = [...arr2].sort()
for (let i = 0; i < sortedArr1.length; i++) {
if (sortedArr1[i] !== sortedArr2[i]) {
return false
}
}
return true
}
$: shouldDisplaySyncButton = !haveSameStringElements(columns, remainingColumns)
</script>
{#if shouldDisplaySyncButton}
<div class="flex flex-row gap-2 items-center flex-wrap">
<Button
on:click={() => {
dispatch('add', remainingColumns)
}}
size="xs2"
color="dark"
startIcon={{ icon: Columns }}
>
Synchronize columns
</Button>
</div>
{/if}
@@ -17,13 +17,13 @@
import TableColumnWizard from '$lib/components/wizards/TableColumnWizard.svelte'
import PlotlyWizard from '$lib/components/wizards/PlotlyWizard.svelte'
import ChartJSWizard from '$lib/components/wizards/ChartJSWizard.svelte'
import DBExplorerWizard from '$lib/components/wizards/DBExplorerWizard.svelte'
export let componentInput: StaticInput<any> | undefined
export let fieldType: InputType | undefined = undefined
export let subFieldType: InputType | undefined = undefined
export let selectOptions: StaticOptions['selectOptions'] | undefined = undefined
export let placeholder: string | undefined = undefined
export let format: string | undefined = undefined
const { onchange } = getContext<AppViewerContext>('AppViewerContext')
@@ -41,7 +41,7 @@
{:else if fieldType === 'boolean'}
<Toggle bind:checked={componentInput.value} size="xs" />
{:else if fieldType === 'select' && selectOptions}
<select on:keydown|stopPropagation on:keydown|stopPropagation bind:value={componentInput.value}>
<select on:keydown|stopPropagation bind:value={componentInput.value}>
{#each selectOptions ?? [] as option}
{#if typeof option == 'string'}
<option value={option}>
@@ -58,6 +58,22 @@
<IconSelectInput bind:componentInput />
{:else if fieldType === 'tab-select'}
<TabSelectInput bind:componentInput />
{:else if fieldType === 'resource'}
<ResourcePicker
initialValue={componentInput.value?.split('$res:')?.[1] || ''}
on:change={(e) => {
let path = e.detail
if (componentInput) {
if (path) {
componentInput.value = `$res:${path}`
} else {
componentInput.value = undefined
}
}
}}
showSchemaExplorer
resourceType="postgresql"
/>
{:else if fieldType === 'labeledresource'}
{#if componentInput?.value && typeof componentInput?.value == 'object' && 'label' in componentInput?.value && (componentInput.value?.['value'] == undefined || typeof componentInput.value?.['value'] == 'string')}
<div class="flex flex-col gap-1 w-full">
@@ -142,6 +158,28 @@
</div>
</div>
</div>
{:else if fieldType === 'db-explorer' && componentInput.value != undefined}
<div class="flex flex-row rounded-md bg-surface items-center h-full">
<div class="relative w-full">
<input
class="text-xs px-2 border-y w-full flex flex-row items-center border-r rounded-r-md h-8"
bind:value={componentInput.value.field}
placeholder="Field"
disabled
/>
<div class="absolute top-1 right-1">
<DBExplorerWizard bind:value={componentInput.value}>
<svelte:fragment slot="trigger">
<Button color="light" size="xs2" nonCaptureEvent={true}>
<div class="flex flex-row items-center gap-2 text-xs font-normal">
<Settings size={16} />
</div>
</Button>
</svelte:fragment>
</DBExplorerWizard>
</div>
</div>
</div>
{:else if fieldType === 'table-column'}
<div class="flex flex-row rounded-md bg-surface items-center h-full">
<div class="relative w-full">
@@ -27,6 +27,9 @@ export type InputType =
| 'plotly'
| 'chartjs'
| 'DecisionTreeNode'
| 'resource'
| 'db-explorer'
| 'db-table'
// Connection to an output of another component
// defined by the id of the component and the path of the output
@@ -140,6 +143,7 @@ type InputConfiguration<T extends InputType, V extends InputType> = {
fieldType: T
subFieldType?: V
format?: string | undefined
loading?: boolean
fileUpload?: {
/** Use `*` to accept anything. */
accept: string
@@ -173,7 +177,7 @@ export type AppInput =
| AppInputSpec<'any', any>
| AppInputSpec<'object', Record<string | number, any>>
| AppInputSpec<'object', string>
| (AppInputSpec<'select', string> & StaticOptions)
| (AppInputSpec<'select', string, 'db-table'> & StaticOptions)
| AppInputSpec<'icon-select', string>
| AppInputSpec<'color', string>
| AppInputSpec<'array', string[], 'text'>
@@ -192,10 +196,12 @@ export type AppInput =
| AppInputSpec<'array', object[], 'tab-select'>
| AppInputSpec<'schema', object>
| AppInputSpec<'array', object[], 'ag-grid'>
| AppInputSpec<'array', object[], 'db-explorer'>
| AppInputSpec<'array', object[], 'table-column'>
| AppInputSpec<'array', object[], 'plotly'>
| AppInputSpec<'array', object[], 'chartjs'>
| AppInputSpec<'array', DecisionTreeNode, 'DecisionTreeNode'>
| AppInputSpec<'resource', string>
export type RowAppInput = Extract<AppInput, { type: 'row' }>
export type StaticAppInput = Extract<AppInput, { type: 'static' }>
@@ -23,6 +23,7 @@
valueParser: string
field: string
headerName: string
editable: boolean
}
export let value: Column | undefined
@@ -103,6 +104,17 @@
<input type="text" placeholder="Header name" bind:value={value.headerName} />
</Label>
<Label label="Editable value">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Editable' }}
bind:checked={value.editable}
size="xs"
/>
</Label>
<Label label="Min width (px)">
<input type="number" placeholder="width" bind:value={value.minWidth} />
</Label>
@@ -185,7 +197,13 @@
</select>
</div>
<SimpleEditor autoHeight lang="javascript" bind:code={value.valueFormatter} />
<SimpleEditor
extraLib={'declare const value: any'}
autoHeight
lang="javascript"
bind:code={value.valueFormatter}
/>
<div class="text-xs text-secondary -mt-4">Use `value` in the formatter</div>
</div>
{/key}
</div>
@@ -0,0 +1,381 @@
<script lang="ts">
import { Badge, Popup, type AlertType } from '../common'
import Toggle from '$lib/components/Toggle.svelte'
import SimpleEditor from '../SimpleEditor.svelte'
import Label from '../Label.svelte'
import Section from '../Section.svelte'
import Tooltip from '../Tooltip.svelte'
import Button from '../common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
import { ColumnIdentity, type ColumnDef } from '../apps/components/display/dbtable/utils'
export let value: ColumnDef | undefined
import Alert from '../common/alert/Alert.svelte'
const presets = [
{
label: 'None',
value: null
},
{
label: 'Currency CHF',
value: 'value + " CHF"'
},
{
label: 'Currency USD',
value: '"$ " + value'
},
{
label: 'Date',
value: 'new Date(value).toLocaleDateString()'
},
{
label: 'Percentage',
value: 'value + " %"'
},
{
label: 'Currency GBP',
value: 'value + " £"'
},
{
label: 'Currency EUR',
value: 'value + " €"'
},
{
label: 'Currency JPY',
value: 'value + " ¥"'
},
{
label: 'Decimal places (2)',
value: 'parseFloat(value).toFixed(2)'
},
{
label: 'Uppercase',
value: 'value.toUpperCase()'
},
{
label: 'Lowercase',
value: 'value.toLowerCase()'
},
{
label: 'Boolean (True/False)',
value: 'value ? "True" : "False"'
},
{
label: 'Object',
value: 'JSON.stringify(value, null, 2)'
}
]
let renderCount = 0
function computeWarning(columnMetadata, value) {
if (columnMetadata?.isnullable === 'NO' && !columnMetadata?.defaultvalue) {
if ([ColumnIdentity.Always, ColumnIdentity.ByDefault].includes(columnMetadata?.isidentity)) {
return {
type: 'info' as AlertType,
title: 'Value will be generated',
message: 'The column is an identity column. The value will be generated by the database.'
}
}
if (value?.hideInsert) {
return {
type: 'warning' as AlertType,
title: 'No default value',
message:
"The column is not nullable and doesn't have a default value. A default value is required."
}
}
}
if (columnMetadata?.defaultvalue !== null) {
return {
type: 'info' as AlertType,
title: 'Default value',
message: `${
value?.hideInsert ? '' : 'You may want to hide this field from insert. '
}The column has a default value defined in the database. The default value is: ${
value?.defaultvalue
}`
}
}
if (columnMetadata?.isnullable === 'YES') {
return {
type: 'info' as AlertType,
title: 'Default value',
message: `${
value?.hideInsert ? '' : 'You may want to hide this field from insert. '
}The column can be null. If no value is provided, the default value will be null.`
}
}
return null
}
$: warning = computeWarning(value, value)
</script>
<Popup
floatingConfig={{ strategy: 'fixed', placement: 'left-start' }}
containerClasses="border rounded-lg shadow-lg bg-surface p-4 h-[512px] max-h-[512px] overflow-y-auto"
>
<svelte:fragment slot="button">
<slot name="trigger" />
</svelte:fragment>
{#if value}
<div class="flex flex-col w-96 p-2 gap-4">
<Section label="Column settings">
<svelte:fragment slot="header">
<Badge color="blue">
{value.field}
</Badge>
</svelte:fragment>
<Label label="Skip for select and update">
<svelte:fragment slot="header">
<Tooltip>
By default, all columns are included in the select and update queries. If you want to
exclude a column from the select and update queries, you can set this property to
true.
</Tooltip>
</svelte:fragment>
<svelte:fragment slot="action">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
bind:checked={value.ignored}
size="xs"
disabled={value?.isprimarykey}
/>
</svelte:fragment>
{#if value?.isprimarykey}
<Alert type="warning" size="xs" title="Primary key" class="my-1">
You cannot skip a primary key.
</Alert>
{/if}
</Label>
<Label label="Hide from insert">
<svelte:fragment slot="header">
<Tooltip>
By default, all columns are used to generate the submit form. If you want to exclude a
column from the submit form, you can set this property to true. If the column is not
nullable or doesn't have a default value, a default value will be required.
</Tooltip>
</svelte:fragment>
<svelte:fragment slot="action">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
bind:checked={value.hideInsert}
size="xs"
/>
</svelte:fragment>
</Label>
{#if warning}
<Alert type={warning.type} size="xs" title={warning.title} class="my-2">
{warning.message}
</Alert>
{/if}
{#if value?.defaultvalue !== null && value?.hideInsert}
<Toggle
bind:checked={value.overrideDefaultValue}
size="xs"
options={{
right: `Override default value: ${value?.defaultvalue}`
}}
on:change={() => {
if (!value || !value.overrideDefaultValue) {
if (value) {
value.defaultValueNull = false
value.defaultUserValue = undefined
}
}
}}
/>
{/if}
<Label label="Default input">
<svelte:fragment slot="header">
<Tooltip>
By default, all columns are used to generate the submit form. If you want to exclude a
column from the submit form, you can set this property to true. If the column is not
nullable or doesn't have a default value, a default value will be required.
</Tooltip>
</svelte:fragment>
{#if value?.datatype}
{@const type = value?.datatype}
<div class="flex flex-row items-center gap-2">
<Badge color="dark-gray">
Type:
{type}
</Badge>
{#if value?.isnullable == 'YES' && value.hideInsert}
<Toggle
bind:checked={value.defaultValueNull}
size="xs"
options={{
right: 'Set to null'
}}
disabled={value.hideInsert &&
value?.defaultvalue !== null &&
!value?.overrideDefaultValue}
on:change={() => {
if (value?.defaultValueNull && value) {
value.defaultUserValue = null
}
}}
/>
{/if}
</div>
<input
type="text"
placeholder="Default value"
class="mt-2"
bind:value={value.defaultUserValue}
disabled={value.defaultValueNull ||
(value.hideInsert && value?.defaultvalue !== null && !value?.overrideDefaultValue)}
/>
{/if}
</Label>
</Section>
<Section label="AG Grid configuration">
<div
class={twMerge('flex flex-col gap-4', value.ignored ? 'opacity-50 cursor-none ' : '')}
on:pointerdown={(e) => {
if (value?.ignored) {
e?.stopPropagation()
}
}}
>
<Label label="Header name">
<input type="text" placeholder="Header name" bind:value={value.headerName} />
</Label>
<Label label="Editable value">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Editable' }}
bind:checked={value.editable}
size="xs"
/>
</Label>
<Label label="Min width (px)">
<input type="number" placeholder="width" bind:value={value.minWidth} />
</Label>
<Label label="Flex">
<svelte:fragment slot="header">
<Tooltip
documentationLink="https://www.ag-grid.com/javascript-data-grid/column-sizing/#column-flex"
>
It's often required that one or more columns fill the entire available space in the
grid. For this scenario, it is possible to use the flex config. Some columns could
be set with a regular width config, while other columns would have a flex config.
Flex sizing works by dividing the remaining space in the grid among all flex columns
in proportion to their flex value. For example, suppose the grid has a total width
of 450px and it has three columns: the first with width: 150; the second with flex:
1; and third with flex: 2. The first column will be 150px wide, leaving 300px
remaining. The column with flex: 2 has twice the size with flex: 1. So final sizes
will be: 150px, 100px, 200px.
</Tooltip>
</svelte:fragment>
<input type="range" step="1" bind:value={value.flex} min={1} max={12} />
<div class="text-xs">{value.flex}</div>
</Label>
<Label label="Hide">
<Toggle
on:pointerdown={(e) => {
e?.stopPropagation()
}}
options={{ right: 'Hide' }}
bind:checked={value.hide}
size="xs"
/>
</Label>
<Label label="Value formatter">
<svelte:fragment slot="header">
<Tooltip
documentationLink="https://www.ag-grid.com/javascript-data-grid/value-formatters/"
>
Value formatters allow you to format values for display. This is useful when data is
one type (e.g. numeric) but needs to be converted for human reading (e.g. putting in
currency symbols and number formatting).
</Tooltip>
</svelte:fragment>
<svelte:fragment slot="action">
<Button
size="xs"
color="light"
variant="border"
on:click={() => {
// @ts-ignore
value.valueFormatter = null
renderCount++
}}
>
Clear
</Button>
</svelte:fragment>
</Label>
<div>
{#key renderCount}
<div class="flex flex-col gap-4">
<div class="relative">
{#if !presets.find((preset) => preset.value === value?.valueFormatter)}
<div
class="z-50 absolute bg-opacity-50 bg-surface top-0 left-0 bottom-0 right-0"
/>
{/if}
<div class="text-xs font-semibold">Presets</div>
<select
bind:value={value.valueFormatter}
on:change={() => {
renderCount++
}}
placeholder="Code"
>
{#each presets as preset}
<option value={preset.value}>{preset.label}</option>
{/each}
</select>
</div>
<SimpleEditor
extraLib={'declare const value: any'}
autoHeight
lang="javascript"
bind:code={value.valueFormatter}
/>
<div class="text-xs text-secondary -mt-4">Use `value` in the formatter</div>
</div>
{/key}
</div>
<Label label="Sort">
<select bind:value={value.sort}>
<option value={null}>None</option>
<option value="asc">Ascending</option>
<option value="desc">Descending</option>
</select>
</Label>
</div>
</Section>
</div>
{/if}
</Popup>
+1 -1
View File
@@ -102,6 +102,6 @@ export interface GraphqlSchema {
export type DBSchema = SQLSchema | GraphqlSchema
type DBSchemas = Partial<Record<string, DBSchema>>
export type DBSchemas = Partial<Record<string, DBSchema>>
export const dbSchemas = writable<DBSchemas>({})
@@ -130,7 +130,6 @@
: true
})
: preFilteredItemsOwners?.filter((x) => {
console.log(x.resource_type)
return (
x.resource_type === typeFilter &&
(tab === 'workspace'