Query UI improvements (#11881)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* linting

* sidebar close icon, fix overflow issue with sidebar

* better schema auto populating behavior

* rename newqueryviewer

* remove external datasource table

* linting

* wip

* align header

* fix needing to press the validate button twice

* table min width

* wip

* remove loading

* remove disabled

* remove validation

* lint

* wip

* fix json panel
This commit is contained in:
Gerard Burns 2023-09-26 12:27:27 +01:00 committed by GitHub
parent de7fbbb9ef
commit 62acbc43fd
13 changed files with 624 additions and 393 deletions

View File

@ -1,33 +0,0 @@
<script>
import Table from "./Table.svelte"
export let query = {}
export let data = []
export let editRows = false
let loading = false
let error = false
let type = "external"
</script>
{#if error}
<div class="errors">{error}</div>
{/if}
<Table
schema={query.schema}
{data}
{loading}
{type}
rowCount={5}
allowEditing={editRows}
/>
<style>
.errors {
color: var(--red);
background: var(--red-light);
padding: var(--spacing-m);
border-radius: var(--border-radius-m);
margin-bottom: var(--spacing-m);
}
</style>

View File

@ -12,11 +12,19 @@
export let borderLeft = false export let borderLeft = false
export let borderRight = false export let borderRight = false
export let wide = false export let wide = false
export let extraWide = false
export let closeButtonIcon = "Close"
$: customHeaderContent = $$slots["panel-header-content"] $: customHeaderContent = $$slots["panel-header-content"]
</script> </script>
<div class="panel" class:wide class:borderLeft class:borderRight> <div
class="panel"
class:wide
class:extraWide
class:borderLeft
class:borderRight
>
<div class="header" class:custom={customHeaderContent}> <div class="header" class:custom={customHeaderContent}>
{#if showBackButton} {#if showBackButton}
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} /> <Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
@ -33,7 +41,7 @@
</div> </div>
{/if} {/if}
{#if showCloseButton} {#if showCloseButton}
<Icon name="Close" hoverable on:click={onClickCloseButton} /> <Icon name={closeButtonIcon} hoverable on:click={onClickCloseButton} />
{/if} {/if}
</div> </div>
@ -70,6 +78,10 @@
width: 310px; width: 310px;
flex: 0 0 310px; flex: 0 0 310px;
} }
.panel.extraWide {
width: 450px;
flex: 0 0 450px;
}
.header { .header {
flex: 0 0 48px; flex: 0 0 48px;
display: flex; display: flex;

View File

@ -18,31 +18,20 @@
</script> </script>
{#each extraFields as { key, displayName, type }} {#each extraFields as { key, displayName, type }}
<div class="config-field"> <Label>{displayName}</Label>
<Label>{displayName}</Label> {#if type === "string"}
{#if type === "string"} <Input
<Input on:change={() => populateExtraQuery(extraQueryFields)}
on:change={() => populateExtraQuery(extraQueryFields)} bind:value={extraQueryFields[key]}
bind:value={extraQueryFields[key]} />
/> {/if}
{/if}
{#if type === "list"} {#if type === "list"}
<Select <Select
on:change={() => populateExtraQuery(extraQueryFields)} on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]} bind:value={extraQueryFields[key]}
options={config[key].data[query.queryVerb]} options={config[key].data[query.queryVerb]}
getOptionLabel={current => current} getOptionLabel={current => current}
/> />
{/if} {/if}
</div>
{/each} {/each}
<style>
.config-field {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View File

@ -34,6 +34,7 @@
export let bindings = [] export let bindings = []
export let bindingDrawerLeft export let bindingDrawerLeft
export let allowHelpers = true export let allowHelpers = true
export let customButtonText = null
let fields = Object.entries(object || {}).map(([name, value]) => ({ let fields = Object.entries(object || {}).map(([name, value]) => ({
name, name,
@ -158,9 +159,13 @@
{/if} {/if}
{#if !readOnly && !noAddButton} {#if !readOnly && !noAddButton}
<div> <div>
<ActionButton icon="Add" secondary thin outline on:click={addEntry} <ActionButton icon="Add" secondary thin outline on:click={addEntry}>
>Add{name ? ` ${lowercase(name)}` : ""}</ActionButton {#if customButtonText}
> {customButtonText}
{:else}
{`Add${name ? ` ${lowercase(name)}` : ""}`}
{/if}
</ActionButton>
</div> </div>
{/if} {/if}

View File

@ -1,364 +1,443 @@
<script> <script>
import { goto, beforeUrlChange } from "@roxi/routify" import { goto } from "@roxi/routify"
import { datasources, integrations, queries } from "stores/backend"
import { import {
Icon, Icon,
Select, Select,
Button,
ButtonGroup,
Body,
Label,
Layout,
Input, Input,
Heading, Label,
Tabs,
Tab,
Modal,
ModalContent,
notifications, notifications,
Heading,
Body,
Divider, Divider,
Button,
} from "@budibase/bbui" } from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte" import { capitalise } from "helpers"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import BindingBuilder from "components/integration/QueryViewerBindingBuilder.svelte"
import { datasources, integrations, queries } from "stores/backend"
import { capitalise } from "../../helpers"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import JSONPreview from "./JSONPreview.svelte"
import { SchemaTypeOptions } from "constants/backend"
import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte" import AccessLevelSelect from "./AccessLevelSelect.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import QueryViewerSidePanel from "./QueryViewerSidePanel/index.svelte"
import { cloneDeep } from "lodash/fp"
import BindingBuilder from "components/integration/QueryViewerBindingBuilder.svelte"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { ValidQueryNameRegex } from "@budibase/shared-core" import { ValidQueryNameRegex } from "@budibase/shared-core"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import QueryViewerSavePromptModal from "./QueryViewerSavePromptModal.svelte"
import { Utils } from "@budibase/frontend-core"
export let query export let query
let queryHash
const resumeNavigation = () => { let loading = false
if (typeof navigateTo == "string") { let modified = false
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo) let scrolling = false
} let showSidePanel = false
let nameError
let newQuery
let datasource
let integration
let schemaType
let autoSchema = {}
let rows = []
const parseQuery = query => {
modified = false
datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
integration = $integrations[datasource.source]
schemaType = integration.query[query.queryVerb].type
newQuery = cloneDeep(query)
// Set the location where the query code will be written to an empty string so that it doesn't
// get changed from undefined -> "" by the input, breaking our unsaved changes checks
newQuery.fields[schemaType] ??= ""
queryHash = JSON.stringify(newQuery)
} }
const transformerDocs = "https://docs.budibase.com/docs/transformers" $: parseQuery(query)
let fields = query?.schema ? schemaToFields(query.schema) : [] const checkIsModified = newQuery => {
let parameters const newQueryHash = JSON.stringify(newQuery)
let data = [] modified = newQueryHash !== queryHash
let saveId
let currentTab = "JSON"
let saveModal
let override = false
let navigateTo = null
let nameError = null
// seed the transformer return modified
if (query && !query.transformer) {
query.transformer = "return data"
} }
// initialise a new empty schema const debouncedCheckIsModified = Utils.debounce(checkIsModified, 1000)
if (query && !query.schema) {
query.schema = {}
}
let queryStr = JSON.stringify(query) $: debouncedCheckIsModified(newQuery)
$beforeUrlChange(event => { async function runQuery({ suppressErrors = true }) {
const updated = JSON.stringify(query)
if (updated !== queryStr && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
saveModal.show()
return false
} else return true
})
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields)
$: datasourceType = datasource?.source
$: integrationInfo = datasourceType ? $integrations[datasourceType] : null
$: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = queryConfig && query.queryVerb
$: readQuery = query.queryVerb === "read" || query.readable
$: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
//Cast field in query preview response to number if specified by schema
$: {
for (let i = 0; i < data.length; i++) {
let row = data[i]
for (let fieldName of Object.keys(fields)) {
if (fields[fieldName] === "number" && !isNaN(Number(row[fieldName]))) {
row[fieldName] = Number(row[fieldName])
} else {
row[fieldName] = row[fieldName]?.toString()
}
}
}
}
function resetDependentFields() {
if (query.fields.extra) {
query.fields.extra = {}
}
}
function populateExtraQuery(extraQueryFields) {
query.fields.extra = extraQueryFields
}
async function previewQuery() {
try { try {
const response = await queries.preview(query) showSidePanel = true
loading = true
const response = await queries.preview(newQuery)
if (response.rows.length === 0) { if (response.rows.length === 0) {
notifications.info( notifications.info(
"Query results empty. Please execute a query with results to create your schema." "Query results empty. Please execute a query with results to create your schema."
) )
return return
} }
data = response.rows
// need to merge fields that already exist/might have changed if (Object.keys(newQuery.schema).length === 0) {
if (fields) { // Assign this to a variable instead of directly to the newQuery.schema so that a user
for (let key of Object.keys(response.schema)) { // can change the table they're querying and have the schema update until they first
if (fields[key]) { // edit it
response.schema[key] = fields[key] autoSchema = response.schema
}
}
} }
fields = response.schema
currentTab = "JSON" rows = response.rows
notifications.success("Query executed successfully") notifications.success("Query executed successfully")
} catch (error) { } catch (error) {
notifications.error(`Query Error: ${error.message}`) notifications.error(`Query Error: ${error.message}`)
if (!suppressErrors) {
throw error
}
} finally {
loading = false
} }
} }
// return the query.
async function saveQuery() { async function saveQuery() {
try { try {
const response = await queries.save(query.datasourceId, query) showSidePanel = true
saveId = response._id loading = true
const response = await queries.save(newQuery.datasourceId, {
if (response?._rev) { ...newQuery,
queryStr = JSON.stringify(query) schema:
} Object.keys(newQuery.schema).length === 0
? autoSchema
: newQuery.schema,
})
notifications.success("Query saved successfully") notifications.success("Query saved successfully")
return response return response
} catch (error) { } catch (error) {
notifications.error(error.message || "Error saving query") notifications.error(error.message || "Error saving query")
} finally {
loading = false
} }
} }
function resetDependentFields() {
if (newQuery.fields.extra) {
newQuery.fields.extra = {}
}
}
function populateExtraQuery(extraQueryFields) {
newQuery.fields.extra = extraQueryFields
}
const handleScroll = e => {
scrolling = e.target.scrollTop !== 0
}
</script> </script>
<Modal <QueryViewerSavePromptModal
bind:this={saveModal} checkIsModified={() => checkIsModified(newQuery)}
on:hide={() => { attemptSave={() => runQuery({ suppressErrors: false }).then(saveQuery)}
navigateTo = null />
}} <div class="queryViewer">
> <div class="main">
<ModalContent <div class="header" class:scrolling>
title="You have unsaved changes" <div class="title">
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
await saveQuery()
override = true
resumeNavigation()
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing and changes to your query</Body>
</ModalContent>
</Modal>
<div class="wrapper">
<Layout gap="S" noPadding>
<Heading size="M">Query {integrationInfo?.friendlyName}</Heading>
<Divider />
<Heading size="S">Config</Heading>
<div class="config">
<div class="config-field">
<Label>Query Name</Label>
<Input
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
if (newValue.match(ValidQueryNameRegex)) {
query.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}}
error={nameError}
/>
</div>
{#if queryConfig}
<div class="config-field">
<Label>Function</Label>
<Select
bind:value={query.queryVerb}
on:change={resetDependentFields}
options={Object.keys(queryConfig)}
getOptionLabel={verb =>
queryConfig[verb]?.displayName || capitalise(verb)}
/>
</div>
<div class="config-field">
<AccessLevelSelect {saveId} {query} label="Access Level" />
</div>
{#if integrationInfo?.extra && query.queryVerb}
<ExtraQueryConfig
{query}
{populateExtraQuery}
config={integrationInfo.extra}
/>
{/if}
{#key query.parameters}
<div class="binding-wrap">
<BindingBuilder
queryBindings={query.parameters}
bindable={false}
on:change={e => {
query.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
</div>
{/key}
{/if}
</div>
{#if shouldShowQueryConfig}
<Divider />
<div class="config">
<Heading size="S">Fields</Heading>
<Body size="S">Fill in the fields specific to this query.</Body>
<IntegrationQueryEditor
{datasource}
{query}
height={200}
schema={queryConfig[query.queryVerb]}
bind:parameters
/>
<Divider />
</div>
<div class="config">
<div class="help-heading">
<Heading size="S">Transformer</Heading>
<Icon
on:click={() => window.open(transformerDocs)}
hoverable
name="Help"
size="L"
/>
</div>
<Body size="S"> <Body size="S">
Add a JavaScript function to transform the query result. {newQuery.name || "Untitled query"}<span class="unsaved"
>{modified ? "*" : ""}</span
>
</Body> </Body>
<CodeMirrorEditor
height={200}
label="Transformer"
value={query.transformer}
resize="vertical"
on:change={e => (query.transformer = e.detail)}
/>
<Divider />
</div> </div>
<div class="viewer-controls"> <div class="controls">
<Heading size="S">Results</Heading> <Button disabled={loading} on:click={runQuery} overBackground>
<ButtonGroup gap="S"> <Icon size="S" name="Play" />
Run query</Button
>
<div class="tooltip" title="Run your query to enable saving">
<Button <Button
cta
disabled={queryInvalid}
on:click={async () => { on:click={async () => {
await saveQuery() const response = await saveQuery()
// Go to the correct URL if we just created a new query
if (!query._rev) { // When creating a new query the initally passed in query object will have no id.
$goto(`../../${query._id}`) if (response._id && !newQuery._id) {
// Set the comparison query hash to match the new query so that the user doesn't
// get nagged when navigating to the edit view
queryHash = JSON.stringify(newQuery)
$goto(`../../${response._id}`)
} }
}} }}
disabled={loading ||
!newQuery.name ||
nameError ||
rows.length === 0}
overBackground
> >
Save Query <Icon size="S" name="SaveFloppy" />
Save
</Button> </Button>
<Button secondary on:click={previewQuery}>Run Query</Button> </div>
</ButtonGroup>
</div> </div>
<Body size="S"> </div>
Below, you can preview the results from your query and change the
schema. <div class="body" on:scroll={handleScroll}>
</Body> <div class="bodyInner">
<section class="viewer"> <div class="configField">
{#if data} <Label>Name</Label>
<Tabs bind:selected={currentTab}> <Input
<Tab title="JSON"> value={newQuery.name}
<JSONPreview data={data[0]} minHeight="120" /> on:input={e => {
</Tab> let newValue = e.target.value || ""
<Tab title="Schema"> if (newValue.match(ValidQueryNameRegex)) {
<KeyValueBuilder newQuery.name = newValue.trim()
bind:object={fields} nameError = null
name="field" } else {
headings nameError = "Invalid query name"
options={SchemaTypeOptions} }
}}
error={nameError}
/>
{#if integration.query}
<Label>Function</Label>
<Select
bind:value={newQuery.queryVerb}
on:change={resetDependentFields}
options={Object.keys(integration.query)}
getOptionLabel={verb =>
integration.query[verb]?.displayName || capitalise(verb)}
/>
<Label>Access</Label>
<AccessLevelSelect query={newQuery} />
{#if integration?.extra && newQuery.queryVerb}
<ExtraQueryConfig
query={newQuery}
{populateExtraQuery}
config={integration.extra}
/> />
</Tab> {/if}
<Tab title="Preview"> {/if}
<ExternalDataSourceTable {query} {data} /> </div>
</Tab>
</Tabs> <Divider />
{/if}
</section> <div class="heading">
{/if} <Heading weight="L" size="XS">Query</Heading>
</Layout> </div>
<div class="copy">
<Body size="S">
{#if schemaType === "sql"}
Add some SQL to query your data
{:else if schemaType === "json"}
Add some JSON to query your data
{:else if schemaType === "fields"}
Add some fields to query your data
{:else}
Enter your query below
{/if}
</Body>
</div>
<IntegrationQueryEditor
noLabel
{datasource}
bind:query={newQuery}
height={200}
schema={integration.query[newQuery.queryVerb]}
/>
<Divider />
<div class="heading">
<Heading weight="L" size="XS">Bindings</Heading>
</div>
<div class="copy">
<Body size="S">
Bindings come in two parts: the binding name, and a default/fallback
value. These bindings can be used as Handlebars expressions
throughout the query.
</Body>
</div>
{#key newQuery.parameters}
<BindingBuilder
hideHeading
queryBindings={newQuery.parameters}
on:change={e => {
newQuery.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
{/key}
<Divider />
<div class="heading">
<Heading weight="L" size="XS">Transformer</Heading>
</div>
<div class="copy">
<Body size="S">
Add a JavaScript function to transform the query result.
</Body>
</div>
<CodeMirrorEditor
height={200}
value={newQuery.transformer}
resize="vertical"
on:change={e => (newQuery.transformer = e.detail)}
/>
</div>
</div>
</div>
<div class:showSidePanel class="sidePanel">
<QueryViewerSidePanel
onClose={() => (showSidePanel = false)}
onSchemaChange={newSchema => {
newQuery.schema = newSchema
}}
{rows}
schema={Object.keys(newQuery.schema).length === 0
? autoSchema
: newQuery.schema}
/>
</div>
</div> </div>
<style> <style>
.wrapper { .unsaved {
width: 640px; color: var(--grey-5);
font-style: italic;
}
.queryViewer {
height: 100%;
margin: -28px -40px -40px -40px;
display: flex;
flex: 1;
}
.queryViewer :global(.spectrum-Divider) {
margin: 35px 0;
}
.main {
flex-grow: 1;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
align-items: center;
padding: 8px 10px 8px 16px;
display: flex;
border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out;
}
.header.scrolling {
border-bottom: var(--border-light);
}
.body {
flex-grow: 1;
overflow-y: scroll;
padding: 23px 23px 80px;
box-sizing: border-box;
}
.bodyInner {
max-width: 520px;
margin: auto; margin: auto;
} }
.config { .title {
display: grid; /* width 0 paired with flex-grow necessary here for the truncation to work properly*/
grid-gap: var(--spacing-s); width: 0;
z-index: 1; flex-grow: 1;
} }
.config-field { .title :global(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
flex-shrink: 0;
}
.tooltip {
display: inline-block;
}
.controls :global(button) {
border: none;
color: var(--grey-7);
font-weight: 300;
}
.controls :global(button):hover {
background-color: transparent;
color: var(--ink);
}
.controls :global(.is-disabled) {
pointer-events: none;
background-color: transparent;
color: var(--grey-3);
}
.controls :global(span) {
display: flex;
align-items: center;
}
.controls :global(.icon) {
margin-right: 8px;
}
.configField {
display: grid; display: grid;
grid-template-columns: 20% 1fr; grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
align-items: center; align-items: center;
} }
.help-heading { .configField :global(label) {
display: flex; color: var(--grey-6);
justify-content: space-between;
} }
.viewer { .heading {
min-height: 200px; margin-bottom: 8px;
width: 640px;
} }
.viewer-controls { .copy {
display: flex; margin-bottom: 14px;
flex-direction: row;
justify-content: space-between;
gap: var(--spacing-m);
min-width: 150px;
align-items: center;
} }
.binding-wrap :global(div.container) { .copy :global(p) {
padding-left: 0px; color: var(--grey-7);
padding-right: 0px; }
.sidePanel {
flex-shrink: 0;
height: 100%;
width: 0;
overflow: hidden;
transition: width 150ms;
}
.sidePanel :global(.panel) {
height: 100%;
}
.showSidePanel {
width: 450px;
} }
</style> </style>

View File

@ -1,9 +1,9 @@
<script> <script>
import { Body, Heading, Layout } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { getUserBindings } from "builderStore/dataBinding" import { getUserBindings } from "builderStore/dataBinding"
export let bindable = true export let bindable = true
export let queryBindings = [] export let queryBindings = []
export let hideHeading = false
const userBindings = getUserBindings() const userBindings = getUserBindings()
@ -13,44 +13,16 @@
}, {}) }, {})
</script> </script>
<Layout noPadding={bindable} gap="S"> <KeyValueBuilder
<div class="controls" class:height={!bindable}> bind:object={internalBindings}
<Heading size="XS">Bindings</Heading> tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query"
</div> name="binding"
<Body size="S"> customButtonText="Bindings"
{#if !bindable} headings
Bindings come in two parts: the binding name, and a default/fallback keyPlaceholder="Binding name"
value. These bindings can be used as Handlebars expressions throughout the valuePlaceholder="Default"
query. bindings={[...userBindings]}
{:else} bindingDrawerLeft="260px"
Enter a value for each binding. The default values will be used for any allowHelpers={false}
values left blank. on:change
{/if} />
</Body>
<div class="bindings" class:bindable>
<KeyValueBuilder
bind:object={internalBindings}
tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query"
name="binding"
headings
keyPlaceholder="Binding name"
valuePlaceholder="Default"
bindings={[...userBindings]}
bindingDrawerLeft="260px"
allowHelpers={false}
on:change
/>
</div>
</Layout>
<style>
.controls {
display: flex;
align-items: center;
justify-content: space-between;
}
.height {
height: 40px;
}
</style>

View File

@ -0,0 +1,53 @@
<script>
import { goto, beforeUrlChange } from "@roxi/routify"
import { Body, Modal, ModalContent } from "@budibase/bbui"
export let checkIsModified = () => {}
export let attemptSave = () => {}
let modal
let navigateTo
let override = false
$beforeUrlChange(event => {
if (checkIsModified() && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
modal.show()
return false
} else return true
})
const resumeNavigation = () => {
if (typeof navigateTo == "string") {
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo)
}
}
</script>
<Modal
bind:this={modal}
on:hide={() => {
navigateTo = null
}}
>
<ModalContent
title="You have unsaved changes"
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
try {
await attemptSave()
override = true
resumeNavigation()
} catch (e) {
navigateTo = false
}
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing any changes to your query</Body>
</ModalContent>
</Modal>

View File

@ -0,0 +1,22 @@
<script>
export let data
$: string = JSON.stringify(data || {}, null, 2)
</script>
<textarea class="json" disabled value={string} />
<style>
.json {
width: 100%;
height: 100%;
color: var(--ink);
padding: 12px;
box-sizing: border-box;
resize: none;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
}
</style>

View File

@ -0,0 +1,33 @@
<script>
import Table from "components/backend/DataTable/Table.svelte"
import { cloneDeep } from "lodash/fp"
export let schema = {}
export let rows = []
$: rowsCopy = cloneDeep(rows)
// Cast field in query preview response to number if specified by schema
$: {
for (let i = 0; i < rowsCopy.length; i++) {
let row = rowsCopy[i]
for (let fieldName of Object.keys(schema)) {
if (schema[fieldName] === "number" && !isNaN(Number(row[fieldName]))) {
row[fieldName] = Number(row[fieldName])
} else {
row[fieldName] = row[fieldName]?.toString()
}
}
}
}
</script>
<div class="table">
<Table {schema} data={rowsCopy} type="external" allowEditing={false} />
</div>
<style>
.table :global(.spectrum-Table-cell) {
min-width: 100px;
}
</style>

View File

@ -0,0 +1,29 @@
<script>
import KeyValueBuilder from "../KeyValueBuilder.svelte"
import { SchemaTypeOptions } from "constants/backend"
export let schema
export let onSchemaChange = () => {}
const handleChange = e => {
let newSchema = {}
// KeyValueBuilder on change event returns an array of objects with each object
// containing a field
e.detail.forEach(({ name, value }) => {
newSchema[name] = value
})
onSchemaChange(newSchema)
}
</script>
{#key schema}
<KeyValueBuilder
on:change={handleChange}
object={schema}
name="field"
headings
options={SchemaTypeOptions}
/>
{/key}

View File

@ -0,0 +1,66 @@
<script>
import Panel from "components/design/Panel.svelte"
import { ActionButton } from "@budibase/bbui"
import JSONPanel from "./JSONPanel.svelte"
import SchemaPanel from "./SchemaPanel.svelte"
import PreviewPanel from "./PreviewPanel.svelte"
export let rows
export let schema
export let onSchemaChange = () => {}
export let onClose = () => {}
const tabs = ["JSON", "Schema", "Preview"]
let activeTab = "JSON"
</script>
<Panel
showCloseButton
closeButtonIcon="RailRightClose"
onClickCloseButton={onClose}
title="Query results"
icon={"SQLQuery"}
borderLeft
extraWide
>
<div slot="panel-header-content">
<div class="settings-tabs">
{#each tabs as tab}
<ActionButton
size="M"
quiet
selected={activeTab === tab}
on:click={() => {
activeTab = tab
}}
>
{tab}
</ActionButton>
{/each}
</div>
</div>
<div class="content">
{#if activeTab === "JSON"}
<JSONPanel data={rows[0] || {}} />
{:else if activeTab === "Schema"}
<SchemaPanel {onSchemaChange} {schema} />
{:else}
<PreviewPanel {schema} {rows} />
{/if}
</div>
</Panel>
<style>
.settings-tabs {
display: flex;
gap: var(--spacing-s);
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.content {
padding: 14px;
height: 100%;
overflow: scroll;
}
</style>

View File

@ -23,6 +23,7 @@
export let schema export let schema
export let editable = true export let editable = true
export let height = 500 export let height = 500
export let noLabel = false
let stepEditors = [] let stepEditors = []
@ -75,7 +76,7 @@
{#if schema.type === QueryTypes.SQL} {#if schema.type === QueryTypes.SQL}
<Editor <Editor
editorHeight={height} editorHeight={height}
label="Query" label={noLabel ? null : "Query"}
mode="sql" mode="sql"
on:change={updateQuery} on:change={updateQuery}
readOnly={!editable} readOnly={!editable}
@ -85,7 +86,7 @@
{:else if shouldDisplayJsonBox} {:else if shouldDisplayJsonBox}
<Editor <Editor
editorHeight={height} editorHeight={height}
label="Query" label={noLabel ? null : "Query"}
mode="json" mode="json"
on:change={updateQuery} on:change={updateQuery}
readOnly={!editable} readOnly={!editable}

View File

@ -16,6 +16,9 @@
const buildNewQuery = isRestQuery => { const buildNewQuery = isRestQuery => {
let query = { let query = {
name: "Untitled query",
transformer: "return data",
schema: {},
datasourceId: $params.datasourceId, datasourceId: $params.datasourceId,
parameters: [], parameters: [],
fields: {}, fields: {},