Merge pull request #2822 from Budibase/develop

develop -> master
This commit is contained in:
Martin McKeaveney 2021-09-30 15:24:25 +01:00 committed by GitHub
commit 1a6ffbe655
110 changed files with 3827 additions and 756 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "0.9.146", "version": "0.9.147-alpha.0",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/auth", "name": "@budibase/auth",
"version": "0.9.146", "version": "0.9.147-alpha.0",
"description": "Authentication middlewares for budibase builder and apps", "description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",

View File

@ -12,7 +12,7 @@ const populateFromDB = async (userId, tenantId) => {
const user = await getGlobalDB(tenantId).get(userId) const user = await getGlobalDB(tenantId).get(userId)
user.budibaseAccess = true user.budibaseAccess = true
if (!env.SELF_HOSTED) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(user.email) const account = await accounts.getAccount(user.email)
if (account) { if (account) {
user.account = account user.account = account

View File

@ -21,6 +21,7 @@ module.exports = {
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
MULTI_TENANCY: process.env.MULTI_TENANCY, MULTI_TENANCY: process.env.MULTI_TENANCY,
ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL, ACCOUNT_PORTAL_URL: process.env.ACCOUNT_PORTAL_URL,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED), SELF_HOSTED: !!parseInt(process.env.SELF_HOSTED),
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
isTest, isTest,

View File

@ -19,6 +19,22 @@ const removeTenantFromInfoDB = async tenantId => {
} }
} }
exports.removeUserFromInfoDB = async dbUser => {
const infoDb = getDB(PLATFORM_INFO_DB)
const keys = [dbUser._id, dbUser.email]
const userDocs = await infoDb.allDocs({
keys,
include_docs: true,
})
const toDelete = userDocs.rows.map(row => {
return {
...row.doc,
_deleted: true,
}
})
await infoDb.bulkDocs(toDelete)
}
const removeUsersFromInfoDB = async tenantId => { const removeUsersFromInfoDB = async tenantId => {
try { try {
const globalDb = getGlobalDB(tenantId) const globalDb = getGlobalDB(tenantId)

View File

@ -73,7 +73,7 @@ exports.tryAddTenant = async (tenantId, userId, email) => {
await Promise.all(promises) await Promise.all(promises)
} }
exports.getGlobalDB = (tenantId = null) => { exports.getGlobalDBName = (tenantId = null) => {
// tenant ID can be set externally, for example user API where // tenant ID can be set externally, for example user API where
// new tenants are being created, this may be the case // new tenants are being created, this may be the case
if (!tenantId) { if (!tenantId) {
@ -81,13 +81,16 @@ exports.getGlobalDB = (tenantId = null) => {
} }
let dbName let dbName
if (tenantId === DEFAULT_TENANT_ID) { if (tenantId === DEFAULT_TENANT_ID) {
dbName = StaticDatabases.GLOBAL.name dbName = StaticDatabases.GLOBAL.name
} else { } else {
dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}` dbName = `${tenantId}${SEPARATOR}${StaticDatabases.GLOBAL.name}`
} }
return dbName
}
exports.getGlobalDB = (tenantId = null) => {
const dbName = exports.getGlobalDBName(tenantId)
return getDB(dbName) return getDB(dbName)
} }

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.9.146", "version": "0.9.147-alpha.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -14,6 +14,7 @@
export let showConfirmButton = true export let showConfirmButton = true
export let showCloseIcon = true export let showCloseIcon = true
export let onConfirm = undefined export let onConfirm = undefined
export let onCancel = undefined
export let disabled = false export let disabled = false
export let showDivider = true export let showDivider = true
@ -28,6 +29,14 @@
} }
loading = false loading = false
} }
async function close() {
loading = true
if (!onCancel || (await onCancel()) !== false) {
cancel()
}
loading = false
}
</script> </script>
<div <div
@ -65,7 +74,7 @@
> >
<slot name="footer" /> <slot name="footer" />
{#if showCancelButton} {#if showCancelButton}
<Button group secondary on:click={cancel}>{cancelText}</Button> <Button group secondary on:click={close}>{cancelText}</Button>
{/if} {/if}
{#if showConfirmButton} {#if showConfirmButton}
<Button <Button

View File

@ -27,6 +27,7 @@
export let selectedRows = [] export let selectedRows = []
export let editColumnTitle = "Edit" export let editColumnTitle = "Edit"
export let customRenderers = [] export let customRenderers = []
export let disableSorting = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -63,7 +64,7 @@
) )
// Reset state when data changes // Reset state when data changes
$: data.length, reset() $: rows.length, reset()
const reset = () => { const reset = () => {
nextScrollTop = 0 nextScrollTop = 0
scrollTop = 0 scrollTop = 0
@ -107,7 +108,7 @@
} }
const sortRows = (rows, sortColumn, sortOrder) => { const sortRows = (rows, sortColumn, sortOrder) => {
if (!sortColumn || !sortOrder) { if (!sortColumn || !sortOrder || disableSorting) {
return rows return rows
} }
return rows.slice().sort((a, b) => { return rows.slice().sort((a, b) => {
@ -131,6 +132,7 @@
sortColumn = fieldSchema.name sortColumn = fieldSchema.name
sortOrder = "Descending" sortOrder = "Descending"
} }
dispatch("sort", { column: sortColumn, order: sortOrder })
} }
const getDisplayName = schema => { const getDisplayName = schema => {

View File

@ -6,7 +6,7 @@ context("Create a Table", () => {
it("should create a new Table", () => { it("should create a new Table", () => {
cy.createTable("dog") cy.createTable("dog")
cy.wait(1000)
// Check if Table exists // Check if Table exists
cy.get(".table-title h1").should("have.text", "dog") cy.get(".table-title h1").should("have.text", "dog")
}) })
@ -36,7 +36,8 @@ context("Create a Table", () => {
it("edits a row", () => { it("edits a row", () => {
cy.contains("button", "Edit").click({ force: true }) cy.contains("button", "Edit").click({ force: true })
cy.wait(1000) cy.wait(1000)
cy.get(".spectrum-Modal input").type("Updated") cy.get(".spectrum-Modal input").clear()
cy.get(".spectrum-Modal input").type("RoverUpdated")
cy.contains("Save").click() cy.contains("Save").click()
cy.contains("RoverUpdated").should("have.text", "RoverUpdated") cy.contains("RoverUpdated").should("have.text", "RoverUpdated")
}) })
@ -62,7 +63,7 @@ context("Create a Table", () => {
it("deletes a table", () => { it("deletes a table", () => {
cy.get(".actions > :nth-child(1) > .icon > .spectrum-Icon > use") cy.get(".actions > :nth-child(1) > .icon > .spectrum-Icon > use")
.first() .eq(1)
.click({ force: true }) .click({ force: true })
cy.get(".spectrum-Menu > :nth-child(2)").click() cy.get(".spectrum-Menu > :nth-child(2)").click()
cy.contains("Delete Table").click() cy.contains("Delete Table").click()

View File

@ -35,8 +35,11 @@ Cypress.Commands.add("createApp", name => {
.within(() => { .within(() => {
cy.get("input").eq(0).type(name).should("have.value", name).blur() cy.get("input").eq(0).type(name).should("have.value", name).blur()
cy.get(".spectrum-ButtonGroup").contains("Create app").click() cy.get(".spectrum-ButtonGroup").contains("Create app").click()
cy.wait(7000)
}) })
.then(() => { .then(() => {
// Because we show the datasource modal on entry, we need to create a table to get rid of the modal in the future
cy.createInitialDatasource("initialTable")
cy.expandBudibaseConnection() cy.expandBudibaseConnection()
cy.get(".nav-item.selected > .content").should("be.visible") cy.get(".nav-item.selected > .content").should("be.visible")
}) })
@ -69,11 +72,28 @@ Cypress.Commands.add("createTestTableWithData", () => {
cy.addColumn("dog", "age", "Number") cy.addColumn("dog", "age", "Number")
}) })
Cypress.Commands.add("createTable", tableName => { Cypress.Commands.add("createInitialDatasource", tableName => {
// Enter table name // Enter table name
cy.get(".spectrum-Modal").within(() => {
cy.contains("Budibase DB").trigger("mouseover").click().click()
cy.wait(1000)
cy.contains("Continue").click()
})
cy.get(".spectrum-Modal").within(() => {
cy.wait(1000)
cy.get("input").first().type(tableName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create").click()
})
cy.contains(tableName).should("be.visible")
})
Cypress.Commands.add("createTable", tableName => {
cy.contains("Budibase DB").click() cy.contains("Budibase DB").click()
cy.contains("Create new table").click() cy.contains("Create new table").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.wait(1000)
cy.get("input").first().type(tableName).blur() cy.get("input").first().type(tableName).blur()
cy.get(".spectrum-ButtonGroup").contains("Create").click() cy.get(".spectrum-ButtonGroup").contains("Create").click()
}) })
@ -145,7 +165,7 @@ Cypress.Commands.add("getComponent", componentId => {
.its("body") .its("body")
.should("not.be.null") .should("not.be.null")
.then(cy.wrap) .then(cy.wrap)
.find(`[data-component-id=${componentId}]`) .find(`[data-id=${componentId}]`)
}) })
Cypress.Commands.add("navigateToFrontend", () => { Cypress.Commands.add("navigateToFrontend", () => {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "0.9.146", "version": "0.9.147-alpha.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.146", "@budibase/bbui": "^0.9.147-alpha.0",
"@budibase/client": "^0.9.146", "@budibase/client": "^0.9.147-alpha.0",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^0.9.146", "@budibase/string-templates": "^0.9.147-alpha.0",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -24,7 +24,7 @@
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import FilterDrawer from "components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte" import FilterDrawer from "components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte"
// need the client lucene builder to convert to the structure API expects // need the client lucene builder to convert to the structure API expects
import { buildLuceneQuery } from "../../../../../client/src/utils/lucene" import { buildLuceneQuery } from "helpers/lucene"
export let block export let block
export let webhookModal export let webhookModal

View File

@ -1,6 +1,6 @@
<script> <script>
import { tables, views } from "stores/backend" import { fade } from "svelte/transition"
import { tables } from "stores/backend"
import CreateRowButton from "./buttons/CreateRowButton.svelte" import CreateRowButton from "./buttons/CreateRowButton.svelte"
import CreateColumnButton from "./buttons/CreateColumnButton.svelte" import CreateColumnButton from "./buttons/CreateColumnButton.svelte"
import CreateViewButton from "./buttons/CreateViewButton.svelte" import CreateViewButton from "./buttons/CreateViewButton.svelte"
@ -8,59 +8,84 @@
import EditRolesButton from "./buttons/EditRolesButton.svelte" import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte" import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
import * as api from "./api" import TableFilterButton from "./buttons/TableFilterButton.svelte"
import Table from "./Table.svelte" import Table from "./Table.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import CreateEditRow from "./modals/CreateEditRow.svelte" import CreateEditRow from "./modals/CreateEditRow.svelte"
import { fetchTableData } from "helpers/fetchTableData"
import { Pagination } from "@budibase/bbui"
let hideAutocolumns = true let hideAutocolumns = true
let data = []
let loading = false
$: isUsersTable = $tables.selected?._id === TableNames.USERS $: isUsersTable = $tables.selected?._id === TableNames.USERS
$: title = $tables.selected?.name
$: schema = $tables.selected?.schema $: schema = $tables.selected?.schema
$: tableView = {
schema,
name: $views.selected?.name,
}
$: type = $tables.selected?.type $: type = $tables.selected?.type
$: isInternal = type !== "external" $: isInternal = type !== "external"
$: id = $tables.selected?._id
$: search = searchTable(id)
$: columnOptions = Object.keys($search.schema || {})
// Fetch rows for specified table // Fetches new data whenever the table changes
$: { const searchTable = tableId => {
loading = true return fetchTableData({
const loadingTableId = $tables.selected?._id tableId,
api.fetchDataForTable($tables.selected?._id).then(rows => { schema,
loading = false limit: 10,
paginate: true,
// If we started a slow request then quickly change table, sometimes })
// the old data overwrites the new data.
// This check ensures that we don't do that.
if (loadingTableId !== $tables.selected?._id) {
return
} }
data = rows || [] // Fetch data whenever sorting option changes
const onSort = e => {
search.update({
sortColumn: e.detail.column,
sortOrder: e.detail.order,
}) })
} }
// Fetch data whenever filters change
const onFilter = e => {
search.update({
filters: e.detail,
})
}
// Fetch data whenever schema changes
const onUpdateColumns = () => {
search.update({
schema,
})
}
// Fetch data whenever rows are modified. Unfortunately we have to lose
// our pagination place, as our bookmarks will have shifted.
const onUpdateRows = () => {
search.update()
}
</script> </script>
<Table <div>
{title} <Table
title={$tables.selected?.name}
{schema} {schema}
tableId={$tables.selected?._id}
{data}
{type} {type}
allowEditing={true} tableId={id}
data={$search.rows}
bind:hideAutocolumns bind:hideAutocolumns
{loading} loading={$search.loading}
> on:sort={onSort}
allowEditing
disableSorting
on:updatecolumns={onUpdateColumns}
on:updaterows={onUpdateRows}
>
{#if isInternal} {#if isInternal}
<CreateColumnButton /> <CreateColumnButton on:updatecolumns={onUpdateColumns} />
{/if} {/if}
{#if schema && Object.keys(schema).length > 0} {#if schema && Object.keys(schema).length > 0}
{#if !isUsersTable} {#if !isUsersTable}
<CreateRowButton <CreateRowButton
on:updaterows={onUpdateRows}
title={"Create row"} title={"Create row"}
modalContentComponent={CreateEditRow} modalContentComponent={CreateEditRow}
/> />
@ -75,5 +100,32 @@
<HideAutocolumnButton bind:hideAutocolumns /> <HideAutocolumnButton bind:hideAutocolumns />
<!-- always have the export last --> <!-- always have the export last -->
<ExportButton view={$tables.selected?._id} /> <ExportButton view={$tables.selected?._id} />
{#key id}
<TableFilterButton {schema} on:change={onFilter} />
{/key}
{/if} {/if}
</Table> </Table>
{#key id}
<div in:fade={{ delay: 200, duration: 100 }}>
<div class="pagination">
<Pagination
page={$search.pageNumber + 1}
hasPrevPage={$search.hasPrevPage}
hasNextPage={$search.hasNextPage}
goToPrevPage={$search.loading ? null : search.prevPage}
goToNextPage={$search.loading ? null : search.nextPage}
/>
</div>
</div>
{/key}
</div>
<style>
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: var(--spacing-xl);
}
</style>

View File

@ -1,8 +1,7 @@
<script> <script>
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { Table, Modal, Heading, notifications } from "@budibase/bbui" import { Table, Modal, Heading, notifications, Layout } from "@budibase/bbui"
import api from "builderStore/api" import api from "builderStore/api"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import DeleteRowsButton from "./buttons/DeleteRowsButton.svelte" import DeleteRowsButton from "./buttons/DeleteRowsButton.svelte"
@ -21,6 +20,7 @@
export let hideAutocolumns export let hideAutocolumns
export let rowCount export let rowCount
export let type export let type
export let disableSorting = false
let selectedRows = [] let selectedRows = []
let editableColumn let editableColumn
@ -98,13 +98,14 @@
} }
</script> </script>
<div> <Layout noPadding gap="S">
<div>
<div class="table-title"> <div class="table-title">
{#if title} {#if title}
<Heading size="S">{title}</Heading> <Heading size="S">{title}</Heading>
{/if} {/if}
{#if loading} {#if loading}
<div transition:fade> <div transition:fade|local>
<Spinner size="10" /> <Spinner size="10" />
</div> </div>
{/if} {/if}
@ -112,17 +113,19 @@
<div class="popovers"> <div class="popovers">
<slot /> <slot />
{#if !isUsersTable && selectedRows.length > 0} {#if !isUsersTable && selectedRows.length > 0}
<DeleteRowsButton {selectedRows} {deleteRows} /> <DeleteRowsButton on:updaterows {selectedRows} {deleteRows} />
{/if} {/if}
</div> </div>
</div> </div>
{#key tableId} {#key tableId}
<div class="table-wrapper" in:fade={{ delay: 200, duration: 100 }}>
<Table <Table
{data} {data}
{schema} {schema}
{loading} {loading}
{customRenderers} {customRenderers}
{rowCount} {rowCount}
{disableSorting}
bind:selectedRows bind:selectedRows
allowSelectRows={allowEditing && !isUsersTable} allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing} allowEditRows={allowEditing}
@ -131,14 +134,21 @@
on:editcolumn={e => editColumn(e.detail)} on:editcolumn={e => editColumn(e.detail)}
on:editrow={e => editRow(e.detail)} on:editrow={e => editRow(e.detail)}
on:clickrelationship={e => selectRelationship(e.detail)} on:clickrelationship={e => selectRelationship(e.detail)}
on:sort
/> />
{/key} </div>
{/key}
</Layout>
<Modal bind:this={editRowModal}> <Modal bind:this={editRowModal}>
<svelte:component this={editRowComponent} row={editableRow} /> <svelte:component this={editRowComponent} on:updaterows row={editableRow} />
</Modal> </Modal>
<Modal bind:this={editColumnModal}> <Modal bind:this={editColumnModal}>
<CreateEditColumn field={editableColumn} onClosed={editColumnModal.hide} /> <CreateEditColumn
field={editableColumn}
on:updatecolumns
onClosed={editColumnModal.hide}
/>
</Modal> </Modal>
<style> <style>
@ -152,6 +162,9 @@
.table-title > div { .table-title > div {
margin-left: var(--spacing-xs); margin-left: var(--spacing-xs);
} }
.table-wrapper {
overflow: hidden;
}
.popovers { .popovers {
display: flex; display: flex;

View File

@ -5,7 +5,7 @@
import Table from "./Table.svelte" import Table from "./Table.svelte"
import CalculateButton from "./buttons/CalculateButton.svelte" import CalculateButton from "./buttons/CalculateButton.svelte"
import GroupByButton from "./buttons/GroupByButton.svelte" import GroupByButton from "./buttons/GroupByButton.svelte"
import FilterButton from "./buttons/FilterButton.svelte" import ViewFilterButton from "./buttons/ViewFilterButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte" import ExportButton from "./buttons/ExportButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte" import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte" import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
@ -61,7 +61,7 @@
allowEditing={!view?.calculation} allowEditing={!view?.calculation}
bind:hideAutocolumns bind:hideAutocolumns
> >
<FilterButton {view} /> <ViewFilterButton {view} />
<CalculateButton {view} /> <CalculateButton {view} />
{#if view.calculation} {#if view.calculation}
<GroupByButton {view} /> <GroupByButton {view} />

View File

@ -9,5 +9,5 @@
Create column Create column
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<CreateEditColumn /> <CreateEditColumn on:updatecolumns />
</Modal> </Modal>

View File

@ -12,5 +12,5 @@
{title} {title}
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<svelte:component this={modalContentComponent} /> <svelte:component this={modalContentComponent} on:updaterows />
</Modal> </Modal>

View File

@ -1,15 +1,18 @@
<script> <script>
import { createEventDispatcher } from "svelte"
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
export let selectedRows export let selectedRows
export let deleteRows export let deleteRows
const dispatch = createEventDispatcher()
let modal let modal
async function confirmDeletion() { async function confirmDeletion() {
await deleteRows() await deleteRows()
modal?.hide() modal?.hide()
dispatch("updaterows")
} }
</script> </script>

View File

@ -0,0 +1,46 @@
<script>
import { createEventDispatcher } from "svelte"
import { ActionButton, Modal, ModalContent } from "@budibase/bbui"
import FilterDrawer from "components/design/PropertiesPanel/PropertyControls/FilterEditor/FilterDrawer.svelte"
export let schema
export let filters
const dispatch = createEventDispatcher()
let modal
let tempValue = filters || []
$: schemaFields = Object.values(schema || {})
</script>
<ActionButton
icon="Filter"
size="S"
quiet
on:click={modal.show}
active={tempValue?.length > 0}
>
Filter
</ActionButton>
<Modal bind:this={modal}>
<ModalContent
title="Filter"
confirmText="Save"
size="XL"
onConfirm={() => dispatch("change", tempValue)}
>
<div class="wrapper">
<FilterDrawer
allowBindings={false}
bind:filters={tempValue}
{schemaFields}
/>
</div>
</ModalContent>
</Modal>
<style>
.wrapper :global(.main) {
padding: 0;
}
</style>

View File

@ -10,6 +10,7 @@
ModalContent, ModalContent,
Context, Context,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables } from "stores/backend" import { tables } from "stores/backend"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
@ -30,8 +31,9 @@
const AUTO_TYPE = "auto" const AUTO_TYPE = "auto"
const FORMULA_TYPE = FIELDS.FORMULA.type const FORMULA_TYPE = FIELDS.FORMULA.type
const LINK_TYPE = FIELDS.LINK.type const LINK_TYPE = FIELDS.LINK.type
let fieldDefinitions = cloneDeep(FIELDS) const dispatch = createEventDispatcher()
const { hide } = getContext(Context.Modal) const { hide } = getContext(Context.Modal)
let fieldDefinitions = cloneDeep(FIELDS)
export let field = { export let field = {
type: "string", type: "string",
@ -81,12 +83,13 @@
if (field.type === AUTO_TYPE) { if (field.type === AUTO_TYPE) {
field = buildAutoColumn($tables.draft.name, field.name, field.subtype) field = buildAutoColumn($tables.draft.name, field.name, field.subtype)
} }
tables.saveField({ await tables.saveField({
originalName, originalName,
field, field,
primaryDisplay, primaryDisplay,
indexes, indexes,
}) })
dispatch("updatecolumns")
} }
function deleteColumn() { function deleteColumn() {
@ -99,6 +102,7 @@
hide() hide()
deletion = false deletion = false
} }
dispatch("updatecolumns")
} }
function handleTypeChange(event) { function handleTypeChange(event) {

View File

@ -1,4 +1,5 @@
<script> <script>
import { createEventDispatcher } from "svelte"
import { tables, rows } from "stores/backend" import { tables, rows } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import RowFieldControl from "../RowFieldControl.svelte" import RowFieldControl from "../RowFieldControl.svelte"
@ -12,6 +13,7 @@
export let row = {} export let row = {}
let errors = [] let errors = []
const dispatch = createEventDispatcher()
$: creating = row?._id == null $: creating = row?._id == null
$: table = row.tableId $: table = row.tableId
@ -43,6 +45,7 @@
notifications.success("Row saved successfully.") notifications.success("Row saved successfully.")
rows.save(rowResponse) rows.save(rowResponse)
dispatch("updaterows")
} }
</script> </script>

View File

@ -1,4 +1,5 @@
<script> <script>
import { createEventDispatcher } from "svelte"
import { tables, rows } from "stores/backend" import { tables, rows } from "stores/backend"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -9,6 +10,7 @@
export let row = {} export let row = {}
const dispatch = createEventDispatcher()
let errors = [] let errors = []
$: creating = row?._id == null $: creating = row?._id == null
@ -71,6 +73,7 @@
notifications.success("User saved successfully") notifications.success("User saved successfully")
rows.save(rowResponse) rows.save(rowResponse)
dispatch("updaterows")
} }
</script> </script>

View File

@ -1,9 +1,9 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { get } from "svelte/store" import { get } from "svelte/store"
import { goto } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { BUDIBASE_INTERNAL_DB } from "constants" import { BUDIBASE_INTERNAL_DB } from "constants"
import { database, datasources, queries, tables } from "stores/backend" import { database, datasources, queries, tables, views } from "stores/backend"
import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte" import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte"
import EditQueryPopover from "./popovers/EditQueryPopover.svelte" import EditQueryPopover from "./popovers/EditQueryPopover.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
@ -11,16 +11,27 @@
import ICONS from "./icons" import ICONS from "./icons"
let openDataSources = [] let openDataSources = []
$: enrichedDataSources = $datasources.list.map(datasource => ({ $: enrichedDataSources = $datasources.list.map(datasource => {
const selected = $datasources.selected === datasource._id
const open = openDataSources.includes(datasource._id)
const containsSelected = containsActiveEntity(datasource)
return {
...datasource, ...datasource,
open: selected,
openDataSources.includes(datasource._id) || open: selected || open || containsSelected,
containsActiveTable(datasource), }
selected: $datasources.selected === datasource._id, })
})) $: openDataSource = enrichedDataSources.find(x => x.open)
$: {
// Ensure the open data source is always included in the list of open
// data sources
if (openDataSource) {
openNode(openDataSource)
}
}
function selectDatasource(datasource) { function selectDatasource(datasource) {
toggleNode(datasource) openNode(datasource)
datasources.select(datasource._id) datasources.select(datasource._id)
$goto(`./datasource/${datasource._id}`) $goto(`./datasource/${datasource._id}`)
} }
@ -30,12 +41,22 @@
$goto(`./datasource/${query.datasourceId}/${query._id}`) $goto(`./datasource/${query.datasourceId}/${query._id}`)
} }
function closeNode(datasource) {
openDataSources = openDataSources.filter(id => datasource._id !== id)
}
function openNode(datasource) {
if (!openDataSources.includes(datasource._id)) {
openDataSources = [...openDataSources, datasource._id]
}
}
function toggleNode(datasource) { function toggleNode(datasource) {
const isOpen = openDataSources.includes(datasource._id) const isOpen = openDataSources.includes(datasource._id)
if (isOpen) { if (isOpen) {
openDataSources = openDataSources.filter(id => datasource._id !== id) closeNode(datasource)
} else { } else {
openDataSources = [...openDataSources, datasource._id] openNode(datasource)
} }
} }
@ -44,16 +65,35 @@
queries.fetch() queries.fetch()
}) })
const containsActiveTable = datasource => { const containsActiveEntity = datasource => {
const activeTableId = get(tables).selected?._id // If we're view a query then the data source ID is in the URL
if ($params.selectedDatasource === datasource._id) {
return true
}
// If there are no entities it can't contain anything
if (!datasource.entities) { if (!datasource.entities) {
return false return false
} }
let tableOptions = datasource.entities
if (!Array.isArray(tableOptions)) { // Get a list of table options
tableOptions = Object.values(tableOptions) let options = datasource.entities
if (!Array.isArray(options)) {
options = Object.values(options)
} }
return tableOptions.find(x => x._id === activeTableId) != null
// Check for a matching table
if ($params.selectedTable) {
const selectedTable = get(tables).selected?._id
return options.find(x => x._id === selectedTable) != null
}
// Check for a matching view
const selectedView = get(views).selected?.name
const table = options.find(table => {
return table.views?.[selectedView] != null
})
return table != null
} }
</script> </script>

View File

@ -1,20 +1,28 @@
<script> <script>
import { Label, Input, Layout, Toggle } from "@budibase/bbui" import { Label, Input, Layout, Toggle, Button } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { capitalise } from "helpers" import { capitalise } from "helpers"
export let integration export let integration
export let schema export let schema
let addButton
</script> </script>
<form> <form>
<Layout gap="S"> <Layout gap="S">
{#each Object.keys(schema) as configKey} {#each Object.keys(schema) as configKey}
{#if schema[configKey].type === "object"} {#if schema[configKey].type === "object"}
<div class="form-row ssl">
<Label>{capitalise(configKey)}</Label> <Label>{capitalise(configKey)}</Label>
<Button secondary thin outline on:click={addButton.addEntry()}
>Add</Button
>
</div>
<KeyValueBuilder <KeyValueBuilder
bind:this={addButton}
defaults={schema[configKey].default} defaults={schema[configKey].default}
bind:object={integration[configKey]} bind:object={integration[configKey]}
noAddButton={true}
/> />
{:else if schema[configKey].type === "boolean"} {:else if schema[configKey].type === "boolean"}
<div class="form-row"> <div class="form-row">
@ -42,4 +50,11 @@
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
align-items: center; align-items: center;
} }
.form-row.ssl {
display: grid;
grid-template-columns: 20% 20%;
grid-gap: var(--spacing-l);
align-items: center;
}
</style> </style>

View File

@ -1,74 +1,160 @@
<script> <script>
import { goto } from "@roxi/routify" import { ModalContent, Modal, Body, Layout, Detail } from "@budibase/bbui"
import { datasources } from "stores/backend" import { onMount } from "svelte"
import { notifications } from "@budibase/bbui" import ICONS from "../icons"
import { Input, Label, ModalContent, Modal, Context } from "@budibase/bbui" import api from "builderStore/api"
import TableIntegrationMenu from "../TableIntegrationMenu/index.svelte" import { IntegrationNames } from "constants"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import analytics, { Events } from "analytics" import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import { getContext } from "svelte"
const modalContext = getContext(Context.Modal) export let modal
let integrations = []
let integration = {}
let internalTableModal
let externalDatasourceModal
let tableModal const INTERNAL = "BUDIBASE"
let name
let error = ""
let integration
$: checkOpenModal(integration && integration.type === "BUDIBASE") onMount(() => {
fetchIntegrations()
function checkValid(evt) {
const datasourceName = evt.target.value
if (
$datasources?.list.some(datasource => datasource.name === datasourceName)
) {
error = `Datasource with name ${datasourceName} already exists. Please choose another name.`
return
}
error = ""
}
function checkOpenModal(isInternal) {
if (isInternal) {
tableModal.show()
}
}
async function saveDatasource() {
const { type, plus, ...config } = integration
// Create datasource
const response = await datasources.save({
name,
source: type,
config,
plus,
}) })
notifications.success(`Datasource ${name} created successfully.`)
analytics.captureEvent(Events.DATASOURCE.CREATED, { name, type })
// Navigate to new datasource function selectIntegration(integrationType) {
$goto(`./datasource/${response._id}`) const selected = integrations[integrationType]
// build the schema
const config = {}
for (let key of Object.keys(selected.datasource)) {
config[key] = selected.datasource[key].default
}
integration = {
type: integrationType,
plus: selected.plus,
config,
schema: selected.datasource,
}
}
function chooseNextModal() {
if (integration.type === INTERNAL) {
externalDatasourceModal.hide()
internalTableModal.show()
} else {
externalDatasourceModal.show()
}
}
async function fetchIntegrations() {
const response = await api.get("/api/integrations")
const json = await response.json()
integrations = {
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
...json,
}
return json
} }
</script> </script>
<Modal bind:this={tableModal} on:hide={modalContext.hide}> <Modal bind:this={internalTableModal}>
<CreateTableModal bind:name /> <CreateTableModal />
</Modal> </Modal>
<ModalContent
title="Create Datasource" <Modal bind:this={externalDatasourceModal}>
size="L" <DatasourceConfigModal {integration} />
confirmText="Create" </Modal>
onConfirm={saveDatasource}
disabled={error || !name || !integration?.type} <Modal bind:this={modal}>
> <ModalContent
<Input disabled={!Object.keys(integration).length}
data-cy="datasource-name-input" title="Data"
label="Datasource Name" confirmText="Continue"
on:input={checkValid} showCancelButton={false}
bind:value={name} size="M"
{error} onConfirm={() => {
chooseNextModal()
}}
>
<Layout noPadding>
<Body size="XS"
>All apps need data. You can connect to a data source below, or add data
to your app using Budibase's built-in database.
</Body>
<div
class:selected={integration.type === INTERNAL}
on:click={() => selectIntegration(INTERNAL)}
class="item hoverable"
>
<div class="item-body">
<svelte:component this={ICONS.BUDIBASE} height="18" width="18" />
<span class="icon-spacing"> <Body size="S">Budibase DB</Body></span>
</div>
</div>
</Layout>
<Layout gap="XS" noPadding>
<div class="title-spacing">
<Detail size="S">Connect to data source</Detail>
</div>
<div class="item-list">
{#each Object.entries(integrations).filter(([key]) => key !== INTERNAL) as [integrationType, schema]}
<div
class:selected={integration.type === integrationType}
on:click={() => selectIntegration(integrationType)}
class="item hoverable"
>
<div class="item-body">
<svelte:component
this={ICONS[integrationType]}
height="18"
width="18"
/> />
<Label>Datasource Type</Label>
<TableIntegrationMenu bind:integration /> <span class="icon-spacing">
</ModalContent> <Body size="S"
>{schema.name || IntegrationNames[integrationType]}</Body
></span
>
</div>
</div>
{/each}
</div>
</Layout>
</ModalContent>
</Modal>
<style>
.icon-spacing {
margin-left: var(--spacing-m);
}
.item-body {
display: flex;
margin-left: var(--spacing-m);
}
.item-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-gap: var(--spectrum-alias-grid-baseline);
}
.item {
cursor: pointer;
display: grid;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: solid var(--spectrum-alias-border-color);
border-radius: 5px;
box-sizing: border-box;
border-width: 2px;
}
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.item:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
</style>

View File

@ -0,0 +1,72 @@
<script>
import { goto } from "@roxi/routify"
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui"
import analytics, { Events } from "analytics"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { datasources } from "stores/backend"
import { IntegrationNames } from "constants"
export let integration
function prepareData() {
let datasource = {}
let existingTypeCount = $datasources.list.filter(
ds => ds.source == integration.type
).length
let baseName = IntegrationNames[integration.type]
let name =
existingTypeCount == 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
datasource.type = "datasource"
datasource.source = integration.type
datasource.config = integration.config
datasource.name = name
datasource.plus = integration.plus
return datasource
}
async function saveDatasource() {
const datasource = prepareData()
try {
// Create datasource
const resp = await datasources.save(datasource, datasource.plus)
await datasources.select(resp._id)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource updated successfully.`)
analytics.captureEvent(Events.DATASOURCE.CREATED, {
name: resp.name,
source: resp.source,
})
return true
} catch (err) {
notifications.error(`Error saving datasource: ${err}`)
return false
}
}
</script>
<ModalContent
title={`Connect to ${IntegrationNames[integration.type]}`}
onConfirm={() => saveDatasource()}
confirmText={integration.plus
? "Fetch tables from database"
: "Save and continue to query"}
cancelText="Back"
size="M"
>
<Layout noPadding>
<Body size="XS"
>Connect your database to Budibase using the config below.
</Body>
</Layout>
<IntegrationConfigForm
schema={integration.schema}
bind:integration={integration.config}
/>
</ModalContent>
<style>
</style>

View File

@ -1,4 +1,5 @@
<script> <script>
import { get } from "svelte/store"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { store, currentAsset } from "builderStore" import { store, currentAsset } from "builderStore"
import iframeTemplate from "./iframeTemplate" import iframeTemplate from "./iframeTemplate"
@ -7,6 +8,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ProgressCircle, Layout, Heading, Body } from "@budibase/bbui" import { ProgressCircle, Layout, Heading, Body } from "@budibase/bbui"
import ErrorSVG from "assets/error.svg?raw" import ErrorSVG from "assets/error.svg?raw"
import { findComponent, findComponentPath } from "builderStore/storeUtils"
let iframe let iframe
let layout let layout
@ -102,7 +104,7 @@
iframe.contentWindow.addEventListener("keydown", handleKeydownEvent) iframe.contentWindow.addEventListener("keydown", handleKeydownEvent)
}) })
// remove all iframe event listeners on component destroy // Remove all iframe event listeners on component destroy
onDestroy(() => { onDestroy(() => {
if (iframe.contentWindow) { if (iframe.contentWindow) {
iframe.contentWindow.removeEventListener("bb-event", handleBudibaseEvent) iframe.contentWindow.removeEventListener("bb-event", handleBudibaseEvent)
@ -122,6 +124,26 @@
// Wait for this event to show the client library if intelligent // Wait for this event to show the client library if intelligent
// loading is supported // loading is supported
loading = false loading = false
} else if (type === "move-component") {
const { componentId, destinationComponentId } = data
const rootComponent = get(currentAsset).props
// Get source and destination components
const source = findComponent(rootComponent, componentId)
const destination = findComponent(rootComponent, destinationComponentId)
// Stop if the target is a child of source
const path = findComponentPath(source, destinationComponentId)
const ids = path.map(component => component._id)
if (ids.includes(data.destinationComponentId)) {
return
}
// Cut and paste the component to the new destination
if (source && destination) {
store.actions.components.copy(source, true)
store.actions.components.paste(destination, data.mode)
}
} else { } else {
console.warning(`Client sent unknown event type: ${type}`) console.warning(`Client sent unknown event type: ${type}`)
} }

View File

@ -12,7 +12,7 @@
import { dndzone } from "svelte-dnd-action" import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid" import { generate } from "shortid"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { OperatorOptions, getValidOperatorsForType } from "helpers/lucene" import { OperatorOptions, getValidOperatorsForType } from "constants/lucene"
import { selectedComponent, store } from "builderStore" import { selectedComponent, store } from "builderStore"
import { getComponentForSettingType } from "./componentSettings" import { getComponentForSettingType } from "./componentSettings"
import PropertyControl from "./PropertyControl.svelte" import PropertyControl from "./PropertyControl.svelte"

View File

@ -13,18 +13,20 @@
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import BindingPanel from "components/common/bindings/BindingPanel.svelte" import BindingPanel from "components/common/bindings/BindingPanel.svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { getValidOperatorsForType, OperatorOptions } from "helpers/lucene" import { getValidOperatorsForType, OperatorOptions } from "constants/lucene"
export let schemaFields export let schemaFields
export let filters = [] export let filters = []
export let bindings = [] export let bindings = []
export let panel = BindingPanel export let panel = BindingPanel
export let allowBindings = true
const BannedTypes = ["link", "attachment", "formula"] const BannedTypes = ["link", "attachment", "formula"]
$: fieldOptions = (schemaFields ?? []) $: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type)) .filter(field => !BannedTypes.includes(field.type))
.map(field => field.name) .map(field => field.name)
$: valueTypeOptions = allowBindings ? ["Value", "Binding"] : ["Value"]
const addFilter = () => { const addFilter = () => {
filters = [ filters = [
@ -93,7 +95,7 @@
<Layout noPadding> <Layout noPadding>
<Body size="S"> <Body size="S">
{#if !filters?.length} {#if !filters?.length}
Add your first filter column. Add your first filter expression.
{:else} {:else}
Results are filtered to only those which match all of the following Results are filtered to only those which match all of the following
constraints. constraints.
@ -117,7 +119,7 @@
/> />
<Select <Select
disabled={filter.noValue || !filter.field} disabled={filter.noValue || !filter.field}
options={["Value", "Binding"]} options={valueTypeOptions}
bind:value={filter.valueType} bind:value={filter.valueType}
placeholder={null} placeholder={null}
/> />

View File

@ -20,7 +20,6 @@
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance) $: dataSource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource($currentAsset, dataSource)?.schema $: schema = getSchemaForDatasource($currentAsset, dataSource)?.schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: internalTable = dataSource?.type === "table"
const saveFilter = async () => { const saveFilter = async () => {
dispatch("change", tempValue) dispatch("change", tempValue)

View File

@ -4,6 +4,7 @@
export let defaults export let defaults
export let object = defaults || {} export let object = defaults || {}
export let readOnly export let readOnly
export let noAddButton
let fields = Object.entries(object).map(([name, value]) => ({ name, value })) let fields = Object.entries(object).map(([name, value]) => ({ name, value }))
@ -12,7 +13,7 @@
{} {}
) )
function addEntry() { export function addEntry() {
fields = [...fields, {}] fields = [...fields, {}]
} }
@ -32,7 +33,7 @@
{/if} {/if}
{/each} {/each}
</div> </div>
{#if !readOnly} {#if !readOnly && !noAddButton}
<div> <div>
<Button secondary thin outline on:click={addEntry}>Add</Button> <Button secondary thin outline on:click={addEntry}>Add</Button>
</div> </div>

View File

@ -15,6 +15,20 @@ export const AppStatus = {
DEPLOYED: "published", DEPLOYED: "published",
} }
export const IntegrationNames = {
POSTGRES: "PostgreSQL",
MONGODB: "MongoDB",
COUCHDB: "CouchDB",
S3: "S3",
MYSQL: "MySQL",
REST: "REST",
DYNAMODB: "DynamoDB",
ELASTICSEARCH: "ElasticSearch",
SQL_SERVER: "SQL Server",
AIRTABLE: "Airtable",
ARANGODB: "ArangoDB",
}
// fields on the user table that cannot be edited // fields on the user table that cannot be edited
export const UNEDITABLE_USER_FIELDS = [ export const UNEDITABLE_USER_FIELDS = [
"email", "email",

View File

@ -0,0 +1,97 @@
/**
* Operator options for lucene queries
*/
export const OperatorOptions = {
Equals: {
value: "equal",
label: "Equals",
},
NotEquals: {
value: "notEqual",
label: "Not equals",
},
Empty: {
value: "empty",
label: "Is empty",
},
NotEmpty: {
value: "notEmpty",
label: "Is not empty",
},
StartsWith: {
value: "string",
label: "Starts with",
},
Like: {
value: "fuzzy",
label: "Like",
},
MoreThan: {
value: "rangeLow",
label: "More than",
},
LessThan: {
value: "rangeHigh",
label: "Less than",
},
Contains: {
value: "equal",
label: "Contains",
},
NotContains: {
value: "notEqual",
label: "Does Not Contain",
},
}
/**
* Returns the valid operator options for a certain data type
* @param type the data type
*/
export const getValidOperatorsForType = type => {
const Op = OperatorOptions
if (type === "string") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "array") {
return [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
}
return []
}

View File

@ -0,0 +1,206 @@
// Do not use any aliased imports in common files, as these will be bundled
// by multiple bundlers which may not be able to resolve them
import { writable, derived, get } from "svelte/store"
import * as API from "../builderStore/api"
import { buildLuceneQuery } from "./lucene"
const defaultOptions = {
tableId: null,
filters: null,
limit: 10,
sortColumn: null,
sortOrder: "ascending",
paginate: true,
schema: null,
}
export const fetchTableData = opts => {
// Save option set so we can override it later rather than relying on params
let options = {
...defaultOptions,
...opts,
}
// Local non-observable state
let query
let sortType
let lastBookmark
// Local observable state
const store = writable({
rows: [],
schema: null,
loading: false,
loaded: false,
bookmarks: [],
pageNumber: 0,
})
// Derive certain properties to return
const derivedStore = derived(store, $store => {
return {
...$store,
hasNextPage: $store.bookmarks[$store.pageNumber + 1] != null,
hasPrevPage: $store.pageNumber > 0,
}
})
const fetchPage = async bookmark => {
lastBookmark = bookmark
const { tableId, limit, sortColumn, sortOrder, paginate } = options
store.update($store => ({ ...$store, loading: true }))
const res = await API.post(`/api/${options.tableId}/search`, {
tableId,
query,
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType,
paginate,
bookmark,
})
store.update($store => ({ ...$store, loading: false, loaded: true }))
return await res.json()
}
// Fetches a fresh set of results from the server
const fetchData = async () => {
const { tableId, schema, sortColumn, filters } = options
// Ensure table ID exists
if (!tableId) {
return
}
// Get and enrich schema.
// Ensure there are "name" properties for all fields and that field schema
// are objects
let enrichedSchema = schema
if (!enrichedSchema) {
const definition = await API.get(`/api/tables/${tableId}`)
enrichedSchema = definition?.schema ?? null
}
if (enrichedSchema) {
Object.entries(schema).forEach(([fieldName, fieldSchema]) => {
if (typeof fieldSchema === "string") {
enrichedSchema[fieldName] = {
type: fieldSchema,
name: fieldName,
}
} else {
enrichedSchema[fieldName] = {
...fieldSchema,
name: fieldName,
}
}
})
// Save fixed schema so we can provide it later
options.schema = enrichedSchema
}
// Ensure schema exists
if (!schema) {
return
}
store.update($store => ({ ...$store, schema }))
// Work out what sort type to use
if (!sortColumn || !schema[sortColumn]) {
sortType = "string"
}
const type = schema?.[sortColumn]?.type
sortType = type === "number" ? "number" : "string"
// Build the lucene query
query = buildLuceneQuery(filters)
// Actually fetch data
const page = await fetchPage()
store.update($store => ({
...$store,
loading: false,
loaded: true,
pageNumber: 0,
rows: page.rows,
bookmarks: page.hasNextPage ? [null, page.bookmark] : [null],
}))
}
// Fetches the next page of data
const nextPage = async () => {
const state = get(derivedStore)
if (state.loading || !options.paginate || !state.hasNextPage) {
return
}
// Fetch next page
const page = await fetchPage(state.bookmarks[state.pageNumber + 1])
// Update state
store.update($store => {
let { bookmarks, pageNumber } = $store
if (page.hasNextPage) {
bookmarks[pageNumber + 2] = page.bookmark
}
return {
...$store,
pageNumber: pageNumber + 1,
rows: page.rows,
bookmarks,
}
})
}
// Fetches the previous page of data
const prevPage = async () => {
const state = get(derivedStore)
if (state.loading || !options.paginate || !state.hasPrevPage) {
return
}
// Fetch previous page
const page = await fetchPage(state.bookmarks[state.pageNumber - 1])
// Update state
store.update($store => {
return {
...$store,
pageNumber: $store.pageNumber - 1,
rows: page.rows,
}
})
}
// Resets the data set and updates options
const update = async newOptions => {
if (newOptions) {
options = {
...options,
...newOptions,
}
}
await fetchData()
}
// Loads the same page again
const refresh = async () => {
if (get(store).loading) {
return
}
const page = await fetchPage(lastBookmark)
store.update($store => ({ ...$store, rows: page.rows }))
}
// Initially fetch data but don't bother waiting for the result
fetchData()
// Return our derived store which will be updated over time
return {
subscribe: derivedStore.subscribe,
nextPage,
prevPage,
update,
refresh,
}
}

View File

@ -1,90 +1,179 @@
export const OperatorOptions = { /**
Equals: { * Builds a lucene JSON query from the filter structure generated in the builder
value: "equal", * @param filter the builder filter structure
label: "Equals", */
}, export const buildLuceneQuery = filter => {
NotEquals: { let query = {
value: "notEqual", string: {},
label: "Not equals", fuzzy: {},
}, range: {},
Empty: { equal: {},
value: "empty", notEqual: {},
label: "Is empty", empty: {},
}, notEmpty: {},
NotEmpty: { contains: {},
value: "notEmpty", notContains: {},
label: "Is not empty", }
}, if (Array.isArray(filter)) {
StartsWith: { filter.forEach(expression => {
value: "string", let { operator, field, type, value } = expression
label: "Starts with", // Parse all values into correct types
}, if (type === "datetime" && value) {
Like: { value = new Date(value).toISOString()
value: "fuzzy", }
label: "Like", if (type === "number") {
}, value = parseFloat(value)
MoreThan: { }
value: "rangeLow", if (type === "boolean") {
label: "More than", value = `${value}`?.toLowerCase() === "true"
}, }
LessThan: { if (operator.startsWith("range")) {
value: "rangeHigh", if (!query.range[field]) {
label: "Less than", query.range[field] = {
}, low:
Contains: { type === "number"
value: "equal", ? Number.MIN_SAFE_INTEGER
label: "Contains", : "0000-00-00T00:00:00.000Z",
}, high:
NotContains: { type === "number"
value: "notEqual", ? Number.MAX_SAFE_INTEGER
label: "Does Not Contain", : "9999-00-00T00:00:00.000Z",
}, }
}
if (operator === "rangeLow" && value != null && value !== "") {
query.range[field].low = value
} else if (operator === "rangeHigh" && value != null && value !== "") {
query.range[field].high = value
}
} else if (query[operator]) {
if (type === "boolean") {
// Transform boolean filters to cope with null.
// "equals false" needs to be "not equals true"
// "not equals false" needs to be "equals true"
if (operator === "equal" && value === false) {
query.notEqual[field] = true
} else if (operator === "notEqual" && value === false) {
query.equal[field] = true
} else {
query[operator][field] = value
}
} else {
query[operator][field] = value
}
}
})
}
return query
} }
export const getValidOperatorsForType = type => { /**
const Op = OperatorOptions * Performs a client-side lucene search on an array of data
if (type === "string") { * @param docs the data
return [ * @param query the JSON lucene query
Op.Equals, */
Op.NotEquals, export const luceneQuery = (docs, query) => {
Op.StartsWith, if (!query) {
Op.Like, return docs
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "array") {
return [Op.Contains, Op.NotContains, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} }
return []
// Iterates over a set of filters and evaluates a fail function against a doc
const match = (type, failFn) => doc => {
const filters = Object.entries(query[type] || {})
for (let i = 0; i < filters.length; i++) {
if (failFn(filters[i][0], filters[i][1], doc)) {
return false
}
}
return true
}
// Process a string match (fails if the value does not start with the string)
const stringMatch = match("string", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
})
// Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
})
// Process a range match
const rangeMatch = match("range", (key, value, doc) => {
return !doc[key] || doc[key] < value.low || doc[key] > value.high
})
// Process an equal match (fails if the value is different)
const equalMatch = match("equal", (key, value, doc) => {
return value != null && value !== "" && doc[key] !== value
})
// Process a not-equal match (fails if the value is the same)
const notEqualMatch = match("notEqual", (key, value, doc) => {
return value != null && value !== "" && doc[key] === value
})
// Process an empty match (fails if the value is not empty)
const emptyMatch = match("empty", (key, value, doc) => {
return doc[key] != null && doc[key] !== ""
})
// Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match("notEmpty", (key, value, doc) => {
return doc[key] == null || doc[key] === ""
})
// Match a document against all criteria
const docMatch = doc => {
return (
stringMatch(doc) &&
fuzzyMatch(doc) &&
rangeMatch(doc) &&
equalMatch(doc) &&
notEqualMatch(doc) &&
emptyMatch(doc) &&
notEmptyMatch(doc)
)
}
// Process all docs
return docs.filter(docMatch)
}
/**
* Performs a client-side sort from the equivalent server-side lucene sort
* parameters.
* @param docs the data
* @param sort the sort column
* @param sortOrder the sort order ("ascending" or "descending")
* @param sortType the type of sort ("string" or "number")
*/
export const luceneSort = (docs, sort, sortOrder, sortType = "string") => {
if (!sort || !sortOrder || !sortType) {
return docs
}
const parse = sortType === "string" ? x => `${x}` : x => parseFloat(x)
return docs.slice().sort((a, b) => {
const colA = parse(a[sort])
const colB = parse(b[sort])
if (sortOrder === "Descending") {
return colA > colB ? -1 : 1
} else {
return colA > colB ? 1 : -1
}
})
}
/**
* Limits the specified docs to the specified number of rows from the equivalent
* server-side lucene limit parameters.
* @param docs the data
* @param limit the number of docs to limit to
*/
export const luceneLimit = (docs, limit) => {
const numLimit = parseFloat(limit)
if (isNaN(numLimit)) {
return docs
}
return docs.slice(0, numLimit)
} }

View File

@ -0,0 +1,16 @@
export const suppressWarnings = warnings => {
if (!warnings?.length) {
return
}
const regex = new RegExp(warnings.map(x => `(${x})`).join("|"), "gi")
const warn = console.warn
console.warn = (...params) => {
const msg = params[0]
if (msg && typeof msg === "string") {
if (msg.match(regex)) {
return
}
}
warn(...params)
}
}

View File

@ -7,11 +7,19 @@ import "@spectrum-css/vars/dist/spectrum-light.css"
import "@spectrum-css/vars/dist/spectrum-lightest.css" import "@spectrum-css/vars/dist/spectrum-lightest.css"
import "@spectrum-css/page/dist/index-vars.css" import "@spectrum-css/page/dist/index-vars.css"
import "./global.css" import "./global.css"
import { suppressWarnings } from "./helpers/warnings"
import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-vite.js" import loadSpectrumIcons from "@budibase/bbui/spectrum-icons-vite.js"
import App from "./App.svelte"
// Init spectrum icons
loadSpectrumIcons() loadSpectrumIcons()
import App from "./App.svelte" // Suppress svelte runtime warnings
suppressWarnings([
"was created with unknown prop",
"was created without expected prop",
"received an unexpected slot",
])
export default new App({ export default new App({
target: document.getElementById("app"), target: document.getElementById("app"),

View File

@ -4,6 +4,9 @@
import { onMount } from "svelte" import { onMount } from "svelte"
let loaded = false let loaded = false
// don't react to these
let cloud = $admin.cloud
let shouldRedirect = !cloud || $admin.disableAccountPortal
$: multiTenancyEnabled = $admin.multiTenancy $: multiTenancyEnabled = $admin.multiTenancy
$: hasAdminUser = $admin?.checklist?.adminUser?.checked $: hasAdminUser = $admin?.checklist?.adminUser?.checked
@ -39,30 +42,35 @@
$: { $: {
// We should never see the org or admin user creation screens in the cloud // We should never see the org or admin user creation screens in the cloud
if (!cloud) {
const apiReady = $admin.loaded && $auth.loaded const apiReady = $admin.loaded && $auth.loaded
// if tenant is not set go to it // if tenant is not set go to it
if (loaded && apiReady && multiTenancyEnabled && !tenantSet) { if (
loaded &&
shouldRedirect &&
apiReady &&
multiTenancyEnabled &&
!tenantSet
) {
$redirect("./auth/org") $redirect("./auth/org")
} }
// Force creation of an admin user if one doesn't exist // Force creation of an admin user if one doesn't exist
else if (loaded && apiReady && !hasAdminUser) { else if (loaded && shouldRedirect && apiReady && !hasAdminUser) {
$redirect("./admin") $redirect("./admin")
} }
}
}
// Redirect to log in at any time if the user isn't authenticated // Redirect to log in at any time if the user isn't authenticated
$: { else if (
if (
loaded && loaded &&
(hasAdminUser || cloud) && (hasAdminUser || cloud) &&
!$auth.user && !$auth.user &&
!$isActive("./auth") && !$isActive("./auth") &&
!$isActive("./invite") !$isActive("./invite") &&
!$isActive("./admin")
) { ) {
const returnUrl = encodeURIComponent(window.location.pathname) const returnUrl = encodeURIComponent(window.location.pathname)
$redirect("./auth?", { returnUrl }) $redirect("./auth?", { returnUrl })
} else if ($auth?.user?.forceResetPassword) { }
// check if password reset required for user
else if ($auth.user?.forceResetPassword) {
$redirect("./auth/reset") $redirect("./auth/reset")
} }
} }

View File

@ -0,0 +1,50 @@
<script>
import { notifications, ModalContent, Dropzone, Body } from "@budibase/bbui"
import { post } from "builderStore/api"
let submitting = false
$: value = { file: null }
async function importApps() {
submitting = true
try {
// Create form data to create app
let data = new FormData()
data.append("importFile", value.file)
// Create App
const importResp = await post("/api/cloud/import", data, {})
const importJson = await importResp.json()
if (!importResp.ok) {
throw new Error(importJson.message)
}
// now reload to get to login
window.location.reload()
} catch (error) {
notifications.error(error)
submitting = false
}
}
</script>
<ModalContent
title="Import apps"
confirmText="Import apps"
onConfirm={importApps}
disabled={!value.file}
>
<Body
>Please upload the file that was exported from your Cloud environment to get
started</Body
>
<Dropzone
gallery={false}
label="File to import"
value={[value.file]}
on:change={e => {
value.file = e.detail?.[0]
}}
/>
</ModalContent>

View File

@ -7,18 +7,22 @@
Input, Input,
Body, Body,
ActionButton, ActionButton,
Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import api from "builderStore/api" import api from "builderStore/api"
import { admin, auth } from "stores/portal" import { admin, auth } from "stores/portal"
import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte" import PasswordRepeatInput from "components/common/users/PasswordRepeatInput.svelte"
import ImportAppsModal from "./_components/ImportAppsModal.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
let adminUser = {} let adminUser = {}
let error let error
let modal
$: tenantId = $auth.tenantId $: tenantId = $auth.tenantId
$: multiTenancyEnabled = $admin.multiTenancy $: multiTenancyEnabled = $admin.multiTenancy
$: cloud = $admin.cloud
async function save() { async function save() {
try { try {
@ -38,6 +42,9 @@
} }
</script> </script>
<Modal bind:this={modal} padding={false} width="600px">
<ImportAppsModal />
</Modal>
<section> <section>
<div class="container"> <div class="container">
<Layout> <Layout>
@ -66,6 +73,15 @@
> >
Change organisation Change organisation
</ActionButton> </ActionButton>
{:else if !cloud}
<ActionButton
quiet
on:click={() => {
modal.show()
}}
>
Import from cloud
</ActionButton>
{/if} {/if}
</Layout> </Layout>
</Layout> </Layout>

View File

@ -1,12 +1,14 @@
<script> <script>
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { Icon, Modal, Tabs, Tab } from "@budibase/bbui" import { Icon, Tabs, Tab } from "@budibase/bbui"
import { BUDIBASE_INTERNAL_DB } from "constants" import { BUDIBASE_INTERNAL_DB } from "constants"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte" import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte" import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
let selected = "Sources" let selected = "Sources"
let modal let modal
$: isExternal = $: isExternal =
$params.selectedDatasource && $params.selectedDatasource &&
$params.selectedDatasource !== BUDIBASE_INTERNAL_DB $params.selectedDatasource !== BUDIBASE_INTERNAL_DB
@ -23,9 +25,7 @@
<Tab title="Sources"> <Tab title="Sources">
<div class="tab-content-padding"> <div class="tab-content-padding">
<DatasourceNavigator /> <DatasourceNavigator />
<Modal bind:this={modal}> <CreateDatasourceModal bind:modal />
<CreateDatasourceModal />
</Modal>
</div> </div>
</Tab> </Tab>
</Tabs> </Tabs>

View File

@ -1 +1,13 @@
<script>
import { params } from "@roxi/routify"
import { queries } from "stores/backend"
if ($params.query) {
const query = $queries.list.find(q => q._id === $params.query)
if (query) {
queries.select(query)
}
}
</script>
<slot /> <slot />

View File

@ -2,7 +2,7 @@
import { params } from "@roxi/routify" import { params } from "@roxi/routify"
import { datasources } from "stores/backend" import { datasources } from "stores/backend"
if ($params.selectedDatasource) { if ($params.selectedDatasource && !$params.query) {
const datasource = $datasources.list.find( const datasource = $datasources.list.find(
m => m._id === $params.selectedDatasource m => m._id === $params.selectedDatasource
) )

View File

@ -1,14 +1 @@
<script>
import { datasources } from "stores/backend"
import { goto, leftover } from "@roxi/routify"
import { onMount } from "svelte"
onMount(async () => {
// navigate to first datasource in list, if not already selected
if (!$leftover && $datasources.list.length > 0 && !$datasources.selected) {
$goto(`./${$datasources.list[0]._id}`)
}
})
</script>
<slot /> <slot />

View File

@ -1,13 +0,0 @@
<script>
import { params } from "@roxi/routify"
import { tables } from "stores/backend"
if ($params.selectedTable) {
const table = $tables.list.find(m => m._id === $params.selectedTable)
if (table) {
tables.select(table)
}
}
</script>
<slot />

View File

@ -1,16 +0,0 @@
<script>
import TableDataTable from "components/backend/DataTable/DataTable.svelte"
import { tables, database } from "stores/backend"
</script>
{#if $database?._id && $tables?.selected?.name}
<TableDataTable />
{:else}<i>Create your first table to start building</i>{/if}
<style>
i {
font-size: var(--font-size-m);
color: var(--grey-5);
margin-top: 2px;
}
</style>

View File

@ -1,10 +0,0 @@
<script>
import { params } from "@roxi/routify"
import RelationshipDataTable from "components/backend/DataTable/RelationshipDataTable.svelte"
</script>
<RelationshipDataTable
tableId={$params.selectedTable}
rowId={$params.selectedRow}
fieldName={decodeURI($params.selectedField)}
/>

View File

@ -1,6 +0,0 @@
<script>
import { goto } from "@roxi/routify"
$goto("../../")
</script>
<!-- routify:options index=false -->

View File

@ -1,6 +0,0 @@
<script>
import { goto } from "@roxi/routify"
$goto("../")
</script>
<!-- routify:options index=false -->

View File

@ -1,19 +0,0 @@
<script>
import { tables } from "stores/backend"
import { goto, leftover } from "@roxi/routify"
import { onMount } from "svelte"
onMount(async () => {
// navigate to first table in list, if not already selected
// and this is the final url (i.e. no selectedTable)
if (
!$leftover &&
$tables.list.length > 0
// (!$tables.selected || !$tables.selected._id)
) {
$goto(`./${$tables.list[0]._id}`)
}
})
</script>
<slot />

View File

@ -1,21 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import { tables } from "stores/backend"
onMount(async () => {
$tables.list.length > 0 && $goto(`./${$tables.list[0]._id}`)
})
</script>
{#if $tables.list.length === 0}
<i>Create your first table to start building</i>
{:else}<i>Select a table to edit</i>{/if}
<style>
i {
font-size: var(--font-size-m);
color: var(--grey-5);
margin-top: 2px;
}
</style>

View File

@ -1,6 +1,22 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import CreateDatasourceModal from "components/backend/DatasourceNavigator/modals/CreateDatasourceModal.svelte"
import { datasources } from "stores/backend"
let modal
$: setupComplete =
$datasources.list.find(x => (x._id = "bb_internal")).entities.length > 1 ||
$datasources.list.length > 1
onMount(() => {
if (!setupComplete) {
modal.show()
} else {
$goto("./table") $goto("./table")
}
})
</script> </script>
<CreateDatasourceModal bind:modal />
<!-- routify:options index=false --> <!-- routify:options index=false -->

View File

@ -9,10 +9,10 @@
$redirect("../") $redirect("../")
} }
// redirect to account portal for authentication in the cloud
if ( if (
!$auth.user && !$auth.user &&
$admin.cloud && $admin.cloud &&
!$admin.disableAccountPortal &&
$admin.accountPortalUrl && $admin.accountPortalUrl &&
!$admin?.checklist?.sso?.checked !$admin?.checklist?.sso?.checked
) { ) {

View File

@ -9,6 +9,7 @@
let tenantId = get(auth).tenantSet ? get(auth).tenantId : "" let tenantId = get(auth).tenantSet ? get(auth).tenantId : ""
$: multiTenancyEnabled = $admin.multiTenancy $: multiTenancyEnabled = $admin.multiTenancy
$: cloud = $admin.cloud $: cloud = $admin.cloud
$: disableAccountPortal = $admin.disableAccountPortal
async function setOrg() { async function setOrg() {
if (tenantId == null || tenantId === "") { if (tenantId == null || tenantId === "") {
@ -26,7 +27,7 @@
onMount(async () => { onMount(async () => {
await auth.checkQueryString() await auth.checkQueryString()
if (!multiTenancyEnabled || cloud) { if (!multiTenancyEnabled || (cloud && !disableAccountPortal)) {
$goto("../") $goto("../")
} else { } else {
admin.unload() admin.unload()

View File

@ -5,11 +5,9 @@
auth.checkQueryString() auth.checkQueryString()
$: { $: {
if (!$auth.user) { if ($auth.user?.builder?.global) {
$redirect(`./auth`)
} else if ($auth.user.builder?.global) {
$redirect(`./portal`) $redirect(`./portal`)
} else { } else if ($auth.user) {
$redirect(`./apps`) $redirect(`./apps`)
} }
} }

View File

@ -12,6 +12,7 @@
Page, Page,
notifications, notifications,
Body, Body,
Search,
} from "@budibase/bbui" } from "@budibase/bbui"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
@ -35,8 +36,13 @@
let unpublishModal let unpublishModal
let creatingApp = false let creatingApp = false
let loaded = false let loaded = false
let searchTerm = ""
let cloud = $admin.cloud
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: enrichedApps = enrichApps($apps, $auth.user, sortBy)
$: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
)
const enrichApps = (apps, user, sortBy) => { const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({ const enrichedApps = apps.map(app => ({
@ -45,6 +51,7 @@
lockedYou: app.lockedBy && app.lockedBy.email === user?.email, lockedYou: app.lockedBy && app.lockedBy.email === user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== user?.email, lockedOther: app.lockedBy && app.lockedBy.email !== user?.email,
})) }))
if (sortBy === "status") { if (sortBy === "status") {
return enrichedApps.sort((a, b) => { return enrichedApps.sort((a, b) => {
if (a.status === b.status) { if (a.status === b.status) {
@ -70,6 +77,15 @@
creatingApp = true creatingApp = true
} }
const initiateAppsExport = () => {
try {
download(`/api/cloud/export`)
notifications.success("Apps exported successfully")
} catch (err) {
notifications.error(`Error exporting apps: ${err}`)
}
}
const initiateAppImport = () => { const initiateAppImport = () => {
template = { fromFile: true } template = { fromFile: true }
creationModal.show() creationModal.show()
@ -190,6 +206,9 @@
<div class="title"> <div class="title">
<Heading>Apps</Heading> <Heading>Apps</Heading>
<ButtonGroup> <ButtonGroup>
{#if cloud}
<Button secondary on:click={initiateAppsExport}>Export apps</Button>
{/if}
<Button secondary on:click={initiateAppImport}>Import app</Button> <Button secondary on:click={initiateAppImport}>Import app</Button>
<Button cta on:click={initiateAppCreation}>Create app</Button> <Button cta on:click={initiateAppCreation}>Create app</Button>
</ButtonGroup> </ButtonGroup>
@ -205,6 +224,7 @@
{ label: "Sort by status", value: "status" }, { label: "Sort by status", value: "status" },
]} ]}
/> />
<Search placeholder="Search" bind:value={searchTerm} />
</div> </div>
<ActionGroup> <ActionGroup>
<ActionButton <ActionButton
@ -225,7 +245,7 @@
class:appGrid={layout === "grid"} class:appGrid={layout === "grid"}
class:appTable={layout === "table"} class:appTable={layout === "table"}
> >
{#each enrichedApps as app (app.appId)} {#each filteredApps as app (app.appId)}
<svelte:component <svelte:component
this={layout === "grid" ? AppCard : AppRow} this={layout === "grid" ? AppCard : AppRow}
{releaseLock} {releaseLock}
@ -301,7 +321,9 @@
} }
.select { .select {
width: 190px; display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 10px;
} }
.appGrid { .appGrid {

View File

@ -67,7 +67,7 @@ export function createDatasourcesStore() {
}) })
return json return json
}, },
save: async datasource => { save: async (datasource, fetchSchema = false) => {
let response let response
if (datasource._id) { if (datasource._id) {
response = await api.put( response = await api.put(
@ -75,7 +75,10 @@ export function createDatasourcesStore() {
datasource datasource
) )
} else { } else {
response = await api.post("/api/datasources", datasource) response = await api.post("/api/datasources", {
datasource: datasource,
fetchSchema,
})
} }
const json = await response.json() const json = await response.json()

View File

@ -1,5 +1,5 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { datasources, integrations, tables } from "./" import { datasources, integrations, tables, views } from "./"
import api from "builderStore/api" import api from "builderStore/api"
export function createQueriesStore() { export function createQueriesStore() {
@ -55,10 +55,9 @@ export function createQueriesStore() {
}, },
select: query => { select: query => {
update(state => ({ ...state, selected: query._id })) update(state => ({ ...state, selected: query._id }))
tables.update(state => ({ views.unselect()
...state, tables.unselect()
selected: null, datasources.unselect()
}))
}, },
unselect: () => { unselect: () => {
update(state => ({ ...state, selected: null })) update(state => ({ ...state, selected: null }))

View File

@ -95,7 +95,13 @@ export function createTablesStore() {
selected: {}, selected: {},
})) }))
}, },
saveField: ({ originalName, field, primaryDisplay = false, indexes }) => { saveField: async ({
originalName,
field,
primaryDisplay = false,
indexes,
}) => {
let promise
update(state => { update(state => {
// delete the original if renaming // delete the original if renaming
// need to handle if the column had no name, empty string // need to handle if the column had no name, empty string
@ -126,9 +132,12 @@ export function createTablesStore() {
...state.draft.schema, ...state.draft.schema,
[field.name]: cloneDeep(field), [field.name]: cloneDeep(field),
} }
save(state.draft) promise = save(state.draft)
return state return state
}) })
if (promise) {
await promise
}
}, },
deleteField: field => { deleteField: field => {
update(state => { update(state => {

View File

@ -16,6 +16,7 @@ export function createViewsStore() {
...state, ...state,
selected: view, selected: view,
})) }))
tables.unselect()
queries.unselect() queries.unselect()
datasources.unselect() datasources.unselect()
}, },

View File

@ -7,6 +7,7 @@ export function createAdminStore() {
loaded: false, loaded: false,
multiTenancy: false, multiTenancy: false,
cloud: false, cloud: false,
disableAccountPortal: false,
accountPortalUrl: "", accountPortalUrl: "",
onboardingProgress: 0, onboardingProgress: 0,
checklist: { checklist: {
@ -47,12 +48,14 @@ export function createAdminStore() {
async function getEnvironment() { async function getEnvironment() {
let multiTenancyEnabled = false let multiTenancyEnabled = false
let cloud = false let cloud = false
let disableAccountPortal = false
let accountPortalUrl = "" let accountPortalUrl = ""
try { try {
const response = await api.get(`/api/system/environment`) const response = await api.get(`/api/system/environment`)
const json = await response.json() const json = await response.json()
multiTenancyEnabled = json.multiTenancy multiTenancyEnabled = json.multiTenancy
cloud = json.cloud cloud = json.cloud
disableAccountPortal = json.disableAccountPortal
accountPortalUrl = json.accountPortalUrl accountPortalUrl = json.accountPortalUrl
} catch (err) { } catch (err) {
// just let it stay disabled // just let it stay disabled
@ -60,6 +63,7 @@ export function createAdminStore() {
admin.update(store => { admin.update(store => {
store.multiTenancy = multiTenancyEnabled store.multiTenancy = multiTenancyEnabled
store.cloud = cloud store.cloud = cloud
store.disableAccountPortal = disableAccountPortal
store.accountPortalUrl = accountPortalUrl store.accountPortalUrl = accountPortalUrl
return store return store
}) })

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "0.9.146", "version": "0.9.147-alpha.0",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "0.9.146", "version": "0.9.147-alpha.0",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -19,9 +19,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.146", "@budibase/bbui": "^0.9.147-alpha.0",
"@budibase/standard-components": "^0.9.139", "@budibase/standard-components": "^0.9.139",
"@budibase/string-templates": "^0.9.146", "@budibase/string-templates": "^0.9.147-alpha.0",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5" "svelte-spa-router": "^3.0.5"

View File

@ -58,6 +58,10 @@ export default {
find: "sdk", find: "sdk",
replacement: path.resolve("./src/sdk"), replacement: path.resolve("./src/sdk"),
}, },
{
find: "builder",
replacement: path.resolve("../builder"),
},
], ],
}), }),
svelte({ svelte({

View File

@ -23,7 +23,8 @@
import SelectionIndicator from "components/preview/SelectionIndicator.svelte" import SelectionIndicator from "components/preview/SelectionIndicator.svelte"
import HoverIndicator from "components/preview/HoverIndicator.svelte" import HoverIndicator from "components/preview/HoverIndicator.svelte"
import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
import ErrorSVG from "../../../builder/assets/error.svg" import DNDHandler from "components/preview/DNDHandler.svelte"
import ErrorSVG from "builder/assets/error.svg"
// Provide contexts // Provide contexts
setContext("sdk", SDK) setContext("sdk", SDK)
@ -104,7 +105,10 @@
<div id="app-root"> <div id="app-root">
<CustomThemeWrapper> <CustomThemeWrapper>
{#key $screenStore.activeLayout._id} {#key $screenStore.activeLayout._id}
<Component instance={$screenStore.activeLayout.props} /> <Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key} {/key}
<!-- Layers on top of app --> <!-- Layers on top of app -->
@ -122,6 +126,7 @@
{#if $builderStore.inBuilder} {#if $builderStore.inBuilder}
<SelectionIndicator /> <SelectionIndicator />
<HoverIndicator /> <HoverIndicator />
<DNDHandler />
{/if} {/if}
</div> </div>
</StateBindingsProvider> </StateBindingsProvider>

View File

@ -11,6 +11,8 @@
import Placeholder from "components/app/Placeholder.svelte" import Placeholder from "components/app/Placeholder.svelte"
export let instance = {} export let instance = {}
export let isLayout = false
export let isScreen = false
// The enriched component settings // The enriched component settings
let enrichedSettings let enrichedSettings
@ -49,11 +51,11 @@
$: children = instance._children || [] $: children = instance._children || []
$: id = instance._id $: id = instance._id
$: name = instance._instanceName $: name = instance._instanceName
$: empty = $: interactive =
!children.length && $builderStore.inBuilder &&
definition?.hasChildren && ($builderStore.previewType === "layout" || insideScreenslot)
definition?.showEmptyState !== false && $: empty = interactive && !children.length && definition?.hasChildren
$builderStore.inBuilder $: emptyState = empty && definition?.showEmptyState !== false
$: rawProps = getRawProps(instance) $: rawProps = getRawProps(instance)
$: instanceKey = JSON.stringify(rawProps) $: instanceKey = JSON.stringify(rawProps)
$: updateComponentProps(rawProps, instanceKey, $context) $: updateComponentProps(rawProps, instanceKey, $context)
@ -61,16 +63,16 @@
$builderStore.inBuilder && $builderStore.inBuilder &&
$builderStore.selectedComponentId === instance._id $builderStore.selectedComponentId === instance._id
$: inSelectedPath = $builderStore.selectedComponentPath?.includes(id) $: inSelectedPath = $builderStore.selectedComponentPath?.includes(id)
$: interactive = $builderStore.previewType === "layout" || insideScreenslot
$: evaluateConditions(enrichedSettings?._conditions) $: evaluateConditions(enrichedSettings?._conditions)
$: componentSettings = { ...enrichedSettings, ...conditionalSettings } $: componentSettings = { ...enrichedSettings, ...conditionalSettings }
$: renderKey = `${propsHash}-${emptyState}`
// Update component context // Update component context
$: componentStore.set({ $: componentStore.set({
id, id,
children: children.length, children: children.length,
styles: { ...instance._styles, id, empty, interactive }, styles: { ...instance._styles, id, empty: emptyState, interactive },
empty, empty: emptyState,
selected, selected,
name, name,
}) })
@ -169,13 +171,22 @@
conditionalSettings = result.settingUpdates conditionalSettings = result.settingUpdates
visible = nextVisible visible = nextVisible
} }
// Drag and drop helper tags
$: draggable = interactive && !isLayout && !isScreen
$: droppable = interactive && !isLayout && !isScreen
</script> </script>
{#key propsHash} {#key renderKey}
{#if constructor && componentSettings && (visible || inSelectedPath)} {#if constructor && componentSettings && (visible || inSelectedPath)}
<!-- The ID is used as a class because getElementsByClassName is O(1) -->
<!-- and the performance matters for the selection indicators -->
<div <div
class={`component ${id}`} class={`component ${id}`}
data-type={interactive ? "component" : ""} class:draggable
class:droppable
class:empty
class:interactive
data-id={id} data-id={id}
data-name={name} data-name={name}
> >
@ -184,7 +195,7 @@
{#each children as child (child._id)} {#each children as child (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} />
{/each} {/each}
{:else if empty} {:else if emptyState}
<Placeholder /> <Placeholder />
{/if} {/if}
</svelte:component> </svelte:component>
@ -196,4 +207,10 @@
.component { .component {
display: contents; display: contents;
} }
.interactive :global(*:hover) {
cursor: pointer;
}
.draggable :global(*:hover) {
cursor: grab;
}
</style> </style>

View File

@ -22,6 +22,6 @@
<!-- Ensure to fully remount when screen changes --> <!-- Ensure to fully remount when screen changes -->
{#key screenDefinition?._id} {#key screenDefinition?._id}
<Provider key="url" data={params}> <Provider key="url" data={params}>
<Component instance={screenDefinition} /> <Component isScreen instance={screenDefinition} />
</Provider> </Provider>
{/key} {/key}

View File

@ -31,4 +31,7 @@
.spectrum-Button--overBackground:hover { .spectrum-Button--overBackground:hover {
color: #555; color: #555;
} }
.spectrum-Button::after {
display: none;
}
</style> </style>

View File

@ -34,7 +34,7 @@
display: flex; display: flex;
max-width: 100%; max-width: 100%;
} }
.valid-container :global([data-type="component"] > *) { .valid-container :global(.component > *) {
max-width: 100%; max-width: 100%;
} }
.direction-row { .direction-row {
@ -46,7 +46,7 @@
/* Grow containers inside a row need 0 width 0 so that they ignore content */ /* Grow containers inside a row need 0 width 0 so that they ignore content */
/* The nested selector for data-type is the wrapper around all components */ /* The nested selector for data-type is the wrapper around all components */
.direction-row :global(> [data-type="component"] > .size-grow) { .direction-row :global(> .component > .size-grow) {
width: 0; width: 0;
} }

View File

@ -6,7 +6,7 @@
luceneQuery, luceneQuery,
luceneSort, luceneSort,
luceneLimit, luceneLimit,
} from "utils/lucene" } from "builder/src/helpers/lucene"
import Placeholder from "./Placeholder.svelte" import Placeholder from "./Placeholder.svelte"
export let dataSource export let dataSource

View File

@ -0,0 +1,240 @@
<script context="module">
export const Sides = {
Top: "Top",
Right: "Right",
Bottom: "Bottom",
Left: "Left",
}
</script>
<script>
import { onMount } from "svelte"
import { get } from "svelte/store"
import IndicatorSet from "./IndicatorSet.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
import { builderStore } from "stores"
let dragInfo
let dropInfo
const getEdges = (bounds, mousePoint) => {
const { width, height, top, left } = bounds
return {
[Sides.Top]: [mousePoint[0], top],
[Sides.Right]: [left + width, mousePoint[1]],
[Sides.Bottom]: [mousePoint[0], top + height],
[Sides.Left]: [left, mousePoint[1]],
}
}
const calculatePointDelta = (point1, point2) => {
const deltaX = Math.abs(point1[0] - point2[0])
const deltaY = Math.abs(point1[1] - point2[1])
return Math.sqrt(deltaX * deltaX + deltaY * deltaY)
}
const getDOMNodeForComponent = component => {
const parent = component.closest(".component")
const children = Array.from(parent.childNodes)
return children?.find(node => node?.nodeType === 1)
}
// Callback when initially starting a drag on a draggable component
const onDragStart = e => {
const parent = e.target.closest(".component")
if (!parent?.classList.contains("draggable")) {
return
}
// Update state
dragInfo = {
target: parent.dataset.id,
parent: parent.dataset.parent,
}
builderStore.actions.selectComponent(dragInfo.target)
builderStore.actions.setDragging(true)
// Highlight being dragged by setting opacity
const child = getDOMNodeForComponent(e.target)
if (child) {
child.style.opacity = "0.5"
}
}
// Callback when drag stops (whether dropped or not)
const onDragEnd = e => {
// Reset opacity style
if (dragInfo) {
const child = getDOMNodeForComponent(e.target)
if (child) {
child.style.opacity = ""
}
}
// Reset state and styles
dragInfo = null
dropInfo = null
builderStore.actions.setDragging(false)
}
// Callback when on top of a component
const onDragOver = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo || !dropInfo) {
return
}
e.preventDefault()
const { droppableInside, bounds } = dropInfo
const { top, left, height, width } = bounds
const mouseY = e.clientY
const mouseX = e.clientX
const snapFactor = droppableInside ? 0.33 : 0.5
const snapLimitV = Math.min(40, height * snapFactor)
const snapLimitH = Math.min(40, width * snapFactor)
// Determine all sies we are within snap range of
let sides = []
if (mouseY <= top + snapLimitV) {
sides.push(Sides.Top)
} else if (mouseY >= top + height - snapLimitV) {
sides.push(Sides.Bottom)
}
if (mouseX < left + snapLimitH) {
sides.push(Sides.Left)
} else if (mouseX > left + width - snapLimitH) {
sides.push(Sides.Right)
}
// When no edges match, drop inside if possible
if (!sides.length) {
dropInfo.mode = droppableInside ? "inside" : null
dropInfo.side = null
return
}
// When one edge matches, use that edge
if (sides.length === 1) {
dropInfo.side = sides[0]
if ([Sides.Top, Sides.Left].includes(sides[0])) {
dropInfo.mode = "above"
} else {
dropInfo.mode = "below"
}
return
}
// When 2 edges match, work out which is closer
const mousePoint = [mouseX, mouseY]
const edges = getEdges(bounds, mousePoint)
const edge1 = edges[sides[0]]
const delta1 = calculatePointDelta(mousePoint, edge1)
const edge2 = edges[sides[1]]
const delta2 = calculatePointDelta(mousePoint, edge2)
const edge = delta1 < delta2 ? sides[0] : sides[1]
dropInfo.side = edge
if ([Sides.Top, Sides.Left].includes(edge)) {
dropInfo.mode = "above"
} else {
dropInfo.mode = "below"
}
}
// Callback when entering a potential drop target
const onDragEnter = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo) {
return
}
const element = e.target.closest(".component")
if (
element &&
element.classList.contains("droppable") &&
element.dataset.id !== dragInfo.target
) {
// Do nothing if this is the same target
if (element.dataset.id === dropInfo?.target) {
return
}
// Ensure the dragging flag is always set.
// There's a bit of a race condition between the app reinitialisation
// after selecting the DND component and setting this the first time
if (!get(builderStore).isDragging) {
builderStore.actions.setDragging(true)
}
// Store target ID
const target = element.dataset.id
// Precompute and store some info to avoid recalculating everything in
// dragOver
const child = getDOMNodeForComponent(e.target)
const bounds = child.getBoundingClientRect()
dropInfo = {
target,
name: element.dataset.name,
droppableInside: element.classList.contains("empty"),
bounds,
}
} else {
dropInfo = null
}
}
// Callback when leaving a potential drop target.
// Since we don't style our targets, we don't need to unset anything.
const onDragLeave = () => {}
// Callback when dropping a drag on top of some component
const onDrop = e => {
e.preventDefault()
if (dropInfo?.mode) {
builderStore.actions.moveComponent(
dragInfo.target,
dropInfo.target,
dropInfo.mode
)
}
}
onMount(() => {
// Events fired on the draggable target
document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragend", onDragEnd, false)
// Events fired on the drop targets
document.addEventListener("dragover", onDragOver, false)
document.addEventListener("dragenter", onDragEnter, false)
document.addEventListener("dragleave", onDragLeave, false)
document.addEventListener("drop", onDrop, false)
return () => {
// Events fired on the draggable target
document.removeEventListener("dragstart", onDragStart, false)
document.removeEventListener("dragend", onDragEnd, false)
// Events fired on the drop targets
document.removeEventListener("dragover", onDragOver, false)
document.removeEventListener("dragenter", onDragEnter, false)
document.removeEventListener("dragleave", onDragLeave, false)
document.removeEventListener("drop", onDrop, false)
}
})
</script>
<IndicatorSet
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null}
color="var(--spectrum-global-color-static-green-500)"
zIndex="930"
transition
prefix="Inside"
/>
<DNDPositionIndicator
{dropInfo}
color="var(--spectrum-global-color-static-green-500)"
zIndex="940"
transition
/>

View File

@ -0,0 +1,54 @@
<script>
import Indicator from "./Indicator.svelte"
import { Sides } from "./DNDHandler.svelte"
export let dropInfo
export let zIndex
export let color
export let transition
$: dimensions = getDimensions(dropInfo)
$: prefix = dropInfo?.mode === "above" ? "Before" : "After"
$: text = `${prefix} ${dropInfo?.name}`
$: renderKey = `${dropInfo?.target}-${dropInfo?.side}`
const getDimensions = info => {
const { bounds, side } = info ?? {}
if (!bounds || !side) {
return null
}
const { left, top, width, height } = bounds
if (side === Sides.Top || side === Sides.Bottom) {
return {
top: side === Sides.Top ? top - 4 : top + height,
left: left - 2,
width: width + 4,
height: 0,
}
} else {
return {
top: top - 2,
left: side === Sides.Left ? left - 4 : left + width,
width: 0,
height: height + 4,
}
}
}
</script>
{#key renderKey}
{#if dimensions && dropInfo?.mode !== "inside"}
<Indicator
left={Math.round(dimensions.left)}
top={Math.round(dimensions.top)}
width={dimensions.width}
height={dimensions.height}
{text}
{zIndex}
{color}
{transition}
alignRight={dropInfo?.side === Sides.Right}
line
/>
{/if}
{/key}

View File

@ -7,7 +7,7 @@
$: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920 $: zIndex = componentId === $builderStore.selectedComponentId ? 900 : 920
const onMouseOver = e => { const onMouseOver = e => {
const element = e.target.closest("[data-type='component']") const element = e.target.closest(".interactive.component")
const newId = element?.dataset?.id const newId = element?.dataset?.id
if (newId !== componentId) { if (newId !== componentId) {
componentId = newId componentId = newId
@ -30,7 +30,7 @@
</script> </script>
<IndicatorSet <IndicatorSet
{componentId} componentId={$builderStore.isDragging ? null : componentId}
color="var(--spectrum-global-color-static-blue-200)" color="var(--spectrum-global-color-static-blue-200)"
transition transition
{zIndex} {zIndex}

View File

@ -9,6 +9,10 @@
export let color export let color
export let zIndex export let zIndex
export let transition = false export let transition = false
export let line = false
export let alignRight = false
$: flipped = top < 20
</script> </script>
<div <div
@ -18,11 +22,12 @@
}} }}
out:fade={{ duration: transition ? 130 : 0 }} out:fade={{ duration: transition ? 130 : 0 }}
class="indicator" class="indicator"
class:flipped={top < 20} class:flipped
class:line
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};" style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
> >
{#if text} {#if text}
<div class="text" class:flipped={top < 20}> <div class="text" class:flipped class:line class:right={alignRight}>
{text} {text}
</div> </div>
{/if} {/if}
@ -30,6 +35,7 @@
<style> <style>
.indicator { .indicator {
right: 0;
position: absolute; position: absolute;
z-index: var(--zIndex); z-index: var(--zIndex);
border: 2px solid var(--color); border: 2px solid var(--color);
@ -42,6 +48,9 @@
.indicator.flipped { .indicator.flipped {
border-top-left-radius: 4px; border-top-left-radius: 4px;
} }
.indicator.line {
border-radius: 4px !important;
}
.text { .text {
background-color: var(--color); background-color: var(--color);
color: white; color: white;
@ -61,9 +70,18 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
} }
.text.line {
transform: translateY(-50%);
border-radius: 4px;
}
.text.flipped { .text.flipped {
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
transform: translateY(0%); transform: translateY(0%);
top: -2px; top: -2px;
} }
.text.right {
right: -2px;
left: auto;
}
</style> </style>

View File

@ -7,6 +7,7 @@
export let color export let color
export let transition export let transition
export let zIndex export let zIndex
export let prefix = null
let indicators = [] let indicators = []
let interval let interval
@ -51,6 +52,9 @@
const parents = document.getElementsByClassName(componentId) const parents = document.getElementsByClassName(componentId)
if (parents.length) { if (parents.length) {
text = parents[0].dataset.name text = parents[0].dataset.name
if (prefix) {
text = `${prefix} ${text}`
}
} }
// Batch reads to minimize reflow // Batch reads to minimize reflow

View File

@ -16,7 +16,7 @@
let measured = false let measured = false
$: definition = $builderStore.selectedComponentDefinition $: definition = $builderStore.selectedComponentDefinition
$: showBar = definition?.showSettingsBar $: showBar = definition?.showSettingsBar && !$builderStore.isDragging
$: settings = definition?.settings?.filter(setting => setting.showInBar) ?? [] $: settings = definition?.settings?.filter(setting => setting.showInBar) ?? []
const updatePosition = () => { const updatePosition = () => {

View File

@ -23,6 +23,7 @@ const createBuilderStore = () => {
theme: null, theme: null,
customTheme: null, customTheme: null,
previewDevice: "desktop", previewDevice: "desktop",
isDragging: false,
} }
const writableStore = writable(initialState) const writableStore = writable(initialState)
const derivedStore = derived(writableStore, $state => { const derivedStore = derived(writableStore, $state => {
@ -64,13 +65,24 @@ const createBuilderStore = () => {
dispatchEvent("preview-loaded") dispatchEvent("preview-loaded")
}, },
setSelectedPath: path => { setSelectedPath: path => {
console.log("set to ")
console.log(path)
writableStore.update(state => { writableStore.update(state => {
state.selectedPath = path state.selectedPath = path
return state return state
}) })
}, },
moveComponent: (componentId, destinationComponentId, mode) => {
dispatchEvent("move-component", {
componentId,
destinationComponentId,
mode,
})
},
setDragging: dragging => {
writableStore.update(state => {
state.isDragging = dragging
return state
})
},
} }
return { return {
...writableStore, ...writableStore,

View File

@ -1,5 +1,5 @@
import { writable, get, derived } from "svelte/store" import { writable, get, derived } from "svelte/store"
import { localStorageStore } from "../../../builder/src/builderStore/store/localStorage" import { localStorageStore } from "builder/src/builderStore/store/localStorage"
import { appStore } from "./app" import { appStore } from "./app"
const createStateStore = () => { const createStateStore = () => {

View File

@ -1,4 +1,4 @@
import { buildLuceneQuery, luceneQuery } from "./lucene" import { buildLuceneQuery, luceneQuery } from "builder/src/helpers/lucene"
export const getActiveConditions = conditions => { export const getActiveConditions = conditions => {
if (!conditions?.length) { if (!conditions?.length) {

View File

@ -1,179 +0,0 @@
/**
* Builds a lucene JSON query from the filter structure generated in the builder
* @param filter the builder filter structure
*/
export const buildLuceneQuery = filter => {
let query = {
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
contains: {},
notContains: {},
}
if (Array.isArray(filter)) {
filter.forEach(expression => {
let { operator, field, type, value } = expression
// Parse all values into correct types
if (type === "datetime" && value) {
value = new Date(value).toISOString()
}
if (type === "number") {
value = parseFloat(value)
}
if (type === "boolean") {
value = `${value}`?.toLowerCase() === "true"
}
if (operator.startsWith("range")) {
if (!query.range[field]) {
query.range[field] = {
low:
type === "number"
? Number.MIN_SAFE_INTEGER
: "0000-00-00T00:00:00.000Z",
high:
type === "number"
? Number.MAX_SAFE_INTEGER
: "9999-00-00T00:00:00.000Z",
}
}
if (operator === "rangeLow" && value != null && value !== "") {
query.range[field].low = value
} else if (operator === "rangeHigh" && value != null && value !== "") {
query.range[field].high = value
}
} else if (query[operator]) {
if (type === "boolean") {
// Transform boolean filters to cope with null.
// "equals false" needs to be "not equals true"
// "not equals false" needs to be "equals true"
if (operator === "equal" && value === false) {
query.notEqual[field] = true
} else if (operator === "notEqual" && value === false) {
query.equal[field] = true
} else {
query[operator][field] = value
}
} else {
query[operator][field] = value
}
}
})
}
return query
}
/**
* Performs a client-side lucene search on an array of data
* @param docs the data
* @param query the JSON lucene query
*/
export const luceneQuery = (docs, query) => {
if (!query) {
return docs
}
// Iterates over a set of filters and evaluates a fail function against a doc
const match = (type, failFn) => doc => {
const filters = Object.entries(query[type] || {})
for (let i = 0; i < filters.length; i++) {
if (failFn(filters[i][0], filters[i][1], doc)) {
return false
}
}
return true
}
// Process a string match (fails if the value does not start with the string)
const stringMatch = match("string", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
})
// Process a fuzzy match (treat the same as starts with when running locally)
const fuzzyMatch = match("fuzzy", (key, value, doc) => {
return !doc[key] || !doc[key].startsWith(value)
})
// Process a range match
const rangeMatch = match("range", (key, value, doc) => {
return !doc[key] || doc[key] < value.low || doc[key] > value.high
})
// Process an equal match (fails if the value is different)
const equalMatch = match("equal", (key, value, doc) => {
return value != null && value !== "" && doc[key] !== value
})
// Process a not-equal match (fails if the value is the same)
const notEqualMatch = match("notEqual", (key, value, doc) => {
return value != null && value !== "" && doc[key] === value
})
// Process an empty match (fails if the value is not empty)
const emptyMatch = match("empty", (key, value, doc) => {
return doc[key] != null && doc[key] !== ""
})
// Process a not-empty match (fails is the value is empty)
const notEmptyMatch = match("notEmpty", (key, value, doc) => {
return doc[key] == null || doc[key] === ""
})
// Match a document against all criteria
const docMatch = doc => {
return (
stringMatch(doc) &&
fuzzyMatch(doc) &&
rangeMatch(doc) &&
equalMatch(doc) &&
notEqualMatch(doc) &&
emptyMatch(doc) &&
notEmptyMatch(doc)
)
}
// Process all docs
return docs.filter(docMatch)
}
/**
* Performs a client-side sort from the equivalent server-side lucene sort
* parameters.
* @param docs the data
* @param sort the sort column
* @param sortOrder the sort order ("ascending" or "descending")
* @param sortType the type of sort ("string" or "number")
*/
export const luceneSort = (docs, sort, sortOrder, sortType = "string") => {
if (!sort || !sortOrder || !sortType) {
return docs
}
const parse = sortType === "string" ? x => `${x}` : x => parseFloat(x)
return docs.slice().sort((a, b) => {
const colA = parse(a[sort])
const colB = parse(b[sort])
if (sortOrder === "Descending") {
return colA > colB ? -1 : 1
} else {
return colA > colB ? 1 : -1
}
})
}
/**
* Limits the specified docs to the specified number of rows from the equivalent
* server-side lucene limit parameters.
* @param docs the data
* @param limit the number of docs to limit to
*/
export const luceneLimit = (docs, limit) => {
const numLimit = parseFloat(limit)
if (isNaN(numLimit)) {
return docs
}
return docs.slice(0, numLimit)
}

View File

@ -23,10 +23,14 @@ export const styleable = (node, styles = {}) => {
let applyHoverStyles let applyHoverStyles
let selectComponent let selectComponent
// Allow dragging if required
const parent = node.closest(".component")
if (parent && parent.classList.contains("draggable")) {
node.setAttribute("draggable", true)
}
// Creates event listeners and applies initial styles // Creates event listeners and applies initial styles
const setupStyles = (newStyles = {}) => { const setupStyles = (newStyles = {}) => {
// Use empty state styles as base styles if required, but let them, get
// overridden by any user specified styles
let baseStyles = {} let baseStyles = {}
if (newStyles.empty) { if (newStyles.empty) {
baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)" baseStyles.border = "2px dashed var(--spectrum-global-color-gray-600)"
@ -45,7 +49,6 @@ export const styleable = (node, styles = {}) => {
// Applies a style string to a DOM node // Applies a style string to a DOM node
const applyStyles = styleString => { const applyStyles = styleString => {
node.style = styleString node.style = styleString
node.dataset.componentId = componentId
} }
// Applies the "normal" style definition // Applies the "normal" style definition

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "0.9.146", "version": "0.9.147-alpha.0",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.js", "main": "src/index.js",
"repository": { "repository": {
@ -64,9 +64,9 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.9.146", "@budibase/auth": "^0.9.147-alpha.0",
"@budibase/client": "^0.9.146", "@budibase/client": "^0.9.147-alpha.0",
"@budibase/string-templates": "^0.9.146", "@budibase/string-templates": "^0.9.147-alpha.0",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
"@koa/router": "8.0.0", "@koa/router": "8.0.0",
"@sendgrid/mail": "7.1.1", "@sendgrid/mail": "7.1.1",
@ -96,6 +96,7 @@
"koa-session": "5.12.0", "koa-session": "5.12.0",
"koa-static": "5.0.0", "koa-static": "5.0.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"memorystream": "^0.3.1",
"mongodb": "3.6.3", "mongodb": "3.6.3",
"mssql": "6.2.3", "mssql": "6.2.3",
"mysql": "2.18.1", "mysql": "2.18.1",

View File

@ -37,7 +37,7 @@ async function init() {
const envFileJson = { const envFileJson = {
PORT: 4001, PORT: 4001,
MINIO_URL: "http://localhost:10000/", MINIO_URL: "http://localhost:10000/",
COUCH_DB_URL: "http://@localhost:10000/db/", COUCH_DB_URL: "http://budibase:budibase@localhost:10000/db/",
REDIS_URL: "localhost:6379", REDIS_URL: "localhost:6379",
WORKER_URL: "http://localhost:4002", WORKER_URL: "http://localhost:4002",
INTERNAL_API_KEY: "budibase", INTERNAL_API_KEY: "budibase",
@ -48,6 +48,7 @@ async function init() {
COUCH_DB_PASSWORD: "budibase", COUCH_DB_PASSWORD: "budibase",
COUCH_DB_USER: "budibase", COUCH_DB_USER: "budibase",
SELF_HOSTED: 1, SELF_HOSTED: 1,
DISABLE_ACCOUNT_PORTAL: "",
MULTI_TENANCY: "", MULTI_TENANCY: "",
} }
let envFile = "" let envFile = ""

View File

@ -31,7 +31,7 @@ const {
getDeployedApps, getDeployedApps,
removeAppFromUserRoles, removeAppFromUserRoles,
} = require("../../utilities/workerRequests") } = require("../../utilities/workerRequests")
const { clientLibraryPath } = require("../../utilities") const { clientLibraryPath, stringToReadStream } = require("../../utilities")
const { getAllLocks } = require("../../utilities/redis") const { getAllLocks } = require("../../utilities/redis")
const { const {
updateClientLibrary, updateClientLibrary,
@ -114,8 +114,13 @@ async function createInstance(template) {
// replicate the template data to the instance DB // replicate the template data to the instance DB
// this is currently very hard to test, downloading and importing template files // this is currently very hard to test, downloading and importing template files
if (template && template.templateString) {
const { ok } = await db.load(stringToReadStream(template.templateString))
if (!ok) {
throw "Error loading database dump from memory."
}
} else if (template && template.useTemplate === "true") {
/* istanbul ignore next */ /* istanbul ignore next */
if (template && template.useTemplate === "true") {
const { ok } = await db.load(await getTemplateStream(template)) const { ok } = await db.load(await getTemplateStream(template))
if (!ok) { if (!ok) {
throw "Error loading database dump from template." throw "Error loading database dump from template."
@ -191,10 +196,11 @@ exports.fetchAppPackage = async function (ctx) {
} }
exports.create = async function (ctx) { exports.create = async function (ctx) {
const { useTemplate, templateKey } = ctx.request.body const { useTemplate, templateKey, templateString } = ctx.request.body
const instanceConfig = { const instanceConfig = {
useTemplate, useTemplate,
key: templateKey, key: templateKey,
templateString,
} }
if (ctx.request.files && ctx.request.files.templateFile) { if (ctx.request.files && ctx.request.files.templateFile) {
instanceConfig.file = ctx.request.files.templateFile instanceConfig.file = ctx.request.files.templateFile

View File

@ -0,0 +1,92 @@
const env = require("../../environment")
const { getAllApps } = require("@budibase/auth/db")
const CouchDB = require("../../db")
const {
exportDB,
sendTempFile,
readFileSync,
} = require("../../utilities/fileSystem")
const { stringToReadStream } = require("../../utilities")
const { getGlobalDBName, getGlobalDB } = require("@budibase/auth/tenancy")
const { create } = require("./application")
const { getDocParams, DocumentTypes, isDevAppID } = require("../../db/utils")
async function createApp(appName, appImport) {
const ctx = {
request: {
body: {
templateString: appImport,
name: appName,
},
},
}
return create(ctx)
}
exports.exportApps = async ctx => {
if (env.SELF_HOSTED || !env.MULTI_TENANCY) {
ctx.throw(400, "Exporting only allowed in multi-tenant cloud environments.")
}
const apps = await getAllApps(CouchDB, { all: true })
const globalDBString = await exportDB(getGlobalDBName())
let allDBs = {
global: globalDBString,
}
for (let app of apps) {
// only export the dev apps as they will be the latest, the user can republish the apps
// in their self hosted environment
if (isDevAppID(app._id)) {
allDBs[app.name] = await exportDB(app._id)
}
}
const filename = `cloud-export-${new Date().getTime()}.txt`
ctx.attachment(filename)
ctx.body = sendTempFile(JSON.stringify(allDBs))
}
async function getAllDocType(db, docType) {
const response = await db.allDocs(
getDocParams(docType, null, {
include_docs: true,
})
)
return response.rows.map(row => row.doc)
}
exports.importApps = async ctx => {
if (!env.SELF_HOSTED || env.MULTI_TENANCY) {
ctx.throw(400, "Importing only allowed in self hosted environments.")
}
const apps = await getAllApps(CouchDB, { all: true })
if (
apps.length !== 0 ||
!ctx.request.files ||
!ctx.request.files.importFile
) {
ctx.throw(
400,
"Import file is required and environment must be fresh to import apps."
)
}
const importFile = ctx.request.files.importFile
const importString = readFileSync(importFile.path)
const dbs = JSON.parse(importString)
const globalDbImport = dbs.global
// remove from the list of apps
delete dbs.global
const globalDb = getGlobalDB()
// load the global db first
await globalDb.load(stringToReadStream(globalDbImport))
for (let [appName, appImport] of Object.entries(dbs)) {
await createApp(appName, appImport)
}
// once apps are created clean up the global db
let users = await getAllDocType(globalDb, DocumentTypes.USER)
for (let user of users) {
delete user.tenantId
}
await globalDb.bulkDocs(users)
ctx.body = {
message: "Apps successfully imported.",
}
}

View File

@ -41,15 +41,10 @@ exports.fetch = async function (ctx) {
exports.buildSchemaFromDb = async function (ctx) { exports.buildSchemaFromDb = async function (ctx) {
const db = new CouchDB(ctx.appId) const db = new CouchDB(ctx.appId)
const datasourceId = ctx.params.datasourceId const datasource = await db.get(ctx.params.datasourceId)
const datasource = await db.get(datasourceId)
const Connector = integrations[datasource.source] const tables = await buildSchemaHelper(datasource)
datasource.entities = tables
// Connect to the DB and build the schema
const connector = new Connector(datasource.config)
await connector.buildSchema(datasource._id, datasource.entities)
datasource.entities = connector.tables
const response = await db.put(datasource) const response = await db.put(datasource)
datasource._rev = response.rev datasource._rev = response.rev
@ -81,12 +76,18 @@ exports.update = async function (ctx) {
exports.save = async function (ctx) { exports.save = async function (ctx) {
const db = new CouchDB(ctx.appId) const db = new CouchDB(ctx.appId)
const plus = ctx.request.body.plus const plus = ctx.request.body.datasource.plus
const fetchSchema = ctx.request.body.fetchSchema
const datasource = { const datasource = {
_id: generateDatasourceID({ plus }), _id: generateDatasourceID({ plus }),
type: plus ? DocumentTypes.DATASOURCE_PLUS : DocumentTypes.DATASOURCE, type: plus ? DocumentTypes.DATASOURCE_PLUS : DocumentTypes.DATASOURCE,
...ctx.request.body, ...ctx.request.body.datasource,
}
if (fetchSchema) {
let tables = await buildSchemaHelper(datasource)
datasource.entities = tables
} }
const response = await db.put(datasource) const response = await db.put(datasource)
@ -133,3 +134,14 @@ exports.query = async function (ctx) {
ctx.throw(400, err) ctx.throw(400, err)
} }
} }
const buildSchemaHelper = async datasource => {
const Connector = integrations[datasource.source]
// Connect to the DB and build the schema
const connector = new Connector(datasource.config)
await connector.buildSchema(datasource._id, datasource.entities)
datasource.entities = connector.tables
return connector.tables
}

View File

@ -5,7 +5,6 @@ const {
generateRowID, generateRowID,
DocumentTypes, DocumentTypes,
InternalTables, InternalTables,
generateMemoryViewID,
} = require("../../../db/utils") } = require("../../../db/utils")
const userController = require("../user") const userController = require("../user")
const { const {
@ -20,7 +19,12 @@ const { fullSearch, paginatedSearch } = require("./internalSearch")
const { getGlobalUsersFromMetadata } = require("../../../utilities/global") const { getGlobalUsersFromMetadata } = require("../../../utilities/global")
const inMemoryViews = require("../../../db/inMemoryView") const inMemoryViews = require("../../../db/inMemoryView")
const env = require("../../../environment") const env = require("../../../environment")
const { migrateToInMemoryView } = require("../view/utils") const {
migrateToInMemoryView,
migrateToDesignView,
getFromDesignDoc,
getFromMemoryDoc,
} = require("../view/utils")
const CALCULATION_TYPES = { const CALCULATION_TYPES = {
SUM: "sum", SUM: "sum",
@ -74,33 +78,24 @@ async function getRawTableData(ctx, db, tableId) {
} }
async function getView(db, viewName) { async function getView(db, viewName) {
let viewInfo let mainGetter = env.SELF_HOSTED ? getFromDesignDoc : getFromMemoryDoc
async function getFromDesignDoc() { let secondaryGetter = env.SELF_HOSTED ? getFromMemoryDoc : getFromDesignDoc
const designDoc = await db.get("_design/database") let migration = env.SELF_HOSTED ? migrateToDesignView : migrateToInMemoryView
viewInfo = designDoc.views[viewName] let viewInfo,
return viewInfo migrate = false
}
let migrate = false
if (env.SELF_HOSTED) {
viewInfo = await getFromDesignDoc()
} else {
try { try {
viewInfo = await db.get(generateMemoryViewID(viewName)) viewInfo = await mainGetter(db, viewName)
if (viewInfo) {
viewInfo = viewInfo.view
}
} catch (err) { } catch (err) {
// check if it can be retrieved from design doc (needs migrated) // check if it can be retrieved from design doc (needs migrated)
if (err.status !== 404) { if (err.status !== 404) {
viewInfo = null viewInfo = null
} else { } else {
viewInfo = await getFromDesignDoc() viewInfo = await secondaryGetter(db, viewName)
migrate = !!viewInfo migrate = !!viewInfo
} }
} }
}
if (migrate) { if (migrate) {
await migrateToInMemoryView(db, viewName) await migration(db, viewName)
} }
if (!viewInfo) { if (!viewInfo) {
throw "View does not exist." throw "View does not exist."
@ -351,6 +346,11 @@ exports.bulkDestroy = async ctx => {
} }
exports.search = async ctx => { exports.search = async ctx => {
// Fetch the whole table when running in cypress, as search doesn't work
if (env.isCypress()) {
return { rows: await exports.fetch(ctx) }
}
const appId = ctx.appId const appId = ctx.appId
const { tableId } = ctx.params const { tableId } = ctx.params
const db = new CouchDB(appId) const db = new CouchDB(appId)

View File

@ -107,3 +107,30 @@ exports.migrateToInMemoryView = async (db, viewName) => {
await db.put(designDoc) await db.put(designDoc)
await exports.saveView(db, null, viewName, view) await exports.saveView(db, null, viewName, view)
} }
exports.migrateToDesignView = async (db, viewName) => {
let view = await db.get(generateMemoryViewID(viewName))
const designDoc = await db.get("_design/database")
designDoc.views[viewName] = view.view
await db.put(designDoc)
await db.remove(view._id, view._rev)
}
exports.getFromDesignDoc = async (db, viewName) => {
const designDoc = await db.get("_design/database")
let view = designDoc.views[viewName]
if (view == null) {
throw { status: 404, message: "Unable to get view" }
}
return view
}
exports.getFromMemoryDoc = async (db, viewName) => {
let view = await db.get(generateMemoryViewID(viewName))
if (view) {
view = view.view
} else {
throw { status: 404, message: "Unable to get view" }
}
return view
}

View File

@ -0,0 +1,13 @@
const Router = require("@koa/router")
const controller = require("../controllers/cloud")
const authorized = require("../../middleware/authorized")
const { BUILDER } = require("@budibase/auth/permissions")
const router = Router()
router
.get("/api/cloud/export", authorized(BUILDER), controller.exportApps)
// has to be public, only run if apps don't exist
.post("/api/cloud/import", controller.importApps)
module.exports = router

View File

@ -24,6 +24,7 @@ const hostingRoutes = require("./hosting")
const backupRoutes = require("./backup") const backupRoutes = require("./backup")
const metadataRoutes = require("./metadata") const metadataRoutes = require("./metadata")
const devRoutes = require("./dev") const devRoutes = require("./dev")
const cloudRoutes = require("./cloud")
exports.mainRoutes = [ exports.mainRoutes = [
authRoutes, authRoutes,
@ -49,6 +50,7 @@ exports.mainRoutes = [
backupRoutes, backupRoutes,
metadataRoutes, metadataRoutes,
devRoutes, devRoutes,
cloudRoutes,
// these need to be handled last as they still use /api/:tableId // these need to be handled last as they still use /api/:tableId
// this could be breaking as koa may recognise other routes as this // this could be breaking as koa may recognise other routes as this
tableRoutes, tableRoutes,

View File

@ -1,6 +1,7 @@
// mock out postgres for this // mock out postgres for this
jest.mock("pg") jest.mock("pg")
const { findLastKey } = require("lodash/fp")
const setup = require("./utilities") const setup = require("./utilities")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions") const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const { basicQuery, basicDatasource } = setup.structures const { basicQuery, basicDatasource } = setup.structures
@ -19,10 +20,10 @@ describe("/queries", () => {
}) })
async function createInvalidIntegration() { async function createInvalidIntegration() {
const datasource = await config.createDatasource({ const datasource = await config.createDatasource({datasource: {
...basicDatasource(), ...basicDatasource().datasource,
source: "INVALID_INTEGRATION", source: "INVALID_INTEGRATION",
}) }})
const query = await config.createQuery() const query = await config.createQuery()
return { datasource, query } return { datasource, query }
} }
@ -183,11 +184,14 @@ describe("/queries", () => {
}) })
it("should fail with invalid integration type", async () => { it("should fail with invalid integration type", async () => {
const { query } = await createInvalidIntegration() const { query, datasource } = await createInvalidIntegration()
await request await request
.post(`/api/queries/${query._id}`) .post(`/api/queries/${query._id}`)
.send({ .send({
datasourceId: datasource._id,
parameters: {}, parameters: {},
fields: {},
queryVerb: "read",
}) })
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect(400) .expect(400)

View File

@ -317,7 +317,7 @@ describe("/rows", () => {
await request await request
.get(`/api/views/derp`) .get(`/api/views/derp`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
.expect(400) .expect(404)
}) })
it("should be able to run on a view", async () => { it("should be able to run on a view", async () => {

View File

@ -110,6 +110,8 @@ function getDocParams(docType, docId = null, otherProps = {}) {
} }
} }
exports.getDocParams = getDocParams
/** /**
* Gets parameters for retrieving tables, this is a utility function for the getDocParams function. * Gets parameters for retrieving tables, this is a utility function for the getDocParams function.
*/ */

View File

@ -13,6 +13,10 @@ function isDev() {
) )
} }
function isCypress() {
return process.env.NODE_ENV === "cypress"
}
let LOADED = false let LOADED = false
if (!LOADED && isDev() && !isTest()) { if (!LOADED && isDev() && !isTest()) {
require("dotenv").config() require("dotenv").config()
@ -40,6 +44,7 @@ module.exports = {
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
JEST_WORKER_ID: process.env.JEST_WORKER_ID, JEST_WORKER_ID: process.env.JEST_WORKER_ID,
BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT, BUDIBASE_ENVIRONMENT: process.env.BUDIBASE_ENVIRONMENT,
DISABLE_ACCOUNT_PORTAL: process.env.DISABLE_ACCOUNT_PORTAL,
// minor // minor
SALT_ROUNDS: process.env.SALT_ROUNDS, SALT_ROUNDS: process.env.SALT_ROUNDS,
LOGGER: process.env.LOGGER, LOGGER: process.env.LOGGER,
@ -61,6 +66,7 @@ module.exports = {
module.exports[key] = value module.exports[key] = value
}, },
isTest, isTest,
isCypress,
isDev, isDev,
isProd: () => { isProd: () => {
return !isDev() return !isDev()

View File

@ -85,10 +85,10 @@ module MongoDBModule {
// which method we want to call on the collection // which method we want to call on the collection
switch (query.extra.actionTypes) { switch (query.extra.actionTypes) {
case "insertOne": { case "insertOne": {
return collection.insertOne(query.json) return await collection.insertOne(query.json)
} }
case "insertMany": { case "insertMany": {
return collection.insertOne(query.json).toArray() return await collection.insertOne(query.json).toArray()
} }
default: { default: {
throw new Error( throw new Error(
@ -112,19 +112,19 @@ module MongoDBModule {
switch (query.extra.actionTypes) { switch (query.extra.actionTypes) {
case "find": { case "find": {
return collection.find(query.json).toArray() return await collection.find(query.json).toArray()
} }
case "findOne": { case "findOne": {
return collection.findOne(query.json) return await collection.findOne(query.json)
} }
case "findOneAndUpdate": { case "findOneAndUpdate": {
return collection.findOneAndUpdate(query.json) return await collection.findOneAndUpdate(query.json)
} }
case "count": { case "count": {
return collection.countDocuments(query.json) return await collection.countDocuments(query.json)
} }
case "distinct": { case "distinct": {
return collection.distinct(query.json) return await collection.distinct(query.json)
} }
default: { default: {
throw new Error( throw new Error(
@ -148,10 +148,10 @@ module MongoDBModule {
switch (query.extra.actionTypes) { switch (query.extra.actionTypes) {
case "updateOne": { case "updateOne": {
return collection.updateOne(query.json) return await collection.updateOne(query.json)
} }
case "updateMany": { case "updateMany": {
return collection.updateMany(query.json).toArray() return await collection.updateMany(query.json).toArray()
} }
default: { default: {
throw new Error( throw new Error(
@ -175,10 +175,10 @@ module MongoDBModule {
switch (query.extra.actionTypes) { switch (query.extra.actionTypes) {
case "deleteOne": { case "deleteOne": {
return collection.deleteOne(query.json) return await collection.deleteOne(query.json)
} }
case "deleteMany": { case "deleteMany": {
return collection.deleteMany(query.json).toArray() return await collection.deleteMany(query.json).toArray()
} }
default: { default: {
throw new Error( throw new Error(

View File

@ -6,6 +6,9 @@ jest.mock("../../environment", () => ({
isDev: () => true, isDev: () => true,
_set: () => {}, _set: () => {},
})) }))
jest.mock("@budibase/auth/tenancy", () => ({
getTenantId: () => "testing123"
}))
const usageQuotaMiddleware = require("../usageQuota") const usageQuotaMiddleware = require("../usageQuota")
const usageQuota = require("../../utilities/usageQuota") const usageQuota = require("../../utilities/usageQuota")

View File

@ -1,6 +1,10 @@
const CouchDB = require("../db") const CouchDB = require("../db")
const usageQuota = require("../utilities/usageQuota") const usageQuota = require("../utilities/usageQuota")
const env = require("../environment") const env = require("../environment")
const { getTenantId } = require("@budibase/auth/tenancy")
// tenants without limits
const EXCLUDED_TENANTS = ["bb", "default", "bbtest", "bbstaging"]
// currently only counting new writes and deletes // currently only counting new writes and deletes
const METHOD_MAP = { const METHOD_MAP = {
@ -28,8 +32,10 @@ function getProperty(url) {
} }
module.exports = async (ctx, next) => { module.exports = async (ctx, next) => {
const tenantId = getTenantId()
// if in development or a self hosted cloud usage quotas should not be executed // if in development or a self hosted cloud usage quotas should not be executed
if (env.isDev() || env.SELF_HOSTED) { if (env.isDev() || env.SELF_HOSTED || EXCLUDED_TENANTS.includes(tenantId)) {
return next() return next()
} }

Some files were not shown because too many files have changed in this diff Show More