query and datasource unit tests

This commit is contained in:
Martin McKeaveney 2021-01-14 20:51:03 +00:00
parent 83910f0aab
commit 882823671a
12 changed files with 506 additions and 251 deletions

View File

@ -1,6 +1,7 @@
<script>
import { onMount } from "svelte"
import { backendUiStore } from "builderStore"
import api from "builderStore/api"
import { Input, TextArea, Spacer } from "@budibase/bbui"
import ICONS from "../icons"

View File

@ -35,8 +35,6 @@
notifier.success(`Datasource ${name} created successfully.`)
analytics.captureEvent("Datasource Created", { name })
console.log(response)
// Navigate to new datasource
$goto(`./datasource/${response._id}`)
}

View File

@ -36,7 +36,7 @@
{#each $backendUiStore.tables as table, idx}
<NavItem
border={idx > 0}
icon={table.integration?.type ? 'ri-database-2-line' : `ri-${table._id === TableNames.USERS ? 'user' : 'table'}-line`}
icon={`ri-${table._id === TableNames.USERS ? 'user' : 'table'}-line`}
text={table.name}
selected={selectedView === `all_${table._id}`}
on:click={() => selectTable(table)}>

View File

@ -2,7 +2,7 @@
import { goto } from "@sveltech/routify"
import { backendUiStore, store } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { Input, Label, ModalContent, Button, Spacer } from "@budibase/bbui"
import { Input, Label, ModalContent, Button, Spacer, Toggle } from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte"
import analytics from "analytics"
import screenTemplates from "builderStore/store/screenTemplates"
@ -19,7 +19,6 @@
let modal
let name
let dataImport
let integration
let error = ""
let createAutoscreens = true

View File

@ -1,59 +1,162 @@
<script>
import cm from "./codemirror"
import CodeMirror from "./codemirror"
import { onMount, createEventDispatcher } from "svelte"
import { themeStore } from "builderStore"
const dispatch = createEventDispatcher()
export let value
export let mode = "sql"
const THEMES = {
DARK: "tomorrow-night-eighties",
LIGHT: "default",
}
let editor
let codemirror
export let value = ""
export let readOnly = false
export let lineNumbers = true
export let tab = true
export let mode
$: {
if (codemirror) {
const { left, top } = codemirror.getScrollInfo()
codemirror.setValue(value)
codemirror.scrollTo(left, top)
let width
let height
// We have to expose set and update methods, rather
// than making this state-driven through props,
// because it's difficult to update an editor
// without resetting scroll otherwise
export async function set(new_value, new_mode) {
if (new_mode !== mode) {
await createEditor((mode = new_mode))
}
value = new_value
updating_externally = true
if (editor) editor.setValue(value)
updating_externally = false
}
export function update(new_value) {
value = new_value
if (editor) {
const { left, top } = editor.getScrollInfo()
editor.setValue((value = new_value))
editor.scrollTo(left, top)
}
}
export function resize() {
editor.refresh()
}
export function focus() {
editor.focus()
}
const modes = {
js: {
name: "javascript",
json: false,
},
json: {
name: "javascript",
json: true,
},
sql: {
name: "sql",
},
svelte: {
name: "handlebars",
base: "text/html",
},
}
const refs = {}
let editor
let updating_externally = false
let marker
let error_line
let destroyed = false
$: if (editor && width && height) {
editor.refresh()
}
onMount(() => {
if (codemirror) codemirror.toTextArea()
codemirror = cm.fromTextArea(editor, {
lineNumbers: true,
mode,
lineWrapping: true,
indentUnit: 2,
tabSize: 2,
autoCloseBrackets: true,
autoCloseTags: true,
extraKeys: {
"Ctrl-/": "toggleComment",
},
createEditor(mode).then(() => {
if (editor) editor.setValue(value || "")
})
codemirror.on("change", instance => {
const code = instance.getValue()
dispatch("change", code)
})
codemirror.setValue(value || "")
return () => {
if (codemirror) codemirror.toTextArea()
destroyed = true
if (editor) editor.toTextArea()
}
})
let first = true
async function createEditor(mode) {
if (destroyed || !CodeMirror) return
if (editor) editor.toTextArea()
const opts = {
lineNumbers,
lineWrapping: true,
indentWithTabs: true,
indentUnit: 2,
tabSize: 2,
value: "",
mode: modes[mode] || {
name: mode,
},
readOnly,
autoCloseBrackets: true,
autoCloseTags: true,
theme: $themeStore.darkMode ? THEMES.DARK : THEMES.LIGHT,
}
if (!tab)
opts.extraKeys = {
Tab: tab,
"Shift-Tab": tab,
}
// Creating a text editor is a lot of work, so we yield
// the main thread for a moment. This helps reduce jank
if (first) await sleep(50)
if (destroyed) return
editor = CodeMirror.fromTextArea(refs.editor, opts)
editor.on("change", instance => {
if (!updating_externally) {
const value = instance.getValue()
dispatch("change", { value })
}
})
if (first) await sleep(50)
editor.refresh()
first = false
}
function sleep(ms) {
return new Promise(fulfil => setTimeout(fulfil, ms))
}
</script>
<textarea bind:value bind:this={editor} />
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
<style>
textarea {
background: var(--background);
border-radius: var(--border-radius-m);
visibility: hidden;
}
:global(.CodeMirror) {
height: auto !important;
border-radius: var(--border-radius-m);
font-family: var(--font-sans) !important;
}
</style>

View File

@ -5,7 +5,6 @@
Label,
Input,
Heading,
Spacer,
Select
} from "@budibase/bbui"
@ -13,53 +12,62 @@
export let schema
export let editable
let customSchema = {}
let draftField = {}
$: fieldKeys = Object.keys(fields)
$: schemaKeys = Object.keys(schema.fields)
$: console.log({ fields, schema })
function addField() {
// Add the new field to custom fields for the query
customSchema[draftField.name] = {
schema.fields[draftField.name] = {
type: draftField.type
}
// reset the draft field
draftField = {}
}
function removeField(field) {
delete fields[field]
fields = fields
delete schema.fields[field]
schema = schema
}
</script>
<form on:submit|preventDefault>
{#each Object.keys(schema.fields) as field}
{#each schemaKeys as field}
<Label extraSmall grey>{field}</Label>
<Input
disabled={!editable}
type={schema.fields[field]?.type}
required={schema.fields[field]?.required}
bind:value={fields[field]} />
<Spacer medium />
<div class="field">
<Input
disabled={!editable}
type={schema.fields[field]?.type}
required={schema.fields[field]?.required}
bind:value={fields[field]} />
{#if !schema.fields[field]?.required}
<i class="ri-close-circle-line" on:click={() => removeField(field)} />
{/if}
</div>
{/each}
{#if schema.customisable && editable}
<Spacer large />
<Label>Add Custom Field</Label>
{#each Object.keys(customSchema) as field}
<Label extraSmall grey>{field}</Label>
<Input
thin
type={customSchema[field]?.type}
bind:value={fields[field]}
/>
<Spacer medium />
{/each}
<div class="new-field">
<Label extraSmall grey>Name</Label>
<Label extraSmall grey>Type</Label>
<Input thin bind:value={draftField.name} />
<Select thin secondary bind:value={draftField.name}>
<option value={"text"}>String</option>
<option value={"number"}>Number</option>
</Select>
</div>
<Button small thin secondary on:click={addField}>Add Field</Button>
{/if}
</form>
{#if schema.customisable && editable}
<div>
<Label>Add Custom Field</Label>
<div class="new-field">
<Label extraSmall grey>Name</Label>
<Label extraSmall grey>Type</Label>
<Input thin bind:value={draftField.name} />
<Select thin secondary bind:value={draftField.type}>
<option value={"text"}>String</option>
<option value={"number"}>Number</option>
</Select>
</div>
<Button small thin secondary on:click={addField}>Add Field</Button>
</div>
{/if}
<style>
.new-field {
@ -69,4 +77,23 @@
margin-top: var(--spacing-m);
margin-bottom: var(--spacing-m);
}
.field {
margin-bottom: var(--spacing-m);
display: grid;
grid-template-columns: 1fr 2%;
grid-gap: var(--spacing-m);
align-items: center;
}
i {
transition: all 0.2s;
}
i:hover {
transform: scale(1.1);
font-weight: 500;
cursor: pointer;
}
</style>

View File

@ -1,162 +0,0 @@
<script>
import CodeMirror from "./codemirror"
import { onMount, createEventDispatcher } from "svelte"
import { themeStore } from "builderStore"
const dispatch = createEventDispatcher()
const THEMES = {
DARK: "tomorrow-night-eighties",
LIGHT: "default",
}
export let value = ""
export let readOnly = false
export let lineNumbers = true
export let tab = true
export let mode
let width
let height
// We have to expose set and update methods, rather
// than making this state-driven through props,
// because it's difficult to update an editor
// without resetting scroll otherwise
export async function set(new_value, new_mode) {
if (new_mode !== mode) {
await createEditor((mode = new_mode))
}
value = new_value
updating_externally = true
if (editor) editor.setValue(value)
updating_externally = false
}
export function update(new_value) {
value = new_value
if (editor) {
const { left, top } = editor.getScrollInfo()
editor.setValue((value = new_value))
editor.scrollTo(left, top)
}
}
export function resize() {
editor.refresh()
}
export function focus() {
editor.focus()
}
const modes = {
js: {
name: "javascript",
json: false,
},
json: {
name: "javascript",
json: true,
},
sql: {
name: "sql",
},
svelte: {
name: "handlebars",
base: "text/html",
},
}
const refs = {}
let editor
let updating_externally = false
let marker
let error_line
let destroyed = false
$: if (editor && width && height) {
editor.refresh()
}
onMount(() => {
createEditor(mode).then(() => {
if (editor) editor.setValue(value || "")
})
return () => {
destroyed = true
if (editor) editor.toTextArea()
}
})
let first = true
async function createEditor(mode) {
if (destroyed || !CodeMirror) return
if (editor) editor.toTextArea()
const opts = {
lineNumbers,
lineWrapping: true,
indentWithTabs: true,
indentUnit: 2,
tabSize: 2,
value: "",
mode: modes[mode] || {
name: mode,
},
readOnly,
autoCloseBrackets: true,
autoCloseTags: true,
theme: $themeStore.darkMode ? THEMES.DARK : THEMES.LIGHT,
}
if (!tab)
opts.extraKeys = {
Tab: tab,
"Shift-Tab": tab,
}
// Creating a text editor is a lot of work, so we yield
// the main thread for a moment. This helps reduce jank
if (first) await sleep(50)
if (destroyed) return
editor = CodeMirror.fromTextArea(refs.editor, opts)
editor.on("change", instance => {
if (!updating_externally) {
const value = instance.getValue()
dispatch("change", { value })
}
})
if (first) await sleep(50)
editor.refresh()
first = false
}
function sleep(ms) {
return new Promise(fulfil => setTimeout(fulfil, ms))
}
</script>
<textarea tabindex="0" bind:this={refs.editor} readonly {value} />
<style>
textarea {
visibility: hidden;
}
:global(.CodeMirror) {
height: auto !important;
border-radius: var(--border-radius-m);
font-family: var(--font-sans) !important;
}
</style>

View File

@ -1,7 +1,7 @@
<script>
import { onMount } from "svelte"
import { TextArea, Label, Input, Heading, Spacer } from "@budibase/bbui"
import Editor from "./SvelteEditor.svelte"
import Editor from "./QueryEditor.svelte"
import ParameterBuilder from "./QueryParameterBuilder.svelte"
import FieldsBuilder from "./QueryFieldsBuilder.svelte"
@ -9,6 +9,7 @@
SQL: "sql",
JSON: "json",
FIELDS: "fields",
LIST: "list",
}
export let query
@ -26,7 +27,7 @@
{/if}
<Heading extraSmall black>Query</Heading>
<Spacer large />
<Spacer medium />
{#if schema}
{#if schema.type === QueryTypes.SQL}
@ -46,5 +47,5 @@
value={query.fields.json} />
{:else if schema.type === QueryTypes.FIELDS}
<FieldsBuilder bind:fields={query.fields} {schema} {editable} />
{/if}
{:else if schema.type === QueryTypes.LIST}LIST STUFF{/if}
{/if}

View File

@ -25,20 +25,6 @@
parameters: [],
fields: {},
}
// $: {
// if ($params.query !== "new") {
// query = $backendUiStore.queries.find(query => query._id === $params.query)
// } else {
// // New query
// query = {
// datasourceId: $params.selectedDatasource,
// name: "New Query",
// parameters: [],
// fields: {},
// }
// }
// }
</script>
<section>

View File

@ -32,7 +32,7 @@ exports.save = async function(ctx) {
datasource._rev = response.rev
ctx.status = 200
ctx.message = "Datasource created successfully."
ctx.message = "Datasource saved successfully."
ctx.body = datasource
} catch (err) {
ctx.throw(err.status, err)

View File

@ -0,0 +1,149 @@
const {
supertest,
createApplication,
defaultHeaders,
builderEndpointShouldBlockNormalUsers,
getDocument,
insertDocument
} = require("./couchTestUtils")
let { generateDatasourceID, generateQueryID } = require("../../../db/utils")
const DATASOURCE_ID = generateDatasourceID()
const TEST_DATASOURCE = {
_id: DATASOURCE_ID,
type: "datasource",
name: "Test",
source: "POSTGRES",
config: {},
type: "datasource",
}
const TEST_QUERY = {
_id: generateQueryID(DATASOURCE_ID),
datasourceId: DATASOURCE_ID,
name:"New Query",
parameters:[],
fields:{},
schema:{},
queryVerb:"read",
queryType:"Table",
}
describe("/datasources", () => {
let request
let server
let app
let appId
let datasource
beforeAll(async () => {
({ request, server } = await supertest())
});
afterAll(() => {
server.close()
})
beforeEach(async () => {
app = await createApplication(request)
appId = app.instance._id
});
async function createDatasource() {
return await insertDocument(appId, TEST_DATASOURCE)
}
async function createQuery() {
return await insertDocument(appId, TEST_QUERY)
}
describe("create", () => {
it("should create a new datasource", async () => {
const res = await request
.post(`/api/datasources`)
.send(TEST_DATASOURCE)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect(200)
expect(res.res.statusMessage).toEqual("Datasource saved successfully.");
expect(res.body.name).toEqual("Test");
})
});
describe("fetch", () => {
let datasource
beforeEach(async () => {
datasource = await createDatasource()
});
afterEach(() => {
delete datasource._rev
});
it("returns all the datasources from the server", async () => {
const res = await request
.get(`/api/datasources`)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect(200)
const datasources = res.body;
expect(datasources).toEqual([
{
"_rev": datasources[0]._rev,
...TEST_DATASOURCE
}
]);
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/datasources`,
appId: appId,
})
})
});
describe("destroy", () => {
let datasource;
beforeEach(async () => {
datasource = await createDatasource()
});
afterEach(() => {
delete datasource._rev
});
it("deletes queries for the datasource after deletion and returns a success message", async () => {
await createQuery(datasource.id)
await request
.delete(`/api/datasources/${datasource.id}/${datasource.rev}`)
.set(defaultHeaders(appId))
.expect(200)
const res = await request
.get(`/api/datasources`)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body).toEqual([])
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "DELETE",
url: `/api/datasources/${datasource._id}/${datasource._rev}`,
appId: appId,
})
})
});
});

View File

@ -0,0 +1,153 @@
const {
supertest,
createApplication,
defaultHeaders,
builderEndpointShouldBlockNormalUsers,
getDocument,
insertDocument
} = require("./couchTestUtils")
let { generateDatasourceID, generateQueryID } = require("../../../db/utils")
const DATASOURCE_ID = generateDatasourceID()
const TEST_DATASOURCE = {
_id: DATASOURCE_ID,
type: "datasource",
name: "Test",
source: "POSTGRES",
config: {},
type: "datasource",
}
const TEST_QUERY = {
_id: generateQueryID(DATASOURCE_ID),
datasourceId: DATASOURCE_ID,
name:"New Query",
parameters:[],
fields:{},
schema:{},
queryVerb:"read",
queryType:"Table",
}
describe("/queries", () => {
let request
let server
let app
let appId
let datasource
let query
beforeAll(async () => {
({ request, server } = await supertest())
});
afterAll(() => {
server.close()
})
beforeEach(async () => {
app = await createApplication(request)
appId = app.instance._id
});
async function createDatasource() {
return await insertDocument(appId, TEST_DATASOURCE)
}
async function createQuery() {
return await insertDocument(appId, TEST_QUERY)
}
describe("create", () => {
it("should create a new query", async () => {
const res = await request
.post(`/api/queries`)
.send(TEST_QUERY)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect(200)
expect(res.res.statusMessage).toEqual(`Query ${TEST_QUERY.name} saved successfully.`);
expect(res.body).toEqual({
_rev: res.body._rev,
...TEST_QUERY,
});
})
});
describe("fetch", () => {
let datasource
beforeEach(async () => {
datasource = await createDatasource()
});
afterEach(() => {
delete datasource._rev
});
it("returns all the queries from the server", async () => {
const query = await createQuery()
const res = await request
.get(`/api/queries`)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect(200)
const queries = res.body;
expect(queries).toEqual([
{
"_rev": query.rev,
...TEST_QUERY
}
]);
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "GET",
url: `/api/datasources`,
appId: appId,
})
})
});
describe("destroy", () => {
let datasource;
beforeEach(async () => {
datasource = await createDatasource()
});
afterEach(() => {
delete datasource._rev
});
it("deletes a query and returns a success message", async () => {
const query = await createQuery()
await request
.delete(`/api/queries/${query.id}/${query.rev}`)
.set(defaultHeaders(appId))
.expect(200)
const res = await request
.get(`/api/queries`)
.set(defaultHeaders(appId))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body).toEqual([])
})
it("should apply authorization to endpoint", async () => {
await builderEndpointShouldBlockNormalUsers({
request,
method: "DELETE",
url: `/api/datasources/${datasource._id}/${datasource._rev}`,
appId: appId,
})
})
});
});