Merge branch 'component-sdk' of github.com:Budibase/budibase into feature/self-hosting

This commit is contained in:
mike12345567 2020-12-01 17:54:50 +00:00
commit de26870303
166 changed files with 8488 additions and 5050 deletions

View File

View File

@ -186,7 +186,7 @@ Or if you are in the builder you can run `yarn cy:test`.
### Other Useful Information
* The contributors are listed in [AUTHORS.md](https://github.com/budibase/server/blob/master/AUTHORS.md) (add yourself).
* The contributors are listed in [AUTHORS.md](https://github.com/Budibase/budibase/blob/master/.github/AUTHORS.md) (add yourself).
* This project uses a modified version of the MPLv2 license, see [LICENSE](https://github.com/budibase/server/blob/master/LICENSE).

View File

@ -116,7 +116,7 @@ You can also follow a quick tutorial on [how to build a CRM with Budibase](https
## ❗ Code of Conduct
Budibase is dedicated to providing a welcoming, diverse, and harrassment-free experience for everyone. We expect everyone in the Budibase community to abide by our [**Code of Conduct**](https://github.com/Budibase/budibase/blob/master/CODE_OF_CONDUCT.md). Please read it.
Budibase is dedicated to providing a welcoming, diverse, and harrassment-free experience for everyone. We expect everyone in the Budibase community to abide by our [**Code of Conduct**](https://github.com/Budibase/budibase/blob/master/.github/CODE_OF_CONDUCT.md). Please read it.
## 🙌 Contributing to Budibase
@ -134,7 +134,7 @@ Budibase is a monorepo managed by lerna. Lerna manages the building and publishi
- [packages/server](https://github.com/Budibase/budibase/tree/master/packages/server) - The budibase server. This Koa app is responsible for serving the JS for the builder and budibase apps, as well as providing the API for interaction with the database and file system.
For more information, see [CONTRIBUTING.md](./CONTRIBUTING.md)
For more information, see [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/master/.github/CONTRIBUTING.md)
## 📝 License

View File

@ -14,7 +14,7 @@
"prettier-plugin-svelte": "^1.4.0",
"rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0",
"svelte": "^3.28.0"
"svelte": "^3.30.0"
},
"scripts": {
"bootstrap": "lerna bootstrap",
@ -26,7 +26,7 @@
"nuke": "rimraf ~/.budibase && npm run restore",
"clean": "lerna clean",
"kill-port": "kill-port 4001",
"dev": "npm run kill-port && node ./scripts/symlinkDev.js && lerna run --parallel dev:builder --concurrency 1",
"dev": "yarn run kill-port && node ./scripts/symlinkDev.js && lerna run --parallel dev:builder --concurrency 1",
"test": "lerna run test",
"lint": "eslint packages",
"lint:fix": "eslint --fix packages",

View File

@ -16,6 +16,9 @@ process.env.BUDIBASE_API_KEY = "6BE826CB-6B30-4AEC-8777-2E90464633DE"
process.env.NODE_ENV = "cypress"
process.env.ENABLE_ANALYTICS = "false"
// Stop info logs polluting test outputs
process.env.LOG_LEVEL = "error"
async function run(dir) {
process.env.BUDIBASE_DIR = resolve(dir)
require("dotenv").config({ path: resolve(dir, ".env") })

View File

@ -81,8 +81,8 @@
"shortid": "^2.2.15",
"svelte-loading-spinners": "^0.1.1",
"svelte-portal": "^0.1.0",
"yup": "^0.29.2",
"uuid": "^8.3.1"
"uuid": "^8.3.1",
"yup": "^0.29.2"
},
"devDependencies": {
"@babel/core": "^7.5.5",
@ -90,6 +90,7 @@
"@babel/preset-env": "^7.5.5",
"@babel/runtime": "^7.5.5",
"@rollup/plugin-alias": "^3.0.1",
"@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-json": "^4.0.3",
"@sveltech/routify": "1.7.11",
"@testing-library/jest-dom": "^5.11.0",
@ -104,7 +105,6 @@
"rimraf": "^3.0.2",
"rollup": "^2.11.2",
"rollup-plugin-alias": "^1.5.2",
"rollup-plugin-commonjs": "^10.0.0",
"rollup-plugin-copy": "^3.0.0",
"rollup-plugin-css-only": "^2.1.0",
"rollup-plugin-livereload": "^1.0.0",
@ -115,7 +115,7 @@
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-url": "^2.2.2",
"start-server-and-test": "^1.11.0",
"svelte": "^3.29.0",
"svelte": "^3.30.0",
"svelte-jester": "^1.0.6"
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"

View File

@ -1,7 +1,7 @@
import alias from "@rollup/plugin-alias"
import svelte from "rollup-plugin-svelte"
import resolve from "rollup-plugin-node-resolve"
import commonjs from "rollup-plugin-commonjs"
import commonjs from "@rollup/plugin-commonjs"
import url from "rollup-plugin-url"
import livereload from "rollup-plugin-livereload"
import { terser } from "rollup-plugin-terser"
@ -15,102 +15,7 @@ import json from "@rollup/plugin-json"
import path from "path"
const production = !process.env.ROLLUP_WATCH
const lodash_fp_exports = [
"flow",
"pipe",
"union",
"reduce",
"isUndefined",
"cloneDeep",
"split",
"some",
"map",
"filter",
"isEmpty",
"countBy",
"includes",
"last",
"find",
"constant",
"take",
"first",
"intersection",
"mapValues",
"isNull",
"has",
"isInteger",
"isNumber",
"isString",
"isBoolean",
"isDate",
"isArray",
"isObject",
"clone",
"values",
"keyBy",
"isNaN",
"keys",
"orderBy",
"concat",
"reverse",
"difference",
"merge",
"flatten",
"each",
"pull",
"join",
"defaultCase",
"uniqBy",
"every",
"uniqWith",
"isFunction",
"groupBy",
"differenceBy",
"intersectionBy",
"isEqual",
"max",
"sortBy",
"assign",
"uniq",
"trimChars",
"trimCharsStart",
"isObjectLike",
"flattenDeep",
"indexOf",
"isPlainObject",
"toNumber",
"takeRight",
"toPairs",
"remove",
"findIndex",
"compose",
"get",
"tap",
]
const lodash_exports = [
"flow",
"join",
"replace",
"trim",
"dropRight",
"takeRight",
"head",
"reduce",
"tail",
"startsWith",
"findIndex",
"merge",
"assign",
"each",
"find",
"orderBy",
"union",
]
const outputpath = "../server/builder"
const coreExternal = [
"lodash",
"lodash/fp",
@ -224,13 +129,7 @@ export default {
)
},
}),
commonjs({
namedExports: {
"lodash/fp": lodash_fp_exports,
lodash: lodash_exports,
shortid: ["generate"],
},
}),
commonjs(),
url({
limit: 0,
include: ["**/*.woff2", "**/*.png"],

View File

@ -24,7 +24,7 @@ import { cloneDeep, difference } from "lodash/fp"
* @returns {Array.<BindableProperty>}
*/
export default function({ componentInstanceId, screen, components, tables }) {
const walkResult = walk({
const result = walk({
// cloning so we are free to mutate props (e.g. by adding _contexts)
instance: cloneDeep(screen.props),
targetId: componentInstanceId,
@ -33,13 +33,10 @@ export default function({ componentInstanceId, screen, components, tables }) {
})
return [
...walkResult.bindableInstances
.filter(isInstanceInSharedContext(walkResult))
.map(componentInstanceToBindable(walkResult)),
...(walkResult.target?._contexts
.map(contextToBindables(tables, walkResult))
.flat() ?? []),
...result.bindableInstances
.filter(isInstanceInSharedContext(result))
.map(componentInstanceToBindable),
...(result.target?._contexts.map(contextToBindables(tables)).flat() ?? []),
]
}
@ -53,26 +50,18 @@ const isInstanceInSharedContext = walkResult => i =>
// turns a component instance prop into binding expressions
// used by the UI
const componentInstanceToBindable = walkResult => i => {
const lastContext =
i.instance._contexts.length &&
i.instance._contexts[i.instance._contexts.length - 1]
const contextParentPath = lastContext
? getParentPath(walkResult, lastContext)
: ""
const componentInstanceToBindable = i => {
return {
type: "instance",
instance: i.instance,
// how the binding expression persists, and is used in the app at runtime
runtimeBinding: `${contextParentPath}${i.instance._id}.${i.prop}`,
runtimeBinding: `${i.instance._id}`,
// how the binding exressions looks to the user of the builder
readableBinding: `${i.instance._instanceName}`,
}
}
const contextToBindables = (tables, walkResult) => context => {
const contextParentPath = getParentPath(walkResult, context)
const contextToBindables = tables => context => {
const tableId = context.table?.tableId ?? context.table
const table = tables.find(table => table._id === tableId)
let schema =
@ -98,7 +87,7 @@ const contextToBindables = (tables, walkResult) => context => {
fieldSchema,
instance: context.instance,
// how the binding expression persists, and is used in the app at runtime
runtimeBinding: `${contextParentPath}data.${runtimeBoundKey}`,
runtimeBinding: `${context.instance._id}.${runtimeBoundKey}`,
// how the binding expressions looks to the user of the builder
readableBinding: `${context.instance._instanceName}.${table.name}.${key}`,
// table / view info
@ -118,20 +107,6 @@ const contextToBindables = (tables, walkResult) => context => {
)
}
const getParentPath = (walkResult, context) => {
// describes the number of "parent" in the path
// clone array first so original array is not mtated
const contextParentNumber = [...walkResult.target._contexts]
.reverse()
.indexOf(context)
return (
new Array(contextParentNumber).fill("parent").join(".") +
// trailing . if has parents
(contextParentNumber ? "." : "")
)
}
const walk = ({ instance, targetId, components, tables, result }) => {
if (!result) {
result = {

View File

@ -12,10 +12,7 @@ export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
return boundValue === `{{ ${readableBinding} }}`
})
if (binding) {
result = textWithBindings.replace(
boundValue,
`{{ ${binding.runtimeBinding} }}`
)
result = result.replace(boundValue, `{{ ${binding.runtimeBinding} }}`)
}
})
return result

View File

@ -481,7 +481,7 @@ export const getFrontendStore = () => {
// Try to extract a nav component from the master screen
const nav = findChildComponentType(
state.pages.main,
"@budibase/standard-components/Navigation"
"@budibase/standard-components/navigation"
)
if (nav) {
let newLink

View File

@ -15,8 +15,6 @@ export class Component extends BaseStructure {
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "",
_instanceName: "",
_children: [],

View File

@ -1,140 +1,63 @@
<script>
import { store, backendUiStore } from "builderStore"
import { map, join } from "lodash/fp"
import { onMount } from "svelte"
import { store } from "builderStore"
import iframeTemplate from "./iframeTemplate"
import { pipe } from "../../../helpers"
import { Screen } from "../../../builderStore/store/screenTemplates/utils/Screen"
import { Component } from "../../../builderStore/store/screenTemplates/utils/Component"
import { Screen } from "builderStore/store/screenTemplates/utils/Screen"
let iframe
let styles = ""
function transform_component(comp) {
const props = comp.props || comp
if (props && props._children && props._children.length) {
props._children = props._children.map(transform_component)
}
return props
}
const getComponentTypeName = component => {
let [componentName] = component._component.match(/[a-z]*$/)
return componentName || "element"
}
const headingStyle = {
width: "500px",
padding: "8px",
}
const textStyle = {
...headingStyle,
"max-width": "",
"text-align": "left",
}
const heading = new Component("@budibase/standard-components/heading")
.normalStyle(headingStyle)
.type("h1")
.text("Screen Slot")
.instanceName("Heading")
const textScreenDisplay = new Component("@budibase/standard-components/text")
.normalStyle(textStyle)
.instanceName("Text")
.type("none")
.text(
"The screens that you create will be displayed inside this box. This box is just a placeholder, to show you the position of screens."
)
const container = new Component("@budibase/standard-components/container")
.normalStyle({
display: "flex",
"flex-direction": "column",
"align-items": "center",
flex: "1 1 auto",
})
.type("div")
.instanceName("Container")
.addChild(heading)
.addChild(textScreenDisplay)
// Create screen slot placeholder for use when a page is selected rather
// than a screen
const screenPlaceholder = new Screen()
.name("Screen Placeholder")
.route("*")
.component("@budibase/standard-components/container")
.mainType("div")
.component("@budibase/standard-components/screenslotplaceholder")
.instanceName("Content Placeholder")
.normalStyle({
flex: "1 1 auto",
})
.addChild(container)
.json()
// TODO: this ID is attached to how the screen slot is rendered, confusing, would be better a type etc
screenPlaceholder.props._id = "screenslot-placeholder"
$: hasComponent = !!$store.currentPreviewItem
// Extract data to pass to the iframe
$: page = $store.pages[$store.currentPageName]
$: screen =
$store.currentFrontEndType === "page"
? screenPlaceholder
: $store.currentPreviewItem
$: selectedComponentId = $store.currentComponentInfo?._id ?? ""
$: {
styles = ""
// Apply the CSS from the currently selected page and its screens
const currentPage = $store.pages[$store.currentPageName]
styles += currentPage._css
for (let screen of currentPage._screens) {
styles += screen._css
// Saving pages and screens to the DB causes them to have _revs.
// These revisions change every time a save happens and causes
// these reactive statements to fire, even though the actual
// definition hasn't changed.
// By deleting all _rev properties we can avoid this and increase
// performance.
$: json = JSON.stringify({ page, screen, selectedComponentId })
$: strippedJson = json.replaceAll(/"_rev":\s*"[^"]+"/g, `"_rev":""`)
// Update the iframe with the builder info to render the correct preview
const refreshContent = message => {
if (iframe) {
iframe.contentWindow.postMessage(message)
}
styles = styles
}
$: stylesheetLinks = pipe($store.pages.stylesheets, [
map(s => `<link rel="stylesheet" href="${s}"/>`),
join("\n"),
])
// Refresh the preview when required
$: refreshContent(strippedJson)
$: screensExist =
$store.currentPreviewItem._screens &&
$store.currentPreviewItem._screens.length > 0
$: frontendDefinition = {
appId: $store.appId,
libraries: $store.libraries,
page: $store.pages[$store.currentPageName],
screens: [
$store.currentFrontEndType === "page"
? screenPlaceholder
: $store.currentPreviewItem,
],
}
$: selectedComponentType = getComponentTypeName($store.currentComponentInfo)
$: selectedComponentId = $store.currentComponentInfo
? $store.currentComponentInfo._id
: ""
const refreshContent = () => {
iframe.contentWindow.postMessage(
JSON.stringify({
styles,
stylesheetLinks,
selectedComponentType,
selectedComponentId,
frontendDefinition,
appId: $store.appId,
instanceId: $backendUiStore.selectedDatabase._id,
})
// Initialise the app when mounted
onMount(() => {
iframe.contentWindow.addEventListener(
"bb-ready",
() => {
refreshContent(strippedJson)
},
{
once: true,
}
)
}
$: if (iframe)
iframe.contentWindow.addEventListener("bb-ready", refreshContent, {
once: true,
})
$: if (iframe && frontendDefinition) {
refreshContent()
}
})
</script>
<div class="component-container">
{#if hasComponent && $store.currentPreviewItem}
{#if $store.currentPreviewItem}
<iframe
style="height: 100%; width: 100%"
title="componentPreview"
@ -152,7 +75,6 @@
margin: auto;
height: 100%;
}
.component-container iframe {
border: 0;
left: 0;

View File

@ -4,72 +4,59 @@ export default `<html>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono">
<style>
body, html {
height: 100%!important;
height: 100% !important;
font-family: Inter !important;
margin: 0px!important;
margin: 0px !important;
}
*, *:before, *:after {
box-sizing: border-box;
}
.container-screenslot-placeholder {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
border-style: dashed !important;
border-width: 1px;
color: #000000;
background-color: rgba(0, 0, 0, 0.05);
flex: 1 1 auto;
}
.container-screenslot-placeholder span {
display: block;
margin-bottom: 10px;
}
</style>
<script src='/assets/budibase-client.js'></script>
<script>
function receiveMessage(event) {
if (!event.data) {
return
}
if (!event.data) return
const data = JSON.parse(event.data)
try {
if (styles) document.head.removeChild(styles)
} catch(_) { }
try {
if (selectedComponentStyle) document.head.removeChild(selectedComponentStyle)
} catch(_) { }
selectedComponentStyle = document.createElement('style');
// Extract data from message
const { selectedComponentId, page, screen } = JSON.parse(event.data)
// Update selected component style
if (selectedComponentStyle) {
document.head.removeChild(selectedComponentStyle)
}
selectedComponentStyle = document.createElement("style");
document.head.appendChild(selectedComponentStyle)
var selectedCss = '.' + data.selectedComponentType + '-' + data.selectedComponentId + '{ border: 2px solid #0055ff; }'
var selectedCss = '[data-bb-id="' + selectedComponentId + '"]' + '{border:2px solid #0055ff !important;}'
selectedComponentStyle.appendChild(document.createTextNode(selectedCss))
styles = document.createElement('style')
document.head.appendChild(styles)
styles.appendChild(document.createTextNode(data.styles))
window["##BUDIBASE_FRONTEND_DEFINITION##"] = data.frontendDefinition;
// Set some flags so the app knows we're in the builder
window["##BUDIBASE_IN_BUILDER##"] = true
window["##BUDIBASE_PREVIEW_PAGE##"] = page
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
// Initialise app
if (window.loadBudibase) {
loadBudibase({ window, localStorage })
loadBudibase()
}
}
let styles
let selectedComponentStyle
document.addEventListener("click", function(e) {
e.preventDefault()
e.stopPropagation()
return false;
}, true)
// Ignore clicks
["click", "mousedown"].forEach(type => {
document.addEventListener(type, function(e) {
e.preventDefault()
e.stopPropagation()
return false
}, true)
})
window.addEventListener('message', receiveMessage)
window.dispatchEvent(new Event('bb-ready'))
window.addEventListener("message", receiveMessage)
window.dispatchEvent(new Event("bb-ready"))
</script>
</head>
<body>

View File

@ -15,7 +15,8 @@
let anchor
$: noChildrenAllowed =
!component || !getComponentDefinition($store, component._component).children
!component ||
!getComponentDefinition($store, component._component)?.children
$: noPaste = !$store.componentToPaste
const lastPartOfName = c => (c ? last(c._component.split("/")) : "")

View File

@ -1,11 +1,11 @@
<script>
import { TextButton, Body, DropdownMenu, ModalContent } from "@budibase/bbui"
import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import { EVENT_TYPE_MEMBER_NAME } from "../../../../../client/src/state/eventHandlers"
import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
const eventTypeKey = "##eventHandlerType"
export let event
@ -18,8 +18,7 @@
$: actions = event || []
$: selectedActionComponent =
selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME])
.component
actionTypes.find(t => t.name === selectedAction[eventTypeKey]).component
const updateEventHandler = (updatedHandler, index) => {
actions[index] = updatedHandler
@ -33,7 +32,7 @@
const addAction = actionType => () => {
const newAction = {
parameters: {},
[EVENT_TYPE_MEMBER_NAME]: actionType.name,
[eventTypeKey]: actionType.name,
}
actions.push(newAction)
selectedAction = newAction
@ -79,7 +78,7 @@
{#each actions as action, index}
<div class="action-container">
<div class="action-header" on:click={selectAction(action)}>
<Body small lh>{index + 1}. {action[EVENT_TYPE_MEMBER_NAME]}</Body>
<Body small lh>{index + 1}. {action[eventTypeKey]}</Body>
<div class="row-expander" class:rotate={action !== selectedAction}>
<ArrowDownIcon />
</div>

View File

@ -1,127 +0,0 @@
<script>
import { keys, map, includes, filter } from "lodash/fp"
import EventEditorModal from "./EventEditorModal.svelte"
import { Modal } from "@budibase/bbui"
export const EVENT_TYPE = "event"
export let component
let events = []
let selectedEvent = null
let modal
$: {
events = Object.keys(component)
// TODO: use real events
.filter(propName => ["onChange", "onClick", "onLoad"].includes(propName))
.map(propName => ({
name: propName,
handlers: component[propName] || [],
}))
}
const openModal = event => {
selectedEvent = event
modal.show()
}
</script>
<button class="newevent" on:click={() => openModal()}>
<i class="icon ri-add-circle-fill" />
Create New Event
</button>
<div class="root">
<form on:submit|preventDefault class="form-root">
{#each events as event, index}
{#if event.handlers.length > 0}
<div
class:selected={selectedEvent && selectedEvent.index === index}
class="handler-container budibase__nav-item"
on:click={() => openModal({ ...event, index })}>
<span class="event-name">{event.name}</span>
<span class="edit-text">EDIT</span>
</div>
{/if}
{/each}
</form>
</div>
<Modal bind:this={modal} width="600px">
<EventEditorModal eventOptions={events} event={selectedEvent} />
</Modal>
<style>
.root {
font-size: 10pt;
width: 100%;
}
.newevent {
cursor: pointer;
border: 1px solid var(--grey-4);
border-radius: 3px;
width: 100%;
padding: 8px 16px;
margin: 0px 0px 12px 0px;
display: flex;
justify-content: center;
align-items: center;
background: var(--background);
color: var(--ink);
font-size: 14px;
font-weight: 500;
transition: all 2ms;
}
.newevent:hover {
background: var(--grey-1);
}
.icon {
color: var(--ink);
font-size: 16px;
margin-right: 4px;
}
.form-root {
display: flex;
flex-wrap: wrap;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
}
.handler-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
border: 2px solid var(--grey-1);
height: 80px;
width: 100%;
}
.event-name {
margin-top: 5px;
font-weight: bold;
font-size: 16px;
color: rgba(22, 48, 87, 0.6);
align-self: end;
}
.edit-text {
font-family: Arial, Helvetica, sans-serif;
font-weight: bold;
align-self: end;
justify-self: end;
font-size: 10px;
color: rgba(35, 65, 105, 0.4);
}
.selected {
color: var(--blue);
background: var(--grey-1) !important;
}
</style>

View File

@ -1,53 +0,0 @@
<script>
import { Input, DataList, Select } from "@budibase/bbui"
import { automationStore, allScreens } from "builderStore"
export let parameter
let isOpen = false
const capitalize = s => {
if (typeof s !== "string") return ""
return s.charAt(0).toUpperCase() + s.slice(1)
}
</script>
<div class="handler-option">
{#if parameter.name === 'automation'}<span>{parameter.name}</span>{/if}
{#if parameter.name === 'automation'}
<Select on:change bind:value={parameter.value}>
<option value="" />
{#each $automationStore.automations.filter(wf => wf.live) as automation}
<option value={automation._id}>{automation.name}</option>
{/each}
</Select>
{:else if parameter.name === 'url'}
<DataList on:change bind:value={parameter.value}>
<option value="" />
{#each $allScreens as screen}
<option value={screen.routing.route}>
{screen.props._instanceName}
</option>
{/each}
</DataList>
{:else}
<Input
name={parameter.name}
label={capitalize(parameter.name)}
on:change
value={parameter.value} />
{/if}
</div>
<style>
.handler-option {
display: flex;
flex-direction: column;
}
span {
font-size: 18px;
margin-bottom: 10px;
font-weight: 500;
}
</style>

View File

@ -1,9 +1,8 @@
<script>
import { buildStyle } from "../../helpers.js"
export let value = ""
export let text = ""
export let icon = ""
export let onClick = value => {}
export let onClick = () => {}
export let selected = false
$: useIcon = !!icon

View File

@ -40,13 +40,16 @@
$: links = bindableProperties
.filter(x => x.fieldSchema?.type === "link")
.map(property => ({
label: property.readableBinding,
fieldName: property.fieldSchema.name,
name: `all_${property.fieldSchema.tableId}`,
tableId: property.fieldSchema.tableId,
type: "link",
}))
.map(property => {
return {
providerId: property.instance._id,
label: property.readableBinding,
fieldName: property.fieldSchema.name,
name: `all_${property.fieldSchema.tableId}`,
tableId: property.fieldSchema.tableId,
type: "link",
}
})
</script>
<div

View File

@ -1175,7 +1175,7 @@ export default {
},
{
name: "Nav Bar",
_component: "@budibase/standard-components/Navigation",
_component: "@budibase/standard-components/navigation",
description:
"A component for handling the navigation within your app.",
icon: "ri-navigation-line",

View File

@ -11,7 +11,7 @@ describe("fetch bindable properties", () => {
)
expect(componentBinding).toBeDefined()
expect(componentBinding.type).toBe("instance")
expect(componentBinding.runtimeBinding).toBe("search-input-id.value")
expect(componentBinding.runtimeBinding).toBe("search-input-id")
})
it("should not return bindable components when not in their context", () => {
@ -37,20 +37,22 @@ describe("fetch bindable properties", () => {
expect(contextBindings.length).toBe(4)
const namebinding = contextBindings.find(
b => b.runtimeBinding === "data.name"
b => b.runtimeBinding === "list-id.name"
)
expect(namebinding).toBeDefined()
expect(namebinding.readableBinding).toBe("list-name.Test Table.name")
const descriptionbinding = contextBindings.find(
b => b.runtimeBinding === "data.description"
b => b.runtimeBinding === "list-id.description"
)
expect(descriptionbinding).toBeDefined()
expect(descriptionbinding.readableBinding).toBe(
"list-name.Test Table.description"
)
const idbinding = contextBindings.find(b => b.runtimeBinding === "data._id")
const idbinding = contextBindings.find(
b => b.runtimeBinding === "list-id._id"
)
expect(idbinding).toBeDefined()
expect(idbinding.readableBinding).toBe("list-name.Test Table._id")
})
@ -65,13 +67,13 @@ describe("fetch bindable properties", () => {
expect(contextBindings.length).toBe(8)
const namebinding_parent = contextBindings.find(
b => b.runtimeBinding === "parent.data.name"
b => b.runtimeBinding === "list-id.name"
)
expect(namebinding_parent).toBeDefined()
expect(namebinding_parent.readableBinding).toBe("list-name.Test Table.name")
const descriptionbinding_parent = contextBindings.find(
b => b.runtimeBinding === "parent.data.description"
b => b.runtimeBinding === "list-id.description"
)
expect(descriptionbinding_parent).toBeDefined()
expect(descriptionbinding_parent.readableBinding).toBe(
@ -79,7 +81,7 @@ describe("fetch bindable properties", () => {
)
const namebinding_own = contextBindings.find(
b => b.runtimeBinding === "data.name"
b => b.runtimeBinding === "child-list-id.name"
)
expect(namebinding_own).toBeDefined()
expect(namebinding_own.readableBinding).toBe(
@ -87,7 +89,7 @@ describe("fetch bindable properties", () => {
)
const descriptionbinding_own = contextBindings.find(
b => b.runtimeBinding === "data.description"
b => b.runtimeBinding === "child-list-id.description"
)
expect(descriptionbinding_own).toBeDefined()
expect(descriptionbinding_own.readableBinding).toBe(
@ -104,7 +106,7 @@ describe("fetch bindable properties", () => {
r => r.instance._id === "list-item-input-id" && r.type === "instance"
)
expect(componentBinding).toBeDefined()
expect(componentBinding.runtimeBinding).toBe("list-item-input-id.value")
expect(componentBinding.runtimeBinding).toBe("list-item-input-id")
})
it("should not return components from child context", () => {

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
.DS_Store
node_modules
package-lock.json
yarn.lock
release/
dist/

View File

@ -1,13 +0,0 @@
module.exports = {
presets: ["@babel/preset-env"],
sourceMaps: "inline",
retainLines: true,
plugins: [
[
"@babel/plugin-transform-runtime",
{
regenerator: true,
},
],
],
}

View File

@ -6,56 +6,27 @@
"module": "dist/budibase-client.esm.mjs",
"scripts": {
"build": "rollup -c",
"test": "jest",
"dev:builder": "rollup -cw"
},
"jest": {
"globals": {
"GLOBALS": {
"client": "web"
}
},
"testURL": "http://test.com",
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/internals/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"moduleFileExtensions": [
"js",
"svelte"
],
"moduleDirectories": [
"node_modules"
],
"transform": {
"^.+js$": "babel-jest",
"^.+.svelte$": "svelte-jester"
},
"transformIgnorePatterns": [
"/node_modules/(?!svelte).+\\.js$"
]
},
"dependencies": {
"deep-equal": "^2.0.1",
"mustache": "^4.0.1",
"regexparam": "^1.3.0"
"regexparam": "^1.3.0",
"svelte-spa-router": "^3.0.5"
},
"devDependencies": {
"@babel/core": "^7.5.5",
"@babel/plugin-transform-runtime": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/runtime": "^7.5.5",
"babel-jest": "^24.8.0",
"@budibase/standard-components": "^0.3.8",
"@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-node-resolve": "^10.0.0",
"fs-extra": "^8.1.0",
"jest": "^24.8.0",
"jsdom": "^16.0.1",
"rollup": "^1.12.0",
"rollup-plugin-commonjs": "^10.0.0",
"rollup": "^2.33.2",
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-svelte": "^6.1.1",
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^4.0.4",
"svelte": "^3.29.7",
"svelte": "^3.30.0",
"svelte-jester": "^1.0.6"
},
"gitHead": "e4e053cb6ff9a0ddc7115b44ccaa24b8ec41fb9a"

View File

@ -1,31 +1,30 @@
import resolve from "rollup-plugin-node-resolve"
import commonjs from "rollup-plugin-commonjs"
import commonjs from "@rollup/plugin-commonjs"
import resolve from "@rollup/plugin-node-resolve"
import builtins from "rollup-plugin-node-builtins"
import nodeglobals from "rollup-plugin-node-globals"
import svelte from "rollup-plugin-svelte"
const production = !process.env.ROLLUP_WATCH
export default {
input: "src/index.js",
output: [
{
sourcemap: true,
format: "iife",
name: "app",
file: `./dist/budibase-client.js`,
},
{
file: "dist/budibase-client.esm.mjs",
format: "esm",
sourcemap: "inline",
file: `./dist/budibase-client.js`,
},
],
plugins: [
svelte({
dev: !production,
}),
resolve({
preferBuiltins: true,
browser: true,
dedupe: ["svelte", "svelte/internal"],
}),
commonjs(),
builtins(),
nodeglobals(),
],
watch: {
clearScreen: false,

View File

@ -0,0 +1,96 @@
import { getAppId } from "../utils/getAppId"
/**
* API cache for cached request responses.
*/
let cache = {}
/**
* Makes a fully formatted URL based on the SDK configuration.
*/
const makeFullURL = path => {
return `/${path}`.replace("//", "/")
}
/**
* Handler for API errors.
*/
const handleError = error => {
return { error }
}
/**
* Performs an API call to the server.
* App ID header is always correctly set.
*/
const makeApiCall = async ({ method, url, body, json = true }) => {
try {
const requestBody = json ? JSON.stringify(body) : body
let headers = {
Accept: "application/json",
"Content-Type": "application/json",
"x-budibase-app-id": getAppId(),
}
if (!window["##BUDIBASE_IN_BUILDER##"]) {
headers["x-budibase-type"] = "client"
}
const response = await fetch(url, {
method,
headers,
body: requestBody,
credentials: "same-origin",
})
switch (response.status) {
case 200:
return response.json()
case 404:
return handleError(`${url}: Not Found`)
case 400:
return handleError(`${url}: Bad Request`)
case 403:
return handleError(`${url}: Forbidden`)
default:
if (response.status >= 200 && response.status < 400) {
return response.json()
}
return handleError(`${url} - ${response.statusText}`)
}
} catch (error) {
return handleError(error)
}
}
/**
* Performs an API call to the server and caches the response.
* Future invocation for this URL will return the cached result instead of
* hitting the server again.
*/
const makeCachedApiCall = async params => {
const identifier = params.url
if (!identifier) {
return null
}
if (!cache[identifier]) {
cache[identifier] = makeApiCall(params)
cache[identifier] = await cache[identifier]
}
return await cache[identifier]
}
/**
* Constructs an API call function for a particular HTTP method.
*/
const requestApiCall = method => async params => {
const { url, cache = false } = params
const fullURL = makeFullURL(url)
const enrichedParams = { ...params, method, url: fullURL }
return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams)
}
export default {
post: requestApiCall("POST"),
get: requestApiCall("GET"),
patch: requestApiCall("PATCH"),
del: requestApiCall("DELETE"),
error: handleError,
}

View File

@ -0,0 +1,10 @@
import API from "./api"
/**
* Fetches screen definition for an app.
*/
export const fetchAppDefinition = async appId => {
return await API.get({
url: `/api/applications/${appId}/definition`,
})
}

View File

@ -0,0 +1,12 @@
import API from "./api"
/**
* Uploads an attachment to the server.
*/
export const uploadAttachment = async data => {
return await API.post({
url: "/api/attachments/upload",
body: data,
json: false,
})
}

View File

@ -0,0 +1,17 @@
import API from "./api"
/**
* Performs a log in request.
*/
export const logIn = async ({ username, password }) => {
if (!username) {
return API.error("Please enter your username")
}
if (!password) {
return API.error("Please enter your password")
}
return await API.post({
url: "/api/authenticate",
body: { username, password },
})
}

View File

@ -1,28 +0,0 @@
import appStore from "../state/store"
export const USER_STATE_PATH = "_bbuser"
export const authenticate = api => async ({ username, password }) => {
if (!username) {
api.error("Authenticate: username not set")
return
}
if (!password) {
api.error("Authenticate: password not set")
return
}
const user = await api.post({
url: "/api/authenticate",
body: { username, password },
})
// set user even if error - so it is defined at least
appStore.update(s => {
s[USER_STATE_PATH] = user
return s
})
localStorage.setItem("budibase:user", JSON.stringify(user))
}

View File

@ -0,0 +1,32 @@
import { fetchTableData } from "./tables"
import { fetchViewData } from "./views"
import { fetchRelationshipData } from "./relationships"
import { enrichRows } from "./rows"
/**
* Fetches all rows for a particular Budibase data source.
*/
export const fetchDatasource = async (datasource, dataContext) => {
if (!datasource || !datasource.type) {
return []
}
// Fetch all rows in data source
const { type, tableId, fieldName } = datasource
let rows = []
if (type === "table") {
rows = await fetchTableData(tableId)
} else if (type === "view") {
rows = await fetchViewData(datasource)
} else if (type === "link") {
const row = dataContext[datasource.providerId]
rows = await fetchRelationshipData({
rowId: row?._id,
tableId: row?.tableId,
fieldName,
})
}
// Enrich rows
return await enrichRows(rows, tableId)
}

View File

@ -1,120 +1,9 @@
import { authenticate } from "./authenticate"
import { getAppId } from "../render/getAppId"
export async function baseApiCall(method, url, body) {
return await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
"x-budibase-app-id": getAppId(window.document.cookie),
"x-budibase-type": "client",
},
body: body && JSON.stringify(body),
credentials: "same-origin",
})
}
const apiCall = method => async ({ url, body }) => {
const response = await baseApiCall(method, url, body)
switch (response.status) {
case 200:
return response.json()
case 404:
return error(`${url} Not found`)
case 400:
return error(`${url} Bad Request`)
case 403:
return error(`${url} Forbidden`)
default:
if (response.status >= 200 && response.status < 400) {
return response.json()
}
return error(`${url} - ${response.statusText}`)
}
}
const post = apiCall("POST")
const get = apiCall("GET")
const patch = apiCall("PATCH")
const del = apiCall("DELETE")
const ERROR_MEMBER = "##error"
const error = message => {
// appStore.update(s => s["##error_message"], message)
return { [ERROR_MEMBER]: message }
}
const isSuccess = obj => !obj || !obj[ERROR_MEMBER]
const apiOpts = {
isSuccess,
error,
post,
get,
patch,
delete: del,
}
const saveRow = async (params, state) =>
await post({
url: `/api/${params.tableId}/rows`,
body: makeRowRequestBody(params, state),
})
const updateRow = async (params, state) => {
const row = makeRowRequestBody(params, state)
row._id = params._id
await patch({
url: `/api/${params.tableId}/rows/${params._id}`,
body: row,
})
}
const deleteRow = async params =>
await del({
url: `/api/${params.tableId}/rows/${params.rowId}/${params.revId}`,
})
const makeRowRequestBody = (parameters, state) => {
// start with the row thats currently in context
const body = { ...(state.data || {}) }
// dont send the table
if (body._table) delete body._table
// then override with supplied parameters
if (parameters.fields) {
for (let fieldName of Object.keys(parameters.fields)) {
const field = parameters.fields[fieldName]
// ensure fields sent are of the correct type
if (field.type === "boolean") {
if (field.value === "true") body[fieldName] = true
if (field.value === "false") body[fieldName] = false
} else if (field.type === "number") {
const val = parseFloat(field.value)
if (!isNaN(val)) {
body[fieldName] = val
}
} else if (field.type === "datetime") {
const date = new Date(field.value)
if (!isNaN(date.getTime())) {
body[fieldName] = date.toISOString()
}
} else {
body[fieldName] = field.value
}
}
}
return body
}
export default {
authenticate: authenticate(apiOpts),
saveRow,
updateRow,
deleteRow,
}
export * from "./rows"
export * from "./auth"
export * from "./datasources"
export * from "./tables"
export * from "./attachments"
export * from "./views"
export * from "./relationships"
export * from "./routes"
export * from "./app"

View File

@ -0,0 +1,14 @@
import API from "./api"
import { enrichRows } from "./rows"
/**
* Fetches related rows for a certain field of a certain row.
*/
export const fetchRelationshipData = async ({ tableId, rowId, fieldName }) => {
if (!tableId || !rowId || !fieldName) {
return []
}
const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` })
const rows = response[fieldName] || []
return await enrichRows(rows, tableId)
}

View File

@ -0,0 +1,10 @@
import API from "./api"
/**
* Fetches available routes for the client app.
*/
export const fetchRoutes = async () => {
return await API.get({
url: `/api/routing/client`,
})
}

View File

@ -0,0 +1,86 @@
import API from "./api"
import { fetchTableDefinition } from "./tables"
/**
* Fetches data about a certain row in a table.
*/
export const fetchRow = async ({ tableId, rowId }) => {
const row = await API.get({
url: `/api/${tableId}/rows/${rowId}`,
})
return (await enrichRows([row], tableId))[0]
}
/**
* Creates a row in a table.
*/
export const saveRow = async row => {
return await API.post({
url: `/api/${row.tableId}/rows`,
body: row,
})
}
/**
* Updates a row in a table.
*/
export const updateRow = async row => {
return await API.patch({
url: `/api/${row.tableId}/rows/${row._id}`,
body: row,
})
}
/**
* Deletes a row from a table.
*/
export const deleteRow = async ({ tableId, rowId, revId }) => {
return await API.del({
url: `/api/${tableId}/rows/${rowId}/${revId}`,
})
}
/**
* Deletes many rows from a table.
*/
export const deleteRows = async ({ tableId, rows }) => {
return await API.post({
url: `/api/${tableId}/rows`,
body: {
rows,
type: "delete",
},
})
}
/**
* Enriches rows which contain certain field types so that they can
* be properly displayed.
*/
export const enrichRows = async (rows, tableId) => {
if (rows && rows.length && tableId) {
// Fetch table schema so we can check column types
const tableDefinition = await fetchTableDefinition(tableId)
const schema = tableDefinition && tableDefinition.schema
if (schema) {
const keys = Object.keys(schema)
rows.forEach(row => {
for (let key of keys) {
const type = schema[key].type
if (type === "link") {
// Enrich row with the count of any relationship fields
row[`${key}_count`] = Array.isArray(row[key]) ? row[key].length : 0
} else if (type === "attachment") {
// Enrich row with the first image URL for any attachment fields
let url = null
if (Array.isArray(row[key]) && row[key][0] != null) {
url = row[key][0].url
}
row[`${key}_first`] = url
}
}
})
}
}
return rows
}

View File

@ -0,0 +1,18 @@
import API from "./api"
import { enrichRows } from "./rows"
/**
* Fetches a table definition.
* Since definitions cannot change at runtime, the result is cached.
*/
export const fetchTableDefinition = async tableId => {
return await API.get({ url: `/api/tables/${tableId}`, cache: true })
}
/**
* Fetches all rows from a table.
*/
export const fetchTableData = async tableId => {
const rows = await API.get({ url: `/api/${tableId}/rows` })
return await enrichRows(rows, tableId)
}

View File

@ -0,0 +1,30 @@
import API from "./api"
import { enrichRows } from "./rows"
/**
* Fetches all rows in a view.
*/
export const fetchViewData = async ({
name,
field,
groupBy,
calculation,
tableId,
}) => {
const params = new URLSearchParams()
if (calculation) {
params.set("field", field)
params.set("calculation", calculation)
}
if (groupBy) {
params.set("group", groupBy)
}
const QUERY_VIEW_URL = field
? `/api/views/${name}?${params}`
: `/api/views/${name}`
const rows = await API.get({ url: QUERY_VIEW_URL })
return await enrichRows(rows, tableId)
}

View File

@ -0,0 +1,25 @@
<script>
import { writable } from "svelte/store"
import { setContext, onMount } from "svelte"
import Component from "./Component.svelte"
import SDK from "../sdk"
import { createDataStore, routeStore, screenStore } from "../store"
// Provide contexts
setContext("sdk", SDK)
setContext("component", writable({}))
setContext("data", createDataStore())
let loaded = false
// Load app config
onMount(async () => {
await routeStore.actions.fetchRoutes()
await screenStore.actions.fetchScreens()
loaded = true
})
</script>
{#if loaded}
<Component definition={$screenStore.page.props} />
{/if}

View File

@ -0,0 +1,53 @@
<script>
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import * as ComponentLibrary from "@budibase/standard-components"
import Router from "./Router.svelte"
import { enrichProps } from "../utils/componentProps"
import { bindingStore, builderStore } from "../store"
export let definition = {}
// Get local data binding context
const dataContext = getContext("data")
// Create component context
const componentStore = writable({})
setContext("component", componentStore)
// Extract component definition info
$: constructor = getComponentConstructor(definition._component)
$: children = definition._children
$: id = definition._id
$: enrichedProps = enrichProps(definition, $dataContext, $bindingStore)
// Update component context
// ID is duplicated inside style so that the "styleable" helper can set
// an ID data tag for unique reference to components
$: componentStore.set({ id, styles: { ...definition._styles, id } })
// Gets the component constructor for the specified component
const getComponentConstructor = component => {
const split = component?.split("/")
const name = split?.[split.length - 1]
return name === "screenslot" ? Router : ComponentLibrary[name]
}
// Returns a unique key to let svelte know when to remount components.
// If a component is selected we want to remount it every time any props
// change.
const getChildKey = childId => {
const selected = childId === $builderStore.selectedComponentId
return selected ? `${childId}-${$builderStore.previewId}` : childId
}
</script>
{#if constructor}
<svelte:component this={constructor} {...enrichedProps}>
{#if children && children.length}
{#each children as child (getChildKey(child._id))}
<svelte:self definition={child} />
{/each}
{/if}
</svelte:component>
{/if}

View File

@ -0,0 +1,15 @@
<script>
import { getContext, setContext } from "svelte"
import { createDataStore } from "../store"
export let row
// Clone and create new data context for this component tree
const dataContext = getContext("data")
const component = getContext("component")
const newData = createDataStore($dataContext)
setContext("data", newData)
$: newData.actions.addContext(row, $component.id)
</script>
<slot />

View File

@ -0,0 +1,38 @@
<script>
import { getContext } from "svelte"
import Router from "svelte-spa-router"
import { routeStore } from "../store"
import Screen from "./Screen.svelte"
const { styleable } = getContext("sdk")
const component = getContext("component")
$: routerConfig = getRouterConfig($routeStore.routes)
const getRouterConfig = routes => {
let config = {}
routes.forEach(route => {
config[route.path] = Screen
})
// Add catch-all route so that we serve the Screen component always
config["*"] = Screen
return config
}
const onRouteLoading = ({ detail }) => {
routeStore.actions.setActiveRoute(detail.route)
}
</script>
{#if routerConfig}
<div use:styleable={$component.styles}>
<Router on:routeLoading={onRouteLoading} routes={routerConfig} />
</div>
{/if}
<style>
div {
position: relative;
}
</style>

View File

@ -0,0 +1,35 @@
<script>
import { fade } from "svelte/transition"
import { screenStore, routeStore } from "../store"
import Component from "./Component.svelte"
// Keep route params up to date
export let params = {}
$: routeStore.actions.setRouteParams(params || {})
// Get the screen definition for the current route
$: screenDefinition = $screenStore.activeScreen?.props
// Redirect to home page if no matching route
$: screenDefinition == null && routeStore.actions.navigate("/")
// Make a screen array so we can use keying to properly re-render each screen
$: screens = screenDefinition ? [screenDefinition] : []
</script>
{#each screens as screen (screen._id)}
<div in:fade>
<Component definition={screen} />
</div>
{/each}
<style>
div {
flex: 1 1 auto;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
</style>

View File

@ -1,91 +0,0 @@
import { attachChildren } from "./render/attachChildren"
import { createTreeNode } from "./render/prepareRenderComponent"
import { screenRouter } from "./render/screenRouter"
import { createStateManager } from "./state/stateManager"
import { getAppId } from "./render/getAppId"
export const createApp = ({
componentLibraries,
frontendDefinition,
window,
}) => {
let routeTo
let currentUrl
let screenStateManager
const onScreenSlotRendered = screenSlotNode => {
const onScreenSelected = (screen, url) => {
const stateManager = createStateManager({
componentLibraries,
onScreenSlotRendered: () => {},
routeTo,
})
const getAttachChildrenParams = attachChildrenParams(stateManager)
screenSlotNode.props._children = [screen.props]
const initialiseChildParams = getAttachChildrenParams(screenSlotNode)
attachChildren(initialiseChildParams)(screenSlotNode.rootElement, {
hydrate: true,
force: true,
})
if (screenStateManager) screenStateManager.destroy()
screenStateManager = stateManager
currentUrl = url
}
routeTo = screenRouter({
screens: frontendDefinition.screens,
onScreenSelected,
window,
})
const fallbackPath = window.location.pathname.replace(
getAppId(window.document.cookie),
""
)
routeTo(currentUrl || fallbackPath)
}
const attachChildrenParams = stateManager => {
const getInitialiseParams = treeNode => ({
componentLibraries,
treeNode,
onScreenSlotRendered,
setupState: stateManager.setup,
})
return getInitialiseParams
}
let rootTreeNode
const pageStateManager = createStateManager({
componentLibraries,
onScreenSlotRendered,
// seems weird, but the routeTo variable may not be available at this point
routeTo: url => routeTo(url),
})
const initialisePage = (page, target, urlPath) => {
currentUrl = urlPath
rootTreeNode = createTreeNode()
rootTreeNode.props = {
_children: [page.props],
}
const getInitialiseParams = attachChildrenParams(pageStateManager)
const initChildParams = getInitialiseParams(rootTreeNode)
attachChildren(initChildParams)(target, {
hydrate: true,
force: true,
})
return rootTreeNode
}
return {
initialisePage,
screenStore: () => screenStateManager.store,
pageStore: () => pageStateManager.store,
routeTo: () => routeTo,
rootNode: () => rootTreeNode,
}
}

View File

@ -1,59 +1,25 @@
import { createApp } from "./createApp"
import { builtins, builtinLibName } from "./render/builtinComponents"
import { getAppId } from "./render/getAppId"
import ClientApp from "./components/ClientApp.svelte"
import { builderStore } from "./store"
/**
* create a web application from static budibase definition files.
* @param {object} opts - configuration options for budibase client libary
*/
export const loadBudibase = async opts => {
const _window = (opts && opts.window) || window
// const _localStorage = (opts && opts.localStorage) || localStorage
const appId = getAppId(window.document.cookie)
const frontendDefinition = _window["##BUDIBASE_FRONTEND_DEFINITION##"]
let app
const user = {}
const componentLibraryModules = (opts && opts.componentLibraries) || {}
const libraries = frontendDefinition.libraries || []
for (let library of libraries) {
// fetch the JavaScript for the component libraries from the server
componentLibraryModules[library] = await import(
`/componentlibrary?library=${encodeURI(library)}&appId=${appId}`
)
}
componentLibraryModules[builtinLibName] = builtins(_window)
const {
initialisePage,
screenStore,
pageStore,
routeTo,
rootNode,
} = createApp({
componentLibraries: componentLibraryModules,
frontendDefinition,
user,
window: _window,
const loadBudibase = () => {
// Update builder store with any builder flags
builderStore.set({
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
page: window["##BUDIBASE_PREVIEW_PAGE##"],
screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],
previewId: window["##BUDIBASE_PREVIEW_ID##"],
})
const route = _window.location
? _window.location.pathname.replace(`${appId}/`, "").replace(appId, "")
: ""
initialisePage(frontendDefinition.page, _window.document.body, route)
return {
screenStore,
pageStore,
routeTo,
rootNode,
// Create app if one hasn't been created yet
if (!app) {
app = new ClientApp({
target: window.document.body,
})
}
}
if (window) {
window.loadBudibase = loadBudibase
}
// Attach to window so the HTML template can call this when it loads
window.loadBudibase = loadBudibase

View File

@ -1,138 +0,0 @@
import { prepareRenderComponent } from "./prepareRenderComponent"
import { isScreenSlot } from "./builtinComponents"
import deepEqual from "deep-equal"
import appStore from "../state/store"
export const attachChildren = initialiseOpts => (htmlElement, options) => {
const {
componentLibraries,
treeNode,
onScreenSlotRendered,
setupState,
} = initialiseOpts
const anchor = options && options.anchor ? options.anchor : null
const force = options ? options.force : false
const hydrate = options ? options.hydrate : true
const context = options && options.context
if (!force && treeNode.children.length > 0) return treeNode.children
for (let childNode of treeNode.children) {
childNode.destroy()
}
if (!htmlElement) return
if (hydrate) {
while (htmlElement.firstChild) {
htmlElement.removeChild(htmlElement.firstChild)
}
}
const contextStoreKeys = []
// create new context if supplied
if (context) {
let childIndex = 0
// if context is an array, map to new structure
const contextArray = Array.isArray(context) ? context : [context]
for (let ctx of contextArray) {
const key = appStore.create(
ctx,
treeNode.props._id,
childIndex,
treeNode.contextStoreKey
)
contextStoreKeys.push(key)
childIndex++
}
}
const childNodes = []
const createChildNodes = contextStoreKey => {
for (let childProps of treeNode.props._children) {
const { componentName, libName } = splitName(childProps._component)
if (!componentName || !libName) return
const ComponentConstructor = componentLibraries[libName][componentName]
const childNode = prepareRenderComponent({
props: childProps,
parentNode: treeNode,
ComponentConstructor,
htmlElement,
anchor,
// in same context as parent, unless a new one was supplied
contextStoreKey,
})
childNodes.push(childNode)
}
}
if (context) {
// if new context(s) is supplied, then create nodes
// with keys to new context stores
for (let contextStoreKey of contextStoreKeys) {
createChildNodes(contextStoreKey)
}
} else {
// otherwise, use same context store as parent
// which maybe undefined (therfor using the root state)
createChildNodes(treeNode.contextStoreKey)
}
// if everything is equal, then don't re-render
if (areTreeNodesEqual(treeNode.children, childNodes)) return treeNode.children
for (let node of childNodes) {
const initialProps = setupState(node)
node.render(initialProps)
}
const screenSlot = childNodes.find(n => isScreenSlot(n.props._component))
if (onScreenSlotRendered && screenSlot) {
// assuming there is only ever one screen slot
onScreenSlotRendered(screenSlot)
}
treeNode.children = childNodes
return childNodes
}
const splitName = fullname => {
const nameParts = fullname.split("/")
const componentName = nameParts[nameParts.length - 1]
const libName = fullname.substring(
0,
fullname.length - componentName.length - 1
)
return { libName, componentName }
}
const areTreeNodesEqual = (children1, children2) => {
if (children1.length !== children2.length) return false
if (children1 === children2) return true
let isEqual = false
for (let i = 0; i < children1.length; i++) {
// same context and same children, then nothing has changed
isEqual =
deepEqual(children1[i].context, children2[i].context) &&
areTreeNodesEqual(children1[i].children, children2[i].children)
if (!isEqual) return false
if (isScreenSlot(children1[i].parentNode.props._component)) {
isEqual = deepEqual(children1[i].props, children2[i].props)
}
if (!isEqual) return false
}
return true
}

View File

@ -1,10 +0,0 @@
import { screenSlotComponent } from "./screenSlotComponent"
export const builtinLibName = "##builtin"
export const isScreenSlot = componentName =>
componentName === "##builtin/screenslot"
export const builtins = window => ({
screenslot: screenSlotComponent(window),
})

View File

@ -1,88 +0,0 @@
import renderTemplateString from "../state/renderTemplateString"
import appStore from "../state/store"
import hasBinding from "../state/hasBinding"
export const prepareRenderComponent = ({
ComponentConstructor,
htmlElement,
anchor,
props,
parentNode,
contextStoreKey,
}) => {
const thisNode = createTreeNode()
thisNode.parentNode = parentNode
thisNode.props = props
thisNode.contextStoreKey = contextStoreKey
// the treeNode is first created (above), and then this
// render method is add. The treeNode is returned, and
// render is called later (in attachChildren)
thisNode.render = initialProps => {
thisNode.component = new ComponentConstructor({
target: htmlElement,
props: initialProps,
hydrate: false,
anchor,
})
// finds the root element of the component, which was created by the contructor above
// we use this later to attach a className to. This is how styles
// are applied by the builder
thisNode.rootElement = htmlElement.children[htmlElement.children.length - 1]
let [componentName] = props._component.match(/[a-z]*$/)
if (props._id && thisNode.rootElement) {
thisNode.rootElement.classList.add(`${componentName}-${props._id}`)
}
// make this node listen to the store
if (thisNode.stateBound) {
const unsubscribe = appStore.subscribe(state => {
const storeBoundProps = Object.keys(initialProps._bb.props).filter(p =>
hasBinding(initialProps._bb.props[p])
)
if (storeBoundProps.length > 0) {
const toSet = {}
for (let prop of storeBoundProps) {
const propValue = initialProps._bb.props[prop]
toSet[prop] = renderTemplateString(propValue, state)
}
thisNode.component.$set(toSet)
}
}, thisNode.contextStoreKey)
thisNode.unsubscribe = unsubscribe
}
}
return thisNode
}
export const createTreeNode = () => ({
context: {},
props: {},
rootElement: null,
parentNode: null,
children: [],
bindings: [],
component: null,
unsubscribe: () => {},
render: () => {},
get destroy() {
const node = this
return () => {
if (node.children) {
// destroy children first - from leaf nodes up
for (let child of node.children) {
child.destroy()
}
}
if (node.unsubscribe) node.unsubscribe()
if (node.component && node.component.$destroy) node.component.$destroy()
for (let onDestroyItem of node.onDestroy) {
onDestroyItem()
}
}
},
onDestroy: [],
})

View File

@ -1,124 +0,0 @@
import regexparam from "regexparam"
import appStore from "../state/store"
import { getAppId } from "./getAppId"
export const screenRouter = ({ screens, onScreenSelected, window }) => {
function sanitize(url) {
if (!url) return url
return url
.split("/")
.map(part => {
// if parameter, then use as is
if (part.startsWith(":")) return part
return encodeURIComponent(part)
})
.join("/")
.toLowerCase()
}
const isRunningLocally = () => {
const hostname = (window.location && window.location.hostname) || ""
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168")
)
}
const makeRootedPath = url => {
if (isRunningLocally()) {
const appId = getAppId(window.document.cookie)
if (url) {
url = sanitize(url)
if (!url.startsWith("/")) {
url = `/${url}`
}
if (url.startsWith(`/${appId}`)) {
return url
}
return `/${appId}${url}`
}
return `/${appId}`
}
return sanitize(url)
}
const routes = screens.map(screen =>
makeRootedPath(screen.routing ? screen.routing.route : null)
)
let fallback = routes.findIndex(([p]) => p === makeRootedPath("*"))
if (fallback < 0) fallback = 0
let current
function route(url) {
const _url = makeRootedPath(url.state || url)
current = routes.findIndex(
p =>
p !== makeRootedPath("*") &&
new RegExp("^" + p.toLowerCase() + "$").test(_url.toLowerCase())
)
const params = {}
if (current === -1) {
routes.forEach((p, i) => {
// ignore home - which matched everything
if (p === makeRootedPath("*")) return
const pm = regexparam(p)
const matches = pm.pattern.exec(_url)
if (!matches) return
let j = 0
while (j < pm.keys.length) {
params[pm.keys[j]] = matches[++j] || null
}
current = i
})
}
appStore.update(state => {
state["##routeParams"] = params
return state
})
const screenIndex = current !== -1 ? current : fallback
try {
!url.state && history.pushState(_url, null, _url)
} catch (_) {
// ignoring an exception here as the builder runs an iframe, which does not like this
}
onScreenSelected(screens[screenIndex], _url)
}
function click(e) {
const x = e.target.closest("a")
const y = x && x.getAttribute("href")
if (
e.ctrlKey ||
e.metaKey ||
e.altKey ||
e.shiftKey ||
e.button ||
e.defaultPrevented
)
return
const target = (x && x.target) || "_self"
if (!y || target !== "_self" || x.host !== location.host) return
e.preventDefault()
route(y)
}
addEventListener("popstate", route)
addEventListener("pushstate", route)
addEventListener("click", click)
return route
}

View File

@ -1,14 +0,0 @@
export const screenSlotComponent = window => {
return function(opts) {
const node = window.document.createElement("DIV")
const $set = props => {
props._bb.attachChildren(node)
}
const $destroy = () => {
if (opts.target && node) opts.target.removeChild(node)
}
this.$set = $set
this.$destroy = $destroy
opts.target.appendChild(node)
}
}

View File

@ -0,0 +1,18 @@
import * as API from "./api"
import { authStore, routeStore, screenStore, bindingStore } from "./store"
import { styleable } from "./utils/styleable"
import { getAppId } from "./utils/getAppId"
import { link as linkable } from "svelte-spa-router"
import DataProvider from "./components/DataProvider.svelte"
export default {
API,
authStore,
routeStore,
screenStore,
styleable,
linkable,
getAppId,
DataProvider,
setBindableValue: bindingStore.actions.setBindableValue,
}

View File

@ -1,43 +0,0 @@
import setBindableComponentProp from "./setBindableComponentProp"
import { attachChildren } from "../render/attachChildren"
import store from "../state/store"
import { baseApiCall } from "../api/index"
export const bbFactory = ({
componentLibraries,
onScreenSlotRendered,
runEventActions,
}) => {
const api = {
post: (url, body) => baseApiCall("POST", url, body),
get: (url, body) => baseApiCall("GET", url, body),
patch: (url, body) => baseApiCall("PATCH", url, body),
delete: (url, body) => baseApiCall("DELETE", url, body),
}
return (treeNode, setupState) => {
const attachParams = {
componentLibraries,
treeNode,
onScreenSlotRendered,
setupState,
}
return {
attachChildren: attachChildren(attachParams),
props: treeNode.props,
call: async eventName =>
eventName &&
(await runEventActions(
treeNode.props[eventName],
store.getState(treeNode.contextStoreKey)
)),
setBinding: setBindableComponentProp(treeNode),
api,
parent,
store: store.getStore(treeNode.contextStoreKey),
// these parameters are populated by screenRouter
routeParams: () => store.getState()["##routeParams"],
}
}
}

View File

@ -1,43 +0,0 @@
import api from "../api"
import renderTemplateString from "./renderTemplateString"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = routeTo => {
const handlers = {
"Navigate To": param => routeTo(param && param.url),
"Update Row": api.updateRow,
"Save Row": api.saveRow,
"Delete Row": api.deleteRow,
"Trigger Workflow": api.triggerWorkflow,
}
// when an event is called, this is what gets run
const runEventActions = async (actions, state) => {
if (!actions) return
// calls event handlers sequentially
for (let action of actions) {
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]]
const parameters = createParameters(action.parameters, state)
if (handler) {
await handler(parameters, state)
}
}
}
return runEventActions
}
// this will take a parameters obj, iterate all keys, and do a mustache render
// for every string. It will work recursively if it encounnters an {}
const createParameters = (parameterTemplateObj, state) => {
const parameters = {}
for (let key in parameterTemplateObj) {
if (typeof parameterTemplateObj[key] === "string") {
parameters[key] = renderTemplateString(parameterTemplateObj[key], state)
} else if (typeof parameterTemplateObj[key] === "object") {
parameters[key] = createParameters(parameterTemplateObj[key], state)
}
}
return parameters
}

View File

@ -1,11 +0,0 @@
export const setContext = treeNode => (key, value) =>
(treeNode.context[key] = value)
export const getContext = treeNode => key => {
if (treeNode.context && treeNode.context[key] !== undefined)
return treeNode.context[key]
if (!treeNode.context.$parent) return
return getContext(treeNode.parentNode)(key)
}

View File

@ -1 +0,0 @@
export default value => typeof value === "string" && value.includes("{{")

View File

@ -1,17 +0,0 @@
import mustache from "mustache"
// this is a much more liberal version of mustache's escape function
// ...just ignoring < and > to prevent tags from user input
// original version here https://github.com/janl/mustache.js/blob/4b7908f5c9fec469a11cfaed2f2bed23c84e1c5c/mustache.js#L78
const entityMap = {
"<": "&lt;",
">": "&gt;",
}
mustache.escape = text =>
String(text).replace(/[&<>"'`=/]/g, function fromEntityMap(s) {
return entityMap[s] || s
})
export default mustache.render

View File

@ -1,13 +0,0 @@
import appStore from "./store"
export default treeNode => (propName, value) => {
if (!propName || propName.length === 0) return
if (!treeNode) return
const componentId = treeNode.props._id
appStore.update(state => {
state[componentId] = state[componentId] || {}
state[componentId][propName] = value
return state
}, treeNode.contextStoreKey)
}

View File

@ -1,65 +0,0 @@
import { eventHandlers } from "./eventHandlers"
import { bbFactory } from "./bbComponentApi"
import renderTemplateString from "./renderTemplateString"
import appStore from "./store"
import hasBinding from "./hasBinding"
const doNothing = () => {}
doNothing.isPlaceholder = true
const isMetaProp = propName =>
propName === "_component" ||
propName === "_children" ||
propName === "_id" ||
propName === "_style" ||
propName === "_code" ||
propName === "_codeMeta" ||
propName === "_styles"
export const createStateManager = ({
componentLibraries,
onScreenSlotRendered,
routeTo,
}) => {
let runEventActions = eventHandlers(routeTo)
const bb = bbFactory({
componentLibraries,
onScreenSlotRendered,
runEventActions,
})
const setup = _setup(bb)
return {
setup,
destroy: () => {},
}
}
const _setup = bb => node => {
const props = node.props
const initialProps = { ...props }
for (let propName in props) {
if (isMetaProp(propName)) continue
const propValue = props[propName]
const isBound = hasBinding(propValue)
if (isBound) {
const state = appStore.getState(node.contextStoreKey)
initialProps[propName] = renderTemplateString(propValue, state)
if (!node.stateBound) {
node.stateBound = true
}
}
}
const setup = _setup(bb)
initialProps._bb = bb(node, setup)
return initialProps
}

View File

@ -1,108 +0,0 @@
import { writable } from "svelte/store"
// we assume that the reference to this state object
// will remain for the life of the application
const rootState = {}
const rootStore = writable(rootState)
const contextStores = {}
// contextProviderId is the component id that provides the data for the context
const contextStoreKey = (dataProviderId, childIndex) =>
`${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}`
// creates a store for a datacontext (e.g. each item in a list component)
// overrides store if already exists
const create = (data, dataProviderId, childIndex, parentContextStoreId) => {
const key = contextStoreKey(dataProviderId, childIndex)
const state = { data }
// add reference to parent state object,
// so we can use bindings like state.parent.parent
// (if no parent, then parent is rootState )
state.parent = parentContextStoreId
? contextStores[parentContextStoreId].state
: rootState
contextStores[key] = {
store: writable(state),
subscriberCount: 0,
state,
parentContextStoreId,
}
return key
}
const subscribe = (subscription, storeKey) => {
if (!storeKey) {
return rootStore.subscribe(subscription)
}
const contextStore = contextStores[storeKey]
// we are subscribing to multiple stores,
// we dont want to run our listener for every subscription, the first time
// as this could repeatedly run $set on the same component
// ... which already has its initial properties set properly
const ignoreFirstSubscription = () => {
let hasRunOnce = false
return () => {
if (hasRunOnce) subscription(contextStore.state)
hasRunOnce = true
}
}
const unsubscribes = [rootStore.subscribe(ignoreFirstSubscription())]
// we subscribe to all stores in the hierarchy
const ancestorSubscribe = ctxStore => {
// unsubscribe func returned by svelte store
const svelteUnsub = ctxStore.store.subscribe(ignoreFirstSubscription())
// we wrap the svelte unsubscribe, so we can
// cleanup stores when they are no longer subscribed to
const unsub = () => {
ctxStore.subscriberCount = contextStore.subscriberCount - 1
// when no subscribers left, we delete the store
if (ctxStore.subscriberCount === 0) {
delete ctxStore[storeKey]
}
svelteUnsub()
}
unsubscribes.push(unsub)
if (ctxStore.parentContextStoreId) {
ancestorSubscribe(contextStores[ctxStore.parentContextStoreId])
}
}
ancestorSubscribe(contextStore)
// our final unsubscribe function calls unsubscribe on all stores
return () => unsubscribes.forEach(u => u())
}
const findStore = (dataProviderId, childIndex) =>
dataProviderId
? contextStores[contextStoreKey(dataProviderId, childIndex)].store
: rootStore
const update = (updatefunc, dataProviderId, childIndex) =>
findStore(dataProviderId, childIndex).update(updatefunc)
const set = (value, dataProviderId, childIndex) =>
findStore(dataProviderId, childIndex).set(value)
const getState = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey].state : rootState
const getStore = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey].store : rootStore
export default {
subscribe,
update,
set,
getState,
create,
contextStoreKey,
getStore,
}

View File

@ -0,0 +1,32 @@
import * as API from "../api"
import { getAppId } from "../utils/getAppId"
import { writable } from "svelte/store"
const createAuthStore = () => {
const store = writable("")
const logIn = async ({ username, password }) => {
const user = await API.logIn({ username, password })
if (!user.error) {
store.set(user.token)
location.reload()
}
}
const logOut = () => {
store.set("")
const appId = getAppId()
if (appId) {
for (let environment of ["local", "cloud"]) {
window.document.cookie = `budibase:${appId}:${environment}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;`
}
}
location.reload()
}
return {
subscribe: store.subscribe,
actions: { logIn, logOut },
}
}
export const authStore = createAuthStore()

View File

@ -0,0 +1,21 @@
import { writable } from "svelte/store"
const createBindingStore = () => {
const store = writable({})
const setBindableValue = (value, componentId) => {
store.update(state => {
if (componentId) {
state[componentId] = value
}
return state
})
}
return {
subscribe: store.subscribe,
actions: { setBindableValue },
}
}
export const bindingStore = createBindingStore()

View File

@ -0,0 +1,14 @@
import { writable } from "svelte/store"
const createBuilderStore = () => {
const initialState = {
inBuilder: false,
page: null,
screen: null,
selectedComponentId: null,
previewId: null,
}
return writable(initialState)
}
export const builderStore = createBuilderStore()

View File

@ -0,0 +1,26 @@
import { writable } from "svelte/store"
import { cloneDeep } from "lodash/fp"
export const createDataStore = existingContext => {
const store = writable({ ...existingContext })
// Adds a context layer to the data context tree
const addContext = (row, componentId) => {
store.update(state => {
if (componentId) {
state[componentId] = row
state[`${componentId}_draft`] = cloneDeep(row)
state.closestComponentId = componentId
}
return state
})
}
return {
subscribe: store.subscribe,
update: store.update,
actions: { addContext },
}
}
export const dataStore = createDataStore()

View File

@ -0,0 +1,8 @@
export { authStore } from "./auth"
export { routeStore } from "./routes"
export { screenStore } from "./screens"
export { builderStore } from "./builder"
export { bindingStore } from "./binding"
// Data stores are layered and duplicated, so it is not a singleton
export { createDataStore, dataStore } from "./data"

View File

@ -0,0 +1,49 @@
import { writable } from "svelte/store"
import { push } from "svelte-spa-router"
import * as API from "../api"
const createRouteStore = () => {
const initialState = {
routes: [],
routeParams: {},
activeRoute: null,
}
const store = writable(initialState)
const fetchRoutes = async () => {
const routeConfig = await API.fetchRoutes()
let routes = []
Object.values(routeConfig.routes).forEach(route => {
Object.entries(route.subpaths).forEach(([path, config]) => {
routes.push({
path,
screenId: config.screenId,
})
})
})
store.update(state => {
state.routes = routes
return state
})
}
const setRouteParams = routeParams => {
store.update(state => {
state.routeParams = routeParams
return state
})
}
const setActiveRoute = route => {
store.update(state => {
state.activeRoute = route
return state
})
}
const navigate = push
return {
subscribe: store.subscribe,
actions: { fetchRoutes, navigate, setRouteParams, setActiveRoute },
}
}
export const routeStore = createRouteStore()

View File

@ -0,0 +1,51 @@
import { writable, derived } from "svelte/store"
import { routeStore } from "./routes"
import { builderStore } from "./builder"
import * as API from "../api"
import { getAppId } from "../utils/getAppId"
const createScreenStore = () => {
const config = writable({
screens: [],
page: {},
})
const store = derived(
[config, routeStore, builderStore],
([$config, $routeStore, $builderStore]) => {
let page
let activeScreen
if ($builderStore.inBuilder) {
// Use builder defined definitions if inside the builder preview
page = $builderStore.page
activeScreen = $builderStore.screen
} else {
// Otherwise find the correct screen by matching the current route
page = $config.page
const { screens } = $config
if (screens.length === 1) {
activeScreen = screens[0]
} else {
activeScreen = screens.find(
screen => screen.routing.route === $routeStore.activeRoute
)
}
}
return { page, activeScreen }
}
)
const fetchScreens = async () => {
const appDefinition = await API.fetchAppDefinition(getAppId())
config.set({
screens: appDefinition.screens,
page: appDefinition.page,
})
}
return {
subscribe: store.subscribe,
actions: { fetchScreens },
}
}
export const screenStore = createScreenStore()

View File

@ -0,0 +1,45 @@
import { enrichDataBinding } from "./enrichDataBinding"
import { routeStore } from "../store"
import { saveRow, deleteRow } from "../api"
const saveRowHandler = async (action, context) => {
let draft = context[`${action.parameters.contextPath}_draft`]
if (action.parameters.fields) {
Object.entries(action.parameters.fields).forEach(([key, entry]) => {
draft[key] = enrichDataBinding(entry.value, context)
})
}
await saveRow(draft)
}
const deleteRowHandler = async (action, context) => {
const { tableId, revId, rowId } = action.parameters
await deleteRow({
tableId: enrichDataBinding(tableId, context),
rowId: enrichDataBinding(rowId, context),
revId: enrichDataBinding(revId, context),
})
}
const navigationHandler = action => {
routeStore.actions.navigate(action.parameters.url)
}
const handlerMap = {
["Save Row"]: saveRowHandler,
["Delete Row"]: deleteRowHandler,
["Navigate To"]: navigationHandler,
}
/**
* Parses an array of actions and returns a function which will execute the
* actions in the current context.
*/
export const enrichButtonActions = (actions, context) => {
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]])
return async () => {
for (let i = 0; i < handlers.length; i++) {
await handlers[i](actions[i], context)
}
}
}

View File

@ -0,0 +1,35 @@
import { enrichDataBindings } from "./enrichDataBinding"
import { enrichButtonActions } from "./buttonActions"
/**
* Enriches component props.
* Data bindings are enriched, and button actions are enriched.
*/
export const enrichProps = (props, dataContexts, dataBindings) => {
// Exclude all private props that start with an underscore
let validProps = {}
Object.entries(props)
.filter(([name]) => !name.startsWith("_"))
.forEach(([key, value]) => {
validProps[key] = value
})
// Create context of all bindings and data contexts
// Duplicate the closest context as "data" which the builder requires
const context = {
...dataContexts,
...dataBindings,
data: dataContexts[dataContexts.closestComponentId],
data_draft: dataContexts[`${dataContexts.closestComponentId}_draft`],
}
// Enrich all data bindings in top level props
let enrichedProps = enrichDataBindings(validProps, context)
// Enrich button actions if they exist
if (props._component.endsWith("/button") && enrichedProps.onClick) {
enrichedProps.onClick = enrichButtonActions(enrichedProps.onClick, context)
}
return enrichedProps
}

View File

@ -0,0 +1,46 @@
import mustache from "mustache"
// this is a much more liberal version of mustache's escape function
// ...just ignoring < and > to prevent tags from user input
// original version here https://github.com/janl/mustache.js/blob/4b7908f5c9fec469a11cfaed2f2bed23c84e1c5c/mustache.js#L78
const entityMap = {
"<": "&lt;",
">": "&gt;",
}
mustache.escape = text => {
if (text == null || typeof text !== "string") {
return text
}
return text.replace(/[<>]/g, function fromEntityMap(s) {
return entityMap[s] || s
})
}
// Regex to test inputs with to see if they are likely candidates for mustache
const looksLikeMustache = /{{.*}}/
/**
* Enriches a given input with a row from the database.
*/
export const enrichDataBinding = (input, context) => {
// Only accept string inputs
if (!input || typeof input !== "string") {
return input
}
// Do a fast regex check if this looks like a mustache string
if (!looksLikeMustache.test(input)) {
return input
}
return mustache.render(input, context)
}
/**
* Enriches each prop in a props object
*/
export const enrichDataBindings = (props, context) => {
let enrichedProps = {}
Object.entries(props).forEach(([key, value]) => {
enrichedProps[key] = enrichDataBinding(value, context)
})
return enrichedProps
}

View File

@ -9,6 +9,9 @@ function confirmAppId(possibleAppId) {
}
function tryGetFromCookie({ cookies }) {
if (!cookies) {
return undefined
}
const cookie = cookies
.split(COOKIE_SEPARATOR)
.find(cookie => cookie.trim().startsWith("budibase:currentapp"))
@ -30,7 +33,7 @@ function tryGetFromSubdomain() {
return confirmAppId(appId)
}
export const getAppId = cookies => {
export const getAppId = (cookies = window.document.cookie) => {
const functions = [tryGetFromSubdomain, tryGetFromPath, tryGetFromCookie]
// try getting the app Id in order
let appId
@ -42,5 +45,3 @@ export const getAppId = cookies => {
}
return appId
}
export const getAppIdFromPath = getAppId

View File

@ -0,0 +1,66 @@
/**
* Helper to build a CSS string from a style object
*/
const buildStyleString = styles => {
let str = ""
Object.entries(styles).forEach(([style, value]) => {
if (style && value != null) {
str += `${style}: ${value}; `
}
})
return str
}
/**
* Svelte action to apply correct component styles.
*/
export const styleable = (node, styles = {}) => {
let applyNormalStyles
let applyHoverStyles
// Creates event listeners and applies initial styles
const setupStyles = newStyles => {
const normalStyles = newStyles.normal || {}
const hoverStyles = {
...normalStyles,
...newStyles.hover,
}
applyNormalStyles = () => {
node.style = buildStyleString(normalStyles)
}
applyHoverStyles = () => {
node.style = buildStyleString(hoverStyles)
}
// Add listeners to toggle hover styles
node.addEventListener("mouseover", applyHoverStyles)
node.addEventListener("mouseout", applyNormalStyles)
node.setAttribute("data-bb-id", newStyles.id)
// Apply initial normal styles
applyNormalStyles()
}
// Removes the current event listeners
const removeListeners = () => {
node.removeEventListener("mouseover", applyHoverStyles)
node.removeEventListener("mouseout", applyNormalStyles)
}
// Apply initial styles
setupStyles(styles)
return {
// Clean up old listeners and apply new ones on update
update: newStyles => {
removeListeners()
setupStyles(newStyles)
},
// Clean up listeners when component is destroyed
destroy: () => {
removeListeners()
},
}
}

View File

@ -1,209 +0,0 @@
import { load, makePage, makeScreen } from "./testAppDef"
describe("binding", () => {
it("should bind to data in context", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/list",
data: dataArray,
_children: [
{
_component: "testlib/h1",
text: "{{data.name}}",
}
],
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children[0].children.length).toBe(2)
expect(screenRoot.children[0].children[0].innerText).toBe(dataArray[0].name)
expect(screenRoot.children[0].children[1].innerText).toBe(dataArray[1].name)
})
it("should bind to input in root", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/div",
_children: [
{
_component: "testlib/h1",
text: "{{inputid.value}}",
},
{
_id: "inputid",
_component: "testlib/input",
value: "hello"
}
],
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children[0].children.length).toBe(2)
expect(screenRoot.children[0].children[0].innerText).toBe("hello")
// change value of input
const input = dom.window.document.getElementsByClassName("input-inputid")[0]
changeInputValue(dom, input, "new value")
expect(screenRoot.children[0].children[0].innerText).toBe("new value")
})
it("should bind to input in context", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/list",
data: dataArray,
_children: [
{
_component: "testlib/h1",
text: "{{inputid.value}}",
},
{
_id: "inputid",
_component: "testlib/input",
value: "hello"
}
],
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children[0].children.length).toBe(4)
const firstHeader = screenRoot.children[0].children[0]
const firstInput = screenRoot.children[0].children[1]
const secondHeader = screenRoot.children[0].children[2]
const secondInput = screenRoot.children[0].children[3]
expect(firstHeader.innerText).toBe("hello")
expect(secondHeader.innerText).toBe("hello")
changeInputValue(dom, firstInput, "first input value")
expect(firstHeader.innerText).toBe("first input value")
changeInputValue(dom, secondInput, "second input value")
expect(secondHeader.innerText).toBe("second input value")
})
it("should bind contextual component, to input in root context", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/div",
_children: [
{
_id: "inputid",
_component: "testlib/input",
value: "hello"
},
{
_component: "testlib/list",
data: dataArray,
_children: [
{
_component: "testlib/h1",
text: "{{parent.inputid.value}}",
},
],
}
]
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children[0].children.length).toBe(2)
const input = screenRoot.children[0].children[0]
const firstHeader = screenRoot.children[0].children[1].children[0]
const secondHeader = screenRoot.children[0].children[1].children[0]
expect(firstHeader.innerText).toBe("hello")
expect(secondHeader.innerText).toBe("hello")
changeInputValue(dom, input, "new input value")
expect(firstHeader.innerText).toBe("new input value")
expect(secondHeader.innerText).toBe("new input value")
})
const changeInputValue = (dom, input, newValue) => {
var event = new dom.window.Event("change")
input.value = newValue
input.dispatchEvent(event)
}
const dataArray = [
{
name: "katherine",
age: 30,
},
{
name: "steve",
age: 41,
},
]
})

View File

@ -1,172 +0,0 @@
import { load, makePage, makeScreen } from "./testAppDef"
describe("initialiseApp", () => {
it("should populate simple div with initial props", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
className: "my-test-class",
})
)
expect(dom.window.document.body.children.length).toBe(1)
const child = dom.window.document.body.children[0]
expect(child.className.includes("my-test-class")).toBeTruthy()
})
it("should populate child component with props", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "testlib/h1",
text: "header one",
},
{
_component: "testlib/h1",
text: "header two",
},
],
})
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(2)
expect(rootDiv.children[0].tagName).toBe("H1")
expect(rootDiv.children[0].innerText).toBe("header one")
expect(rootDiv.children[1].tagName).toBe("H1")
expect(rootDiv.children[1].innerText).toBe("header two")
})
it("should append children when told to do so", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "testlib/h1",
text: "header one",
},
{
_component: "testlib/h1",
text: "header two",
},
],
append: true,
})
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(3)
expect(rootDiv.children[0].tagName).toBe("DIV")
expect(rootDiv.children[0].className).toBe("default-child")
expect(rootDiv.children[1].tagName).toBe("H1")
expect(rootDiv.children[1].innerText).toBe("header one")
expect(rootDiv.children[2].tagName).toBe("H1")
expect(rootDiv.children[2].innerText).toBe("header two")
})
it("should populate page with correct screen", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
},
],
}),
[
makeScreen("/", {
_component: "testlib/div",
className: "screen-class",
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
expect(rootDiv.children[0].children.length).toBe(1)
expect(
rootDiv.children[0].children[0].className.includes("screen-class")
).toBeTruthy()
})
it("should populate screen with children", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/div",
className: "screen-class",
_children: [
{
_component: "testlib/h1",
text: "header one",
},
{
_component: "testlib/h1",
text: "header two",
},
],
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children.length).toBe(1)
expect(screenRoot.children[0].children.length).toBe(2)
expect(screenRoot.children[0].children[0].innerText).toBe("header one")
expect(screenRoot.children[0].children[1].innerText).toBe("header two")
})
it("should repeat elements that pass an array of contexts", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/list",
data: [1,2,3,4],
_children: [
{
_component: "testlib/h1",
text: "header",
}
],
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children[0].children.length).toBe(4)
expect(screenRoot.children[0].children[0].innerText).toBe("header")
})
})

View File

@ -1,174 +0,0 @@
import { load, makePage, makeScreen, walkComponentTree } from "./testAppDef"
import { isScreenSlot } from "../src/render/builtinComponents"
jest.mock("../src/render/getAppId", () => ({
getAppId: () => "TEST_APP_ID"
}))
describe("screenRouting", () => {
it("should load correct screen, for initial URL", async () => {
const { page, screens } = pageWith3Screens()
const { dom } = await load(page, screens, "/screen2")
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children.length).toBe(1)
expect(screenRoot.children[0].children.length).toBe(1)
expect(screenRoot.children[0].children[0].innerText).toBe("screen 2")
})
it("should load correct screen, for initial URL, when appRootPath is something", async () => {
const { page, screens } = pageWith3Screens()
const { dom } = await load(page, screens, "/TEST_APP_ID/screen2", "127.0.0.1")
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children.length).toBe(1)
expect(screenRoot.children[0].children.length).toBe(1)
expect(screenRoot.children[0].children[0].innerText).toBe("screen 2")
})
it("should be able to route to the correct screen", async () => {
const { page, screens } = pageWith3Screens()
const { dom, app } = await load(page, screens, "/screen2")
app.routeTo()("/screen3")
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children.length).toBe(1)
expect(screenRoot.children[0].children.length).toBe(1)
expect(screenRoot.children[0].children[0].innerText).toBe("screen 3")
})
it("should be able to route to the correct screen, when appRootPath is something", async () => {
const { page, screens } = pageWith3Screens()
const { dom, app } = await load(
page,
screens,
"/TEST_APP_ID/screen2",
"127.0.0.1"
)
app.routeTo()("/screen3")
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children.length).toBe(1)
expect(screenRoot.children[0].children.length).toBe(1)
expect(screenRoot.children[0].children[0].innerText).toBe("screen 3")
})
it("should destroy and unsubscribe all components on a screen whe screen is changed", async () => {
const { page, screens } = pageWith3Screens()
const { app } = await load(page, screens, "/screen2")
const nodes = createTrackerNodes(app)
app.routeTo()("/screen3")
expect(nodes.length > 0).toBe(true)
expect(
nodes.some(n => n.isDestroyed === false && isUnderScreenSlot(n.node))
).toBe(false)
expect(
nodes.some(n => n.isUnsubscribed === false && isUnderScreenSlot(n.node))
).toBe(false)
})
it("should not destroy and unsubscribe page and screenslot components when screen is changed", async () => {
const { page, screens } = pageWith3Screens()
const { app } = await load(page, screens, "/screen2")
const nodes = createTrackerNodes(app)
app.routeTo()("/screen3")
expect(nodes.length > 0).toBe(true)
expect(
nodes.some(n => n.isDestroyed === true && !isUnderScreenSlot(n.node))
).toBe(false)
})
})
const createTrackerNodes = app => {
const nodes = []
walkComponentTree(app.rootNode(), n => {
if (!n.component) return
const tracker = { node: n, isDestroyed: false, isUnsubscribed: false }
const _destroy = n.component.$destroy
n.component.$destroy = () => {
_destroy()
tracker.isDestroyed = true
}
const _unsubscribe = n.unsubscribe
if (!_unsubscribe) {
tracker.isUnsubscribed = undefined
} else {
n.unsubscribe = () => {
_unsubscribe()
tracker.isUnsubscribed = true
}
}
nodes.push(tracker)
})
return nodes
}
const isUnderScreenSlot = node =>
node.parentNode &&
(isScreenSlot(node.parentNode.props._component) ||
isUnderScreenSlot(node.parentNode))
const pageWith3Screens = () => ({
page: makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
screens: [
makeScreen("/", {
_component: "testlib/div",
className: "screen-class",
_children: [
{
_component: "testlib/h1",
text: "screen 1",
},
],
}),
makeScreen("/screen2", {
_component: "testlib/div",
className: "screen-class",
_children: [
{
_component: "testlib/h1",
text: "screen 2",
},
],
}),
makeScreen("/screen3", {
_component: "testlib/div",
className: "screen-class",
_children: [
{
_component: "testlib/h1",
text: "screen 3",
},
],
}),
],
})

View File

@ -1,244 +0,0 @@
import jsdom, { JSDOM } from "jsdom"
import { loadBudibase } from "../src/index"
export const APP_ID = "TEST_APP_ID"
export const load = async (page, screens, url, host = "test.com") => {
screens = screens || []
url = url || "/"
const fullUrl = `http://${host}${url}`
const cookieJar = new jsdom.CookieJar()
const cookie = `${btoa("{}")}.${btoa(`{"appId":"${APP_ID}"}`)}.signature`
cookieJar.setCookie(
`budibase:${APP_ID}:local=${cookie};domain=${host};path=/`,
fullUrl,
{
looseMode: false,
},
() => {}
)
const dom = new JSDOM("<!DOCTYPE html><html><body></body><html>", {
url: fullUrl,
cookieJar,
})
autoAssignIds(page.props)
for (let s of screens) {
autoAssignIds(s.props)
}
setAppDef(dom.window, page, screens)
addWindowGlobals(dom.window, page, screens, {
hierarchy: {},
actions: [],
triggers: [],
})
setComponentCodeMeta(page, screens)
const app = await loadBudibase({
componentLibraries: allLibs(dom.window),
window: dom.window,
localStorage: createLocalStorage(),
})
return { dom, app }
}
const addWindowGlobals = (window, page, screens) => {
window["##BUDIBASE_FRONTEND_DEFINITION##"] = {
page,
screens,
}
}
export const makePage = props => ({ props })
export const makeScreen = (route, props) => ({
props,
routing: { route, accessLevelId: "" },
})
export const timeout = ms => new Promise(resolve => setTimeout(resolve, ms))
export const walkComponentTree = (node, action) => {
action(node)
// works for nodes or props
const children = node.children || node._children
if (children) {
for (let child of children) {
walkComponentTree(child, action)
}
}
}
// this happens for real by the builder...
// ..this only assigns _ids when missing
const autoAssignIds = (props, count = 0) => {
if (!props._id) {
props._id = `auto_id_${count}`
}
if (props._children) {
for (let child of props._children) {
count += 1
autoAssignIds(child, count)
}
}
}
// any component with an id that include "based_on_store" is
// assumed to have code that depends on store value
const setComponentCodeMeta = (page, screens) => {
const setComponentCodeMeta_single = props => {
walkComponentTree(props, c => {
if (c._id.indexOf("based_on_store") >= 0) {
c._codeMeta = { dependsOnStore: true }
}
})
}
setComponentCodeMeta_single(page.props)
for (let s of screens || []) {
setComponentCodeMeta_single(s.props)
}
}
const setAppDef = (window, page, screens) => {
window["##BUDIBASE_FRONTEND_DEFINITION##"] = {
componentLibraries: [],
page,
screens,
}
}
const allLibs = window => ({
testlib: maketestlib(window),
})
const createLocalStorage = () => {
const data = {}
return {
getItem: key => data[key],
setItem: (key, value) => (data[key] = value),
}
}
const maketestlib = window => ({
div: function(opts) {
const node = window.document.createElement("DIV")
const defaultChild = window.document.createElement("DIV")
defaultChild.className = "default-child"
node.appendChild(defaultChild)
let currentProps = { ...opts.props }
let childNodes = []
const set = props => {
currentProps = Object.assign(currentProps, props)
node.className = currentProps.className || ""
if (currentProps._children && currentProps._children.length > 0) {
if (currentProps.append) {
for (let c of childNodes) {
node.removeChild(c)
}
const components = currentProps._bb.attachChildren(node, {
hydrate: false,
})
childNodes = components.map(c => c.component._element)
} else {
currentProps._bb.attachChildren(node)
}
}
}
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
h1: function(opts) {
const node = window.document.createElement("H1")
let currentProps = { ...opts.props }
const set = props => {
currentProps = Object.assign(currentProps, props)
if (currentProps.text) {
node.innerText = currentProps.text
}
}
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
button: function(opts) {
const node = window.document.createElement("BUTTON")
let currentProps = { ...opts.props }
const set = props => {
currentProps = Object.assign(currentProps, props)
if (currentProps.onClick) {
node.addEventListener("click", () => {
currentProps._bb.call("onClick")
})
}
}
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
list: function(opts) {
const node = window.document.createElement("DIV")
let currentProps = { ...opts.props }
const set = props => {
currentProps = Object.assign(currentProps, props)
if (currentProps._children && currentProps._children.length > 0) {
currentProps._bb.attachChildren(node, {
context: currentProps.data || {},
})
}
}
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
input: function(opts) {
const node = window.document.createElement("INPUT")
let currentProps = { ...opts.props }
const set = props => {
currentProps = Object.assign(currentProps, props)
opts.props._bb.setBinding("value", props.value)
}
node.addEventListener("change", e => {
opts.props._bb.setBinding("value", e.target.value)
})
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
})

View File

@ -1,16 +0,0 @@
<script>
export let _bb
export let className = ""
let containerElement
let hasLoaded
$: {
if (containerElement) {
_bb.attachChildren(containerElement)
hasLoaded = true
}
}
</script>
<div bind:this={containerElement} class={className} />

2497
packages/client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -87,7 +87,7 @@
"pouchdb-all-dbs": "^1.0.2",
"pouchdb-replication-stream": "^1.2.9",
"sanitize-s3-objectkey": "^0.0.1",
"svelte": "^3.29.4",
"svelte": "^3.30.0",
"tar-fs": "^2.1.0",
"to-json-schema": "^0.2.5",
"uuid": "^3.3.2",

View File

@ -31,15 +31,13 @@ const MAIN = {
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "div",
_appId: "inst_app_80b_f158d4057d2c4bedb0042d42fda8abaf",
_instanceName: "Header",
_children: [
{
_id: "49e0e519-9e5e-4127-885a-ee6a0a49e2c1",
_component: "@budibase/standard-components/Navigation",
_component: "@budibase/standard-components/navigation",
_styles: {
normal: {
"max-width": "1400px",
@ -58,12 +56,6 @@ const MAIN = {
_code: "",
logoUrl:
"https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg",
title: "",
backgroundColor: "",
color: "",
borderWidth: "",
borderColor: "",
borderStyle: "",
_appId: "inst_cf8ace4_69efc0d72e6f443db2d4c902c14d9394",
_instanceName: "Navigation",
_children: [
@ -88,11 +80,6 @@ const MAIN = {
url: "/",
openInNewTab: false,
text: "Home",
color: "",
hoverColor: "",
underline: false,
fontSize: "",
fontFamily: "initial",
_appId: "inst_cf8ace4_69efc0d72e6f443db2d4c902c14d9394",
_instanceName: "Home Link",
_children: [],
@ -143,8 +130,6 @@ const MAIN = {
selected: {},
},
_code: "",
className: "",
onLoad: [],
},
}
@ -181,13 +166,7 @@ const UNAUTHENTICATED = {
selected: {},
},
_code: "",
loginRedirect: "",
usernameLabel: "Username",
passwordLabel: "Password",
loginButtonLabel: "Login",
buttonClass: "",
_instanceName: "Login",
inputClass: "",
_children: [],
title: "Log in to {{ name }}",
buttonText: "Log In",
@ -213,8 +192,6 @@ const UNAUTHENTICATED = {
selected: {},
},
_code: "",
className: "",
onLoad: [],
},
}

View File

@ -19,8 +19,6 @@ exports.HOME_SCREEN = {
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "div",
_children: [
{
@ -35,7 +33,6 @@ exports.HOME_SCREEN = {
selected: {},
},
_code: "",
className: "",
text: "Welcome to your Budibase App 👋",
type: "h2",
_appId: "inst_cf8ace4_69efc0d72e6f443db2d4c902c14d9394",
@ -61,8 +58,6 @@ exports.HOME_SCREEN = {
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "div",
_appId: "inst_app_2cc_ca3383f896034e9295345c05f7dfca0c",
_instanceName: "Video Container",

View File

@ -1,5 +1,4 @@
.DS_Store
node_modules
yarn.lock
package-lock.json
dist/

View File

@ -13,18 +13,12 @@
"embed": "string"
}
},
"Navigation": {
"navigation": {
"name": "Navigation",
"description": "A basic header navigation component",
"children": true,
"props": {
"logoUrl": "string",
"title": "string",
"backgroundColor": "string",
"color": "string",
"borderWidth": "string",
"borderColor": "string",
"borderStyle": "string"
"logoUrl": "string"
}
},
"button": {
@ -32,7 +26,6 @@
"description": "an html <button />",
"props": {
"text": "string",
"className": "string",
"disabled": "bool",
"onClick": "event"
},
@ -65,23 +58,8 @@
"name": "Login Control",
"description": "A control that accepts username, password an also handles password resets",
"props": {
"logo": "asset",
"loginRedirect": "string",
"logo": "string",
"title": "string",
"usernameLabel": {
"type": "string",
"default": "Username"
},
"passwordLabel": {
"type": "string",
"default": "Password"
},
"loginButtonLabel": {
"type": "string",
"default": "Login"
},
"buttonClass": "string",
"inputClass": "string",
"buttonText": "string"
},
"tags": [
@ -96,7 +74,6 @@
"bindable": "value",
"description": "An HTML input",
"props": {
"value": "string",
"type": {
"type": "options",
"options": [
@ -122,36 +99,15 @@
"week"
],
"default": "text"
},
"onChange": "event",
"className": "string"
}
},
"tags": [
"form"
]
},
"select": {
"name": "Select",
"bindable": "value",
"description": "An HTML <select> (dropdown)",
"props": {
"value": "string",
"className": "string"
}
},
"option": {
"name": "Option",
"description": "An HTML <option>, to be used with <select>",
"children": false,
"props": {
"value": "string",
"text": "string"
}
},
"text": {
"name": "Text",
"description": "stylable block of text",
"children": false,
"props": {
"text": "string",
"type": {
@ -164,16 +120,6 @@
"container"
]
},
"textfield": {
"name": "Textfield",
"description": "A component that allows the user to input text.",
"props": {
"label": "string",
"type": "string",
"value": "string",
"onchange": "event"
}
},
"richtext": {
"name": "Rich Text",
"description": "A component that allows the user to enter long form text.",
@ -181,28 +127,6 @@
"value": "string"
}
},
"checkbox": {
"name": "Checkbox",
"bindable": "value",
"description": "A selectable checkbox component",
"props": {
"label": "string",
"checked": "bool",
"value": "string",
"onchange": "event"
}
},
"radiobutton": {
"name": "Radiobutton",
"bindable": "value",
"description": "A selectable radiobutton component",
"props": {
"label": "string",
"checked": "bool",
"value": "string",
"onchange": "event"
}
},
"icon": {
"description": "A HTML icon tag",
"props": {
@ -217,17 +141,6 @@
}
}
},
"datatable": {
"description": "an HTML table that fetches data from a table or view and displays it.",
"data": true,
"props": {
"datasource": "tables",
"stripeColor": "string",
"borderColor": "string",
"backgroundColor": "string",
"color": "string"
}
},
"datagrid": {
"name": "Grid",
"description": "a datagrid component with functionality to add, remove and edit rows.",
@ -269,21 +182,6 @@
"data": true,
"props": {}
},
"datalist": {
"description": "A configurable data list that attaches to your backend tables.",
"data": true,
"props": {
"table": "tables",
"layout": {
"type": "options",
"default": "list",
"options": [
"list",
"grid"
]
}
}
},
"list": {
"name": "Repeater",
"description": "A configurable data list that attaches to your backend tables.",
@ -696,36 +594,13 @@
"props": {
"url": "string",
"openInNewTab": "bool",
"text": "string",
"color": "string",
"hoverColor": "string",
"underline": "bool",
"fontSize": "string",
"fontFamily": {
"type": "options",
"default": "initial",
"styleBindingProperty": "font-family",
"options": [
"initial",
"Times New Roman",
"Georgia",
"Arial",
"Arial Black",
"Comic Sans MS",
"Impact",
"Lucida Sans Unicode"
]
}
"text": "string"
}
},
"image": {
"description": "an HTML <img> tag",
"props": {
"url": "string",
"className": "string",
"description": "string",
"height": "string",
"width": "string"
"url": "string"
}
},
"container": {
@ -733,8 +608,6 @@
"children": true,
"description": "An element that contains and lays out other elements. e.g. <div>, <header> etc",
"props": {
"className": "string",
"onLoad": "event",
"type": {
"type": "options",
"options": [
@ -766,7 +639,6 @@
"name": "Heading",
"description": "An HTML H1 - H6 tag",
"props": {
"className": "string",
"text": "string",
"type": {
"type": "options",
@ -782,19 +654,5 @@
}
},
"tags": []
},
"thead": {
"name": "Table Head",
"description": "an HTML <thead> tab",
"props": {
"className": "string"
}
},
"tbody": {
"name": "Table Body",
"description": "an HTML <tbody> tab",
"props": {
"className": "string"
}
}
}

View File

@ -1,32 +1,29 @@
{
"name": "@budibase/standard-components",
"svelte": "src/index.svelte",
"main": "dist/index.js",
"module": "dist/index.js",
"scripts": {
"build": "rollup -c",
"prepublishOnly": "npm run build",
"postpublish": "node scripts/deploy.js",
"testbuild": "rollup -w -c rollup.testconfig.js",
"dev": "run-p start:dev testbuild",
"start:dev": "sirv public --single --dev",
"dev:builder": "rollup -cw"
},
"devDependencies": {
"@budibase/client": "^0.3.8",
"@rollup/plugin-commonjs": "^11.1.0",
"@rollup/plugin-alias": "^3.1.1",
"@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^10.0.0",
"@rollup/plugin-replace": "^2.3.4",
"lodash": "^4.17.15",
"rollup": "^2.11.2",
"rollup-plugin-commonjs": "^10.0.2",
"rollup-plugin-json": "^4.0.0",
"rollup-plugin-livereload": "^1.0.1",
"rollup-plugin-node-resolve": "^5.0.0",
"rollup-plugin-postcss": "^3.1.5",
"rollup-plugin-svelte": "^6.1.1",
"rollup-plugin-terser": "^7.0.2",
"shortid": "^2.2.15",
"sirv-cli": "^0.4.4",
"svelte": "^3.29.0"
"svelte": "^3.30.0"
},
"keywords": [
"svelte"
@ -35,12 +32,10 @@
"license": "MIT",
"gitHead": "284cceb9b703c38566c6e6363c022f79a08d5691",
"dependencies": {
"@budibase/bbui": "^1.50.1",
"@budibase/bbui": "^1.51.0",
"@budibase/svelte-ag-grid": "^0.0.16",
"@fortawesome/fontawesome-free": "^5.14.0",
"@svelteschool/svelte-forms": "^0.7.0",
"apexcharts": "^3.22.1",
"fast-sort": "^2.2.0",
"flatpickr": "^4.6.6",
"lodash.debounce": "^4.0.8",
"quill": "^1.3.7",

View File

@ -1,38 +1,32 @@
import svelte from "rollup-plugin-svelte"
import resolve from "rollup-plugin-node-resolve"
import commonjs from "@rollup/plugin-commonjs"
import resolve from "@rollup/plugin-node-resolve"
import svelte from "rollup-plugin-svelte"
import postcss from "rollup-plugin-postcss"
import { terser } from "rollup-plugin-terser"
const production = !process.env.ROLLUP_WATCH
const lodash_fp_exports = ["isEmpty"]
const externals = ["svelte", "svelte/internal"]
export default {
external: externals,
input: "src/index.js",
output: [
{
file: "dist/index.js",
format: "esm",
name: "budibaseStandardComponents",
sourcemap: true,
sourcemap: false,
},
],
plugins: [
// Only run terser in production environments
production && terser(),
postcss({
plugins: [],
}),
postcss(),
svelte({
hydratable: true,
dev: !production,
}),
resolve({
browser: true,
skip: externals,
}),
commonjs({
namedExports: {
"lodash/fp": lodash_fp_exports,
},
}),
commonjs(),
],
}

View File

@ -1,138 +0,0 @@
import svelte from "rollup-plugin-svelte"
import resolve from "rollup-plugin-node-resolve"
import commonjs from "rollup-plugin-commonjs"
import livereload from "rollup-plugin-livereload"
import { terser } from "rollup-plugin-terser"
import json from "rollup-plugin-json"
const production = !process.env.ROLLUP_WATCH
const lodash_fp_exports = [
"find",
"isUndefined",
"split",
"max",
"last",
"union",
"reduce",
"isObject",
"cloneDeep",
"some",
"isArray",
"map",
"filter",
"keys",
"isFunction",
"isEmpty",
"countBy",
"join",
"includes",
"flatten",
"constant",
"first",
"intersection",
"take",
"has",
"mapValues",
"isString",
"isBoolean",
"isNull",
"isNumber",
"isObjectLike",
"isDate",
"clone",
"values",
"keyBy",
"isNaN",
"isInteger",
"toNumber",
]
const lodash_exports = [
"flow",
"head",
"find",
"each",
"tail",
"findIndex",
"startsWith",
"dropRight",
"takeRight",
"trim",
"split",
"replace",
"merge",
"assign",
]
const coreExternal = [
"lodash",
"lodash/fp",
"date-fns",
"lunr",
"safe-buffer",
"shortid",
"@nx-js/compiler-util",
"bcryptjs",
]
export default {
input: "src/Test/testMain.js",
output: {
sourcemap: true,
format: "iife",
name: "app",
file: "public/bundle.js",
globals: {
crypto: "crypto",
},
},
plugins: [
svelte({
// enable run-time checks when not in production
dev: !production,
// we'll extract any component CSS out into
// a separate file — better for performance
css: css => {
css.write("bundle.css")
},
hydratable: true,
}),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration —
// consult the documentation for details:
// https://github.com/rollup/rollup-plugin-commonjs
resolve({
browser: true,
dedupe: importee => {
return (
importee === "svelte" ||
importee.startsWith("svelte/") ||
coreExternal.includes(importee)
)
},
}),
commonjs({
namedExports: {
"lodash/fp": lodash_fp_exports,
lodash: lodash_exports,
shortid: ["generate"],
},
}),
json(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload("public"),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser(),
],
watch: {
clearScreen: false,
},
}

View File

@ -1,25 +1,21 @@
<script>
import { getContext } from "svelte"
const { styleable } = getContext("sdk")
const component = getContext("component")
export let className = "default"
export let disabled = false
export let text
export let _bb
let theButton
$: if (_bb.props._children && _bb.props._children.length > 0)
theButton && _bb.attachChildren(theButton)
const clickHandler = () => {
_bb.call("onClick")
}
export let onClick
</script>
<button
bind:this={theButton}
class="default"
disabled={disabled || false}
on:click|once={clickHandler}>
{#if !_bb.props._children || _bb.props._children.length === 0}{text}{/if}
use:styleable={$component.styles}
on:click={onClick}>
{text}
</button>
<style>
@ -37,28 +33,4 @@
white-space: nowrap;
text-align: center;
}
.border {
border: var(--border);
}
.color {
color: var(--color);
}
.background {
background: var(--background);
}
.hoverBorder:hover {
border: var(--hoverBorder);
}
.hoverColor:hover {
color: var(--hoverColor);
}
.hoverBack:hover {
background: var(--hoverBackground);
}
</style>

View File

@ -1,5 +1,9 @@
<script>
import { cssVars, createClasses } from "./cssVars"
import { getContext } from "svelte"
import { cssVars } from "./helpers"
const { styleable } = getContext("sdk")
const component = getContext("component")
export const className = ""
export let imageUrl = ""
@ -22,7 +26,10 @@
$: showImage = !!imageUrl
</script>
<div use:cssVars={cssVariables} class="container">
<div
use:cssVars={cssVariables}
class="container"
use:styleable={$component.styles}>
{#if showImage}<img class="image" src={imageUrl} alt="" />{/if}
<div class="content">
<h2 class="heading">{heading}</h2>

View File

@ -1,5 +1,9 @@
<script>
import { cssVars, createClasses } from "./cssVars"
import { getContext } from "svelte"
import { cssVars } from "./helpers"
const { styleable } = getContext("sdk")
const component = getContext("component")
export const className = ""
export let imageUrl = ""
@ -25,7 +29,10 @@
$: showImage = !!imageUrl
</script>
<div use:cssVars={cssVariables} class="container">
<div
use:cssVars={cssVariables}
class="container"
use:styleable={$component.styles}>
{#if showImage}<img class="image" src={imageUrl} alt="" />{/if}
<div class="content">
<main>

View File

@ -1,11 +0,0 @@
<script>
import Input from "./Input.svelte"
export let _bb
export let label = ""
export let checked = false
export let value = ""
export let onchange = () => {}
</script>
<Input type="checkbox" {_bb} {checked} {label} {value} {onchange} />

View File

@ -1,50 +1,62 @@
<script>
import { cssVars, createClasses } from "./cssVars"
import { getContext } from "svelte"
const { styleable } = getContext("sdk")
const component = getContext("component")
export let className = ""
export let onLoad
export let type = "div"
export let _bb
let containerElement
let hasLoaded
let currentChildren
$: {
if (containerElement) {
_bb.attachChildren(containerElement)
if (!hasLoaded) {
_bb.call("onLoad")
hasLoaded = true
}
}
}
</script>
{#if type === 'div'}
<div bind:this={containerElement} />
<div use:styleable={$component.styles}>
<slot />
</div>
{:else if type === 'header'}
<header bind:this={containerElement} />
<header use:styleable={$component.styles}>
<slot />
</header>
{:else if type === 'main'}
<main bind:this={containerElement} />
<main use:styleable={$component.styles}>
<slot />
</main>
{:else if type === 'footer'}
<footer bind:this={containerElement} />
<footer use:styleable={$component.styles}>
<slot />
</footer>
{:else if type === 'aside'}
<aside bind:this={containerElement} />
<aside use:styleable={$component.styles}>
<slot />
</aside>
{:else if type === 'summary'}
<summary bind:this={containerElement} />
<summary use:styleable={$component.styles}>
<slot />
</summary>
{:else if type === 'details'}
<details bind:this={containerElement} />
<details use:styleable={$component.styles}>
<slot />
</details>
{:else if type === 'article'}
<article bind:this={containerElement} />
<article use:styleable={$component.styles}>
<slot />
</article>
{:else if type === 'nav'}
<nav bind:this={containerElement} />
<nav use:styleable={$component.styles}>
<slot />
</nav>
{:else if type === 'mark'}
<mark bind:this={containerElement} />
<mark use:styleable={$component.styles}>
<slot />
</mark>
{:else if type === 'figure'}
<figure bind:this={containerElement} />
<figure use:styleable={$component.styles}>
<slot />
</figure>
{:else if type === 'figcaption'}
<figcaption bind:this={containerElement} />
<figcaption use:styleable={$component.styles}>
<slot />
</figcaption>
{:else if type === 'paragraph'}
<p bind:this={containerElement} />
<p use:styleable={$component.styles}>
<slot />
</p>
{/if}

View File

@ -1,10 +1,5 @@
<script>
import Form from "./Form.svelte"
export let _bb
export let table
export let title
export let buttonText
</script>
<Form {_bb} {table} {title} {buttonText} wide={false} />
<Form wide={false} />

View File

@ -1,10 +1,5 @@
<script>
import Form from "./Form.svelte"
export let _bb
export let table
export let title
export let buttonText
</script>
<Form {_bb} {table} {title} {buttonText} wide={true} />
<Form wide />

View File

@ -1,20 +0,0 @@
import api from "../../api"
let cache = {}
async function fetchTable(id) {
const FETCH_TABLE_URL = `/api/tables/${id}`
const response = await api.get(FETCH_TABLE_URL)
return await response.json()
}
export async function getTable(tableId) {
if (!tableId) {
return null
}
if (!cache[tableId]) {
cache[tableId] = fetchTable(tableId)
cache[tableId] = await cache[tableId]
}
return await cache[tableId]
}

View File

@ -1,82 +0,0 @@
<script>
import { onMount } from "svelte"
export let _bb
export let table
export let layout = "list"
let headers = []
let store = _bb.store
async function fetchData() {
if (!table || !table.length) return
const FETCH_ROWS_URL = `/api/views/all_${table}`
const response = await _bb.api.get(FETCH_ROWS_URL)
if (response.status === 200) {
const json = await response.json()
store.update(state => {
state[table] = json
return state
})
} else {
throw new Error("Failed to fetch rows.", response)
}
}
$: data = $store[table] || []
$: if (table) fetchData()
onMount(async () => {
await fetchData()
})
</script>
<section class:grid={layout === 'grid'} class:list={layout === 'list'}>
{#each data as data}
<div class="data-card">
<ul>
{#each Object.keys(data) as key}
<li>
<span class="data-key">{key}:</span>
<span class="data-value">{data[key]}</span>
</li>
{/each}
</ul>
</div>
{/each}
</section>
<style>
.list {
display: flex;
flex-direction: column;
font-family: Inter;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
ul {
list-style-type: none;
}
li {
margin: 5px 0 5px 0;
}
.data-card {
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
}
.data-key {
font-weight: bold;
font-size: 20px;
text-transform: capitalize;
}
</style>

View File

@ -1,15 +0,0 @@
<script>
export let _bb
export let table
let searchValue = ""
function search() {
const SEARCH_URL = _bb.api.get(``)
}
</script>
<div>
<input type="text" bind:value={searchValue} />
<button on:click={search}>Search</button>
</div>

View File

@ -1,18 +1,21 @@
<script>
import Flatpickr from "svelte-flatpickr"
import { DatePicker } from "@budibase/bbui"
import { getContext } from "svelte"
const { styleable, setBindableValue } = getContext("sdk")
const component = getContext("component")
export let placeholder
export let value
export let _bb
let value
$: setBindableValue(value, $component.id)
function handleChange(event) {
const [fullDate, dateStr, instance] = event.detail
if (_bb) {
_bb.setBinding("value", fullDate)
}
const [fullDate] = event.detail
value = fullDate
}
</script>
<DatePicker {placeholder} on:change={handleChange} {value} />
<div use:styleable={$component.styles}>
<DatePicker {placeholder} on:change={handleChange} {value} />
</div>

View File

@ -1,5 +1,23 @@
<script>
import { getContext } from "svelte"
const { styleable } = getContext("sdk")
const component = getContext("component")
export let embed
</script>
{@html embed}
<div use:styleable={$component.styles}>
{@html embed}
</div>
<style>
div {
position: relative;
}
div :global(> *) {
width: 100%;
height: 100%;
position: absolute;
}
</style>

View File

@ -1,54 +1,66 @@
<script>
import { getContext } from "svelte"
import { Label, DatePicker, Input, Select, Toggle } from "@budibase/bbui"
import Dropzone from "./attachments/Dropzone.svelte"
import LinkedRowSelector from "./LinkedRowSelector.svelte"
import ErrorsBox from "./ErrorsBox.svelte"
import { capitalise } from "./helpers"
export let _bb
export let table
const { styleable, API } = getContext("sdk")
const component = getContext("component")
const dataContext = getContext("data")
export let wide = false
let store = _bb.store
let schema = {}
let rowId
let errors = {}
let row
let schema
let fields = []
$: schema = $store.data && $store.data._table && $store.data._table.schema
$: fields = schema ? Object.keys(schema) : []
// Fetch info about the closest data context
$: getFormData($dataContext[$dataContext.closestComponentId])
const getFormData = async context => {
if (context) {
const tableDefinition = await API.fetchTableDefinition(context.tableId)
schema = tableDefinition.schema
fields = Object.keys(schema)
// Use the draft version for editing
row = $dataContext[`${$dataContext.closestComponentId}_draft`]
}
}
</script>
<div class="form-content">
<ErrorsBox errors={$store.saveRowErrors || {}} />
<div class="form-content" use:styleable={$component.styles}>
<!-- <ErrorsBox errors={$store.saveRowErrors || {}} />-->
{#each fields as field}
<div class="form-field" class:wide>
{#if !(schema[field].type === 'boolean' && !wide)}
<Label extraSmall={!wide} grey>{capitalise(schema[field].name)}</Label>
{/if}
{#if schema[field].type === 'options'}
<Select secondary bind:value={$store.data[field]}>
<Select secondary bind:value={row[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={$store.data[field]} />
<DatePicker bind:value={row[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle
text={wide ? null : capitalise(schema[field].name)}
bind:checked={$store.data[field]} />
bind:checked={row[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={$store.data[field]} />
<Input type="number" bind:value={row[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={$store.data[field]} />
<Input bind:value={row[field]} />
{:else if schema[field].type === 'attachment'}
<Dropzone bind:files={$store.data[field]} />
<Dropzone bind:files={row[field]} />
{:else if schema[field].type === 'link'}
<LinkedRowSelector
secondary
showLabel={false}
bind:linkedRows={$store.data[field]}
bind:linkedRows={row[field]}
schema={schema[field]} />
{/if}
</div>

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