Merge branch 'master' of github.com:Budibase/budibase into stat-card
This commit is contained in:
commit
3164b45f95
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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") })
|
||||
|
|
|
@ -113,23 +113,26 @@ 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]")
|
||||
.first()
|
||||
.type(password)
|
||||
cy.get("select")
|
||||
.first()
|
||||
.select(accessLevel)
|
||||
cy.contains("Create New Row").click()
|
||||
|
||||
// Save
|
||||
cy.get(".inputs")
|
||||
.contains("Create")
|
||||
.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(".buttons")
|
||||
.contains("Create Row")
|
||||
.click()
|
||||
})
|
||||
})
|
||||
|
||||
Cypress.Commands.add("addHeadlineComponent", text => {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"],
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -15,8 +15,6 @@ export class Component extends BaseStructure {
|
|||
selected: {},
|
||||
},
|
||||
_code: "",
|
||||
className: "",
|
||||
onLoad: [],
|
||||
type: "",
|
||||
_instanceName: "",
|
||||
_children: [],
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
||||
|
|
|
@ -1,140 +1,63 @@
|
|||
<script>
|
||||
import { store, backendUiStore } from "builderStore"
|
||||
import { map, join } from "lodash/fp"
|
||||
import { onMount } from "svelte"
|
||||
import { store } from "builderStore"
|
||||
import iframeTemplate from "./iframeTemplate"
|
||||
import { pipe } from "../../../helpers"
|
||||
import { Screen } from "../../../builderStore/store/screenTemplates/utils/Screen"
|
||||
import { Component } from "../../../builderStore/store/screenTemplates/utils/Component"
|
||||
import { Screen } from "builderStore/store/screenTemplates/utils/Screen"
|
||||
|
||||
let iframe
|
||||
let styles = ""
|
||||
|
||||
function transform_component(comp) {
|
||||
const props = comp.props || comp
|
||||
if (props && props._children && props._children.length) {
|
||||
props._children = props._children.map(transform_component)
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
|
||||
const getComponentTypeName = component => {
|
||||
let [componentName] = component._component.match(/[a-z]*$/)
|
||||
return componentName || "element"
|
||||
}
|
||||
|
||||
const headingStyle = {
|
||||
width: "500px",
|
||||
padding: "8px",
|
||||
}
|
||||
const textStyle = {
|
||||
...headingStyle,
|
||||
"max-width": "",
|
||||
"text-align": "left",
|
||||
}
|
||||
|
||||
const heading = new Component("@budibase/standard-components/heading")
|
||||
.normalStyle(headingStyle)
|
||||
.type("h1")
|
||||
.text("Screen Slot")
|
||||
.instanceName("Heading")
|
||||
const textScreenDisplay = new Component("@budibase/standard-components/text")
|
||||
.normalStyle(textStyle)
|
||||
.instanceName("Text")
|
||||
.type("none")
|
||||
.text(
|
||||
"The screens that you create will be displayed inside this box. This box is just a placeholder, to show you the position of screens."
|
||||
)
|
||||
const container = new Component("@budibase/standard-components/container")
|
||||
.normalStyle({
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
"align-items": "center",
|
||||
flex: "1 1 auto",
|
||||
})
|
||||
.type("div")
|
||||
.instanceName("Container")
|
||||
.addChild(heading)
|
||||
.addChild(textScreenDisplay)
|
||||
// Create screen slot placeholder for use when a page is selected rather
|
||||
// than a screen
|
||||
const screenPlaceholder = new Screen()
|
||||
.name("Screen Placeholder")
|
||||
.route("*")
|
||||
.component("@budibase/standard-components/container")
|
||||
.mainType("div")
|
||||
.component("@budibase/standard-components/screenslotplaceholder")
|
||||
.instanceName("Content Placeholder")
|
||||
.normalStyle({
|
||||
flex: "1 1 auto",
|
||||
})
|
||||
.addChild(container)
|
||||
.json()
|
||||
// TODO: this ID is attached to how the screen slot is rendered, confusing, would be better a type etc
|
||||
screenPlaceholder.props._id = "screenslot-placeholder"
|
||||
|
||||
$: hasComponent = !!$store.currentPreviewItem
|
||||
// Extract data to pass to the iframe
|
||||
$: page = $store.pages[$store.currentPageName]
|
||||
$: screen =
|
||||
$store.currentFrontEndType === "page"
|
||||
? screenPlaceholder
|
||||
: $store.currentPreviewItem
|
||||
$: selectedComponentId = $store.currentComponentInfo?._id ?? ""
|
||||
|
||||
$: {
|
||||
styles = ""
|
||||
// Apply the CSS from the currently selected page and its screens
|
||||
const currentPage = $store.pages[$store.currentPageName]
|
||||
styles += currentPage._css
|
||||
for (let screen of currentPage._screens) {
|
||||
styles += screen._css
|
||||
// Saving pages and screens to the DB causes them to have _revs.
|
||||
// These revisions change every time a save happens and causes
|
||||
// these reactive statements to fire, even though the actual
|
||||
// definition hasn't changed.
|
||||
// By deleting all _rev properties we can avoid this and increase
|
||||
// performance.
|
||||
$: json = JSON.stringify({ page, screen, selectedComponentId })
|
||||
$: strippedJson = json.replaceAll(/"_rev":\s*"[^"]+"/g, `"_rev":""`)
|
||||
|
||||
// Update the iframe with the builder info to render the correct preview
|
||||
const refreshContent = message => {
|
||||
if (iframe) {
|
||||
iframe.contentWindow.postMessage(message)
|
||||
}
|
||||
styles = styles
|
||||
}
|
||||
|
||||
$: stylesheetLinks = pipe($store.pages.stylesheets, [
|
||||
map(s => `<link rel="stylesheet" href="${s}"/>`),
|
||||
join("\n"),
|
||||
])
|
||||
// Refresh the preview when required
|
||||
$: refreshContent(strippedJson)
|
||||
|
||||
$: screensExist =
|
||||
$store.currentPreviewItem._screens &&
|
||||
$store.currentPreviewItem._screens.length > 0
|
||||
|
||||
$: frontendDefinition = {
|
||||
appId: $store.appId,
|
||||
libraries: $store.libraries,
|
||||
page: $store.pages[$store.currentPageName],
|
||||
screens: [
|
||||
$store.currentFrontEndType === "page"
|
||||
? screenPlaceholder
|
||||
: $store.currentPreviewItem,
|
||||
],
|
||||
}
|
||||
|
||||
$: selectedComponentType = getComponentTypeName($store.currentComponentInfo)
|
||||
|
||||
$: selectedComponentId = $store.currentComponentInfo
|
||||
? $store.currentComponentInfo._id
|
||||
: ""
|
||||
|
||||
const refreshContent = () => {
|
||||
iframe.contentWindow.postMessage(
|
||||
JSON.stringify({
|
||||
styles,
|
||||
stylesheetLinks,
|
||||
selectedComponentType,
|
||||
selectedComponentId,
|
||||
frontendDefinition,
|
||||
appId: $store.appId,
|
||||
instanceId: $backendUiStore.selectedDatabase._id,
|
||||
})
|
||||
// Initialise the app when mounted
|
||||
onMount(() => {
|
||||
iframe.contentWindow.addEventListener(
|
||||
"bb-ready",
|
||||
() => {
|
||||
refreshContent(strippedJson)
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
$: if (iframe)
|
||||
iframe.contentWindow.addEventListener("bb-ready", refreshContent, {
|
||||
once: true,
|
||||
})
|
||||
|
||||
$: if (iframe && frontendDefinition) {
|
||||
refreshContent()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="component-container">
|
||||
{#if hasComponent && $store.currentPreviewItem}
|
||||
{#if $store.currentPreviewItem}
|
||||
<iframe
|
||||
style="height: 100%; width: 100%"
|
||||
title="componentPreview"
|
||||
|
@ -152,7 +75,6 @@
|
|||
margin: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.component-container iframe {
|
||||
border: 0;
|
||||
left: 0;
|
||||
|
|
|
@ -4,72 +4,50 @@ export default `<html>
|
|||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono">
|
||||
<style>
|
||||
body, html {
|
||||
height: 100%!important;
|
||||
height: 100% !important;
|
||||
font-family: Inter !important;
|
||||
margin: 0px!important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.container-screenslot-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-style: dashed !important;
|
||||
border-width: 1px;
|
||||
color: #000000;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.container-screenslot-placeholder span {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
<script src='/assets/budibase-client.js'></script>
|
||||
<script>
|
||||
function receiveMessage(event) {
|
||||
if (!event.data) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!event.data) return
|
||||
// Extract data from message
|
||||
const { selectedComponentId, page, screen } = JSON.parse(event.data)
|
||||
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
try {
|
||||
if (styles) document.head.removeChild(styles)
|
||||
} catch(_) { }
|
||||
|
||||
try {
|
||||
if (selectedComponentStyle) document.head.removeChild(selectedComponentStyle)
|
||||
} catch(_) { }
|
||||
|
||||
selectedComponentStyle = document.createElement('style');
|
||||
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;
|
||||
// Set some flags so the app knows we're in the builder
|
||||
window["##BUDIBASE_IN_BUILDER##"] = true
|
||||
window["##BUDIBASE_PREVIEW_PAGE##"] = page
|
||||
window["##BUDIBASE_PREVIEW_SCREEN##"] = screen
|
||||
window["##BUDIBASE_SELECTED_COMPONENT_ID##"] = selectedComponentId
|
||||
window["##BUDIBASE_PREVIEW_ID##"] = Math.random()
|
||||
|
||||
// Initialise app
|
||||
if (window.loadBudibase) {
|
||||
loadBudibase({ window, localStorage })
|
||||
loadBudibase()
|
||||
}
|
||||
}
|
||||
let styles
|
||||
|
||||
let selectedComponentStyle
|
||||
|
||||
document.addEventListener("click", function(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false;
|
||||
}, true)
|
||||
// Ignore clicks
|
||||
["click", "mousedown"].forEach(type => {
|
||||
document.addEventListener(type, function(e) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}, true)
|
||||
})
|
||||
|
||||
window.addEventListener('message', receiveMessage)
|
||||
window.dispatchEvent(new Event('bb-ready'))
|
||||
|
||||
window.addEventListener("message", receiveMessage)
|
||||
window.dispatchEvent(new Event("bb-ready"))
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -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("/")) : "")
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -40,13 +40,16 @@
|
|||
|
||||
$: links = bindableProperties
|
||||
.filter(x => x.fieldSchema?.type === "link")
|
||||
.map(property => ({
|
||||
label: property.readableBinding,
|
||||
fieldName: property.fieldSchema.name,
|
||||
name: `all_${property.fieldSchema.tableId}`,
|
||||
tableId: property.fieldSchema.tableId,
|
||||
type: "link",
|
||||
}))
|
||||
.map(property => {
|
||||
return {
|
||||
providerId: property.instance._id,
|
||||
label: property.readableBinding,
|
||||
fieldName: property.fieldSchema.name,
|
||||
name: `all_${property.fieldSchema.tableId}`,
|
||||
tableId: property.fieldSchema.tableId,
|
||||
type: "link",
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
@ -1,6 +1,5 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
release/
|
||||
dist/
|
|
@ -1,13 +0,0 @@
|
|||
module.exports = {
|
||||
presets: ["@babel/preset-env"],
|
||||
sourceMaps: "inline",
|
||||
retainLines: true,
|
||||
plugins: [
|
||||
[
|
||||
"@babel/plugin-transform-runtime",
|
||||
{
|
||||
regenerator: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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`,
|
||||
})
|
||||
}
|
|
@ -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,
|
||||
})
|
||||
}
|
|
@ -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 },
|
||||
})
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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`,
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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}
|
|
@ -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}
|
|
@ -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 />
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -1,59 +1,25 @@
|
|||
import { createApp } from "./createApp"
|
||||
import { builtins, builtinLibName } from "./render/builtinComponents"
|
||||
import { getAppId } from "./render/getAppId"
|
||||
import ClientApp from "./components/ClientApp.svelte"
|
||||
import { builderStore } from "./store"
|
||||
|
||||
/**
|
||||
* create a web application from static budibase definition files.
|
||||
* @param {object} opts - configuration options for budibase client libary
|
||||
*/
|
||||
export const loadBudibase = async opts => {
|
||||
const _window = (opts && opts.window) || window
|
||||
// const _localStorage = (opts && opts.localStorage) || localStorage
|
||||
const appId = getAppId(window.document.cookie)
|
||||
const frontendDefinition = _window["##BUDIBASE_FRONTEND_DEFINITION##"]
|
||||
let app
|
||||
|
||||
const user = {}
|
||||
|
||||
const componentLibraryModules = (opts && opts.componentLibraries) || {}
|
||||
|
||||
const libraries = frontendDefinition.libraries || []
|
||||
|
||||
for (let library of libraries) {
|
||||
// fetch the JavaScript for the component libraries from the server
|
||||
componentLibraryModules[library] = await import(
|
||||
`/componentlibrary?library=${encodeURI(library)}&appId=${appId}`
|
||||
)
|
||||
}
|
||||
|
||||
componentLibraryModules[builtinLibName] = builtins(_window)
|
||||
|
||||
const {
|
||||
initialisePage,
|
||||
screenStore,
|
||||
pageStore,
|
||||
routeTo,
|
||||
rootNode,
|
||||
} = createApp({
|
||||
componentLibraries: componentLibraryModules,
|
||||
frontendDefinition,
|
||||
user,
|
||||
window: _window,
|
||||
const loadBudibase = () => {
|
||||
// Update builder store with any builder flags
|
||||
builderStore.set({
|
||||
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"],
|
||||
page: window["##BUDIBASE_PREVIEW_PAGE##"],
|
||||
screen: window["##BUDIBASE_PREVIEW_SCREEN##"],
|
||||
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"],
|
||||
previewId: window["##BUDIBASE_PREVIEW_ID##"],
|
||||
})
|
||||
|
||||
const route = _window.location
|
||||
? _window.location.pathname.replace(`${appId}/`, "").replace(appId, "")
|
||||
: ""
|
||||
|
||||
initialisePage(frontendDefinition.page, _window.document.body, route)
|
||||
|
||||
return {
|
||||
screenStore,
|
||||
pageStore,
|
||||
routeTo,
|
||||
rootNode,
|
||||
// Create app if one hasn't been created yet
|
||||
if (!app) {
|
||||
app = new ClientApp({
|
||||
target: window.document.body,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (window) {
|
||||
window.loadBudibase = loadBudibase
|
||||
}
|
||||
// Attach to window so the HTML template can call this when it loads
|
||||
window.loadBudibase = loadBudibase
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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),
|
||||
})
|
|
@ -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: [],
|
||||
})
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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"],
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export default value => typeof value === "string" && value.includes("{{")
|
|
@ -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 = {
|
||||
"<": "<",
|
||||
">": ">",
|
||||
}
|
||||
|
||||
mustache.escape = text =>
|
||||
String(text).replace(/[&<>"'`=/]/g, function fromEntityMap(s) {
|
||||
return entityMap[s] || s
|
||||
})
|
||||
|
||||
export default mustache.render
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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"
|
|
@ -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()
|
|
@ -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()
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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 = {
|
||||
"<": "<",
|
||||
">": ">",
|
||||
}
|
||||
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
|
||||
}
|
|
@ -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
|
|
@ -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()
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
})
|
|
@ -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")
|
||||
})
|
||||
})
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
|
@ -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)
|
||||
},
|
||||
})
|
|
@ -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} />
|
File diff suppressed because it is too large
Load Diff
|
@ -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",
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue