Merge branch 'develop' of github.com:Budibase/budibase into develop

This commit is contained in:
Martin McKeaveney 2021-12-13 18:36:03 +01:00
commit 650480e3a6
76 changed files with 2226 additions and 785 deletions

View File

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

View File

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

View File

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

View File

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

@ -25,7 +25,9 @@
class="spectrum-Switch-input"
/>
<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>
<style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,9 +6,13 @@
export let selected
export let vertical = 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 emphasized = false
let thisSelected = undefined
let _id = id()
const tab = writable({ title: selected, id: _id, emphasized })
setContext("tab", tab)
@ -18,9 +22,19 @@
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
dispatch("select", selected)
dispatch("select", thisSelected)
}
if ($tab.title !== thisSelected) {
tab.update(state => {
state.title = thisSelected
return state
})
}
}
@ -59,6 +73,7 @@
<div
bind:this={container}
class:quiet
class:noHorizPadding
class="selected-border spectrum-Tabs {quiet &&
'spectrum-Tabs--quiet'} spectrum-Tabs--{vertical
? 'vertical'
@ -99,6 +114,9 @@
.spectrum-Tabs--horizontal .spectrum-Tabs-selectionIndicator {
bottom: 0 !important;
}
.noHorizPadding {
padding: 0;
}
.noPadding {
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 ColorPicker } from "./ColorPicker/ColorPicker.svelte"
export { default as InlineAlert } from "./InlineAlert/InlineAlert.svelte"
export { default as Banner } from "./Banner/Banner.svelte"
// Typography
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

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "1.0.18",
"version": "1.0.19-alpha.0",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -65,10 +65,10 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.0.18",
"@budibase/client": "^1.0.18",
"@budibase/bbui": "^1.0.19-alpha.0",
"@budibase/client": "^1.0.19-alpha.0",
"@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^1.0.18",
"@budibase/string-templates": "^1.0.19-alpha.0",
"@sentry/browser": "5.19.1",
"@spectrum-css/page": "^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
$: {
UNSORTABLE_TYPES.forEach(type => {
Object.values(schema).forEach(col => {
Object.values(schema || {}).forEach(col => {
if (col.type === type) {
col.sortable = false
}
@ -113,16 +113,16 @@
<Layout noPadding gap="S">
<div>
<div class="table-title">
{#if title}
{#if title}
<div class="table-title">
<Heading size="S">{title}</Heading>
{/if}
{#if loading}
<div transition:fade|local>
<Spinner size="10" />
</div>
{/if}
</div>
{#if loading}
<div transition:fade|local>
<Spinner size="10" />
</div>
{/if}
</div>
{/if}
<div class="popovers">
<slot />
{#if !isUsersTable && selectedRows.length > 0}

View File

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

View File

@ -9,19 +9,53 @@
} from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { capitalise } from "helpers"
import { IntegrationTypes } from "constants/backend"
export let integration
export let datasource
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
function getDisplayName(key) {
let name
if (schema[key]?.display) {
name = schema[key].display
} else {
name = key
}
return capitalise(name)
}
</script>
<form>
<Layout gap="S">
{#each Object.keys(schema) as configKey}
<Layout noPadding gap="S">
{#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"}
<div class="form-row ssl">
<Label>{capitalise(configKey)}</Label>
<Label>{getDisplayName(configKey)}</Label>
<Button secondary thin outline on:click={addButton.addEntry()}
>Add</Button
>
@ -29,30 +63,31 @@
<KeyValueBuilder
bind:this={addButton}
defaults={schema[configKey].default}
bind:object={integration[configKey]}
bind:object={config[configKey]}
on:change
noAddButton={true}
/>
{:else if schema[configKey].type === "boolean"}
<div class="form-row">
<Label>{capitalise(configKey)}</Label>
<Toggle text="" bind:value={integration[configKey]} />
<Label>{getDisplayName(configKey)}</Label>
<Toggle text="" bind:value={config[configKey]} />
</div>
{:else if schema[configKey].type === "longForm"}
<div class="form-row">
<Label>{capitalise(configKey)}</Label>
<Label>{getDisplayName(configKey)}</Label>
<TextArea
type={schema[configKey].type}
on:change
bind:value={integration[configKey]}
bind:value={config[configKey]}
/>
</div>
{:else}
<div class="form-row">
<Label>{capitalise(configKey)}</Label>
<Label>{getDisplayName(configKey)}</Label>
<Input
type={schema[configKey].type}
on:change
bind:value={integration[configKey]}
bind:value={config[configKey]}
/>
</div>
{/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/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,45 @@
<script>
import { Divider, Heading, ActionButton, Badge, Body } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
export let datasource
let addHeader
</script>
<Divider size="S" />
<div class="query-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>
<style>
.query-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.badge {
display: flex;
gap: var(--spacing-m);
}
</style>

View File

@ -3,9 +3,11 @@
import { onMount } from "svelte"
import ICONS from "../icons"
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 DatasourceConfigModal from "components/backend/DatasourceNavigator/modals/DatasourceConfigModal.svelte"
import { createRestDatasource } from "builderStore/datasource"
import { goto } from "@roxi/routify"
import ImportRestQueriesModal from "./ImportRestQueriesModal.svelte"
export let modal
@ -19,8 +21,6 @@
checkShowImport()
const INTERNAL = "BUDIBASE"
onMount(() => {
fetchIntegrations()
})
@ -50,10 +50,14 @@
importModal.show()
}
function chooseNextModal() {
if (integration.type === INTERNAL) {
async function chooseNextModal() {
if (integration.type === IntegrationTypes.INTERNAL) {
externalDatasourceModal.hide()
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 {
externalDatasourceModal.show()
}
@ -63,7 +67,7 @@
const response = await api.get("/api/integrations")
const json = await response.json()
integrations = {
[INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
[IntegrationTypes.INTERNAL]: { datasource: {}, name: "INTERNAL/CSV" },
...json,
}
return json
@ -108,8 +112,8 @@
to your app using Budibase's built-in database.
</Body>
<div
class:selected={integration.type === INTERNAL}
on:click={() => selectIntegration(INTERNAL)}
class:selected={integration.type === IntegrationTypes.INTERNAL}
on:click={() => selectIntegration(IntegrationTypes.INTERNAL)}
class="item hoverable"
>
<div class="item-body">
@ -124,7 +128,7 @@
<Detail size="S">Connect to data source</Detail>
</div>
<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
class:selected={integration.type === integrationType}
on:click={() => selectIntegration(integrationType)}

View File

@ -1,66 +1,33 @@
<script>
import { goto } from "@roxi/routify"
import { ModalContent, notifications, Body, Layout } from "@budibase/bbui"
import analytics, { Events } from "analytics"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import { datasources, tables } from "stores/backend"
import { IntegrationNames } from "constants"
import { IntegrationNames } from "constants/backend"
import cloneDeep from "lodash/cloneDeepWith"
import { saveDatasource as save } from "builderStore/datasource"
export let integration
export let modal
// 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() {
const datasource = prepareData()
try {
// 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)
const resp = await save(datasource)
$goto(`./datasource/${resp._id}`)
notifications.success(`Datasource updated successfully.`)
analytics.captureEvent(Events.DATASOURCE.CREATED, {
name: resp.name,
source: resp.source,
})
return true
} catch (err) {
notifications.error(`Error saving datasource: ${err}`)
return false
}
}
</script>
<ModalContent
title={`Connect to ${IntegrationNames[config.type]}`}
title={`Connect to ${IntegrationNames[datasource.type]}`}
onConfirm={() => saveDatasource()}
onCancel={() => modal.show()}
confirmText={config.plus
confirmText={datasource.plus
? "Fetch tables from database"
: "Save and continue to query"}
cancelText="Back"
@ -72,7 +39,11 @@
</Body>
</Layout>
<IntegrationConfigForm schema={config.schema} integration={config.config} />
<IntegrationConfigForm
schema={datasource.schema}
bind:datasource
creating={true}
/>
</ModalContent>
<style>

View File

@ -1,6 +1,6 @@
<script>
import { goto } from "@roxi/routify"
import { datasources, tables } from "stores/backend"
import { datasources, queries, tables } from "stores/backend"
import { notifications } from "@budibase/bbui"
import { ActionMenu, MenuItem, Icon } from "@budibase/bbui"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
@ -12,12 +12,18 @@
let updateDatasourceDialog
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
await datasources.delete(datasource)
notifications.success("Datasource deleted")
// 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 (
wasSelectedSource === datasource._id ||
(entities &&

View File

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

View File

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

View File

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

View File

@ -1,12 +1,30 @@
<script>
import { Icon } from "@budibase/bbui"
import { Icon, Body } from "@budibase/bbui"
</script>
<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>
<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 {
color: inherit;
position: absolute;
@ -14,4 +32,10 @@
right: var(--spacing-m);
border-radius: 55%;
}
.hidden {
display: none;
}
.inner:hover .hidden {
display: block;
}
</style>

View File

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

@ -17,7 +17,7 @@
queries as queriesStore,
} 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 { makePropSafe as safe } from "@budibase/string-templates"
@ -148,15 +148,15 @@
/>
{#if value?.type === "query"}
<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>
<DrawerContent slot="body">
<Layout noPadding>
<Layout noPadding gap="XS">
{#if getQueryParams(value).length > 0}
<ParameterBuilder
<BindingBuilder
bind:customParams={tmpQueryParams}
parameters={getQueryParams(value)}
{bindings}
bindings={getQueryParams(value)}
bind:bindableOptions={bindings}
/>
{/if}
<IntegrationQueryEditor

View File

@ -1,7 +1,7 @@
<script>
import { Select, Layout, Input, Checkbox } from "@budibase/bbui"
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"
export let parameters
@ -53,10 +53,10 @@
{#if query?.parameters?.length > 0}
<div>
<ParameterBuilder
<BindingBuilder
bind:customParams={parameters.queryParams}
parameters={query.parameters}
{bindings}
bindings={query.parameters}
bind:bindableOptions={bindings}
/>
<IntegrationQueryEditor
height={200}

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>
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 object = defaults || {}
export let activity = {}
export let readOnly
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 fieldActivity = buildFieldActivity(activity)
$: object = fields.reduce(
(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() {
fields = [...fields, {}]
fields = [...fields, { name: "", value: "" }]
fieldActivity = [...fieldActivity, true]
changed()
}
function deleteEntry(idx) {
fields.splice(idx, 1)
fieldActivity.splice(idx, 1)
changed()
}
function changed() {
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>
<!-- Builds Objects with Key Value Pairs. Useful for building things like Request Headers. -->
<div class="container" class:readOnly>
{#each fields as field, idx}
<Input placeholder="Key" bind:value={field.name} />
<Input placeholder="Value" bind:value={field.value} />
{#if !readOnly}
<Icon hoverable name="Close" on:click={() => deleteEntry(idx)} />
{/if}
{/each}
</div>
{#if Object.keys(object || {}).length > 0}
{#if headings}
<div class="container" class:container-active={toggle}>
<Label {tooltip}>{keyPlaceholder}</Label>
<Label>{valuePlaceholder}</Label>
{#if toggle}
<Label>Active</Label>
{/if}
</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}
<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>
{/if}
@ -47,4 +124,10 @@
align-items: center;
margin-bottom: var(--spacing-m);
}
.container-active {
grid-template-columns: 1fr 1fr 50px 20px;
}
.readOnly {
grid-template-columns: 1fr 1fr;
}
</style>

View File

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

View File

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

View File

@ -14,37 +14,27 @@
Tab,
} from "@budibase/bbui"
import { notifications, Divider } from "@budibase/bbui"
import api from "builderStore/api"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import ParameterBuilder from "components/integration/QueryParameterBuilder.svelte"
import {
datasources,
integrations,
queries,
roles,
permissions,
} from "stores/backend"
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
import { datasources, integrations, queries } from "stores/backend"
import { capitalise } from "../../helpers"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { Roles } from "constants/backend"
import { onMount } from "svelte"
import JSONPreview from "./JSONPreview.svelte"
import { SchemaTypeOptions } from "constants/backend"
import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import AccessLevelSelect from "./AccessLevelSelect.svelte"
export let query
let fields = query.schema ? schemaToFields(query.schema) : []
let fields = query?.schema ? schemaToFields(query.schema) : []
let parameters
let data = []
let roleId
let saveId
const transformerDocs =
"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)
$: query.schema = fieldsToSchema(fields)
@ -60,84 +50,37 @@
query.transformer = "return data"
}
function newField() {
fields = [...fields, {}]
}
function deleteField(idx) {
fields.splice(idx, 1)
fields = fields
}
function resetDependentFields() {
if (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) {
query.fields.extra = extraQueryFields
}
async function previewQuery() {
try {
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: datasource._id,
})
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
data = json.rows || []
if (data.length === 0) {
const response = await queries.preview(query)
if (response.rows.length === 0) {
notifications.info(
"Query results empty. Please execute a query with results to create your schema."
)
return
}
data = response.rows
fields = response.schema
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) {
notifications.error(`Query Error: ${err.message}`)
console.error(err)
notifications.error(err)
}
}
async function saveQuery() {
try {
const { _id } = await queries.save(query.datasourceId, query)
await updateRole(roleId, _id)
saveId = _id
notifications.success(`Query saved successfully.`)
$goto(`../${_id}`)
} catch (err) {
@ -145,38 +88,6 @@
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>
<Layout gap="S" noPadding>
@ -200,14 +111,7 @@
/>
</div>
<div class="config-field">
<Label>Access level</Label>
<Select
value={roleId}
on:change={e => updateRole(e.detail)}
options={$roles}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
<AccessLevelSelect {saveId} {query} label="Access Level" />
</div>
{#if integrationInfo?.extra && query.queryVerb}
<ExtraQueryConfig
@ -216,7 +120,7 @@
config={integrationInfo.extra}
/>
{/if}
<ParameterBuilder bind:parameters={query.parameters} bindable={false} />
<BindingBuilder bind:bindings={query.parameters} bindable={false} />
{/if}
</div>
{#if shouldShowQueryConfig}
@ -271,29 +175,15 @@
{#if data}
<Tabs selected="JSON">
<Tab title="JSON">
<pre
class="preview">
<!-- prettier-ignore -->
{#if !data[0]}
Please run your query to fetch some data.
{:else}
{JSON.stringify(data[0], undefined, 2)}
{/if}
</pre>
<JSONPreview data={data[0]} minHeight="120" />
</Tab>
<Tab title="Schema">
<Layout gap="S">
{#each fields as field, idx}
<div class="field">
<Input placeholder="Field Name" bind:value={field.name} />
<Select bind:value={field.type} options={typeOptions} />
<Icon name="bleClose" on:click={() => deleteField(idx)} />
</div>
{/each}
<div>
<Button secondary on:click={newField}>Add Field</Button>
</div>
</Layout>
<KeyValueBuilder
bind:object={fields}
name="field"
headings
options={SchemaTypeOptions}
/>
</Tab>
<Tab title="Preview">
<ExternalDataSourceTable {query} {data} />
@ -322,29 +212,11 @@
justify-content: space-between;
}
.field {
display: grid;
grid-template-columns: 1fr 1fr 5%;
gap: var(--spacing-l);
}
.viewer {
min-height: 200px;
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 {
display: flex;
flex-direction: row;

View File

@ -154,3 +154,58 @@ export const ALLOWABLE_NUMBER_TYPES = ALLOWABLE_NUMBER_OPTIONS.map(
export const SWITCHABLE_TYPES = ALLOWABLE_NUMBER_TYPES.concat(
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",
}
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
export const UNEDITABLE_USER_FIELDS = [
"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 lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise])

View File

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

View File

@ -1,6 +1,6 @@
<script>
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 DeployModal from "components/deploy/DeployModal.svelte"
import RevertModal from "components/deploy/RevertModal.svelte"
@ -49,6 +49,7 @@
}
await automationStore.actions.fetch()
await roles.fetch()
await flags.fetch()
return pkg
} else {
throw new Error(pkg)

View File

@ -1,13 +1,23 @@
<script>
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) {
const query = $queries.list.find(q => q._id === $params.query)
if (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>
<slot />

View File

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

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

View File

@ -6,75 +6,48 @@
Body,
Divider,
Layout,
notifications,
Table,
Modal,
InlineAlert,
ActionButton,
} from "@budibase/bbui"
import { datasources, integrations, queries, tables } from "stores/backend"
import { notifications } from "@budibase/bbui"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
import CreateExternalTableModal from "./modals/CreateExternalTableModal.svelte"
import RestExtraConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/RestExtraConfigForm.svelte"
import PlusConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons"
import { capitalise } from "helpers"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import VerbRenderer from "./_components/VerbRenderer.svelte"
import { IntegrationTypes } from "constants/backend"
import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
let importQueriesModal
let relationshipModal
let createExternalTableModal
let selectedFromRelationship, selectedToRelationship
let baseDatasource, changed
const querySchema = {
name: {},
queryVerb: { displayName: "Method" },
}
$: datasource = $datasources.list.find(ds => ds._id === $datasources.selected)
$: integration = datasource && $integrations[datasource.source]
$: plusTables = datasource?.plus
? Object.values(datasource.entities || {})
: []
$: relationships = getRelationships(plusTables)
$: schemaError = $datasources.schemaError
function getRelationships(tables) {
if (!tables || !Array.isArray(tables)) {
return {}
$: {
if (
datasource &&
(!baseDatasource || baseDatasource.source !== datasource.source)
) {
baseDatasource = cloneDeep(datasource)
}
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
}
$: queryList = $queries.list.filter(
query => query.datasourceId === datasource?._id
)
$: hasChanged(baseDatasource, datasource)
function buildRelationshipDisplayString(fromCol, toCol) {
function getTableName(tableId) {
if (!tableId || typeof tableId !== "string") {
return null
}
return plusTables.find(table => table._id === tableId)?.name || "Unknown"
function hasChanged(base, ds) {
if (base && ds) {
changed = !isEqual(base, ds)
}
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 saveDatasource() {
@ -86,53 +59,18 @@
}
await datasources.fetch()
notifications.success(`Datasource ${name} updated successfully.`)
baseDatasource = cloneDeep(datasource)
} catch (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) {
queries.select(query)
$goto(`./${query._id}`)
}
function onClickTable(table) {
tables.select(table)
$goto(`../../table/${table._id}`)
}
function openRelationshipModal(fromRelationship, toRelationship) {
selectedFromRelationship = fromRelationship || {}
selectedToRelationship = toRelationship || {}
relationshipModal.show()
}
function createNewTable() {
createExternalTableModal.show()
}
</script>
<Modal bind:this={relationshipModal}>
<CreateEditRelationship
{datasource}
save={saveDatasource}
close={relationshipModal.hide}
{plusTables}
fromRelationship={selectedFromRelationship}
toRelationship={selectedToRelationship}
/>
</Modal>
<Modal bind:this={importQueriesModal}>
{#if datasource.source === "REST"}
<ImportRestQueriesModal
@ -142,10 +80,6 @@
{/if}
</Modal>
<Modal bind:this={createExternalTableModal}>
<CreateExternalTableModal {datasource} />
</Modal>
{#if datasource && integration}
<section>
<Layout>
@ -156,125 +90,58 @@
height="26"
width="26"
/>
<Heading size="M">{datasource.name}</Heading>
<Heading size="M">{baseDatasource.name}</Heading>
</header>
<Body size="M">{integration.description}</Body>
</Layout>
<Divider />
<div class="container">
<div class="config-header">
<Heading size="S">Configuration</Heading>
<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}
/>
<Divider size="S" />
<div class="config-header">
<Heading size="S">Configuration</Heading>
<Button disabled={!changed} cta on:click={saveDatasource}>Save</Button>
</div>
<IntegrationConfigForm
on:change={hasChanged}
schema={integration.datasource}
bind:datasource
/>
{#if datasource.plus}
<Divider />
<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>
<PlusConfigForm bind:datasource save={saveDatasource} />
{/if}
<Divider />
<Divider size="S" />
<div class="query-header">
<Heading size="S">Queries</Heading>
<div class="query-buttons">
{#if datasource.source === "REST"}
{#if datasource?.source === IntegrationTypes.REST}
<Button secondary on:click={() => importQueriesModal.show()}
>Import</Button
>
{/if}
<Button secondary on:click={() => $goto("./new")}>Add Query</Button>
<Button cta icon="Add" on:click={() => $goto("./new")}
>Add query
</Button>
</div>
</div>
<div class="query-list">
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query}
<div class="query-list-item" on:click={() => onClickQuery(query)}>
<p class="query-name">{query.name}</p>
<p>{capitalise(query.queryVerb)}</p>
<p></p>
</div>
{/each}
</div>
<Body size="S">
To build an app using a datasource, you must first query the data. A
query is a request for data or information from a datasource, for
example a database table.
</Body>
{#if queryList && queryList.length > 0}
<div class="query-list">
<Table
on:click={({ detail }) => onClickQuery(detail)}
schema={querySchema}
data={queryList}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "queryVerb", component: VerbRenderer }]}
/>
</div>
{/if}
{#if datasource?.source === IntegrationTypes.REST}
<RestExtraConfigForm bind:datasource on:change={hasChanged} />
{/if}
</Layout>
</section>
{/if}
@ -297,18 +164,11 @@
margin: 0 0 var(--spacing-xs) 0;
}
.container {
width: 100%;
border-radius: var(--border-radius-m);
margin: 0 auto;
}
.query-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin: 0 0 var(--spacing-s) 0;
}
.query-buttons {
@ -321,48 +181,4 @@
flex-direction: column;
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>

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,403 @@
<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
$: datasourceType = datasource?.source
$: integrationInfo = $integrations[datasourceType]
$: queryConfig = integrationInfo?.query
$: url = buildUrl(url, breakQs)
$: checkQueryName(url)
$: responseSuccess =
response?.info?.code >= 200 && response?.info?.code <= 206
function getSelectedQuery() {
return cloneDeep(
$queries.list.find(q => q._id === $queries.selected) || {
datasourceId: $params.selectedDatasource,
parameters: [],
fields: {},
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
}
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.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)
}
}
onMount(() => {
query = getSelectedQuery()
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)
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>
</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;
}
</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 { integrations } from "./integrations"
export { queries } from "./queries"
export { flags } from "./flags"

View File

@ -78,6 +78,35 @@ export function createQueriesStore() {
unselect: () => {
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 => {
const response = await api.delete(
`/api/queries/${query._id}/${query._rev}`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ const CouchDB = require("../../db")
const {
generateUserMetadataID,
getUserMetadataParams,
generateUserFlagID,
} = require("../../db/utils")
const { InternalTables } = require("../../db/utils")
const { getGlobalUsers, getRawGlobalUser } = require("../../utilities/global")
@ -195,3 +196,35 @@ exports.destroyMetadata = async function (ctx) {
exports.findMetadata = async function (ctx) {
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),
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

View File

@ -40,6 +40,7 @@ const DocumentTypes = {
DEPLOYMENTS: "deployments",
METADATA: "metadata",
MEM_VIEW: "view",
USER_FLAG: "flag",
}
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) => {
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
_rev?: string
}
@ -93,39 +93,3 @@ export interface Automation extends Base {
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 {
CREATE = "CREATE",
@ -181,3 +181,52 @@ export interface SqlQuery {
export interface QueryOptions {
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 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
}
export interface RestConfig {
url: string
defaultHeaders: {
[key: string]: any
}
}
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 {
create?(query: any): Promise<any[]>
read?(query: any): Promise<any[]>
update?(query: any): Promise<any[]>
delete?(query: any): Promise<any[]>
create?(query: any): Promise<any[]|any>
read?(query: any): Promise<any[]|any>
update?(query: any): Promise<any[]|any>
delete?(query: any): Promise<any[]|any>
}

View File

@ -2,29 +2,58 @@ import {
Integration,
DatasourceFieldTypes,
QueryTypes,
RestConfig,
RestQueryFields as RestQuery,
} from "../definitions/datasource"
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 {
const fetch = require("node-fetch")
interface RestConfig {
url: string
defaultHeaders: {
[key: string]: any
}
}
const { formatBytes } = require("../utilities")
const { performance } = require("perf_hooks")
const SCHEMA: Integration = {
docs: "https://github.com/node-fetch/node-fetch",
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",
datasource: {
url: {
type: DatasourceFieldTypes.STRING,
default: "localhost",
required: true,
default: "",
required: false,
deprecated: true,
},
defaultHeaders: {
type: DatasourceFieldTypes.OBJECT,
@ -37,97 +66,30 @@ module RestModule {
readable: true,
displayName: "POST",
type: QueryTypes.FIELDS,
urlDisplay: true,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
fields: coreFields,
},
read: {
displayName: "GET",
readable: true,
type: QueryTypes.FIELDS,
urlDisplay: true,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
},
fields: coreFields,
},
update: {
displayName: "PUT",
readable: true,
type: QueryTypes.FIELDS,
urlDisplay: true,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
fields: coreFields,
},
patch: {
displayName: "PATCH",
readable: true,
type: QueryTypes.FIELDS,
urlDisplay: true,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
fields: coreFields,
},
delete: {
displayName: "DELETE",
type: QueryTypes.FIELDS,
urlDisplay: true,
fields: {
path: {
type: DatasourceFieldTypes.STRING,
},
queryString: {
type: DatasourceFieldTypes.STRING,
},
headers: {
type: DatasourceFieldTypes.OBJECT,
},
requestBody: {
type: DatasourceFieldTypes.JSON,
},
},
fields: coreFields,
},
},
}
@ -137,94 +99,106 @@ module RestModule {
private headers: {
[key: string]: string
} = {}
private startTimeMs: number = performance.now()
constructor(config: RestConfig) {
this.config = config
}
async parseResponse(response: any) {
let data, raw, headers
const contentType = response.headers.get("content-type")
if (contentType && contentType.indexOf("application/json") !== -1) {
return await response.json()
data = await response.json()
raw = JSON.stringify(data)
} 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 {
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 = {} }) {
async _req(query: RestQuery) {
const { path = "", queryString = "", headers = {}, method = "GET", disabledHeaders, bodyType, requestBody } = query
this.headers = {
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.getUrl(path, queryString), {
method: "POST",
headers: this.headers,
body: JSON.stringify(json),
})
if (disabledHeaders) {
for (let headerKey of Object.keys(this.headers)) {
if (disabledHeaders[headerKey]) {
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 response = await fetch(this.getUrl(path, queryString), input)
return await this.parseResponse(response)
}
async read({ path = "", queryString = "", headers = {} }) {
this.headers = {
...this.config.defaultHeaders,
...headers,
}
const response = await fetch(this.getUrl(path, queryString), {
headers: this.headers,
})
return await this.parseResponse(response)
async create(opts: RestQuery) {
return this._req({ ...opts, method: "POST" })
}
async update({ path = "", queryString = "", headers = {}, json = {} }) {
this.headers = {
...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 read(opts: RestQuery) {
return this._req({ ...opts, method: "GET" })
}
async patch({ path = "", queryString = "", headers = {}, json = {} }) {
this.headers = {
...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 update(opts: RestQuery) {
return this._req({ ...opts, method: "PUT" })
}
async delete({ path = "", queryString = "", headers = {} }) {
this.headers = {
...this.config.defaultHeaders,
...headers,
}
async patch(opts: RestQuery) {
return this._req({ ...opts, method: "PATCH" })
}
const response = await fetch(this.getUrl(path, queryString), {
method: "DELETE",
headers: this.headers,
})
return await this.parseResponse(response)
async delete(opts: RestQuery) {
return this._req({ ...opts, method: "DELETE" })
}
}

View File

@ -1,6 +1,9 @@
jest.mock("node-fetch", () =>
jest.fn(() => ({
headers: {
raw: () => {
return { "content-type": ["application/json"] }
},
get: () => ["application/json"]
},
json: jest.fn(),
@ -24,6 +27,7 @@ describe("REST Integration", () => {
config = new TestConfiguration({
url: BASE_URL,
})
jest.clearAllMocks()
})
it("calls the create method with the correct params", async () => {
@ -33,9 +37,10 @@ describe("REST Integration", () => {
headers: {
Accept: "application/json",
},
json: {
bodyType: "json",
requestBody: JSON.stringify({
name: "test",
},
}),
}
const response = await config.integration.create(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, {
@ -60,6 +65,7 @@ describe("REST Integration", () => {
headers: {
Accept: "text/html",
},
method: "GET",
})
})
@ -70,13 +76,14 @@ describe("REST Integration", () => {
headers: {
Accept: "application/json",
},
json: {
bodyType: "json",
requestBody: JSON.stringify({
name: "test",
},
}),
}
const response = await config.integration.update(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, {
method: "POST",
method: "PUT",
body: '{"name":"test"}',
headers: {
Accept: "application/json",
@ -91,9 +98,10 @@ describe("REST Integration", () => {
headers: {
Accept: "application/json",
},
json: {
bodyType: "json",
requestBody: JSON.stringify({
name: "test",
},
}),
}
const response = await config.integration.delete(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/api?test=1`, {
@ -101,6 +109,7 @@ describe("REST Integration", () => {
headers: {
Accept: "application/json",
},
body: '{"name":"test"}',
})
})
})

View File

@ -15,6 +15,15 @@ function formatResponse(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) {
const Integration = integrations[datasource.source]
if (!Integration) {
@ -22,7 +31,15 @@ async function runAndTransform(datasource, queryVerb, query, transformer) {
}
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
if (transformer) {
@ -47,7 +64,7 @@ async function runAndTransform(datasource, queryVerb, query, transformer) {
integration.end()
}
return { rows, keys }
return { rows, keys, info, extra }
}
module.exports = (input, callback) => {

View File

@ -150,3 +150,14 @@ exports.doesDatabaseExist = async dbName => {
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]}`
}

View File

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

View File

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