Merge branch 'develop' of github.com:Budibase/budibase into feature/json-backend

This commit is contained in:
Andrew Kingston 2021-12-15 13:36:10 +00:00
commit d06cc93757
101 changed files with 3876 additions and 1616 deletions

36
.github/workflows/release-charts.yml vendored Normal file
View File

@ -0,0 +1,36 @@
name: Budibase Release Helm Charts
on:
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: 'Get Previous tag'
id: previoustag
uses: "WyriHaximus/github-action-get-previous-tag@v1"
- name: Install Helm
uses: azure/setup-helm@v1
with:
version: v3.4.0
# - run: yarn release:helm
# env:
# BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
- name: Configure Git
run: |
git config user.name "Budibase Helm Bot"
git config user.email "<>"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.2.1
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

View File

@ -1,4 +1,4 @@
name: Budibase Release Selfhost name: Budibase Release Docker Selfhost
on: on:
workflow_dispatch: workflow_dispatch:
@ -42,20 +42,4 @@ jobs:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }} BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
- uses: azure/setup-helm@v1
id: install
- run: yarn release:helm
env:
BUDIBASE_RELEASE_VERSION: ${{ steps.previoustag.outputs.tag }}
- name: Configure Git
run: |
git config user.name "Budibase Helm Bot"
git config user.email "<>"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.2.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

3
index.yaml Normal file
View File

@ -0,0 +1,3 @@
apiVersion: v1
entries: {}
generated: "2021-12-13T12:46:40.291206+01:00"

View File

@ -1,5 +1,5 @@
{ {
"version": "1.0.15-alpha.1", "version": "1.0.19-alpha.3",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "1.0.15-alpha.1", "version": "1.0.19-alpha.3",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -80,4 +80,8 @@
.active svg { .active svg {
color: var(--spectrum-global-color-blue-600); color: var(--spectrum-global-color-blue-600);
} }
.spectrum-ActionButton-label {
padding-bottom: 2px;
}
</style> </style>

View File

@ -0,0 +1,59 @@
<script>
import { createEventDispatcher } from "svelte"
let dispatch = createEventDispatcher()
export let type = "info"
export let icon = "Info"
export let size = "S"
export let extraButtonText
export let extraButtonAction
let show = true
function clear() {
show = false
dispatch("change")
}
</script>
{#if show}
<div class="spectrum-Toast spectrum-Toast--{type}">
<svg
class="spectrum-Icon spectrum-Icon--size{size} spectrum-Toast-typeIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
<div class="spectrum-Toast-body">
<div class="spectrum-Toast-content">
<slot />
</div>
{#if extraButtonText && extraButtonAction}
<button
class="spectrum-Button spectrum-Button--sizeM spectrum-Button--overBackground spectrum-Button--quiet"
on:click={extraButtonAction}
>
<span class="spectrum-Button-label">{extraButtonText}</span>
</button>
{/if}
</div>
<div class="spectrum-Toast-buttons">
<button
class="spectrum-ClearButton spectrum-ClearButton--overBackground spectrum-ClearButton--size{size}"
on:click={clear}
>
<div class="spectrum-ClearButton-fill">
<svg
class="spectrum-ClearButton-icon spectrum-Icon spectrum-UIIcon-Cross100"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Cross100" />
</svg>
</div>
</button>
</div>
</div>
{/if}

View File

@ -7,7 +7,7 @@
export let disabled = false export let disabled = false
export let id = null export let id = null
export let updateOnChange = true export let updateOnChange = true
export let quiet = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let focus = false let focus = false
@ -41,6 +41,7 @@
<div class="spectrum-Search" class:is-disabled={disabled}> <div class="spectrum-Search" class:is-disabled={disabled}>
<div <div
class="spectrum-Textfield" class="spectrum-Textfield"
class:spectrum-Textfield--quiet={quiet}
class:is-focused={focus} class:is-focused={focus}
class:is-disabled={disabled} class:is-disabled={disabled}
> >

View File

@ -25,7 +25,9 @@
class="spectrum-Switch-input" class="spectrum-Switch-input"
/> />
<span class="spectrum-Switch-switch" /> <span class="spectrum-Switch-switch" />
<label class="spectrum-Switch-label" for={id}>{text}</label> {#if text}
<label class="spectrum-Switch-label" for={id}>{text}</label>
{/if}
</div> </div>
<style> <style>

View File

@ -7,6 +7,8 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let id = null export let id = null
export let height = null
export let minHeight = null
export const getCaretPosition = () => ({ export const getCaretPosition = () => ({
start: textarea.selectionStart, start: textarea.selectionStart,
end: textarea.selectionEnd, end: textarea.selectionEnd,
@ -22,6 +24,8 @@
</script> </script>
<div <div
style={(height ? `height: ${height}px;` : "") +
(minHeight ? `min-height: ${minHeight}px` : "")}
class="spectrum-Textfield spectrum-Textfield--multiline" class="spectrum-Textfield spectrum-Textfield--multiline"
class:is-invalid={!!error} class:is-invalid={!!error}
class:is-disabled={disabled} class:is-disabled={disabled}

View File

@ -9,6 +9,7 @@
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error = null
export let options = [] export let options = []
export let direction = "vertical"
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
@ -31,6 +32,7 @@
{disabled} {disabled}
{value} {value}
{options} {options}
{direction}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
on:change={onChange} on:change={onChange}

View File

@ -9,6 +9,7 @@
export let placeholder = null export let placeholder = null
export let disabled = false export let disabled = false
export let updateOnChange = true export let updateOnChange = true
export let quiet = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -23,6 +24,7 @@
{disabled} {disabled}
{value} {value}
{placeholder} {placeholder}
{quiet}
on:change={onChange} on:change={onChange}
on:click on:click
on:input on:input

View File

@ -10,6 +10,8 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let getCaretPosition = null export let getCaretPosition = null
export let height = null
export let minHeight = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
@ -25,6 +27,8 @@
{disabled} {disabled}
{value} {value}
{placeholder} {placeholder}
{height}
{minHeight}
on:change={onChange} on:change={onChange}
/> />
</Field> </Field>

View File

@ -9,6 +9,7 @@
export let size = "M" export let size = "M"
export let hoverable = false export let hoverable = false
export let disabled = false export let disabled = false
export let color
$: rotation = getRotation(direction) $: rotation = getRotation(direction)
@ -25,7 +26,9 @@
focusable="false" focusable="false"
aria-hidden={hidden} aria-hidden={hidden}
aria-label={name} aria-label={name}
style={`transform: rotate(${rotation}deg)`} style={`transform: rotate(${rotation}deg); ${
color ? `color: ${color};` : ""
}`}
> >
<use xlink:href="#spectrum-icon-18-{name}" /> <use xlink:href="#spectrum-icon-18-{name}" />
</svg> </svg>

View File

@ -19,6 +19,7 @@
<div class="icon-container"> <div class="icon-container">
<div <div
class="icon" class="icon"
class:icon-small={size === "M" || size === "S"}
on:mouseover={() => (showTooltip = true)} on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)} on:mouseleave={() => (showTooltip = false)}
> >
@ -44,6 +45,7 @@
} }
.container { .container {
display: flex; display: flex;
align-items: center;
} }
.icon-container { .icon-container {
position: relative; position: relative;
@ -64,4 +66,8 @@
.icon { .icon {
transform: scale(0.75); transform: scale(0.75);
} }
.icon-small {
margin-top: -2px;
margin-bottom: -5px;
}
</style> </style>

View File

@ -21,6 +21,7 @@
export let showSecondaryButton = false export let showSecondaryButton = false
export let secondaryButtonText = undefined export let secondaryButtonText = undefined
export let secondaryAction = undefined export let secondaryAction = undefined
export let secondaryButtonWarning = false
const { hide, cancel } = getContext(Context.Modal) const { hide, cancel } = getContext(Context.Modal)
let loading = false let loading = false
@ -88,8 +89,11 @@
{#if showSecondaryButton && secondaryButtonText && secondaryAction} {#if showSecondaryButton && secondaryButtonText && secondaryAction}
<div class="secondary-action"> <div class="secondary-action">
<Button group secondary on:click={secondary} <Button
>{secondaryButtonText}</Button group
secondary
warning={secondaryButtonWarning}
on:click={secondary}>{secondaryButtonText}</Button
> >
</div> </div>
{/if} {/if}

View File

@ -5,7 +5,7 @@
export let icon = "" export let icon = ""
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const selected = getContext("tab") let selected = getContext("tab")
let tab let tab
let tabInfo let tabInfo
@ -16,8 +16,8 @@
// We just need to get this off the main thread to fix this, by using // We just need to get this off the main thread to fix this, by using
// a 0ms timeout. // a 0ms timeout.
setTimeout(() => { setTimeout(() => {
tabInfo = tab.getBoundingClientRect() tabInfo = tab?.getBoundingClientRect()
if ($selected.title === title) { if (tabInfo && $selected.title === title) {
$selected.info = tabInfo $selected.info = tabInfo
} }
}, 0) }, 0)

View File

@ -6,9 +6,13 @@
export let selected export let selected
export let vertical = false export let vertical = false
export let noPadding = false export let noPadding = false
// added as a separate option as noPadding is used for vertical padding
export let noHorizPadding = false
export let quiet = false export let quiet = false
export let emphasized = false export let emphasized = false
let thisSelected = undefined
let _id = id() let _id = id()
const tab = writable({ title: selected, id: _id, emphasized }) const tab = writable({ title: selected, id: _id, emphasized })
setContext("tab", tab) setContext("tab", tab)
@ -18,9 +22,19 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: { $: {
if ($tab.title !== selected) { if (thisSelected !== selected) {
thisSelected = selected
dispatch("select", thisSelected)
} else if ($tab.title !== thisSelected) {
thisSelected = $tab.title
selected = $tab.title selected = $tab.title
dispatch("select", selected) dispatch("select", thisSelected)
}
if ($tab.title !== thisSelected) {
tab.update(state => {
state.title = thisSelected
return state
})
} }
} }
@ -59,6 +73,7 @@
<div <div
bind:this={container} bind:this={container}
class:quiet class:quiet
class:noHorizPadding
class="selected-border spectrum-Tabs {quiet && class="selected-border spectrum-Tabs {quiet &&
'spectrum-Tabs--quiet'} spectrum-Tabs--{vertical 'spectrum-Tabs--quiet'} spectrum-Tabs--{vertical
? 'vertical' ? 'vertical'
@ -99,6 +114,9 @@
.spectrum-Tabs--horizontal .spectrum-Tabs-selectionIndicator { .spectrum-Tabs--horizontal .spectrum-Tabs-selectionIndicator {
bottom: 0 !important; bottom: 0 !important;
} }
.noHorizPadding {
padding: 0;
}
.noPadding { .noPadding {
margin: 0; margin: 0;
} }

View File

@ -59,6 +59,7 @@ export { default as Badge } from "./Badge/Badge.svelte"
export { default as StatusLight } from "./StatusLight/StatusLight.svelte" export { default as StatusLight } from "./StatusLight/StatusLight.svelte"
export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte" export { default as ColorPicker } from "./ColorPicker/ColorPicker.svelte"
export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte" export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte"
export { default as Banner } from "./Banner/Banner.svelte"
// Typography // Typography
export { default as Body } from "./Typography/Body.svelte" export { default as Body } from "./Typography/Body.svelte"

View File

@ -0,0 +1,227 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 424.9 376.9" style="enable-background:new 0 0 424.9 376.9;" xml:space="preserve">
<style type="text/css">
.st0{fill:url(#SVGID_1_);}
.st1{fill:url(#SVGID_00000173146453699406745440000014498258185172885933_);}
.st2{fill:url(#SVGID_00000072276482643233320020000011975551142177928352_);}
.st3{fill:url(#SVGID_00000000209806784423878430000006497884456548529061_);}
.st4{fill:url(#SVGID_00000145771761158215620900000018209346423627093124_);}
.st5{fill:#FFFFFF;}
.st6{fill:url(#SVGID_00000134247842479715961920000014109114775597637809_);}
.st7{fill:url(#SVGID_00000119810811088729404010000001339012453506459048_);}
.st8{fill:url(#SVGID_00000056391310230795810690000008029868739465234354_);}
.st9{fill:#010202;}
</style>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="51.0692" y1="421.4218" x2="245.8004" y2="138.7009">
<stop offset="0" style="stop-color:#90D1E1"/>
<stop offset="0.9921" style="stop-color:#F195BE"/>
</linearGradient>
<circle class="st0" cx="211.1" cy="189.1" r="179.2"/>
<linearGradient id="SVGID_00000181791594591671767050000008664012113219957652_" gradientUnits="userSpaceOnUse" x1="240.147" y1="117.7065" x2="241.3703" y2="117.7065">
<stop offset="6.070287e-03" style="stop-color:#EAB593"/>
<stop offset="0.9975" style="stop-color:#F4C278"/>
</linearGradient>
<path style="fill:url(#SVGID_00000181791594591671767050000008664012113219957652_);" d="M240.1,118
C241.1,117.6,242.4,117,240.1,118L240.1,118z"/>
<linearGradient id="SVGID_00000058550945621228767580000005935124031691961279_" gradientUnits="userSpaceOnUse" x1="94.8494" y1="142.6618" x2="97.0305" y2="142.6618">
<stop offset="6.070287e-03" style="stop-color:#EAB593"/>
<stop offset="0.9975" style="stop-color:#F4C278"/>
</linearGradient>
<path style="fill:url(#SVGID_00000058550945621228767580000005935124031691961279_);" d="M97,142.9c-3.2-0.6-2.2-0.3-1.3-0.2
c0.4,0,0.7,0.1,1.1,0.2C96.9,142.8,97,142.8,97,142.9z"/>
<linearGradient id="SVGID_00000137132525261985581510000000877506361518317499_" gradientUnits="userSpaceOnUse" x1="178.9468" y1="243.9432" x2="180.17" y2="243.9432">
<stop offset="6.070287e-03" style="stop-color:#EAB593"/>
<stop offset="0.9975" style="stop-color:#F4C278"/>
</linearGradient>
<path style="fill:url(#SVGID_00000137132525261985581510000000877506361518317499_);" d="M178.9,244.2
C179.9,243.8,181.2,243.3,178.9,244.2L178.9,244.2z"/>
<linearGradient id="SVGID_00000147899999161542128250000012606861005779996338_" gradientUnits="userSpaceOnUse" x1="147.0169" y1="218.2912" x2="147.8155" y2="218.2912">
<stop offset="6.070287e-03" style="stop-color:#EAB593"/>
<stop offset="0.9975" style="stop-color:#F4C278"/>
</linearGradient>
<path style="fill:url(#SVGID_00000147899999161542128250000012606861005779996338_);" d="M147.8,218.4c-1.2-0.2-0.8-0.1-0.5-0.1
C147.5,218.3,147.6,218.3,147.8,218.4C147.8,218.4,147.8,218.4,147.8,218.4z"/>
<path class="st5" d="M136.2,205.2l0.6,2.3l-6.8,1.6l0.8,3.3l-1.9,0.5l-1.3-5.6L136.2,205.2z"/>
<path class="st5" d="M134.6,216.1c2.7-1.1,4.9-0.1,5.8,2.1c0.9,2.3,0,4.5-2.7,5.6c-2.7,1.1-5,0.2-5.9-2.1
C130.9,219.4,131.9,217.2,134.6,216.1z M136.8,221.5c1.5-0.6,2.1-1.6,1.7-2.6c-0.4-1-1.5-1.3-3-0.7c-1.5,0.6-2.2,1.6-1.8,2.6
C134.1,221.9,135.3,222.1,136.8,221.5z"/>
<radialGradient id="SVGID_00000148647616164358038390000000096934996311696560_" cx="211.1145" cy="189.2326" r="154.4542" gradientUnits="userSpaceOnUse">
<stop offset="3.599497e-04" style="stop-color:#FFFFFF"/>
<stop offset="0.9083" style="stop-color:#FFFFFF;stop-opacity:9.169579e-02"/>
<stop offset="1" style="stop-color:#FFFFFF;stop-opacity:0"/>
</radialGradient>
<circle style="fill:url(#SVGID_00000148647616164358038390000000096934996311696560_);" cx="211.1" cy="189.2" r="154.5"/>
<linearGradient id="SVGID_00000181795849749029799540000017664282943098263974_" gradientUnits="userSpaceOnUse" x1="201.3407" y1="294.9478" x2="220.3445" y2="87.1506">
<stop offset="0" style="stop-color:#5760A9"/>
<stop offset="0.5157" style="stop-color:#ADB3D9"/>
<stop offset="0.6156" style="stop-color:#BEC4E2"/>
<stop offset="0.7199" style="stop-color:#BBC1DF"/>
<stop offset="0.8218" style="stop-color:#B0B7D7"/>
<stop offset="0.9223" style="stop-color:#9FA6CA"/>
<stop offset="1" style="stop-color:#8D95BD"/>
</linearGradient>
<path style="fill:url(#SVGID_00000181795849749029799540000017664282943098263974_);" d="M397.7,131.5L314,95.7L85.6,142.5l-2.7-4.6
l-4.4,0.8l-2-4.5c0,0-0.8-2.1,2.2-1.8c3,0.3,4.7,0.2,10.6-3.7c5.9-3.9,2.3-2.2,2.8-5.5c0.5-3.3-5.2-6.1-11.5-10.7
c-6.3-4.6-12.4-5.9-14.1-2.7c-4.9,11,1.7,17.2,1.7,17.2l3.9,3.3l-0.6,2.1l2.7,7.4l-4,0.7l-1,5.4l-46.2,9.5l1.3,19.3
c-0.3-0.6,0.8,1.9-0.8,2c-3.1,0.1-5.7,0.5-8.1,1.6c-5.4,2.4-9.5,7-11.2,12.6C2.4,196.4,5,203.4,10,207c3.3,2.4,5.2,4.1,8.9,3.4
c0.8,0.2,1.2,0,2,0c1,0,3-0.1,3.8-0.5c1,0.1,1.3,1,1.4-0.3c0-0.1,1.6,0.1,1.6,0c0.1-0.1-0.3,1.8-0.1,1.7l-0.5,3.8
c-5.1,0.8-10.1,2.8-13.7,6.6c-4,4.3-5.4,9.6-4.9,15.3c0.4,5.2,1.3,9.2,6.7,11.3c2.9,1.1,6.6,2.3,9.5,1.5c1.6-0.4,4.5-0.2,6.1-0.6
l1.2,9l44.4,25.4l276.3-52.7l67.8-67.9L397.7,131.5z M26.2,202.7c0-0.2,0-0.4,0-0.6L26.2,202.7C26.2,202.7,26.2,202.7,26.2,202.7z
M28,229.2l0.1,0.9c-0.2-0.2-0.3-0.3-0.5-0.4C27.7,229.5,27.9,229.4,28,229.2z M27.2,233.9c0,0-0.1,0-0.1,0c0.4-0.4,0.7-0.8,1.1-1.1
c0.1-0.1,0.1-0.1,0.1-0.2l0.1,1.1C28,233.6,27.6,233.7,27.2,233.9z M28.8,244.6c0.1-0.1,0.1-0.2,0.2-0.3c0-0.1,0.1-0.3,0.1-0.4
l0,0.7C29,244.5,28.9,244.6,28.8,244.6z"/>
<linearGradient id="SVGID_00000152227878615532122350000012236979203658928007_" gradientUnits="userSpaceOnUse" x1="318.3721" y1="135.4384" x2="340.2567" y2="95.2667" gradientTransform="matrix(0.921 -3.872472e-02 0 1 31.6061 15.4856)">
<stop offset="0" style="stop-color:#B263A4"/>
<stop offset="2.387382e-02" style="stop-color:#B465A4"/>
<stop offset="0.7022" style="stop-color:#E5A177"/>
<stop offset="0.9988" style="stop-color:#FAB84D"/>
</linearGradient>
<polygon style="fill:url(#SVGID_00000152227878615532122350000012236979203658928007_);" points="312.1,97 315.4,129.8 367,120.3
"/>
<g>
<path class="st9" d="M422.2,162.2c-0.3-1.1-1.1-1.9-1.7-2.8c-0.8-1-1.6-2-2.3-3.1c-2.6-3.4-4.4-6.6-7-10c-1.6-2.1-3-4.2-4.6-6.4
c-1.3-1.9-4.9-6.9-7.8-9.6c-1.6-1.4-11-5.7-19.1-9.2C352.7,54.5,287.3,7.3,211.1,7.3c-87.4,0-160.5,62-177.8,144.4c0,0-0.1,0-0.1,0
c-1.2,0.3-2.4,0.5-3.6,0.8c-0.9,0.2-1.9,0.4-2.8,0.6c-0.7,0.1-1.4,0.3-2,0.4c-0.4,0.1-0.8,0.2-1.2,0.2c-0.1,0-0.2,0-0.3,0.1
l-1.7,0.2l0.2,1.8l1.5,18.7c-0.7,0.1-1.5,0.3-2.2,0.4c-0.9,0.2-1.8,0.4-2.7,0.5c-4.8,0.8-9,3.5-11.9,7.6c-2.9,4.1-4.2,9-3.5,13.9
c0.6,4.9,3.1,9.2,6.9,12.1c3.2,2.5,7.1,3.7,11.1,3.5c0.7,0,1.5-0.1,2.2-0.2l1.7-0.3c0.5-0.1,0.9-0.2,1.4-0.3l0.2,2.4
c-0.6,0.1-1.1,0.2-1.7,0.3c-0.9,0.2-1.8,0.4-2.7,0.5c-4.8,0.8-9,3.5-11.9,7.6c-2.9,4.1-4.2,9-3.5,13.9c1.2,9.4,9,16,18,15.6
c0.7,0,1.4-0.1,2.2-0.2l1.7-0.3c0.3-0.1,0.6-0.1,0.9-0.2l0.5,6.4l0.2,1.1l0.8,0.4l17.6,10.1c29.7,59.9,91.5,101.3,162.9,101.3
c98.8,0,179.5-79.3,181.6-177.7c1.3-1.3,2.7-2.6,4-4c2.7-2.6,5.4-5.3,8.1-7.9c2.3-2.3,4.7-4.6,7-6.9c2.1-2.1,4.2-4.2,6.3-6.2
c1-1,2-1.9,3-2.9C421.9,164.3,422.5,163.3,422.2,162.2z M211.1,12.3c72.3,0,134.6,43.7,161.9,106.1c-7-3-14.1-5.9-21.1-8.9
c-8.4-3.6-16.9-7.1-25.3-10.7c-2.5-1.1-5-2.1-7.5-3.2c-1.3-0.5-2.5-1.1-3.8-1.6c-1.1-0.5-2.4-1.2-3.6-1c-0.2,0-0.3,0.1-0.5,0.1
c-0.5,0.1-1.1,0.2-1.6,0.3c-0.8,0.2-1.6,0.3-2.4,0.5c-1.1,0.2-2.2,0.5-3.2,0.7c-1.3,0.3-2.7,0.6-4,0.8c-1.6,0.3-3.1,0.7-4.7,1
c-1.8,0.4-3.6,0.7-5.3,1.1c-2,0.4-4,0.8-6,1.3c-2.2,0.5-4.4,0.9-6.5,1.4c-2.4,0.5-4.7,1-7.1,1.5c-2.5,0.5-5,1.1-7.5,1.6
c-2.7,0.6-5.3,1.1-8,1.7c-2.8,0.6-5.6,1.2-8.4,1.8c-2.9,0.6-5.8,1.2-8.7,1.8c-3,0.6-6,1.3-9.1,1.9c-3.1,0.7-6.2,1.3-9.3,2
c-3.2,0.7-6.4,1.3-9.6,2c-3.2,0.7-6.5,1.4-9.7,2.1c-3.3,0.7-6.6,1.4-9.9,2.1c-3.3,0.7-6.7,1.4-10,2.1c-3.3,0.7-6.7,1.4-10,2.1
c-3.3,0.7-6.7,1.4-10,2.1c-3.3,0.7-6.7,1.4-10,2.1c-3.3,0.7-6.6,1.4-9.9,2.1c-3.3,0.7-6.5,1.4-9.8,2.1c-3.2,0.7-6.4,1.4-9.7,2
c-3.1,0.7-6.3,1.3-9.4,2c-3.1,0.6-6.1,1.3-9.2,1.9c-3,0.6-5.9,1.3-8.9,1.9c-2.4,0.5-4.8,1-7.2,1.5c-0.7-1.2-1.5-2.3-2.5-3.2
c-0.2-0.2-0.5-0.2-0.8-0.2c-0.3-0.3-0.7-0.3-1.1-0.2c-0.9,0.2-1.7,0.4-2.6,0.5c-0.7-1.4-1.3-2.9-1.8-4.4c6.3,1,12.7-1.9,16.6-7.4
c0.3-0.4,0.2-0.8-0.1-1c0-1.9-1.7-4.2-3.6-6.1c0.6-1.1,1.3-2.1,1.8-3.2c0.7-0.1,1.3-0.5,1.6-1.1c0.6-1.2-0.1-2.7-1.4-3.1
c-1.3-0.4-3.1,1.1-2.3,2.5c0,0.3,0.1,0.5,0.2,0.8c-0.6,0.9-1.1,1.7-1.6,2.6c-1.4-1.2-2.7-2.2-3.4-2.8c-3.1-2.2-13.4-9.7-18-6.5
c-0.3,0-0.6,0.1-0.7,0.4c-0.1,0.2-0.2,0.4-0.3,0.6c-0.1,0.1-0.1,0.2-0.2,0.4c-3.8,7.4-2.1,16.7,5.2,21.5c0.1,0,0.2,0.1,0.2,0.1
c-0.2,0.3-0.4,0.7-0.5,1.1c-0.2,0.2-0.2,0.5-0.1,0.8c0,0.1,0,0.1,0.1,0.2c0,0.6,0.3,1.2,0.6,1.7c0.5,1.5,1.1,3,1.6,4.4
c-0.9,0.2-1.8,0.4-2.7,0.7c-0.4,0-0.8,0.2-1,0.6c-0.5,1.4-1.1,2.7-1.4,4.2c-2.2,0.5-4.4,0.9-6.5,1.4c-2.3,0.5-4.5,1-6.8,1.4
c-2.1,0.4-4.2,0.9-6.2,1.3c-1.9,0.4-3.8,0.8-5.7,1.2c-1.3,0.3-2.6,0.5-3.9,0.8C56.3,71.6,126.9,12.3,211.1,12.3z M312.1,97
L312.1,97l10.6,4.4c-0.1,0.1-0.2,0.3-0.3,0.4c-0.1,0.2-0.3,0.4-0.5,0.6c0,0-0.2,0.2-0.3,0.3c-0.1,0.1-0.3,0.3-0.3,0.4
c-0.2,0.2-0.3,0.4-0.5,0.6c-0.3,0.4-0.7,0.8-1.1,1.1c-0.7,0.7-1.4,1.4-2.2,2c0,0-0.4,0.3-0.4,0.3c-0.1,0.1-0.3,0.2-0.4,0.3
c-0.4,0.3-0.9,0.6-1.3,0.9c-0.6,0.4-1.3,0.8-2,1.1L312.1,97z M327.3,115.9c3.5-1.8,8.4-4.4,11.4-7.8l6.6,2.8c0,0.1,0,0.2,0,0.3
c0,0.3,0,0,0,0.4c-0.1,0.3-0.1,0.7-0.2,1c-0.3,1.2-0.6,1.8-1.3,2.9c-1.2,2-3.1,3.9-4.9,5.2c-3.3,2.3-6.8,4.2-10.3,6.3l-12.7,2.6
l-0.4-3.8C317.4,121.1,323.1,118.1,327.3,115.9z M359.2,116.8l6.4,2.7l-7.2,1.5C358.8,119.6,359.1,118.2,359.2,116.8z M70.8,141.6
c3.9-0.5,7.7-1.3,11.5-2.2c0.1,0,0.1-0.1,0.2-0.1c0.3,0.7,0.6,1.3,1,1.9c-2.4,0.5-4.7,1-7.1,1.5c-2.2,0.5-4.3,0.9-6.5,1.4
C70.2,143.2,70.5,142.4,70.8,141.6z M66.3,112.9c0.7,1.2,1.7,2.4,2.8,3.5c-1.3,2-1.6,4.7-1.3,7.1c0.1,0.5,0.2,1.2,0.4,1.9
C65.6,121.8,65.1,117.2,66.3,112.9z M84,115.9c0.6,0.5,1.9,1.7,3.3,3c-0.3,0.5-0.5,0.9-0.8,1.4c-0.8,1.3-1.7,2.7-2.2,4.1
c-1.1-0.5-2.2-1-3.3-1.6c-0.2-0.5-0.7-0.8-1.1-0.7c-1.6-1-3.2-2-4.6-3.1c0,0-0.1-0.1-0.1-0.1c0.1-0.9,0-1.8,0.1-2.7
c0.3-1.6,1.2-2.8,2-4.1c0.1-0.2,0.1-0.4,0.1-0.6C79.6,112.8,81.9,114.4,84,115.9z M76.3,131c0-0.1-0.1-0.1-0.1-0.1
c0.9-0.5,1.6-1.8,2.2-2.6c0.7-1.1,1.4-2.3,2-3.5c2,1.1,4.1,2.2,6.1,2.7c-0.9,0.9-1.8,1.6-2.9,2.2c-1.3,0.7-2.7,0.9-3.9,1.7
C78.6,131.4,77.4,131.3,76.3,131z M88.6,127.9c0.2,0,0.3,0,0.5,0c-0.5,0.4-1.1,0.8-1.6,1.1C87.9,128.7,88.3,128.3,88.6,127.9z
M87.8,125.5c-0.6-0.1-1.3-0.3-1.9-0.5c0.9-1.1,1.6-2.5,2.3-3.7c0.2-0.3,0.3-0.5,0.5-0.8C91.3,123.5,92.8,126.5,87.8,125.5z
M76,111.3c-1.3,1.1-2,3-2.3,4.6c-0.1,0.5-0.2,1.1-0.2,1.8c-3.2-3-8.8-9.1-3-8.3c2,0.3,3.9,0.9,5.8,1.8
C76.2,111.2,76.1,111.2,76,111.3z M69.7,123.8c-0.4-2.2,0.1-4.1,0.7-6.2c1.6,1.4,3.1,2.5,3.8,3.1c0.9,0.7,2.6,1.9,4.5,3.1
c-0.5,1.2-1,2.3-1.7,3.4c-0.5,0.9-1.5,1.9-1.8,2.9c-0.6-0.8-1.7-0.9-2.6-0.6c-0.3-0.2-0.6-0.3-0.8-0.5c-0.3-0.2-0.5-0.4-0.7-0.6
C71.3,126.9,70,125.3,69.7,123.8z M74.3,132.4c0.1,0.2,0.1,0.4,0,0.6c-0.2,0.5-0.9,0.5-1.3,0.2c-0.5-0.3-0.5-1-0.2-1.5
C73.3,132,73.8,132.2,74.3,132.4z M73.7,135.7c0.8,0,1.5-0.3,2.1-0.8c0.4,1,0.8,2,1.3,3c-0.8,0.2-1.6,0.4-2.4,0.6
C74.4,137.5,74,136.6,73.7,135.7z M91.7,113.1C91.7,113.1,91.7,113.1,91.7,113.1c0.1,0.1,0.2,0.2,0.1,0.3c0,0,0,0-0.1,0
c-0.1,0-0.1-0.1-0.2-0.1C91.5,113.2,91.6,113.1,91.7,113.1z M12.3,206.2c0.4-0.1,0.7-0.3,1.1-0.4c0.5-0.2,1-0.6,1.5-0.8
c0.4-0.2,0.1-0.7-0.3-0.7c-0.6,0.1-1.1,0.1-1.7,0.2c-0.5,0.1-1.1,0.2-1.6,0.4c-0.1,0-0.3,0.1-0.4,0.1c-1.2-1.2-2.2-2.5-2.9-4
c0.3-0.1,0.5-0.2,0.8-0.3c0.5-0.2,1.1-0.4,1.6-0.7c0.5-0.2,1-0.6,1.5-0.8c0.4-0.2,0.1-0.7-0.3-0.7c-0.6,0.1-1.1,0.1-1.7,0.2
c-0.5,0.1-1.1,0.2-1.6,0.4c-0.3,0.1-0.6,0.2-0.9,0.3c-0.3-1-0.6-2-0.7-3c-0.1-0.7-0.1-1.4-0.1-2.2c0.4-0.2,0.9-0.3,1.3-0.5
c0.5-0.2,1.1-0.4,1.6-0.7c0.5-0.2,1-0.6,1.5-0.8c0.4-0.2,0.1-0.7-0.3-0.7c-0.6,0.1-1.1,0.1-1.7,0.2c-0.5,0.1-1.1,0.2-1.6,0.4
c-0.2,0.1-0.4,0.1-0.7,0.2c0.2-1.5,0.6-3.1,1.3-4.5c0.2,0,0.3,0,0.5,0c0.3-0.1,0.5-0.1,0.8-0.2c0.5-0.1,1-0.3,1.4-0.4
c0.5-0.1,1-0.3,1.4-0.4c0.5-0.2,1-0.4,1.5-0.6c0.4-0.1,0.4-0.8-0.1-0.8c-0.3,0-0.5,0-0.8,0c-0.3,0-0.5,0-0.8,0
c-0.5,0.1-1.1,0.1-1.6,0.3c-0.4,0.1-0.9,0.2-1.3,0.3c0.2-0.2,0.3-0.5,0.5-0.7c2.1-2.9,5-4.9,8.2-5.7c-0.8,1.2-1.5,2.4-2.1,3.7
c-1.4,3.2-2.1,6.7-2,10.1c0.1,3.3,1.1,6.6,2.7,9.5c0.8,1.6,1.9,3,3,4.4c0.3,0.4,0.7,0.9,1.1,1.4C17.4,208.8,14.6,207.9,12.3,206.2z
M24.3,208.3l-0.7,0.1c-0.6-0.9-1.6-1.6-2.2-2.4c-1-1.2-1.9-2.5-2.7-3.9c-1.5-2.7-2.4-5.7-2.6-8.7c-0.1-3.2,0.5-6.3,1.8-9.2
c0.7-1.5,1.5-2.9,2.4-4.3c0.2-0.3,0.5-0.7,0.7-1c0.2,0,0.4-0.1,0.6-0.1c0.7-0.1,1.3-0.2,1.9-0.4l2.3,29.6
C25.3,208.1,24.8,208.2,24.3,208.3z M15.9,245.7c0.5-0.2,1-0.4,1.4-0.6c0.5-0.2,1-0.6,1.5-0.8c0.4-0.2,0.1-0.7-0.3-0.7
c-0.6,0.1-1.1,0.1-1.7,0.2c-0.5,0.1-1.1,0.2-1.6,0.4c-0.3,0.1-0.6,0.2-0.9,0.3c-1.2-1.2-2.2-2.6-3-4.2c0.4-0.2,0.9-0.3,1.3-0.5
c0.5-0.2,1.1-0.4,1.6-0.7c0.5-0.2,1-0.6,1.5-0.8c0.4-0.2,0.1-0.7-0.3-0.7c-0.6,0.1-1.1,0.1-1.7,0.2c-0.5,0.1-1.1,0.2-1.6,0.4
c-0.5,0.1-0.9,0.3-1.4,0.4c-0.3-0.9-0.5-1.8-0.6-2.8c-0.1-1.1-0.2-2.1-0.1-3.2c0.1,0,0.2-0.1,0.3-0.1c0.5-0.2,1.1-0.3,1.6-0.5
c0.5-0.2,1.1-0.4,1.6-0.6c0.5-0.2,0.9-0.6,1.4-0.8c0.3-0.2,0.2-0.7-0.2-0.7c-0.5,0.1-1.1,0-1.6,0.1c-0.6,0.1-1.2,0.2-1.7,0.3
c-0.4,0.1-0.8,0.2-1.1,0.3c0.4-1.9,1.1-3.6,2.2-5.3c0.1,0,0.1,0,0.2,0c0.3-0.1,0.5-0.1,0.8-0.2c0.5-0.1,1-0.3,1.4-0.4
c0.5-0.1,1-0.3,1.4-0.4c0.5-0.2,1-0.4,1.5-0.6c0.4-0.1,0.4-0.8-0.1-0.8c-0.3,0-0.5,0-0.8,0c-0.3,0-0.5,0-0.8,0
c-0.5,0.1-1.1,0.1-1.6,0.3c-0.1,0-0.3,0.1-0.4,0.1c2.2-2.4,5.1-4,8.3-4.6c0,0,0,0,0.1,0c-0.4,0.5-0.7,1.1-1,1.6
c-0.8,1.4-1.5,2.9-2.1,4.5c-1.1,3.2-1.4,6.7-0.9,10c0.4,3.2,1.5,6.2,3.2,8.9c0.8,1.3,1.8,2.6,2.9,3.8c0.2,0.2,0.4,0.4,0.6,0.6
C21.7,248.6,18.5,247.6,15.9,245.7z M28.4,247.7c-0.7-0.6-1.4-1.1-2-1.7c-1-1-1.8-2.1-2.5-3.2c-1.6-2.5-2.6-5.3-3-8.2
c-0.4-3-0.1-6,0.8-8.9c0.4-1.5,1-2.8,1.7-4.2c0.6-1,1.5-2.1,1.8-3.3c0,0,0,0,0,0c0.5-0.1,0.9-0.2,1.4-0.3l2.3,29.6
C28.7,247.7,28.6,247.7,28.4,247.7z M25.7,157.2l282.7-59.5c0.5,4,0.9,7.9,1.4,11.9c0.7,6.2,1.4,12.4,2.1,18.6c0.1,1,0.3,6,2,5.6
c0.5-0.1,1.1-0.2,1.6-0.3c1.7-0.4,3.5-0.7,5.2-1.1c5.3-1.1,10.7-2.2,16-3.3c6-1.2,12-2.4,18-3.7c4.6-0.9,9.1-1.9,13.7-2.8
c1.2-0.2,2.4-0.5,3.6-0.7l23.3,10.4L325,198.2L33.4,255.5L25.7,157.2z M325.2,202.1l23.2,27.2L76.3,281.1l-39.2-22.5L325.2,202.1z
M211.1,365.8c-66.6,0-124.7-37.1-154.8-91.6L75,284.8l0.7,0.2h0.6c0.7-0.1,1.3-0.3,2-0.4c1.9-0.4,3.8-0.7,5.7-1.1
c3-0.6,6-1.2,9.1-1.7c4-0.8,8-1.5,12-2.3c4.8-0.9,9.6-1.8,14.5-2.8c5.5-1.1,11-2.1,16.5-3.2c6.1-1.2,12.1-2.3,18.2-3.5
c6.5-1.2,13-2.5,19.4-3.7c6.8-1.3,13.5-2.6,20.3-3.9c6.9-1.3,13.8-2.6,20.7-3.9c6.9-1.3,13.8-2.6,20.7-3.9
c6.7-1.3,13.5-2.6,20.2-3.9c6.5-1.2,12.9-2.5,19.4-3.7c6-1.2,12.1-2.3,18.1-3.5c5.5-1,11-2.1,16.5-3.1c4.8-0.9,9.6-1.8,14.4-2.7
c4-0.8,7.9-1.5,11.9-2.3c3-0.6,6-1.1,9-1.7c1.9-0.4,3.8-0.7,5.6-1.1c1.2-0.2,2.3-0.3,3.2-1.2c1.6-1.6,3.2-3.2,4.8-4.8
c1.9-1.9,3.8-3.8,5.7-5.7c2.4-2.4,4.8-4.7,7.2-7.1c2.7-2.7,5.4-5.3,8.1-8c2.6-2.6,5.3-5.2,7.9-7.8
C382.7,291.4,305.5,365.8,211.1,365.8z M352.1,227.7l-23.5-27.6l68.9-64.5l20.6,27.1L352.1,227.7z"/>
<path class="st9" d="M180.7,145.3l6.5,65.1l27.8-4.7l-5.8-65.5L180.7,145.3z M188.9,208.2l-6.1-61.3l24.7-4.4l5.5,61.6L188.9,208.2
z"/>
<path class="st9" d="M150.8,168.3l4.4,48.5l20.4-4.2l-4.6-48.2L150.8,168.3z M156.9,214.5l-4.1-44.7l16.4-3.1l4.1,44.4L156.9,214.5
z"/>
<path class="st9" d="M120.2,199.8l2.5,24l28.7-6.4l-1.9-24.7L120.2,199.8z M124.5,221.4l-2.1-20.2l25.4-6.1l1.6,20.8L124.5,221.4z"
/>
<path class="st9" d="M40.5,215.3l2.9,27.8l22.4-3.3l-2.5-28.5L40.5,215.3z M45.2,240.9l-2.5-24l19-3.3l2.2,24.6L45.2,240.9z"/>
<path class="st9" d="M308.3,222.2l-10.7-7.4l-25.9,4.3l10.7,7.4L308.3,222.2z M297.3,216.3l7.3,5l-21.8,3.7l-7.3-5L297.3,216.3z"/>
<path class="st9" d="M295.2,236.7l25.9-4.3l-10.7-7.4l-25.9,4.3L295.2,236.7z M317.4,231.5l-21.8,3.7l-7.3-5l21.8-3.7L317.4,231.5z
"/>
<path class="st9" d="M88.7,275.9l25.9-4.3l-10.7-7.4l-25.9,4.3L88.7,275.9z M110.8,270.8L89,274.4l-7.3-5l21.8-3.7L110.8,270.8z"/>
<path class="st9" d="M91.3,254.3l-25.9,4.3l10.7,7.4l25.9-4.3L91.3,254.3z M69.1,259.4l21.8-3.7l7.3,5l-21.8,3.7L69.1,259.4z"/>
<path class="st9" d="M245.4,175l-2.8-36c-0.2-2-1.9-3.5-3.9-3.3c-1,0.1-1.9,0.5-2.5,1.3c-0.6,0.7-0.9,1.7-0.9,2.6l2.9,36.7
l-1.9,0.3l-2.8-35.8c-0.1-1-0.5-1.9-1.3-2.5c-0.7-0.6-1.7-0.9-2.6-0.9c-1,0.1-1.8,0.5-2.5,1.3c-0.6,0.7-0.9,1.7-0.9,2.6l2.9,36.5
l-1.9,0.3l-2.7-35.1c-0.2-2-1.9-3.5-3.9-3.3c-1,0.1-1.9,0.5-2.5,1.3c-0.6,0.7-0.9,1.7-0.9,2.6l2.8,35.7l-1.5,0.3l2.5,24.2l28.1-4.3
l-1.9-24.8L245.4,175z M237.2,139.4c0-0.5,0.1-0.9,0.4-1.2c0.3-0.3,0.7-0.6,1.2-0.6c0,0,0.1,0,0.1,0c0.9,0,1.6,0.7,1.7,1.6
l2.8,36.2l-3.4,0.6L237.2,139.4z M228.2,141.1c0-0.5,0.1-0.9,0.4-1.2c0.3-0.3,0.7-0.6,1.2-0.6h0c0,0,0.1,0,0.1,0
c0.4,0,0.8,0.1,1.1,0.4c0.3,0.3,0.6,0.7,0.6,1.2l2.8,36l-3.4,0.6L228.2,141.1z M219.1,143.4c0-0.5,0.1-0.9,0.4-1.2
c0.3-0.3,0.7-0.6,1.2-0.6c0,0,0.1,0,0.1,0c0.9,0,1.6,0.7,1.7,1.6l2.8,35.3l-3.4,0.6L219.1,143.4z M222.6,201.5l-2.1-20.4l24.8-4.2
l1.6,20.9L222.6,201.5z"/>
<path class="st9" d="M99.7,179c0.2-2.1-2.5-3-4.1-2.1c-1.1,0.7-1.8,2.1-1.5,3.3c-1.6-0.8-3.5,0-5.3,0.3c-2.6,0.4-5.2,0.9-7.8,1.5
c0.1-2.5-0.1-5-0.3-7.4c2.6-0.2,5.3-0.6,7.9-1.1c0.2,0,0.3-0.1,0.4-0.2c1.1,1.3,3.5,1.5,4.5,0c0.4-0.6,0.6-1.6,0.4-2.5
c0,0,0,0,0-0.1c0.2-2.1-2.5-3-4.1-2.1c-1,0.6-1.6,1.9-1.6,3c-0.1,0-0.2,0-0.2,0c-2.5,0.4-5,0.8-7.5,1.4c0-0.2,0-0.4-0.1-0.6
c-0.2-2.4-0.3-5-1.2-7.2c2.7-0.3,5.3-0.6,8-1c1.9-0.3,4.5-0.1,5.8-1.8c0.1,0.4,0.2,0.7,0.4,1c1.1,1.4,3.6,1.7,4.6,0.2
c0.4-0.6,0.6-1.6,0.4-2.5c0,0,0,0,0-0.1c0.2-2.1-2.5-3-4.1-2.1c-1.1,0.6-1.7,2-1.5,3.2c-1.6-1.2-3.8-0.2-5.7,0.1
c-2.9,0.5-5.7,1-8.5,1.6c-0.7,0.2-0.6,1.2,0,1.3c-0.2,0.7-0.3,1.4-0.3,2.1c-1.8-0.4-3.7,0.3-5.6,0.7c-3.4,0.6-6.9,1.2-10.3,1.9
c-0.7,0.1-0.6,1.2,0,1.3c-0.6,4.3,0.1,8.7,0.4,12.9c0.3,4.4,0.6,8.7,1.4,13c-1.7-0.4-3.6-0.2-5.4,0.2c0.1-1.4-0.1-2.7-0.3-4.1
c-0.1-1-0.1-2.1-0.4-3.1c0.5-0.1,1-0.2,1.5-0.2c0.4-0.1,0.7-0.5,0.7-0.9c-0.2-2.5-0.4-5.1-0.6-7.6c0-0.5-0.4-1-0.9-0.9
c-5.9,0.7-10.8-2.7-11.6-8.7c0-0.1,0-0.1,0-0.1c0,0,0,0,0,0c0-0.1-0.1-0.2-0.1-0.3c0-0.1-0.1-0.1-0.1-0.2c0,0,0,0-0.1-0.1
c-0.2-0.2-0.5-0.3-0.8-0.2c-2.5,0.4-5.1,0.8-7.6,1.2c-0.4,0.1-0.7,0.5-0.7,0.9c0.5,6.4,1.1,12.7,1.6,19.1c0.1,0.7,0.6,1,1.2,0.9
c0.4-0.1,0.8-0.1,1.1-0.2c-0.1,0.9,0,1.8,0.1,2.7c0.1,1.8,0.2,3.7,0.8,5.4c0,0.4,0.2,0.9,0.6,0.8c3.7-0.5,7.4-1,11.1-1.5
c3.2-0.5,7.3-0.5,10.1-2.2c0.1,0.5,0.9,0.4,0.9-0.1c0.1-4.9-0.4-9.7-0.8-14.6c-0.3-4-0.4-8.1-1.6-11.9c3-0.3,6-0.7,9-1.2
c2-0.3,4.5-0.3,6.4-1.2c0.1,1.7,0.3,3.4,0.4,5c0.2,2.9,0.4,5.7,1,8.5c-0.2,0.4,0,1.1,0.6,1.1c2.8-0.3,5.5-0.7,8.3-1.1
c1.8-0.3,4.2-0.1,5.5-1.5c0.1,0.2,0.1,0.3,0.2,0.4c1.1,1.4,3.6,1.7,4.6,0.2C99.7,180.9,99.9,179.9,99.7,179
C99.7,179,99.7,179,99.7,179z M90.9,170.5c0.7-0.5,1.2,0.1,1.5,0.7c0,0.3,0,0.5-0.2,0.8c-0.3,0.6-1.2,0.6-1.6,0.1
C90.1,171.7,90.4,170.8,90.9,170.5z M95.5,160.8c0.7-0.5,1.2,0.1,1.5,0.7c0,0.3,0,0.5-0.2,0.8c-0.3,0.6-1.2,0.6-1.6,0.1
C94.8,162,95,161.2,95.5,160.8z M40.1,174.1c0-0.1,0-0.1,0-0.2c1.8,0.1,4-0.5,5.9-1c1.3,6,6.1,9.8,12.3,9.5c0,1.9-0.1,4.2,0.5,5.8
c-0.1,0-0.1,0-0.2,0c-1-0.1-2.6,0.4-3.6,0.6c-4.5,0.7-9,1.5-13.4,2.2c-0.4-4.5-0.7-9-1.1-13.5C40.3,176.6,40.5,175.1,40.1,174.1z
M49.6,191.6c-0.1,0.9,0,2,0.1,2.9c0.1,1.6,0.1,3.1,0.5,4.6c-2,0.4-4.1,0.7-6.1,1.1c0.1-1.6-0.1-3.1-0.2-4.7
c-0.1-0.9-0.1-2-0.4-2.9C45.5,192.3,47.6,192,49.6,191.6z M51.7,198.9c0.1-1.4-0.1-2.9-0.3-4.3c-0.1-1-0.1-2.1-0.4-3.1
c2.1-0.3,4.1-0.7,6.2-1c-0.1,0.9,0,2,0.1,2.9c0.1,1.5,0.1,3,0.5,4.4c-0.7,0.1-1.3,0.3-2,0.4C54.4,198.4,53,198.6,51.7,198.9z
M97.9,180.2c-0.3,0.6-1.2,0.6-1.6,0.1c-0.4-0.5-0.2-1.3,0.3-1.7c0.7-0.5,1.2,0.1,1.5,0.7C98.1,179.6,98.1,179.9,97.9,180.2z"/>
<path class="st9" d="M293.1,170.4c-5.6,0.8-11.1,1.6-16.7,2.4c0.2-6.9-0.6-14.5-2.3-21.1c0.5-0.2,0.9-0.4,1.2-0.9
c0.4-0.6,0.6-1.6,0.4-2.5c0,0,0,0,0-0.1c0.2-2.1-2.5-3-4.1-2.1c-1.5,0.9-2.1,3.1-1,4.5c0.6,0.8,1.7,1.3,2.8,1.2
c-0.6,6.8-0.1,14.5,1.3,21.2c-2.4,0.4-4.7,0.8-7.1,1.2c0.2-4-0.3-8.1-0.7-12.1c-0.3-2.8-0.4-5.5-1.3-8.1c0.6-0.1,1.2-0.4,1.5-0.9
c0.4-0.6,0.6-1.6,0.4-2.5c0,0,0,0,0-0.1c0.2-2.1-2.5-3-4.1-2.1c-1.5,0.9-2.1,3.1-1,4.5c0.5,0.7,1.3,1.1,2.1,1.2
c-0.5,3.2-0.1,6.6,0.2,9.8c0.3,3.6,0.5,7.2,1.2,10.7c-2.2,0.4-4.5,0.8-6.7,1.3c-0.1-7-0.4-15-2.3-21.8c0.5-0.2,0.9-0.4,1.2-0.9
c0.4-0.6,0.6-1.6,0.4-2.5c0,0,0,0,0-0.1c0.2-2.1-2.5-3-4.1-2.1c-1.5,0.9-2.1,3.1-1,4.5c0.5,0.7,1.5,1.2,2.4,1.2
c-0.7,7.3,0.3,15.7,1.5,22.7c0.1,0.5,0.5,0.7,0.9,0.7c0.1,0.1,0.3,0.2,0.6,0.2c11.5-1.4,23-3.3,34.4-5.4c-0.4,3.7,0.1,7.7,0.5,11.3
c0,0.1,0,0.2,0.1,0.2c-2.7,0-5.4,0.5-8,1c-1.9,0.3-4.1,0.4-5.8,1.4c-0.9-0.8-2.4-1-3.4-0.4c-1.5,0.9-2.1,3.1-1,4.5
c1.1,1.4,3.6,1.7,4.6,0.2c0.4-0.6,0.6-1.6,0.4-2.5c0,0,0,0,0-0.1c0-0.2,0-0.3,0-0.5c1.5,0.1,3-0.2,4.5-0.5c3-0.5,6.2-0.8,9.1-1.8
c0.3-0.1,0.5-0.4,0.5-0.7c0.4,0,0.7-0.3,0.7-0.8c-0.2-3.8-0.3-8.1-1.5-11.8C294.6,171.2,294,170.2,293.1,170.4z M272.4,149.6
c-0.4-0.5-0.2-1.3,0.3-1.7c0.7-0.5,1.2,0.1,1.5,0.7c0,0.3,0,0.5-0.2,0.8C273.7,150.1,272.8,150.1,272.4,149.6z M264.6,150.2
c0.7-0.5,1.2,0.1,1.5,0.7c0,0.3,0,0.5-0.2,0.8c-0.3,0.6-1.2,0.6-1.6,0.1C263.8,151.4,264.1,150.6,264.6,150.2z M255.5,151.9
c-0.4-0.5-0.2-1.3,0.3-1.7c0.7-0.5,1.2,0.1,1.5,0.7c0,0.3,0,0.5-0.2,0.8C256.8,152.4,255.9,152.4,255.5,151.9z M279.2,189.1
c-0.3,0.6-1.2,0.6-1.6,0.1c-0.4-0.5-0.2-1.3,0.3-1.7c0.7-0.5,1.2,0.1,1.5,0.7C279.4,188.5,279.4,188.8,279.2,189.1z"/>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -10,7 +10,7 @@ it("should rename an unpublished application", () => {
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(appRename) renameApp(appRename)
cy.searchForApplication(appRename) cy.searchForApplication(appRename)
cy.get(".appGrid").find(".wrapper").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
cy.deleteApp(appRename) cy.deleteApp(appRename)
}) })
@ -29,7 +29,7 @@ xit("Should rename a published application", () => {
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(appRename, true) renameApp(appRename, true)
cy.searchForApplication(appRename) cy.searchForApplication(appRename)
cy.get(".appGrid").find(".wrapper").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
}) })
it("Should try to rename an application to have no name", () => { it("Should try to rename an application to have no name", () => {
@ -38,7 +38,7 @@ it("Should try to rename an application to have no name", () => {
// Close modal and confirm name has not been changed // Close modal and confirm name has not been changed
cy.get(".spectrum-Dialog-grid").contains("Cancel").click() cy.get(".spectrum-Dialog-grid").contains("Cancel").click()
cy.searchForApplication("Cypress Tests") cy.searchForApplication("Cypress Tests")
cy.get(".appGrid").find(".wrapper").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
}) })
xit("Should create two applications with the same name", () => { xit("Should create two applications with the same name", () => {
@ -64,7 +64,7 @@ it("should validate application names", () => {
cy.get(".home-logo").click() cy.get(".home-logo").click()
renameApp(numberName) renameApp(numberName)
cy.searchForApplication(numberName) cy.searchForApplication(numberName)
cy.get(".appGrid").find(".wrapper").should("have.length", 1) cy.get(".appTable").find(".title").should("have.length", 1)
renameApp(specialCharName) renameApp(specialCharName)
cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only") cy.get(".error").should("have.text", "App name must be letters, numbers and spaces only")
}) })
@ -74,14 +74,14 @@ it("should validate application names", () => {
.its("body") .its("body")
.then(val => { .then(val => {
if (val.length > 0) { if (val.length > 0) {
cy.get(".title > :nth-child(3) > .spectrum-Icon").click() cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
// Check for when an app is published // Check for when an app is published
if (published == true){ if (published == true){
// Should not have Edit as option, will unpublish app // Should not have Edit as option, will unpublish app
cy.should("not.have.value", "Edit") cy.should("not.have.value", "Edit")
cy.get(".spectrum-Menu").contains("Unpublish").click() cy.get(".spectrum-Menu").contains("Unpublish").click()
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".title > :nth-child(3) > .spectrum-Icon").click() cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
} }
cy.contains("Edit").click() cy.contains("Edit").click()
cy.get(".spectrum-Modal") cy.get(".spectrum-Modal")

View File

@ -50,7 +50,9 @@ Cypress.Commands.add("deleteApp", appName => {
.its("body") .its("body")
.then(val => { .then(val => {
if (val.length > 0) { if (val.length > 0) {
cy.get(".title > :nth-child(3) > .spectrum-Icon").click() cy.get(
".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon"
).click()
cy.contains("Delete").click() cy.contains("Delete").click()
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get("input").type(appName) cy.get("input").type(appName)

View File

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

View File

@ -0,0 +1,44 @@
import { datasources, tables } from "../stores/backend"
import { IntegrationNames } from "../constants/backend"
import analytics, { Events } from "../analytics"
import { get } from "svelte/store"
import cloneDeep from "lodash/cloneDeepWith"
function prepareData(config) {
let datasource = {}
let existingTypeCount = get(datasources).list.filter(
ds => ds.source === config.type
).length
let baseName = IntegrationNames[config.type]
let name =
existingTypeCount === 0 ? baseName : `${baseName}-${existingTypeCount + 1}`
datasource.type = "datasource"
datasource.source = config.type
datasource.config = config.config
datasource.name = name
datasource.plus = config.plus
return datasource
}
export async function saveDatasource(config) {
const datasource = prepareData(config)
// Create datasource
const resp = await datasources.save(datasource, datasource.plus)
// update the tables incase data source plus
await tables.fetch()
await datasources.select(resp._id)
analytics.captureEvent(Events.DATASOURCE.CREATED, {
name: resp.name,
source: resp.source,
})
return resp
}
export async function createRestDatasource(integration) {
const config = cloneDeep(integration)
return saveDatasource(config)
}

View File

@ -39,7 +39,7 @@
$: editRowComponent = isUsersTable ? CreateEditUser : CreateEditRow $: editRowComponent = isUsersTable ? CreateEditUser : CreateEditRow
$: { $: {
UNSORTABLE_TYPES.forEach(type => { UNSORTABLE_TYPES.forEach(type => {
Object.values(schema).forEach(col => { Object.values(schema || {}).forEach(col => {
if (col.type === type) { if (col.type === type) {
col.sortable = false col.sortable = false
} }
@ -113,16 +113,16 @@
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div> <div>
<div class="table-title"> {#if title}
{#if title} <div class="table-title">
<Heading size="S">{title}</Heading> <Heading size="S">{title}</Heading>
{/if} {#if loading}
{#if loading} <div transition:fade|local>
<div transition:fade|local> <Spinner size="10" />
<Spinner size="10" /> </div>
</div> {/if}
{/if} </div>
</div> {/if}
<div class="popovers"> <div class="popovers">
<slot /> <slot />
{#if !isUsersTable && selectedRows.length > 0} {#if !isUsersTable && selectedRows.length > 0}

View File

@ -8,6 +8,7 @@
import EditQueryPopover from "./popovers/EditQueryPopover.svelte" import EditQueryPopover from "./popovers/EditQueryPopover.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import TableNavigator from "components/backend/TableNavigator/TableNavigator.svelte" import TableNavigator from "components/backend/TableNavigator/TableNavigator.svelte"
import { customQueryIconText, customQueryIconColor } from "helpers/data/utils"
import ICONS from "./icons" import ICONS from "./icons"
let openDataSources = [] let openDataSources = []
@ -129,6 +130,8 @@
<NavItem <NavItem
indentLevel={1} indentLevel={1}
icon="SQLQuery" icon="SQLQuery"
iconText={customQueryIconText(datasource, query)}
iconColor={customQueryIconColor(datasource, query)}
text={query.name} text={query.name}
opened={$queries.selected === query._id} opened={$queries.selected === query._id}
selected={$queries.selected === query._id} selected={$queries.selected === query._id}

View File

@ -9,19 +9,53 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte" import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { IntegrationTypes } from "constants/backend"
export let integration export let datasource
export let schema export let schema
export let creating
function filter([key, value]) {
if (!value) {
return false
}
return !(
(datasource.source === IntegrationTypes.REST &&
key === "defaultHeaders") ||
value.deprecated
)
}
$: config = datasource?.config
$: configKeys = Object.entries(schema || {})
.filter(el => filter(el))
.map(([key]) => key)
let addButton let addButton
function getDisplayName(key) {
let name
if (schema[key]?.display) {
name = schema[key].display
} else {
name = key
}
return capitalise(name)
}
</script> </script>
<form> <form>
<Layout gap="S"> <Layout noPadding gap="S">
{#each Object.keys(schema) as configKey} {#if !creating}
<div class="form-row">
<Label>Name</Label>
<Input on:change bind:value={datasource.name} />
</div>
{/if}
{#each configKeys as configKey}
{#if schema[configKey].type === "object"} {#if schema[configKey].type === "object"}
<div class="form-row ssl"> <div class="form-row ssl">
<Label>{capitalise(configKey)}</Label> <Label>{getDisplayName(configKey)}</Label>
<Button secondary thin outline on:click={addButton.addEntry()} <Button secondary thin outline on:click={addButton.addEntry()}
>Add</Button >Add</Button
> >
@ -29,30 +63,31 @@
<KeyValueBuilder <KeyValueBuilder
bind:this={addButton} bind:this={addButton}
defaults={schema[configKey].default} defaults={schema[configKey].default}
bind:object={integration[configKey]} bind:object={config[configKey]}
on:change
noAddButton={true} noAddButton={true}
/> />
{:else if schema[configKey].type === "boolean"} {:else if schema[configKey].type === "boolean"}
<div class="form-row"> <div class="form-row">
<Label>{capitalise(configKey)}</Label> <Label>{getDisplayName(configKey)}</Label>
<Toggle text="" bind:value={integration[configKey]} /> <Toggle text="" bind:value={config[configKey]} />
</div> </div>
{:else if schema[configKey].type === "longForm"} {:else if schema[configKey].type === "longForm"}
<div class="form-row"> <div class="form-row">
<Label>{capitalise(configKey)}</Label> <Label>{getDisplayName(configKey)}</Label>
<TextArea <TextArea
type={schema[configKey].type} type={schema[configKey].type}
on:change on:change
bind:value={integration[configKey]} bind:value={config[configKey]}
/> />
</div> </div>
{:else} {:else}
<div class="form-row"> <div class="form-row">
<Label>{capitalise(configKey)}</Label> <Label>{getDisplayName(configKey)}</Label>
<Input <Input
type={schema[configKey].type} type={schema[configKey].type}
on:change on:change
bind:value={integration[configKey]} bind:value={config[configKey]}
/> />
</div> </div>
{/if} {/if}

View File

@ -0,0 +1,221 @@
<script>
import {
Heading,
Body,
Divider,
InlineAlert,
Button,
notifications,
Modal,
Table,
} from "@budibase/bbui"
import { datasources, integrations, tables } from "stores/backend"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
import CreateExternalTableModal from "./CreateExternalTableModal.svelte"
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify"
export let datasource
export let save
let tableSchema = {
name: {},
primary: { displayName: "Primary Key" },
}
let relationshipSchema = {
tables: {},
columns: {},
}
let relationshipModal
let createExternalTableModal
let selectedFromRelationship, selectedToRelationship
let confirmDialog
$: integration = datasource && $integrations[datasource.source]
$: plusTables = datasource?.plus
? Object.values(datasource?.entities || {})
: []
$: relationships = getRelationships(plusTables)
$: schemaError = $datasources.schemaError
$: relationshipInfo = relationshipTableData(relationships)
function getRelationships(tables) {
if (!tables || !Array.isArray(tables)) {
return {}
}
let pairs = {}
for (let table of tables) {
for (let column of Object.values(table.schema)) {
if (column.type !== "link") {
continue
}
// these relationships have an id to pair them to each other
// one has a main for the from side
const key = column.main ? "from" : "to"
pairs[column._id] = {
...pairs[column._id],
[key]: column,
}
}
}
return pairs
}
function buildRelationshipDisplayString(fromCol, toCol) {
function getTableName(tableId) {
if (!tableId || typeof tableId !== "string") {
return null
}
return plusTables.find(table => table._id === tableId)?.name || "Unknown"
}
if (!toCol || !fromCol) {
return "Cannot build name"
}
const fromTableName = getTableName(toCol.tableId)
const toTableName = getTableName(fromCol.tableId)
const throughTableName = getTableName(fromCol.through)
let displayString
if (throughTableName) {
displayString = `${fromTableName} through ${throughTableName} → ${toTableName}`
} else {
displayString = `${fromTableName} → ${toTableName}`
}
return displayString
}
async function updateDatasourceSchema() {
try {
await datasources.updateSchema(datasource)
notifications.success(`Datasource ${name} tables updated successfully.`)
await tables.fetch()
} catch (err) {
notifications.error(`Error updating datasource schema: ${err}`)
}
}
function onClickTable(table) {
tables.select(table)
$goto(`../../table/${table._id}`)
}
function openRelationshipModal(fromRelationship, toRelationship) {
selectedFromRelationship = fromRelationship || {}
selectedToRelationship = toRelationship || {}
relationshipModal.show()
}
function createNewTable() {
createExternalTableModal.show()
}
function relationshipTableData(relations) {
return Object.values(relations).map(relationship => ({
tables: buildRelationshipDisplayString(
relationship.from,
relationship.to
),
columns: `${relationship.from?.name} to ${relationship.to?.name}`,
from: relationship.from,
to: relationship.to,
}))
}
</script>
<Modal bind:this={relationshipModal}>
<CreateEditRelationship
{datasource}
{save}
close={relationshipModal.hide}
{plusTables}
fromRelationship={selectedFromRelationship}
toRelationship={selectedToRelationship}
/>
</Modal>
<Modal bind:this={createExternalTableModal}>
<CreateExternalTableModal {datasource} />
</Modal>
<ConfirmDialog
bind:this={confirmDialog}
okText="Fetch tables"
onOk={updateDatasourceSchema}
onCancel={() => confirmDialog.hide()}
warning={false}
title="Confirm table fetch"
>
<Body>
If you have fetched tables from this database before, this action may
overwrite any changes you made after your initial fetch.
</Body>
</ConfirmDialog>
<Divider size="S" />
<div class="query-header">
<Heading size="S">Tables</Heading>
<div class="table-buttons">
<Button secondary on:click={() => confirmDialog.show()}>
Fetch tables
</Button>
<Button cta icon="Add" on:click={createNewTable}>New table</Button>
</div>
</div>
<Body>
This datasource can determine tables automatically. Budibase can fetch your
tables directly from the database and you can use them without having to write
any queries at all.
</Body>
{#if schemaError}
<InlineAlert
type="error"
header="Error fetching tables"
message={schemaError}
onConfirm={datasources.removeSchemaError}
/>
{/if}
<Table
on:click={({ detail }) => onClickTable(detail)}
schema={tableSchema}
data={Object.values(plusTables)}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "primary", component: ArrayRenderer }]}
/>
{#if plusTables?.length !== 0}
<Divider size="S" />
<div class="query-header">
<Heading size="S">Relationships</Heading>
<Button primary on:click={openRelationshipModal}>
Define relationship
</Button>
</div>
<Body>
Tell budibase how your tables are related to get even more smart features.
</Body>
{/if}
<Table
on:click={({ detail }) => openRelationshipModal(detail.from, detail.to)}
schema={relationshipSchema}
data={relationshipInfo}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
<style>
.query-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 0 0 var(--spacing-s) 0;
}
.table-buttons {
display: flex;
gap: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,58 @@
<script>
import { Divider, Heading, ActionButton, Badge, Body } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import RestAuthenticationBuilder from "./auth/RestAuthenticationBuilder.svelte"
export let datasource
let addHeader
</script>
<Divider size="S" />
<div class="section-header">
<div class="badge">
<Heading size="S">Headers</Heading>
<Badge quiet grey>Optional</Badge>
</div>
</div>
<Body size="S">
Headers enable you to provide additional information about the request, such
as format.
</Body>
<KeyValueBuilder
bind:this={addHeader}
bind:object={datasource.config.defaultHeaders}
on:change
noAddButton
/>
<div>
<ActionButton icon="Add" on:click={() => addHeader.addEntry()}>
Add header
</ActionButton>
</div>
<Divider size="S" />
<div class="section-header">
<div class="badge">
<Heading size="S">Authentication</Heading>
<Badge quiet grey>Optional</Badge>
</div>
</div>
<Body size="S">
Create an authentication config that can be shared with queries.
</Body>
<RestAuthenticationBuilder bind:configs={datasource.config.authConfigs} />
<style>
.section-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.badge {
display: flex;
gap: var(--spacing-m);
}
</style>

View File

@ -0,0 +1,13 @@
<script>
import { AUTH_TYPE_LABELS } from "./authTypes"
export let value
const renderAuthType = value => {
return AUTH_TYPE_LABELS.filter(type => type.value === value).map(
type => type.label
)
}
</script>
{renderAuthType(value)}

View File

@ -0,0 +1,65 @@
<script>
import { Table, Modal, Layout, ActionButton } from "@budibase/bbui"
import AuthTypeRenderer from "./AuthTypeRenderer.svelte"
import RestAuthenticationModal from "./RestAuthenticationModal.svelte"
import { uuid } from "builderStore/uuid"
export let configs = []
let currentConfig = null
let modal
const schema = {
name: "",
type: "",
}
const openConfigModal = config => {
currentConfig = config
modal.show()
}
const onConfirm = config => {
if (currentConfig) {
configs = configs.map(c => {
// replace the current config with the new one
if (c._id === currentConfig._id) {
return config
}
return c
})
} else {
config._id = uuid()
configs = [...configs, config]
}
}
const onRemove = () => {
configs = configs.filter(c => {
return c._id !== currentConfig._id
})
}
</script>
<Modal bind:this={modal}>
<RestAuthenticationModal {configs} {currentConfig} {onConfirm} {onRemove} />
</Modal>
<Layout gap="S" noPadding>
{#if configs && configs.length > 0}
<Table
on:click={({ detail }) => openConfigModal(detail)}
{schema}
data={configs}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "type", component: AuthTypeRenderer }]}
/>
{/if}
<div>
<ActionButton on:click={() => openConfigModal()} con="Add"
>Add authentication</ActionButton
>
</div>
</Layout>

View File

@ -0,0 +1,218 @@
<script>
import { onMount } from "svelte"
import { ModalContent, Layout, Select, Body, Input } from "@budibase/bbui"
import { AUTH_TYPE_LABELS, AUTH_TYPES } from "./authTypes"
export let configs
export let currentConfig
export let onConfirm
export let onRemove
let form = {
basic: {},
bearer: {},
}
let errors = {
basic: {},
bearer: {},
}
let blurred = {
basic: {},
bearer: {},
}
let hasErrors = false
let hasChanged = false
onMount(() => {
if (currentConfig) {
deconstructConfig()
}
})
/**
* map the current config's data into the form by type
*/
const deconstructConfig = () => {
form.name = currentConfig.name
form.type = currentConfig.type
if (currentConfig.type === AUTH_TYPES.BASIC) {
form.basic = {
...currentConfig.config,
}
} else if (currentConfig.type === AUTH_TYPES.BEARER) {
form.bearer = {
...currentConfig.config,
}
}
}
/**
* map the form into a new config to save by type
*/
const constructConfig = () => {
const newConfig = {
name: form.name,
type: form.type,
}
if (currentConfig) {
newConfig._id = currentConfig._id
}
if (form.type === AUTH_TYPES.BASIC) {
newConfig.config = {
...form.basic,
}
} else if (form.type === AUTH_TYPES.BEARER) {
newConfig.config = {
...form.bearer,
}
}
return newConfig
}
/**
* compare the existing config with the new config to see if there are any changes
*/
const checkChanged = () => {
if (currentConfig) {
hasChanged =
JSON.stringify(currentConfig) !== JSON.stringify(constructConfig())
} else {
hasChanged = true
}
}
const checkErrors = () => {
hasErrors = false
// NAME
const nameError = () => {
// Unique name
if (form.name) {
errors.name =
// check for duplicate excluding the current config
configs.find(
c => c.name === form.name && c.name !== currentConfig?.name
) !== undefined
? "Name must be unique"
: null
}
// Name required
else {
errors.name = "Name is required"
}
return !!errors.name
}
// TYPE
const typeError = () => {
errors.type = form.type ? null : "Type is required"
return !!errors.type
}
// BASIC AUTH
const basicAuthErrors = () => {
errors.basic.username = form.basic.username
? null
: "Username is required"
errors.basic.password = form.basic.password
? null
: "Password is required"
return !!(errors.basic.username || errors.basic.password || commonError)
}
// BEARER TOKEN
const bearerTokenErrors = () => {
errors.bearer.token = form.bearer.token ? null : "Token is required"
return !!(errors.bearer.token || commonError)
}
const commonError = nameError() || typeError()
if (form.type === AUTH_TYPES.BASIC) {
hasErrors = basicAuthErrors() || commonError
} else if (form.type === AUTH_TYPES.BEARER) {
hasErrors = bearerTokenErrors() || commonError
} else {
hasErrors = !!commonError
}
}
const onFieldChange = () => {
checkErrors()
checkChanged()
}
const onConfirmInternal = () => {
onConfirm(constructConfig())
}
</script>
<ModalContent
title={currentConfig ? "Update Authentication" : "Add Authentication"}
onConfirm={onConfirmInternal}
confirmText={currentConfig ? "Update" : "Add"}
disabled={hasErrors || !hasChanged}
cancelText={"Cancel"}
size="M"
showSecondaryButton={!!currentConfig}
secondaryButtonText={"Remove"}
secondaryAction={onRemove}
secondaryButtonWarning={true}
>
<Layout gap="S">
<Body size="S">
The authorization header will be automatically generated when you send the
request.
</Body>
<Input
label="Name"
bind:value={form.name}
on:change={onFieldChange}
on:blur={() => (blurred.name = true)}
error={blurred.name ? errors.name : null}
/>
<Select
label="Type"
bind:value={form.type}
on:change={onFieldChange}
options={AUTH_TYPE_LABELS}
on:blur={() => (blurred.type = true)}
error={blurred.type ? errors.type : null}
/>
{#if form.type === AUTH_TYPES.BASIC}
<Input
label="Username"
bind:value={form.basic.username}
on:change={onFieldChange}
on:blur={() => (blurred.basic.username = true)}
error={blurred.basic.username ? errors.basic.username : null}
/>
<Input
label="Password"
bind:value={form.basic.password}
on:change={onFieldChange}
on:blur={() => (blurred.basic.password = true)}
error={blurred.basic.password ? errors.basic.password : null}
/>
{/if}
{#if form.type === AUTH_TYPES.BEARER}
<Input
label="Token"
bind:value={form.bearer.token}
on:change={onFieldChange}
on:blur={() => (blurred.bearer.token = true)}
error={blurred.bearer.token ? errors.bearer.token : null}
/>
{/if}
</Layout>
</ModalContent>
<style>
</style>

View File

@ -0,0 +1,15 @@
export const AUTH_TYPES = {
BASIC: "basic",
BEARER: "bearer",
}
export const AUTH_TYPE_LABELS = [
{
label: "Basic Auth",
value: AUTH_TYPES.BASIC,
},
{
label: "Bearer Token",
value: AUTH_TYPES.BEARER,
},
]

View File

@ -3,9 +3,11 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import ICONS from "../icons" import ICONS from "../icons"
import api from "builderStore/api" import api from "builderStore/api"
import { IntegrationNames } from "constants" import { IntegrationNames, IntegrationTypes } from "constants/backend"
import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte" import CreateTableModal from "components/backend/TableNavigator/modals/CreateTableModal.svelte"
import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte" import DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
export let modal export let modal
@ -19,8 +21,6 @@
checkShowImport() checkShowImport()
const INTERNAL = "BUDIBASE"
onMount(() => { onMount(() => {
fetchIntegrations() fetchIntegrations()
}) })
@ -50,10 +50,14 @@
importModal.show() importModal.show()
} }
function chooseNextModal() { async function chooseNextModal() {
if (integration.type === INTERNAL) { if (integration.type === IntegrationTypes.INTERNAL) {
externalDatasourceModal.hide() externalDatasourceModal.hide()
internalTableModal.show() internalTableModal.show()
} else if (integration.type === IntegrationTypes.REST) {
// skip modal for rest, create straight away
const resp = await createRestDatasource(integration)
$goto(`./datasource/${resp._id}`)
} else { } else {
externalDatasourceModal.show() externalDatasourceModal.show()
} }
@ -63,7 +67,7 @@
const response = await api.get("/api/integrations") const response = await api.get("/api/integrations")
const json = await response.json() const json = await response.json()
integrations = { integrations = {
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" }, [IntegrationTypes.INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
...json, ...json,
} }
return json return json
@ -108,8 +112,8 @@
to your app using Budibase's built-in database. to your app using Budibase's built-in database.
</Body> </Body>
<div <div
class:selected={integration.type === INTERNAL} class:selected={integration.type === IntegrationTypes.INTERNAL}
on:click={() => selectIntegration(INTERNAL)} on:click={() => selectIntegration(IntegrationTypes.INTERNAL)}
class="item hoverable" class="item hoverable"
> >
<div class="item-body"> <div class="item-body">
@ -124,7 +128,7 @@
<Detail size="S">Connect to data source</Detail> <Detail size="S">Connect to data source</Detail>
</div> </div>
<div class="item-list"> <div class="item-list">
{#each Object.entries(integrations).filter(([key]) => key !== INTERNAL) as [integrationType, schema]} {#each Object.entries(integrations).filter(([key]) => key !== IntegrationTypes.INTERNAL) as [integrationType, schema]}
<div <div
class:selected={integration.type === integrationType} class:selected={integration.type === integrationType}
on:click={() => selectIntegration(integrationType)} on:click={() => selectIntegration(integrationType)}

View File

@ -1,66 +1,33 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui" import { ModalContent, notifications, Body, Layout } from "@budibase/bbui"
import analytics, { Events } from "analytics"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { datasources, tables } from "stores/backend" import { IntegrationNames } from "constants/backend"
import { IntegrationNames } from "constants"
import cloneDeep from "lodash/cloneDeepWith" import cloneDeep from "lodash/cloneDeepWith"
import { saveDatasource as save } from "builderStore/datasource"
export let integration export let integration
export let modal export let modal
// kill the reference so the input isn't saved // kill the reference so the input isn't saved
let config = cloneDeep(integration) let datasource = cloneDeep(integration)
function prepareData() {
let datasource = {}
let existingTypeCount = $datasources.list.filter(
ds => ds.source == config.type
).length
let baseName = IntegrationNames[config.type]
let name =
existingTypeCount === 0
? baseName
: `${baseName}-${existingTypeCount + 1}`
datasource.type = "datasource"
datasource.source = config.type
datasource.config = config.config
datasource.name = name
datasource.plus = config.plus
return datasource
}
async function saveDatasource() { async function saveDatasource() {
const datasource = prepareData()
try { try {
// Create datasource const resp = await save(datasource)
const resp = await datasources.save(datasource, datasource.plus)
// update the tables incase data source plus
await tables.fetch()
await datasources.select(resp._id)
$goto(`./datasource/${resp._id}`) $goto(`./datasource/${resp._id}`)
notifications.success(`Datasource updated successfully.`) notifications.success(`Datasource updated successfully.`)
analytics.captureEvent(Events.DATASOURCE.CREATED, {
name: resp.name,
source: resp.source,
})
return true
} catch (err) { } catch (err) {
notifications.error(`Error saving datasource: ${err}`) notifications.error(`Error saving datasource: ${err}`)
return false
} }
} }
</script> </script>
<ModalContent <ModalContent
title={`Connect to ${IntegrationNames[config.type]}`} title={`Connect to ${IntegrationNames[datasource.type]}`}
onConfirm={() => saveDatasource()} onConfirm={() => saveDatasource()}
onCancel={() => modal.show()} onCancel={() => modal.show()}
confirmText={config.plus confirmText={datasource.plus
? "Fetch tables from database" ? "Fetch tables from database"
: "Save and continue to query"} : "Save and continue to query"}
cancelText="Back" cancelText="Back"
@ -72,7 +39,11 @@
</Body> </Body>
</Layout> </Layout>
<IntegrationConfigForm schema={config.schema} integration={config.config} /> <IntegrationConfigForm
schema={datasource.schema}
bind:datasource
creating={true}
/>
</ModalContent> </ModalContent>
<style> <style>

View File

@ -1,6 +1,6 @@
<script> <script>
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { datasources, tables } from "stores/backend" import { datasources, queries, tables } from "stores/backend"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -12,12 +12,18 @@
let updateDatasourceDialog let updateDatasourceDialog
async function deleteDatasource() { async function deleteDatasource() {
const wasSelectedSource = $datasources.selected let wasSelectedSource = $datasources.selected
if (!wasSelectedSource && $queries.selected) {
const queryId = $queries.selected
wasSelectedSource = $datasources.list.find(ds =>
queryId.includes(ds._id)
)?._id
}
const wasSelectedTable = $tables.selected const wasSelectedTable = $tables.selected
await datasources.delete(datasource) await datasources.delete(datasource)
notifications.success("Datasource deleted") notifications.success("Datasource deleted")
// navigate to first index page if the source you are deleting is selected // navigate to first index page if the source you are deleting is selected
const entities = Object.values(datasource.entities) const entities = Object.values(datasource?.entities || {})
if ( if (
wasSelectedSource === datasource._id || wasSelectedSource === datasource._id ||
(entities && (entities &&

View File

@ -1,14 +1,20 @@
<script> <script>
import { goto } from "@roxi/routify"
import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon, notifications } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { queries } from "stores/backend" import { datasources, queries } from "stores/backend"
export let query export let query
let confirmDeleteDialog let confirmDeleteDialog
async function deleteQuery() { async function deleteQuery() {
const wasSelectedQuery = $queries.selected
const selectedDatasource = $datasources.selected
await queries.delete(query) await queries.delete(query)
if (wasSelectedQuery === query._id) {
$goto(`./datasource/${selectedDatasource}`)
}
notifications.success("Query deleted") notifications.success("Query deleted")
} }

View File

@ -15,6 +15,9 @@
name: "handlebars", name: "handlebars",
base: "text/html", base: "text/html",
}, },
Text: {
name: "text/html",
},
} }
</script> </script>

View File

@ -1,12 +1,30 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon, Body } from "@budibase/bbui"
</script> </script>
<a target="_blank" href="https://github.com/Budibase/budibase/discussions"> <a target="_blank" href="https://github.com/Budibase/budibase/discussions">
<Icon hoverable name="Help" size="XXL" /> <div class="inner hoverable">
<div class="hidden hoverable">
<Body size="S">Need help? Go to our forums</Body>
</div>
<Icon name="Help" size="XXL" />
</div>
</a> </a>
<style> <style>
.inner {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
.inner :global(*) {
pointer-events: all;
transition: color var(--spectrum-global-animation-duration-100, 130ms);
}
.inner:hover :global(*) {
color: var(--spectrum-alias-icon-color-selected-hover);
cursor: pointer;
}
a { a {
color: inherit; color: inherit;
position: absolute; position: absolute;
@ -14,4 +32,10 @@
right: var(--spacing-m); right: var(--spacing-m);
border-radius: 55%; border-radius: 55%;
} }
.hidden {
display: none;
}
.inner:hover .hidden {
display: block;
}
</style> </style>

View File

@ -11,6 +11,8 @@
export let selected = false export let selected = false
export let opened = false export let opened = false
export let draggable = false export let draggable = false
export let iconText
export let iconColor
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -42,9 +44,13 @@
{/if} {/if}
<slot name="icon" /> <slot name="icon" />
{#if icon} {#if iconText}
<div class="iconText" style={iconColor ? `color: ${iconColor};` : ""}>
{iconText}
</div>
{:else if icon}
<div class="icon"> <div class="icon">
<Icon size="S" name={icon} /> <Icon color={iconColor} size="S" name={icon} />
</div> </div>
{/if} {/if}
<div class="text">{text}</div> <div class="text">{text}</div>
@ -123,4 +129,9 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.iconText {
margin-top: 1px;
font-size: var(--spectrum-global-dimension-font-size-50);
}
</style> </style>

View File

@ -0,0 +1,63 @@
<script>
import { Heading, Icon, Input, Label, Body } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
let dispatch = createEventDispatcher()
export let defaultValue = ""
export let value
export let type = "label"
export let size = "M"
let editing = false
function setEditing(state) {
editing = state
if (editing) {
dispatch("change")
}
}
</script>
<div class="parent">
{#if !editing}
{#if type === "heading"}
<Heading {size}>{value || defaultValue}</Heading>
{:else if type === "body"}
<Body {size}>{value || defaultValue}</Body>
{:else}
<Label {size}>{value || defaultValue}</Label>
{/if}
<div class="hide">
<Icon name="Edit" hoverable size="S" on:click={() => setEditing(true)} />
</div>
{:else}
<div class="input">
<Input placeholder={defaultValue} bind:value on:change />
</div>
<Icon
name="SaveFloppy"
hoverable
size="S"
on:click={() => setEditing(false)}
/>
{/if}
</div>
<style>
.parent {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.hide {
display: none;
margin-top: 5px;
}
.parent:hover .hide {
display: block;
}
.input {
flex: 1;
}
</style>

View File

@ -0,0 +1,5 @@
<script>
export let value
</script>
{Array.isArray(value) ? value.join(", ") : value}

View File

@ -0,0 +1,6 @@
<script>
import { capitalise } from "helpers"
export let value
</script>
{capitalise(value)}

View File

@ -1,7 +1,7 @@
<script> <script>
import { Select, Layout, Input, Checkbox } from "@budibase/bbui" import { Select, Layout, Input, Checkbox } from "@budibase/bbui"
import { datasources, integrations, queries } from "stores/backend" import { datasources, integrations, queries } from "stores/backend"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
export let parameters export let parameters
@ -53,10 +53,10 @@
{#if query?.parameters?.length > 0} {#if query?.parameters?.length > 0}
<div> <div>
<ParameterBuilder <BindingBuilder
bind:customParams={parameters.queryParams} bind:customParams={parameters.queryParams}
parameters={query.parameters} bindings={query.parameters}
{bindings} bind:bindableOptions={bindings}
/> />
<IntegrationQueryEditor <IntegrationQueryEditor
height={200} height={200}

View File

@ -17,7 +17,7 @@
queries as queriesStore, queries as queriesStore,
} from "stores/backend" } from "stores/backend"
import { datasources, integrations } from "stores/backend" import { datasources, integrations } from "stores/backend"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
@ -162,15 +162,15 @@
/> />
{#if value?.type === "query"} {#if value?.type === "query"}
<i class="ri-settings-5-line" on:click={openQueryParamsDrawer} /> <i class="ri-settings-5-line" on:click={openQueryParamsDrawer} />
<Drawer title={"Query Parameters"} bind:this={drawer}> <Drawer title={"Query Bindings"} bind:this={drawer}>
<Button slot="buttons" cta on:click={saveQueryParams}>Save</Button> <Button slot="buttons" cta on:click={saveQueryParams}>Save</Button>
<DrawerContent slot="body"> <DrawerContent slot="body">
<Layout noPadding> <Layout noPadding gap="XS">
{#if getQueryParams(value).length > 0} {#if getQueryParams(value).length > 0}
<ParameterBuilder <BindingBuilder
bind:customParams={tmpQueryParams} bind:customParams={tmpQueryParams}
parameters={getQueryParams(value)} bindings={getQueryParams(value)}
{bindings} bind:bindableOptions={bindings}
/> />
{/if} {/if}
<IntegrationQueryEditor <IntegrationQueryEditor

View File

@ -0,0 +1,56 @@
<script>
import { Label, Select } from "@budibase/bbui"
import { permissions, roles } from "stores/backend"
import { onMount } from "svelte"
import { Roles } from "constants/backend"
export let query
export let saveId
export let label
$: updateRole(roleId, saveId)
let roleId, loaded
async function updateRole(role, id) {
roleId = role
const queryId = query?._id || id
if (roleId && queryId) {
for (let level of ["read", "write"]) {
await permissions.save({
level,
role,
resource: queryId,
})
}
}
}
onMount(async () => {
if (!query || !query._id) {
roleId = Roles.BASIC
loaded = true
return
}
try {
roleId = (await permissions.forResource(query._id))["read"]
} catch (err) {
roleId = Roles.BASIC
}
loaded = true
})
</script>
{#if loaded}
{#if label}
<Label>{label}</Label>
{/if}
<Select
value={roleId}
on:change={e => updateRole(e.detail)}
options={$roles}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
autoWidth
/>
{/if}

View File

@ -0,0 +1,11 @@
<script>
import { TextArea } from "@budibase/bbui"
export let data
export let height
export let minHeight = "120"
$: string = JSON.stringify(data || {}, null, 2)
</script>
<TextArea disabled value={string} {height} {minHeight} />

View File

@ -1,41 +1,118 @@
<script> <script>
import { Icon, Button, Input } from "@budibase/bbui" import {
Icon,
ActionButton,
Input,
Label,
Toggle,
Select,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { lowercase } from "helpers"
let dispatch = createEventDispatcher()
export let defaults export let defaults
export let object = defaults || {} export let object = defaults || {}
export let activity = {}
export let readOnly export let readOnly
export let noAddButton export let noAddButton
export let name
export let headings = false
export let options
export let toggle
export let keyPlaceholder = "Key"
export let valuePlaceholder = "Value"
export let tooltip
let fields = Object.entries(object).map(([name, value]) => ({ name, value })) let fields = Object.entries(object).map(([name, value]) => ({ name, value }))
let fieldActivity = buildFieldActivity(activity)
$: object = fields.reduce( $: object = fields.reduce(
(acc, next) => ({ ...acc, [next.name]: next.value }), (acc, next) => ({ ...acc, [next.name]: next.value }),
{} {}
) )
function buildFieldActivity(obj) {
if (!obj || typeof obj !== "object") {
return []
}
const array = Array(fields.length)
for (let [key, value] of Object.entries(obj)) {
const field = fields.find(el => el.name === key)
const idx = fields.indexOf(field)
array[idx] = idx !== -1 ? value : true
}
return array
}
export function addEntry() { export function addEntry() {
fields = [...fields, {}] fields = [...fields, { name: "", value: "" }]
fieldActivity = [...fieldActivity, true]
changed()
} }
function deleteEntry(idx) { function deleteEntry(idx) {
fields.splice(idx, 1) fields.splice(idx, 1)
fieldActivity.splice(idx, 1)
changed()
}
function changed() {
fields = fields fields = fields
const newActivity = {}
for (let idx = 0; idx < fields.length; idx++) {
const fieldName = fields[idx].name
if (fieldName) {
newActivity[fieldName] = fieldActivity[idx]
}
}
activity = newActivity
dispatch("change", fields)
} }
</script> </script>
<!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. --> <!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. -->
<div class="container" class:readOnly> {#if Object.keys(object || {}).length > 0}
{#each fields as field, idx} {#if headings}
<Input placeholder="Key" bind:value={field.name} /> <div class="container" class:container-active={toggle}>
<Input placeholder="Value" bind:value={field.value} /> <Label {tooltip}>{keyPlaceholder}</Label>
{#if !readOnly} <Label>{valuePlaceholder}</Label>
<Icon hoverable name="Close" on:click={() => deleteEntry(idx)} /> {#if toggle}
{/if} <Label>Active</Label>
{/each} {/if}
</div> </div>
{/if}
<div class="container" class:container-active={toggle} class:readOnly>
{#each fields as field, idx}
<Input
placeholder={keyPlaceholder}
bind:value={field.name}
on:change={changed}
/>
{#if options}
<Select bind:value={field.value} on:change={changed} {options} />
{:else}
<Input
placeholder={valuePlaceholder}
bind:value={field.value}
on:change={changed}
/>
{/if}
{#if toggle}
<Toggle bind:value={fieldActivity[idx]} on:change={changed} />
{/if}
{#if !readOnly}
<Icon hoverable name="Close" on:click={() => deleteEntry(idx)} />
{/if}
{/each}
</div>
{/if}
{#if !readOnly && !noAddButton} {#if !readOnly && !noAddButton}
<div> <div>
<Button secondary thin outline on:click={addEntry}>Add</Button> <ActionButton icon="Add" secondary thin outline on:click={addEntry}
>Add{name ? ` ${lowercase(name)}` : ""}</ActionButton
>
</div> </div>
{/if} {/if}
@ -47,4 +124,10 @@
align-items: center; align-items: center;
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);
} }
.container-active {
grid-template-columns: 1fr 1fr 50px 20px;
}
.readOnly {
grid-template-columns: 1fr 1fr;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { Icon, Body, Button, Input, Heading, Layout } from "@budibase/bbui" import { Body, Button, Heading, Icon, Input, Layout } from "@budibase/bbui"
import { import {
readableToRuntimeBinding, readableToRuntimeBinding,
runtimeToReadableBinding, runtimeToReadableBinding,
@ -7,83 +7,82 @@
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let bindable = true export let bindable = true
export let parameters = []
export let bindings = [] export let bindings = []
export let bindableOptions = []
export let customParams = {} export let customParams = {}
function newQueryParameter() { function newQueryBinding() {
parameters = [...parameters, {}] bindings = [...bindings, {}]
} }
function deleteQueryParameter(idx) { function deleteQueryBinding(idx) {
parameters.splice(idx, 1) bindings.splice(idx, 1)
parameters = parameters bindings = bindings
} }
// This is necessary due to the way readable and writable bindings are stored. // This is necessary due to the way readable and writable bindings are stored.
// The readable binding in the UI gets converted to a UUID value that the client understands // The readable binding in the UI gets converted to a UUID value that the client understands
// for parsing, then converted back so we can display it the readable form in the UI // for parsing, then converted back so we can display it the readable form in the UI
function onBindingChange(param, valueToParse) { function onBindingChange(param, valueToParse) {
const parsedBindingValue = readableToRuntimeBinding(bindings, valueToParse) customParams[param] = readableToRuntimeBinding(
customParams[param] = parsedBindingValue bindableOptions,
valueToParse
)
} }
</script> </script>
<Layout paddingX="none" gap="S"> <Layout noPadding={bindable} gap="S">
<div class="controls"> <div class="controls" class:height={!bindable}>
<Heading size="XS">Parameters</Heading> <Heading size="XS">Bindings</Heading>
{#if !bindable} {#if !bindable}
<Button secondary on:click={newQueryParameter}>Add Param</Button> <Button secondary on:click={newQueryBinding}>Add Binding</Button>
{/if} {/if}
</div> </div>
<Body size="S"> <Body size="S">
{#if !bindable} {#if !bindable}
Parameters come in two parts: the parameter name, and a default/fallback Bindings come in two parts: the binding name, and a default/fallback
value. value. These bindings can be used as Handlebars expressions throughout the
query.
{:else} {:else}
Enter a value for each parameter. The default values will be used for any Enter a value for each binding. The default values will be used for any
values left blank. values left blank.
{/if} {/if}
</Body> </Body>
<div class="parameters" class:bindable> <div class="bindings" class:bindable>
{#each parameters as parameter, idx} {#each bindings as binding, idx}
<Input <Input
placeholder="Parameter Name" placeholder="Binding Name"
thin thin
disabled={bindable} disabled={bindable}
bind:value={parameter.name} bind:value={binding.name}
/> />
<Input <Input
placeholder="Default" placeholder="Default"
thin thin
disabled={bindable} disabled={bindable}
bind:value={parameter.default} bind:value={binding.default}
/> />
{#if bindable} {#if bindable}
<DrawerBindableInput <DrawerBindableInput
title={`Query parameter "${parameter.name}"`} title={`Query binding "${binding.name}"`}
placeholder="Value" placeholder="Value"
thin thin
on:change={evt => onBindingChange(parameter.name, evt.detail)} on:change={evt => onBindingChange(binding.name, evt.detail)}
value={runtimeToReadableBinding( value={runtimeToReadableBinding(
bindings, bindableOptions,
customParams?.[parameter.name] customParams?.[binding.name]
)} )}
{bindings} {bindableOptions}
/> />
{:else} {:else}
<Icon <Icon hoverable name="Close" on:click={() => deleteQueryBinding(idx)} />
hoverable
name="Close"
on:click={() => deleteQueryParameter(idx)}
/>
{/if} {/if}
{/each} {/each}
</div> </div>
</Layout> </Layout>
<style> <style>
.parameters.bindable { .bindings.bindable {
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
} }
@ -91,13 +90,16 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 40px;
} }
.parameters { .bindings {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 5%; grid-template-columns: 1fr 1fr 5%;
grid-gap: 10px; grid-gap: 10px;
align-items: center; align-items: center;
} }
.height {
height: 40px;
}
</style> </style>

View File

@ -2,29 +2,42 @@
import { Label, Layout, Input } from "@budibase/bbui" import { Label, Layout, Input } from "@budibase/bbui"
import Editor from "./QueryEditor.svelte" import Editor from "./QueryEditor.svelte"
import KeyValueBuilder from "./KeyValueBuilder.svelte" import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { capitalise } from "helpers"
export let fields = {} export let fields = {}
export let schema export let schema
export let editable export let editable
$: schemaKeys = Object.keys(schema.fields) $: schemaKeys = Object.keys(schema?.fields || {})
function updateCustomFields({ detail }) { function updateCustomFields({ detail }) {
fields.customData = detail.value fields.customData = detail.value
} }
function getDisplayName(field) {
let name
if (schema.fields[field]?.display) {
name = schema.fields[field]?.display
} else {
name = field
}
return capitalise(name)
}
</script> </script>
<form on:submit|preventDefault> <form on:submit|preventDefault>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#each schemaKeys as field} {#each schemaKeys as field}
{#if schema.fields[field]?.type === "object"} {#if schema.fields[field]?.type === "object"}
<div> <Label small>{getDisplayName(field)}</Label>
<Label small>{field}</Label> <KeyValueBuilder
<KeyValueBuilder readOnly={!editable} bind:object={fields[field]} /> name={getDisplayName(field)}
</div> readOnly={!editable}
bind:object={fields[field]}
/>
{:else if schema.fields[field]?.type === "json"} {:else if schema.fields[field]?.type === "json"}
<div> <div>
<Label extraSmall grey>{field}</Label> <Label extraSmall grey>{getDisplayName(field)}</Label>
<Editor <Editor
mode="json" mode="json"
on:change={({ detail }) => (fields[field] = detail.value)} on:change={({ detail }) => (fields[field] = detail.value)}
@ -34,9 +47,9 @@
</div> </div>
{:else} {:else}
<div class="horizontal"> <div class="horizontal">
<Label small>{field}</Label> <Label small>{getDisplayName(field)}</Label>
<Input <Input
placeholder="Enter {field}" placeholder="Enter {getDisplayName(field)}"
outline outline
disabled={!editable} disabled={!editable}
type={schema.fields[field]?.type} type={schema.fields[field]?.type}

View File

@ -14,37 +14,27 @@
Tab, Tab,
} from "@budibase/bbui" } from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui" import { notifications, Divider } from "@budibase/bbui"
import api from "builderStore/api"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte" import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte" import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte" import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import { import { datasources, integrations, queries } from "stores/backend"
datasources,
integrations,
queries,
roles,
permissions,
} from "stores/backend"
import { capitalise } from "../../helpers" import { capitalise } from "../../helpers"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte" import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { Roles } from "constants/backend" import JSONPreview from "./JSONPreview.svelte"
import { onMount } from "svelte" import { SchemaTypeOptions } from "constants/backend"
import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte"
export let query export let query
let fields = query.schema ? schemaToFields(query.schema) : [] let fields = query?.schema ? schemaToFields(query.schema) : []
let parameters let parameters
let data = [] let data = []
let roleId let saveId
const transformerDocs = const transformerDocs =
"https://docs.budibase.com/building-apps/data/transformers" "https://docs.budibase.com/building-apps/data/transformers"
const typeOptions = [
{ label: "Text", value: "string" },
{ label: "Number", value: "number" },
{ label: "Boolean", value: "boolean" },
{ label: "Datetime", value: "datetime" },
]
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId) $: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields) $: query.schema = fieldsToSchema(fields)
@ -60,84 +50,37 @@
query.transformer = "return data" query.transformer = "return data"
} }
function newField() {
fields = [...fields, {}]
}
function deleteField(idx) {
fields.splice(idx, 1)
fields = fields
}
function resetDependentFields() { function resetDependentFields() {
if (query.fields.extra) { if (query.fields.extra) {
query.fields.extra = {} query.fields.extra = {}
} }
} }
async function updateRole(role, id = null) {
roleId = role
if (query?._id || id) {
for (let level of ["read", "write"]) {
await permissions.save({
level,
role,
resource: query?._id || id,
})
}
}
}
function populateExtraQuery(extraQueryFields) { function populateExtraQuery(extraQueryFields) {
query.fields.extra = extraQueryFields query.fields.extra = extraQueryFields
} }
async function previewQuery() { async function previewQuery() {
try { try {
const response = await api.post(`/api/queries/preview`, { const response = await queries.preview(query)
fields: query.fields, if (response.rows.length === 0) {
queryVerb: query.queryVerb,
transformer: query.transformer,
parameters: query.parameters.reduce(
(acc, next) => ({
...acc,
[next.name]: next.default,
}),
{}
),
datasourceId: datasource._id,
})
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
data = json.rows || []
if (data.length === 0) {
notifications.info( notifications.info(
"Query results empty. Please execute a query with results to create your schema." "Query results empty. Please execute a query with results to create your schema."
) )
return return
} }
data = response.rows
fields = response.schema
notifications.success("Query executed successfully.") notifications.success("Query executed successfully.")
// Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server
fields = json.schemaFields.map(field => ({
name: field,
type: "string",
}))
} catch (err) { } catch (err) {
notifications.error(`Query Error: ${err.message}`) notifications.error(err)
console.error(err)
} }
} }
async function saveQuery() { async function saveQuery() {
try { try {
const { _id } = await queries.save(query.datasourceId, query) const { _id } = await queries.save(query.datasourceId, query)
await updateRole(roleId, _id) saveId = _id
notifications.success(`Query saved successfully.`) notifications.success(`Query saved successfully.`)
$goto(`../${_id}`) $goto(`../${_id}`)
} catch (err) { } catch (err) {
@ -145,38 +88,6 @@
notifications.error(`Error creating query. ${err.message}`) notifications.error(`Error creating query. ${err.message}`)
} }
} }
function schemaToFields(schema) {
return Object.keys(schema).map(key => ({
name: key,
type: query.schema[key].type,
}))
}
function fieldsToSchema(fieldsToConvert) {
return fieldsToConvert.reduce(
(acc, next) => ({
...acc,
[next.name]: {
name: next.name,
type: next.type,
},
}),
{}
)
}
onMount(async () => {
if (!query || !query._id) {
roleId = Roles.BASIC
return
}
try {
roleId = (await permissions.forResource(query._id))["read"]
} catch (err) {
roleId = Roles.BASIC
}
})
</script> </script>
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
@ -200,14 +111,7 @@
/> />
</div> </div>
<div class="config-field"> <div class="config-field">
<Label>Access level</Label> <AccessLevelSelect {saveId} {query} label="Access Level" />
<Select
value={roleId}
on:change={e => updateRole(e.detail)}
options={$roles}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
</div> </div>
{#if integrationInfo?.extra && query.queryVerb} {#if integrationInfo?.extra && query.queryVerb}
<ExtraQueryConfig <ExtraQueryConfig
@ -216,7 +120,7 @@
config={integrationInfo.extra} config={integrationInfo.extra}
/> />
{/if} {/if}
<ParameterBuilder bind:parameters={query.parameters} bindable={false} /> <BindingBuilder bind:bindings={query.parameters} bindable={false} />
{/if} {/if}
</div> </div>
{#if shouldShowQueryConfig} {#if shouldShowQueryConfig}
@ -271,29 +175,15 @@
{#if data} {#if data}
<Tabs selected="JSON"> <Tabs selected="JSON">
<Tab title="JSON"> <Tab title="JSON">
<pre <JSONPreview data={data[0]} minHeight="120" />
class="preview">
<!-- prettier-ignore -->
{#if !data[0]}
Please run your query to fetch some data.
{:else}
{JSON.stringify(data[0], undefined, 2)}
{/if}
</pre>
</Tab> </Tab>
<Tab title="Schema"> <Tab title="Schema">
<Layout gap="S"> <KeyValueBuilder
{#each fields as field, idx} bind:object={fields}
<div class="field"> name="field"
<Input placeholder="Field Name" bind:value={field.name} /> headings
<Select bind:value={field.type} options={typeOptions} /> options={SchemaTypeOptions}
<Icon name="bleClose" on:click={() => deleteField(idx)} /> />
</div>
{/each}
<div>
<Button secondary on:click={newField}>Add Field</Button>
</div>
</Layout>
</Tab> </Tab>
<Tab title="Preview"> <Tab title="Preview">
<ExternalDataSourceTable {query} {data} /> <ExternalDataSourceTable {query} {data} />
@ -322,29 +212,11 @@
justify-content: space-between; justify-content: space-between;
} }
.field {
display: grid;
grid-template-columns: 1fr 1fr 5%;
gap: var(--spacing-l);
}
.viewer { .viewer {
min-height: 200px; min-height: 200px;
width: 640px; width: 640px;
} }
.preview {
height: 100%;
min-height: 120px;
overflow-y: auto;
overflow-wrap: break-word;
white-space: pre-wrap;
background-color: var(--grey-2);
padding: var(--spacing-m);
border-radius: 8px;
color: var(--ink);
}
.viewer-controls { .viewer-controls {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -1,5 +1,4 @@
<script> <script>
import { gradient } from "actions"
import { import {
Heading, Heading,
Button, Button,
@ -18,14 +17,19 @@
export let deleteApp export let deleteApp
export let unpublishApp export let unpublishApp
export let releaseLock export let releaseLock
export let editIcon
</script> </script>
<div class="title"> <div class="title">
<div class="preview" use:gradient={{ seed: app.name }} /> <div style="display: flex;">
<div class="name" on:click={() => editApp(app)}> <div style="color: {app.icon?.color || ''}">
<Heading size="XS"> <Icon size="XL" name={app.icon?.name || "Apps"} />
{app.name} </div>
</Heading> <div class="name" on:click={() => editApp(app)}>
<Heading size="XS">
{app.name}
</Heading>
</div>
</div> </div>
</div> </div>
<div class="desktop"> <div class="desktop">
@ -62,6 +66,7 @@
disabled={app.lockedOther} disabled={app.lockedOther}
on:click={() => editApp(app)} on:click={() => editApp(app)}
size="S" size="S"
quiet
secondary>Open</Button secondary>Open</Button
> >
<ActionMenu align="right"> <ActionMenu align="right">
@ -86,15 +91,11 @@
<MenuItem on:click={() => updateApp(app)} icon="Edit">Edit</MenuItem> <MenuItem on:click={() => updateApp(app)} icon="Edit">Edit</MenuItem>
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem> <MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem>
{/if} {/if}
<MenuItem on:click={() => editIcon(app)} icon="Brush">Edit Icon</MenuItem>
</ActionMenu> </ActionMenu>
</div> </div>
<style> <style>
.preview {
height: 40px;
width: 40px;
border-radius: var(--border-radius-s);
}
.name { .name {
text-decoration: none; text-decoration: none;
overflow: hidden; overflow: hidden;
@ -103,6 +104,7 @@
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
margin-left: calc(1.5 * var(--spacing-xl));
} }
.title :global(h1:hover) { .title :global(h1:hover) {
color: var(--spectrum-global-color-blue-600); color: var(--spectrum-global-color-blue-600);

View File

@ -0,0 +1,127 @@
<script>
import { ModalContent, Modal, Icon, ColorPicker, Label } from "@budibase/bbui"
import { apps } from "stores/portal"
export let app
let modal
$: selectedIcon = app?.icon?.name
$: selectedColor = app?.icon?.color
let iconsList = [
"Actions",
"ConversionFunnel",
"App",
"Briefcase",
"Money",
"ShoppingCart",
"Form",
"Help",
"Monitoring",
"Sandbox",
"Project",
"Organisations",
"Magnify",
"Launch",
"Car",
"Camera",
"Bug",
"Channel",
"Calculator",
"Calendar",
"GraphDonut",
"GraphBarHorizontal",
"Demographic",
"Apps",
]
export const show = () => {
modal.show()
}
export const hide = () => {
modal.hide()
}
const onCancel = () => {
selectedIcon = ""
selectedColor = ""
hide()
}
const changeColor = val => {
selectedColor = val
}
const save = async () => {
await apps.update(app.instance._id, {
icon: {
name: selectedIcon,
color: selectedColor,
},
})
}
</script>
<Modal bind:this={modal} on:hide={onCancel}>
<ModalContent
title={"Edit Icon"}
confirmText={"Save"}
onConfirm={() => save()}
>
<div class="scrollable-icons">
<div class="title-spacing">
<Label>Select an Icon</Label>
</div>
<div class="grid">
{#each iconsList as item}
<div
class="icon-item"
style="color: {item === selectedIcon ? selectedColor : ''}"
on:click={() => (selectedIcon = item)}
>
<Icon name={item} />
</div>
{/each}
</div>
</div>
<div class="color-selection">
<div>
<Label>Select a Color</Label>
</div>
<div class="color-selection-item">
<ColorPicker
bind:value={selectedColor}
on:change={e => changeColor(e.detail)}
/>
</div>
</div>
</ModalContent>
</Modal>
<style>
.scrollable-icons {
overflow-y: auto;
height: 230px;
}
.grid {
display: grid;
grid-gap: 20px;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
}
.color-selection {
display: flex;
align-items: center;
}
.color-selection-item {
margin-left: 20px;
}
.title-spacing {
margin-bottom: 20px;
}
.icon-item {
cursor: pointer;
}
</style>

View File

@ -1,13 +1,7 @@
<script> <script>
import { writable, get as svelteGet } from "svelte/store" import { writable, get as svelteGet } from "svelte/store"
import {
notifications, import { notifications, Input, ModalContent, Dropzone } from "@budibase/bbui"
Input,
ModalContent,
Dropzone,
Body,
Checkbox,
} from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore" import { store, automationStore, hostingStore } from "builderStore"
import { admin, auth } from "stores/portal" import { admin, auth } from "stores/portal"
import { string, mixed, object } from "yup" import { string, mixed, object } from "yup"
@ -147,16 +141,6 @@
} }
} }
function getModalTitle() {
let title = "Create App"
if (template.fromFile) {
title = "Import App"
} else if (template.key) {
title = "Create app from template"
}
return title
}
async function onCancel() { async function onCancel() {
template = null template = null
await auth.setInitInfo({}) await auth.setInitInfo({})
@ -187,7 +171,7 @@
</ModalContent> </ModalContent>
{:else} {:else}
<ModalContent <ModalContent
title={getModalTitle()} title={"Name your app"}
confirmText={template?.fromFile ? "Import app" : "Create app"} confirmText={template?.fromFile ? "Import app" : "Create app"}
onConfirm={createNewApp} onConfirm={createNewApp}
onCancel={inline ? onCancel : null} onCancel={inline ? onCancel : null}
@ -207,16 +191,14 @@
}} }}
/> />
{/if} {/if}
<Body size="S">
Give your new app a name, and choose which groups have access (paid plans
only).
</Body>
<Input <Input
bind:value={$values.name} bind:value={$values.name}
error={$touched.name && $errors.name} error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)} on:blur={() => ($touched.name = true)}
label="Name" label="Name"
placeholder={$auth.user.firstName
? `${$auth.user.firstName}'s app`
: "My app"}
/> />
<Checkbox label="Group access" disabled value={true} text="All users" />
</ModalContent> </ModalContent>
{/if} {/if}

View File

@ -1,46 +1,10 @@
<script> <script>
import { Heading, Layout, Icon, Body } from "@budibase/bbui" import { Heading, Layout, Icon } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import api from "builderStore/api"
export let onSelect export let onSelect
async function fetchTemplates() {
const response = await api.get("/api/templates?type=app")
return await response.json()
}
let templatesPromise = fetchTemplates()
</script> </script>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
{#await templatesPromise}
<div class="spinner-container">
<Spinner size="30" />
</div>
{:then templates}
{#if templates?.length > 0}
<Body size="M">Select a template below, or start from scratch.</Body>
{:else}
<Body size="M">Start your app from scratch below.</Body>
{/if}
<div class="templates">
{#each templates as template}
<div class="template" on:click={() => onSelect(template)}>
<div
class="background-icon"
style={`background: ${template.background};`}
>
<Icon name={template.icon} />
</div>
<Heading size="XS">{template.name}</Heading>
<p class="detail">{template?.category?.toUpperCase()}</p>
</div>
{/each}
</div>
{:catch err}
<h1 style="color:red">{err}</h1>
{/await}
<div class="template start-from-scratch" on:click={() => onSelect(null)}> <div class="template start-from-scratch" on:click={() => onSelect(null)}>
<div <div
class="background-icon" class="background-icon"
@ -67,15 +31,6 @@
</Layout> </Layout>
<style> <style>
.templates {
display: grid;
width: 100%;
grid-gap: var(--spacing-m);
grid-template-columns: 1fr;
justify-content: start;
margin-top: 15px;
}
.background-icon { .background-icon {
padding: 10px; padding: 10px;
border-radius: 4px; border-radius: 4px;

View File

@ -77,7 +77,7 @@
async function updateApp() { async function updateApp() {
try { try {
// Update App // Update App
await apps.update(app.instance._id, $values.name.trim()) await apps.update(app.instance._id, { name: $values.name.trim() })
hide() hide()
} catch (error) { } catch (error) {
console.error(error) console.error(error)

View File

@ -162,3 +162,58 @@ export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat( export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat(
ALLOWABLE_STRING_TYPES ALLOWABLE_STRING_TYPES
) )
export const IntegrationTypes = {
POSTGRES: "POSTGRES",
MONGODB: "MONGODB",
COUCHDB: "COUCHDB",
S3: "S3",
MYSQL: "MYSQL",
REST: "REST",
DYNAMODB: "DYNAMODB",
ELASTICSEARCH: "ELASTICSEARCH",
SQL_SERVER: "SQL_SERVER",
AIRTABLE: "AIRTABLE",
ARANGODB: "ARANGODB",
ORACLE: "ORACLE",
INTERNAL: "INTERNAL",
}
export const IntegrationNames = {
[IntegrationTypes.POSTGRES]: "PostgreSQL",
[IntegrationTypes.MONGODB]: "MongoDB",
[IntegrationTypes.COUCHDB]: "CouchDB",
[IntegrationTypes.S3]: "S3",
[IntegrationTypes.MYSQL]: "MySQL",
[IntegrationTypes.REST]: "REST",
[IntegrationTypes.DYNAMODB]: "DynamoDB",
[IntegrationTypes.ELASTICSEARCH]: "ElasticSearch",
[IntegrationTypes.SQL_SERVER]: "SQL Server",
[IntegrationTypes.AIRTABLE]: "Airtable",
[IntegrationTypes.ARANGODB]: "ArangoDB",
[IntegrationTypes.ORACLE]: "Oracle",
[IntegrationTypes.INTERNAL]: "Internal",
}
export const SchemaTypeOptions = [
{ label: "Text", value: "string" },
{ label: "Number", value: "number" },
{ label: "Boolean", value: "boolean" },
{ label: "Datetime", value: "datetime" },
]
export const RawRestBodyTypes = {
NONE: "none",
FORM: "form",
ENCODED: "encoded",
JSON: "json",
TEXT: "text",
}
export const RestBodyTypes = [
{ name: "none", value: "none" },
{ name: "form-data", value: "form" },
{ name: "x-www-form-encoded", value: "encoded" },
{ name: "raw (JSON)", value: "json" },
{ name: "raw (Text)", value: "text" },
]

View File

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

View File

@ -0,0 +1,121 @@
import { IntegrationTypes } from "constants/backend"
export function schemaToFields(schema) {
const response = {}
if (schema && typeof schema === "object") {
for (let [field, value] of Object.entries(schema)) {
response[field] = value?.type || "string"
}
}
return response
}
export function fieldsToSchema(fields) {
const response = {}
if (fields && typeof fields === "object") {
for (let [name, type] of Object.entries(fields)) {
response[name] = { name, type }
}
}
return response
}
export function breakQueryString(qs) {
if (!qs) {
return {}
}
if (qs.includes("?")) {
qs = qs.split("?")[1]
}
const params = qs.split("&")
let paramObj = {}
for (let param of params) {
const [key, value] = param.split("=")
paramObj[key] = value
}
return paramObj
}
export function buildQueryString(obj) {
let str = ""
if (obj) {
for (let [key, value] of Object.entries(obj)) {
if (!key || key === "") {
continue
}
if (str !== "") {
str += "&"
}
str += `${key}=${value || ""}`
}
}
return str
}
export function keyValueToQueryParameters(obj) {
let array = []
if (obj && typeof obj === "object") {
for (let [key, value] of Object.entries(obj)) {
array.push({ name: key, default: value })
}
}
return array
}
export function queryParametersToKeyValue(array) {
let obj = {}
if (Array.isArray(array)) {
for (let param of array) {
obj[param.name] = param.default
}
}
return obj
}
export function customQueryIconText(datasource, query) {
if (datasource.source !== IntegrationTypes.REST) {
return
}
switch (query.queryVerb) {
case "create":
return "POST"
case "update":
return "PUT"
case "read":
return "GET"
case "delete":
return "DELETE"
case "patch":
return "PATCH"
}
}
export function customQueryIconColor(datasource, query) {
if (datasource.source !== IntegrationTypes.REST) {
return
}
switch (query.queryVerb) {
case "create":
return "#dcc339"
case "update":
return "#5197ec"
case "read":
return "#53a761"
case "delete":
return "#ea7d82"
case "patch":
default:
return
}
}
export function flipHeaderState(headersActivity) {
if (!headersActivity) {
return {}
}
const enabled = {}
Object.entries(headersActivity).forEach(([key, value]) => {
enabled[key] = !value
})
return enabled
}

View File

@ -19,6 +19,8 @@ export const pipe = (arg, funcs) => flow(funcs)(arg)
export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1) export const capitalise = s => s.substring(0, 1).toUpperCase() + s.substring(1)
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const get_name = s => (!s ? "" : last(s.split("/"))) export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise]) export const get_capitalised_name = name => pipe(name, [get_name, capitalise])

View File

@ -6,4 +6,5 @@ export {
capitalise, capitalise,
get_name, get_name,
get_capitalised_name, get_capitalised_name,
lowercase,
} from "./helpers" } from "./helpers"

View File

@ -1,6 +1,6 @@
<script> <script>
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import { roles } from "stores/backend" import { roles, flags } from "stores/backend"
import { Icon, ActionGroup, Tabs, Tab, notifications } from "@budibase/bbui" import { Icon, ActionGroup, Tabs, Tab, notifications } from "@budibase/bbui"
import DeployModal from "components/deploy/DeployModal.svelte" import DeployModal from "components/deploy/DeployModal.svelte"
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
@ -49,6 +49,7 @@
} }
await automationStore.actions.fetch() await automationStore.actions.fetch()
await roles.fetch() await roles.fetch()
await flags.fetch()
return pkg return pkg
} else { } else {
throw new Error(pkg) throw new Error(pkg)

View File

@ -1,13 +1,23 @@
<script> <script>
import { params } from "@roxi/routify" import { params } from "@roxi/routify"
import { queries } from "stores/backend" import { queries, datasources } from "stores/backend"
import { IntegrationTypes } from "constants/backend"
import { goto } from "@roxi/routify"
let datasourceId
if ($params.query) { if ($params.query) {
const query = $queries.list.find(q => q._id === $params.query) const query = $queries.list.find(q => q._id === $params.query)
if (query) { if (query) {
queries.select(query) queries.select(query)
datasourceId = query.datasourceId
} }
} }
const datasource = $datasources.list.find(
ds => ds._id === $datasources.selected || ds._id === datasourceId
)
if (datasource?.source === IntegrationTypes.REST) {
$goto(`../rest/${$params.query}`)
}
</script> </script>
<slot /> <slot />

View File

@ -1,8 +1,10 @@
<script> <script>
import { params } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { database, queries } from "stores/backend" import { database, datasources, queries } from "stores/backend"
import QueryInterface from "components/integration/QueryViewer.svelte" import QueryInterface from "components/integration/QueryViewer.svelte"
import { IntegrationTypes } from "constants/backend"
let selectedQuery, datasource
$: selectedQuery = $queries.list.find( $: selectedQuery = $queries.list.find(
query => query._id === $queries.selected query => query._id === $queries.selected
) || { ) || {
@ -11,6 +13,14 @@
fields: {}, fields: {},
queryVerb: "read", queryVerb: "read",
} }
$: datasource = $datasources.list.find(
ds => ds._id === $params.selectedDatasource
)
$: {
if (datasource?.source === IntegrationTypes.REST) {
$goto(`../rest/${$params.query}`)
}
}
</script> </script>
<section> <section>

View File

@ -0,0 +1,62 @@
<script>
import { Body } from "@budibase/bbui"
import { RawRestBodyTypes } from "constants/backend"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import CodeMirrorEditor, {
EditorModes,
} from "components/common/CodeMirrorEditor.svelte"
const objectTypes = [RawRestBodyTypes.FORM, RawRestBodyTypes.ENCODED]
const textTypes = [RawRestBodyTypes.JSON, RawRestBodyTypes.TEXT]
export let query
export let bodyType
$: checkRequestBody(bodyType)
function checkRequestBody(type) {
if (!bodyType || !query) {
return
}
const currentType = typeof query?.fields.requestBody
if (objectTypes.includes(type) && currentType !== "object") {
query.fields.requestBody = {}
} else if (textTypes.includes(type) && currentType !== "string") {
query.fields.requestBody = ""
}
}
</script>
<div class="margin">
{#if bodyType === RawRestBodyTypes.NONE}
<div class="none">
<Body size="S" weight="800">THE REQUEST DOES NOT HAVE A BODY</Body>
</div>
{:else if objectTypes.includes(bodyType)}
<KeyValueBuilder
bind:object={query.fields.requestBody}
name="param"
headings
/>
{:else if textTypes.includes(bodyType)}
<CodeMirrorEditor
height={200}
mode={bodyType === RawRestBodyTypes.JSON
? EditorModes.JSON
: EditorModes.Text}
value={query.fields.requestBody}
resize="vertical"
on:change={e => (query.fields.requestBody = e.detail)}
/>
{/if}
</div>
<style>
.margin {
margin-top: var(--spacing-m);
}
.none {
display: flex;
justify-content: center;
}
</style>

View File

@ -6,75 +6,44 @@
Body, Body,
Divider, Divider,
Layout, Layout,
notifications,
Table,
Modal, Modal,
InlineAlert,
ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { datasources, integrations, queries, tables } from "stores/backend" import { datasources, integrations, queries, tables } from "stores/backend"
import { notifications } from "@budibase/bbui"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte" import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte" import RestExtraConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/rest/RestExtraConfigForm.svelte"
import CreateExternalTableModal from "./modals/CreateExternalTableModal.svelte" import PlusConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { capitalise } from "helpers" import CapitaliseRenderer from "components/common/renderers/CapitaliseRenderer.svelte"
import { IntegrationTypes } from "constants/backend"
import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte" import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { onMount } from "svelte"
let importQueriesModal let importQueriesModal
let relationshipModal
let createExternalTableModal
let selectedFromRelationship, selectedToRelationship
$: datasource = $datasources.list.find(ds => ds._id === $datasources.selected) let changed
$: integration = datasource && $integrations[datasource.source] let datasource
$: plusTables = datasource?.plus const querySchema = {
? Object.values(datasource.entities || {}) name: {},
: [] queryVerb: { displayName: "Method" },
$: relationships = getRelationships(plusTables)
$: schemaError = $datasources.schemaError
function getRelationships(tables) {
if (!tables || !Array.isArray(tables)) {
return {}
}
let pairs = {}
for (let table of tables) {
for (let column of Object.values(table.schema)) {
if (column.type !== "link") {
continue
}
// these relationships have an id to pair them to each other
// one has a main for the from side
const key = column.main ? "from" : "to"
pairs[column._id] = {
...pairs[column._id],
[key]: column,
}
}
}
return pairs
} }
function buildRelationshipDisplayString(fromCol, toCol) { $: baseDatasource = $datasources.list.find(
function getTableName(tableId) { ds => ds._id === $datasources.selected
if (!tableId || typeof tableId !== "string") { )
return null $: integration = datasource && $integrations[datasource.source]
} $: queryList = $queries.list.filter(
return plusTables.find(table => table._id === tableId)?.name || "Unknown" query => query.datasourceId === datasource?._id
} )
if (!toCol || !fromCol) { $: hasChanged(baseDatasource, datasource)
return "Cannot build name"
}
const fromTableName = getTableName(toCol.tableId)
const toTableName = getTableName(fromCol.tableId)
const throughTableName = getTableName(fromCol.through)
let displayString function hasChanged(base, ds) {
if (throughTableName) { if (base && ds) {
displayString = `${fromTableName} through ${throughTableName} → ${toTableName}` changed = !isEqual(base, ds)
} else {
displayString = `${fromTableName} → ${toTableName}`
} }
return displayString
} }
async function saveDatasource() { async function saveDatasource() {
@ -86,53 +55,22 @@
} }
await datasources.fetch() await datasources.fetch()
notifications.success(`Datasource ${name} updated successfully.`) notifications.success(`Datasource ${name} updated successfully.`)
baseDatasource = cloneDeep(datasource)
} catch (err) { } catch (err) {
notifications.error(`Error saving datasource: ${err}`) notifications.error(`Error saving datasource: ${err}`)
} }
} }
async function updateDatasourceSchema() {
try {
await datasources.updateSchema(datasource)
notifications.success(`Datasource ${name} tables updated successfully.`)
await tables.fetch()
} catch (err) {
notifications.error(`Error updating datasource schema: ${err}`)
}
}
function onClickQuery(query) { function onClickQuery(query) {
queries.select(query) queries.select(query)
$goto(`./${query._id}`) $goto(`./${query._id}`)
} }
function onClickTable(table) { onMount(() => {
tables.select(table) datasource = cloneDeep(baseDatasource)
$goto(`../../table/${table._id}`) })
}
function openRelationshipModal(fromRelationship, toRelationship) {
selectedFromRelationship = fromRelationship || {}
selectedToRelationship = toRelationship || {}
relationshipModal.show()
}
function createNewTable() {
createExternalTableModal.show()
}
</script> </script>
<Modal bind:this={relationshipModal}>
<CreateEditRelationship
{datasource}
save={saveDatasource}
close={relationshipModal.hide}
{plusTables}
fromRelationship={selectedFromRelationship}
toRelationship={selectedToRelationship}
/>
</Modal>
<Modal bind:this={importQueriesModal}> <Modal bind:this={importQueriesModal}>
{#if datasource.source === "REST"} {#if datasource.source === "REST"}
<ImportRestQueriesModal <ImportRestQueriesModal
@ -142,10 +80,6 @@
{/if} {/if}
</Modal> </Modal>
<Modal bind:this={createExternalTableModal}>
<CreateExternalTableModal {datasource} />
</Modal>
{#if datasource && integration} {#if datasource && integration}
<section> <section>
<Layout> <Layout>
@ -160,121 +94,56 @@
</header> </header>
<Body size="M">{integration.description}</Body> <Body size="M">{integration.description}</Body>
</Layout> </Layout>
<Divider /> <Divider size="S" />
<div class="container"> <div class="config-header">
<div class="config-header"> <Heading size="S">Configuration</Heading>
<Heading size="S">Configuration</Heading> <Button disabled={!changed} cta on:click={saveDatasource}>Save</Button>
<Button secondary on:click={saveDatasource}>Save</Button>
</div>
<Body size="S">
Connect your database to Budibase using the config below.
</Body>
<IntegrationConfigForm
schema={integration.datasource}
integration={datasource.config}
/>
</div> </div>
<IntegrationConfigForm
on:change={hasChanged}
schema={integration.datasource}
bind:datasource
/>
{#if datasource.plus} {#if datasource.plus}
<Divider /> <PlusConfigForm bind:datasource save={saveDatasource} />
<div class="query-header">
<Heading size="S">Tables</Heading>
<div class="table-buttons">
<div>
<ActionButton
size="S"
quiet
icon="DataRefresh"
on:click={updateDatasourceSchema}
>
Fetch tables from database
</ActionButton>
</div>
</div>
</div>
<Body>
This datasource can determine tables automatically. Budibase can fetch
your tables directly from the database and you can use them without
having to write any queries at all.
</Body>
{#if schemaError}
<InlineAlert
type="error"
header="Error fetching tables"
message={schemaError}
onConfirm={datasources.removeSchemaError}
/>
{/if}
<div class="query-list">
{#each plusTables as table}
<div class="query-list-item" on:click={() => onClickTable(table)}>
<p class="query-name">{table.name}</p>
<p>Primary Key: {table.primary}</p>
<p></p>
</div>
{/each}
<div class="add-table">
<Button cta on:click={createNewTable}>Create new table</Button>
</div>
</div>
{#if plusTables?.length !== 0}
<Divider />
<div class="query-header">
<Heading size="S">Relationships</Heading>
<ActionButton
icon="DataCorrelated"
primary
size="S"
quiet
on:click={openRelationshipModal}
>
Define existing relationship
</ActionButton>
</div>
<Body>
Tell budibase how your tables are related to get even more smart
features.
</Body>
{/if}
<div class="query-list">
{#each Object.values(relationships) as relationship}
<div
class="query-list-item"
on:click={() =>
openRelationshipModal(relationship.from, relationship.to)}
>
<p class="query-name">
{buildRelationshipDisplayString(
relationship.from,
relationship.to
)}
</p>
<p>{relationship.from?.name} to {relationship.to?.name}</p>
<p></p>
</div>
{/each}
</div>
{/if} {/if}
<Divider /> <Divider size="S" />
<div class="query-header"> <div class="query-header">
<Heading size="S">Queries</Heading> <Heading size="S">Queries</Heading>
<div class="query-buttons"> <div class="query-buttons">
{#if datasource.source === "REST"} {#if datasource?.source === IntegrationTypes.REST}
<Button secondary on:click={() => importQueriesModal.show()} <Button secondary on:click={() => importQueriesModal.show()}
>Import</Button >Import</Button
> >
{/if} {/if}
<Button secondary on:click={() => $goto("./new")}>Add Query</Button> <Button cta icon="Add" on:click={() => $goto("./new")}
>Add query
</Button>
</div> </div>
</div> </div>
<div class="query-list"> <Body size="S">
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query} To build an app using a datasource, you must first query the data. A
<div class="query-list-item" on:click={() => onClickQuery(query)}> query is a request for data or information from a datasource, for
<p class="query-name">{query.name}</p> example a database table.
<p>{capitalise(query.queryVerb)}</p> </Body>
<p></p> {#if queryList && queryList.length > 0}
</div> <div class="query-list">
{/each} <Table
</div> on:click={({ detail }) => onClickQuery(detail)}
schema={querySchema}
data={queryList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[
{ column: "queryVerb", component: CapitaliseRenderer },
]}
/>
</div>
{/if}
{#if datasource?.source === IntegrationTypes.REST}
<RestExtraConfigForm bind:datasource on:change={hasChanged} />
{/if}
</Layout> </Layout>
</section> </section>
{/if} {/if}
@ -297,18 +166,11 @@
margin: 0 0 var(--spacing-xs) 0; margin: 0 0 var(--spacing-xs) 0;
} }
.container {
width: 100%;
border-radius: var(--border-radius-m);
margin: 0 auto;
}
.query-header { .query-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin: 0 0 var(--spacing-s) 0;
} }
.query-buttons { .query-buttons {
@ -321,48 +183,4 @@
flex-direction: column; flex-direction: column;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.query-list-item {
border-radius: var(--border-radius-m);
background: var(--background);
border: var(--border-dark);
display: grid;
grid-template-columns: 2fr 0.75fr 20px;
align-items: center;
padding-left: var(--spacing-m);
padding-right: var(--spacing-m);
gap: var(--layout-xs);
transition: 200ms background ease;
}
.query-list-item:hover {
background: var(--grey-1);
cursor: pointer;
}
p {
font-size: var(--font-size-xs);
color: var(--grey-8);
}
.query-name {
color: var(--ink);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--font-size-s);
}
.table-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
}
.table-buttons div {
grid-column-end: -1;
}
.add-table {
margin-top: var(--spacing-m);
}
</style> </style>

View File

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

View File

@ -0,0 +1,460 @@
<script>
import { params } from "@roxi/routify"
import { datasources, integrations, queries, flags } from "stores/backend"
import {
Layout,
Input,
Select,
Tabs,
Tab,
Banner,
Divider,
Button,
Heading,
RadioGroup,
Label,
Body,
TextArea,
Table,
notifications,
} from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import EditableLabel from "components/common/inputs/EditableLabel.svelte"
import CodeMirrorEditor, {
EditorModes,
} from "components/common/CodeMirrorEditor.svelte"
import RestBodyInput from "../../_components/RestBodyInput.svelte"
import { capitalise } from "helpers"
import { onMount } from "svelte"
import {
fieldsToSchema,
schemaToFields,
breakQueryString,
buildQueryString,
keyValueToQueryParameters,
queryParametersToKeyValue,
flipHeaderState,
} from "helpers/data/utils"
import {
RestBodyTypes as bodyTypes,
SchemaTypeOptions,
} from "constants/backend"
import JSONPreview from "components/integration/JSONPreview.svelte"
import AccessLevelSelect from "components/integration/AccessLevelSelect.svelte"
import Placeholder from "assets/bb-spaceship.svg"
import { cloneDeep } from "lodash/fp"
let query, datasource
let breakQs = {},
bindings = {}
let url = ""
let saveId
let response, schema, enabledHeaders
let datasourceType, integrationInfo, queryConfig, responseSuccess
let authConfigId
$: datasourceType = datasource?.source
$: integrationInfo = $integrations[datasourceType]
$: queryConfig = integrationInfo?.query
$: url = buildUrl(url, breakQs)
$: checkQueryName(url)
$: responseSuccess =
response?.info?.code >= 200 && response?.info?.code <= 206
$: authConfigs = buildAuthConfigs(datasource)
function getSelectedQuery() {
return cloneDeep(
$queries.list.find(q => q._id === $queries.selected) || {
datasourceId: $params.selectedDatasource,
parameters: [],
fields: {
// only init the objects, everything else is optional strings
disabledHeaders: {},
headers: {},
},
queryVerb: "read",
}
)
}
function checkQueryName(inputUrl = null) {
if (query && (!query.name || query.flags.urlName)) {
query.flags.urlName = true
query.name = url || inputUrl
}
}
function buildUrl(base, qsObj) {
if (!base) {
return base
}
const qs = buildQueryString(qsObj)
let newUrl = base
if (base.includes("?")) {
newUrl = base.split("?")[0]
}
return qs.length > 0 ? `${newUrl}?${qs}` : newUrl
}
const buildAuthConfigs = datasource => {
if (datasource?.config?.authConfigs) {
return datasource.config.authConfigs.map(c => ({
label: c.name,
value: c._id,
}))
}
return []
}
function learnMoreBanner() {
window.open("https://docs.budibase.com/building-apps/data/transformers")
}
function buildQuery() {
const newQuery = { ...query }
const queryString = buildQueryString(breakQs)
newQuery.fields.path = url.split("?")[0]
newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = flipHeaderState(enabledHeaders)
newQuery.schema = fieldsToSchema(schema)
newQuery.parameters = keyValueToQueryParameters(bindings)
return newQuery
}
async function saveQuery() {
const toSave = buildQuery()
try {
const { _id } = await queries.save(toSave.datasourceId, toSave)
saveId = _id
query = getSelectedQuery()
notifications.success(`Request saved successfully.`)
} catch (err) {
notifications.error(`Error creating query. ${err.message}`)
}
}
async function runQuery() {
try {
response = await queries.preview(buildQuery(query))
if (response.rows.length === 0) {
notifications.info("Request did not return any data.")
} else {
response.info = response.info || { code: 200 }
schema = response.schema
notifications.success("Request sent successfully.")
}
} catch (err) {
notifications.error(err)
}
}
const getAuthConfigId = () => {
let id = query.fields.authConfigId
if (id) {
// find the matching config on the datasource
const matchedConfig = datasource?.config?.authConfigs?.filter(
c => c._id === id
)[0]
// clear the id if the config is not found (deleted)
// i.e. just show 'None' in the dropdown
if (!matchedConfig) {
id = undefined
}
}
return id
}
onMount(async () => {
query = getSelectedQuery()
// clear any unsaved changes to the datasource
await datasources.init()
datasource = $datasources.list.find(ds => ds._id === query?.datasourceId)
const datasourceUrl = datasource?.config.url
const qs = query?.fields.queryString
breakQs = breakQueryString(qs)
if (datasourceUrl && !query.fields.path?.startsWith(datasourceUrl)) {
const path = query.fields.path
query.fields.path = `${datasource.config.url}/${path ? path : ""}`
}
url = buildUrl(query.fields.path, breakQs)
schema = schemaToFields(query.schema)
bindings = queryParametersToKeyValue(query.parameters)
authConfigId = getAuthConfigId()
if (!query.fields.disabledHeaders) {
query.fields.disabledHeaders = {}
}
// make sure the disabled headers are set (migration)
for (let header of Object.keys(query.fields.headers)) {
if (!query.fields.disabledHeaders[header]) {
query.fields.disabledHeaders[header] = false
}
}
enabledHeaders = flipHeaderState(query.fields.disabledHeaders)
if (query && !query.transformer) {
query.transformer = "return data"
}
if (query && !query.flags) {
query.flags = {
urlName: false,
}
}
if (query && !query.fields.bodyType) {
query.fields.bodyType = "none"
}
})
</script>
{#if query && queryConfig}
<div class="inner">
<div class="top">
<Layout gap="S">
<div class="top-bar">
<EditableLabel
type="heading"
bind:value={query.name}
defaultValue="Untitled"
on:change={() => (query.flags.urlName = false)}
/>
<div class="access">
<Label>Access level</Label>
<AccessLevelSelect {query} {saveId} />
</div>
</div>
<div class="url-block">
<div class="verb">
<Select
bind:value={query.queryVerb}
on:change={() => {}}
options={Object.keys(queryConfig)}
getOptionLabel={verb =>
queryConfig[verb]?.displayName || capitalise(verb)}
/>
</div>
<div class="url">
<Input bind:value={url} placeholder="http://www.api.com/endpoint" />
</div>
<Button cta disabled={!url} on:click={runQuery}>Send</Button>
</div>
<Tabs selected="Bindings" quiet noPadding noHorizPadding>
<Tab title="Bindings">
<KeyValueBuilder
bind:object={bindings}
tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query"
name="binding"
headings
keyPlaceholder="Binding name"
valuePlaceholder="Default"
/>
</Tab>
<Tab title="Params">
<KeyValueBuilder bind:object={breakQs} name="param" headings />
</Tab>
<Tab title="Headers">
<KeyValueBuilder
bind:object={query.fields.headers}
bind:activity={enabledHeaders}
toggle
name="header"
headings
/>
</Tab>
<Tab title="Body">
<RadioGroup
bind:value={query.fields.bodyType}
options={bodyTypes}
direction="horizontal"
getOptionLabel={option => option.name}
getOptionValue={option => option.value}
/>
<RestBodyInput bind:bodyType={query.fields.bodyType} bind:query />
</Tab>
<Tab title="Transformer">
<Layout noPadding>
{#if !$flags.queryTransformerBanner}
<Banner
extraButtonText="Learn more"
extraButtonAction={learnMoreBanner}
on:change={() =>
flags.updateFlag("queryTransformerBanner", true)}
>
Add a JavaScript function to transform the query result.
</Banner>
{/if}
<CodeMirrorEditor
height={200}
mode={EditorModes.JSON}
value={query.transformer}
resize="vertical"
on:change={e => (query.transformer = e.detail)}
/>
</Layout>
</Tab>
<div class="auth-container">
<div />
<!-- spacer -->
<div class="auth-select">
<Select
label="Auth"
labelPosition="left"
placeholder="None"
bind:value={authConfigId}
options={authConfigs}
/>
</div>
</div>
</Tabs>
</Layout>
</div>
<div class="bottom">
<Layout paddingY="S" gap="S">
<Divider size="S" />
{#if !response && Object.keys(schema).length === 0}
<Heading size="M">Response</Heading>
<div class="placeholder">
<div class="placeholder-internal">
<img alt="placeholder" src={Placeholder} />
<Body size="XS" textAlign="center"
>{"enter a url in the textbox above and click send to get a response".toUpperCase()}</Body
>
</div>
</div>
{:else}
<Tabs
selected={!response ? "Schema" : "JSON"}
quiet
noPadding
noHorizPadding
>
{#if response}
<Tab title="JSON">
<div>
<JSONPreview height="300" data={response.rows[0]} />
</div>
</Tab>
{/if}
{#if schema || response}
<Tab title="Schema">
<KeyValueBuilder
bind:object={schema}
name="schema"
headings
options={SchemaTypeOptions}
/>
</Tab>
{/if}
{#if response}
<Tab title="Raw">
<TextArea disabled value={response.extra?.raw} height="300" />
</Tab>
<Tab title="Headers">
<KeyValueBuilder object={response.extra?.headers} readOnly />
</Tab>
<Tab title="Preview">
<div class="table">
{#if response}
<Table
schema={response?.schema}
data={response?.rows}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
/>
{/if}
</div>
</Tab>
<div class="stats">
<Label size="L">
Status: <span class={responseSuccess ? "green" : "red"}
>{response?.info.code}</span
>
</Label>
<Label size="L">
Time: <span class={responseSuccess ? "green" : "red"}
>{response?.info.time}</span
>
</Label>
<Label size="L">
Size: <span class={responseSuccess ? "green" : "red"}
>{response?.info.size}</span
>
</Label>
<Button disabled={!responseSuccess} cta on:click={saveQuery}
>Save query</Button
>
</div>
{/if}
</Tabs>
{/if}
</Layout>
</div>
</div>
{/if}
<style>
.inner {
width: 960px;
margin: 0 auto;
height: 100%;
}
.table {
width: 960px;
}
.url-block {
display: flex;
gap: var(--spacing-s);
}
.verb {
flex: 1;
}
.url {
flex: 4;
}
.top {
min-height: 50%;
}
.bottom {
padding-bottom: 50px;
}
.stats {
display: flex;
gap: var(--spacing-xl);
margin-left: auto !important;
margin-right: 0;
align-items: center;
}
.green {
color: #53a761;
}
.red {
color: #ea7d82;
}
.top-bar {
display: flex;
justify-content: space-between;
}
.access {
display: flex;
gap: var(--spacing-m);
align-items: center;
}
.placeholder-internal {
display: flex;
flex-direction: column;
width: 200px;
gap: var(--spacing-l);
}
.placeholder {
display: flex;
margin-top: var(--spacing-xl);
justify-content: center;
}
.auth-container {
width: 100%;
display: flex;
justify-content: space-between;
}
.auth-select {
width: 200px;
}
</style>

View File

@ -2,33 +2,33 @@
import { import {
Heading, Heading,
Layout, Layout,
Detail,
Button, Button,
ActionButton,
ActionGroup,
ButtonGroup, ButtonGroup,
Input, Input,
Select, Select,
Modal, Modal,
Page, Page,
notifications, notifications,
Body,
Search, Search,
} from "@budibase/bbui" } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte" import Spinner from "components/common/Spinner.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import ChooseIconModal from "components/start/ChooseIconModal.svelte"
import { store, automationStore } from "builderStore" import { store, automationStore } from "builderStore"
import api, { del, post, get } from "builderStore/api" import api, { del, post, get } from "builderStore/api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, auth, admin } from "stores/portal" import { apps, auth, admin, templates } from "stores/portal"
import download from "downloadjs" import download from "downloadjs"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import AppCard from "components/start/AppCard.svelte"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
let layout = "grid"
let sortBy = "name" let sortBy = "name"
let template let template
let selectedApp let selectedApp
@ -36,13 +36,13 @@
let updatingModal let updatingModal
let deletionModal let deletionModal
let unpublishModal let unpublishModal
let iconModal
let creatingApp = false let creatingApp = false
let loaded = false let loaded = false
let searchTerm = "" let searchTerm = ""
let cloud = $admin.cloud let cloud = $admin.cloud
let appName = "" let appName = ""
let creatingFromTemplate = false let creatingFromTemplate = false
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: enrichedApps = enrichApps($apps, $auth.user, sortBy)
$: filteredApps = enrichedApps.filter(app => $: filteredApps = enrichedApps.filter(app =>
app?.name?.toLowerCase().includes(searchTerm.toLowerCase()) app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
@ -172,6 +172,11 @@
$goto(`../../app/${app.devId}`) $goto(`../../app/${app.devId}`)
} }
const editIcon = app => {
selectedApp = app
iconModal.show()
}
const exportApp = app => { const exportApp = app => {
const id = app.deployed ? app.prodId : app.devId const id = app.deployed ? app.prodId : app.devId
const appName = encodeURIComponent(app.name) const appName = encodeURIComponent(app.name)
@ -262,6 +267,7 @@
onMount(async () => { onMount(async () => {
await apps.load() await apps.load()
await templates.load()
// if the portal is loaded from an external URL with a template param // if the portal is loaded from an external URL with a template param
const initInfo = await auth.getInitInfo() const initInfo = await auth.getInitInfo()
if (initInfo?.init_template) { if (initInfo?.init_template) {
@ -274,21 +280,66 @@
</script> </script>
<Page wide> <Page wide>
{#if loaded && enrichedApps.length} <Layout noPadding>
<Layout noPadding> <div class="title">
<Heading size="S">Welcome to Budibase</Heading>
<ButtonGroup>
{#if cloud}
<Button secondary on:click={initiateAppsExport}>Export apps</Button>
{/if}
<Button icon="Import" quiet secondary on:click={initiateAppImport}
>Import app</Button
>
<Button icon="Add" cta on:click={initiateAppCreation}>Create app</Button
>
</ButtonGroup>
</div>
<div class="title-text">
<Body size="S">Manage your apps and get a head start with templates</Body>
</div>
<Detail>Quick Start Templates</Detail>
<div class="grid">
{#each $templates as item}
<div
on:click={() => {
template = item
creationModal.show()
creatingApp = true
}}
class="template-card"
>
<div class="card-body">
<div style="color: {item.background}" class="iconAlign">
<svg
width="26px"
height="26px"
class="spectrum-Icon"
style="color:{item.background};"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-{item.icon}" />
</svg>
</div>
<div class="iconAlign">
<Body weight="900" size="S">{item.name}</Body>
<div style="font-size: 10px;">
<Body size="S">{item.category.toUpperCase()}</Body>
</div>
</div>
</div>
</div>
{/each}
</div>
{#if loaded && enrichedApps.length}
<div class="title"> <div class="title">
<Heading>Apps</Heading> <Detail>My Apps</Detail>
<ButtonGroup>
{#if cloud}
<Button secondary on:click={initiateAppsExport}>Export apps</Button>
{/if}
<Button secondary on:click={initiateAppImport}>Import app</Button>
<Button cta on:click={initiateAppCreation}>Create app</Button>
</ButtonGroup>
</div> </div>
<div class="filter"> <div class="filter">
<div class="select"> <div class="select">
<Select <Select
quiet
autoWidth autoWidth
bind:value={sortBy} bind:value={sortBy}
placeholder={null} placeholder={null}
@ -299,35 +350,18 @@
]} ]}
/> />
<div class="desktop-search"> <div class="desktop-search">
<Search placeholder="Search" bind:value={searchTerm} /> <Search quiet placeholder="Search" bind:value={searchTerm} />
</div> </div>
</div> </div>
<ActionGroup>
<ActionButton
on:click={() => (layout = "grid")}
selected={layout === "grid"}
quiet
icon="ClassicGridView"
/>
<ActionButton
on:click={() => (layout = "table")}
selected={layout === "table"}
quiet
icon="ViewRow"
/>
</ActionGroup>
</div> </div>
<div class="mobile-search"> <div class="mobile-search">
<Search placeholder="Search" bind:value={searchTerm} /> <Search placeholder="Search" bind:value={searchTerm} />
</div> </div>
<div <div class="appTable">
class:appGrid={layout === "grid"}
class:appTable={layout === "table"}
>
{#each filteredApps as app (app.appId)} {#each filteredApps as app (app.appId)}
<svelte:component <AppRow
this={layout === "grid" ? AppCard : AppRow}
{releaseLock} {releaseLock}
{editIcon}
{app} {app}
{unpublishApp} {unpublishApp}
{viewApp} {viewApp}
@ -338,22 +372,23 @@
/> />
{/each} {/each}
</div> </div>
</Layout> {/if}
{/if} {#if !enrichedApps.length && !creatingApp && loaded}
{#if !enrichedApps.length && !creatingApp && loaded} <div class="empty-wrapper">
<div class="empty-wrapper"> <Modal inline>
<Modal inline> <CreateAppModal {template} inline={true} />
<CreateAppModal {template} inline={true} /> </Modal>
</Modal> </div>
</div> {/if}
{/if} {#if creatingFromTemplate}
{#if creatingFromTemplate} <div class="empty-wrapper">
<div class="empty-wrapper"> <p>Creating your Budibase app from your selected template...</p>
<p>Creating your Budibase app from your selected template...</p> <Spinner size="10" />
<Spinner size="10" /> </div>
</div> {/if}
{/if} </Layout>
</Page> </Page>
<Modal <Modal
bind:this={creationModal} bind:this={creationModal}
padding={false} padding={false}
@ -389,6 +424,7 @@
</ConfirmDialog> </ConfirmDialog>
<UpdateAppModal app={selectedApp} bind:this={updatingModal} /> <UpdateAppModal app={selectedApp} bind:this={updatingModal} />
<ChooseIconModal app={selectedApp} bind:this={iconModal} />
<style> <style>
.title, .title,
@ -397,7 +433,7 @@
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 10px; gap: 5px;
} }
@media only screen and (max-width: 560px) { @media only screen and (max-width: 560px) {
@ -405,12 +441,48 @@
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
} }
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
.iconAlign {
padding: 0 0 0 var(--spacing-m);
display: inline-block;
}
.template-card {
height: 80px;
width: 270px;
border-radius: var(--border-radius-s);
margin-bottom: var(--spacing-m);
border: 1px solid var(--spectrum-global-color-gray-300);
cursor: pointer;
display: flex;
}
.title-text {
margin-top: calc(var(--spacing-xl) * -1);
}
.card-body {
display: flex;
align-items: center;
padding: 12px;
}
.grid {
display: grid;
grid-gap: 5px;
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
}
@media (min-width: 200px) {
} }
.select { .select {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: auto auto;
grid-gap: 10px; grid-gap: 30px;
} }
.filter :global(.spectrum-ActionGroup) { .filter :global(.spectrum-ActionGroup) {
flex-wrap: nowrap; flex-wrap: nowrap;
@ -419,11 +491,6 @@
display: none; display: none;
} }
.appGrid {
display: grid;
grid-gap: 50px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.appTable { .appTable {
display: grid; display: grid;
grid-template-rows: auto; grid-template-rows: auto;
@ -464,4 +531,8 @@
display: block; display: block;
} }
} }
.template-card:hover {
background: var(--spectrum-alias-background-color-tertiary);
}
</style> </style>

View File

@ -0,0 +1,37 @@
import { writable } from "svelte/store"
import api from "builderStore/api"
export function createFlagsStore() {
const { subscribe, set } = writable({})
return {
subscribe,
fetch: async () => {
const { doc, response } = await getFlags()
set(doc)
return response
},
updateFlag: async (flag, value) => {
const response = await api.post("/api/users/flags", {
flag,
value,
})
if (response.status === 200) {
const { doc } = await getFlags()
set(doc)
}
return response
},
}
}
async function getFlags() {
const response = await api.get("/api/users/flags")
let doc = {}
if (response.status === 200) {
doc = await response.json()
}
return { doc, response }
}
export const flags = createFlagsStore()

View File

@ -7,3 +7,4 @@ export { roles } from "./roles"
export { datasources } from "./datasources" export { datasources } from "./datasources"
export { integrations } from "./integrations" export { integrations } from "./integrations"
export { queries } from "./queries" export { queries } from "./queries"
export { flags } from "./flags"

View File

@ -78,6 +78,35 @@ export function createQueriesStore() {
unselect: () => { unselect: () => {
update(state => ({ ...state, selected: null })) update(state => ({ ...state, selected: null }))
}, },
preview: async query => {
const response = await api.post("/api/queries/preview", {
fields: query.fields,
queryVerb: query.queryVerb,
transformer: query.transformer,
parameters: query.parameters.reduce(
(acc, next) => ({
...acc,
[next.name]: next.default,
}),
{}
),
datasourceId: query.datasourceId,
})
if (response.status !== 200) {
const error = await response.text()
throw `Query error: ${error}`
}
const json = await response.json()
// Assume all the fields are strings and create a basic schema from the
// unique fields returned by the server
const schema = {}
for (let field of json.schemaFields) {
schema[field] = "string"
}
return { ...json, schema, rows: json.rows || [] }
},
delete: async query => { delete: async query => {
const response = await api.delete( const response = await api.delete(
`/api/queries/${query._id}/${query._rev}` `/api/queries/${query._id}/${query._rev}`

View File

@ -65,16 +65,17 @@ export function createAppStore() {
} }
} }
async function update(appId, name) { async function update(appId, value) {
const response = await api.put(`/api/applications/${appId}`, { name }) console.log({ value })
const response = await api.put(`/api/applications/${appId}`, { ...value })
if (response.status === 200) { if (response.status === 200) {
store.update(state => { store.update(state => {
const updatedAppIndex = state.findIndex( const updatedAppIndex = state.findIndex(
app => app.instance._id === appId app => app.instance._id === appId
) )
if (updatedAppIndex !== -1) { if (updatedAppIndex !== -1) {
const updatedApp = state[updatedAppIndex] let updatedApp = state[updatedAppIndex]
updatedApp.name = name updatedApp = { ...updatedApp, ...value }
state.apps = state.splice(updatedAppIndex, 1, updatedApp) state.apps = state.splice(updatedAppIndex, 1, updatedApp)
} }
return state return state

View File

@ -5,3 +5,4 @@ export { apps } from "./apps"
export { email } from "./email" export { email } from "./email"
export { auth } from "./auth" export { auth } from "./auth"
export { oidc } from "./oidc" export { oidc } from "./oidc"
export { templates } from "./templates"

View File

@ -0,0 +1,19 @@
import { writable } from "svelte/store"
import api from "builderStore/api"
export function templatesStore() {
const { subscribe, set } = writable([])
async function load() {
const response = await api.get("/api/templates?type=app")
const json = await response.json()
set(json)
}
return {
subscribe,
load,
}
}
export const templates = templatesStore()

View File

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

View File

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

View File

@ -6,6 +6,9 @@ module FetchMock {
return { return {
status, status,
headers: { headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => { get: () => {
return ["application/json"] return ["application/json"]
}, },

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "1.0.15-alpha.1", "version": "1.0.19-alpha.3",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.ts", "main": "src/index.ts",
"repository": { "repository": {
@ -70,9 +70,9 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@apidevtools/swagger-parser": "^10.0.3", "@apidevtools/swagger-parser": "^10.0.3",
"@budibase/auth": "^1.0.15-alpha.1", "@budibase/auth": "^1.0.19-alpha.3",
"@budibase/client": "^1.0.15-alpha.1", "@budibase/client": "^1.0.19-alpha.3",
"@budibase/string-templates": "^1.0.15-alpha.1", "@budibase/string-templates": "^1.0.19-alpha.3",
"@bull-board/api": "^3.7.0", "@bull-board/api": "^3.7.0",
"@bull-board/koa": "^3.7.0", "@bull-board/koa": "^3.7.0",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",

View File

@ -3,7 +3,7 @@ import { queryValidation } from "../validation"
import { generateQueryID } from "../../../../db/utils" import { generateQueryID } from "../../../../db/utils"
import { ImportInfo, ImportSource } from "./sources/base" import { ImportInfo, ImportSource } from "./sources/base"
import { OpenAPI2 } from "./sources/openapi2" import { OpenAPI2 } from "./sources/openapi2"
import { Query } from './../../../../definitions/common'; import { Query } from "./../../../../definitions/common"
import { Curl } from "./sources/curl" import { Curl } from "./sources/curl"
interface ImportResult { interface ImportResult {
errorQueries: Query[] errorQueries: Query[]

View File

@ -1,5 +1,4 @@
import { Query, QueryParameter } from "../../../../../../definitions/datasource"
import { Query, QueryParameter } from "../../../../../../definitions/common"
export interface ImportInfo { export interface ImportInfo {
url: string url: string

View File

@ -29,7 +29,7 @@ const parseBody = (curl: any) => {
} }
const parseCookie = (curl: any) => { const parseCookie = (curl: any) => {
if (curl.cookies){ if (curl.cookies) {
return Object.entries(curl.cookies).reduce((acc, entry) => { return Object.entries(curl.cookies).reduce((acc, entry) => {
const [key, value] = entry const [key, value] = entry
return acc + `${key}=${value}; ` return acc + `${key}=${value}; `

View File

@ -1,5 +1,5 @@
import { ImportInfo } from "./base" import { ImportInfo } from "./base"
import { Query, QueryParameter } from "../../../../../definitions/common" import { Query, QueryParameter } from "../../../../../definitions/datasource"
import { OpenAPIV2 } from "openapi-types" import { OpenAPIV2 } from "openapi-types"
import { OpenAPISource } from "./base/openapi" import { OpenAPISource } from "./base/openapi"

View File

@ -123,7 +123,7 @@ async function enrichQueryFields(fields, parameters = {}) {
enrichedQuery.requestBody enrichedQuery.requestBody
) )
} catch (err) { } catch (err) {
throw { message: `JSON Invalid - error: ${err}` } // no json found, ignore
} }
delete enrichedQuery.customData delete enrichedQuery.customData
} }
@ -151,7 +151,7 @@ exports.preview = async function (ctx) {
const enrichedQuery = await enrichQueryFields(fields, parameters) const enrichedQuery = await enrichQueryFields(fields, parameters)
try { try {
const { rows, keys } = await Runner.run({ const { rows, keys, info, extra } = await Runner.run({
datasource, datasource,
queryVerb, queryVerb,
query: enrichedQuery, query: enrichedQuery,
@ -161,6 +161,8 @@ exports.preview = async function (ctx) {
ctx.body = { ctx.body = {
rows, rows,
schemaFields: [...new Set(keys)], schemaFields: [...new Set(keys)],
info,
extra,
} }
} catch (err) { } catch (err) {
ctx.throw(400, err) ctx.throw(400, err)

View File

@ -19,6 +19,7 @@ exports.queryValidation = () => {
extra: Joi.object().optional(), extra: Joi.object().optional(),
schema: Joi.object({}).required().unknown(true), schema: Joi.object({}).required().unknown(true),
transformer: Joi.string().optional(), transformer: Joi.string().optional(),
flags: Joi.object().optional(),
}) })
} }

View File

@ -2,6 +2,7 @@ const CouchDB = require("../../db")
const { const {
generateUserMetadataID, generateUserMetadataID,
getUserMetadataParams, getUserMetadataParams,
generateUserFlagID,
} = require("../../db/utils") } = require("../../db/utils")
const { InternalTables } = require("../../db/utils") const { InternalTables } = require("../../db/utils")
const { getGlobalUsers, getRawGlobalUser } = require("../../utilities/global") const { getGlobalUsers, getRawGlobalUser } = require("../../utilities/global")
@ -195,3 +196,35 @@ exports.destroyMetadata = async function (ctx) {
exports.findMetadata = async function (ctx) { exports.findMetadata = async function (ctx) {
ctx.body = await getFullUser(ctx, ctx.params.id) ctx.body = await getFullUser(ctx, ctx.params.id)
} }
exports.setFlag = async function (ctx) {
const userId = ctx.user._id
const { flag, value } = ctx.request.body
if (!flag) {
ctx.throw(400, "Must supply a 'flag' field in request body.")
}
const flagDocId = generateUserFlagID(userId)
const db = new CouchDB(ctx.appId)
let doc
try {
doc = await db.get(flagDocId)
} catch (err) {
doc = { _id: flagDocId }
}
doc[flag] = value || true
await db.put(doc)
ctx.body = { message: "Flag set successfully" }
}
exports.getFlags = async function (ctx) {
const userId = ctx.user._id
const docId = generateUserFlagID(userId)
const db = new CouchDB(ctx.appId)
let doc
try {
doc = await db.get(docId)
} catch (err) {
doc = { _id: docId }
}
ctx.body = doc
}

View File

@ -39,5 +39,15 @@ router
authorized(PermissionTypes.USER, PermissionLevels.WRITE), authorized(PermissionTypes.USER, PermissionLevels.WRITE),
controller.syncUser controller.syncUser
) )
.post(
"/api/users/flags",
authorized(PermissionTypes.USER, PermissionLevels.WRITE),
controller.setFlag
)
.get(
"/api/users/flags",
authorized(PermissionTypes.USER, PermissionLevels.READ),
controller.getFlags
)
module.exports = router module.exports = router

View File

@ -40,6 +40,7 @@ const DocumentTypes = {
DEPLOYMENTS: "deployments", DEPLOYMENTS: "deployments",
METADATA: "metadata", METADATA: "metadata",
MEM_VIEW: "view", MEM_VIEW: "view",
USER_FLAG: "flag",
} }
const ViewNames = { const ViewNames = {
@ -339,6 +340,14 @@ exports.getQueryParams = (datasourceId = null, otherProps = {}) => {
) )
} }
/**
* Generates a new flag document ID.
* @returns {string} The ID of the flag document that was generated.
*/
exports.generateUserFlagID = userId => {
return `${DocumentTypes.USER_FLAG}${SEPARATOR}${userId}`
}
exports.generateMetadataID = (type, entityId) => { exports.generateMetadataID = (type, entityId) => {
return `${DocumentTypes.METADATA}${SEPARATOR}${type}${SEPARATOR}${entityId}` return `${DocumentTypes.METADATA}${SEPARATOR}${type}${SEPARATOR}${entityId}`
} }

View File

@ -1,6 +1,6 @@
import { SourceNames } from "./datasource" export { Query, Datasource } from "./datasource"
interface Base { export interface Base {
_id?: string _id?: string
_rev?: string _rev?: string
} }
@ -93,39 +93,3 @@ export interface Automation extends Base {
trigger?: AutomationStep trigger?: AutomationStep
} }
} }
export interface Datasource extends Base {
type: string
name: string
source: SourceNames
// the config is defined by the schema
config: {
[key: string]: string | number | boolean
}
plus: boolean
entities?: {
[key: string]: Table
}
}
export interface QueryParameter {
name: string
default: string
}
export interface Query {
_id?: string
datasourceId: string
name: string
parameters: QueryParameter[]
fields: {
headers: object
queryString: string | null
path: string
requestBody: string | undefined
}
transformer: string | null
schema: any
readable: boolean
queryVerb: string
}

View File

@ -1,4 +1,4 @@
import { Row, Table } from "./common" import { Row, Table, Base } from "./common"
export enum Operation { export enum Operation {
CREATE = "CREATE", CREATE = "CREATE",
@ -181,3 +181,75 @@ export interface SqlQuery {
export interface QueryOptions { export interface QueryOptions {
disableReturning?: boolean disableReturning?: boolean
} }
export interface Datasource extends Base {
type: string
name: string
source: SourceNames
// the config is defined by the schema
config: {
[key: string]: string | number | boolean
}
plus: boolean
entities?: {
[key: string]: Table
}
}
export enum AuthType {
BASIC = "basic",
BEARER = "bearer",
}
interface AuthConfig {
_id: string
name: string
type: AuthType
config: BasicAuthConfig | BearerAuthConfig
}
export interface BasicAuthConfig {
username: string
password: string
}
export interface BearerAuthConfig {
token: string
}
export interface QueryParameter {
name: string
default: string
}
export interface RestQueryFields {
path: string
queryString?: string
headers: { [key: string]: any }
disabledHeaders: { [key: string]: any }
requestBody: any
bodyType: string
json: object
method: string
authConfigId: string
}
export interface RestConfig {
url: string
defaultHeaders: {
[key: string]: any
}
authConfigs: AuthConfig[]
}
export interface Query {
_id?: string
datasourceId: string
name: string
parameters: QueryParameter[]
fields: RestQueryFields | any
transformer: string | null
schema: any
readable: boolean
queryVerb: string
}

View File

@ -1,6 +1,6 @@
export interface IntegrationBase { export interface IntegrationBase {
create?(query: any): Promise<any[]> create?(query: any): Promise<any[] | any>
read?(query: any): Promise<any[]> read?(query: any): Promise<any[] | any>
update?(query: any): Promise<any[]> update?(query: any): Promise<any[] | any>
delete?(query: any): Promise<any[]> delete?(query: any): Promise<any[] | any>
} }

View File

@ -2,29 +2,61 @@ import {
Integration, Integration,
DatasourceFieldTypes, DatasourceFieldTypes,
QueryTypes, QueryTypes,
RestConfig,
RestQueryFields as RestQuery,
AuthType,
BasicAuthConfig,
BearerAuthConfig
} from "../definitions/datasource" } from "../definitions/datasource"
import { IntegrationBase } from "./base/IntegrationBase" import { IntegrationBase } from "./base/IntegrationBase"
const BodyTypes = {
NONE: "none",
FORM_DATA: "form",
ENCODED: "encoded",
JSON: "json",
TEXT: "text",
}
const coreFields = {
path: {
type: DatasourceFieldTypes.STRING,
display: "URL",
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
enabledHeaders: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
bodyType: {
type: DatasourceFieldTypes.STRING,
enum: Object.values(BodyTypes),
},
}
module RestModule { module RestModule {
const fetch = require("node-fetch") const fetch = require("node-fetch")
const { formatBytes } = require("../utilities")
interface RestConfig { const { performance } = require("perf_hooks")
url: string
defaultHeaders: {
[key: string]: any
}
}
const SCHEMA: Integration = { const SCHEMA: Integration = {
docs: "https://github.com/node-fetch/node-fetch", docs: "https://github.com/node-fetch/node-fetch",
description: description:
"Representational state transfer (REST) is a de-facto standard for a software architecture for interactive applications that typically use multiple Web services. ", "With the REST API datasource, you can connect, query and pull data from multiple REST APIs. You can then use the retrieved data to build apps.",
friendlyName: "REST API", friendlyName: "REST API",
datasource: { datasource: {
url: { url: {
type: DatasourceFieldTypes.STRING, type: DatasourceFieldTypes.STRING,
default: "localhost", default: "",
required: true, required: false,
deprecated: true,
}, },
defaultHeaders: { defaultHeaders: {
type: DatasourceFieldTypes.OBJECT, type: DatasourceFieldTypes.OBJECT,
@ -37,97 +69,30 @@ module RestModule {
readable: true, readable: true,
displayName: "POST", displayName: "POST",
type: QueryTypes.FIELDS, type: QueryTypes.FIELDS,
urlDisplay: true, fields: coreFields,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
}, },
read: { read: {
displayName: "GET", displayName: "GET",
readable: true, readable: true,
type: QueryTypes.FIELDS, type: QueryTypes.FIELDS,
urlDisplay: true, fields: coreFields,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
},
}, },
update: { update: {
displayName: "PUT", displayName: "PUT",
readable: true, readable: true,
type: QueryTypes.FIELDS, type: QueryTypes.FIELDS,
urlDisplay: true, fields: coreFields,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
}, },
patch: { patch: {
displayName: "PATCH", displayName: "PATCH",
readable: true, readable: true,
type: QueryTypes.FIELDS, type: QueryTypes.FIELDS,
urlDisplay: true, fields: coreFields,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
}, },
delete: { delete: {
displayName: "DELETE", displayName: "DELETE",
type: QueryTypes.FIELDS, type: QueryTypes.FIELDS,
urlDisplay: true, fields: coreFields,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
}, },
}, },
} }
@ -137,99 +102,145 @@ module RestModule {
private headers: { private headers: {
[key: string]: string [key: string]: string
} = {} } = {}
private startTimeMs: number = performance.now()
constructor(config: RestConfig) { constructor(config: RestConfig) {
this.config = config this.config = config
} }
async parseResponse(response: any) { async parseResponse(response: any) {
let data, raw, headers
const contentType = response.headers.get("content-type") const contentType = response.headers.get("content-type")
if (contentType && contentType.indexOf("application/json") !== -1) { if (contentType && contentType.indexOf("application/json") !== -1) {
return await response.json() data = await response.json()
raw = JSON.stringify(data)
} else { } else {
return await response.text() data = await response.text()
raw = data
}
const size = formatBytes(response.headers.get("content-length") || Buffer.byteLength(raw, "utf8"))
const time = `${Math.round(performance.now() - this.startTimeMs)}ms`
headers = response.headers.raw()
for (let [key, value] of Object.entries(headers)) {
headers[key] = Array.isArray(value) ? value[0] : value
}
return {
data,
info: {
code: response.status,
size,
time,
},
extra: {
raw,
headers,
},
} }
} }
getUrl(path: string, queryString: string): string { getUrl(path: string, queryString: string): string {
return `${this.config.url}/${path}?${queryString}` const main = `${path}?${queryString}`
let complete = main
if (this.config.url && !main.startsWith(this.config.url)) {
complete = !this.config.url ? main : `${this.config.url}/${main}`
}
if (!complete.startsWith("http")) {
complete = `http://${complete}`
}
return complete
} }
async create({ path = "", queryString = "", headers = {}, json = {} }) { getAuthHeaders(authConfigId: string): { [key: string]: any }{
let headers: any = {}
if (this.config.authConfigs && authConfigId) {
const authConfig = this.config.authConfigs.filter(
c => c._id === authConfigId
)[0]
// check the config still exists before proceeding
// if not - do nothing
if (authConfig) {
let config
switch (authConfig.type) {
case AuthType.BASIC:
config = authConfig.config as BasicAuthConfig
headers.Authorization = `Basic ${Buffer.from(
`${config.username}:${config.password}`
).toString("base64")}`
break
case AuthType.BEARER:
config = authConfig.config as BearerAuthConfig
headers.Authorization = `Bearer ${config.token}`
break
}
}
}
return headers
}
async _req(query: RestQuery) {
const { path = "", queryString = "", headers = {}, method = "GET", disabledHeaders, bodyType, requestBody, authConfigId } = query
const authHeaders = this.getAuthHeaders(authConfigId)
this.headers = { this.headers = {
...this.config.defaultHeaders, ...this.config.defaultHeaders,
...headers, ...headers,
...authHeaders,
} }
const response = await fetch(this.getUrl(path, queryString), { if (disabledHeaders) {
method: "POST", for (let headerKey of Object.keys(this.headers)) {
headers: this.headers, if (disabledHeaders[headerKey]) {
body: JSON.stringify(json), delete this.headers[headerKey]
}) }
}
}
let json
if (bodyType === BodyTypes.JSON && requestBody) {
try {
json = JSON.parse(requestBody)
} catch (err) {
throw "Invalid JSON for request body"
}
}
const input: any = { method, headers: this.headers }
if (json && typeof json === "object" && Object.keys(json).length > 0) {
input.body = JSON.stringify(json)
}
this.startTimeMs = performance.now()
const url = this.getUrl(path, queryString)
const response = await fetch(url, input)
return await this.parseResponse(response) return await this.parseResponse(response)
} }
async read({ path = "", queryString = "", headers = {} }) { async create(opts: RestQuery) {
this.headers = { return this._req({ ...opts, method: "POST" })
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.getUrl(path, queryString), {
headers: this.headers,
})
return await this.parseResponse(response)
} }
async update({ path = "", queryString = "", headers = {}, json = {} }) { async read(opts: RestQuery) {
this.headers = { return this._req({ ...opts, method: "GET" })
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.getUrl(path, queryString), {
method: "PUT",
headers: this.headers,
body: JSON.stringify(json),
})
return await this.parseResponse(response)
} }
async patch({ path = "", queryString = "", headers = {}, json = {} }) { async update(opts: RestQuery) {
this.headers = { return this._req({ ...opts, method: "PUT" })
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.getUrl(path, queryString), {
method: "PATCH",
headers: this.headers,
body: JSON.stringify(json),
})
return await this.parseResponse(response)
} }
async delete({ path = "", queryString = "", headers = {} }) { async patch(opts: RestQuery) {
this.headers = { return this._req({ ...opts, method: "PATCH" })
...this.config.defaultHeaders, }
...headers,
}
const response = await fetch(this.getUrl(path, queryString), { async delete(opts: RestQuery) {
method: "DELETE", return this._req({ ...opts, method: "DELETE" })
headers: this.headers,
})
return await this.parseResponse(response)
} }
} }
module.exports = { module.exports = {
schema: SCHEMA, schema: SCHEMA,
integration: RestIntegration, integration: RestIntegration,
AuthType,
} }
} }

View File

@ -1,6 +1,9 @@
jest.mock("node-fetch", () => jest.mock("node-fetch", () =>
jest.fn(() => ({ jest.fn(() => ({
headers: { headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => ["application/json"] get: () => ["application/json"]
}, },
json: jest.fn(), json: jest.fn(),
@ -9,6 +12,7 @@ jest.mock("node-fetch", () =>
) )
const fetch = require("node-fetch") const fetch = require("node-fetch")
const RestIntegration = require("../rest") const RestIntegration = require("../rest")
const { AuthType } = require("../rest")
class TestConfiguration { class TestConfiguration {
constructor(config = {}) { constructor(config = {}) {
@ -24,6 +28,7 @@ describe("REST Integration", () => {
config = new TestConfiguration({ config = new TestConfiguration({
url: BASE_URL, url: BASE_URL,
}) })
jest.clearAllMocks()
}) })
it("calls the create method with the correct params", async () => { it("calls the create method with the correct params", async () => {
@ -33,9 +38,10 @@ describe("REST Integration", () => {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
json: { bodyType: "json",
requestBody: JSON.stringify({
name: "test", name: "test",
}, }),
} }
const response = await config.integration.create(query) const response = await config.integration.create(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, {
@ -60,6 +66,7 @@ describe("REST Integration", () => {
headers: { headers: {
Accept: "text/html", Accept: "text/html",
}, },
method: "GET",
}) })
}) })
@ -70,13 +77,14 @@ describe("REST Integration", () => {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
json: { bodyType: "json",
requestBody: JSON.stringify({
name: "test", name: "test",
}, }),
} }
const response = await config.integration.update(query) const response = await config.integration.update(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, {
method: "POST", method: "PUT",
body: '{"name":"test"}', body: '{"name":"test"}',
headers: { headers: {
Accept: "application/json", Accept: "application/json",
@ -91,9 +99,10 @@ describe("REST Integration", () => {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
json: { bodyType: "json",
requestBody: JSON.stringify({
name: "test", name: "test",
}, }),
} }
const response = await config.integration.delete(query) const response = await config.integration.delete(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, { expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, {
@ -101,6 +110,61 @@ describe("REST Integration", () => {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
}, },
body: '{"name":"test"}',
})
})
describe("authentication", () => {
const basicAuth = {
_id: "c59c14bd1898a43baa08da68959b24686",
name: "basic-1",
type : AuthType.BASIC,
config : {
username: "user",
password: "password"
}
}
const bearerAuth = {
_id: "0d91d732f34e4befabeff50b392a8ff3",
name: "bearer-1",
type : AuthType.BEARER,
config : {
"token": "mytoken"
}
}
beforeEach(() => {
config = new TestConfiguration({
url: BASE_URL,
authConfigs : [basicAuth, bearerAuth]
})
})
it("adds basic auth", async () => {
const query = {
authConfigId: "c59c14bd1898a43baa08da68959b24686"
}
await config.integration.read(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/?`, {
method: "GET",
headers: {
Authorization: "Basic dXNlcjpwYXNzd29yZA=="
},
})
})
it("adds bearer auth", async () => {
const query = {
authConfigId: "0d91d732f34e4befabeff50b392a8ff3"
}
await config.integration.read(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/?`, {
method: "GET",
headers: {
Authorization: "Bearer mytoken"
},
})
}) })
}) })
}) })

View File

@ -15,6 +15,15 @@ function formatResponse(resp) {
return resp return resp
} }
function hasExtraData(response) {
return (
typeof response === "object" &&
!Array.isArray(response) &&
response.data != null &&
response.info != null
)
}
async function runAndTransform(datasource, queryVerb, query, transformer) { async function runAndTransform(datasource, queryVerb, query, transformer) {
const Integration = integrations[datasource.source] const Integration = integrations[datasource.source]
if (!Integration) { if (!Integration) {
@ -22,7 +31,15 @@ async function runAndTransform(datasource, queryVerb, query, transformer) {
} }
const integration = new Integration(datasource.config) const integration = new Integration(datasource.config)
let rows = formatResponse(await integration[queryVerb](query)) let output = formatResponse(await integration[queryVerb](query))
let rows = output,
info = undefined,
extra = undefined
if (hasExtraData(output)) {
rows = output.data
info = output.info
extra = output.extra
}
// transform as required // transform as required
if (transformer) { if (transformer) {
@ -47,7 +64,7 @@ async function runAndTransform(datasource, queryVerb, query, transformer) {
integration.end() integration.end()
} }
return { rows, keys } return { rows, keys, info, extra }
} }
module.exports = (input, callback) => { module.exports = (input, callback) => {

View File

@ -150,3 +150,14 @@ exports.doesDatabaseExist = async dbName => {
return false return false
} }
} }
exports.formatBytes = bytes => {
const units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
const byteIncrements = 1024
let unit = 0
let size = parseInt(bytes, 10) || 0
while (size >= byteIncrements && ++unit) {
size /= byteIncrements
}
return `${size.toFixed(size < 10 && unit > 0 ? 1 : 0)}${units[unit]}`
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/string-templates", "name": "@budibase/string-templates",
"version": "1.0.15-alpha.1", "version": "1.0.19-alpha.3",
"description": "Handlebars wrapper for Budibase templating.", "description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs", "main": "src/index.cjs",
"module": "dist/bundle.mjs", "module": "dist/bundle.mjs",

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/worker", "name": "@budibase/worker",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "1.0.15-alpha.1", "version": "1.0.19-alpha.3",
"description": "Budibase background service", "description": "Budibase background service",
"main": "src/index.js", "main": "src/index.js",
"repository": { "repository": {
@ -29,8 +29,8 @@
"author": "Budibase", "author": "Budibase",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@budibase/auth": "^1.0.15-alpha.1", "@budibase/auth": "^1.0.19-alpha.3",
"@budibase/string-templates": "^1.0.15-alpha.1", "@budibase/string-templates": "^1.0.19-alpha.3",
"@koa/router": "^8.0.0", "@koa/router": "^8.0.0",
"@sentry/node": "^6.0.0", "@sentry/node": "^6.0.0",
"@techpass/passport-openidconnect": "^0.3.0", "@techpass/passport-openidconnect": "^0.3.0",

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