Merge pull request #1871 from Budibase/develop

Develop
This commit is contained in:
Martin McKeaveney 2021-06-29 14:48:27 +01:00 committed by GitHub
commit ceeb968531
142 changed files with 7289 additions and 4578 deletions

View File

@ -28,8 +28,8 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
- run: yarn
- run: yarn lint
- run: yarn bootstrap
- run: yarn lint
- run: yarn build
- run: yarn test
env:

View File

@ -37,7 +37,8 @@
"lint": "yarn run lint:eslint && yarn run lint:prettier",
"lint:fix:eslint": "eslint --fix packages",
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,svelte}\"",
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"lint:fix:ts": "lerna run lint:fix",
"lint:fix": "yarn run lint:fix:ts && yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"test:e2e": "lerna run cy:test",
"test:e2e:ci": "lerna run cy:ci",
"build:docker": "lerna run build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh && cd -",

View File

@ -18,6 +18,8 @@ function connectionError(timeout, err) {
if (CLOSED) {
return
}
CLIENT.end()
CLOSED = true
// always clear this on error
clearTimeout(timeout)
CONNECTED = false
@ -41,7 +43,7 @@ function init() {
// start the timer - only allowed 5 seconds to connect
timeout = setTimeout(() => {
if (!CONNECTED) {
connectionError(timeout)
connectionError(timeout, "Did not successfully connect in timeout")
}
}, STARTUP_TIMEOUT_MS)

View File

@ -0,0 +1,239 @@
<script>
import { createEventDispatcher } from "svelte"
import "@spectrum-css/popover/dist/index-vars.css"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
import Icon from "../Icon/Icon.svelte"
import Input from "../Form/Input.svelte"
import { capitalise } from "../utils/helpers"
export let value
export let size = "M"
let open = false
$: color = value || "transparent"
$: customValue = getCustomValue(value)
$: checkColor = getCheckColor(value)
const dispatch = createEventDispatcher()
const categories = [
{
label: "Grays",
colors: [
"white",
"gray-100",
"gray-200",
"gray-300",
"gray-400",
"gray-500",
"gray-600",
"gray-700",
"gray-800",
"gray-900",
"black",
],
},
{
label: "Colors",
colors: [
"red-400",
"orange-400",
"yellow-400",
"green-400",
"seafoam-400",
"blue-400",
"indigo-400",
"magenta-400",
"red-500",
"orange-500",
"yellow-500",
"green-500",
"seafoam-500",
"blue-500",
"indigo-500",
"magenta-500",
"red-600",
"orange-600",
"yellow-600",
"green-600",
"seafoam-600",
"blue-600",
"indigo-600",
"magenta-600",
"red-700",
"orange-700",
"yellow-700",
"green-700",
"seafoam-700",
"blue-700",
"indigo-700",
"magenta-700",
],
},
]
const onChange = value => {
dispatch("change", value)
open = false
}
const getCustomValue = value => {
if (!value) {
return value
}
let found = false
const comparisonValue = value.substring(35, value.length - 1)
for (let category of categories) {
found = category.colors.includes(comparisonValue)
if (found) {
break
}
}
return found ? null : value
}
const prettyPrint = color => {
return capitalise(color.split("-").join(" "))
}
const getCheckColor = value => {
return /^.*(white|(gray-(50|75|100|200|300|400|500)))\)$/.test(value)
? "black"
: "white"
}
</script>
<div class="container">
<div
class="preview size--{size || 'M'}"
style="background: {color};"
on:click={() => (open = true)}
/>
{#if open}
<div
use:clickOutside={() => (open = false)}
transition:fly={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
>
{#each categories as category}
<div class="category">
<div class="heading">{category.label}</div>
<div class="colors">
{#each category.colors as color}
<div
on:click={() => {
onChange(`var(--spectrum-global-color-static-${color})`)
}}
class="color"
style="background: var(--spectrum-global-color-static-{color}); color: {checkColor};"
title={prettyPrint(color)}
>
{#if value === `var(--spectrum-global-color-static-${color})`}
<Icon name="Checkmark" size="S" />
{/if}
</div>
{/each}
</div>
</div>
{/each}
<div class="category category--custom">
<div class="heading">Custom</div>
<div class="custom">
<Input
updateOnChange={false}
quiet
placeholder="Hex, RGB, HSL..."
value={customValue}
on:change
/>
<Icon
size="S"
name="Close"
hoverable
on:click={() => onChange(null)}
/>
</div>
</div>
</div>
{/if}
</div>
<style>
.container {
position: relative;
}
.preview {
width: 32px;
height: 32px;
border-radius: 100%;
transition: border-color 130ms ease-in-out;
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
}
.preview:hover {
cursor: pointer;
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300);
}
.size--S {
width: 20px;
height: 20px;
}
.size--M {
width: 32px;
height: 32px;
}
.size--L {
width: 48px;
height: 48px;
}
.spectrum-Popover {
width: 210px;
z-index: 999;
top: 100%;
padding: var(--spacing-l) var(--spacing-xl);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.colors {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr 1fr 1fr;
gap: var(--spacing-xs);
}
.heading {
font-size: var(--font-size-s);
font-weight: 600;
letter-spacing: 0.14px;
flex: 1 1 auto;
text-transform: uppercase;
grid-column: 1 / 5;
margin-bottom: var(--spacing-s);
}
.color {
height: 16px;
width: 16px;
border-radius: 100%;
box-shadow: 0 0 0 1px var(--spectrum-global-color-gray-300);
display: grid;
place-items: center;
}
.color:hover {
cursor: pointer;
box-shadow: 0 0 2px 2px var(--spectrum-global-color-gray-300);
}
.custom {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--spacing-m);
margin-right: var(--spacing-xs);
}
.category--custom .heading {
margin-bottom: var(--spacing-xs);
}
</style>

View File

@ -2,13 +2,15 @@
import Icon from "../Icon/Icon.svelte"
import { createEventDispatcher } from "svelte"
export let name
export let show = false
export let collapsible = true
const dispatch = createEventDispatcher()
export let thin = false
export let name,
show = false
const onHeaderClick = () => {
if (!collapsible) {
return
}
show = !show
if (show) {
dispatch("open")
@ -16,14 +18,14 @@
}
</script>
<div class="property-group-container" class:thin>
<div class="property-group-container">
<div class="property-group-name" on:click={onHeaderClick}>
<div class:thin class="name">{name}</div>
<div class="icon">
<div class="name">{name}</div>
{#if collapsible}
<Icon size="S" name={show ? "Remove" : "Add"} />
{/if}
</div>
</div>
<div class="property-panel" class:show>
<div class="property-panel" class:show={show || !collapsible}>
<slot />
</div>
</div>
@ -32,10 +34,9 @@
.property-group-container {
display: flex;
flex-direction: column;
height: auto;
justify-content: center;
border-radius: var(--border-radius-m);
font-family: var(--font-sans);
justify-content: flex-start;
align-items: stretch;
border-bottom: var(--border-light);
}
.property-group-name {
@ -45,42 +46,38 @@
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: var(--spacing-m) var(--spacing-xl);
color: var(--spectrum-global-color-gray-600);
transition: color 130ms ease-in-out;
}
.property-group-name:hover {
color: var(--spectrum-global-color-gray-900);
}
.name {
text-align: left;
font-size: 14px;
font-size: var(--font-size-s);
font-weight: 600;
letter-spacing: 0.14px;
color: var(--ink);
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
text-transform: capitalize;
text-transform: uppercase;
white-space: nowrap;
user-select: none;
}
.name.thin {
font-size: var(--spectrum-global-dimension-font-size-75);
}
.icon {
flex: 0 0 20px;
text-align: center;
}
.property-panel {
/* height: 0px;
overflow: hidden; */
display: none;
padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl)
var(--spacing-xl);
}
.show {
/* overflow: auto;
height: auto; */
display: flex;
flex-direction: column;
flex: 1;
margin-top: var(--spacing-m);
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-l);
}
</style>

View File

@ -20,6 +20,7 @@
export let open = false
export let readonly = false
export let quiet = false
export let autoWidth = false
const dispatch = createEventDispatcher()
const onClick = () => {
@ -41,7 +42,11 @@
aria-haspopup="listbox"
on:mousedown={onClick}
>
<span class="spectrum-Picker-label" class:is-placeholder={isPlaceholder}>
<span
class="spectrum-Picker-label"
class:is-placeholder={isPlaceholder}
class:auto-width={autoWidth}
>
{fieldText}
</span>
{#if error}
@ -67,11 +72,12 @@
use:clickOutside={() => (open = false)}
transition:fly={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom spectrum-Picker-popover is-open"
class:auto-width={autoWidth}
>
<ul class="spectrum-Menu" role="listbox">
{#if placeholderOption}
<li
class="spectrum-Menu-item"
class="spectrum-Menu-item placeholder"
class:is-selected={isPlaceholder}
role="option"
aria-selected="true"
@ -118,17 +124,28 @@
<style>
.spectrum-Popover {
max-height: 240px;
width: 100%;
z-index: 999;
top: 100%;
}
.spectrum-Popover:not(.auto-width) {
width: 100%;
}
.spectrum-Popover.auto-width :global(.spectrum-Menu-itemLabel) {
white-space: nowrap;
}
.spectrum-Picker {
width: 100%;
}
.spectrum-Picker-label {
.spectrum-Picker-label:not(.auto-width) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 0;
}
.placeholder {
font-style: italic;
}
.spectrum-Picker-label.auto-width.is-placeholder {
padding-right: 2px;
}
</style>

View File

@ -12,6 +12,7 @@
export let getOptionValue = option => option
export let readonly = false
export let quiet = false
export let autoWidth = false
const dispatch = createEventDispatcher()
let open = false
@ -51,6 +52,7 @@
{readonly}
{fieldText}
{options}
{autoWidth}
{getOptionLabel}
{getOptionValue}
isPlaceholder={value == null || value === ""}

View File

@ -10,6 +10,7 @@
export let id = null
export let readonly = false
export let updateOnChange = true
export let quiet = false
const dispatch = createEventDispatcher()
let focus = false
@ -59,6 +60,7 @@
<div
class="spectrum-Textfield"
class:spectrum-Textfield--quiet={quiet}
class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={focus}

View File

@ -12,6 +12,7 @@
export let readonly = false
export let error = null
export let updateOnChange = true
export let quiet = false
const dispatch = createEventDispatcher()
const onChange = e => {
@ -29,6 +30,7 @@
{value}
{placeholder}
{type}
{quiet}
on:change={onChange}
on:click
on:input

View File

@ -14,6 +14,7 @@
export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value")
export let quiet = false
export let autoWidth = false
const dispatch = createEventDispatcher()
const onChange = e => {
@ -37,6 +38,7 @@
{value}
{options}
{placeholder}
{autoWidth}
{getOptionLabel}
{getOptionValue}
on:change={onChange}

View File

@ -5,6 +5,8 @@
export let selected
export let vertical = false
export let noPadding = false
let _id = id()
const tab = writable({ title: selected, id: _id })
setContext("tab", tab)
@ -63,14 +65,17 @@
{/if}
</div>
<div class="spectrum-Tabs-content spectrum-Tabs-content-{_id}" />
<div
class="spectrum-Tabs-content spectrum-Tabs-content-{_id}"
class:noPadding
/>
<style>
.spectrum-Tabs {
padding-left: var(--spacing-xl);
padding-right: var(--spacing-xl);
position: relative;
border-width: 1px !important;
border-bottom: var(--border-light);
}
.spectrum-Tabs-content {
margin-top: var(--spectrum-global-dimension-static-size-150);
@ -81,4 +86,7 @@
.spectrum-Tabs--horizontal .spectrum-Tabs-selectionIndicator {
bottom: 0 !important;
}
.noPadding {
margin: 0;
}
</style>

View File

@ -55,6 +55,7 @@ export { default as Search } from "./Form/Search.svelte"
export { default as Pagination } from "./Pagination/Pagination.svelte"
export { default as Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte"
// Typography
export { default as Body } from "./Typography/Body.svelte"

View File

@ -4,3 +4,5 @@ export const generateID = () => {
// Starts with a letter so that its a valid DOM ID
return `A${rand}`
}
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)

View File

@ -4,6 +4,8 @@ const path = require("path")
const tmpdir = path.join(require("os").tmpdir(), ".budibase")
// these run on ports we don't normally use so that they can run alongside the
const fs = require("fs")
// normal development system
const WORKER_PORT = "10002"
const MAIN_PORT = cypressConfig.env.PORT
@ -27,10 +29,14 @@ process.env.LOG_LEVEL = "error"
async function run() {
// require("dotenv").config({ path: resolve(dir, ".env") })
if (!fs.existsSync("../server/dist")) {
console.error("Unable to run cypress, need to build server first")
process.exit(-1)
}
// dont make this a variable or top level require
// it will cause environment module to be loaded prematurely
const server = require("../../server/src/app")
const server = require("../../server/dist/app")
process.env.PORT = WORKER_PORT
const worker = require("../../worker/src/index")
// reload main port for rest of system

View File

@ -488,12 +488,12 @@ export const getFrontendStore = () => {
})
await Promise.all(promises)
},
updateStyle: async (type, name, value) => {
updateStyle: async (name, value) => {
const selected = get(selectedComponent)
if (value == null || value === "") {
delete selected._styles[type][name]
delete selected._styles.normal[name]
} else {
selected._styles[type][name] = value
selected._styles.normal[name] = value
}
await store.actions.preview.saveSelected()
},

View File

@ -54,6 +54,10 @@ function generateTitleContainer(table) {
.type("h2")
.instanceName("Title")
.text(table.name)
.customProps({
size: "M",
align: "left",
})
return new Component("@budibase/standard-components/container")
.normalStyle({

View File

@ -21,6 +21,7 @@ export class Screen extends BaseStructure {
hAlign: "stretch",
vAlign: "top",
size: "grow",
gap: "M",
},
routing: {
route: "",

View File

@ -25,11 +25,8 @@ export function makeLinkComponent(tableName) {
.customProps({
url: `/${tableName.toLowerCase()}`,
openInNewTab: false,
color: "",
hoverColor: "",
underline: false,
fontSize: "",
fontFamily: "initial",
size: "S",
align: "left",
})
}
@ -62,6 +59,10 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
.customStyle(spectrumColor(700))
.text(">")
.instanceName("Arrow")
.customProps({
size: "S",
align: "left",
})
const textStyling = {
color: "#000000",
@ -77,6 +78,10 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) {
.customStyle(spectrumColor(700))
.text(text)
.instanceName("Identifier")
.customProps({
size: "S",
align: "left",
})
return new Component("@budibase/standard-components/container")
.normalStyle({
@ -148,6 +153,10 @@ export function makeTitleContainer(title) {
.type("h2")
.instanceName("Title")
.text(title)
.customProps({
size: "M",
align: "left",
})
return new Component("@budibase/standard-components/container")
.normalStyle({

View File

@ -45,7 +45,7 @@
size="L"
confirmText="Create"
onConfirm={saveDatasource}
disabled={error || !name}
disabled={error || !name || !integration?.type}
>
<Input
data-cy="datasource-name-input"

View File

@ -4,10 +4,13 @@
import iframeTemplate from "./iframeTemplate"
import { Screen } from "builderStore/store/screenTemplates/utils/Screen"
import { FrontendTypes } from "constants"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
let iframe
let layout
let screen
let confirmDeleteDialog
let idToDelete
// Create screen slot placeholder for use when a page is selected rather
// than a screen
@ -73,15 +76,26 @@
// Add listener for events sent by cliebt library in preview
iframe.contentWindow.addEventListener("bb-event", event => {
const { type, data } = event.detail
if (type === "select-component") {
if (type === "select-component" && data.id) {
store.actions.components.select({ _id: data.id })
} else if (type === "update-prop") {
store.actions.components.updateProp(data.prop, data.value)
} else if (type === "delete-component" && data.id) {
idToDelete = data.id
confirmDeleteDialog.show()
} else {
console.log(data)
}
})
})
const deleteComponent = () => {
store.actions.components.delete({ _id: idToDelete })
idToDelete = null
}
const cancelDeleteComponent = () => {
idToDelete = null
}
</script>
<div class="component-container">
@ -92,6 +106,14 @@
srcdoc={template}
/>
</div>
<ConfirmDialog
bind:this={confirmDeleteDialog}
title="Confirm Deletion"
body={`Are you sure you want to delete this component?`}
okText="Delete component"
onOk={deleteComponent}
onCancel={cancelDeleteComponent}
/>
<style>
.component-container {

View File

@ -1,40 +0,0 @@
<script>
export let categories = []
export let selectedCategory = {}
export let onClick = () => {}
</script>
<div class="tabs">
{#each categories as category}
<li
data-cy={category.name}
on:click={() => onClick(category)}
class:active={selectedCategory === category}
>
{category.name}
</li>
{/each}
</div>
<style>
.tabs {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
list-style: none;
font-size: var(--font-size-m);
font-weight: 600;
height: 24px;
}
li {
color: var(--grey-5);
cursor: pointer;
margin-right: 20px;
}
.active {
color: var(--ink);
}
</style>

View File

@ -1,11 +1,13 @@
<script>
import { get } from "lodash"
import { isEmpty } from "lodash/fp"
import { Button, Checkbox, Input, Select } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { currentAsset } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
import {
Checkbox,
Input,
Select,
DetailSummary,
ColorPicker,
} from "@budibase/bbui"
import { store } from "builderStore"
import PropertyControl from "./PropertyControls/PropertyControl.svelte"
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
import RoleSelect from "./PropertyControls/RoleSelect.svelte"
@ -20,7 +22,6 @@
import EventsEditor from "./PropertyControls/EventsEditor"
import FilterEditor from "./PropertyControls/FilterEditor/FilterEditor.svelte"
import { IconSelect } from "./PropertyControls/IconSelect"
import ColorPicker from "./PropertyControls/ColorPicker.svelte"
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
import NumberFieldSelect from "./PropertyControls/NumberFieldSelect.svelte"
import OptionsFieldSelect from "./PropertyControls/OptionsFieldSelect.svelte"
@ -29,13 +30,11 @@
import DateTimeFieldSelect from "./PropertyControls/DateTimeFieldSelect.svelte"
import AttachmentFieldSelect from "./PropertyControls/AttachmentFieldSelect.svelte"
import RelationshipFieldSelect from "./PropertyControls/RelationshipFieldSelect.svelte"
import ResetFieldsButton from "./PropertyControls/ResetFieldsButton.svelte"
export let componentDefinition = {}
export let componentInstance = {}
export let componentDefinition
export let componentInstance
export let assetInstance
export let onChange = () => {}
export let onScreenPropChange = () => {}
export let showDisplayName = false
const layoutDefinition = []
const screenDefinition = [
@ -44,12 +43,12 @@
{ key: "routing.roleId", label: "Access", control: RoleSelect },
{ key: "layoutId", label: "Layout", control: LayoutSelect },
]
let confirmResetFieldsDialog
$: settings = componentDefinition?.settings ?? []
$: isLayout = assetInstance && assetInstance.favicon
$: assetDefinition = isLayout ? layoutDefinition : screenDefinition
const updateProp = store.actions.components.updateProp
const controlMap = {
text: Input,
select: Select,
@ -91,51 +90,19 @@
}
return true
}
const onInstanceNameChange = name => {
onChange("_instanceName", name)
}
const resetFormFields = () => {
const form = findClosestMatchingComponent(
$currentAsset.props,
componentInstance._id,
component => component._component.endsWith("/form")
)
const dataSource = form?.dataSource
const fields = makeDatasourceFormComponents(dataSource)
onChange(
"_children",
fields.map(field => field.json())
)
}
</script>
<div class="settings-view-container">
{#if assetInstance}
{#each assetDefinition as def (`${componentInstance._id}-${def.key}`)}
<PropertyControl
bindable={false}
control={def.control}
label={def.label}
key={def.key}
value={get(assetInstance, def.key)}
onChange={val => onScreenPropChange(def.key, val)}
/>
{/each}
{/if}
{#if showDisplayName}
<DetailSummary name="General" collapsible={false}>
{#if !componentInstance._component.endsWith("/layout")}
<PropertyControl
bindable={false}
control={Input}
label="Name"
key="_instanceName"
value={componentInstance._instanceName}
onChange={onInstanceNameChange}
onChange={val => updateProp("_instanceName", val)}
/>
{/if}
{#if settings && settings.length > 0}
{#each settings as setting (`${componentInstance._id}-${setting.key}`)}
{#if canRenderControl(setting)}
@ -147,52 +114,28 @@
value={componentInstance[setting.key] ??
componentInstance[setting.key]?.defaultValue}
{componentInstance}
onChange={val => onChange(setting.key, val)}
props={{ options: setting.options, placeholder: setting.placeholder }}
onChange={val => updateProp(setting.key, val)}
props={{
options: setting.options,
placeholder: setting.placeholder,
}}
/>
{/if}
{/each}
{:else}
<div class="text">This component doesn't have any additional settings.</div>
{/if}
{#if componentDefinition?.component?.endsWith("/fieldgroup")}
<ResetFieldsButton {componentInstance} />
{/if}
{#if componentDefinition?.info}
<div class="text">
{@html componentDefinition?.info}
</div>
{/if}
{#if componentDefinition?.component?.endsWith("/fieldgroup")}
<div class="buttonWrapper">
<Button secondary wide on:click={() => confirmResetFieldsDialog?.show()}>
Update Form Fields
</Button>
</div>
{/if}
</div>
<ConfirmDialog
bind:this={confirmResetFieldsDialog}
body={`All components inside this group will be deleted and replaced with fields to match the schema. Are you sure you want to update this Field Group?`}
okText="Update"
onOk={resetFormFields}
title="Confirm Form Field Update"
/>
</DetailSummary>
<style>
.settings-view-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: var(--spacing-s);
}
.text {
font-size: var(--spectrum-global-dimension-font-size-75);
margin-top: var(--spacing-m);
color: var(--grey-6);
}
.buttonWrapper {
margin-top: 10px;
display: flex;
flex-direction: column;
}
</style>

View File

@ -0,0 +1,60 @@
<script>
import {
TextArea,
DetailSummary,
ActionButton,
Drawer,
DrawerContent,
Layout,
Body,
Button,
} from "@budibase/bbui"
import { store } from "builderStore"
export let componentInstance
let tempValue
let drawer
const openDrawer = () => {
tempValue = componentInstance?._styles?.custom
drawer.show()
}
const save = () => {
store.actions.components.updateCustomStyle(tempValue)
drawer.hide()
}
</script>
<DetailSummary
name={`Custom CSS${componentInstance?._styles?.custom ? " *" : ""}`}
collapsible={false}
>
<div>
<ActionButton on:click={openDrawer}>Edit custom CSS</ActionButton>
</div>
</DetailSummary>
<Drawer bind:this={drawer} title="Custom CSS">
<Button cta slot="buttons" on:click={save}>Save</Button>
<DrawerContent slot="body">
<div class="content">
<Layout gap="S">
<Body size="S">Custom CSS overrides all other component styles.</Body>
<TextArea bind:value={tempValue} placeholder="Enter some CSS..." />
</Layout>
</div>
</DrawerContent>
</Drawer>
<style>
.content {
max-width: 800px;
margin: 0 auto;
}
.content :global(textarea) {
font-family: monospace;
min-height: 240px !important;
font-size: var(--font-size-s);
}
</style>

View File

@ -0,0 +1,34 @@
<script>
import StyleSection from "./StyleSection.svelte"
import * as ComponentStyles from "./componentStyles"
export let componentDefinition
export let componentInstance
const getStyles = def => {
if (!def?.styles?.length) {
return [...ComponentStyles.all]
}
let styles = [...ComponentStyles.all]
def.styles.forEach(style => {
if (ComponentStyles[style]) {
styles.push(ComponentStyles[style])
}
})
return styles
}
$: styles = getStyles(componentDefinition)
</script>
{#if styles?.length > 0}
{#each styles as style}
<StyleSection
{style}
name={style.label}
columns={style.columns}
properties={style.settings}
{componentInstance}
/>
{/each}
{/if}

View File

@ -1,111 +0,0 @@
<script>
import { TextArea, DetailSummary, Button } from "@budibase/bbui"
import PropertyGroup from "./PropertyControls/PropertyGroup.svelte"
import FlatButtonGroup from "./PropertyControls/FlatButtonGroup"
import { allStyles } from "./componentStyles"
export let componentDefinition = {}
export let componentInstance = {}
export let onStyleChanged = () => {}
export let onCustomStyleChanged = () => {}
export let onResetStyles = () => {}
let selectedCategory = "normal"
let currentGroup
function onChange(category) {
selectedCategory = category
}
const buttonProps = [
{ value: "normal", text: "Normal" },
{ value: "hover", text: "Hover" },
{ value: "active", text: "Active" },
]
$: groups = componentDefinition?.styleable ? Object.keys(allStyles) : []
</script>
<div class="container">
<div class="state-categories">
<FlatButtonGroup value={selectedCategory} {buttonProps} {onChange} />
</div>
<div class="positioned-wrapper">
<div class="property-groups">
{#if groups.length > 0}
{#each groups as groupName}
<PropertyGroup
name={groupName}
properties={allStyles[groupName]}
styleCategory={selectedCategory}
{onStyleChanged}
{componentInstance}
open={currentGroup === groupName}
on:open={() => (currentGroup = groupName)}
/>
{/each}
<DetailSummary
name={`Custom Styles${componentInstance._styles.custom ? " *" : ""}`}
on:open={() => (currentGroup = "custom")}
show={currentGroup === "custom"}
thin
>
<div class="custom-styles">
<TextArea
value={componentInstance._styles.custom}
on:change={event => onCustomStyleChanged(event.detail)}
placeholder="Enter some CSS..."
/>
</div>
</DetailSummary>
<Button secondary wide on:click={onResetStyles}>Reset Styles</Button>
{:else}
<div class="no-design">
This component doesn't have any design properties.
</div>
{/if}
</div>
</div>
</div>
<style>
.container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: var(--spacing-l);
}
.positioned-wrapper {
position: relative;
display: flex;
min-height: 0;
flex: 1 1 auto;
}
.property-groups {
flex: 1;
overflow-y: auto;
min-height: 0;
margin: 0 -20px;
padding: 0 20px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.no-design {
font-size: var(--font-size-xs);
color: var(--grey-5);
}
.custom-styles :global(textarea) {
font-family: monospace;
min-height: 120px;
font-size: var(--font-size-xs);
}
</style>

View File

@ -1,84 +1,33 @@
<script>
import { get } from "svelte/store"
import { store, selectedComponent, currentAsset } from "builderStore"
import { store, selectedComponent } from "builderStore"
import { Tabs, Tab } from "@budibase/bbui"
import { FrontendTypes } from "constants"
import DesignView from "./DesignView.svelte"
import SettingsView from "./SettingsView.svelte"
import { setWith } from "lodash"
import ScreenSettingsSection from "./ScreenSettingsSection.svelte"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte"
$: definition = store.actions.components.getDefinition(
$: componentInstance = $selectedComponent
$: componentDefinition = store.actions.components.getDefinition(
$selectedComponent?._component
)
$: isComponentOrScreen =
$store.currentView === "component" ||
$store.currentFrontEndType === FrontendTypes.SCREEN
$: isNotScreenslot = !$selectedComponent._component.endsWith("screenslot")
$: showDisplayName = isComponentOrScreen && isNotScreenslot
const onStyleChanged = store.actions.components.updateStyle
const onCustomStyleChanged = store.actions.components.updateCustomStyle
const onResetStyles = store.actions.components.resetStyles
function setAssetProps(name, value) {
const selectedAsset = get(currentAsset)
store.update(state => {
if (
name === "_instanceName" &&
state.currentFrontEndType === FrontendTypes.SCREEN
) {
selectedAsset.props._instanceName = value
} else {
setWith(selectedAsset, name.split("."), value, Object)
}
return state
})
store.actions.preview.saveSelected()
}
</script>
<Tabs selected="Settings">
<Tabs selected="Settings" noPadding>
<Tab title="Settings">
<div class="tab-content-padding">
{#if definition && definition.name}
<div class="instance-name">{definition.name}</div>
{/if}
<SettingsView
componentInstance={$selectedComponent}
componentDefinition={definition}
{showDisplayName}
onChange={store.actions.components.updateProp}
onScreenPropChange={setAssetProps}
assetInstance={$store.currentView !== "component" && $currentAsset}
/>
</div>
</Tab>
<Tab title="Design">
<div class="tab-content-padding">
{#if definition && definition.name}
<div class="instance-name">{definition.name}</div>
{/if}
<DesignView
componentInstance={$selectedComponent}
componentDefinition={definition}
{onStyleChanged}
{onCustomStyleChanged}
{onResetStyles}
/>
<div class="container">
<ScreenSettingsSection {componentInstance} {componentDefinition} />
<ComponentSettingsSection {componentInstance} {componentDefinition} />
<DesignSection {componentInstance} {componentDefinition} />
<CustomStylesSection {componentInstance} {componentDefinition} />
</div>
</Tab>
</Tabs>
<style>
.tab-content-padding {
padding: 0 var(--spacing-xl);
}
.instance-name {
font-size: var(--spectrum-global-dimension-font-size-75);
margin-bottom: var(--spacing-m);
margin-top: var(--spacing-xs);
font-weight: 600;
color: var(--grey-7);
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { Button, Icon, Drawer } from "@budibase/bbui"
import { Button, Icon, Drawer, Label } from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import {
getBindableProperties,
@ -70,7 +70,11 @@
</script>
<div class="property-control" bind:this={anchor} data-cy={`setting-${key}`}>
<div class="label">{label}</div>
{#if type !== "boolean"}
<div class="label">
<Label>{label}</Label>
</div>
{/if}
<div data-cy={`${key}-prop-control`} class="control">
<svelte:component
this={control}
@ -79,11 +83,11 @@
updateOnChange={false}
on:change={handleChange}
onChange={handleChange}
name={key}
text={label}
{type}
{...props}
name={key}
/>
</div>
{#if bindable && !key.startsWith("_") && type === "text"}
<div
class="icon"
@ -96,9 +100,9 @@
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" disabled={!valid} on:click={handleClose}
>Save</Button
>
<Button cta slot="buttons" disabled={!valid} on:click={handleClose}>
Save
</Button>
<BindingPanel
slot="body"
bind:valid
@ -109,33 +113,25 @@
/>
</Drawer>
{/if}
</div>
</div>
<style>
.property-control {
position: relative;
display: flex;
flex-flow: row;
align-items: center;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.label {
display: flex;
align-items: center;
font-size: 12px;
font-weight: 400;
flex: 0 0 80px;
text-align: left;
color: var(--ink);
margin-right: auto;
text-transform: capitalize;
padding-bottom: var(--spectrum-global-dimension-size-65);
}
.control {
flex: 1;
display: inline-block;
padding-left: 2px;
width: 0;
position: relative;
}
.icon {

View File

@ -1,54 +0,0 @@
<script>
import PropertyControl from "./PropertyControl.svelte"
import { DetailSummary } from "@budibase/bbui"
export let name = ""
export let styleCategory = "normal"
export let properties = []
export let componentInstance = {}
export let onStyleChanged = () => {}
export let open = false
$: style = componentInstance["_styles"][styleCategory] || {}
$: changed = properties.some(prop => hasPropChanged(style, prop))
const hasPropChanged = (style, prop) => {
return style[prop.key] != null && style[prop.key] !== ""
}
const getControlProps = props => {
let controlProps = { ...(props || {}) }
delete controlProps.label
delete controlProps.key
delete controlProps.control
return controlProps
}
</script>
<DetailSummary name={`${name}${changed ? " *" : ""}`} on:open show={open} thin>
{#if open}
<div>
{#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)}
<PropertyControl
bindable={false}
label={`${prop.label}${hasPropChanged(style, prop) ? " *" : ""}`}
control={prop.control}
key={prop.key}
value={style[prop.key]}
onChange={value => onStyleChanged(styleCategory, prop.key, value)}
props={getControlProps(prop)}
/>
{/each}
</div>
{/if}
</DetailSummary>
<style>
div {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,43 @@
<script>
import { ActionButton } from "@budibase/bbui"
import { currentAsset, store } from "builderStore"
import { findClosestMatchingComponent } from "builderStore/storeUtils"
import { makeDatasourceFormComponents } from "builderStore/store/screenTemplates/utils/commonComponents"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
export let componentInstance
let confirmResetFieldsDialog
const resetFormFields = () => {
const form = findClosestMatchingComponent(
$currentAsset.props,
componentInstance._id,
component => component._component.endsWith("/form")
)
const dataSource = form?.dataSource
const fields = makeDatasourceFormComponents(dataSource)
store.actions.components.updateProp(
"_children",
fields.map(field => field.json())
)
}
</script>
<div>
<ActionButton
secondary
wide
on:click={() => confirmResetFieldsDialog?.show()}
>
Update form fields
</ActionButton>
</div>
<ConfirmDialog
bind:this={confirmResetFieldsDialog}
body={`All components inside this group will be deleted and replaced with fields to match the schema. Are you sure you want to update this Field Group?`}
okText="Update"
onOk={resetFormFields}
title="Confirm Form Field Update"
/>

View File

@ -0,0 +1,50 @@
<script>
import { get } from "svelte/store"
import { get as deepGet, setWith } from "lodash"
import { Input, DetailSummary } from "@budibase/bbui"
import PropertyControl from "./PropertyControls/PropertyControl.svelte"
import LayoutSelect from "./PropertyControls/LayoutSelect.svelte"
import RoleSelect from "./PropertyControls/RoleSelect.svelte"
import { currentAsset, store } from "builderStore"
import { FrontendTypes } from "constants"
export let componentInstance
function setAssetProps(name, value) {
const selectedAsset = get(currentAsset)
store.update(state => {
if (
name === "_instanceName" &&
state.currentFrontEndType === FrontendTypes.SCREEN
) {
selectedAsset.props._instanceName = value
} else {
setWith(selectedAsset, name.split("."), value, Object)
}
return state
})
store.actions.preview.saveSelected()
}
const screenSettings = [
// { key: "description", label: "Description", control: Input },
{ key: "routing.route", label: "Route", control: Input },
{ key: "routing.roleId", label: "Access", control: RoleSelect },
{ key: "layoutId", label: "Layout", control: LayoutSelect },
]
</script>
{#if $store.currentView !== "component" && $currentAsset && $store.currentFrontEndType === FrontendTypes.SCREEN}
<DetailSummary name="Screen" collapsible={false}>
{#each screenSettings as def (`${componentInstance._id}-${def.key}`)}
<PropertyControl
bindable={false}
control={def.control}
label={def.label}
key={def.key}
value={deepGet($currentAsset, def.key)}
onChange={val => setAssetProps(def.key, val)}
/>
{/each}
</DetailSummary>
{/if}

View File

@ -0,0 +1,51 @@
<script>
import PropertyControl from "./PropertyControls/PropertyControl.svelte"
import { DetailSummary } from "@budibase/bbui"
import { store } from "builderStore"
export let name
export let columns
export let properties
export let componentInstance
$: style = componentInstance._styles.normal || {}
$: changed = properties?.some(prop => hasPropChanged(style, prop)) ?? false
const hasPropChanged = (style, prop) => {
return style[prop.key] != null && style[prop.key] !== ""
}
const getControlProps = props => {
let controlProps = { ...(props || {}) }
delete controlProps.label
delete controlProps.key
delete controlProps.control
return controlProps
}
</script>
<DetailSummary collapsible={false} name={`${name}${changed ? " *" : ""}`}>
<div class="group-content" style="grid-template-columns: {columns || '1fr'}">
{#each properties as prop (`${componentInstance._id}-${prop.key}-${prop.label}`)}
<div style="grid-column: {prop.column || 'auto'}">
<PropertyControl
bindable={false}
label={`${prop.label}${hasPropChanged(style, prop) ? " *" : ""}`}
control={prop.control}
key={prop.key}
value={style[prop.key]}
onChange={val => store.actions.components.updateStyle(prop.key, val)}
props={getControlProps(prop)}
/>
</div>
{/each}
</div>
</DetailSummary>
<style>
.group-content {
display: grid;
align-items: stretch;
gap: var(--spacing-l);
}
</style>

View File

@ -1,112 +1,16 @@
import { Input, Select } from "@budibase/bbui"
import FlatButtonGroup from "./PropertyControls/FlatButtonGroup"
import Colorpicker from "./PropertyControls/ColorPicker.svelte"
import { Input, Select, ColorPicker } from "@budibase/bbui"
export const layout = [
{
label: "Display",
key: "display",
control: Select,
options: [
{ label: "Block", value: "block" },
{ label: "Inline Block", value: "inline-block" },
{ label: "Flex", value: "flex" },
{ label: "Inline Flex", value: "inline-flex" },
],
},
{
label: "Direction",
key: "flex-direction",
control: FlatButtonGroup,
buttonProps: [
{ icon: "ri-arrow-right-line", padding: "0px 5px", value: "row" },
{ icon: "ri-arrow-left-line", padding: "0px 5px", value: "rowReverse" },
{ icon: "ri-arrow-down-line", padding: "0px 5px", value: "column" },
{
icon: "ri-arrow-up-line",
padding: "0px 5px",
value: "columnReverse",
},
{ icon: "ri-close-line", value: "" },
],
},
{
label: "Justify",
key: "justify-content",
control: Select,
options: [
{ label: "Flex Start", value: "flex-start" },
{ label: "Flex End", value: "flex-end" },
{ label: "Center", value: "center" },
{ label: "Space Between", value: "space-between" },
{ label: "Space Around", value: "space-around" },
{ label: "Space Evenly", value: "space-evenly" },
],
},
{
label: "Align",
key: "align-items",
control: Select,
options: [
{ label: "Flex Start", value: "flex-start" },
{ label: "Flex End", value: "flex-end" },
{ label: "Center", value: "center" },
{ label: "Baseline", value: "baseline" },
{ label: "Stretch", value: "stretch" },
],
},
{
label: "Wrap",
key: "flex-wrap",
control: Select,
options: [
{ label: "Wrap", value: "wrap" },
{ label: "No wrap", value: "nowrap" },
],
},
{
label: "Gap",
key: "gap",
control: Select,
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
{ label: "20px", value: "20px" },
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
],
},
]
export const margin = [
{
label: "All sides",
key: "margin",
control: Select,
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
{ label: "20px", value: "20px" },
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
{ label: "128px", value: "128px" },
{ label: "256px", value: "256px" },
{ label: "Auto", value: "auto" },
{ label: "100%", value: "100%" },
],
},
export const margin = {
label: "Margin",
columns: "1fr 1fr",
settings: [
{
label: "Top",
key: "margin-top",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -124,8 +28,9 @@ export const margin = [
label: "Right",
key: "margin-right",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -143,8 +48,9 @@ export const margin = [
label: "Bottom",
key: "margin-bottom",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -162,8 +68,9 @@ export const margin = [
label: "Left",
key: "margin-left",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -177,32 +84,20 @@ export const margin = [
{ label: "100%", value: "100%" },
],
},
]
export const padding = [
{
label: "All sides",
key: "padding",
control: Select,
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
{ label: "20px", value: "20px" },
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
{ label: "Auto", value: "auto" },
{ label: "100%", value: "100%" },
],
},
}
export const padding = {
label: "Padding",
columns: "1fr 1fr",
settings: [
{
label: "Top",
key: "padding-top",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -210,6 +105,8 @@ export const padding = [
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
{ label: "128px", value: "128px" },
{ label: "256px", value: "256px" },
{ label: "Auto", value: "auto" },
{ label: "100%", value: "100%" },
],
@ -218,8 +115,9 @@ export const padding = [
label: "Right",
key: "padding-right",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -227,6 +125,8 @@ export const padding = [
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
{ label: "128px", value: "128px" },
{ label: "256px", value: "256px" },
{ label: "Auto", value: "auto" },
{ label: "100%", value: "100%" },
],
@ -235,8 +135,9 @@ export const padding = [
label: "Bottom",
key: "padding-bottom",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -244,6 +145,8 @@ export const padding = [
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
{ label: "128px", value: "128px" },
{ label: "256px", value: "256px" },
{ label: "Auto", value: "auto" },
{ label: "100%", value: "100%" },
],
@ -252,8 +155,9 @@ export const padding = [
label: "Left",
key: "padding-left",
control: Select,
bindable: false,
placeholder: "None",
options: [
{ label: "None", value: "0px" },
{ label: "4px", value: "4px" },
{ label: "8px", value: "8px" },
{ label: "16px", value: "16px" },
@ -261,240 +165,52 @@ export const padding = [
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "64px", value: "64px" },
{ label: "128px", value: "128px" },
{ label: "256px", value: "256px" },
{ label: "Auto", value: "auto" },
{ label: "100%", value: "100%" },
],
},
]
export const size = [
{
label: "Flex",
key: "flex",
control: Select,
options: [
{ label: "Shrink", value: "0 1 auto" },
{ label: "Grow", value: "1 1 auto" },
],
},
}
export const size = {
label: "Size",
columns: "1fr 1fr",
settings: [
{
label: "Width",
key: "width",
control: Input,
placeholder: "px",
placeholder: "Auto",
},
{
label: "Height",
key: "height",
control: Input,
placeholder: "px",
placeholder: "Auto",
},
{
label: "Min Width",
key: "min-width",
control: Input,
placeholder: "px",
},
{
label: "Max Width",
key: "max-width",
control: Input,
placeholder: "px",
},
{
label: "Min Height",
key: "min-height",
control: Input,
placeholder: "px",
},
{
label: "Max Height",
key: "max-height",
control: Input,
placeholder: "px",
},
]
],
}
export const position = [
{
label: "Position",
key: "position",
control: Select,
options: [
{ label: "Static", value: "static" },
{ label: "Relative", value: "relative" },
{ label: "Fixed", value: "fixed" },
{ label: "Absolute", value: "absolute" },
{ label: "Sticky", value: "sticky" },
],
},
{
label: "Top",
key: "top",
control: Input,
placeholder: "px",
},
{
label: "Right",
key: "right",
control: Input,
placeholder: "px",
},
{
label: "Bottom",
key: "bottom",
control: Input,
placeholder: "px",
},
{
label: "Left",
key: "left",
control: Input,
placeholder: "px",
},
{
label: "Z-index",
key: "z-index",
control: Select,
options: [
{ label: "-9999", value: "-9999" },
{ label: "-3", value: "-3" },
{ label: "-2", value: "-2" },
{ label: "-1", value: "-1" },
{ label: "0", value: "0" },
{ label: "1", value: "1" },
{ label: "2", value: "2" },
{ label: "3", value: "3" },
{ label: "9999", value: "9999" },
],
},
]
export const typography = [
{
label: "Font",
key: "font-family",
control: Select,
options: [
{ label: "Arial", value: "Arial" },
{ label: "Arial Black", value: "Arial Black" },
{ label: "Cursive", value: "Cursive" },
{ label: "Courier", value: "Courier" },
{ label: "Comic Sans MS", value: "Comic Sans MS" },
{ label: "Helvetica", value: "Helvetica" },
{ label: "Helvetica Neue", value: "Helvetica Neue" },
{ label: "Impact", value: "Impact" },
{ label: "Inter", value: "Inter" },
{ label: "Lucida Sans Unicode", value: "Lucida Sans Unicode" },
{ label: "Source Sans Pro", value: "Source Sans Pro" },
{ label: "Times New Roman", value: "Times New Roman" },
{ label: "Verdana", value: "Verdana" },
],
styleBindingProperty: "font-family",
},
{
label: "Weight",
key: "font-weight",
control: Select,
options: [
{ label: "200", value: "200" },
{ label: "300", value: "300" },
{ label: "400", value: "400" },
{ label: "500", value: "500" },
{ label: "600", value: "600" },
{ label: "700", value: "700" },
{ label: "800", value: "800" },
{ label: "900", value: "900" },
],
},
{
label: "size",
key: "font-size",
control: Select,
options: [
{ label: "8px", value: "8px" },
{ label: "10px", value: "10px" },
{ label: "12px", value: "12px" },
{ label: "14px", value: "14px" },
{ label: "16px", value: "16px" },
{ label: "18px", value: "18px" },
{ label: "20px", value: "20px" },
{ label: "24px", value: "24px" },
{ label: "32px", value: "32px" },
{ label: "48px", value: "48px" },
{ label: "60px", value: "60px" },
{ label: "72px", value: "72px" },
],
},
{
label: "Line H",
key: "line-height",
control: Select,
options: [
{ label: "1", value: "1" },
{ label: "1.25", value: "1.25" },
{ label: "1.5", value: "1.5" },
{ label: "1.75", value: "1.75" },
{ label: "2", value: "2" },
{ label: "4", value: "4" },
],
},
{
label: "Color",
key: "color",
control: Colorpicker,
},
{
label: "align",
key: "text-align",
control: FlatButtonGroup,
buttonProps: [
{ icon: "ri-align-left", padding: "0px 5px", value: "left" },
{ icon: "ri-align-center", padding: "0px 5px", value: "center" },
{ icon: "ri-align-right", padding: "0px 5px", value: "right" },
{ icon: "ri-align-justify", padding: "0px 5px", value: "justify" },
{ icon: "ri-close-line", value: "" },
],
},
{
label: "transform",
key: "text-transform",
control: FlatButtonGroup,
buttonProps: [
{ text: "BB", value: "uppercase" },
{ text: "Bb", value: "capitalize" },
{ text: "bb", value: "lowercase" },
{ icon: "ri-close-line", value: "" },
],
},
{
label: "Decoration",
key: "text-decoration-line",
control: Select,
options: [
{ label: "Underline", value: "underline" },
{ label: "Overline", value: "overline" },
{ label: "Line-through", value: "line-through" },
{ label: "Under Over", value: "underline overline" },
],
},
]
export const background = [
export const background = {
label: "Background",
columns: "auto 1fr",
settings: [
{
label: "Color",
key: "background",
control: Colorpicker,
control: ColorPicker,
},
{
label: "Gradient",
key: "background-image",
control: Select,
options: [
{ label: "None", value: "none" },
{
label: "Warm Flame",
value: "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%);",
value:
"linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%);",
},
{
label: "Night Fade",
@ -563,104 +279,46 @@ export const background = [
},
],
},
{
label: "Image",
key: "background",
control: Input,
placeholder: "URL",
},
]
export const border = [
{
label: "Radius",
key: "border-radius",
control: Select,
options: [
{ label: "None", value: "0" },
{ label: "X Small", value: "0.125rem" },
{ label: "Small", value: "0.25rem" },
{ label: "Medium", value: "0.5rem" },
{ label: "Large", value: "1rem" },
{ label: "X Large", value: "2rem" },
{ label: "XX Large", value: "4rem" },
{ label: "Round", value: "5678px" },
],
}
export const border = {
label: "Border",
columns: "auto 1fr",
settings: [
{
label: "Color",
key: "border-color",
control: ColorPicker,
},
{
label: "Width",
key: "border-width",
control: Select,
options: [
{ label: "None", value: "0" },
{ label: "X Small", value: "0.5px" },
{ label: "Small", value: "1px" },
{ label: "Medium", value: "2px" },
{ label: "Large", value: "4px" },
{ label: "X large", value: "8px" },
],
},
{
label: "Color",
key: "border-color",
control: Colorpicker,
},
{
label: "Style",
key: "border-style",
label: "Radius",
key: "border-radius",
control: Select,
column: "1 / 3",
options: [
{ label: "None", value: "none" },
{ label: "Hidden", value: "hidden" },
{ label: "Dotted", value: "dotted" },
{ label: "Dashed", value: "dashed" },
{ label: "Solid", value: "solid" },
{ label: "Double", value: "double" },
{ label: "Groove", value: "groove" },
{ label: "Ridge", value: "ridge" },
{ label: "Inset", value: "inset" },
{ label: "Outset", value: "outset" },
],
},
]
export const effects = [
{
label: "Opacity",
key: "opacity",
control: Select,
options: [
{ label: "0", value: "0" },
{ label: "0.2", value: "0.2" },
{ label: "0.4", value: "0.4" },
{ label: "0.6", value: "0.6" },
{ label: "0.8", value: "0.8" },
{ label: "1", value: "1" },
],
},
{
label: "Rotate",
key: "transform",
control: Select,
options: [
{ label: "None", value: "0" },
{ label: "45 deg", value: "rotate(45deg)" },
{ label: "90 deg", value: "rotate(90deg)" },
{ label: "135 deg", value: "rotate(135deg)" },
{ label: "180 deg", value: "rotate(180deg)" },
{ label: "225 deg", value: "rotate(225deg)" },
{ label: "270 deg", value: "rotate(270deg)" },
{ label: "315 deg", value: "rotate(315deg)" },
{ label: "360 deg", value: "rotate(360deg)" },
{ label: "Small", value: "0.25rem" },
{ label: "Medium", value: "0.5rem" },
{ label: "Large", value: "1rem" },
{ label: "Round", value: "100%" },
],
},
{
label: "Shadow",
key: "box-shadow",
control: Select,
column: "1 / 3",
options: [
{ label: "None", value: "none" },
{ label: "X Small", value: "0 1px 2px 0 rgba(0, 0, 0, 0.05)" },
{
label: "Small",
value:
@ -674,75 +332,11 @@ export const effects = [
{
label: "Large",
value:
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
},
{
label: "X Large",
value:
"0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)",
"0 8px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
},
],
},
]
export const transitions = [
{
label: "Property",
key: "transition-property",
control: Select,
options: [
{ label: "None", value: "none" },
{ label: "All", value: "all" },
{ label: "Background Color", value: "background color" },
{ label: "Color", value: "color" },
{ label: "Font Size", value: "font size" },
{ label: "Font Weight", value: "font weight" },
{ label: "Height", value: "height" },
{ label: "Margin", value: "margin" },
{ label: "Opacity", value: "opacity" },
{ label: "Padding", value: "padding" },
{ label: "Rotate", value: "rotate" },
{ label: "Shadow", value: "shadow" },
{ label: "Width", value: "width" },
],
},
{
label: "Duration",
key: "transition-duration",
control: Select,
placeholder: "sec",
options: [
{ label: "0.4s", value: "0.4s" },
{ label: "0.6s", value: "0.6s" },
{ label: "0.8s", value: "0.8s" },
{ label: "1s", value: "1s" },
{ label: "2s", value: "2s" },
{ label: "4s", value: "4s" },
],
},
{
label: "Ease",
key: "transition-timing-function",
control: Select,
options: [
{ label: "Linear", value: "linear" },
{ label: "Ease", value: "ease" },
{ label: "Ease in", value: "ease-in" },
{ label: "Ease out", value: "ease-out" },
{ label: "Ease in out", value: "ease-in-out" },
],
},
]
export const allStyles = {
layout,
margin,
padding,
size,
position,
typography,
background,
border,
effects,
transitions,
}
export const all = [margin]

View File

@ -62,6 +62,7 @@
{#if datasource && integration}
<section>
<Layout>
<Layout gap="XS" noPadding>
<header>
<svelte:component
this={ICONS[datasource.source]}
@ -70,7 +71,8 @@
/>
<Heading size="M">{datasource.name}</Heading>
</header>
<Body size="S" grey lh>{integration.description}</Body>
<Body size="M">{integration.description}</Body>
</Layout>
<Divider />
<div class="container">
<div class="config-header">
@ -139,7 +141,6 @@
}
header {
margin: 0 0 var(--spacing-xs) 0;
display: flex;
gap: var(--spacing-l);
align-items: center;

View File

@ -14,14 +14,16 @@
<section>
<Layout>
<Layout gap="XS" noPadding>
<header>
<svelte:component this={ICONS.BUDIBASE} height="26" width="26" />
<Heading size="M">Budibase Internal</Heading>
</header>
<Body size="S" grey lh
>Budibase internal tables are part of your app, the data will be stored in
your apps context.</Body
>
<Body size="M">
Budibase internal tables are part of your app, so the data will be
stored in your apps context.
</Body>
</Layout>
<Divider />
<Heading size="S">Tables</Heading>
<div class="table-list">
@ -32,7 +34,7 @@
>
<Body size="S">{table.name}</Body>
{#if table.primaryDisplay}
<Body size="S">display column: {table.primaryDisplay}</Body>
<Body size="S">Display column: {table.primaryDisplay}</Body>
{/if}
</div>
{/each}
@ -50,7 +52,6 @@
}
header {
margin: 0 0 var(--spacing-xs) 0;
display: flex;
gap: var(--spacing-l);
align-items: center;

File diff suppressed because it is too large Load Diff

View File

@ -91,14 +91,15 @@
{/if}
<style>
#spectrum-root {
height: 100%;
width: 100%;
overflow: hidden;
}
#spectrum-root,
#app-root {
height: 100%;
width: 100%;
padding: 0;
margin: 0;
overflow: hidden;
}
#app-root {
position: relative;
}
</style>

View File

@ -23,6 +23,11 @@
// props with old ones, depending on how long enrichment takes.
let latestUpdateTime
// Keep track of stringified representations of context and instance
// to avoid enriching bindings as much as possible
let lastContextKey
let lastInstanceKey
// Get contexts
const context = getContext("context")
const insideScreenslot = !!getContext("screenslot")
@ -42,7 +47,9 @@
definition?.hasChildren &&
definition?.showEmptyState !== false &&
$builderStore.inBuilder
$: updateComponentProps(instance, $context)
$: rawProps = getRawProps(instance)
$: instanceKey = JSON.stringify(rawProps)
$: updateComponentProps(rawProps, instanceKey, $context)
$: selected =
$builderStore.inBuilder &&
$builderStore.selectedComponentId === instance._id
@ -59,6 +66,16 @@
name,
})
const getRawProps = instance => {
let validProps = {}
Object.entries(instance)
.filter(([name]) => !name.startsWith("_"))
.forEach(([key, value]) => {
validProps[key] = value
})
return validProps
}
// Gets the component constructor for the specified component
const getComponentConstructor = component => {
const split = component?.split("/")
@ -76,13 +93,23 @@
}
// Enriches any string component props using handlebars
const updateComponentProps = (instance, context) => {
const updateComponentProps = (rawProps, instanceKey, context) => {
const instanceSame = instanceKey === lastInstanceKey
const contextSame = context.key === lastContextKey
if (instanceSame && contextSame) {
return
} else {
lastInstanceKey = instanceKey
lastContextKey = context.key
}
// Record the timestamp so we can reference it after enrichment
latestUpdateTime = Date.now()
const enrichmentTime = latestUpdateTime
// Enrich props with context
const enrichedProps = enrichProps(instance, context)
const enrichedProps = enrichProps(rawProps, context)
// Abandon this update if a newer update has started
if (enrichmentTime !== latestUpdateTime) {

View File

@ -14,18 +14,32 @@
const newContext = createContextStore(context)
setContext("context", newContext)
$: providerKey = key || $component.id
const providerKey = key || $component.id
// Add data context
$: newContext.actions.provideData(providerKey, data)
// Generate a permanent unique ID for this component and use it to register
// any datasource actions
const instanceId = generate()
// Instance ID is unique to each instance of a provider
let instanceId
// Keep previous state around so we can avoid updating unless necessary
let lastDataKey
let lastActionsKey
// Add actions context
$: {
if (instanceId) {
actions?.forEach(({ type, callback, metadata }) => {
$: provideData(data)
$: provideActions(actions, instanceId)
const provideData = newData => {
const dataKey = JSON.stringify(newData)
if (dataKey !== lastDataKey) {
newContext.actions.provideData(providerKey, newData)
lastDataKey = dataKey
}
}
const provideActions = newActions => {
const actionsKey = JSON.stringify(newActions)
if (actionsKey !== lastActionsKey) {
lastActionsKey = actionsKey
newActions?.forEach(({ type, callback, metadata }) => {
newContext.actions.provideAction(providerKey, type, callback)
// Register any "refresh datasource" actions with a singleton store
@ -43,10 +57,6 @@
}
onMount(() => {
// Generate a permanent unique ID for this component and use it to register
// any datasource actions
instanceId = generate()
// Unregister all datasource instances when unmounting this provider
return () => dataSourceStore.actions.unregisterInstance(instanceId)
})

View File

@ -1,6 +1,8 @@
<script>
import { onMount, onDestroy } from "svelte"
import SettingsButton from "./SettingsButton.svelte"
import SettingsColorPicker from "./SettingsColorPicker.svelte"
import SettingsPicker from "./SettingsPicker.svelte"
import { builderStore } from "../../store"
import { domDebounce } from "../../utils/domDebounce"
@ -87,6 +89,7 @@
>
{#each settings as setting, idx}
{#if setting.type === "select"}
{#if setting.barStyle === "buttons"}
{#each setting.options as option}
<SettingsButton
prop={setting.key}
@ -95,11 +98,35 @@
title={option.barTitle}
/>
{/each}
{:else}
<SettingsPicker
prop={setting.key}
options={setting.options}
label={setting.label}
/>
{/if}
{#if idx < settings.length - 1}
{:else if setting.type === "boolean"}
<SettingsButton
prop={setting.key}
icon={setting.barIcon}
title={setting.barTitle}
bool
/>
{:else if setting.type === "color"}
<SettingsColorPicker prop={setting.key} />
{/if}
{#if setting.barSeparator !== false}
<div class="divider" />
{/if}
{/each}
<SettingsButton
icon="Delete"
on:click={() => {
builderStore.actions.deleteComponent(
$builderStore.selectedComponent._id
)
}}
/>
</div>
{/if}

View File

@ -1,6 +1,7 @@
<script>
import { Icon } from "@budibase/bbui"
import { builderStore } from "../../store"
import { createEventDispatcher } from "svelte"
export let prop
export let value
@ -9,6 +10,7 @@
export let rotate = false
export let bool = false
const dispatch = createEventDispatcher()
$: currentValue = $builderStore.selectedComponent?.[prop]
$: active = prop && (bool ? !!currentValue : currentValue === value)
</script>
@ -22,6 +24,7 @@
const newValue = bool ? !currentValue : value
builderStore.actions.updateProp(prop, newValue)
}
dispatch("click")
}}
>
<Icon name={icon} size="S" />

View File

@ -0,0 +1,26 @@
<script>
import { ColorPicker } from "@budibase/bbui"
import { builderStore } from "../../store"
export let prop
$: currentValue = $builderStore.selectedComponent?.[prop]
</script>
<div>
<ColorPicker
size="S"
value={currentValue}
on:change={e => {
if (prop) {
builderStore.actions.updateProp(prop, e.detail)
}
}}
/>
</div>
<style>
div {
padding: 0 4px;
}
</style>

View File

@ -0,0 +1,31 @@
<script>
import { Select } from "@budibase/bbui"
import { builderStore } from "../../store"
export let prop
export let options
export let label
$: currentValue = $builderStore.selectedComponent?.[prop]
</script>
<div>
<Select
quiet
autoWidth
placeholder={label}
{options}
value={currentValue}
on:change={e => {
if (prop) {
builderStore.actions.updateProp(prop, e.detail)
}
}}
/>
</div>
<style>
div {
padding: 0 4px;
}
</style>

View File

@ -56,13 +56,14 @@ const createBuilderStore = () => {
const actions = {
selectComponent: id => {
if (id) {
dispatchEvent("select-component", { id })
}
},
updateProp: (prop, value) => {
dispatchEvent("update-prop", { prop, value })
},
deleteComponent: id => {
dispatchEvent("delete-component", { id })
},
}
return {
...writableStore,

View File

@ -4,7 +4,21 @@ export const createContextStore = oldContext => {
const newContext = writable({})
const contexts = oldContext ? [oldContext, newContext] : [newContext]
const totalContext = derived(contexts, $contexts => {
return $contexts.reduce((total, context) => ({ ...total, ...context }), {})
// The key is the serialized representation of context
let key = ""
for (let i = 0; i < $contexts.length - 1; i++) {
key += $contexts[i].key
}
key += JSON.stringify($contexts[$contexts.length - 1])
// Reduce global state
const reducer = (total, context) => ({ ...total, ...context })
const context = $contexts.reduce(reducer, {})
return {
...context,
key,
}
})
// Adds a data context layer to the tree

View File

@ -31,9 +31,15 @@ const triggerAutomationHandler = async action => {
}
const navigationHandler = action => {
if (action.parameters.url) {
const { url } = action.parameters
if (url) {
const external = !url.startsWith("/")
if (external) {
window.location.href = url
} else {
routeStore.actions.navigate(action.parameters.url)
}
}
}
const queryExecutionHandler = async action => {

View File

@ -22,14 +22,6 @@ export const propsAreSame = (a, b) => {
* Data bindings are enriched, and button actions are enriched.
*/
export const enrichProps = (props, context) => {
// Exclude all private props that start with an underscore
let validProps = {}
Object.entries(props)
.filter(([name]) => !name.startsWith("_"))
.forEach(([key, value]) => {
validProps[key] = value
})
// Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires
const totalContext = {
@ -41,7 +33,7 @@ export const enrichProps = (props, context) => {
}
// Enrich all data bindings in top level props
let enrichedProps = enrichDataBindings(validProps, totalContext)
let enrichedProps = enrichDataBindings(props, totalContext)
// Enrich click actions if they exist
if (enrichedProps.onClick) {

View File

@ -31,6 +31,12 @@ export const styleable = (node, styles = {}) => {
if (newStyles.empty) {
baseStyles.border = "2px dashed var(--grey-5)"
baseStyles.padding = "var(--spacing-l)"
baseStyles.overflow = "hidden"
}
// Append border-style css if border-width is specified
if (newStyles.normal?.["border-width"]) {
baseStyles["border-style"] = "solid"
}
const componentId = newStyles.id

View File

@ -3,6 +3,7 @@
"emit": true,
"key": true
},
"parser": "@typescript-eslint/parser",
"env": {
"node": true
},

View File

@ -9,6 +9,7 @@ ENV BUDIBASE_ENVIRONMENT=PRODUCTION
# copy files and install dependencies
COPY . ./
RUN yarn
RUN yarn build
EXPOSE 4001

View File

@ -1,4 +1,4 @@
const elastic = {}
const elastic: any = {}
elastic.Client = function () {
this.index = jest.fn().mockResolvedValue({ body: [] })

View File

@ -1,18 +0,0 @@
class Email {
constructor() {
this.apiKey = null
}
setApiKey(apiKey) {
this.apiKey = apiKey
}
async send(msg) {
if (msg.to === "invalid@test.com") {
throw "Invalid"
}
return msg
}
}
module.exports = new Email()

View File

@ -0,0 +1,22 @@
module SendgridMock {
class Email {
constructor() {
// @ts-ignore
this.apiKey = null
}
setApiKey(apiKey: any) {
// @ts-ignore
this.apiKey = apiKey
}
async send(msg: any) {
if (msg.to === "invalid@test.com") {
throw "Invalid"
}
return msg
}
}
module.exports = new Email()
}

View File

@ -1,5 +0,0 @@
function Airtable() {
this.base = jest.fn()
}
module.exports = Airtable

View File

@ -0,0 +1,8 @@
module AirtableMock {
function Airtable() {
// @ts-ignore
this.base = jest.fn()
}
module.exports = Airtable
}

View File

@ -1,21 +0,0 @@
const arangodb = {}
arangodb.Database = function () {
this.query = jest.fn(() => ({
all: jest.fn(),
}))
this.collection = jest.fn(() => "collection")
this.close = jest.fn()
}
arangodb.aql = (strings, ...args) => {
let str = strings.join("{}")
for (let arg of args) {
str = str.replace("{}", arg)
}
return str
}
module.exports = arangodb

View File

@ -0,0 +1,24 @@
module ArangoMock {
const arangodb: any = {}
arangodb.Database = function () {
this.query = jest.fn(() => ({
all: jest.fn(),
}))
this.collection = jest.fn(() => "collection")
this.close = jest.fn()
}
// @ts-ignore
arangodb.aql = (strings, ...args) => {
let str = strings.join("{}")
for (let arg of args) {
str = str.replace("{}", arg)
}
return str
}
module.exports = arangodb
}

View File

@ -1,38 +0,0 @@
const aws = {}
const response = body => () => ({ promise: () => body })
function DocumentClient() {
this.put = jest.fn(response({}))
this.query = jest.fn(
response({
Items: [],
})
)
this.scan = jest.fn(
response({
Items: [
{
Name: "test",
},
],
})
)
this.get = jest.fn(response({}))
this.update = jest.fn(response({}))
this.delete = jest.fn(response({}))
}
function S3() {
this.listObjects = jest.fn(
response({
Contents: {},
})
)
}
aws.DynamoDB = { DocumentClient }
aws.S3 = S3
aws.config = { update: jest.fn() }
module.exports = aws

View File

@ -0,0 +1,47 @@
module AwsMock {
const aws: any = {}
const response = (body: any) => () => ({promise: () => body})
function DocumentClient() {
// @ts-ignore
this.put = jest.fn(response({}))
// @ts-ignore
this.query = jest.fn(
response({
Items: [],
})
)
// @ts-ignore
this.scan = jest.fn(
response({
Items: [
{
Name: "test",
},
],
})
)
// @ts-ignore
this.get = jest.fn(response({}))
// @ts-ignore
this.update = jest.fn(response({}))
// @ts-ignore
this.delete = jest.fn(response({}))
}
function S3() {
// @ts-ignore
this.listObjects = jest.fn(
response({
Contents: {},
})
)
}
aws.DynamoDB = {DocumentClient}
aws.S3 = S3
aws.config = {update: jest.fn()}
module.exports = aws
}

View File

@ -1,19 +0,0 @@
const mongodb = {}
mongodb.MongoClient = function () {
this.connect = jest.fn()
this.close = jest.fn()
this.insertOne = jest.fn()
this.find = jest.fn(() => ({ toArray: () => [] }))
this.collection = jest.fn(() => ({
insertOne: this.insertOne,
find: this.find,
}))
this.db = () => ({
collection: this.collection,
})
}
module.exports = mongodb

View File

@ -0,0 +1,21 @@
module MongoMock {
const mongodb: any = {}
mongodb.MongoClient = function () {
this.connect = jest.fn()
this.close = jest.fn()
this.insertOne = jest.fn()
this.find = jest.fn(() => ({toArray: () => []}))
this.collection = jest.fn(() => ({
insertOne: this.insertOne,
find: this.find,
}))
this.db = () => ({
collection: this.collection,
})
}
module.exports = mongodb
}

View File

@ -1,22 +0,0 @@
const mssql = {}
mssql.query = jest.fn(() => ({
recordset: [
{
a: "string",
b: 1,
},
],
}))
// mssql.connect = jest.fn(() => ({ recordset: [] }))
mssql.ConnectionPool = jest.fn(() => ({
connect: jest.fn(() => ({
request: jest.fn(() => ({
query: jest.fn(() => ({})),
})),
})),
}))
module.exports = mssql

View File

@ -0,0 +1,24 @@
module MsSqlMock {
const mssql: any = {}
mssql.query = jest.fn(() => ({
recordset: [
{
a: "string",
b: 1,
},
],
}))
// mssql.connect = jest.fn(() => ({ recordset: [] }))
mssql.ConnectionPool = jest.fn(() => ({
connect: jest.fn(() => ({
request: jest.fn(() => ({
query: jest.fn(() => ({})),
})),
})),
}))
module.exports = mssql
}

View File

@ -1,12 +0,0 @@
const mysql = {}
const client = {
connect: jest.fn(),
query: jest.fn((query, bindings, fn) => {
fn(null, [])
}),
}
mysql.createConnection = jest.fn(() => client)
module.exports = mysql

View File

@ -0,0 +1,14 @@
module MySQLMock {
const mysql: any = {}
const client = {
connect: jest.fn(),
query: jest.fn((query, bindings, fn) => {
fn(null, [])
}),
}
mysql.createConnection = jest.fn(() => client)
module.exports = mysql
}

View File

@ -1,58 +0,0 @@
const fetch = jest.requireActual("node-fetch")
module.exports = async (url, opts) => {
function json(body, status = 200) {
return {
status,
headers: {
get: () => {
return ["application/json"]
},
},
json: async () => {
return body
},
}
}
if (url.includes("/api/admin")) {
return json({
email: "test@test.com",
_id: "us_test@test.com",
status: "active",
})
}
// mocked data based on url
else if (url.includes("api/apps")) {
return json({
app1: {
url: "/app1",
},
})
} else if (url.includes("test.com")) {
return json({
body: opts.body,
url,
method: opts.method,
})
} else if (url.includes("invalid.com")) {
return json(
{
invalid: true,
},
404
)
} else if (url.includes("_search")) {
return json({
rows: [
{
doc: {
_id: "test",
},
},
],
bookmark: "test",
})
}
return fetch(url, opts)
}

View File

@ -0,0 +1,60 @@
module FetchMock {
const fetch = jest.requireActual("node-fetch")
module.exports = async (url: any, opts: any) => {
function json(body: any, status = 200) {
return {
status,
headers: {
get: () => {
return ["application/json"]
},
},
json: async () => {
return body
},
}
}
if (url.includes("/api/admin")) {
return json({
email: "test@test.com",
_id: "us_test@test.com",
status: "active",
})
}
// mocked data based on url
else if (url.includes("api/apps")) {
return json({
app1: {
url: "/app1",
},
})
} else if (url.includes("test.com")) {
return json({
body: opts.body,
url,
method: opts.method,
})
} else if (url.includes("invalid.com")) {
return json(
{
invalid: true,
},
404
)
} else if (url.includes("_search")) {
return json({
rows: [
{
doc: {
_id: "test",
},
},
],
bookmark: "test",
})
}
return fetch(url, opts)
}
}

View File

@ -1,29 +0,0 @@
const pg = {}
const query = jest.fn(() => ({
rows: [
{
a: "string",
b: 1,
},
],
}))
// constructor
function Client() {}
Client.prototype.query = query
Client.prototype.connect = jest.fn()
Client.prototype.release = jest.fn()
function Pool() {}
Pool.prototype.query = query
Pool.prototype.connect = jest.fn(() => {
return new Client()
})
pg.Client = Client
pg.Pool = Pool
pg.queryMock = query
module.exports = pg

View File

@ -0,0 +1,35 @@
module PgMock {
const pg: any = {}
const query = jest.fn(() => ({
rows: [
{
a: "string",
b: 1,
},
],
}))
// constructor
function Client() {
}
Client.prototype.query = query
Client.prototype.connect = jest.fn()
Client.prototype.release = jest.fn()
function Pool() {
}
Pool.prototype.query = query
Pool.prototype.connect = jest.fn(() => {
// @ts-ignore
return new Client()
})
pg.Client = Client
pg.Pool = Pool
pg.queryMock = query
module.exports = pg
}

View File

@ -3,25 +3,29 @@
"email": "hi@budibase.com",
"version": "0.9.66",
"description": "Budibase Web Server",
"main": "src/electron.js",
"main": "src/index.js",
"repository": {
"type": "git",
"url": "https://github.com/Budibase/budibase.git"
},
"scripts": {
"test": "jest --testPathIgnorePatterns=routes && yarn run test:integration",
"test:integration": "jest --coverage --detectOpenHandles",
"build": "rm -rf dist/ && tsc && mv dist/src/* dist/ && rmdir dist/src/ && yarn postbuild",
"postbuild": "copyfiles -u 1 src/**/*.svelte dist/ && copyfiles -u 1 src/**/*.hbs dist/ && copyfiles -u 1 src/**/*.json dist/",
"test": "jest --coverage --maxWorkers=2",
"test:watch": "jest --watch",
"build:docker": "docker build . -t app-service",
"run:docker": "node src/index",
"run:docker": "node dist/index.js",
"dev:stack:up": "node scripts/dev/manage.js up",
"dev:stack:down": "node scripts/dev/manage.js down",
"dev:stack:nuke": "node scripts/dev/manage.js nuke",
"dev:builder": "yarn run dev:stack:up && nodemon src/index.js",
"dev:builder": "yarn run dev:stack:up && ts-node src/index.ts",
"format": "prettier --config ../../.prettierrc.json 'src/**/*.ts' --write",
"lint": "eslint --fix src/",
"lint:fix": "yarn run format && yarn run lint",
"initialise": "node scripts/initialise.js"
},
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"setupFiles": [
"./scripts/jestSetup.js"
@ -112,14 +116,25 @@
"@babel/preset-env": "^7.14.4",
"@budibase/standard-components": "^0.9.66",
"@jest/test-sequencer": "^24.8.0",
"@types/bull": "^3.15.1",
"@types/jest": "^26.0.23",
"@types/koa": "^2.13.3",
"@types/koa-router": "^7.4.2",
"@types/node": "^15.12.4",
"@typescript-eslint/parser": "^4.28.0",
"babel-jest": "^27.0.2",
"copyfiles": "^2.4.1",
"docker-compose": "^0.23.6",
"eslint": "^6.8.0",
"express": "^4.17.1",
"jest": "^24.8.0",
"jest": "^27.0.5",
"nodemon": "^2.0.4",
"pouchdb-adapter-memory": "^7.2.1",
"supertest": "^4.0.2"
"prettier": "^2.3.1",
"supertest": "^4.0.2",
"ts-jest": "^27.0.3",
"ts-node": "^10.0.0",
"typescript": "^4.3.4"
},
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
}

View File

@ -6,3 +6,4 @@ env._set("JWT_SECRET", "test-jwtsecret")
env._set("CLIENT_ID", "test-client-id")
env._set("BUDIBASE_DIR", tmpdir("budibase-unittests"))
env._set("LOG_LEVEL", "silent")
env._set("PORT", 0)

View File

@ -14,6 +14,7 @@ exports.save = async function (ctx) {
...EMPTY_LAYOUT,
...layout,
}
layout.props._instanceName = layout.name
}
layout._id = layout._id || generateLayoutID()

View File

@ -1,7 +1,3 @@
const { clearAllApps, checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
const { AppStatus } = require("../../../db/utils")
jest.mock("../../../utilities/redis", () => ({
init: jest.fn(),
getAllLocks: () => {
@ -15,6 +11,10 @@ jest.mock("../../../utilities/redis", () => ({
checkDebounce: jest.fn(),
}))
const { clearAllApps, checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
const { AppStatus } = require("../../../db/utils")
describe("/applications", () => {
let request = setup.getRequest()
let config = setup.getConfig()

View File

@ -1,8 +1,8 @@
jest.mock("../../../utilities/fileSystem/utilities")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
jest.mock("../../../utilities/fileSystem/utilities")
describe("/backups", () => {
let request = setup.getRequest()
let config = setup.getConfig()

View File

@ -1,8 +1,8 @@
jest.mock("pg")
let setup = require("./utilities")
let { basicDatasource } = setup.structures
let { checkBuilderEndpoint } = require("./utilities/TestFunctions")
jest.mock("pg")
const pg = require("pg")
describe("/datasources", () => {

View File

@ -1,4 +1,6 @@
// need to load environment first
import { ExtendableContext } from "koa"
const env = require("./environment")
const CouchDB = require("./db")
require("@budibase/auth").init(CouchDB)
@ -40,7 +42,7 @@ app.use(
if (!env.isTest()) {
const bullApp = bullboard.init()
app.use(async (ctx, next) => {
app.use(async (ctx: ExtendableContext, next: () => any) => {
if (ctx.path.startsWith(bullboard.pathPrefix)) {
ctx.status = 200
ctx.respond = false
@ -61,9 +63,9 @@ if (env.isProd()) {
env._set("NODE_ENV", "production")
Sentry.init()
app.on("error", (err, ctx) => {
Sentry.withScope(function (scope) {
scope.addEventProcessor(function (event) {
app.on("error", (err: any, ctx: ExtendableContext) => {
Sentry.withScope(function (scope: any) {
scope.addEventProcessor(function (event: any) {
return Sentry.Handlers.parseRequest(event, ctx.request)
})
Sentry.captureException(err)

View File

@ -1,18 +1,4 @@
require("../../environment")
const automation = require("../index")
const usageQuota = require("../../utilities/usageQuota")
const thread = require("../thread")
const triggers = require("../triggers")
const { basicAutomation, basicTable } = require("../../tests/utilities/structures")
const { wait } = require("../../utilities")
const { makePartial } = require("../../tests/utilities")
const { cleanInputValues } = require("../automationUtils")
const setup = require("./utilities")
let workerJob
jest.mock("../../utilities/usageQuota")
usageQuota.getAPIKey.mockReturnValue({ apiKey: "test" })
jest.mock("../thread")
jest.spyOn(global.console, "error")
jest.mock("worker-farm", () => {
@ -30,6 +16,21 @@ jest.mock("worker-farm", () => {
}
})
require("../../environment")
const automation = require("../index")
const usageQuota = require("../../utilities/usageQuota")
const thread = require("../thread")
const triggers = require("../triggers")
const { basicAutomation, basicTable } = require("../../tests/utilities/structures")
const { wait } = require("../../utilities")
const { makePartial } = require("../../tests/utilities")
const { cleanInputValues } = require("../automationUtils")
const setup = require("./utilities")
let workerJob
usageQuota.getAPIKey.mockReturnValue({ apiKey: "test" })
describe("Run through some parts of the automations system", () => {
let config = setup.getConfig()

View File

@ -1,9 +1,8 @@
const usageQuota = require("../../utilities/usageQuota")
const env = require("../../environment")
const setup = require("./utilities")
jest.mock("../../utilities/usageQuota")
const usageQuota = require("../../utilities/usageQuota")
const setup = require("./utilities")
describe("test the create row action", () => {
let table, row
let config = setup.getConfig()

View File

@ -1,9 +1,8 @@
const usageQuota = require("../../utilities/usageQuota")
const env = require("../../environment")
const setup = require("./utilities")
jest.mock("../../utilities/usageQuota")
const usageQuota = require("../../utilities/usageQuota")
const setup = require("./utilities")
describe("test the delete row action", () => {
let table, row, inputs
let config = setup.getConfig()

View File

@ -0,0 +1,28 @@
export interface Table {
_id: string
_rev?: string
type?: string
views?: {}
name?: string
primary?: string[]
schema: {
[key: string]: {
// TODO: replace with field types enum when done
type: string
fieldName?: string
name: string
constraints?: {
type?: string
email?: boolean
inclusion?: string[]
length?: {
minimum?: string | number
maximum?: string | number
}
presence?: boolean
}
}
}
primaryDisplay?: string
sourceId?: string
}

View File

@ -17,6 +17,7 @@ exports.FieldTypes = {
LINK: "link",
FORMULA: "formula",
AUTO: "auto",
JSON: "json",
}
exports.RelationshipTypes = {

View File

@ -57,6 +57,7 @@ const BASE_LAYOUTS = [
name: "Navigation Layout",
props: {
_id: "4f569166-a4f3-47ea-a09e-6d218c75586f",
_instanceName: "Navigation Layout",
_component: "@budibase/standard-components/layout",
_children: [
{
@ -102,6 +103,7 @@ const BASE_LAYOUTS = [
name: "Empty Layout",
props: {
_id: "3723ffa1-f9e0-4c05-8013-98195c788ed6",
_instanceName: "Empty Layout",
_component: "@budibase/standard-components/layout",
_children: [
{

View File

@ -20,15 +20,13 @@ exports.createHomeScreen = () => ({
_id: "ef60083f-4a02-4df3-80f3-a0d3d16847e7",
_component: "@budibase/standard-components/heading",
_styles: {
normal: {
"text-align": "left",
},
hover: {},
active: {},
selected: {},
},
text: "Welcome to your Budibase App 👋",
type: "h2",
size: "M",
align: "left",
_instanceName: "Heading",
_children: [],
},
@ -38,6 +36,7 @@ exports.createHomeScreen = () => ({
hAlign: "stretch",
vAlign: "top",
size: "grow",
gap: "M",
},
routing: {
route: "/",

View File

@ -87,33 +87,27 @@ async function getFullLinkedDocs(appId, links) {
/**
* Update link documents for a row or table - this is to be called by the API controller when a change is occurring.
* @param {string} eventType states what type of change which is occurring, means this can be expanded upon in the
* @param {string} args.eventType states what type of change which is occurring, means this can be expanded upon in the
* future quite easily (all updates go through one function).
* @param {string} appId The ID of the instance in which the change is occurring.
* @param {string} tableId The ID of the of the table which is being changed.
* @param {object|null} row The row which is changing, e.g. created, updated or deleted.
* @param {object|null} table If the table has already been retrieved this can be used to reduce database gets.
* @param {object|null} oldTable If the table is being updated then the old table can be provided for differencing.
* @param {string} args.appId The ID of the instance in which the change is occurring.
* @param {string} args.tableId The ID of the of the table which is being changed.
* @param {object|null} args.row The row which is changing, e.g. created, updated or deleted.
* @param {object|null} args.table If the table has already been retrieved this can be used to reduce database gets.
* @param {object|null} args.oldTable If the table is being updated then the old table can be provided for differencing.
* @returns {Promise<object>} When the update is complete this will respond successfully. Returns the row for
* row operations and the table for table operations.
*/
exports.updateLinks = async function ({
eventType,
appId,
row,
tableId,
table,
oldTable,
}) {
exports.updateLinks = async function (args) {
const { eventType, appId, row, tableId, table, oldTable } = args
const baseReturnObj = row == null ? table : row
if (appId == null) {
throw "Cannot operate without an instance ID."
}
// make sure table ID is set
if (tableId == null && table != null) {
arguments[0].tableId = table._id
args.tableId = table._id
}
let linkController = new LinkController(arguments[0])
let linkController = new LinkController(args)
try {
if (
!(await linkController.doesTableHaveLinkedFields()) &&

View File

@ -17,24 +17,20 @@ exports.createLinkView = createLinkView
/**
* Gets the linking documents, not the linked documents themselves.
* @param {string} appId The instance in which we are searching for linked rows.
* @param {string} tableId The table which we are searching for linked rows against.
* @param {string|null} fieldName The name of column/field which is being altered, only looking for
* @param {string} args.appId The instance in which we are searching for linked rows.
* @param {string} args.tableId The table which we are searching for linked rows against.
* @param {string|null} args.fieldName The name of column/field which is being altered, only looking for
* linking documents that are related to it. If this is not specified then the table level will be assumed.
* @param {string|null} rowId The ID of the row which we want to find linking documents for -
* @param {string|null} args.rowId The ID of the row which we want to find linking documents for -
* if this is not specified then it will assume table or field level depending on whether the
* field name has been specified.
* @param {boolean|null} includeDocs whether to include docs in the response call, this is considerably slower so only
* @param {boolean|null} args.includeDocs whether to include docs in the response call, this is considerably slower so only
* use this if actually interested in the docs themselves.
* @returns {Promise<object[]>} This will return an array of the linking documents that were found
* (if any).
*/
exports.getLinkDocuments = async function ({
appId,
tableId,
rowId,
includeDocs,
}) {
exports.getLinkDocuments = async function (args) {
const { appId, tableId, rowId, includeDocs } = args
const db = new CouchDB(appId)
let params
if (rowId != null) {

View File

@ -1,15 +0,0 @@
exports.QUERY_TYPES = {
SQL: "sql",
JSON: "json",
FIELDS: "fields",
}
exports.FIELD_TYPES = {
STRING: "string",
BOOLEAN: "boolean",
NUMBER: "number",
PASSWORD: "password",
LIST: "list",
OBJECT: "object",
JSON: "json",
}

View File

@ -1,130 +0,0 @@
const Airtable = require("airtable")
const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://airtable.com/api",
description:
"Airtable is a spreadsheet-database hybrid, with the features of a database but applied to a spreadsheet.",
friendlyName: "Airtable",
datasource: {
apiKey: {
type: FIELD_TYPES.PASSWORD,
default: "enter api key",
required: true,
},
base: {
type: FIELD_TYPES.STRING,
default: "mybase",
required: true,
},
},
query: {
create: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
read: {
type: QUERY_TYPES.FIELDS,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
view: {
type: FIELD_TYPES.STRING,
required: true,
},
numRecords: {
type: FIELD_TYPES.NUMBER,
default: 10,
},
},
},
update: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
id: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
delete: {
type: QUERY_TYPES.JSON,
},
},
}
class AirtableIntegration {
constructor(config) {
this.config = config
this.client = new Airtable(config).base(config.base)
}
async create(query) {
const { table, json } = query
try {
const records = await this.client(table).create([
{
fields: json,
},
])
return records
} catch (err) {
console.error("Error writing to airtable", err)
throw err
}
}
async read(query) {
try {
const records = await this.client(query.table)
.select({ maxRecords: query.numRecords || 10, view: query.view })
.firstPage()
return records.map(({ fields }) => fields)
} catch (err) {
console.error("Error writing to airtable", err)
return []
}
}
async update(query) {
const { table, id, json } = query
try {
const records = await this.client(table).update([
{
id,
fields: json,
},
])
return records
} catch (err) {
console.error("Error writing to airtable", err)
throw err
}
}
async delete(query) {
try {
const records = await this.client(query.table).destroy(query.ids)
return records
} catch (err) {
console.error("Error writing to airtable", err)
throw err
}
}
}
module.exports = {
schema: SCHEMA,
integration: AirtableIntegration,
}

View File

@ -0,0 +1,143 @@
import {
Integration,
DatasourceFieldTypes,
QueryTypes,
} from "./base/definitions"
module AirtableModule {
const Airtable = require("airtable")
interface AirtableConfig {
apiKey: string
base: string
}
const SCHEMA: Integration = {
docs: "https://airtable.com/api",
description:
"Airtable is a spreadsheet-database hybrid, with the features of a database but applied to a spreadsheet.",
friendlyName: "Airtable",
datasource: {
apiKey: {
type: DatasourceFieldTypes.PASSWORD,
default: "enter api key",
required: true,
},
base: {
type: DatasourceFieldTypes.STRING,
default: "mybase",
required: true,
},
},
query: {
create: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
read: {
type: QueryTypes.FIELDS,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
view: {
type: DatasourceFieldTypes.STRING,
required: true,
},
numRecords: {
type: DatasourceFieldTypes.NUMBER,
default: 10,
},
},
},
update: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
id: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
delete: {
type: QueryTypes.JSON,
},
},
}
class AirtableIntegration {
private config: AirtableConfig
private client: any
constructor(config: AirtableConfig) {
this.config = config
this.client = new Airtable(config).base(config.base)
}
async create(query: { table: any; json: any }) {
const { table, json } = query
try {
return await this.client(table).create([
{
fields: json,
},
])
} catch (err) {
console.error("Error writing to airtable", err)
throw err
}
}
async read(query: { table: any; numRecords: any; view: any }) {
try {
const records = await this.client(query.table)
.select({ maxRecords: query.numRecords || 10, view: query.view })
.firstPage()
// @ts-ignore
return records.map(({ fields }) => fields)
} catch (err) {
console.error("Error writing to airtable", err)
return []
}
}
async update(query: { table: any; id: any; json: any }) {
const { table, id, json } = query
try {
return await this.client(table).update([
{
id,
fields: json,
},
])
} catch (err) {
console.error("Error writing to airtable", err)
throw err
}
}
async delete(query: { table: any; ids: any }) {
try {
return await this.client(query.table).destroy(query.ids)
} catch (err) {
console.error("Error writing to airtable", err)
throw err
}
}
}
module.exports = {
schema: SCHEMA,
integration: AirtableIntegration,
}
}

View File

@ -1,86 +0,0 @@
const { Database, aql } = require("arangojs")
const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://github.com/arangodb/arangojs",
friendlyName: "ArangoDB",
description:
"ArangoDB is a scalable open-source multi-model database natively supporting graph, document and search. All supported data models & access patterns can be combined in queries allowing for maximal flexibility. ",
datasource: {
url: {
type: FIELD_TYPES.STRING,
default: "http://localhost:8529",
required: true,
},
username: {
type: FIELD_TYPES.STRING,
default: "root",
required: true,
},
password: {
type: FIELD_TYPES.PASSWORD,
required: true,
},
databaseName: {
type: FIELD_TYPES.STRING,
default: "_system",
required: true,
},
collection: {
type: FIELD_TYPES.STRING,
required: true,
},
},
query: {
read: {
type: QUERY_TYPES.SQL,
},
create: {
type: QUERY_TYPES.JSON,
},
},
}
class ArangoDBIntegration {
constructor(config) {
config.auth = {
username: config.username,
password: config.password,
}
this.config = config
this.client = new Database(config)
}
async read(query) {
try {
const result = await this.client.query(query.sql)
return result.all()
} catch (err) {
console.error("Error querying arangodb", err.message)
throw err
} finally {
this.client.close()
}
}
async create(query) {
const clc = this.client.collection(this.config.collection)
try {
const result = await this.client.query(
aql`INSERT ${query.json} INTO ${clc} RETURN NEW`
)
return result.all()
} catch (err) {
console.error("Error querying arangodb", err.message)
throw err
} finally {
this.client.close()
}
}
}
module.exports = {
schema: SCHEMA,
integration: ArangoDBIntegration,
}

View File

@ -0,0 +1,106 @@
import {
Integration,
DatasourceFieldTypes,
QueryTypes,
} from "./base/definitions"
module ArangoModule {
const { Database, aql } = require("arangojs")
interface ArangodbConfig {
url: string
username: string
password: string
databaseName: string
collection: string
}
const SCHEMA: Integration = {
docs: "https://github.com/arangodb/arangojs",
friendlyName: "ArangoDB",
description:
"ArangoDB is a scalable open-source multi-model database natively supporting graph, document and search. All supported data models & access patterns can be combined in queries allowing for maximal flexibility. ",
datasource: {
url: {
type: DatasourceFieldTypes.STRING,
default: "http://localhost:8529",
required: true,
},
username: {
type: DatasourceFieldTypes.STRING,
default: "root",
required: true,
},
password: {
type: DatasourceFieldTypes.PASSWORD,
required: true,
},
databaseName: {
type: DatasourceFieldTypes.STRING,
default: "_system",
required: true,
},
collection: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
query: {
read: {
type: QueryTypes.SQL,
},
create: {
type: QueryTypes.JSON,
},
},
}
class ArangoDBIntegration {
private config: ArangodbConfig
private client: any
constructor(config: ArangodbConfig) {
const newConfig = {
auth: {
username: config.username,
password: config.password,
},
}
this.config = config
this.client = new Database(newConfig)
}
async read(query: { sql: any }) {
try {
const result = await this.client.query(query.sql)
return result.all()
} catch (err) {
console.error("Error querying arangodb", err.message)
throw err
} finally {
this.client.close()
}
}
async create(query: { json: any }) {
const clc = this.client.collection(this.config.collection)
try {
const result = await this.client.query(
aql`INSERT ${query.json} INTO ${clc} RETURN NEW`
)
return result.all()
} catch (err) {
console.error("Error querying arangodb", err.message)
throw err
} finally {
this.client.close()
}
}
}
module.exports = {
schema: SCHEMA,
integration: ArangoDBIntegration,
}
}

View File

@ -1,11 +0,0 @@
exports.Operation = {
CREATE: "CREATE",
READ: "READ",
UPDATE: "UPDATE",
DELETE: "DELETE",
}
exports.SortDirection = {
ASCENDING: "ASCENDING",
DESCENDING: "DESCENDING",
}

View File

@ -0,0 +1,109 @@
export enum Operation {
CREATE = "CREATE",
READ = "READ",
UPDATE = "UPDATE",
DELETE = "DELETE",
}
export enum SortDirection {
ASCENDING = "ASCENDING",
DESCENDING = "DESCENDING",
}
export enum QueryTypes {
SQL = "sql",
JSON = "json",
FIELDS = "fields",
}
export enum DatasourceFieldTypes {
STRING = "string",
BOOLEAN = "boolean",
NUMBER = "number",
PASSWORD = "password",
LIST = "list",
OBJECT = "object",
JSON = "json",
}
export interface QueryDefinition {
type: QueryTypes
displayName?: string
readable?: boolean
customisable?: boolean
fields?: object
urlDisplay?: boolean
}
export interface Integration {
docs: string
plus?: boolean
description: string
friendlyName: string
datasource: {}
query: {
[key: string]: QueryDefinition
}
}
export interface SearchFilters {
allOr: boolean
string?: {
[key: string]: string
}
fuzzy?: {
[key: string]: string
}
range?: {
[key: string]: {
high: number | string
low: number | string
}
}
equal?: {
[key: string]: any
}
notEqual?: {
[key: string]: any
}
empty?: {
[key: string]: any
}
notEmpty?: {
[key: string]: any
}
}
export interface QueryJson {
endpoint: {
datasourceId: string
entityId: string
operation: Operation
}
resource: {
fields: string[]
}
filters?: SearchFilters
sort?: {
[key: string]: SortDirection
}
paginate?: {
limit: number
page: string | number
}
body?: object
extra: {
idFilter?: SearchFilters
}
}
export interface SqlQuery {
sql: string
bindings?: {
[key: string]: any
}
}
export interface QueryOptions {
disableReturning?: boolean
}

View File

@ -1,9 +1,21 @@
const { DataSourceOperation, SortDirection } = require("../../constants")
import { Knex, knex } from "knex"
const BASE_LIMIT = 5000
import {
QueryJson,
SearchFilters,
QueryOptions,
SortDirection,
Operation,
} from "./definitions"
function addFilters(query, filters) {
function iterate(structure, fn) {
function addFilters(
query: any,
filters: SearchFilters | undefined
): Knex.QueryBuilder {
function iterate(
structure: { [key: string]: any },
fn: (key: string, value: any) => void
) {
for (let [key, value] of Object.entries(structure)) {
fn(key, value)
}
@ -12,7 +24,7 @@ function addFilters(query, filters) {
return query
}
// if all or specified in filters, then everything is an or
const allOr = !!filters.allOr
const allOr = filters.allOr
if (filters.string) {
iterate(filters.string, (key, value) => {
const fnc = allOr ? "orWhere" : "where"
@ -55,7 +67,7 @@ function addFilters(query, filters) {
return query
}
function buildCreate(knex, json, opts) {
function buildCreate(knex: Knex, json: QueryJson, opts: QueryOptions) {
const { endpoint, body } = json
let query = knex(endpoint.entityId)
// mysql can't use returning
@ -66,9 +78,9 @@ function buildCreate(knex, json, opts) {
}
}
function buildRead(knex, json, limit) {
function buildRead(knex: Knex, json: QueryJson, limit: number) {
let { endpoint, resource, filters, sort, paginate } = json
let query = knex(endpoint.entityId)
let query: Knex.QueryBuilder = knex(endpoint.entityId)
// select all if not specified
if (!resource) {
resource = { fields: [] }
@ -90,6 +102,7 @@ function buildRead(knex, json, limit) {
}
// handle pagination
if (paginate && paginate.page && paginate.limit) {
// @ts-ignore
const page = paginate.page <= 1 ? 0 : paginate.page - 1
const offset = page * paginate.limit
query = query.offset(offset).limit(paginate.limit)
@ -101,7 +114,7 @@ function buildRead(knex, json, limit) {
return query
}
function buildUpdate(knex, json, opts) {
function buildUpdate(knex: Knex, json: QueryJson, opts: QueryOptions) {
const { endpoint, body, filters } = json
let query = knex(endpoint.entityId)
query = addFilters(query, filters)
@ -113,7 +126,7 @@ function buildUpdate(knex, json, opts) {
}
}
function buildDelete(knex, json, opts) {
function buildDelete(knex: Knex, json: QueryJson, opts: QueryOptions) {
const { endpoint, filters } = json
let query = knex(endpoint.entityId)
query = addFilters(query, filters)
@ -126,20 +139,19 @@ function buildDelete(knex, json, opts) {
}
class SqlQueryBuilder {
private readonly sqlClient: string
private readonly limit: number
// pass through client to get flavour of SQL
constructor(client, limit = BASE_LIMIT) {
this._client = client
this._limit = limit
constructor(client: string, limit: number = BASE_LIMIT) {
this.sqlClient = client
this.limit = limit
}
/**
* @param json the input JSON structure from which an SQL query will be built.
* @return {string} the operation that was found in the JSON.
*/
_operation(json) {
if (!json || !json.endpoint) {
return ""
}
_operation(json: QueryJson): Operation {
return json.endpoint.operation
}
@ -149,21 +161,21 @@ class SqlQueryBuilder {
* which for the sake of mySQL stops adding the returning statement to inserts, updates and deletes.
* @return {{ sql: string, bindings: object }} the query ready to be passed to the driver.
*/
_query(json, opts = {}) {
const knex = require("knex")({ client: this._client })
_query(json: QueryJson, opts: QueryOptions = {}) {
const client = knex({ client: this.sqlClient })
let query
switch (this._operation(json)) {
case DataSourceOperation.CREATE:
query = buildCreate(knex, json, opts)
case Operation.CREATE:
query = buildCreate(client, json, opts)
break
case DataSourceOperation.READ:
query = buildRead(knex, json, this._limit, opts)
case Operation.READ:
query = buildRead(client, json, this.limit)
break
case DataSourceOperation.UPDATE:
query = buildUpdate(knex, json, opts)
case Operation.UPDATE:
query = buildUpdate(client, json, opts)
break
case DataSourceOperation.DELETE:
query = buildDelete(knex, json, opts)
case Operation.DELETE:
query = buildDelete(client, json, opts)
break
default:
throw `Operation type is not supported by SQL query builder`

View File

@ -1,95 +0,0 @@
const PouchDB = require("pouchdb")
const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://docs.couchdb.org/en/stable/",
friendlyName: "CouchDB",
description:
"Apache CouchDB is an open-source document-oriented NoSQL database, implemented in Erlang.",
datasource: {
url: {
type: FIELD_TYPES.STRING,
required: true,
default: "http://localhost:5984",
},
database: {
type: FIELD_TYPES.STRING,
required: true,
},
},
query: {
create: {
type: QUERY_TYPES.JSON,
},
read: {
type: QUERY_TYPES.JSON,
},
update: {
type: QUERY_TYPES.JSON,
},
delete: {
type: QUERY_TYPES.FIELDS,
fields: {
id: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
},
}
class CouchDBIntegration {
constructor(config) {
this.config = config
this.client = new PouchDB(`${config.url}/${config.database}`)
}
async create(query) {
try {
const result = await this.client.post(query.json)
return result
} catch (err) {
console.error("Error writing to couchDB", err)
throw err
}
}
async read(query) {
try {
const result = await this.client.allDocs({
include_docs: true,
...query.json,
})
return result.rows.map(row => row.doc)
} catch (err) {
console.error("Error querying couchDB", err)
throw err
}
}
async update(query) {
try {
const result = await this.client.put(query.json)
return result
} catch (err) {
console.error("Error updating couchDB document", err)
throw err
}
}
async delete(query) {
try {
const result = await this.client.remove(query.id)
return result
} catch (err) {
console.error("Error deleting couchDB document", err)
throw err
}
}
}
module.exports = {
schema: SCHEMA,
integration: CouchDBIntegration,
}

View File

@ -0,0 +1,107 @@
import {
Integration,
DatasourceFieldTypes,
QueryTypes,
} from "./base/definitions"
module CouchDBModule {
const PouchDB = require("pouchdb")
interface CouchDBConfig {
url: string
database: string
}
const SCHEMA: Integration = {
docs: "https://docs.couchdb.org/en/stable/",
friendlyName: "CouchDB",
description:
"Apache CouchDB is an open-source document-oriented NoSQL database, implemented in Erlang.",
datasource: {
url: {
type: DatasourceFieldTypes.STRING,
required: true,
default: "http://localhost:5984",
},
database: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
query: {
create: {
type: QueryTypes.JSON,
},
read: {
type: QueryTypes.JSON,
},
update: {
type: QueryTypes.JSON,
},
delete: {
type: QueryTypes.FIELDS,
fields: {
id: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
},
}
class CouchDBIntegration {
private config: CouchDBConfig
private client: any
constructor(config: CouchDBConfig) {
this.config = config
this.client = new PouchDB(`${config.url}/${config.database}`)
}
async create(query: { json: object }) {
try {
return this.client.post(query.json)
} catch (err) {
console.error("Error writing to couchDB", err)
throw err
}
}
async read(query: { json: object }) {
try {
const result = await this.client.allDocs({
include_docs: true,
...query.json,
})
return result.rows.map((row: { doc: object }) => row.doc)
} catch (err) {
console.error("Error querying couchDB", err)
throw err
}
}
async update(query: { json: object }) {
try {
return this.client.put(query.json)
} catch (err) {
console.error("Error updating couchDB document", err)
throw err
}
}
async delete(query: { id: string }) {
try {
return await this.client.remove(query.id)
} catch (err) {
console.error("Error deleting couchDB document", err)
throw err
}
}
}
module.exports = {
schema: SCHEMA,
integration: CouchDBIntegration,
}
}

View File

@ -1,200 +0,0 @@
const AWS = require("aws-sdk")
const { FIELD_TYPES, QUERY_TYPES } = require("./Integration")
const { AWS_REGION } = require("../db/dynamoClient")
const SCHEMA = {
docs: "https://github.com/dabit3/dynamodb-documentclient-cheat-sheet",
description:
"Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale.",
friendlyName: "DynamoDB",
datasource: {
region: {
type: FIELD_TYPES.STRING,
required: true,
default: "us-east-1",
},
accessKeyId: {
type: FIELD_TYPES.PASSWORD,
required: true,
},
secretAccessKey: {
type: FIELD_TYPES.PASSWORD,
required: true,
},
endpoint: {
type: FIELD_TYPES.STRING,
required: false,
default: "https://dynamodb.us-east-1.amazonaws.com",
},
},
query: {
create: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
read: {
type: QUERY_TYPES.FIELDS,
customisable: true,
readable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
index: {
type: FIELD_TYPES.STRING,
},
},
},
scan: {
type: QUERY_TYPES.FIELDS,
customisable: true,
readable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
index: {
type: FIELD_TYPES.STRING,
},
},
},
get: {
type: QUERY_TYPES.FIELDS,
customisable: true,
readable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
update: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
delete: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
table: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
},
}
class DynamoDBIntegration {
constructor(config) {
this.config = config
this.connect()
let options = {
correctClockSkew: true,
}
if (config.endpoint) {
options.endpoint = config.endpoint
}
this.client = new AWS.DynamoDB.DocumentClient({
correctClockSkew: true,
})
}
end() {
this.disconnect()
}
connect() {
AWS.config.update(this.config)
}
disconnect() {
AWS.config.update({
secretAccessKey: undefined,
accessKeyId: undefined,
region: AWS_REGION,
})
}
async create(query) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.put(params).promise()
}
async read(query) {
const params = {
TableName: query.table,
...query.json,
}
if (query.index) {
params.IndexName = query.index
}
const response = await this.client.query(params).promise()
if (response.Items) {
return response.Items
}
return response
}
async scan(query) {
const params = {
TableName: query.table,
...query.json,
}
if (query.index) {
params.IndexName = query.index
}
const response = await this.client.scan(params).promise()
if (response.Items) {
return response.Items
}
return response
}
async get(query) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.get(params).promise()
}
async update(query) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.update(params).promise()
}
async delete(query) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.delete(params).promise()
}
}
module.exports = {
schema: SCHEMA,
integration: DynamoDBIntegration,
}

View File

@ -0,0 +1,211 @@
import {
Integration,
DatasourceFieldTypes,
QueryTypes,
} from "./base/definitions"
module DynamoModule {
const AWS = require("aws-sdk")
const { AWS_REGION } = require("../db/dynamoClient")
interface DynamoDBConfig {
region: string
accessKeyId: string
secretAccessKey: string
endpoint: string
}
const SCHEMA: Integration = {
docs: "https://github.com/dabit3/dynamodb-documentclient-cheat-sheet",
description:
"Amazon DynamoDB is a key-value and document database that delivers single-digit millisecond performance at any scale.",
friendlyName: "DynamoDB",
datasource: {
region: {
type: DatasourceFieldTypes.STRING,
required: true,
default: "us-east-1",
},
accessKeyId: {
type: DatasourceFieldTypes.PASSWORD,
required: true,
},
secretAccessKey: {
type: DatasourceFieldTypes.PASSWORD,
required: true,
},
endpoint: {
type: DatasourceFieldTypes.STRING,
required: false,
default: "https://dynamodb.us-east-1.amazonaws.com",
},
},
query: {
create: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
read: {
type: QueryTypes.FIELDS,
customisable: true,
readable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
index: {
type: DatasourceFieldTypes.STRING,
},
},
},
scan: {
type: QueryTypes.FIELDS,
customisable: true,
readable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
index: {
type: DatasourceFieldTypes.STRING,
},
},
},
get: {
type: QueryTypes.FIELDS,
customisable: true,
readable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
update: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
delete: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
table: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
},
}
class DynamoDBIntegration {
private config: DynamoDBConfig
private client: any
constructor(config: DynamoDBConfig) {
this.config = config
this.connect()
let options = {
correctClockSkew: true,
endpoint: config.endpoint ? config.endpoint : undefined,
}
this.client = new AWS.DynamoDB.DocumentClient(options)
}
end() {
this.disconnect()
}
connect() {
AWS.config.update(this.config)
}
disconnect() {
AWS.config.update({
secretAccessKey: undefined,
accessKeyId: undefined,
region: AWS_REGION,
})
}
async create(query: { table: string; json: object }) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.put(params).promise()
}
async read(query: { table: string; json: object; index: null | string }) {
const params = {
TableName: query.table,
IndexName: query.index ? query.index : undefined,
...query.json,
}
if (query.index) {
const response = await this.client.query(params).promise()
if (response.Items) {
return response.Items
}
return response
}
}
async scan(query: { table: string; json: object; index: null | string }) {
const params = {
TableName: query.table,
IndexName: query.index ? query.index : undefined,
...query.json,
}
const response = await this.client.scan(params).promise()
if (response.Items) {
return response.Items
}
return response
}
async get(query: { table: string; json: object }) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.get(params).promise()
}
async update(query: { table: string; json: object }) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.update(params).promise()
}
async delete(query: { table: string; json: object }) {
const params = {
TableName: query.table,
...query.json,
}
return this.client.delete(params).promise()
}
}
module.exports = {
schema: SCHEMA,
integration: DynamoDBIntegration,
}
}

View File

@ -1,139 +0,0 @@
const { Client } = require("@elastic/elasticsearch")
const { QUERY_TYPES, FIELD_TYPES } = require("./Integration")
const SCHEMA = {
docs: "https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html",
description:
"Elasticsearch is a search engine based on the Lucene library. It provides a distributed, multitenant-capable full-text search engine with an HTTP web interface and schema-free JSON documents.",
friendlyName: "ElasticSearch",
datasource: {
url: {
type: "string",
required: true,
default: "http://localhost:9200",
},
},
query: {
create: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
index: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
read: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
index: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
update: {
type: QUERY_TYPES.FIELDS,
customisable: true,
fields: {
id: {
type: FIELD_TYPES.STRING,
required: true,
},
index: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
delete: {
type: QUERY_TYPES.FIELDS,
fields: {
index: {
type: FIELD_TYPES.STRING,
required: true,
},
id: {
type: FIELD_TYPES.STRING,
required: true,
},
},
},
},
}
class ElasticSearchIntegration {
constructor(config) {
this.config = config
this.client = new Client({ node: config.url })
}
async create(query) {
const { index, json } = query
try {
const result = await this.client.index({
index,
body: json,
})
return result.body
} catch (err) {
console.error("Error writing to elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
async read(query) {
const { index, json } = query
try {
const result = await this.client.search({
index: index,
body: json,
})
return result.body.hits.hits.map(({ _source }) => _source)
} catch (err) {
console.error("Error querying elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
async update(query) {
const { id, index, json } = query
try {
const result = await this.client.update({
id,
index,
body: json,
})
return result.body
} catch (err) {
console.error("Error querying elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
async delete(query) {
try {
const result = await this.client.delete(query)
return result.body
} catch (err) {
console.error("Error deleting from elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
}
module.exports = {
schema: SCHEMA,
integration: ElasticSearchIntegration,
}

View File

@ -0,0 +1,153 @@
import {
Integration,
DatasourceFieldTypes,
QueryTypes,
} from "./base/definitions"
module ElasticsearchModule {
const { Client } = require("@elastic/elasticsearch")
interface ElasticsearchConfig {
url: string
}
const SCHEMA: Integration = {
docs: "https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html",
description:
"Elasticsearch is a search engine based on the Lucene library. It provides a distributed, multitenant-capable full-text search engine with an HTTP web interface and schema-free JSON documents.",
friendlyName: "ElasticSearch",
datasource: {
url: {
type: DatasourceFieldTypes.STRING,
required: true,
default: "http://localhost:9200",
},
},
query: {
create: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
index: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
read: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
index: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
update: {
type: QueryTypes.FIELDS,
customisable: true,
fields: {
id: {
type: DatasourceFieldTypes.STRING,
required: true,
},
index: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
delete: {
type: QueryTypes.FIELDS,
fields: {
index: {
type: DatasourceFieldTypes.STRING,
required: true,
},
id: {
type: DatasourceFieldTypes.STRING,
required: true,
},
},
},
},
}
class ElasticSearchIntegration {
private config: ElasticsearchConfig
private client: any
constructor(config: ElasticsearchConfig) {
this.config = config
this.client = new Client({ node: config.url })
}
async create(query: { index: string; json: object }) {
const { index, json } = query
try {
const result = await this.client.index({
index,
body: json,
})
return result.body
} catch (err) {
console.error("Error writing to elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
async read(query: { index: string; json: object }) {
const { index, json } = query
try {
const result = await this.client.search({
index: index,
body: json,
})
return result.body.hits.hits.map(({ _source }: any) => _source)
} catch (err) {
console.error("Error querying elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
async update(query: { id: string; index: string; json: object }) {
const { id, index, json } = query
try {
const result = await this.client.update({
id,
index,
body: json,
})
return result.body
} catch (err) {
console.error("Error querying elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
async delete(query: object) {
try {
const result = await this.client.delete(query)
return result.body
} catch (err) {
console.error("Error deleting from elasticsearch", err)
throw err
} finally {
await this.client.close()
}
}
}
module.exports = {
schema: SCHEMA,
integration: ElasticSearchIntegration,
}
}

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