Merge branch 'master' of github.com:Budibase/budibase into stat-card

This commit is contained in:
Andrew Kingston 2020-12-04 09:00:43 +00:00
commit 3164b45f95
189 changed files with 9244 additions and 5773 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

@ -60,7 +60,7 @@ context("Create a Table", () => {
})
it("deletes a table", () => {
cy.contains(".nav-item", "dog").get(".actions").invoke("show").click()
cy.get(".actions").first().invoke("show").click()
cy.get("[data-cy=delete-table]").click()
cy.contains("Delete Table").click()
cy.contains("dog").should("not.exist")

View File

@ -9,9 +9,9 @@ context('Create a User', () => {
// https://on.cypress.io/interacting-with-elements
it('should create a user', () => {
cy.createUser('bbuser', 'test', 'POWER_USER')
cy.createUser("bbuser", "test", "ADMIN")
// Check to make sure user was created!
cy.get("input[disabled]").should('have.value', 'bbuser')
// // Check to make sure user was created!
cy.contains("bbuser").should('be.visible')
})
})

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

@ -113,24 +113,27 @@ Cypress.Commands.add("addRow", values => {
Cypress.Commands.add("createUser", (username, password, accessLevel) => {
// Create User
cy.get(".toprightnav > .settings").click()
cy.contains("Users").click()
cy.get("[name=Name]")
.first()
.type(username)
cy.get("[name=Password]")
cy.contains("Create New Row").click()
cy.get(".modal").within(() => {
cy.get("input")
.first()
.type(password)
cy.get("input")
.eq(1)
.type(username)
cy.get("select")
.first()
.select(accessLevel)
// Save
cy.get(".inputs")
.contains("Create")
cy.get(".buttons")
.contains("Create Row")
.click()
})
})
Cypress.Commands.add("addHeadlineComponent", text => {
cy.get(".switcher > :nth-child(2)").click()

View File

@ -63,7 +63,7 @@
}
},
"dependencies": {
"@budibase/bbui": "^1.50.2",
"@budibase/bbui": "^1.51.0",
"@budibase/client": "^0.3.8",
"@budibase/colorpicker": "^1.0.1",
"@budibase/svelte-ag-grid": "^0.0.16",
@ -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,14 +1,19 @@
<script>
import { Input, Select, Label, DatePicker, Toggle } from "@budibase/bbui"
import { Input, Select, Label, DatePicker, Toggle, RichText } from "@budibase/bbui"
import { backendUiStore } from "builderStore"
import { TableNames } from "constants"
import Dropzone from "components/common/Dropzone.svelte"
import { capitalise } from "../../../helpers"
import LinkedRowSelector from "components/common/LinkedRowSelector.svelte"
export let meta
export let creating
export let value = meta.type === "boolean" ? false : ""
$: type = meta.type
$: label = capitalise(meta.name)
$: editingUser =
!creating && $backendUiStore.selectedTable?._id === TableNames.USERS
</script>
{#if type === 'options'}
@ -29,6 +34,17 @@
<Toggle text={label} bind:checked={value} data-cy="{meta.name}-input" />
{:else if type === 'link'}
<LinkedRowSelector bind:linkedRows={value} schema={meta} />
{:else if type === 'longform'}
<div>
<Label extraSmall grey>{label}</Label>
<RichText bind:value />
</div>
{:else}
<Input thin {label} data-cy="{meta.name}-input" {type} bind:value />
<Input
thin
{label}
data-cy="{meta.name}-input"
{type}
bind:value
disabled={editingUser} />
{/if}

View File

@ -7,8 +7,8 @@ export async function createUser(user) {
}
export async function saveRow(row, tableId) {
const SAVE_ROWS_URL = `/api/${tableId}/rows`
const response = await api.post(SAVE_ROWS_URL, row)
const SAVE_ROW_URL = `/api/${tableId}/rows`
const response = await api.post(SAVE_ROW_URL, row)
return await response.json()
}

View File

@ -2,6 +2,7 @@
import { Input, Button, TextButton, Select, Toggle } from "@budibase/bbui"
import { cloneDeep } from "lodash/fp"
import { backendUiStore } from "builderStore"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
import { FIELDS } from "constants/backend"
import { notifier } from "builderStore/store/notifications"
import ValuesList from "components/common/ValuesList.svelte"
@ -30,6 +31,9 @@
table => table._id !== $backendUiStore.draftTable._id
)
$: required = !!field?.constraints?.presence || primaryDisplay
$: uneditable =
$backendUiStore.selectedTable?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(field.name)
async function saveColumn() {
backendUiStore.update(state => {
@ -87,7 +91,7 @@
</script>
<div class="actions" class:hidden={deletion}>
<Input label="Name" thin bind:value={field.name} />
<Input label="Name" thin bind:value={field.name} disabled={uneditable} />
<Select
disabled={originalName}
@ -101,7 +105,7 @@
{/each}
</Select>
{#if field.type !== 'link'}
{#if field.type !== 'link' && !uneditable}
<Toggle
checked={required}
on:change={onChangeRequired}
@ -157,7 +161,7 @@
bind:value={field.fieldName} />
{/if}
<footer class="create-column-options">
{#if originalName}
{#if !uneditable && originalName}
<TextButton text on:click={confirmDelete}>Delete Column</TextButton>
{/if}
<Button secondary on:click={onClosed}>Cancel</Button>

View File

@ -1,5 +1,6 @@
<script>
import { backendUiStore } from "builderStore"
import { TableNames } from "constants"
import { notifier } from "builderStore/store/notifications"
import RowFieldControl from "../RowFieldControl.svelte"
import * as api from "../api"
@ -21,9 +22,10 @@
{ ...row, tableId: table._id },
table._id
)
if (rowResponse.errors) {
errors = Object.keys(rowResponse.errors)
.map(k => ({ dataPath: k, message: rowResponse.errors[k] }))
errors = Object.entries(rowResponse.errors)
.map(([key, error]) => ({ dataPath: key, message: error }))
.flat()
// Prevent modal closing if there were errors
return false
@ -38,9 +40,15 @@
confirmText={creating ? 'Create Row' : 'Save Row'}
onConfirm={saveRow}>
<ErrorsBox {errors} />
{#if creating && table._id === TableNames.USERS}
<RowFieldControl
{creating}
meta={{ name: 'password', type: 'password' }}
bind:value={row.password} />
{/if}
{#each tableSchema as [key, meta]}
<div>
<RowFieldControl {meta} bind:value={row[key]} />
<RowFieldControl {meta} bind:value={row[key]} {creating} />
</div>
{/each}
</ModalContent>

View File

@ -1,6 +1,7 @@
<script>
import { goto } from "@sveltech/routify"
import { backendUiStore } from "builderStore"
import { TableNames } from "constants"
import ListItem from "./ListItem.svelte"
import CreateTableModal from "./modals/CreateTableModal.svelte"
import EditTablePopover from "./popovers/EditTablePopover.svelte"
@ -42,7 +43,7 @@
{#each $backendUiStore.tables as table, idx}
<NavItem
border={idx > 0}
icon="ri-table-line"
icon={`ri-${table._id === TableNames.USERS ? 'user' : 'table'}-line`}
text={table.name}
selected={selectedView === `all_${table._id}`}
on:click={() => selectTable(table)}>

View File

@ -39,7 +39,7 @@
async function deleteTable() {
await backendUiStore.actions.tables.delete(table)
store.store.actions.screens.delete(templateScreens)
store.actions.screens.delete(templateScreens)
await backendUiStore.actions.tables.fetch()
notifier.success("Table deleted")
hideEditor()

View File

@ -1,5 +1,5 @@
<script>
import { General, Users, DangerZone, APIKeys } from "./tabs"
import { General, DangerZone, APIKeys } from "./tabs"
import { Switcher, ModalContent } from "@budibase/bbui"
const tabs = [
@ -8,11 +8,6 @@
key: "GENERAL",
component: General,
},
{
title: "Users",
key: "USERS",
component: Users,
},
{
title: "API Keys",
key: "API_KEYS",

View File

@ -1,43 +0,0 @@
<script>
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
import { Input, Select, Button } from "@budibase/bbui"
export let user
let editMode = false
</script>
<div class="inputs">
<Input
disabled
thin
bind:value={user.username}
name="Name"
placeholder="Username" />
<Select disabled={!editMode} bind:value={user.accessLevelId} thin secondary>
<option value="">Choose an option</option>
<option value="ADMIN">Admin</option>
<option value="POWER_USER">Power User</option>
</Select>
{#if editMode}
<Button
blue
on:click={() => {
dispatch('save', user)
editMode = false
}}>
Save
</Button>
{:else}
<Button secondary on:click={() => (editMode = true)}>Edit</Button>
{/if}
</div>
<style>
.inputs {
display: grid;
justify-items: stretch;
grid-gap: var(--spacing-m);
grid-template-columns: 1fr 1fr 140px;
}
</style>

View File

@ -1,114 +0,0 @@
<script>
import { Input, Select, Button, Label } from "@budibase/bbui"
import UserRow from "../UserRow.svelte"
import { store, backendUiStore } from "builderStore"
import api from "builderStore/api"
// import * as api from "../api"
let username = ""
let password = ""
let accessLevelId = "ADMIN"
$: valid = username && password && accessLevelId
$: appId = $store.appId
// Create user!
async function createUser() {
if (valid) {
const user = { name: username, username, password, accessLevelId }
const response = await api.post(`/api/users`, user)
const json = await response.json()
backendUiStore.actions.users.create(json)
fetchUsersPromise = fetchUsers()
}
}
// Update user!
async function updateUser(event) {
let data = event.detail
delete data.password
const response = await api.put(`/api/users`, data)
const users = await response.json()
backendUiStore.update(state => {
state.users = users
return state
})
fetchUsersPromise = fetchUsers()
}
// Get users
async function fetchUsers() {
const response = await api.get(`/api/users`)
const users = await response.json()
backendUiStore.update(state => {
state.users = users
return state
})
return users
}
let fetchUsersPromise = fetchUsers()
</script>
<div class="container">
<div>
<Label extraSmall grey>Create New User</Label>
<div class="inputs">
<Input thin bind:value={username} name="Name" placeholder="Username" />
<Input
thin
type="password"
bind:value={password}
name="Password"
placeholder="Password" />
<Select secondary bind:value={accessLevelId} thin>
<option value="">Choose an option</option>
<option value="ADMIN">Admin</option>
<option value="POWER_USER">Power User</option>
</Select>
<Button on:click={createUser} primary>Create</Button>
</div>
</div>
<div>
<Label extraSmall grey>Current Users</Label>
{#await fetchUsersPromise}
Loading...
{:then users}
<ul>
{#each users as user}
<li>
<UserRow {user} on:save={updateUser} />
</li>
{:else}
<li>No Users found</li>
{/each}
</ul>
{:catch err}
Something went wrong when trying to fetch users. Please refresh (CMD + R /
CTRL + R) the page and try again.
{/await}
</div>
</div>
<style>
.container {
display: grid;
grid-gap: var(--spacing-xl);
}
.inputs {
display: grid;
justify-items: stretch;
grid-gap: var(--spacing-m);
grid-template-columns: 1fr 1fr 1fr 140px;
}
ul {
list-style: none;
padding: 0;
display: grid;
grid-gap: var(--spacing-m);
margin: 0;
}
</style>

View File

@ -1,6 +1,5 @@
export { default as General } from "./General.svelte"
export { default as Integrations } from "./Integrations.svelte"
export { default as Permissions } from "./Permissions.svelte"
export { default as Users } from "./Users.svelte"
export { default as APIKeys } from "./APIKeys.svelte"
export { default as DangerZone } from "./DangerZone.svelte"

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
$: {
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
}
styles = styles
}
$: stylesheetLinks = pipe($store.pages.stylesheets, [
map(s => `<link rel="stylesheet" href="${s}"/>`),
join("\n"),
])
$: screensExist =
$store.currentPreviewItem._screens &&
$store.currentPreviewItem._screens.length > 0
$: frontendDefinition = {
appId: $store.appId,
libraries: $store.libraries,
page: $store.pages[$store.currentPageName],
screens: [
// Extract data to pass to the iframe
$: page = $store.pages[$store.currentPageName]
$: screen =
$store.currentFrontEndType === "page"
? screenPlaceholder
: $store.currentPreviewItem,
],
: $store.currentPreviewItem
$: selectedComponentId = $store.currentComponentInfo?._id ?? ""
// 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)
}
}
$: selectedComponentType = getComponentTypeName($store.currentComponentInfo)
// Refresh the preview when required
$: refreshContent(strippedJson)
$: 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,
})
)
}
$: if (iframe)
iframe.contentWindow.addEventListener("bb-ready", refreshContent, {
// Initialise the app when mounted
onMount(() => {
iframe.contentWindow.addEventListener(
"bb-ready",
() => {
refreshContent(strippedJson)
},
{
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

@ -11,65 +11,43 @@ export default `<html>
*, *: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
// Extract data from message
const { selectedComponentId, page, screen } = JSON.parse(event.data)
const data = JSON.parse(event.data)
// 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()
try {
if (styles) document.head.removeChild(styles)
} catch(_) { }
try {
if (selectedComponentStyle) document.head.removeChild(selectedComponentStyle)
} catch(_) { }
selectedComponentStyle = document.createElement('style');
document.head.appendChild(selectedComponentStyle)
var selectedCss = '.' + data.selectedComponentType + '-' + data.selectedComponentId + '{ border: 2px solid #0055ff; }'
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;
// Initialise app
if (window.loadBudibase) {
loadBudibase({ window, localStorage })
loadBudibase()
}
}
let styles
let selectedComponentStyle
document.addEventListener("click", function(e) {
// Ignore clicks
["click", "mousedown"].forEach(type => {
document.addEventListener(type, function(e) {
e.preventDefault()
e.stopPropagation()
return false;
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 => ({
.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

@ -1217,7 +1217,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

@ -9,6 +9,16 @@ export const FIELDS = {
presence: false,
},
},
LONGFORM: {
name: "Long Form Text",
icon: "ri-file-text-line",
type: "longform",
constraints: {
type: "string",
length: {},
presence: false,
},
},
OPTIONS: {
name: "Options",
icon: "ri-list-check-2",

View File

@ -1,3 +1,10 @@
export const TableNames = {
USERS: "ta_users",
}
// fields on the user table that cannot be edited
export const UNEDITABLE_USER_FIELDS = ["username", "password", "accessLevelId"]
export const DEFAULT_PAGES_OBJECT = {
main: {
props: {

View File

@ -68,16 +68,11 @@
<div class="toprightnav">
<ThemeEditor />
<FeedbackNavLink />
<div class="topnavitemright">
<a target="_blank" href="https://docs.budibase.com">
<i class="ri-question-line" />
</a>
</div>
<div class="topnavitemright">
<a
target="_blank"
href="https://github.com/Budibase/budibase/discussions">
<i class="ri-discuss-line" />
<i class="ri-question-line" />
</a>
</div>
<SettingsLink />

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,52 @@
<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)
$: selected = id === $builderStore.selectedComponentId
// Update component context
$: componentStore.set({ id, styles: { ...definition._styles, selected } })
// 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) {
// 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,69 @@
/**
* Helper to build a CSS string from a style object
*/
const buildStyleString = (styles, selected) => {
let str = ""
if (selected) {
styles.border = "2px solid #0055ff !important"
}
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 selected = newStyles.selected
const normalStyles = newStyles.normal || {}
const hoverStyles = {
...normalStyles,
...newStyles.hover,
}
applyNormalStyles = () => {
node.style = buildStyleString(normalStyles, selected)
}
applyHoverStyles = () => {
node.style = buildStyleString(hoverStyles, selected)
}
// Add listeners to toggle hover styles
node.addEventListener("mouseover", applyHoverStyles)
node.addEventListener("mouseout", applyNormalStyles)
// 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} />

2164
packages/client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -81,12 +81,13 @@
"lodash": "^4.17.13",
"mustache": "^4.0.1",
"node-fetch": "^2.6.0",
"open": "^7.3.0",
"pino-pretty": "^4.0.0",
"pouchdb": "^7.2.1",
"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

@ -26,6 +26,7 @@ const {
const { MAIN, UNAUTHENTICATED, PageTypes } = require("../../constants/pages")
const { HOME_SCREEN } = require("../../constants/screens")
const { cloneDeep } = require("lodash/fp")
const { USERS_TABLE_SCHEMA } = require("../../constants")
const APP_PREFIX = DocumentTypes.APP + SEPARATOR
@ -67,6 +68,9 @@ async function createInstance(template) {
if (!ok) {
throw "Error loading database dump from template."
}
} else {
// create the users table
await db.put(USERS_TABLE_SCHEMA)
}
return { _id: appId }

View File

@ -6,7 +6,9 @@ const {
generateRowID,
DocumentTypes,
SEPARATOR,
ViewNames,
} = require("../../db/utils")
const usersController = require("./user")
const { cloneDeep } = require("lodash")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}`
@ -118,6 +120,16 @@ exports.save = async function(ctx) {
table,
})
// Creation of a new user goes to the user controller
if (!existingRow && row.tableId === ViewNames.USERS) {
try {
await usersController.create(ctx)
} catch (err) {
ctx.body = { errors: [err.message] }
}
return
}
if (existingRow) {
const response = await db.put(row)
row._rev = response.rev
@ -315,8 +327,8 @@ exports.fetchEnrichedRow = async function(ctx) {
ctx.status = 200
}
function coerceRowValues(rec, table) {
const row = cloneDeep(rec)
function coerceRowValues(record, table) {
const row = cloneDeep(record)
for (let [key, value] of Object.entries(row)) {
const field = table.schema[key]
if (!field) continue
@ -347,6 +359,11 @@ const TYPE_TRANSFORM_MAP = {
[null]: "",
[undefined]: undefined,
},
longform: {
"": "",
[null]: "",
[undefined]: undefined,
},
number: {
"": null,
[null]: null,

View File

@ -1,6 +1,6 @@
const CouchDB = require("../../db")
const bcrypt = require("../../utilities/bcrypt")
const { generateUserID, getUserParams } = require("../../db/utils")
const { generateUserID, getUserParams, ViewNames } = require("../../db/utils")
const {
BUILTIN_LEVEL_ID_ARRAY,
} = require("../../utilities/security/accessLevels")
@ -11,7 +11,7 @@ const {
exports.fetch = async function(ctx) {
const database = new CouchDB(ctx.user.appId)
const data = await database.allDocs(
getUserParams(null, {
getUserParams("", {
include_docs: true,
})
)
@ -44,6 +44,7 @@ exports.create = async function(ctx) {
type: "user",
accessLevelId,
permissions: permissions || [BUILTIN_PERMISSION_NAMES.POWER],
tableId: ViewNames.USERS,
}
try {

View File

@ -28,7 +28,7 @@ module.exports.definition = {
accessLevelId: {
type: "string",
title: "Access Level",
enum: accessLevels.BUILTIN_LEVEL_IDS,
enum: accessLevels.BUILTIN_LEVEL_ID_ARRAY,
pretty: accessLevels.BUILTIN_LEVEL_NAME_ARRAY,
},
},

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