mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 16:02:14 +00:00
feat: Database Manager (#5586)
* hack fix dnd with tick * DBExplorer table left table selector * kinda works * correct table metadata * separated columnDefs creation logic * Removed dependency on AppDbExplorer * (tweak) loadTableMetaData much faster * nit for darkmode * DBExplorerDrawerButton * footer * count footer * reload * update * fix height * db explorer btn in resources table * delete row * InsertRowDrawerButton * insert * refresh on insert and delete * moved db logic to ts file * better update ux * moved all IO upwards * fix: Remaining svelte 5 bugs (#5563) * hack fix dnd with tick * fix: infinite loading in CodeDisplay after update to svelte 5 * regen package-lock * fix tutorial (#5562) * fix tutorial first part * fix tutorial --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev> * feat: Implement sending diff to ai (#5510) * allow mentioning specific files in instructions * remove not working highlight implementation * make highlighted text work * fix tooltip position * clean code * cleaning * use lib for tooltip positioning * fix logic * draft for db in context * use tools for db in context * fixes * cleaning and bug fixes * fix * cleaning * fix when script is db type * simplify logic * put schema in context if already here * fix imports * fix tooltip position and make it scrollable * remove console logs * check if selected is in available * fix tooltip list * add back lost logic * last fix * fix type errors * use loaded schema from dbSchemas * fix typing, content and lang are always there * remove from context if not available anymore * add not loaded yet mention if schema not loaded * add missing callback logic * fix prompt * fix usage of updateselectedContext function * fix styling for white theme * handle tab and arrows * fix schemas not being refreshed on contexts * also refresh displayMessages when dbschemas change * fix duplicate available contexts * fix logic for new scripts * fix new lines inside text area * implement sending diff in context * add button in deploy options to ask ai about diff * also visualize change when asking for diff * better prompt * add limit to diff size * put diff mode toggle in editor bar * add button to see history from editor * adjustements * put see diff button in dropdown * fixes * better styling * highlight if diff mode * format files * change buttons based on diffmode * remove diff after sending message * fix type error * smaller buttons * draft * use existing editor in diff editor * fix number of db resources fetches * fix apply and add buttons on diff mode * cleaning * undo ai gen button show * better buttons * styling asjustements + show diff in badge * styling * fix deployed code check * cleaning and styling * better quick actions * dont send code when analyzing * remove apply in chat if only code and no diff * fix bad code refactor --------- Co-authored-by: HugoCasa <hugo@casademont.ch> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> * fix: prevent invalid returned ai completion object errors (#5564) * fix(frontend): app builder - force json configuration in rich result (#5565) * feat: make azure a standalone AI provider (#5558) * feat: make azure a standalone AI provider * oups * nit * fix: openai/azure oauth * nit * nits * feat(frontend) add flow step result viewer (#5398) * fix(cli): properly handle enabled/disabled updates of schedules * fix benchmarks * feat: handle sending selected lines to ai context (#5527) * allow mentioning specific files in instructions * remove not working highlight implementation * make highlighted text work * fix tooltip position * clean code * cleaning * use lib for tooltip positioning * fix logic * draft for db in context * use tools for db in context * fixes * cleaning and bug fixes * fix * cleaning * fix when script is db type * simplify logic * put schema in context if already here * fix imports * fix tooltip position and make it scrollable * remove console logs * check if selected is in available * fix tooltip list * add back lost logic * last fix * fix type errors * use loaded schema from dbSchemas * fix typing, content and lang are always there * remove from context if not available anymore * add not loaded yet mention if schema not loaded * add missing callback logic * fix prompt * fix usage of updateselectedContext function * fix styling for white theme * handle tab and arrows * fix schemas not being refreshed on contexts * also refresh displayMessages when dbschemas change * fix duplicate available contexts * fix logic for new scripts * fix new lines inside text area * implement sending diff in context * add button in deploy options to ask ai about diff * also visualize change when asking for diff * better prompt * add limit to diff size * put diff mode toggle in editor bar * add button to see history from editor * adjustements * put see diff button in dropdown * fixes * better styling * handle adding code piece to context * add code piece in context * draft start end markers * adapt code * draft * apply code pieces before sending request * cleaning * highlight if diff mode * format files * change buttons based on diffmode * remove diff after sending message * fix type error * smaller buttons * draft * use existing editor in diff editor * fix number of db resources fetches * fix apply and add buttons on diff mode * cleaning * undo ai gen button show * better buttons * better prompt * remove console log * fix merge * avoid duplicates * fix merge * fix * fix apply logic * remove useless if * focus text area + close chat if no selected lines --------- Co-authored-by: HugoCasa <hugo@casademont.ch> * fix: flow editor svelte 5 issues (#5567) * feat: add diff toggle to flow inline scripts (#5550) * draft flow diff * add missing import * cleaning * code cleaning * fix for recursive renderings * fix typo * cleaning --------- Co-authored-by: HugoCasa <hugo@casademont.ch> * fix(frontend): proper each block binding + better app settings reactivity (#5568) * fix: properly bind to array elements in Svelte each loops This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables. The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]} Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte * better app settings panel reactivity --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: HugoCasa <hugo@casademont.ch> * fix: app editor svelte 5 fixes (#5570) * fix: properly bind to array elements in Svelte each loops This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables. The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]} Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte * better app settings panel reactivity * fix: app editor table svelte 5 fixes --------- Co-authored-by: Guilhem <guilhemlemouel@gmail.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> * select border (#5571) * fix: properly bind to array elements in Svelte each loops This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables. The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]} Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte * better app settings panel reactivity * fix: app editor table svelte 5 fixes * fix: select border --------- Co-authored-by: Guilhem <guilhemlemouel@gmail.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> * feat: add windmill context to autocomplete (#5548) * add windmill context to autocomplete * fix formatting * remove console log * do not mention tool call for autocomplete * apply logic to php --------- Co-authored-by: HugoCasa <hugo@casademont.ch> * fix tabs selected behavior change from svelte 5 * fix: fix list jobs by tag * fix: tenant id to never be undefined on teams (#5572) * fix: tenant id to never be undefined * simplify azure oauth * simplify azure oauth * update ee ref * sqlx prepare * sqlx prepare * fix: legacy script gen model selection (#5574) * feat: add wildcards filter for worker/label/tags * fix: Dynamic select does not work with tag //native (#5576) closes #5490 * function takes 13 arguments but 14 arguments were supplied (#5577) * fix(frontend): prevent deploy popover to show if deploy dropdown is open (#5542) * prevent deploy popover to show if deploy dropdown is open * wip * Revert "wip" This reverts commit85434654af. * Revert "prevent deploy popover to show if deploy dropdown is open" This reverts commitedd9eda156. * add prop to hide popup fro dropdown * feat: button can have tooltip * improve deploy tooltip and dropdown behavior * rename tooltip to tooltipPopover * nit * add deploy button component * use svelte 5 runes * use new deploy button for script builder * add delay to deploy popover when dropdown is open * add delay to deploy popover when dropdown is open # Conflicts: # frontend/src/lib/components/DeployButton.svelte # frontend/src/lib/components/common/button/Button.svelte * Update frontend/src/lib/components/common/button/Button.svelte Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * remove unsused field * nit --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * chore: update hub sync script (#5579) * fix monaco suggestion z-index (#5578) * fix: validate saved module before passing to flow module editor (#5580) * fix: freeze when clicking script history diff button (#5581) * fix: validate saved module before passing to flow module editor * fix: freeze when clicking script history diff button * add space (#5582) * fix: binding not working in nested array script arg (#5585) * fix: mssql ca_cert deserializing (#5587) * fix: improve app image picker UX (#5589) * DBTableAction * delete table * fix intempestive error toasts * fullscreen mode * rename db explorer to db manager * use drawer open state instead of oo open method * create table btn * factor away sucess text * basic table creation form * uniq check * better select * better add btn * extra settings * create table works in pgsql * MySQL kinda works * CRUD works in mysql * fix lowercase * allow create table with no schema * handle default value * sql repl pane * execute sql queries * db ops opt in * SQL Repl v0 * ux * UX * better refresh * better placeholder sql * sql code clipboard copy * handle multiple primary keys in table creation * fix all fields being required on insert * fix postgres enum not properly converted * use InsertRowDrawerButton in App db studio * insert shortcut * fix mysql datetime parser for non tz dates * npm run check * Revert "fix all fields being required on insert" This reverts commit6bec952fb7. * fk ui * don't commit .env... * tweak * fk ui bindings * fk dropdowns right values * schema notation fixes * handle on delete / update cascade etc * better loading button without flicker * fix infinite loop caused by getDbSchemas * foreign key error validation * type error * cache col defs * fix label hover * fix fk select overflow * Fix Select styling * mssql fixes * fix wrong typecast failing with mssql * extract makeLoadTableMetaDataQuery * Fetch all col defs in one go (mysql) * loadAllTablesMetaData for postgres * for some reason factoring transformColumnDefs broke ag infinite table * mssql loads all coldefs at once * snowflake preload all col defs * filter out information schema snowflake * default schema select * fix original pg col def logic broken * Fix ugly flickers * fix updateGrid before grid ready * better auto sizing * smoother CRUD UI refreshes * fix col defs qury for bigquery * bigquery works * nits * do not change queries used in policies ! * fix runPreviewJobAndPollResult on WINDMILL_TOO_BIG * select padding * ellipsis typos * fix mysterious ugly red flash when mounting ag grid in dark mode only * Load on click * Remove schema explorer mode * repl min size * fix flash on dark mode * dirty fix ag grid not refreshing sometimes * sql repl history * fix ag theme * close sql repl result viewer by clicking outside * Select styling fix in dark mode * better default query sqlrepl * Buttons less aggressive --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: centdix <40307056+centdix@users.noreply.github.com> Co-authored-by: HugoCasa <hugo@casademont.ch> Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com> Co-authored-by: Guilhem <guilhemlemouel@gmail.com> Co-authored-by: Piyush मिश्र <piyushxcoder@gmail.com> Co-authored-by: Piyush मिश्र <piyush.raj.kit@gmail.com> Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Generated
-2
@@ -11220,8 +11220,6 @@
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "3.24.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { type DBSchema } from '$lib/stores'
|
||||
import { MoreVertical, Plus, Table2 } from 'lucide-svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { ClearableInput, Drawer, DrawerContent } from './common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { type ColumnDef } from './apps/components/display/dbtable/utils'
|
||||
import DBTable from './DBTable.svelte'
|
||||
import type { DbTableActionFactory, IDbTableOps } from './dbOps'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import DbTableEditor, { type DBTableEditorProps } from './DBTableEditor.svelte'
|
||||
|
||||
type Props = {
|
||||
dbSchema: DBSchema
|
||||
dbSupportsSchemas: boolean
|
||||
getColDefs: (tableKey: string) => Promise<ColumnDef[]>
|
||||
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
|
||||
dbTableActionsFactory?: DbTableActionFactory[]
|
||||
refresh?: () => void
|
||||
dbTableEditorPropsFactory?: (params: { selectedSchemaKey?: string }) => DBTableEditorProps
|
||||
}
|
||||
let {
|
||||
dbSchema,
|
||||
dbTableOpsFactory,
|
||||
getColDefs,
|
||||
dbTableActionsFactory,
|
||||
refresh,
|
||||
dbTableEditorPropsFactory,
|
||||
dbSupportsSchemas
|
||||
}: Props = $props()
|
||||
|
||||
let schemaKeys = $derived(Object.keys(dbSchema.schema))
|
||||
let search = $state('')
|
||||
let selected: {
|
||||
schemaKey?: undefined | string
|
||||
tableKey?: undefined | string
|
||||
} = $state({})
|
||||
|
||||
$effect(() => {
|
||||
if (!selected.schemaKey && schemaKeys.length) {
|
||||
selected = {
|
||||
schemaKey:
|
||||
'public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let tableKeys = $derived.by(() => {
|
||||
if (dbSchema.lang === 'graphql') {
|
||||
sendUserToast('graphql not supported by DBExplorerTable', true)
|
||||
return []
|
||||
}
|
||||
if (!selected.schemaKey) return []
|
||||
return Object.keys(dbSchema.schema[selected.schemaKey])
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (tableKeys.length && !selected.tableKey) {
|
||||
selected.tableKey = filteredTableKeys[0]
|
||||
}
|
||||
})
|
||||
|
||||
let filteredTableKeys = $derived.by(() => {
|
||||
const l = tableKeys.filter((tk) => tk.includes(search))
|
||||
l.sort()
|
||||
return l
|
||||
})
|
||||
|
||||
let tableKey = $derived(
|
||||
dbSupportsSchemas && selected.schemaKey
|
||||
? `${selected.schemaKey}.${selected.tableKey}`
|
||||
: selected.tableKey
|
||||
)
|
||||
|
||||
let askingForConfirmation:
|
||||
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
|
||||
| undefined = $state()
|
||||
|
||||
let dbTableEditorState: { open: boolean } = $state({ open: false })
|
||||
|
||||
let dbTableEditorProps = $derived(
|
||||
dbTableEditorPropsFactory?.({ selectedSchemaKey: selected.schemaKey })
|
||||
)
|
||||
</script>
|
||||
|
||||
<Splitpanes>
|
||||
<Pane size={24} class="relative flex flex-col">
|
||||
<div class="mx-3 mt-3">
|
||||
{#if dbSupportsSchemas}
|
||||
<select
|
||||
value={selected.schemaKey}
|
||||
onchange={(e) => {
|
||||
selected = { schemaKey: e.currentTarget.value }
|
||||
}}
|
||||
>
|
||||
{#each schemaKeys as schemaKey}
|
||||
<option value={schemaKey}>{schemaKey}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<ClearableInput wrapperClass="mt-3" bind:value={search} placeholder="Search table..." />
|
||||
</div>
|
||||
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
|
||||
{#each filteredTableKeys as tableKey}
|
||||
<button
|
||||
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
|
||||
(selected.tableKey === tableKey ? 'bg-gray-500/25' : 'hover:bg-gray-500/10')}
|
||||
onclick={() => (selected.tableKey = tableKey)}
|
||||
>
|
||||
<Table2 class="text-gray-500/40 shrink-0" size={16} />
|
||||
<p class="truncate text-ellipsis grow text-left">{tableKey}</p>
|
||||
{#if dbTableActionsFactory}
|
||||
{@const dbTableActions = dbTableActionsFactory.map((f) =>
|
||||
f({
|
||||
tableKey: `${selected.schemaKey}.${tableKey}`,
|
||||
refresh: refresh ?? (() => {})
|
||||
})
|
||||
)}
|
||||
<DropdownV2
|
||||
items={() =>
|
||||
dbTableActions.map((tableAction) => ({
|
||||
displayName: tableAction.displayName,
|
||||
...(tableAction.icon ? { icon: tableAction.icon } : {}),
|
||||
action: () =>
|
||||
(askingForConfirmation = {
|
||||
title: tableAction.confirmTitle ?? 'Are you sure ?',
|
||||
confirmationText: tableAction.confirmBtnText ?? 'Confirm',
|
||||
open: true,
|
||||
onConfirm: async () => {
|
||||
askingForConfirmation && (askingForConfirmation.loading = true)
|
||||
try {
|
||||
await tableAction.action()
|
||||
tableAction.successText && sendUserToast(tableAction.successText)
|
||||
} catch (e) {
|
||||
let msg: string | undefined = (e as Error).message
|
||||
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
|
||||
sendUserToast(msg ?? 'Action failed!', true)
|
||||
}
|
||||
askingForConfirmation = undefined
|
||||
}
|
||||
})
|
||||
}))}
|
||||
class="w-fit"
|
||||
>
|
||||
<svelte:fragment slot="buttonReplacement">
|
||||
<MoreVertical
|
||||
size={8}
|
||||
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<Button
|
||||
variant="border"
|
||||
on:click={() => (dbTableEditorState = { open: true })}
|
||||
wrapperClasses="mx-2 my-2 text-sm"
|
||||
startIcon={{ icon: Plus }}
|
||||
>
|
||||
New table
|
||||
</Button>
|
||||
</Pane>
|
||||
<Pane class="p-3 pt-1">
|
||||
{#if tableKey}
|
||||
{#await getColDefs(tableKey) then colDefs}
|
||||
{#if colDefs && colDefs?.length}
|
||||
{@const dbTableOps = dbTableOpsFactory({ colDefs, tableKey })}
|
||||
<DBTable {dbTableOps} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
|
||||
<ConfirmationModal
|
||||
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
|
||||
on:canceled={() => (askingForConfirmation = undefined)}
|
||||
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
|
||||
/>
|
||||
|
||||
{#if dbTableEditorProps}
|
||||
<Drawer
|
||||
size="600px"
|
||||
open={dbTableEditorState.open}
|
||||
on:close={() => (dbTableEditorState = { open: false })}
|
||||
>
|
||||
<DrawerContent
|
||||
on:close={() => (dbTableEditorState = { open: false })}
|
||||
title="Create a new table"
|
||||
>
|
||||
<DbTableEditor
|
||||
{...dbTableEditorProps}
|
||||
{dbSchema}
|
||||
currentSchema={selected.schemaKey}
|
||||
onConfirm={async (values) => {
|
||||
await dbTableEditorProps.onConfirm(values)
|
||||
dbTableEditorState = { open: false }
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -0,0 +1,272 @@
|
||||
<script lang="ts">
|
||||
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 { sendUserToast, sortArray } from '$lib/utils'
|
||||
import { ArrowLeft, Database, Expand, Loader2, Minimize, RefreshCcw } from 'lucide-svelte'
|
||||
import {
|
||||
dbSupportsSchemas,
|
||||
getDbSchemas,
|
||||
getLanguageByResourceType,
|
||||
loadAllTablesMetaData,
|
||||
loadTableMetaData,
|
||||
type DbType,
|
||||
type TableMetadata
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import DbManager from './DBManager.svelte'
|
||||
import { Alert } from './common'
|
||||
import { dbDeleteTableActionWithPreviewScript, dbTableOpsWithPreviewScripts } from './dbOps'
|
||||
import { makeCreateTableQuery } from './apps/components/display/dbtable/queries/createTable'
|
||||
import { runPreviewJobAndPollResult } from './jobs/utils'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import SqlRepl from './SqlRepl.svelte'
|
||||
import SimpleAgTable from './SimpleAgTable.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
resourceType: DbType
|
||||
resourcePath: string
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { resourceType, resourcePath }: Props = $props()
|
||||
|
||||
let dbSchema: DBSchema | undefined = $derived(
|
||||
resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
|
||||
)
|
||||
|
||||
let isDrawerOpen: boolean = $state(false)
|
||||
|
||||
let shouldDisplayError = $derived(
|
||||
resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
|
||||
)
|
||||
|
||||
// `refreshCount` is a derived state. `refreshing` is the source of truth
|
||||
let refreshCount = $state(0)
|
||||
$effect(() => {
|
||||
if (refreshing) untrack(() => (refreshCount += 1))
|
||||
})
|
||||
|
||||
let refreshing = $state(false)
|
||||
$effect(() => {
|
||||
if (refreshing) getSchema()
|
||||
})
|
||||
const refresh = () => !refreshing && (refreshing = true)
|
||||
|
||||
let expand = $state(false)
|
||||
$effect(() => {
|
||||
if (!isDrawerOpen) expand = false
|
||||
})
|
||||
|
||||
async function getSchema() {
|
||||
if ($dbSchemas[resourcePath] && !refreshing) return
|
||||
try {
|
||||
const oldDbSchema = $dbSchemas[resourcePath]
|
||||
await getDbSchemas(
|
||||
resourceType,
|
||||
resourcePath,
|
||||
$workspaceStore,
|
||||
$dbSchemas,
|
||||
(message: string) => {
|
||||
if (isDrawerOpen) {
|
||||
sendUserToast(message, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
// avoid infinite loop on error due to the way getDbSchemas is implemented
|
||||
// and relying on an assignement side effect
|
||||
if (oldDbSchema !== $dbSchemas[resourcePath]) $dbSchemas = $dbSchemas
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
let windowWidth = $state(window.innerWidth)
|
||||
|
||||
let replPanelSize = $state(36)
|
||||
const REPL_MIN_SIZE = 1.5
|
||||
|
||||
let replResultData: undefined | Record<string, any>[] = $state(undefined)
|
||||
|
||||
let cachedColDefs: Record<string, TableMetadata> = {}
|
||||
let cachedLastRefreshCount = 0
|
||||
|
||||
async function getColDefs(tableKey: string) {
|
||||
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
|
||||
cachedLastRefreshCount = refreshCount
|
||||
if (cachedColDefs[tableKey]) {
|
||||
return cachedColDefs[tableKey]
|
||||
}
|
||||
try {
|
||||
cachedColDefs =
|
||||
(await loadAllTablesMetaData('$res:' + resourcePath, $workspaceStore, resourceType)) ??
|
||||
cachedColDefs
|
||||
return cachedColDefs[tableKey]
|
||||
} catch (e) {
|
||||
const result = await loadTableMetaData(
|
||||
'$res:' + resourcePath,
|
||||
$workspaceStore,
|
||||
tableKey,
|
||||
resourceType
|
||||
)
|
||||
|
||||
if (result) cachedColDefs[tableKey] = result
|
||||
return result ?? []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
bind:innerWidth={windowWidth}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (replResultData) {
|
||||
replResultData = undefined
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{#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>
|
||||
{:else}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
spacingSize="xs2"
|
||||
btnClasses="mt-1 w-24"
|
||||
on:click={async () => {
|
||||
if (!dbSchema || !$workspaceStore) refreshing = true
|
||||
isDrawerOpen = true
|
||||
}}
|
||||
>
|
||||
<Database size={18} /> Manager
|
||||
</Button>
|
||||
<Drawer bind:open={isDrawerOpen} size={expand ? `${windowWidth}px` : '1200px'} preventEscape>
|
||||
<DrawerContent
|
||||
title={replResultData ? 'Query Result' : 'Database Manager'}
|
||||
on:close={() => {
|
||||
if (replResultData) {
|
||||
replResultData = undefined
|
||||
} else {
|
||||
isDrawerOpen = false
|
||||
}
|
||||
}}
|
||||
CloseIcon={replResultData ? ArrowLeft : undefined}
|
||||
noPadding
|
||||
>
|
||||
{#if dbSchema && $workspaceStore}
|
||||
<Splitpanes horizontal>
|
||||
<Pane class="relative">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={'absolute inset-0 z-10 p-8 ' +
|
||||
(replResultData
|
||||
? 'bg-surface/90'
|
||||
: 'transition-colors bg-transparent pointer-events-none select-none')}
|
||||
onclick={(e) => {
|
||||
// Only proceed if the click is directly on this div and not on the child elements
|
||||
if (e.target === e.currentTarget) {
|
||||
replResultData = undefined
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#if replResultData}
|
||||
{#key replResultData}
|
||||
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<DbManager
|
||||
dbSupportsSchemas={dbSupportsSchemas(resourceType)}
|
||||
{dbSchema}
|
||||
{getColDefs}
|
||||
dbTableOpsFactory={({ colDefs, tableKey }) =>
|
||||
dbTableOpsWithPreviewScripts({
|
||||
colDefs,
|
||||
tableKey,
|
||||
resourcePath,
|
||||
resourceType,
|
||||
workspace: $workspaceStore
|
||||
})}
|
||||
dbTableActionsFactory={[
|
||||
dbDeleteTableActionWithPreviewScript({
|
||||
resourcePath,
|
||||
resourceType,
|
||||
workspace: $workspaceStore
|
||||
})
|
||||
]}
|
||||
{refresh}
|
||||
dbTableEditorPropsFactory={({ selectedSchemaKey }) => ({
|
||||
resourceType,
|
||||
previewSql: (values) =>
|
||||
makeCreateTableQuery(values, resourceType, selectedSchemaKey),
|
||||
async onConfirm(values) {
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath },
|
||||
content: makeCreateTableQuery(values, resourceType, selectedSchemaKey),
|
||||
language: getLanguageByResourceType(resourceType)
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Pane>
|
||||
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
|
||||
<SqlRepl
|
||||
{resourcePath}
|
||||
{resourceType}
|
||||
onData={(data) => {
|
||||
replResultData = data
|
||||
}}
|
||||
placeholderTableName={sortArray(
|
||||
Object.keys(
|
||||
dbSchema?.schema[
|
||||
'public' in dbSchema?.schema
|
||||
? 'public'
|
||||
: 'dbo' in dbSchema?.schema
|
||||
? 'dbo'
|
||||
: Object.keys(dbSchema?.schema)?.[0]
|
||||
]
|
||||
)
|
||||
)?.[0]}
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{:else}
|
||||
<Splitpanes>
|
||||
<Pane class="relative flex justify-center items-center">
|
||||
<Loader2 class="animate-spin" size={32} />
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
{/if}
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
loading={refreshing}
|
||||
on:click={() => refresh()}
|
||||
startIcon={{ icon: RefreshCcw }}
|
||||
size="xs"
|
||||
color="light"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
on:click={() => (expand = !expand)}
|
||||
startIcon={{ icon: expand ? Minimize : Expand }}
|
||||
size="xs"
|
||||
color="light"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
{/if}
|
||||
@@ -1,58 +1,16 @@
|
||||
<script lang="ts">
|
||||
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 { type DBSchema } from '$lib/stores'
|
||||
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
|
||||
import { sendUserToast } from '$lib/utils'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.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'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { formatGraphqlSchema, formatSchema } from './apps/components/display/dbtable/utils'
|
||||
|
||||
export let resourceType: string | undefined
|
||||
export let resourcePath: string | undefined = undefined
|
||||
let dbSchema: DBSchema | undefined = undefined
|
||||
let loading = false
|
||||
|
||||
let drawer: Drawer | undefined
|
||||
|
||||
async function getSchema() {
|
||||
if (!resourceType || !resourcePath) return
|
||||
if ($dbSchemas[resourcePath]) return
|
||||
|
||||
loading = true
|
||||
|
||||
try {
|
||||
await getDbSchemas(
|
||||
resourceType,
|
||||
resourcePath,
|
||||
$workspaceStore,
|
||||
$dbSchemas,
|
||||
(message: string) => {
|
||||
if (drawer?.isOpen()) {
|
||||
sendUserToast(message, true)
|
||||
}
|
||||
}
|
||||
)
|
||||
$dbSchemas = $dbSchemas
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
loading = false
|
||||
type Props = {
|
||||
dbSchema: DBSchema
|
||||
}
|
||||
|
||||
$: resourcePath && Object.keys(scripts).includes(resourceType || '') && getSchema()
|
||||
|
||||
$: dbSchema = resourcePath && resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
|
||||
|
||||
$: shouldDisplayError = resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
|
||||
let { dbSchema }: Props = $props()
|
||||
|
||||
function handleSelected({ detail }: CustomEvent<string>) {
|
||||
if (dbSchema && dbSchema.lang !== 'graphql') {
|
||||
@@ -61,60 +19,23 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<Loader2 size={14} class="animate-spin " />
|
||||
{/if}
|
||||
|
||||
{#if dbSchema}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="blue"
|
||||
spacingSize="xs2"
|
||||
btnClasses="mt-1"
|
||||
on:click={drawer?.openDrawer ?? (() => {})}
|
||||
{#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)}
|
||||
<ToggleButtonGroup
|
||||
class="mb-4"
|
||||
selected={dbSchema.publicOnly ? 'dbo' : 'all'}
|
||||
on:selected={handleSelected}
|
||||
let:item
|
||||
>
|
||||
Explore schema
|
||||
</Button>
|
||||
<Drawer bind:this={drawer}>
|
||||
<DrawerContent title="Schema Explorer" on:close={drawer.closeDrawer}>
|
||||
<svelte:fragment slot="actions">
|
||||
<Button
|
||||
on:click={getSchema}
|
||||
startIcon={{
|
||||
icon: RefreshCcw
|
||||
}}
|
||||
{loading}
|
||||
size="xs"
|
||||
color="light"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
{#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)}
|
||||
<ToggleButtonGroup
|
||||
class="mb-4"
|
||||
selected={dbSchema.publicOnly ? 'dbo' : 'all'}
|
||||
on:selected={handleSelected}
|
||||
let:item
|
||||
>
|
||||
<ToggleButton value="dbo" label={dbSchema.schema.dbo ? 'Dbo' : 'Public'} {item} />
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{#if dbSchema.lang === 'graphql'}
|
||||
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default code={formatGraphqlSchema(dbSchema.schema)} class="h-full" />
|
||||
{/await}
|
||||
{:else}
|
||||
<ObjectViewer json={formatSchema(dbSchema)} pureViewer collapseLevel={1} />
|
||||
{/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>
|
||||
<ToggleButton value="dbo" label={dbSchema.schema.dbo ? 'Dbo' : 'Public'} {item} />
|
||||
<ToggleButton value="all" label="All" {item} />
|
||||
</ToggleButtonGroup>
|
||||
{/if}
|
||||
{#if dbSchema.lang === 'graphql'}
|
||||
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
|
||||
<Loader2 class="animate-spin" />
|
||||
{:then Module}
|
||||
<Module.default code={formatGraphqlSchema(dbSchema.schema)} class="h-full" />
|
||||
{/await}
|
||||
{:else}
|
||||
<ObjectViewer json={formatSchema(dbSchema)} pureViewer collapseLevel={1} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { createGrid, type GridApi, type IDatasource } from 'ag-grid-community'
|
||||
import { transformColumnDefs } from './apps/components/display/table/utils'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
import { Button } from './common'
|
||||
import { Download } from 'lucide-svelte'
|
||||
import Popover from './Popover.svelte'
|
||||
import DebouncedInput from './apps/components/helpers/DebouncedInput.svelte'
|
||||
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
|
||||
import type { IDbTableOps } from './dbOps'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import 'ag-grid-community/styles/ag-grid.css'
|
||||
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
|
||||
|
||||
type Props = {
|
||||
dbTableOps: IDbTableOps
|
||||
}
|
||||
let { dbTableOps }: Props = $props()
|
||||
|
||||
let [clientHeight, clientWidth, darkMode, firstRow, lastRow] = $state([0, 0, false, -1, -1])
|
||||
let quicksearch = $state('')
|
||||
let api: GridApi<any> | undefined = $state()
|
||||
let eGui: HTMLDivElement | undefined = $state()
|
||||
|
||||
let datasource: IDatasource = {
|
||||
getRows: async function (params) {
|
||||
if (!$workspaceStore) return params.failCallback()
|
||||
let lastRow = rowCount && rowCount <= params.endRow ? rowCount : -1
|
||||
|
||||
const items = await dbTableOps.getRows({
|
||||
offset: params.startRow,
|
||||
limit: params.endRow - params.startRow,
|
||||
quicksearch: params.context.quicksearch,
|
||||
order_by: params.sortModel?.[0]?.colId ?? dbTableOps.colDefs[0]?.field,
|
||||
is_desc: params.sortModel?.[0]?.sort === 'desc'
|
||||
})
|
||||
|
||||
if (items.length < params.endRow - params.startRow) lastRow = params.startRow + items.length
|
||||
params.successCallback(items, lastRow)
|
||||
}
|
||||
}
|
||||
let rowCount = $state(0)
|
||||
let refreshCount = $state(0)
|
||||
const refresh = () => (refreshCount += 1)
|
||||
|
||||
$effect(() => eGui && mountGrid())
|
||||
function mountGrid() {
|
||||
if (eGui && !api) {
|
||||
createGrid(eGui, {
|
||||
rowModelType: 'infinite',
|
||||
pagination: false,
|
||||
...(dbTableOps.onUpdate && {
|
||||
defaultColDef: {
|
||||
flex: 1,
|
||||
minWidth: 160,
|
||||
editable: true,
|
||||
onCellValueChanged: (e) => {
|
||||
if (!$workspaceStore) return
|
||||
const colDef = e.colDef as unknown as { field: string; datatype: string }
|
||||
dbTableOps
|
||||
.onUpdate?.(
|
||||
{ values: { ...(e.data as object), [colDef.field]: e.oldValue } },
|
||||
colDef,
|
||||
e.newValue
|
||||
)
|
||||
.then(() => {
|
||||
sendUserToast('Value updated')
|
||||
})
|
||||
.catch(() => {
|
||||
sendUserToast('Error updating value', true)
|
||||
refresh?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
onViewportChanged: (e) => ([firstRow, lastRow] = [e.firstRow, e.lastRow]),
|
||||
infiniteInitialRowCount: 100,
|
||||
cacheBlockSize: 100,
|
||||
cacheOverflowSize: 10,
|
||||
maxBlocksInCache: 20,
|
||||
suppressColumnMoveAnimation: true,
|
||||
suppressDragLeaveHidesColumns: true,
|
||||
onGridReady: (e) => {
|
||||
api = e.api
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let prevUpdateKey: any = undefined
|
||||
$effect(() => {
|
||||
if (!$workspaceStore || !api) return
|
||||
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount }
|
||||
if (deepEqual(key, prevUpdateKey)) return
|
||||
prevUpdateKey = key
|
||||
updateGrid()
|
||||
})
|
||||
function updateGrid() {
|
||||
dbTableOps.getCount({ quicksearch }).then((result) => (rowCount = result))
|
||||
|
||||
api?.purgeInfiniteCache()
|
||||
api?.updateGridOptions({
|
||||
datasource,
|
||||
columnDefs: transformColumnDefs({
|
||||
columnDefs: dbTableOps.colDefs ?? [],
|
||||
...(dbTableOps.onDelete && {
|
||||
onDelete: (values) => {
|
||||
if (!$workspaceStore) return
|
||||
dbTableOps
|
||||
.onDelete?.({ values })
|
||||
.then(() => {
|
||||
refresh?.()
|
||||
sendUserToast('Row deleted')
|
||||
})
|
||||
.catch(() => {
|
||||
sendUserToast('Error deleting row', true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}),
|
||||
context: {
|
||||
quicksearch
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div class="h-full relative flex flex-col">
|
||||
<div class="flex py-2 h-12 justify-between gap-4">
|
||||
<DebouncedInput
|
||||
class="w-full max-w-[300px]"
|
||||
type="text"
|
||||
bind:value={quicksearch}
|
||||
placeholder="Search..."
|
||||
/>
|
||||
{#if dbTableOps.onInsert}
|
||||
<InsertRowDrawerButton
|
||||
columnDefs={dbTableOps.colDefs ?? []}
|
||||
dbType={dbTableOps.resourceType}
|
||||
onInsert={(values) => {
|
||||
if (!$workspaceStore) return
|
||||
dbTableOps.onInsert?.({ values }).then((result) => {
|
||||
refresh?.()
|
||||
sendUserToast('Row inserted')
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
class={'flex flex-col flex-1 component-wrapper divide-y wm-aggrid-container'}
|
||||
bind:clientHeight
|
||||
bind:clientWidth
|
||||
>
|
||||
<div
|
||||
style:height="{clientHeight}px"
|
||||
style:width="{clientWidth}px"
|
||||
class="ag-theme-alpine"
|
||||
class:ag-theme-alpine-dark={darkMode}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={eGui}
|
||||
class={api ? '' : 'opacity-0'}
|
||||
style:height="100%"
|
||||
onkeydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selectedCell = api?.getFocusedCell()
|
||||
if (selectedCell) {
|
||||
const rowIndex = selectedCell.rowIndex
|
||||
const colId = selectedCell.column?.getId()
|
||||
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
|
||||
const selectedValue = rowNode?.data?.[colId]
|
||||
navigator.clipboard.writeText(selectedValue)
|
||||
sendUserToast('Copied cell value to clipboard', false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 w-full justify-between items-center text-xs text-primary p-2">
|
||||
<div>
|
||||
<Popover>
|
||||
<svelte:fragment slot="text">Download</svelte:fragment>
|
||||
<Button
|
||||
startIcon={{ icon: Download }}
|
||||
color="light"
|
||||
size="xs2"
|
||||
on:click={() => api?.exportDataAsCsv()}
|
||||
iconOnly
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
{#if rowCount}
|
||||
{firstRow}{'->'}{lastRow + 1} of {rowCount} rows
|
||||
{:else}
|
||||
{firstRow}{'->'}{lastRow + 1}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,435 @@
|
||||
<script lang="ts" module>
|
||||
function validate(values: CreateTableValues, dbSchema?: DBSchema) {
|
||||
const columnNamesErrs = values.columns.flatMap((column) => {
|
||||
const isUnique = values.columns.filter((c) => c.name === column.name).length === 1
|
||||
return !column.name.length || !isUnique ? [column.name] : []
|
||||
})
|
||||
const fkErrs = values.foreignKeys.map((fk) => ({
|
||||
emptyTarget: !fk.targetTable,
|
||||
nonExistingSourceColumns: fk.columns
|
||||
.map((c) => c.sourceColumn)
|
||||
.filter((sc) => values.columns.every((c) => c.name !== sc)),
|
||||
nonExistingTargetColumns: fk.columns
|
||||
.map((c) => c.targetColumn)
|
||||
.filter(
|
||||
(tc) =>
|
||||
!tc ||
|
||||
!Object.keys(
|
||||
dbSchema?.schema?.[fk.targetTable?.split('.')?.[0] ?? '']?.[
|
||||
fk.targetTable?.split('.')[1]
|
||||
] ?? {}
|
||||
).includes(tc)
|
||||
)
|
||||
}))
|
||||
const someFkErr = fkErrs.some(
|
||||
(fkErr) =>
|
||||
fkErr.emptyTarget ||
|
||||
fkErr.nonExistingSourceColumns.length ||
|
||||
fkErr.nonExistingTargetColumns.length
|
||||
)
|
||||
|
||||
const errs = {
|
||||
name: !values.name.length,
|
||||
columns: columnNamesErrs.length ? columnNamesErrs : undefined,
|
||||
foreignKeys: someFkErr ? fkErrs : undefined
|
||||
}
|
||||
if (Object.values(errs).every((v) => !v)) return undefined
|
||||
return errs
|
||||
}
|
||||
|
||||
export type DBTableEditorProps = {
|
||||
onConfirm: (values: CreateTableValues) => void | Promise<void>
|
||||
previewSql?: (values: CreateTableValues) => string
|
||||
resourceType: DbType
|
||||
dbSchema?: DBSchema
|
||||
currentSchema?: string
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ArrowRight, ClipboardCopy, Info, Plus, Settings, X } from 'lucide-svelte'
|
||||
|
||||
import { Button } from './common'
|
||||
import { Cell } from './table'
|
||||
import DataTable from './table/DataTable.svelte'
|
||||
import Head from './table/Head.svelte'
|
||||
import {
|
||||
datatypeHasLength,
|
||||
dbSupportsSchemas,
|
||||
type DbType
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import { DB_TYPES } from '$lib/consts'
|
||||
import Select from './apps/svelte-select/lib/Select.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Tooltip from './meltComponents/Tooltip.svelte'
|
||||
import {
|
||||
datatypeDefaultLength,
|
||||
type CreateTableValues
|
||||
} from './apps/components/display/dbtable/queries/createTable'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { copyToClipboard } from '$lib/utils'
|
||||
import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
|
||||
const { onConfirm, resourceType, previewSql, dbSchema, currentSchema }: DBTableEditorProps =
|
||||
$props()
|
||||
|
||||
const columnTypes = DB_TYPES[resourceType]
|
||||
const defaultColumnType = (
|
||||
{
|
||||
postgresql: 'VARCHAR',
|
||||
snowflake: 'varchar',
|
||||
ms_sql_server: 'varchar',
|
||||
bigquery: 'string',
|
||||
mysql: 'varchar'
|
||||
} satisfies Record<DbType, string>
|
||||
)[resourceType]
|
||||
|
||||
const values: CreateTableValues = $state({
|
||||
name: '',
|
||||
columns: [],
|
||||
foreignKeys: []
|
||||
})
|
||||
|
||||
function addColumn({ name, primaryKey }: { name: string; primaryKey?: boolean }) {
|
||||
values.columns.push({
|
||||
name,
|
||||
datatype: defaultColumnType,
|
||||
...(datatypeHasLength(defaultColumnType) && {
|
||||
datatype_length: datatypeDefaultLength(defaultColumnType)
|
||||
}),
|
||||
...(primaryKey && { primaryKey })
|
||||
})
|
||||
}
|
||||
addColumn({ name: 'id', primaryKey: true })
|
||||
|
||||
const errors: ReturnType<typeof validate> = $derived(validate(values, dbSchema))
|
||||
|
||||
let askingForConfirmation:
|
||||
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void; codeContent?: string })
|
||||
| undefined = $state()
|
||||
|
||||
let darkMode = $state(false)
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex-1 overflow-y-auto flex flex-col gap-6">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
placeholder="my_table"
|
||||
class={errors?.name ? 'border !border-red-600/60' : ''}
|
||||
bind:value={values.name}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>Columns</label>
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Type</Cell>
|
||||
<Cell head last>Primary</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#each values.columns as column, i}
|
||||
<tr>
|
||||
<Cell first>
|
||||
<input
|
||||
type="text"
|
||||
class={'h-10 ' +
|
||||
(errors?.columns?.includes(column.name) ? 'border !border-red-600/60' : '')}
|
||||
style="height: 2rem;"
|
||||
placeholder="column_name"
|
||||
bind:value={column.name}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class="!w-48"
|
||||
value={column.datatype}
|
||||
on:change={(e) => {
|
||||
column.datatype = e.detail.value
|
||||
if (datatypeHasLength(column.datatype)) {
|
||||
column.datatype_length = datatypeDefaultLength(column.datatype)
|
||||
} else {
|
||||
column.datatype_length = undefined
|
||||
}
|
||||
}}
|
||||
items={columnTypes}
|
||||
clearable={false}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell last class="flex items-center mt-1.5">
|
||||
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.primaryKey} />
|
||||
<Popover class="ml-8" contentClasses="py-3 px-5 flex flex-col gap-6">
|
||||
{#snippet trigger()}
|
||||
<Settings size={18} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{#if datatypeHasLength(column.datatype)}
|
||||
<label class="text-xs">
|
||||
Length
|
||||
<input type="number" placeholder="0" bind:value={column.datatype_length} />
|
||||
</label>
|
||||
{/if}
|
||||
<label class="text-xs">
|
||||
<span class="flex gap-1 mb-1">
|
||||
Default value
|
||||
<Tooltip>
|
||||
<Info size={14} />
|
||||
{#snippet text()}
|
||||
Surround your expressions with curly brackets:
|
||||
<code>
|
||||
{'{NOW()}'}
|
||||
</code>.
|
||||
<br />
|
||||
By default, it will be parsed as a literal
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</span>
|
||||
<input type="text" placeholder="NULL" bind:value={column.defaultValue} />
|
||||
</label>
|
||||
{#if !column.primaryKey}
|
||||
<label class="flex gap-2 items-center text-xs">
|
||||
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.not_null} />
|
||||
Not nullable
|
||||
</label>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
<Button
|
||||
color="light"
|
||||
startIcon={{ icon: X }}
|
||||
wrapperClasses="w-fit ml-2"
|
||||
btnClasses="p-0"
|
||||
on:click={() => values.columns.splice(i, 1)}
|
||||
/>
|
||||
</Cell>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr class="w-full">
|
||||
<td colspan={99} class="p-1">
|
||||
<Button
|
||||
wrapperClasses="mx-auto"
|
||||
startIcon={{ icon: Plus }}
|
||||
color="light"
|
||||
on:click={() => addColumn({ name: '' })}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
<label>Foreign Keys</label>
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Table</Cell>
|
||||
<Cell head last>Columns</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#each values.foreignKeys as foreignKey, foreignKeyIndex}
|
||||
{@const fkErrors = errors?.foreignKeys?.[foreignKeyIndex]}
|
||||
<tr>
|
||||
<Cell first class="flex">
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class={twMerge('!w-48', fkErrors?.emptyTarget ? 'border !border-red-600/60' : '')}
|
||||
placeholder=""
|
||||
value={foreignKey.targetTable}
|
||||
on:change={(e) => (foreignKey.targetTable = e.detail.value)}
|
||||
items={getFlatTableNamesFromSchema(dbSchema).map((o) => ({
|
||||
value: o,
|
||||
label:
|
||||
(currentSchema && o.startsWith(currentSchema)) ||
|
||||
!dbSupportsSchemas(resourceType)
|
||||
? o.split('.')[1]
|
||||
: o
|
||||
}))}
|
||||
clearable={false}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each foreignKey.columns as column, columnIndex}
|
||||
<div class="flex">
|
||||
<div class="flex items-center gap-1 w-60">
|
||||
<!-- Div wrappers with absolute select are to prevent the Select content
|
||||
from overflowing -->
|
||||
<div class="grow h-[2rem] relative">
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class={twMerge(
|
||||
'!absolute inset-0',
|
||||
fkErrors?.nonExistingSourceColumns.includes(column.sourceColumn)
|
||||
? 'border !border-red-600/60'
|
||||
: ''
|
||||
)}
|
||||
placeholder=""
|
||||
value={column.sourceColumn}
|
||||
on:change={(e) => (column.sourceColumn = e.detail.value)}
|
||||
items={values.columns.map((c) => c.name)}
|
||||
clearable={false}
|
||||
/>
|
||||
</div>
|
||||
<ArrowRight size={16} class="h-fit shrink-0" />
|
||||
<div class="grow h-[2rem] relative">
|
||||
<Select
|
||||
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
|
||||
containerStyles={darkMode
|
||||
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
|
||||
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
|
||||
class={twMerge(
|
||||
'!absolute inset-0',
|
||||
fkErrors?.nonExistingTargetColumns.includes(column.targetColumn)
|
||||
? 'border !border-red-600/60'
|
||||
: ''
|
||||
)}
|
||||
placeholder=""
|
||||
value={column.targetColumn}
|
||||
on:change={(e) => (column.targetColumn = e.detail.value)}
|
||||
items={Object.keys(
|
||||
dbSchema?.schema?.[foreignKey.targetTable?.split('.')?.[0] ?? '']?.[
|
||||
foreignKey.targetTable?.split('.')[1]
|
||||
] ?? {}
|
||||
)}
|
||||
clearable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex">
|
||||
{#if columnIndex === 0}
|
||||
<Popover contentClasses="py-3 px-5 w-52 flex flex-col gap-6">
|
||||
{#snippet trigger()}
|
||||
<Settings size={18} />
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<span>
|
||||
ON DELETE <select bind:value={foreignKey.onDelete}>
|
||||
<option value="NO ACTION" selected>NO ACTION</option>
|
||||
<option value="CASCADE" selected>CASCADE</option>
|
||||
<option value="SET NULL" selected>SET NULL</option>
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
ON UPDATE <select bind:value={foreignKey.onUpdate}>
|
||||
<option value="NO ACTION" selected>NO ACTION</option>
|
||||
<option value="CASCADE" selected>CASCADE</option>
|
||||
<option value="SET NULL" selected>SET NULL</option>
|
||||
</select>
|
||||
</span>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
<Button
|
||||
color="light"
|
||||
startIcon={{ icon: X }}
|
||||
wrapperClasses="w-fit ml-2"
|
||||
btnClasses="p-0"
|
||||
on:click={foreignKey.columns.length > 1
|
||||
? () => foreignKey.columns.splice(columnIndex, 1)
|
||||
: () => values.foreignKeys.splice(foreignKeyIndex, 1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
<button
|
||||
class="w-60 border-dashed dark:border-gray-600 border-2 rounded-md flex justify-center items-center py-1 gap-2 text-primary-500 font-normal"
|
||||
onclick={() => foreignKey.columns.push({})}
|
||||
>
|
||||
<Plus class="h-fit" size={12} /> Add
|
||||
</button>
|
||||
</div></Cell
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
<tr class="w-full">
|
||||
<td colspan={99} class="p-1">
|
||||
<Button
|
||||
wrapperClasses="mx-auto"
|
||||
startIcon={{ icon: Plus }}
|
||||
color="light"
|
||||
on:click={() =>
|
||||
values.foreignKeys.push({
|
||||
columns: [{}],
|
||||
onDelete: 'NO ACTION',
|
||||
onUpdate: 'NO ACTION'
|
||||
})}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!!errors}
|
||||
on:click={() =>
|
||||
(askingForConfirmation = {
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
askingForConfirmation && (askingForConfirmation.loading = true)
|
||||
await onConfirm(values)
|
||||
sendUserToast(values.name + ' created!')
|
||||
} catch (e) {
|
||||
let msg: string | undefined = (e as Error)?.message
|
||||
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred'
|
||||
sendUserToast(msg, true)
|
||||
}
|
||||
askingForConfirmation = undefined
|
||||
},
|
||||
title: 'Confirm running the following:',
|
||||
confirmationText: 'Create ' + values.name,
|
||||
open: true,
|
||||
...(previewSql && { codeContent: previewSql(values) })
|
||||
})}>Create table</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<ConfirmationModal
|
||||
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
|
||||
on:canceled={() => (askingForConfirmation = undefined)}
|
||||
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
|
||||
>
|
||||
{#if askingForConfirmation?.codeContent}
|
||||
<div class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative">
|
||||
<code class="whitespace-pre-wrap">
|
||||
{askingForConfirmation.codeContent}
|
||||
</code>
|
||||
<Button
|
||||
on:click={() => copyToClipboard(askingForConfirmation?.codeContent)}
|
||||
size="xs"
|
||||
startIcon={{ icon: ClipboardCopy }}
|
||||
color="none"
|
||||
wrapperClasses="absolute z-10 top-0 right-0"
|
||||
></Button>
|
||||
</div>
|
||||
{/if}
|
||||
</ConfirmationModal>
|
||||
@@ -8,10 +8,11 @@
|
||||
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
|
||||
|
||||
import { Button } from './common'
|
||||
import DBSchemaExplorer from './DBSchemaExplorer.svelte'
|
||||
import DBManagerDrawerButton from './DBManagerDrawerButton.svelte'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
import { Pen, Plus, RotateCw } from 'lucide-svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { isDbType } from './apps/components/display/dbtable/utils'
|
||||
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -221,7 +222,7 @@
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{#if showSchemaExplorer}
|
||||
<DBSchemaExplorer {resourceType} resourcePath={value} />
|
||||
{#if showSchemaExplorer && isDbType(resourceType) && value}
|
||||
<DBManagerDrawerButton {resourceType} resourcePath={value} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { createGrid, type GridApi } from 'ag-grid-community'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
|
||||
type Props = {
|
||||
data: Record<string, any>[]
|
||||
class?: string
|
||||
}
|
||||
let { data, class: className }: Props = $props()
|
||||
let api: GridApi<any> | undefined = $state()
|
||||
let eGui: HTMLDivElement | undefined = $state()
|
||||
|
||||
$effect(() => eGui && mountGrid())
|
||||
function mountGrid() {
|
||||
if (eGui) {
|
||||
createGrid(eGui, {
|
||||
onGridReady: (e) => {
|
||||
api = e.api
|
||||
},
|
||||
autoSizeStrategy: { type: 'fitCellContents' },
|
||||
...updateGridOptions
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let columnDefs = $derived.by(() => {
|
||||
if (!data.length) return []
|
||||
const defs: { field: string }[] = []
|
||||
for (const row of data) {
|
||||
for (const key in row) {
|
||||
if (!defs.some((def) => def.field === key)) {
|
||||
defs.push({ field: key })
|
||||
}
|
||||
}
|
||||
}
|
||||
return defs
|
||||
})
|
||||
|
||||
let updateGridOptions = $derived({
|
||||
rowData: data.map((row) => {
|
||||
const newRow: Record<string, any> = {}
|
||||
for (const key in row) {
|
||||
newRow[key] = typeof row[key] === 'string' ? row[key].trim() : JSON.stringify(row[key])
|
||||
}
|
||||
return newRow
|
||||
}),
|
||||
columnDefs
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
api?.updateGridOptions(updateGridOptions)
|
||||
})
|
||||
|
||||
let darkMode = $state(false)
|
||||
let [clientHeight, clientWidth] = $state([0, 0])
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<div
|
||||
class={'flex flex-col h-full component-wrapper divide-y wm-aggrid-container ' +
|
||||
(api ? '' : 'opacity-0 ') +
|
||||
className}
|
||||
bind:clientHeight
|
||||
bind:clientWidth
|
||||
>
|
||||
<div
|
||||
style:height="{clientHeight}px"
|
||||
style:width="{clientWidth}px"
|
||||
class="ag-theme-alpine"
|
||||
class:ag-theme-alpine-dark={darkMode}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={eGui}
|
||||
style:height="100%"
|
||||
onkeydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
||||
const selectedCell = api?.getFocusedCell()
|
||||
if (selectedCell) {
|
||||
const rowIndex = selectedCell.rowIndex
|
||||
const colId = selectedCell.column?.getId()
|
||||
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
|
||||
const selectedValue = rowNode?.data?.[colId]
|
||||
navigator.clipboard.writeText(selectedValue)
|
||||
sendUserToast('Copied cell value to clipboard', false)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,178 @@
|
||||
<script module lang="ts">
|
||||
// code may be composed of many sql statements separated by ';'
|
||||
// this function splits them while taking into account that ';' may not
|
||||
// be the end of a statement (string or escaped)
|
||||
function splitSqlStatements(code: string) {
|
||||
const statements: string[] = []
|
||||
let currentStatement = ''
|
||||
let inSingleQuote = false
|
||||
let inDoubleQuote = false
|
||||
let inBacktick = false
|
||||
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const char = code[i]
|
||||
const prevChar = i > 0 ? code[i - 1] : null
|
||||
|
||||
if (char === "'" && !inDoubleQuote && !inBacktick && prevChar !== '\\') {
|
||||
inSingleQuote = !inSingleQuote
|
||||
} else if (char === '"' && !inSingleQuote && !inBacktick && prevChar !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
} else if (char === '`' && !inSingleQuote && !inDoubleQuote && prevChar !== '\\') {
|
||||
inBacktick = !inBacktick
|
||||
}
|
||||
|
||||
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inBacktick) {
|
||||
statements.push(currentStatement.trim())
|
||||
currentStatement = ''
|
||||
} else {
|
||||
currentStatement += char
|
||||
}
|
||||
}
|
||||
|
||||
if (currentStatement.trim()) {
|
||||
statements.push(currentStatement.trim())
|
||||
}
|
||||
|
||||
return statements
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { CornerDownLeft } from 'lucide-svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Editor from './Editor.svelte'
|
||||
import { runPreviewJobAndPollResult } from './jobs/utils'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { untrack } from 'svelte'
|
||||
import { getLanguageByResourceType } from './apps/components/display/dbtable/utils'
|
||||
import StepHistory, { type StepHistoryData } from './flows/propPicker/StepHistory.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
import { _ } from 'ag-grid-community'
|
||||
|
||||
type Props = {
|
||||
resourceType: string
|
||||
resourcePath: string
|
||||
onData: (data: Record<string, any>[]) => void
|
||||
placeholderTableName?: string
|
||||
}
|
||||
let { resourcePath, resourceType, onData, placeholderTableName }: Props = $props()
|
||||
|
||||
const DEFAULT_SQL = 'SELECT * FROM _'
|
||||
let code = $state(DEFAULT_SQL)
|
||||
$effect(() => {
|
||||
const _code = untrack(() => code)
|
||||
if (placeholderTableName && _code === DEFAULT_SQL) {
|
||||
code = _code.replace('_', placeholderTableName ?? 'table')
|
||||
}
|
||||
})
|
||||
let isRunning = $state(false)
|
||||
|
||||
let runHistory: (StepHistoryData & { code: string; result: Record<string, any>[] })[] = $state([])
|
||||
|
||||
async function run({ doPostgresRowToJsonFix }: { doPostgresRowToJsonFix?: boolean } = {}) {
|
||||
if (isRunning || !$workspaceStore) return
|
||||
isRunning = true
|
||||
try {
|
||||
const statements = splitSqlStatements(code)
|
||||
|
||||
// Transform all to JSON in case of select. This fixes the issue of
|
||||
// custom postgres enum type failing to convert to a rust type in the backend.
|
||||
// We don't always put the fix by default for row ordering concerns
|
||||
let transformedCode = code
|
||||
if (doPostgresRowToJsonFix) {
|
||||
transformedCode = statements
|
||||
.map((statement) => {
|
||||
if (statement.trim().toUpperCase().startsWith('SELECT')) {
|
||||
return `SELECT row_to_json(__t__) FROM (${statement}) __t__`
|
||||
}
|
||||
return statement
|
||||
})
|
||||
.join(';')
|
||||
}
|
||||
|
||||
let { job, result } = (await runPreviewJobAndPollResult(
|
||||
{
|
||||
workspace: $workspaceStore,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: transformedCode,
|
||||
args: {
|
||||
database: '$res:' + resourcePath
|
||||
}
|
||||
}
|
||||
},
|
||||
{ withJobData: true }
|
||||
)) as any
|
||||
if (statements.length > 1) {
|
||||
result = result[result.length - 1]
|
||||
}
|
||||
if (!Array.isArray(result)) {
|
||||
sendUserToast('Query result is not an array', true)
|
||||
return
|
||||
}
|
||||
|
||||
if (doPostgresRowToJsonFix) {
|
||||
result = result.map((row: any) => row['row_to_json'])
|
||||
}
|
||||
|
||||
if (statements[statements.length - 1].toUpperCase().trim().startsWith('SELECT')) {
|
||||
runHistory.push({
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: '',
|
||||
id: job.id,
|
||||
success: true,
|
||||
code,
|
||||
result
|
||||
})
|
||||
onData(result)
|
||||
}
|
||||
if (doPostgresRowToJsonFix)
|
||||
sendUserToast('Query failed but recovered with the row_to_json fix')
|
||||
else sendUserToast('Query executed')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (resourceType === 'postgresql' && !doPostgresRowToJsonFix) {
|
||||
console.error('Error running query, trying with row_to_json fix')
|
||||
isRunning = false
|
||||
return await run({ doPostgresRowToJsonFix: true })
|
||||
}
|
||||
sendUserToast('Error running query: ' + (e.message ?? e.error.message), true)
|
||||
} finally {
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
let editor = $state<Editor | null>(null)
|
||||
</script>
|
||||
|
||||
<Splitpanes>
|
||||
<Pane class="relative">
|
||||
<Editor
|
||||
bind:this={editor}
|
||||
bind:code
|
||||
lang="sql"
|
||||
scriptLang="mysql"
|
||||
class="w-full h-full"
|
||||
cmdEnterAction={run}
|
||||
/>
|
||||
<Button
|
||||
wrapperClasses="absolute z-10 bottom-2 right-6"
|
||||
color={isRunning ? 'red' : undefined}
|
||||
variant="border"
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
on:click={() => run()}
|
||||
>
|
||||
{isRunning ? 'Running...' : 'Run'}
|
||||
</Button>
|
||||
</Pane>
|
||||
<Pane size={24} minSize={16}>
|
||||
<StepHistory
|
||||
staticInputs={runHistory}
|
||||
on:select={(e) => {
|
||||
const data = e.detail as (typeof runHistory)[number]
|
||||
editor?.setCode(data.code)
|
||||
onData(data.result)
|
||||
}}
|
||||
/>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { Drawer, DrawerContent, Button } from '$lib/components/common'
|
||||
import { CornerDownLeft, Plus } from 'lucide-svelte'
|
||||
import InsertRow from './dbtable/InsertRow.svelte'
|
||||
import type { ColumnDef, DbType } from './dbtable/utils'
|
||||
|
||||
type Props = {
|
||||
columnDefs: ColumnDef[]
|
||||
dbType: DbType
|
||||
onInsert: (args: Record<string, any>) => void | Promise<void>
|
||||
}
|
||||
|
||||
let { columnDefs, dbType, onInsert }: Props = $props()
|
||||
|
||||
let args: Record<string, any> = $state({})
|
||||
let insertDrawer: Drawer | undefined = $state()
|
||||
let isInsertable = $state(false)
|
||||
|
||||
const onConfirm = async () => {
|
||||
await onInsert(args)
|
||||
insertDrawer?.closeDrawer()
|
||||
args = {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
onConfirm()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
startIcon={{ icon: Plus }}
|
||||
variant="border"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
args = {}
|
||||
insertDrawer?.openDrawer()
|
||||
}}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
|
||||
<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={onConfirm}
|
||||
disabled={!isInsertable}
|
||||
shortCut={{ Icon: CornerDownLeft }}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
</svelte:fragment>
|
||||
<InsertRow bind:args bind:isInsertable {columnDefs} {dbType} />
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -21,11 +21,7 @@
|
||||
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 '$lib/components/Portal.svelte'
|
||||
import { Drawer } from '$lib/components/common'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import type { AppInput, StaticInput } from '$lib/components/apps/inputType'
|
||||
@@ -41,6 +37,7 @@
|
||||
import { CancelablePromise } from '$lib/gen'
|
||||
import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte'
|
||||
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
|
||||
import InsertRowDrawerButton from '../InsertRowDrawerButton.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
@@ -173,8 +170,6 @@
|
||||
)
|
||||
}
|
||||
|
||||
let args: Record<string, any> = {}
|
||||
|
||||
let outputs = initOutput($worldStore, id, {
|
||||
selectedRowIndex: 0,
|
||||
selectedRow: {},
|
||||
@@ -189,7 +184,7 @@
|
||||
|
||||
let lastResource: string | undefined = undefined
|
||||
|
||||
function updateOneOfConfiguration<T, U extends string, V>(
|
||||
function updateOneOfConfiguration<U extends string, V>(
|
||||
oneOfConfiguration: OneOfConfiguration,
|
||||
resolvedConfig: {
|
||||
configuration: Record<U, V>
|
||||
@@ -249,7 +244,7 @@
|
||||
resolvedConfig.type.configuration[resolvedConfig?.type?.selected].resource.split(':')[1],
|
||||
$workspaceStore,
|
||||
dbSchemas,
|
||||
(message: string) => {}
|
||||
() => {}
|
||||
)
|
||||
|
||||
updateOneOfConfiguration(
|
||||
@@ -263,7 +258,7 @@
|
||||
resolvedConfig?.type?.selected,
|
||||
resource.split(':')[1],
|
||||
$workspaceStore!
|
||||
)
|
||||
)
|
||||
: [],
|
||||
loading: false
|
||||
}
|
||||
@@ -479,8 +474,6 @@
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
let isInsertable: boolean = false
|
||||
|
||||
$: $worldStore && render && connectToComponents()
|
||||
|
||||
function connectToComponents() {
|
||||
@@ -521,7 +514,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function insert() {
|
||||
async function insert(args: object) {
|
||||
try {
|
||||
const selected = resolvedConfig.type.selected
|
||||
|
||||
@@ -539,8 +532,6 @@
|
||||
} catch (e) {
|
||||
sendUserToast(e.message, true)
|
||||
}
|
||||
|
||||
args = {}
|
||||
}
|
||||
|
||||
let runnableComponent: RunnableComponent
|
||||
@@ -651,17 +642,11 @@
|
||||
<div class="flex flex-row gap-2">
|
||||
<RefreshButton {id} {loading} />
|
||||
{#if hideInsert !== true}
|
||||
<Button
|
||||
startIcon={{ icon: Plus }}
|
||||
color="dark"
|
||||
size="xs2"
|
||||
on:click={() => {
|
||||
args = {}
|
||||
insertDrawer?.openDrawer()
|
||||
}}
|
||||
>
|
||||
Insert
|
||||
</Button>
|
||||
<InsertRowDrawerButton
|
||||
columnDefs={resolvedConfig.columnDefs}
|
||||
dbType={resolvedConfig.type.selected}
|
||||
onInsert={(args) => insert(args)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,7 +681,7 @@
|
||||
</div>
|
||||
</RunnableWrapper>
|
||||
|
||||
<Portal name="db-explorer">
|
||||
<!-- <Portal name="db-explorer">
|
||||
<Drawer bind:this={insertDrawer} size="800px">
|
||||
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
|
||||
<svelte:fragment slot="actions">
|
||||
@@ -710,4 +695,4 @@
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
</Portal>
|
||||
</Portal> -->
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
|
||||
import { buildParameters, type DbType } from '../utils'
|
||||
import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils'
|
||||
|
||||
function makeCountQuery(
|
||||
export function makeCountQuery(
|
||||
dbType: DbType,
|
||||
table: string,
|
||||
whereClause: string | undefined = undefined,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { dbSupportsSchemas, type DbType } from '../utils'
|
||||
|
||||
export type CreateTableValues = {
|
||||
name: string
|
||||
columns: CreateTableValuesColumn[]
|
||||
foreignKeys: {
|
||||
targetTable?: string
|
||||
columns: {
|
||||
sourceColumn?: string
|
||||
targetColumn?: string
|
||||
}[]
|
||||
onDelete: 'CASCADE' | 'SET NULL' | 'NO ACTION'
|
||||
onUpdate: 'CASCADE' | 'SET NULL' | 'NO ACTION'
|
||||
}[]
|
||||
}
|
||||
|
||||
type CreateTableValuesColumn = {
|
||||
name: string
|
||||
datatype: string
|
||||
primaryKey?: boolean
|
||||
defaultValue?: string
|
||||
not_null?: boolean
|
||||
datatype_length?: number // e.g varchar(255)
|
||||
}
|
||||
|
||||
export function makeCreateTableQuery(
|
||||
values: CreateTableValues,
|
||||
resourceType: DbType,
|
||||
schema?: string
|
||||
) {
|
||||
const pkCount = values.columns.reduce((p, c) => p + (c.primaryKey ? 1 : 0), 0)
|
||||
|
||||
function transformColumn(c: CreateTableValuesColumn): string {
|
||||
const datatype = c.datatype_length ? `${c.datatype}(${c.datatype_length})` : c.datatype
|
||||
const defValue = c.defaultValue && formatDefaultValue(c.defaultValue, datatype, resourceType)
|
||||
|
||||
let str = ` ${c.name} ${datatype}`
|
||||
if (c.not_null) str += ' NOT NULL'
|
||||
if (defValue) str += ` DEFAULT ${defValue}`
|
||||
if (pkCount === 1 && c.primaryKey) str += ' PRIMARY KEY'
|
||||
return str
|
||||
}
|
||||
|
||||
function transformFk(fk: CreateTableValues['foreignKeys'][number]): string {
|
||||
const sourceColumns = fk.columns.map((c) => c.sourceColumn).filter(Boolean)
|
||||
const targetColumns = fk.columns.map((c) => c.targetColumn).filter(Boolean)
|
||||
const targetTable =
|
||||
useSchema || !fk.targetTable?.includes('.')
|
||||
? fk.targetTable
|
||||
: fk.targetTable?.split('.').pop()
|
||||
|
||||
let l = ` FOREIGN KEY (${sourceColumns.join(', ')}) REFERENCES ${targetTable} (${targetColumns.join(
|
||||
', '
|
||||
)})`
|
||||
if (fk.onDelete !== 'NO ACTION') l += ` ON DELETE ${fk.onDelete}`
|
||||
if (fk.onUpdate !== 'NO ACTION') l += ` ON UPDATE ${fk.onUpdate}`
|
||||
return l
|
||||
}
|
||||
|
||||
const useSchema = dbSupportsSchemas(resourceType)
|
||||
|
||||
const lines = values.columns.map(transformColumn)
|
||||
lines.push(...values.foreignKeys.map(transformFk))
|
||||
if (pkCount > 1) {
|
||||
const pks = values.columns.filter((c) => c.primaryKey)
|
||||
lines.push(` PRIMARY KEY (${pks.map((c) => c.name).join(', ')})`)
|
||||
}
|
||||
|
||||
return `CREATE TABLE ${useSchema && schema ? schema.trim() + '.' : ''}${values.name.trim()} (
|
||||
${lines.join(',\n')}
|
||||
);`
|
||||
}
|
||||
|
||||
function formatDefaultValue(str: string, datatype: string, resourceType: DbType): string {
|
||||
if (!str) return ''
|
||||
if (str.startsWith('{') && str.endsWith('}')) {
|
||||
return str.slice(1, str.length - 1)
|
||||
}
|
||||
if (resourceType === 'postgresql') {
|
||||
return `CAST('${str}' AS ${datatype})`
|
||||
}
|
||||
return `'${str}'`
|
||||
}
|
||||
|
||||
export function datatypeDefaultLength(datatype: string): number {
|
||||
datatype = datatype.toLowerCase()
|
||||
if (datatype == 'bit') return 1
|
||||
if (['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary'].includes(datatype)) {
|
||||
return 255
|
||||
} else {
|
||||
return 10
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
|
||||
import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils'
|
||||
|
||||
function deleteWithAllValues(table: string, columns: ColumnDef[], dbType: DbType) {
|
||||
export function makeDeleteQuery(table: string, columns: ColumnDef[], dbType: DbType) {
|
||||
let query = buildParameters(
|
||||
dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns,
|
||||
dbType
|
||||
@@ -71,7 +71,7 @@ export function getDeleteInput(
|
||||
name: 'AppDbExplorer',
|
||||
type: 'runnableByName',
|
||||
inlineScript: {
|
||||
content: deleteWithAllValues(table, columns, dbType),
|
||||
content: makeDeleteQuery(table, columns, dbType),
|
||||
language: getLanguageByResourceType(dbType),
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { DbType } from '../utils'
|
||||
|
||||
export function makeDeleteTableQuery(tableKey: string, resourceType: DbType): string {
|
||||
// same for all sql dbs
|
||||
return `DROP TABLE ${tableKey};`
|
||||
}
|
||||
@@ -79,7 +79,7 @@ function makeSnowflakeSelectQuery(
|
||||
return query
|
||||
}
|
||||
|
||||
function makeSelectQuery(
|
||||
export function makeSelectQuery(
|
||||
table: string,
|
||||
columnDefs: ColumnDef[],
|
||||
whereClause: string | undefined,
|
||||
@@ -155,6 +155,9 @@ CASE WHEN :order_by = '${column.field}' AND :is_desc IS true THEN \`${column.fie
|
||||
case 'ms_sql_server':
|
||||
// MSSQL uses CONCAT for string concatenation and supports OFFSET FETCH for pagination
|
||||
// Note: MSSQL does not have a built-in ILIKE function, so we use LIKE with a case-insensitive collation if needed
|
||||
//
|
||||
// Note 2: CONCAT in mssql requires 2 to 254 arguments. But we can't change this query without breaking
|
||||
// existing policies
|
||||
const orderBy = columnDefs
|
||||
.map((column) => {
|
||||
return `
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
|
||||
import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils'
|
||||
|
||||
function updateWithAllValues(
|
||||
export function makeUpdateQuery(
|
||||
table: string,
|
||||
column: ColumnDef,
|
||||
columns: ColumnDef[],
|
||||
column: { datatype: string; field: string },
|
||||
columns: { datatype: string; field: string }[],
|
||||
dbType: DbType
|
||||
) {
|
||||
let query = buildParameters(
|
||||
[
|
||||
{
|
||||
field: 'value_to_update',
|
||||
datatype: column.datatype
|
||||
},
|
||||
{ field: 'value_to_update', datatype: column.datatype },
|
||||
...(dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns)
|
||||
],
|
||||
dbType
|
||||
@@ -85,7 +82,7 @@ export function getUpdateInput(
|
||||
name: 'AppDbExplorer',
|
||||
type: 'runnableByName',
|
||||
inlineScript: {
|
||||
content: updateWithAllValues(table, column, columns, dbType),
|
||||
content: makeUpdateQuery(table, column, columns, dbType),
|
||||
language: getLanguageByResourceType(dbType),
|
||||
schema: {
|
||||
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'graphql'
|
||||
import { tryEvery } from '$lib/utils'
|
||||
import { stringifySchema } from '$lib/components/copilot/lib'
|
||||
import { runPreviewJobAndPollResult } from '$lib/components/jobs/utils'
|
||||
|
||||
export enum ColumnIdentity {
|
||||
ByDefault = 'By Default',
|
||||
@@ -61,15 +62,99 @@ export async function loadTableMetaData(
|
||||
if (!resource || !table || !workspace) {
|
||||
return undefined
|
||||
}
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: await makeLoadTableMetaDataQuery(resource, workspace, table, resourceType),
|
||||
args: {
|
||||
database: resource
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let code: string = ''
|
||||
const maxRetries = 8
|
||||
let attempts = 0
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempts || 0.6)))
|
||||
|
||||
const testResult = (await JobService.getCompletedJob({
|
||||
workspace: workspace,
|
||||
id: job
|
||||
})) as any
|
||||
|
||||
if (testResult.success) {
|
||||
attempts = maxRetries
|
||||
|
||||
if (resourceType === 'ms_sql_server') {
|
||||
return testResult.result[0].map(lowercaseKeys)
|
||||
} else {
|
||||
return testResult.result.map(lowercaseKeys)
|
||||
}
|
||||
} else {
|
||||
attempts++
|
||||
}
|
||||
} catch (error) {
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
console.error('Failed to load table metadata after maximum retries.')
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadAllTablesMetaData(
|
||||
resource: string,
|
||||
workspace: string | undefined,
|
||||
resourceType: string
|
||||
): Promise<Record<string, TableMetadata> | undefined> {
|
||||
if (!resource || !workspace) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
let result = (await runPreviewJobAndPollResult({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: await makeLoadTableMetaDataQuery(resource, workspace, undefined, resourceType),
|
||||
args: {
|
||||
database: resource
|
||||
}
|
||||
}
|
||||
})) as ({ table_name: string; schema_name?: string } & object)[]
|
||||
if (resourceType === 'ms_sql_server') {
|
||||
result = (result as any)[0]
|
||||
}
|
||||
const map: Record<string, TableMetadata> = {}
|
||||
|
||||
for (const _col of result) {
|
||||
const col = lowercaseKeys(_col)
|
||||
const tableKey = col.schema_name ? `${col.schema_name}.${col.table_name}` : col.table_name
|
||||
if (!(tableKey in map)) {
|
||||
map[tableKey] = []
|
||||
}
|
||||
map[tableKey].push(col)
|
||||
}
|
||||
return map
|
||||
} catch (e) {
|
||||
throw new Error('Error loading all tables metadata: ' + e)
|
||||
}
|
||||
}
|
||||
|
||||
async function makeLoadTableMetaDataQuery(
|
||||
resource: string,
|
||||
workspace: string,
|
||||
table: string | undefined,
|
||||
resourceType: string
|
||||
): Promise<string> {
|
||||
if (resourceType === 'mysql') {
|
||||
const resourceObj = (await ResourceService.getResourceValue({
|
||||
workspace,
|
||||
path: resource.split(':')[1]
|
||||
})) as any
|
||||
code = `
|
||||
return `
|
||||
SELECT
|
||||
COLUMN_NAME as field,
|
||||
COLUMN_TYPE as DataType,
|
||||
@@ -77,19 +162,31 @@ export async function loadTableMetaData(
|
||||
CASE WHEN COLUMN_KEY = 'PRI' THEN 'YES' ELSE 'NO' END as IsPrimaryKey,
|
||||
CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity,
|
||||
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
TABLE_NAME as table_name`
|
||||
}
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE
|
||||
INFORMATION_SCHEMA.COLUMNS${
|
||||
table
|
||||
? `
|
||||
WHERE
|
||||
TABLE_NAME = '${table.split('.').reverse()[0]}' AND TABLE_SCHEMA = '${
|
||||
table.split('.').reverse()[1] ?? resourceObj?.database ?? ''
|
||||
}'
|
||||
ORDER BY
|
||||
table.split('.').reverse()[1] ?? resourceObj?.database ?? ''
|
||||
}'`
|
||||
: `
|
||||
WHERE
|
||||
TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')`
|
||||
}
|
||||
ORDER BY
|
||||
TABLE_NAME,
|
||||
ORDINAL_POSITION;
|
||||
`
|
||||
} else if (resourceType === 'postgresql') {
|
||||
code = `
|
||||
SELECT
|
||||
return `
|
||||
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)
|
||||
@@ -110,17 +207,32 @@ export async function loadTableMetaData(
|
||||
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 e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
ns.nspname AS schema_name,
|
||||
c.relname AS table_name`
|
||||
}
|
||||
FROM pg_catalog.pg_attribute a${
|
||||
table
|
||||
? `
|
||||
WHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '${
|
||||
table.split('.').reverse()[0]
|
||||
}' AND ns.nspname = '${table.split('.').reverse()[1] ?? 'public'}')
|
||||
AND a.attnum > 0 AND NOT a.attisdropped
|
||||
ORDER BY a.attnum;
|
||||
`
|
||||
: `
|
||||
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
|
||||
WHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped
|
||||
AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'`
|
||||
}
|
||||
ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'};
|
||||
|
||||
`
|
||||
} else if (resourceType === 'ms_sql_server') {
|
||||
code = `
|
||||
return `
|
||||
SELECT
|
||||
COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
@@ -128,32 +240,52 @@ export async function loadTableMetaData(
|
||||
CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity,
|
||||
CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as IsPrimaryKey, -- This line still needs correction for primary key identification
|
||||
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
TABLE_NAME as table_name`
|
||||
}
|
||||
FROM
|
||||
INFORMATION_SCHEMA.COLUMNS
|
||||
INFORMATION_SCHEMA.COLUMNS${
|
||||
table
|
||||
? `
|
||||
WHERE
|
||||
TABLE_NAME = '${table}'
|
||||
TABLE_NAME = '${table}'`
|
||||
: ''
|
||||
}
|
||||
ORDER BY
|
||||
ORDINAL_POSITION;
|
||||
|
||||
`
|
||||
} else if (resourceType === 'snowflake' || resourceType === 'snowflake_oauth') {
|
||||
code = `
|
||||
return `
|
||||
select COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
COLUMN_DEFAULT as DefaultValue,
|
||||
CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity,
|
||||
CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 1 ELSE 0 END as IsPrimaryKey,
|
||||
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum
|
||||
from information_schema.columns
|
||||
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${
|
||||
table
|
||||
? ''
|
||||
: `,
|
||||
table_name as table_name,
|
||||
table_schema as schema_name`
|
||||
}
|
||||
from information_schema.columns${
|
||||
table
|
||||
? `
|
||||
where table_name = '${table.split('.').reverse()[0]}' and table_schema = '${
|
||||
table.split('.').reverse()[1] ?? 'PUBLIC'
|
||||
}'
|
||||
table.split('.').reverse()[1] ?? 'PUBLIC'
|
||||
}'`
|
||||
: "\nwhere table_schema <> 'INFORMATION_SCHEMA'\n"
|
||||
}
|
||||
order by ORDINAL_POSITION;
|
||||
`
|
||||
} else if (resourceType === 'bigquery') {
|
||||
code = `SELECT
|
||||
// TODO: find a solution for this (query uses hardcoded dataset name)
|
||||
if (!table) throw new Error('Table name is required for BigQuery')
|
||||
return `SELECT
|
||||
c.COLUMN_NAME as field,
|
||||
DATA_TYPE as DataType,
|
||||
CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,
|
||||
@@ -164,58 +296,14 @@ ORDER BY
|
||||
FROM
|
||||
${table.split('.')[0]}.INFORMATION_SCHEMA.COLUMNS c
|
||||
LEFT JOIN
|
||||
test_dataset.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
|
||||
${table.split('.')[0]}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
|
||||
on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME
|
||||
WHERE
|
||||
c.TABLE_NAME = "${table.split('.')[1]}"
|
||||
c.TABLE_NAME = '${table.split('.')[1]}'
|
||||
order by c.ORDINAL_POSITION;`
|
||||
} else {
|
||||
throw new Error('Unsupported database type:' + resourceType)
|
||||
}
|
||||
|
||||
const maxRetries = 3
|
||||
let attempts = 0
|
||||
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
const job = await JobService.runScriptPreview({
|
||||
workspace: workspace,
|
||||
requestBody: {
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: code,
|
||||
args: {
|
||||
database: resource
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000))
|
||||
|
||||
const testResult = (await JobService.getCompletedJob({
|
||||
workspace: workspace,
|
||||
id: job
|
||||
})) as any
|
||||
|
||||
if (testResult.success) {
|
||||
attempts = maxRetries
|
||||
|
||||
if (resourceType === 'ms_sql_server') {
|
||||
return lowercaseKeys(testResult.result[0])
|
||||
} else {
|
||||
return lowercaseKeys(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) {
|
||||
@@ -226,14 +314,12 @@ export function resourceTypeToLang(rt: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function lowercaseKeys(arr: Array<Record<string, any>>): Array<any> {
|
||||
return arr.map((obj) => {
|
||||
const newObj = {}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
newObj[key.toLowerCase()] = obj[key]
|
||||
})
|
||||
return newObj
|
||||
function lowercaseKeys(obj: Record<string, any>): any {
|
||||
const newObj = {}
|
||||
Object.keys(obj).forEach((key) => {
|
||||
newObj[key.toLowerCase()] = obj[key]
|
||||
})
|
||||
return newObj
|
||||
}
|
||||
|
||||
const scripts: Record<
|
||||
@@ -569,7 +655,9 @@ export function formatGraphqlSchema(schema: IntrospectionQuery): string {
|
||||
return printSchema(buildClientSchema(schema))
|
||||
}
|
||||
|
||||
export type DbType = 'mysql' | 'ms_sql_server' | 'postgresql' | 'snowflake' | 'bigquery'
|
||||
export type DbType = (typeof dbTypes)[number]
|
||||
export const dbTypes = ['mysql', 'ms_sql_server', 'postgresql', 'snowflake', 'bigquery'] as const
|
||||
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
|
||||
|
||||
export function buildVisibleFieldList(columnDefs: ColumnDef[], dbType: DbType) {
|
||||
// Filter out hidden columns to avoid counting the wrong number of rows
|
||||
@@ -714,3 +802,13 @@ export async function getTablesByResource(
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function dbSupportsSchemas(dbType: DbType): boolean {
|
||||
return dbType === 'postgresql' || dbType === 'snowflake' || dbType === 'bigquery'
|
||||
}
|
||||
|
||||
export function datatypeHasLength(datatype: string): boolean {
|
||||
datatype = datatype.toLowerCase()
|
||||
const lengthDataTypes = ['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary', 'bit']
|
||||
return lengthDataTypes.some((type) => datatype === type)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
* 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'
|
||||
import type { ColDef, ColGroupDef, ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
|
||||
import { ColumnIdentity, type ColumnDef } from '../dbtable/utils'
|
||||
import type { TableAction } from '$lib/components/apps/editor/component'
|
||||
import { mount, unmount } from 'svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Trash2 } from 'lucide-svelte'
|
||||
|
||||
/**
|
||||
* Class for defining a cell renderer.
|
||||
@@ -110,3 +115,139 @@ export function defaultCellRenderer(cellRendererType: string) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function transformColumnDefs({
|
||||
columnDefs,
|
||||
actions,
|
||||
customActionsHeader,
|
||||
wrapActions,
|
||||
tableActionsFactory,
|
||||
onDelete,
|
||||
onInvalidColumnDefs
|
||||
}: {
|
||||
columnDefs: ColumnDef[]
|
||||
actions?: TableAction[]
|
||||
customActionsHeader?: string
|
||||
wrapActions?: boolean
|
||||
tableActionsFactory?: ReturnType<typeof cellRendererFactory>
|
||||
onDelete?: (values: object) => void
|
||||
onInvalidColumnDefs?: (errors: string[]) => void
|
||||
}): (ColDef<unknown, any> | ColGroupDef<unknown>)[] {
|
||||
if (!columnDefs) {
|
||||
return []
|
||||
}
|
||||
|
||||
const { isValid, errors } = validateColumnDefs(columnDefs)
|
||||
|
||||
if (!isValid) {
|
||||
onInvalidColumnDefs?.(errors)
|
||||
return []
|
||||
}
|
||||
|
||||
let r: any[] = columnDefs?.filter((x) => x && !x.ignored) ?? []
|
||||
|
||||
if (onDelete) {
|
||||
r.push({
|
||||
field: 'delete',
|
||||
headerName: 'Delete',
|
||||
cellRenderer: cellRendererFactory((c, p) => {
|
||||
const btnComponent = mount(Button, {
|
||||
target: c.eGui,
|
||||
props: {
|
||||
btnClasses: 'w-12',
|
||||
wrapperClasses: 'flex justify-end items-center h-full',
|
||||
color: 'light',
|
||||
size: 'sm',
|
||||
variant: 'contained',
|
||||
iconOnly: true,
|
||||
startIcon: { icon: Trash2 },
|
||||
nonCaptureEvent: true
|
||||
}
|
||||
})
|
||||
return {
|
||||
destroy: () => {
|
||||
unmount(btnComponent)
|
||||
},
|
||||
refresh(params) {
|
||||
//
|
||||
}
|
||||
}
|
||||
}),
|
||||
cellRendererParams: {
|
||||
onClick: (e) => {
|
||||
onDelete?.(e)
|
||||
}
|
||||
},
|
||||
lockPosition: 'right',
|
||||
editable: false,
|
||||
flex: 0,
|
||||
width: 100
|
||||
})
|
||||
}
|
||||
|
||||
if (actions?.length) {
|
||||
r.push({
|
||||
headerName: customActionsHeader ?? 'Actions',
|
||||
cellRenderer: tableActionsFactory,
|
||||
autoHeight: true,
|
||||
cellStyle: { textAlign: 'center' },
|
||||
cellClass: 'grid-cell-centered',
|
||||
lockPosition: 'right',
|
||||
|
||||
...(!wrapActions ? { minWidth: 130 * actions?.length } : {})
|
||||
})
|
||||
}
|
||||
|
||||
return r.map((fields) => {
|
||||
let cr = defaultCellRenderer(fields.cellRendererType)
|
||||
return {
|
||||
...fields,
|
||||
...(cr ? { cellRenderer: cr } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function validateColumnDefs(columnDefs: ColumnDef[]): {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
let isValid = true
|
||||
const errors: string[] = []
|
||||
|
||||
if (!Array.isArray(columnDefs)) {
|
||||
return { isValid: false, errors: ['Column definitions must be an array.'] }
|
||||
}
|
||||
|
||||
// Validate each column definition
|
||||
columnDefs.forEach((colDef, index) => {
|
||||
let noField = !colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === ''
|
||||
|
||||
if (
|
||||
(colDef.isidentity === ColumnIdentity.ByDefault ||
|
||||
colDef.isidentity === ColumnIdentity.Always) &&
|
||||
colDef.hideInsert == undefined
|
||||
) {
|
||||
colDef.hideInsert = true
|
||||
}
|
||||
|
||||
// Check if 'field' property exists and is a non-empty string
|
||||
if (noField && !(colDef.children && Array.isArray(colDef.children))) {
|
||||
isValid = false
|
||||
errors.push(
|
||||
`Column at index ${index} is missing a valid 'field' property nor having any children.`
|
||||
)
|
||||
}
|
||||
|
||||
if (colDef.children && Array.isArray(colDef.children)) {
|
||||
const { isValid: isChildrenValid, errors: childrenErrors } = validateColumnDefs(
|
||||
colDef.children
|
||||
)
|
||||
if (!isChildrenValid) {
|
||||
isValid = false
|
||||
errors.push(...childrenErrors.map((err) => `Error in children at index ${index}: ${err}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { isValid, errors }
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@
|
||||
|
||||
export let noBg = false
|
||||
export let small: boolean = false
|
||||
export let Icon: any | undefined = undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Button
|
||||
on:click={() => dispatch('close')}
|
||||
startIcon={{ icon: X }}
|
||||
startIcon={{ icon: Icon ?? X }}
|
||||
iconOnly
|
||||
size="sm"
|
||||
color="light"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
export let forceOverflowVisible = false
|
||||
export let tooltip: string = ''
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let CloseIcon: any | undefined = undefined
|
||||
|
||||
export let fullScreen: boolean = true
|
||||
</script>
|
||||
@@ -16,7 +17,7 @@
|
||||
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
|
||||
<div class="flex justify-between w-full items-center px-4 py-2 gap-2">
|
||||
<div class="flex items-center gap-2 w-full truncate">
|
||||
<CloseButton on:close />
|
||||
<CloseButton on:close Icon={CloseIcon} />
|
||||
|
||||
<span class="font-semibold truncate text-primary !text-lg max-w-sm"
|
||||
>{title ?? ''}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
getLanguageByResourceType,
|
||||
type ColumnDef,
|
||||
type DbType
|
||||
} from './apps/components/display/dbtable/utils'
|
||||
import { makeSelectQuery } from './apps/components/display/dbtable/queries/select'
|
||||
import { runPreviewJobAndPollResult } from './jobs/utils'
|
||||
import { makeCountQuery } from './apps/components/display/dbtable/queries/count'
|
||||
import { makeUpdateQuery } from './apps/components/display/dbtable/queries/update'
|
||||
import { makeDeleteQuery } from './apps/components/display/dbtable/queries/delete'
|
||||
import { makeInsertQuery } from './apps/components/display/dbtable/queries/insert'
|
||||
import { Trash2 } from 'lucide-svelte'
|
||||
import { makeDeleteTableQuery } from './apps/components/display/dbtable/queries/deleteTable'
|
||||
|
||||
export type IDbTableOps = {
|
||||
resourcePath: string
|
||||
resourceType: DbType
|
||||
tableKey: string
|
||||
colDefs: ColumnDef[]
|
||||
|
||||
getRows: (params: {
|
||||
offset: number
|
||||
limit: number
|
||||
quicksearch: string
|
||||
order_by: string
|
||||
is_desc: boolean
|
||||
}) => Promise<unknown[]>
|
||||
getCount: (params: { quicksearch: string }) => Promise<number>
|
||||
onUpdate?: (
|
||||
row: { values: object },
|
||||
colDef: { field: string; datatype: string },
|
||||
newValue: string
|
||||
) => Promise<void>
|
||||
onDelete?: (row: { values: object }) => Promise<void>
|
||||
onInsert?: (row: { values: object }) => Promise<void>
|
||||
}
|
||||
|
||||
export function dbTableOpsWithPreviewScripts({
|
||||
resourcePath,
|
||||
resourceType,
|
||||
tableKey,
|
||||
colDefs,
|
||||
workspace
|
||||
}: {
|
||||
resourcePath: string
|
||||
resourceType: DbType
|
||||
tableKey: string
|
||||
colDefs: ColumnDef[]
|
||||
workspace: string
|
||||
}): IDbTableOps {
|
||||
return {
|
||||
resourcePath,
|
||||
resourceType,
|
||||
tableKey,
|
||||
colDefs,
|
||||
getCount: async ({ quicksearch }) => {
|
||||
const countQuery = makeCountQuery(resourceType, tableKey, undefined, colDefs)
|
||||
const result = await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, quicksearch },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: countQuery
|
||||
}
|
||||
})
|
||||
const count = result?.[0].count as number
|
||||
return count
|
||||
},
|
||||
getRows: async (params) => {
|
||||
const query = makeSelectQuery(tableKey, colDefs, undefined, resourceType as DbType)
|
||||
let items = (await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, ...params },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: query
|
||||
}
|
||||
})) as unknown[]
|
||||
if (resourceType === 'ms_sql_server') items = items?.[0] as unknown[]
|
||||
if (!items || !Array.isArray(items)) {
|
||||
throw 'items is not an array'
|
||||
}
|
||||
return items
|
||||
},
|
||||
onUpdate: async ({ values }, colDef, newValue) => {
|
||||
const updateQuery = makeUpdateQuery(tableKey, colDef, colDefs, resourceType)
|
||||
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: {
|
||||
database: '$res:' + resourcePath,
|
||||
value_to_update: newValue,
|
||||
...values
|
||||
},
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: updateQuery
|
||||
}
|
||||
})
|
||||
},
|
||||
onDelete: async ({ values }) => {
|
||||
const deleteQuery = makeDeleteQuery(tableKey, colDefs, resourceType)
|
||||
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, ...values },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: deleteQuery
|
||||
}
|
||||
})
|
||||
},
|
||||
onInsert: async ({ values }) => {
|
||||
const insertQuery = makeInsertQuery(tableKey, colDefs, resourceType)
|
||||
runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath, ...values },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: insertQuery
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type DbTableAction = {
|
||||
action: () => void | Promise<void>
|
||||
displayName: string
|
||||
confirmTitle?: string
|
||||
confirmBtnText?: string
|
||||
icon?: any
|
||||
successText?: string
|
||||
}
|
||||
|
||||
export type DbTableActionFactory = (params: {
|
||||
tableKey: string
|
||||
refresh: () => void
|
||||
}) => DbTableAction
|
||||
|
||||
export function dbDeleteTableActionWithPreviewScript({
|
||||
workspace,
|
||||
resourcePath,
|
||||
resourceType
|
||||
}: {
|
||||
workspace: string
|
||||
resourcePath: string
|
||||
resourceType: DbType
|
||||
}): DbTableActionFactory {
|
||||
return ({ tableKey, refresh }) => ({
|
||||
confirmTitle: `Are you sure you want to delete '${tableKey}' ? This action is irreversible`,
|
||||
displayName: 'Delete',
|
||||
confirmBtnText: `Delete permanently`,
|
||||
icon: Trash2,
|
||||
successText: `Table '${tableKey}' deleted successfully`,
|
||||
action: async () => {
|
||||
const deleteQuery = makeDeleteTableQuery(tableKey, resourceType)
|
||||
await runPreviewJobAndPollResult({
|
||||
workspace,
|
||||
requestBody: {
|
||||
args: { database: '$res:' + resourcePath },
|
||||
language: getLanguageByResourceType(resourceType),
|
||||
content: deleteQuery
|
||||
}
|
||||
})
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,12 @@
|
||||
<script module lang="ts">
|
||||
export type StepHistoryData = {
|
||||
id: string
|
||||
created_at: string
|
||||
created_by: string
|
||||
success: boolean
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import InfiniteList from '$lib/components/InfiniteList.svelte'
|
||||
import { JobService } from '$lib/gen'
|
||||
@@ -14,16 +23,20 @@
|
||||
export let mockValue: any = undefined
|
||||
export let mockEnabled: boolean = false
|
||||
export let path: string = ''
|
||||
export let staticInputs: undefined | StepHistoryData[] = undefined
|
||||
export let noHistory: 'isLoop' | 'isInsideLoop' | undefined = undefined
|
||||
|
||||
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext')
|
||||
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext') ?? {}
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let infiniteList: InfiniteList | undefined = undefined
|
||||
let loadInputsPageFn: ((page: number, perPage: number) => Promise<any>) | undefined = undefined
|
||||
let loadInputsPageFn:
|
||||
| ((page: number, perPage: number) => Promise<StepHistoryData[]>)
|
||||
| undefined = undefined
|
||||
|
||||
function initLoadInputs() {
|
||||
loadInputsPageFn = async (page: number, perPage: number) => {
|
||||
if (staticInputs) return staticInputs
|
||||
const previousJobs = await JobService.listCompletedJobs({
|
||||
workspace: $workspaceStore!,
|
||||
scriptPathExact: path === '' ? $pathStore + '/' + moduleId : path,
|
||||
@@ -42,6 +55,8 @@
|
||||
infiniteList?.setLoader(loadInputsPageFn)
|
||||
}
|
||||
|
||||
$: staticInputs && infiniteList?.loadData('forceRefresh')
|
||||
|
||||
async function getJobResultAndLogs(jobId: string, noLogs: boolean) {
|
||||
try {
|
||||
const job = await JobService.getJob({
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { JobService, type RunScriptPreviewData } from '$lib/gen'
|
||||
|
||||
export async function runPreviewJobAndPollResult(
|
||||
data: RunScriptPreviewData,
|
||||
{ maxRetries = 7, withJobData }: { maxRetries?: number; withJobData?: boolean } = {}
|
||||
): Promise<unknown> {
|
||||
const uuid = await JobService.runScriptPreview(data)
|
||||
|
||||
let attempts = 0
|
||||
while (attempts < maxRetries) {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * (attempts || 0.75)))
|
||||
const job = await JobService.getCompletedJobResultMaybe({
|
||||
id: uuid,
|
||||
workspace: data.workspace
|
||||
})
|
||||
if (job.success) {
|
||||
if (withJobData) {
|
||||
return { job: { id: uuid }, result: job.result }
|
||||
} else {
|
||||
return job.result as any
|
||||
}
|
||||
} else if (job.completed) {
|
||||
attempts = maxRetries
|
||||
let errorMsg: string | undefined = (job?.result as any)?.error?.message
|
||||
if (typeof errorMsg !== 'string') errorMsg = undefined
|
||||
console.error('JOB FAILED', job.result)
|
||||
throw new Error(errorMsg ?? 'Job failed')
|
||||
}
|
||||
} catch (e) {
|
||||
if (attempts == maxRetries) {
|
||||
throw e
|
||||
}
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
throw 'Could not get job result, should not get here'
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { DbType } from './components/apps/components/display/dbtable/utils'
|
||||
|
||||
export const DEFAULT_WEBHOOK_TYPE: 'async' | 'sync' = 'async'
|
||||
|
||||
export const HOME_SHOW_HUB = true
|
||||
@@ -201,3 +203,11 @@ export const MSSQL_TYPES = [
|
||||
'decimal',
|
||||
'bit'
|
||||
]
|
||||
|
||||
export const DB_TYPES: Record<DbType, string[]> = {
|
||||
bigquery: BIGQUERY_TYPES,
|
||||
ms_sql_server: MSSQL_TYPES,
|
||||
mysql: MYSQL_TYPES,
|
||||
postgresql: POSTGRES_TYPES,
|
||||
snowflake: SNOWFLAKE_TYPES
|
||||
}
|
||||
|
||||
@@ -235,3 +235,16 @@ export const workspaceColor: Readable<string | null | undefined> = derived(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
|
||||
const schema = dbSchema?.schema ?? {}
|
||||
const tableNames: string[] = []
|
||||
|
||||
for (const schemaKey in schema) {
|
||||
for (const tableKey in schema[schemaKey]) {
|
||||
tableNames.push(`${schemaKey}.${tableKey}`)
|
||||
}
|
||||
}
|
||||
|
||||
return tableNames
|
||||
}
|
||||
|
||||
@@ -692,6 +692,12 @@ export function sortObject<T>(o: T & object): T {
|
||||
}, {}) as T
|
||||
}
|
||||
|
||||
export function sortArray<T>(array: T[], compareFn?: (a: T, b: T) => number): T[] {
|
||||
const arr = [...array]
|
||||
arr.sort(compareFn)
|
||||
return arr
|
||||
}
|
||||
|
||||
export function generateRandomString(len: number = 24): string {
|
||||
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
|
||||
@@ -60,6 +60,8 @@
|
||||
import EditableSchemaWrapper from '$lib/components/schema/EditableSchemaWrapper.svelte'
|
||||
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
|
||||
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
|
||||
import DbManagerDrawerButton from '$lib/components/DBManagerDrawerButton.svelte'
|
||||
import { isDbType } from '$lib/components/apps/components/display/dbtable/utils'
|
||||
|
||||
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
|
||||
type ResourceTypeW = ResourceType & { canWrite: boolean }
|
||||
@@ -136,23 +138,23 @@
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
: tab === 'states'
|
||||
? x.resource_type === 'state'
|
||||
: tab === 'cache'
|
||||
? x.resource_type === 'cache'
|
||||
: tab === 'theme'
|
||||
? x.resource_type === 'app_theme'
|
||||
: true
|
||||
})
|
||||
? x.resource_type === 'state'
|
||||
: tab === 'cache'
|
||||
? x.resource_type === 'cache'
|
||||
: tab === 'theme'
|
||||
? x.resource_type === 'app_theme'
|
||||
: true
|
||||
})
|
||||
: preFilteredItemsOwners?.filter((x) => {
|
||||
return (
|
||||
x.resource_type === typeFilter &&
|
||||
(tab === 'workspace'
|
||||
? x.resource_type !== 'app_theme' &&
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
: true)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
async function loadResources(): Promise<void> {
|
||||
resources = await loadResourceInternal(undefined, 'cache,state')
|
||||
@@ -488,10 +490,7 @@
|
||||
>
|
||||
<label>
|
||||
<div class="mb-1 font-semibold text-secondary">Description</div>
|
||||
<textarea
|
||||
use:autosize
|
||||
autocomplete="off"
|
||||
bind:value={editResourceType.description}
|
||||
<textarea use:autosize autocomplete="off" bind:value={editResourceType.description}
|
||||
></textarea></label
|
||||
>
|
||||
<div>
|
||||
@@ -569,7 +568,8 @@
|
||||
</label>
|
||||
<label>
|
||||
<div class="mb-1 font-semibold text-secondary">Description</div>
|
||||
<textarea use:autosize autocomplete="off" bind:value={newResourceType.description}></textarea></label
|
||||
<textarea use:autosize autocomplete="off" bind:value={newResourceType.description}
|
||||
></textarea></label
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between w-full items-center">
|
||||
@@ -603,422 +603,435 @@
|
||||
f={(x) => x.path + ' ' + x.resource_type + ' ' + x.description + ' '}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find(_ => _.id === $workspaceStore)?.operator_settings?.resources}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
<div class="flex flex-row justify-end gap-4">
|
||||
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
|
||||
Add resource type
|
||||
</Button>
|
||||
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
|
||||
Add resource
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div class="flex justify-between">
|
||||
<Tabs
|
||||
class="w-full"
|
||||
bind:selected={tab}
|
||||
on:selected={(e) => {
|
||||
if (e.detail == 'cache') {
|
||||
loading.resources = true
|
||||
loadCache()
|
||||
} else if (e.detail == 'states') {
|
||||
loading.resources = true
|
||||
loadState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="types">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Resource Types
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
Every resource has a Resource Type attached to it which contains its schema and make it
|
||||
easy in scripts and flows to accept only resources of a specific resource type.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="states">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
States
|
||||
<Tooltip>
|
||||
States are actually resources (but excluded from the Workspace tab for clarity). States
|
||||
are used by scripts to keep data persistent between runs of the same script by the same
|
||||
trigger (schedule or user)
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="cache">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Cache
|
||||
<Tooltip>
|
||||
Cached results are actually resources (but excluded from the Workspace tab for clarity).
|
||||
Cache are used by flows's step to cache result to avoid recomputing unnecessarily
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="theme">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Theme
|
||||
<Tooltip>
|
||||
Theme are actually resources (but excluded from the Workspace tab for clarity). Theme
|
||||
are used by the apps to customize their look and feel.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={reload}
|
||||
startIcon={{
|
||||
icon: RotateCw,
|
||||
classes: loading.resources || loading.types ? 'animate-spin' : ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
<p>Page not available for operators</p>
|
||||
</div>
|
||||
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
|
||||
<div class="pt-2">
|
||||
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
|
||||
</div>
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
{#if tab != 'states' && tab != 'cache'}
|
||||
<ListFilters
|
||||
queryName="app_filter"
|
||||
bind:selectedFilter={typeFilter}
|
||||
filters={types}
|
||||
resourceType
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto pb-40 mt-4">
|
||||
{#if loading.resources}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else if filteredItems?.length == 0}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="text-md font-medium">No resources found</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Try changing the filters or creating a new resource
|
||||
{:else}
|
||||
<CenteredPage>
|
||||
<PageHeader
|
||||
title="Resources"
|
||||
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
<div class="flex flex-row justify-end gap-4">
|
||||
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
|
||||
Add resource type
|
||||
</Button>
|
||||
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
|
||||
Add resource
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
<div class="flex justify-between">
|
||||
<Tabs
|
||||
class="w-full"
|
||||
bind:selected={tab}
|
||||
on:selected={(e) => {
|
||||
if (e.detail == 'cache') {
|
||||
loading.resources = true
|
||||
loadCache()
|
||||
} else if (e.detail == 'states') {
|
||||
loading.resources = true
|
||||
loadState()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tab size="md" value="workspace">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
<Building size={18} />
|
||||
Workspace
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="types">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Resource Types
|
||||
<Tooltip
|
||||
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
|
||||
>
|
||||
Every resource has a Resource Type attached to it which contains its schema and make
|
||||
it easy in scripts and flows to accept only resources of a specific resource type.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="states">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
States
|
||||
<Tooltip>
|
||||
States are actually resources (but excluded from the Workspace tab for clarity).
|
||||
States are used by scripts to keep data persistent between runs of the same script by
|
||||
the same trigger (schedule or user)
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="cache">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Cache
|
||||
<Tooltip>
|
||||
Cached results are actually resources (but excluded from the Workspace tab for
|
||||
clarity). Cache are used by flows's step to cache result to avoid recomputing
|
||||
unnecessarily
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab size="md" value="theme">
|
||||
<div class="flex gap-2 items-center my-1">
|
||||
Theme
|
||||
<Tooltip>
|
||||
Theme are actually resources (but excluded from the Workspace tab for clarity). Theme
|
||||
are used by the apps to customize their look and feel.
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="border"
|
||||
color="light"
|
||||
on:click={reload}
|
||||
startIcon={{
|
||||
icon: RotateCw,
|
||||
classes: loading.resources || loading.types ? 'animate-spin' : ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
|
||||
<div class="pt-2">
|
||||
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
|
||||
</div>
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
{#if tab != 'states' && tab != 'cache'}
|
||||
<ListFilters
|
||||
queryName="app_filter"
|
||||
bind:selectedFilter={typeFilter}
|
||||
filters={types}
|
||||
resourceType
|
||||
/>
|
||||
{:else}
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first />
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Resource type</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head />
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if filteredItems}
|
||||
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
class="break-all"
|
||||
href="#{path}"
|
||||
on:click={() => resourceEditor?.initEdit?.(path)}
|
||||
>{#if marked}{@html marked}{:else}{path}{/if}</a
|
||||
>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
|
||||
if (linkedRt) {
|
||||
resourceTypeViewerObj = {
|
||||
rt: linkedRt.name,
|
||||
//@ts-ignore
|
||||
schema: linkedRt.schema,
|
||||
description: linkedRt.description ?? '',
|
||||
formatExtension: linkedRt.format_extension
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
|
||||
<div class="overflow-x-auto pb-40 mt-4">
|
||||
{#if loading.resources}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else if filteredItems?.length == 0}
|
||||
<div class="flex flex-col items-center justify-center h-full">
|
||||
<div class="text-md font-medium">No resources found</div>
|
||||
<div class="text-sm text-secondary">
|
||||
Try changing the filters or creating a new resource
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first />
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Resource type</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head />
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if filteredItems}
|
||||
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
class="break-all"
|
||||
href="#{path}"
|
||||
on:click={() => resourceEditor?.initEdit?.(path)}
|
||||
>{#if marked}{@html marked}{:else}{path}{/if}</a
|
||||
>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
|
||||
if (linkedRt) {
|
||||
resourceTypeViewerObj = {
|
||||
rt: linkedRt.name,
|
||||
//@ts-ignore
|
||||
schema: linkedRt.schema,
|
||||
description: linkedRt.description ?? '',
|
||||
formatExtension: linkedRt.format_extension
|
||||
}
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
} else {
|
||||
sendUserToast(
|
||||
`Resource type ${resource_type} not found in workspace.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
} else {
|
||||
sendUserToast(
|
||||
`Resource type ${resource_type} not found in workspace.`,
|
||||
true
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconedResourceType
|
||||
name={resource_type}
|
||||
after={true}
|
||||
formatExtension={resourceNameToFileExt(resource_type)}
|
||||
/>
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs">
|
||||
{removeMarkdown(truncate(description ?? '', 30))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-row text-center">
|
||||
<div class="w-10">
|
||||
{#if is_linked}
|
||||
<Popover>
|
||||
<Link />
|
||||
<div slot="text">
|
||||
This resource is linked with a variable of the same path. They are
|
||||
deleted and renamed together.
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-10">
|
||||
{#if is_refreshed}
|
||||
<Popover>
|
||||
<RotateCw />
|
||||
<div slot="text">
|
||||
The OAuth token will be kept up-to-date in the background by Windmill
|
||||
using its refresh token
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if is_oauth}
|
||||
<div class="w-10 pt-1.5">
|
||||
{#if refresh_error}
|
||||
}}
|
||||
>
|
||||
<IconedResourceType
|
||||
name={resource_type}
|
||||
after={true}
|
||||
formatExtension={resourceNameToFileExt(resource_type)}
|
||||
/>
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs">
|
||||
{removeMarkdown(truncate(description ?? '', 30))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<div class="flex flex-row text-center">
|
||||
<div class="w-10">
|
||||
{#if is_linked}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-red-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<Link />
|
||||
<div slot="text">
|
||||
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else if is_expired}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
|
||||
<div slot="text">
|
||||
The access_token is expired, it will get renewed the next time this
|
||||
variable is fetched or you can request is to be refreshed in the
|
||||
dropdown on the right.
|
||||
</div>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<div slot="text">
|
||||
The resource was connected through OAuth and the token is not
|
||||
expired.
|
||||
This resource is linked with a variable of the same path. They are
|
||||
deleted and renamed together.
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="w-10">
|
||||
{#if is_refreshed}
|
||||
<Popover>
|
||||
<RotateCw />
|
||||
<div slot="text">
|
||||
The OAuth token will be kept up-to-date in the background by
|
||||
Windmill using its refresh token
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if is_oauth}
|
||||
<div class="w-10 pt-1.5">
|
||||
{#if refresh_error}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-red-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<div slot="text">
|
||||
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
|
||||
</div>
|
||||
</Popover>
|
||||
{:else if is_expired}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
|
||||
<div slot="text">
|
||||
The access_token is expired, it will get renewed the next time
|
||||
this variable is fetched or you can request is to be refreshed in
|
||||
the dropdown on the right.
|
||||
</div>
|
||||
</Popover>
|
||||
{:else}
|
||||
<Popover>
|
||||
<Circle
|
||||
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
|
||||
size={12}
|
||||
/>
|
||||
<div slot="text">
|
||||
The resource was connected through OAuth and the token is not
|
||||
expired.
|
||||
</div>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell class="flex justify-end">
|
||||
{#if path && isDbType(resource_type)}
|
||||
<DbManagerDrawerButton
|
||||
resourcePath={path}
|
||||
resourceType={resource_type}
|
||||
class="mr-8"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
displayName: !canWrite ? 'View Permissions' : 'Share',
|
||||
icon: Share,
|
||||
action: () => {
|
||||
shareModal.openDrawer?.(path, 'resource')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
disabled: !canWrite,
|
||||
action: () => {
|
||||
resourceEditor?.initEdit?.(path)
|
||||
}
|
||||
},
|
||||
...(isDeployable('resource', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'resource')
|
||||
<Dropdown
|
||||
class="w-fit"
|
||||
items={[
|
||||
{
|
||||
displayName: !canWrite ? 'View Permissions' : 'Share',
|
||||
icon: Share,
|
||||
action: () => {
|
||||
shareModal.openDrawer?.(path, 'resource')
|
||||
}
|
||||
},
|
||||
{
|
||||
displayName: 'Edit',
|
||||
icon: Pen,
|
||||
disabled: !canWrite,
|
||||
action: () => {
|
||||
resourceEditor?.initEdit?.(path)
|
||||
}
|
||||
},
|
||||
...(isDeployable('resource', path, deployUiSettings)
|
||||
? [
|
||||
{
|
||||
displayName: 'Deploy to prod/staging',
|
||||
icon: FileUp,
|
||||
action: () => {
|
||||
deploymentDrawer.openDrawer(path, 'resource')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
disabled: !canWrite,
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteResource(path, account)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Delete',
|
||||
disabled: !canWrite,
|
||||
icon: Trash,
|
||||
type: 'delete',
|
||||
action: (event) => {
|
||||
// TODO
|
||||
// @ts-ignore
|
||||
if (event?.shiftKey) {
|
||||
deleteResource(path, account)
|
||||
} else {
|
||||
deleteConfirmedCallback = () => {
|
||||
deleteResource(path, account)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: RotateCw,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadResources()
|
||||
},
|
||||
...(account != undefined
|
||||
? [
|
||||
{
|
||||
displayName: 'Refresh token',
|
||||
icon: RotateCw,
|
||||
action: async () => {
|
||||
await OauthService.refreshToken({
|
||||
workspace: $workspaceStore ?? '',
|
||||
id: account ?? 0,
|
||||
requestBody: {
|
||||
path
|
||||
}
|
||||
})
|
||||
sendUserToast('Token refreshed')
|
||||
loadResources()
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'types'}
|
||||
{#if loading.types}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="overflow-auto mt-4">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if resourceTypes}
|
||||
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
resourceTypeViewerObj = {
|
||||
rt: name,
|
||||
//@ts-ignore
|
||||
schema: schema,
|
||||
description: description ?? '',
|
||||
formatExtension: format_extension
|
||||
}
|
||||
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
}}
|
||||
>
|
||||
<IconedResourceType after={true} {name} formatExtension={format_extension} />
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs w-96 flex flex-wrap whitespace-pre-wrap">
|
||||
{removeMarkdown(truncate(description ?? '', 200))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
{#if !canWrite}
|
||||
<Badge>
|
||||
Shared globally
|
||||
<Tooltip>
|
||||
This resource type is from the 'admins' workspace shared with all
|
||||
workspaces
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="border"
|
||||
btnClasses="border-0"
|
||||
startIcon={{ icon: Trash }}
|
||||
on:click={() => handleDeleteResourceType(name)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => startEditResourceType(name)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Badge>
|
||||
Non Editable
|
||||
<Tooltip>
|
||||
Since resource types are shared with the whole workspace, only admins can
|
||||
edit/delete them
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
/>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab == 'types'}
|
||||
{#if loading.types}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.7]} />
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="overflow-auto mt-4">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<Row>
|
||||
<Cell head first>Name</Cell>
|
||||
<Cell head>Description</Cell>
|
||||
<Cell head last />
|
||||
</Row>
|
||||
</Head>
|
||||
<tbody class="divide-y bg-surface">
|
||||
{#if resourceTypes}
|
||||
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<a
|
||||
href="#{name}"
|
||||
on:click={() => {
|
||||
resourceTypeViewerObj = {
|
||||
rt: name,
|
||||
//@ts-ignore
|
||||
schema: schema,
|
||||
description: description ?? '',
|
||||
formatExtension: format_extension
|
||||
}
|
||||
|
||||
resourceTypeViewer.openDrawer?.()
|
||||
}}
|
||||
>
|
||||
<IconedResourceType
|
||||
after={true}
|
||||
{name}
|
||||
formatExtension={format_extension}
|
||||
/>
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-tertiary text-xs w-96 flex flex-wrap whitespace-pre-wrap">
|
||||
{removeMarkdown(truncate(description ?? '', 200))}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
{#if !canWrite}
|
||||
<Badge>
|
||||
Shared globally
|
||||
<Tooltip>
|
||||
This resource type is from the 'admins' workspace shared with all
|
||||
workspaces
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
variant="border"
|
||||
btnClasses="border-0"
|
||||
startIcon={{ icon: Trash }}
|
||||
on:click={() => handleDeleteResourceType(name)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: Pen }}
|
||||
on:click={() => startEditResourceType(name)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Badge>
|
||||
Non Editable
|
||||
<Tooltip>
|
||||
Since resource types are shared with the whole workspace, only admins
|
||||
can edit/delete them
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
{/if}
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</CenteredPage>
|
||||
</CenteredPage>
|
||||
{/if}
|
||||
|
||||
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />
|
||||
|
||||
@@ -472,11 +472,16 @@ const config = {
|
||||
'4k': '3800px'
|
||||
},
|
||||
animation: {
|
||||
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite'
|
||||
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite',
|
||||
'zoom-in': 'zoom-in 0.25s ease-in-out'
|
||||
},
|
||||
keyframes: {
|
||||
'spin-counter-clockwise': {
|
||||
to: { transform: 'rotate(-360deg)' }
|
||||
},
|
||||
'zoom-in': {
|
||||
'0%': { transform: 'scale(0.95)' },
|
||||
'100%': { transform: 'scale(1)' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user