merge from master

This commit is contained in:
Michael Shanks 2020-08-13 11:50:12 +01:00
parent 2f8d63959d
commit 5ab905067f
39 changed files with 1129 additions and 551 deletions

View File

@ -17,7 +17,7 @@
"cy:test": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run", "cy:test": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run",
"cy:ci": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run:ci" "cy:ci": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run:ci"
}, },
"jest": { "jest": {
"globals": { "globals": {
"GLOBALS": { "GLOBALS": {
"client": "web" "client": "web"
@ -93,14 +93,14 @@
"@sveltech/routify": "1.7.11", "@sveltech/routify": "1.7.11",
"@testing-library/jest-dom": "^5.11.0", "@testing-library/jest-dom": "^5.11.0",
"@testing-library/svelte": "^3.0.0", "@testing-library/svelte": "^3.0.0",
"babel-jest": "^24.8.0", "babel-jest": "^26.2.2",
"browser-sync": "^2.26.7", "browser-sync": "^2.26.7",
"cypress": "^4.8.0", "cypress": "^4.8.0",
"cypress-terminal-report": "^1.4.1", "cypress-terminal-report": "^1.4.1",
"eslint-plugin-cypress": "^2.11.1", "eslint-plugin-cypress": "^2.11.1",
"http-proxy-middleware": "^0.19.1", "http-proxy-middleware": "^0.19.1",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^24.8.0", "jest": "^26.2.2",
"ncp": "^2.0.0", "ncp": "^2.0.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",

View File

@ -0,0 +1,156 @@
import { cloneDeep, difference } from "lodash"
/**
* parameter for fetchBindableProperties function
* @typedef {Object} fetchBindablePropertiesParameter
* @property {string} componentInstanceId - an _id of a component that has been added to a screen, whihc you want to fetch bindable props for
* @propperty {Object} screen - current screen - where componentInstanceId lives
* @property {Object} components - dictionary of component definitions
* @property {Array} models - array of all models
*/
/**
*
* @typedef {Object} BindableProperty
* @property {string} type - either "instance" (binding to a component instance) or "context" (binding to data in context e.g. List Item)
* @property {Object} instance - relevant component instance. If "context" type, this instance is the component that provides the context... e.g. the List
* @property {string} runtimeBinding - a binding string that is a) saved against the string, and b) used at runtime to read/write the value
* @property {string} readableBinding - a binding string that is displayed to the user, in the builder
*/
/**
* Generates all allowed bindings from within any particular component instance
* @param {fetchBindablePropertiesParameter} param
* @returns {Array.<BindableProperty>}
*/
export default function({ componentInstanceId, screen, components, models }) {
const walkResult = walk({
// cloning so we are free to mutate props (e.g. by adding _contexts)
instance: cloneDeep(screen.props),
targetId: componentInstanceId,
components,
models,
})
return [
...walkResult.bindableInstances
.filter(isInstanceInSharedContext(walkResult))
.map(componentInstanceToBindable(walkResult)),
...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(),
]
}
const isInstanceInSharedContext = walkResult => i =>
// should cover
// - neither are in any context
// - both in same context
// - instance is in ancestor context of target
i.instance._contexts.length <= walkResult.target._contexts.length &&
difference(i.instance._contexts, walkResult.target._contexts).length === 0
// 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)
: ""
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}`,
// how the binding exressions looks to the user of the builder
readableBinding: `${i.instance._instanceName}`,
}
}
const contextToBindables = walkResult => c => {
const contextParentPath = getParentPath(walkResult, c)
return Object.keys(c.model.schema).map(k => ({
type: "context",
instance: c.instance,
// how the binding expression persists, and is used in the app at runtime
runtimeBinding: `${contextParentPath}data.${k}`,
// how the binding exressions looks to the user of the builder
readableBinding: `${c.instance._instanceName}.${c.model.name}.${k}`,
}))
}
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, models, result }) => {
if (!result) {
result = {
target: null,
bindableInstances: [],
allContexts: [],
currentContexts: [],
}
}
if (!instance._contexts) instance._contexts = []
// "component" is the component definition (object in component.json)
const component = components[instance._component]
if (instance._id === targetId) {
// found it
result.target = instance
} else {
if (component.bindable) {
// pushing all components in here initially
// but this will not be correct, as some of
// these components will be in another context
// but we dont know this until the end of the walk
// so we will filter in another metod
result.bindableInstances.push({
instance,
prop: component.bindable,
})
}
}
// a component that provides context to it's children
const contextualInstance = component.context && instance[component.context]
if (contextualInstance) {
// add to currentContexts (ancestory of context)
// before walking children
const model = models.find(m => m._id === instance[component.context])
result.currentContexts.push({ instance, model })
}
const currentContexts = [...result.currentContexts]
for (let child of instance._children || []) {
// attaching _contexts of components, for eas comparison later
// these have been deep cloned above, so shouln't modify the
// original component instances
child._contexts = currentContexts
walk({ instance: child, targetId, components, models, result })
}
if (contextualInstance) {
// child walk done, remove from currentContexts
result.currentContexts.pop()
}
return result
}

View File

@ -0,0 +1,39 @@
import { walkProps } from "./storeUtils"
import { get_capitalised_name } from "../helpers"
export default function(component, state) {
const capitalised = get_capitalised_name(component)
const matchingComponents = []
const findMatches = props => {
walkProps(props, c => {
if ((c._instanceName || "").startsWith(capitalised)) {
matchingComponents.push(c._instanceName)
}
})
}
// check page first
findMatches(state.pages[state.currentPageName].props)
// if viewing screen, check current screen for duplicate
if (state.currentFrontEndType === "screen") {
findMatches(state.currentPreviewItem.props)
} else {
// viewing master page - need to find against all screens
for (let screen of state.screens) {
findMatches(screen.props)
}
}
let index = 1
let name
while (!name) {
const tryName = `${capitalised} ${index}`
if (!matchingComponents.includes(tryName)) name = tryName
index++
}
return name
}

View File

@ -1,5 +1,5 @@
import { values, cloneDeep } from "lodash/fp" import { values, cloneDeep } from "lodash/fp"
import { get_capitalised_name } from "../../helpers" import getNewComponentName from "../getNewComponentName"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import * as backendStoreActions from "./backend" import * as backendStoreActions from "./backend"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
@ -281,7 +281,7 @@ const addChildComponent = store => (componentToAdd, presetProps = {}) => {
const component = getComponentDefinition(state, componentToAdd) const component = getComponentDefinition(state, componentToAdd)
const instanceId = get(backendUiStore).selectedDatabase._id const instanceId = get(backendUiStore).selectedDatabase._id
const instanceName = get_capitalised_name(componentToAdd) const instanceName = getNewComponentName(componentToAdd, state)
const newComponent = createProps( const newComponent = createProps(
component, component,
@ -487,7 +487,7 @@ const pasteComponent = store => (targetComponent, mode) => {
// in case we paste a second time // in case we paste a second time
s.componentToPaste.isCut = false s.componentToPaste.isCut = false
} else { } else {
generateNewIdsForComponent(componentToPaste) generateNewIdsForComponent(componentToPaste, s)
} }
delete componentToPaste.isCut delete componentToPaste.isCut

View File

@ -2,6 +2,7 @@ import { makePropsSafe } from "components/userInterface/pagesParsing/createProps
import api from "./api" import api from "./api"
import { generate_screen_css } from "./generate_css" import { generate_screen_css } from "./generate_css"
import { uuid } from "./uuid" import { uuid } from "./uuid"
import getNewComponentName from "./getNewComponentName"
export const selectComponent = (state, component) => { export const selectComponent = (state, component) => {
const componentDef = component._component.startsWith("##") const componentDef = component._component.startsWith("##")
@ -81,7 +82,8 @@ export const regenerateCssForCurrentScreen = state => {
return state return state
} }
export const generateNewIdsForComponent = c => export const generateNewIdsForComponent = (c, state) =>
walkProps(c, p => { walkProps(c, p => {
p._id = uuid() p._id = uuid()
p._instanceName = getNewComponentName(p._component, state)
}) })

View File

@ -1,5 +1,7 @@
export function uuid() { export function uuid() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { // always want to make this start with a letter, as this makes it
// easier to use with mustache bindings in the client
return "cxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0, const r = (Math.random() * 16) | 0,
v = c == "x" ? r : (r & 0x3) | 0x8 v = c == "x" ? r : (r & 0x3) | 0x8
return v.toString(16) return v.toString(16)

View File

@ -13,7 +13,6 @@
} from "components/common/Icons/" } from "components/common/Icons/"
import CodeEditor from "./CodeEditor.svelte" import CodeEditor from "./CodeEditor.svelte"
import LayoutEditor from "./LayoutEditor.svelte" import LayoutEditor from "./LayoutEditor.svelte"
import EventsEditor from "./EventsEditor"
import panelStructure from "./temporaryPanelStructure.js" import panelStructure from "./temporaryPanelStructure.js"
import CategoryTab from "./CategoryTab.svelte" import CategoryTab from "./CategoryTab.svelte"
import DesignView from "./DesignView.svelte" import DesignView from "./DesignView.svelte"
@ -25,7 +24,6 @@
let categories = [ let categories = [
{ value: "settings", name: "Settings" }, { value: "settings", name: "Settings" },
{ value: "design", name: "Design" }, { value: "design", name: "Design" },
{ value: "events", name: "Events" },
] ]
let selectedCategory = categories[0] let selectedCategory = categories[0]
@ -113,8 +111,6 @@
displayNameField={displayName} displayNameField={displayName}
onChange={onPropChanged} onChange={onPropChanged}
screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} /> screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} />
{:else if selectedCategory.value === 'events'}
<EventsEditor component={componentInstance} />
{/if} {/if}
</div> </div>

View File

@ -1,37 +1,28 @@
<script> <script>
import { store } from "builderStore" import { store } from "builderStore"
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
import Modal from "../../common/Modal.svelte"
import HandlerSelector from "./HandlerSelector.svelte" import HandlerSelector from "./HandlerSelector.svelte"
import IconButton from "../../common/IconButton.svelte"
import ActionButton from "../../common/ActionButton.svelte"
import PlusButton from "../../common/PlusButton.svelte"
import Select from "../../common/Select.svelte"
import Input from "../../common/Input.svelte"
import getIcon from "../../common/icon"
import { CloseIcon } from "components/common/Icons/" import { CloseIcon } from "components/common/Icons/"
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers"
import { createEventDispatcher } from "svelte"
export let event export let event = []
export let eventOptions = [] export let eventType
export let onClose
let eventType = "" const dispatch = createEventDispatcher()
let draftEventHandler = { parameters: [] } let draftEventHandler = { parameters: [] }
$: eventData = event || { handlers: [] } $: handlers = (event && [...event]) || []
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0)
eventType = eventOptions[0].name
const closeModal = () => { const closeModal = () => {
onClose() dispatch("close")
draftEventHandler = { parameters: [] } draftEventHandler = { parameters: [] }
eventData = { handlers: [] } handlers = []
} }
const updateEventHandler = (updatedHandler, index) => { const updateEventHandler = (updatedHandler, index) => {
eventData.handlers[index] = updatedHandler handlers[index] = updatedHandler
dispatch("change", handlers)
} }
const updateDraftEventHandler = updatedHandler => { const updateDraftEventHandler = updatedHandler => {
@ -39,8 +30,8 @@
} }
const deleteEventHandler = index => { const deleteEventHandler = index => {
eventData.handlers.splice(index, 1) handlers.splice(index, 1)
eventData = eventData dispatch("change", handlers)
} }
const createNewEventHandler = handler => { const createNewEventHandler = handler => {
@ -48,37 +39,15 @@
parameters: {}, parameters: {},
[EVENT_TYPE_MEMBER_NAME]: "", [EVENT_TYPE_MEMBER_NAME]: "",
} }
eventData.handlers.push(newHandler) handlers.push(newHandler)
eventData = eventData dispatch("change", handlers)
}
const deleteEvent = () => {
store.setComponentProp(eventType, [])
closeModal()
}
const saveEventData = () => {
store.setComponentProp(eventType, eventData.handlers)
closeModal()
} }
</script> </script>
<div class="container"> <div class="container">
<div class="body"> <div class="body">
<div class="heading"> <div class="heading">
<h3> <h3>{eventType} Event</h3>
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'}
</h3>
</div>
<div class="event-options">
<div class="section">
<h4>Event Type</h4>
<Select bind:value={eventType}>
{#each eventOptions as option}
<option value={option.name}>{option.name}</option>
{/each}
</Select>
</div>
</div> </div>
<div class="section"> <div class="section">
@ -92,35 +61,16 @@
}} }}
handler={draftEventHandler} /> handler={draftEventHandler} />
</div> </div>
{#if eventData} {#each handlers as handler, index}
{#each eventData.handlers as handler, index} <HandlerSelector
<HandlerSelector {index}
{index} onChanged={updateEventHandler}
onChanged={updateEventHandler} onRemoved={() => deleteEventHandler(index)}
onRemoved={() => deleteEventHandler(index)} {handler} />
{handler} /> {/each}
{/each}
{/if}
</div> </div>
<div class="footer">
{#if eventData.name}
<Button
outline
on:click={deleteEvent}
disabled={eventData.handlers.length === 0}>
Delete
</Button>
{/if}
<div class="save">
<Button
primary
on:click={saveEventData}
disabled={eventData.handlers.length === 0}>
Save
</Button>
</div>
</div>
<div class="close-button" on:click={closeModal}> <div class="close-button" on:click={closeModal}>
<CloseIcon /> <CloseIcon />
</div> </div>
@ -129,6 +79,7 @@
<style> <style>
.container { .container {
position: relative; position: relative;
width: 600px;
} }
.heading { .heading {
margin-bottom: 20px; margin-bottom: 20px;

View File

@ -0,0 +1,26 @@
<script>
import { Button, DropdownMenu } from "@budibase/bbui"
import EventEditorModal from "./EventEditorModal.svelte"
import { getContext } from "svelte"
export let value
export let name
let button
let dropdown
</script>
<div bind:this={button}>
<Button secondary small on:click={dropdown.show}>Define Actions</Button>
</div>
<DropdownMenu bind:this={dropdown} align="right" anchor={button}>
<EventEditorModal
event={value}
eventType={name}
on:change
on:close={dropdown.hide} />
</DropdownMenu>
<style>
</style>

View File

@ -1,161 +0,0 @@
<script>
import { getContext } from "svelte"
import {
keys,
map,
some,
includes,
cloneDeep,
isEqual,
sortBy,
filter,
difference,
} from "lodash/fp"
import { pipe } from "components/common/core"
import Checkbox from "components/common/Checkbox.svelte"
import Textbox from "components/common/Textbox.svelte"
import Dropdown from "components/common/Dropdown.svelte"
import PlusButton from "components/common/PlusButton.svelte"
import IconButton from "components/common/IconButton.svelte"
import EventEditorModal from "./EventEditorModal.svelte"
import { PencilIcon } from "components/common/Icons"
import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers"
export const EVENT_TYPE = "event"
export let component
let events = []
let selectedEvent = null
$: {
events = Object.keys(component)
// TODO: use real events
.filter(propName => ["onChange", "onClick", "onLoad"].includes(propName))
.map(propName => ({
name: propName,
handlers: component[propName] || [],
}))
}
// Handle create app modal
const { open, close } = getContext("simple-modal")
const openModal = event => {
selectedEvent = event
open(
EventEditorModal,
{
eventOptions: events,
event: selectedEvent,
onClose: () => {
close()
selectedEvent = null
},
},
{
closeButton: false,
closeOnEsc: false,
styleContent: { padding: 0 },
closeOnOuterClick: true,
}
)
}
</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="uk-form-stacked 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>
<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: white;
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 #f9f9f9;
height: 80px;
width: 100%;
}
.event-name {
margin-top: 5px;
font-weight: bold;
font-size: 16px;
color: rgba(22, 48, 87, 0.6);
align-self: end;
}
.edit-text {
font-family: Arial, Helvetica, sans-serif;
font-weight: bold;
align-self: end;
justify-self: end;
font-size: 10px;
color: rgba(35, 65, 105, 0.4);
}
.selected {
color: var(--blue);
background: var(--grey-1) !important;
}
</style>

View File

@ -1,8 +1,5 @@
<script> <script>
import { Button } from "@budibase/bbui" import { Button, Select } from "@budibase/bbui"
import IconButton from "components/common/IconButton.svelte"
import PlusButton from "components/common/PlusButton.svelte"
import Select from "components/common/Select.svelte"
import StateBindingCascader from "./StateBindingCascader.svelte" import StateBindingCascader from "./StateBindingCascader.svelte"
import { find, map, keys, reduce, keyBy } from "lodash/fp" import { find, map, keys, reduce, keyBy } from "lodash/fp"
import { pipe } from "components/common/core" import { pipe } from "components/common/core"
@ -96,7 +93,7 @@
{#if newHandler} {#if newHandler}
<Button primary thin on:click={onCreate}>Add Action</Button> <Button primary thin on:click={onCreate}>Add Action</Button>
{:else} {:else}
<Button outline thin on:click={onRemoved}>Remove Action</Button> <Button secondary thin on:click={onRemoved}>Remove Action</Button>
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@ -4,6 +4,8 @@
import Input from "../common/Input.svelte" import Input from "../common/Input.svelte"
import { goto } from "@sveltech/routify" import { goto } from "@sveltech/routify"
import { excludeProps } from "./propertyCategories.js" import { excludeProps } from "./propertyCategories.js"
import { store } from "builderStore"
import { walkProps } from "builderStore/storeUtils"
export let panelDefinition = [] export let panelDefinition = []
export let componentDefinition = {} export let componentDefinition = {}
@ -13,6 +15,7 @@
export let screenOrPageInstance export let screenOrPageInstance
let pageScreenProps = ["title", "favicon", "description", "route"] let pageScreenProps = ["title", "favicon", "description", "route"]
let duplicateName = false
const propExistsOnComponentDef = prop => const propExistsOnComponentDef = prop =>
pageScreenProps.includes(prop) || prop in componentDefinition.props pageScreenProps.includes(prop) || prop in componentDefinition.props
@ -33,6 +36,43 @@
$: isPage = screenOrPageInstance && screenOrPageInstance.favicon $: isPage = screenOrPageInstance && screenOrPageInstance.favicon
$: screenOrPageDefinition = isPage ? pageDefinition : screenDefinition $: screenOrPageDefinition = isPage ? pageDefinition : screenDefinition
const isDuplicateName = name => {
let duplicate = false
const lookForDuplicate = rootPops => {
walkProps(rootPops, (inst, cancel) => {
if (inst._instanceName === name && inst._id !== componentInstance._id) {
duplicate = true
cancel()
}
})
}
// check page first
lookForDuplicate($store.pages[$store.currentPageName].props)
if (duplicate) return true
// if viwing screen, check current screen for duplicate
if ($store.currentFrontEndType === "screen") {
lookForDuplicate($store.currentPreviewItem.props)
} else {
// viewing master page - need to dedupe against all screens
for (let screen of $store.screens) {
lookForDuplicate(screen.props)
}
}
return duplicate
}
const onInstanceNameChange = (_, name) => {
if (isDuplicateName(name)) {
duplicateName = true
} else {
duplicateName = false
onChange("_instanceName", name)
}
}
</script> </script>
{#if screenOrPageInstance} {#if screenOrPageInstance}
@ -54,7 +94,10 @@
label="Name" label="Name"
key="_instanceName" key="_instanceName"
value={componentInstance._instanceName} value={componentInstance._instanceName}
{onChange} /> onChange={onInstanceNameChange} />
{#if duplicateName}
<span class="duplicate-name">Name must be unique</span>
{/if}
{/if} {/if}
{#if panelDefinition && panelDefinition.length > 0} {#if panelDefinition && panelDefinition.length > 0}
@ -79,4 +122,11 @@
div { div {
text-align: center; text-align: center;
} }
.duplicate-name {
color: var(--red);
font-size: var(--font-size-xs);
position: relative;
top: -10px;
}
</style> </style>

View File

@ -2,6 +2,7 @@ import Input from "../common/Input.svelte"
import OptionSelect from "./OptionSelect.svelte" import OptionSelect from "./OptionSelect.svelte"
import Checkbox from "../common/Checkbox.svelte" import Checkbox from "../common/Checkbox.svelte"
import ModelSelect from "components/userInterface/ModelSelect.svelte" import ModelSelect from "components/userInterface/ModelSelect.svelte"
import Event from "components/userInterface/EventsEditor/EventPropertyControl.svelte"
import { all } from "./propertyCategories.js" import { all } from "./propertyCategories.js"
/* /*
@ -201,6 +202,7 @@ export default {
valueKey: "checked", valueKey: "checked",
control: Checkbox, control: Checkbox,
}, },
{ label: "onClick", key: "onClick", control: Event },
], ],
}, },
}, },

View File

@ -0,0 +1,200 @@
import fetchBindableProperties from "../src/builderStore/fetchBindableProperties"
describe("fetch bindable properties", () => {
it("should return bindable properties from screen components", () => {
const result = fetchBindableProperties({
componentInstanceId: "heading-id",
...testData()
})
const componentBinding = result.find(r => r.instance._id === "search-input-id" && r.type === "instance")
expect(componentBinding).toBeDefined()
expect(componentBinding.type).toBe("instance")
expect(componentBinding.runtimeBinding).toBe("search-input-id.value")
})
it("should not return bindable components when not in their context", () => {
const result = fetchBindableProperties({
componentInstanceId: "heading-id",
...testData()
})
const componentBinding = result.find(r => r.instance._id === "list-item-input-id")
expect(componentBinding).not.toBeDefined()
})
it("should return model schema, when inside a context", () => {
const result = fetchBindableProperties({
componentInstanceId: "list-item-input-id",
...testData()
})
const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context")
expect(contextBindings.length).toBe(2)
const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name")
expect(namebinding).toBeDefined()
expect(namebinding.readableBinding).toBe("list-name.Test Model.name")
const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description")
expect(descriptionbinding).toBeDefined()
expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description")
})
it("should return model schema, for grantparent context", () => {
const result = fetchBindableProperties({
componentInstanceId: "child-list-item-input-id",
...testData()
})
const contextBindings = result.filter(r => r.type==="context")
expect(contextBindings.length).toBe(4)
const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name")
expect(namebinding_parent).toBeDefined()
expect(namebinding_parent.readableBinding).toBe("list-name.Test Model.name")
const descriptionbinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.description")
expect(descriptionbinding_parent).toBeDefined()
expect(descriptionbinding_parent.readableBinding).toBe("list-name.Test Model.description")
const namebinding_own = contextBindings.find(b => b.runtimeBinding === "data.name")
expect(namebinding_own).toBeDefined()
expect(namebinding_own.readableBinding).toBe("child-list-name.Test Model.name")
const descriptionbinding_own = contextBindings.find(b => b.runtimeBinding === "data.description")
expect(descriptionbinding_own).toBeDefined()
expect(descriptionbinding_own.readableBinding).toBe("child-list-name.Test Model.description")
})
it("should return bindable component props, from components in same context", () => {
const result = fetchBindableProperties({
componentInstanceId: "list-item-heading-id",
...testData()
})
const componentBinding = result.find(r => r.instance._id === "list-item-input-id" && r.type === "instance")
expect(componentBinding).toBeDefined()
expect(componentBinding.runtimeBinding).toBe("list-item-input-id.value")
})
it("should not return components from child context", () => {
const result = fetchBindableProperties({
componentInstanceId: "list-item-heading-id",
...testData()
})
const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance")
expect(componentBinding).not.toBeDefined()
})
it("should return bindable component props, from components in same context (when nested context)", () => {
const result = fetchBindableProperties({
componentInstanceId: "child-list-item-heading-id",
...testData()
})
const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance")
expect(componentBinding).toBeDefined()
})
})
const testData = () => {
const screen = {
instanceName: "test screen",
name: "screen-id",
route: "/",
props: {
_id:"screent-root-id",
_component: "@budibase/standard-components/container",
_children: [
{
_id: "heading-id",
_instanceName: "list item heading",
_component: "@budibase/standard-components/heading",
text: "Screen Title"
},
{
_id: "search-input-id",
_instanceName: "Search Input",
_component: "@budibase/standard-components/input",
value: "search phrase"
},
{
_id: "list-id",
_component: "@budibase/standard-components/list",
_instanceName: "list-name",
model: "test-model-id",
_children: [
{
_id: "list-item-heading-id",
_instanceName: "list item heading",
_component: "@budibase/standard-components/heading",
text: "hello"
},
{
_id: "list-item-input-id",
_instanceName: "List Item Input",
_component: "@budibase/standard-components/input",
value: "list item"
},
{
_id: "child-list-id",
_component: "@budibase/standard-components/list",
_instanceName: "child-list-name",
model: "test-model-id",
_children: [
{
_id: "child-list-item-heading-id",
_instanceName: "child list item heading",
_component: "@budibase/standard-components/heading",
text: "hello"
},
{
_id: "child-list-item-input-id",
_instanceName: "Child List Item Input",
_component: "@budibase/standard-components/input",
value: "child list item"
},
]
},
]
},
]
}
}
const models = [{
_id: "test-model-id",
name: "Test Model",
schema: {
name: {
type: "string"
},
description: {
type: "string"
}
}
}]
const components = {
"@budibase/standard-components/container" : {
props: {},
},
"@budibase/standard-components/list" : {
context: "model",
props: {
model: "string"
},
},
"@budibase/standard-components/input" : {
bindable: "value",
props: {
value: "string"
},
},
"@budibase/standard-components/heading" : {
props: {
text: "string"
},
},
}
return { screen, models, components }
}

View File

@ -21,28 +21,24 @@
"\\.(css|less|sass|scss)$": "identity-obj-proxy" "\\.(css|less|sass|scss)$": "identity-obj-proxy"
}, },
"moduleFileExtensions": [ "moduleFileExtensions": [
"js" "js",
"svelte"
], ],
"moduleDirectories": [ "moduleDirectories": [
"node_modules" "node_modules"
], ],
"transform": { "transform": {
"^.+js$": "babel-jest" "^.+js$": "babel-jest",
"^.+.svelte$": "svelte-jester"
}, },
"transformIgnorePatterns": [ "transformIgnorePatterns": [
"/node_modules/(?!svelte).+\\.js$" "/node_modules/(?!svelte).+\\.js$"
] ]
}, },
"dependencies": { "dependencies": {
"@nx-js/compiler-util": "^2.0.0",
"bcryptjs": "^2.4.3",
"deep-equal": "^2.0.1", "deep-equal": "^2.0.1",
"lodash": "^4.17.15",
"lunr": "^2.3.5",
"mustache": "^4.0.1", "mustache": "^4.0.1",
"regexparam": "^1.3.0", "regexparam": "^1.3.0"
"shortid": "^2.2.8",
"svelte": "^3.9.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.5.5", "@babel/core": "^7.5.5",
@ -58,7 +54,9 @@
"rollup-plugin-node-builtins": "^2.1.2", "rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0", "rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-terser": "^4.0.4" "rollup-plugin-terser": "^4.0.4",
"svelte": "3.23.x",
"svelte-jester": "^1.0.6"
}, },
"gitHead": "e4e053cb6ff9a0ddc7115b44ccaa24b8ec41fb9a" "gitHead": "e4e053cb6ff9a0ddc7115b44ccaa24b8ec41fb9a"
} }

View File

@ -3,74 +3,6 @@ import commonjs from "rollup-plugin-commonjs"
import builtins from "rollup-plugin-node-builtins" import builtins from "rollup-plugin-node-builtins"
import nodeglobals from "rollup-plugin-node-globals" import nodeglobals from "rollup-plugin-node-globals"
const lodash_fp_exports = [
"find",
"compose",
"isUndefined",
"split",
"max",
"last",
"union",
"reduce",
"isObject",
"cloneDeep",
"some",
"isArray",
"map",
"filter",
"keys",
"isFunction",
"isEmpty",
"countBy",
"join",
"includes",
"flatten",
"constant",
"first",
"intersection",
"take",
"has",
"mapValues",
"isString",
"isBoolean",
"isNull",
"isNumber",
"isObjectLike",
"isDate",
"clone",
"values",
"keyBy",
"isNaN",
"isInteger",
"toNumber",
]
const lodash_exports = [
"flow",
"head",
"find",
"each",
"tail",
"findIndex",
"startsWith",
"dropRight",
"takeRight",
"trim",
"split",
"replace",
"merge",
"assign",
]
const coreExternal = [
"lodash",
"lodash/fp",
"lunr",
"safe-buffer",
"shortid",
"@nx-js/compiler-util",
]
export default { export default {
input: "src/index.js", input: "src/index.js",
output: [ output: [
@ -90,17 +22,8 @@ export default {
resolve({ resolve({
preferBuiltins: true, preferBuiltins: true,
browser: true, browser: true,
dedupe: importee => {
return coreExternal.includes(importee)
},
}),
commonjs({
namedExports: {
"lodash/fp": lodash_fp_exports,
lodash: lodash_exports,
shortid: ["generate"],
},
}), }),
commonjs(),
builtins(), builtins(),
nodeglobals(), nodeglobals(),
], ],

View File

@ -1,3 +1,5 @@
import appStore from "../state/store"
export const USER_STATE_PATH = "_bbuser" export const USER_STATE_PATH = "_bbuser"
export const authenticate = api => async ({ username, password }) => { export const authenticate = api => async ({ username, password }) => {
@ -17,6 +19,10 @@ export const authenticate = api => async ({ username, password }) => {
}) })
// set user even if error - so it is defined at least // set user even if error - so it is defined at least
api.setState(USER_STATE_PATH, user) appStore.update(s => {
s[USER_STATE_PATH] = user
return s
})
localStorage.setItem("budibase:user", JSON.stringify(user)) localStorage.setItem("budibase:user", JSON.stringify(user))
} }

View File

@ -1,62 +1,59 @@
import { authenticate } from "./authenticate" import { authenticate } from "./authenticate"
import { triggerWorkflow } from "./workflow" import { triggerWorkflow } from "./workflow"
import appStore from "../state/store"
export const createApi = ({ setState, getState }) => { const apiCall = method => async ({ url, body }) => {
const apiCall = method => async ({ url, body }) => { const response = await fetch(url, {
const response = await fetch(url, { method: method,
method: method, headers: {
headers: { "Content-Type": "application/json",
"Content-Type": "application/json", },
}, body: body && JSON.stringify(body),
body: body && JSON.stringify(body), credentials: "same-origin",
credentials: "same-origin", })
})
switch (response.status) { switch (response.status) {
case 200: 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 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}`) 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 => {
const err = { [ERROR_MEMBER]: message }
setState("##error_message", message)
return err
}
const isSuccess = obj => !obj || !obj[ERROR_MEMBER]
const apiOpts = {
setState,
getState,
isSuccess,
error,
post,
get,
patch,
delete: del,
}
return {
authenticate: authenticate(apiOpts),
triggerWorkflow: triggerWorkflow(apiOpts),
} }
} }
const post = apiCall("POST")
const get = apiCall("GET")
const patch = apiCall("PATCH")
const del = apiCall("DELETE")
const ERROR_MEMBER = "##error"
const error = message => {
const err = { [ERROR_MEMBER]: message }
appStore.update(s => s["##error_message"], message)
return err
}
const isSuccess = obj => !obj || !obj[ERROR_MEMBER]
const apiOpts = {
isSuccess,
error,
post,
get,
patch,
delete: del,
}
export default {
authenticate: authenticate(apiOpts),
triggerWorkflow: triggerWorkflow(apiOpts),
}

View File

@ -1,16 +1,6 @@
import { setState } from "../../state/setState"
const delay = ms => new Promise(resolve => setTimeout(resolve, ms)) const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
export default { export default {
SET_STATE: ({ context, args, id }) => {
setState(...Object.values(args))
context = {
...context,
[id]: args,
}
return context
},
NAVIGATE: () => { NAVIGATE: () => {
// TODO client navigation // TODO client navigation
}, },

View File

@ -1,6 +1,5 @@
import { get } from "svelte/store" import renderTemplateString from "../../state/renderTemplateString"
import mustache from "mustache" import appStore from "../../state/store"
import { appStore } from "../../state/store"
import Orchestrator from "./orchestrator" import Orchestrator from "./orchestrator"
import clientActions from "./actions" import clientActions from "./actions"
@ -18,9 +17,9 @@ export const clientStrategy = ({ api }) => ({
if (typeof argValue !== "string") continue if (typeof argValue !== "string") continue
// Render the string with values from the workflow context and state // Render the string with values from the workflow context and state
mappedArgs[arg] = mustache.render(argValue, { mappedArgs[arg] = renderTemplateString(argValue, {
context: this.context, context: this.context,
state: get(appStore), state: appStore.get(),
}) })
} }

View File

@ -1,7 +1,7 @@
import { split, last, compose } from "lodash/fp"
import { prepareRenderComponent } from "./prepareRenderComponent" import { prepareRenderComponent } from "./prepareRenderComponent"
import { isScreenSlot } from "./builtinComponents" import { isScreenSlot } from "./builtinComponents"
import deepEqual from "deep-equal" import deepEqual from "deep-equal"
import appStore from "../state/store"
export const attachChildren = initialiseOpts => (htmlElement, options) => { export const attachChildren = initialiseOpts => (htmlElement, options) => {
const { const {
@ -30,11 +30,28 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
} }
} }
const contextArray = Array.isArray(context) ? context : [context] 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 childNodes = []
for (let context of contextArray) { const createChildNodes = contextStoreKey => {
for (let childProps of treeNode.props._children) { for (let childProps of treeNode.props._children) {
const { componentName, libName } = splitName(childProps._component) const { componentName, libName } = splitName(childProps._component)
@ -42,25 +59,33 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
const ComponentConstructor = componentLibraries[libName][componentName] const ComponentConstructor = componentLibraries[libName][componentName]
const prepareNodes = ctx => { const childNode = prepareRenderComponent({
const childNodesThisIteration = prepareRenderComponent({ props: childProps,
props: childProps, parentNode: treeNode,
parentNode: treeNode, ComponentConstructor,
ComponentConstructor, htmlElement,
htmlElement, anchor,
anchor, // in same context as parent, unless a new one was supplied
context: ctx, contextStoreKey,
}) })
for (let childNode of childNodesThisIteration) { childNodes.push(childNode)
childNodes.push(childNode)
}
}
prepareNodes(context)
} }
} }
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 if (areTreeNodesEqual(treeNode.children, childNodes)) return treeNode.children
for (let node of childNodes) { for (let node of childNodes) {
@ -81,9 +106,9 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
} }
const splitName = fullname => { const splitName = fullname => {
const getComponentName = compose(last, split("/")) const nameParts = fullname.split("/")
const componentName = getComponentName(fullname) const componentName = nameParts[nameParts.length - 1]
const libName = fullname.substring( const libName = fullname.substring(
0, 0,

View File

@ -1,5 +1,6 @@
import { appStore } from "../state/store" import renderTemplateString from "../state/renderTemplateString"
import mustache from "mustache" import appStore from "../state/store"
import hasBinding from "../state/hasBinding"
export const prepareRenderComponent = ({ export const prepareRenderComponent = ({
ComponentConstructor, ComponentConstructor,
@ -7,62 +8,54 @@ export const prepareRenderComponent = ({
anchor, anchor,
props, props,
parentNode, parentNode,
context, contextStoreKey,
}) => { }) => {
const parentContext = (parentNode && parentNode.context) || {} const thisNode = createTreeNode()
thisNode.parentNode = parentNode
thisNode.props = props
thisNode.contextStoreKey = contextStoreKey
let nodesToRender = [] // the treeNode is first created (above), and then this
const createNodeAndRender = () => { // render method is add. The treeNode is returned, and
let componentContext = parentContext // render is called later (in attachChildren)
if (context) { thisNode.render = initialProps => {
componentContext = { ...context } thisNode.component = new ComponentConstructor({
componentContext.$parent = parentContext 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}`)
} }
const thisNode = createTreeNode() // make this node listen to the store
thisNode.context = componentContext if (thisNode.stateBound) {
thisNode.parentNode = parentNode const unsubscribe = appStore.subscribe(state => {
thisNode.props = props const storeBoundProps = Object.keys(initialProps._bb.props).filter(p =>
nodesToRender.push(thisNode) hasBinding(initialProps._bb.props[p])
)
thisNode.render = initialProps => { if (storeBoundProps.length > 0) {
thisNode.component = new ComponentConstructor({ const toSet = {}
target: htmlElement, for (let prop of storeBoundProps) {
props: initialProps, const propValue = initialProps._bb.props[prop]
hydrate: false, toSet[prop] = renderTemplateString(propValue, state)
anchor,
})
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 = { ...initialProps._bb.props }
for (let prop in storeBoundProps) {
const propValue = storeBoundProps[prop]
if (typeof propValue === "string") {
storeBoundProps[prop] = mustache.render(propValue, {
state,
context: componentContext,
})
}
} }
thisNode.component.$set(storeBoundProps) thisNode.component.$set(toSet)
}) }
thisNode.unsubscribe = unsubscribe }, thisNode.contextStoreKey)
} thisNode.unsubscribe = unsubscribe
} }
} }
createNodeAndRender() return thisNode
return nodesToRender
} }
export const createTreeNode = () => ({ export const createTreeNode = () => ({

View File

@ -1,5 +1,5 @@
import regexparam from "regexparam" import regexparam from "regexparam"
import { appStore } from "../state/store" import appStore from "../state/store"
import { parseAppIdFromCookie } from "./getAppId" import { parseAppIdFromCookie } from "./getAppId"
export const screenRouter = ({ screens, onScreenSelected, window }) => { export const screenRouter = ({ screens, onScreenSelected, window }) => {

View File

@ -1,11 +1,9 @@
import { setState } from "./setState" import setBindableComponentProp from "./setBindableComponentProp"
import { attachChildren } from "../render/attachChildren" import { attachChildren } from "../render/attachChildren"
import { getContext, setContext } from "./getSetContext"
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "") export const trimSlash = str => str.replace(/^\/+|\/+$/g, "")
export const bbFactory = ({ export const bbFactory = ({
store,
componentLibraries, componentLibraries,
onScreenSlotRendered, onScreenSlotRendered,
getCurrentState, getCurrentState,
@ -45,13 +43,9 @@ export const bbFactory = ({
return { return {
attachChildren: attachChildren(attachParams), attachChildren: attachChildren(attachParams),
context: treeNode.context,
props: treeNode.props, props: treeNode.props,
call: safeCallEvent, call: safeCallEvent,
setState, setBinding: setBindableComponentProp(treeNode),
getContext: getContext(treeNode),
setContext: setContext(treeNode),
store: store,
api, api,
parent, parent,
// these parameters are populated by screenRouter // these parameters are populated by screenRouter

View File

@ -1,8 +1,4 @@
import { setState } from "./setState" import api from "../api"
import { getState } from "./getState"
import { isArray, isUndefined } from "lodash/fp"
import { createApi } from "../api"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
@ -12,21 +8,13 @@ export const eventHandlers = routeTo => {
parameters, parameters,
}) })
const api = createApi({
setState,
getState: (path, fallback) => getState(path, fallback),
})
const setStateHandler = ({ path, value }) => setState(path, value)
return { return {
"Set State": handler(["path", "value"], setStateHandler),
"Navigate To": handler(["url"], param => routeTo(param && param.url)), "Navigate To": handler(["url"], param => routeTo(param && param.url)),
"Trigger Workflow": handler(["workflow"], api.triggerWorkflow), "Trigger Workflow": handler(["workflow"], api.triggerWorkflow),
} }
} }
export const isEventType = prop => export const isEventType = prop =>
isArray(prop) && Array.isArray(prop) &&
prop.length > 0 && prop.length > 0 &&
!isUndefined(prop[0][EVENT_TYPE_MEMBER_NAME]) !prop[0][EVENT_TYPE_MEMBER_NAME] === undefined

View File

@ -1,9 +0,0 @@
import { get } from "svelte/store"
import getOr from "lodash/fp/getOr"
import { appStore } from "./store"
export const getState = (path, fallback) => {
if (!path || path.length === 0) return fallback
return getOr(fallback, path, get(appStore))
}

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
import set from "lodash/fp/set"
import { appStore } from "./store"
export const setState = (path, value) => {
if (!path || path.length === 0) return
appStore.update(state => {
state = set(path, value, state)
return state
})
}

View File

@ -4,9 +4,9 @@ import {
EVENT_TYPE_MEMBER_NAME, EVENT_TYPE_MEMBER_NAME,
} from "./eventHandlers" } from "./eventHandlers"
import { bbFactory } from "./bbComponentApi" import { bbFactory } from "./bbComponentApi"
import mustache from "mustache" import renderTemplateString from "./renderTemplateString"
import { get } from "svelte/store" import appStore from "./store"
import { appStore } from "./store" import hasBinding from "./hasBinding"
const doNothing = () => {} const doNothing = () => {}
doNothing.isPlaceholder = true doNothing.isPlaceholder = true
@ -37,41 +37,34 @@ export const createStateManager = ({
const getCurrentState = () => currentState const getCurrentState = () => currentState
const bb = bbFactory({ const bb = bbFactory({
store: appStore,
getCurrentState, getCurrentState,
componentLibraries, componentLibraries,
onScreenSlotRendered, onScreenSlotRendered,
}) })
const setup = _setup({ handlerTypes, getCurrentState, bb, store: appStore }) const setup = _setup({ handlerTypes, getCurrentState, bb })
return { return {
setup, setup,
destroy: () => {}, destroy: () => {},
getCurrentState, getCurrentState,
store: appStore,
} }
} }
const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => { const _setup = ({ handlerTypes, getCurrentState, bb }) => node => {
const props = node.props const props = node.props
const context = node.context || {}
const initialProps = { ...props } const initialProps = { ...props }
const currentStoreState = get(appStore)
for (let propName in props) { for (let propName in props) {
if (isMetaProp(propName)) continue if (isMetaProp(propName)) continue
const propValue = props[propName] const propValue = props[propName]
// A little bit of a hack - won't bind if the string doesn't start with {{ const isBound = hasBinding(propValue)
const isBound = typeof propValue === "string" && propValue.includes("{{")
if (isBound) { if (isBound) {
initialProps[propName] = mustache.render(propValue, { const state = appStore.getState(node.contextStoreKey)
state: currentStoreState, initialProps[propName] = renderTemplateString(propValue, state)
context,
})
if (!node.stateBound) { if (!node.stateBound) {
node.stateBound = true node.stateBound = true
@ -79,6 +72,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => {
} }
if (isEventType(propValue)) { if (isEventType(propValue)) {
const state = appStore.getState(node.contextStoreKey)
const handlersInfos = [] const handlersInfos = []
for (let event of propValue) { for (let event of propValue) {
const handlerInfo = { const handlerInfo = {
@ -90,10 +84,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => {
for (let paramName in handlerInfo.parameters) { for (let paramName in handlerInfo.parameters) {
const paramValue = handlerInfo.parameters[paramName] const paramValue = handlerInfo.parameters[paramName]
resolvedParams[paramName] = () => resolvedParams[paramName] = () =>
mustache.render(paramValue, { renderTemplateString(paramValue, state)
state: getCurrentState(),
context,
})
} }
handlerInfo.parameters = resolvedParams handlerInfo.parameters = resolvedParams
@ -113,7 +104,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => {
} }
} }
const setup = _setup({ handlerTypes, getCurrentState, bb, store }) const setup = _setup({ handlerTypes, getCurrentState, bb })
initialProps._bb = bb(node, setup) initialProps._bb = bb(node, setup)
return initialProps return initialProps

View File

@ -1,9 +1,104 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
const appStore = writable({}) // we assume that the reference to this state object
appStore.actions = {} // will remain for the life of the application
const rootState = {}
const rootStore = writable(rootState)
const contextStores = {}
const routerStore = writable({}) // contextProviderId is the component id that provides the data for the context
routerStore.actions = {} const contextStoreKey = (dataProviderId, childIndex) =>
`${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}`
export { appStore, routerStore } // creates a store for a datacontext (e.g. each item in a list component)
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
if (!contextStores[key]) {
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 each 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
export default {
subscribe,
update,
set,
getState,
create,
contextStoreKey,
}

View File

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

View File

@ -135,4 +135,38 @@ describe("initialiseApp", () => {
expect(screenRoot.children[0].children[0].innerText).toBe("header one") expect(screenRoot.children[0].children[0].innerText).toBe("header one")
expect(screenRoot.children[0].children[1].innerText).toBe("header two") expect(screenRoot.children[0].children[1].innerText).toBe("header two")
}) })
it("should repeat elements that pass an array of contexts", async () => {
const { dom } = await load(
makePage({
_component: "testlib/div",
_children: [
{
_component: "##builtin/screenslot",
text: "header one",
},
],
}),
[
makeScreen("/", {
_component: "testlib/list",
data: [1,2,3,4],
_children: [
{
_component: "testlib/h1",
text: "header",
}
],
}),
]
)
const rootDiv = dom.window.document.body.children[0]
expect(rootDiv.children.length).toBe(1)
const screenRoot = rootDiv.children[0]
expect(screenRoot.children[0].children.length).toBe(4)
expect(screenRoot.children[0].children[0].innerText).toBe("header")
})
}) })

View File

@ -194,4 +194,47 @@ const maketestlib = window => ({
set(opts.props) set(opts.props)
opts.target.appendChild(node) opts.target.appendChild(node)
}, },
list: function(opts) {
const node = window.document.createElement("DIV")
let currentProps = { ...opts.props }
const set = props => {
currentProps = Object.assign(currentProps, props)
if (currentProps._children && currentProps._children.length > 0) {
currentProps._bb.attachChildren(node, {
context: currentProps.data || {},
})
}
}
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
input: function(opts) {
const node = window.document.createElement("INPUT")
let currentProps = { ...opts.props }
const set = props => {
currentProps = Object.assign(currentProps, props)
opts.props._bb.setBinding("value", props.value)
}
node.addEventListener("change", e => {
opts.props._bb.setBinding("value", e.target.value)
})
this.$destroy = () => opts.target.removeChild(node)
this.$set = set
this._element = node
set(opts.props)
opts.target.appendChild(node)
},
}) })

View File

@ -0,0 +1,16 @@
<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} />

View File

@ -131,6 +131,7 @@
}, },
"select": { "select": {
"name": "Select", "name": "Select",
"bindable": "value",
"description": "An HTML <select> (dropdown)", "description": "An HTML <select> (dropdown)",
"props": { "props": {
"value": "string", "value": "string",
@ -171,6 +172,7 @@
}, },
"checkbox": { "checkbox": {
"name": "Checkbox", "name": "Checkbox",
"bindable": "value",
"description": "A selectable checkbox component", "description": "A selectable checkbox component",
"props": { "props": {
"label": "string", "label": "string",
@ -181,6 +183,7 @@
}, },
"radiobutton": { "radiobutton": {
"name": "Radiobutton", "name": "Radiobutton",
"bindable": "value",
"description": "A selectable radiobutton component", "description": "A selectable radiobutton component",
"props": { "props": {
"label": "string", "label": "string",
@ -244,8 +247,9 @@
} }
}, },
"list": { "list": {
"description": "A configurable data list that attaches to your backend models.", "description": "configurable data list that attaches to your backend models.",
"children": true, "children": true,
"context": "model",
"data": true, "data": true,
"props": { "props": {
"model": "models" "model": "models"
@ -266,6 +270,7 @@
"recorddetail": { "recorddetail": {
"description": "Loads a record, using an ID in the url", "description": "Loads a record, using an ID in the url",
"children": true, "children": true,
"context": "model",
"data": true, "data": true,
"props": { "props": {
"model": "models" "model": "models"

View File

@ -12,7 +12,8 @@
const onchange = ev => { const onchange = ev => {
if (_bb) { if (_bb) {
_bb.setStateFromBinding(_bb.props.value, ev.target.value) const val = type === "checkbox" ? ev.target.checked : ev.target.value
_bb.setBinding("value", val)
} }
} }
</script> </script>

View File

@ -11,7 +11,7 @@
const onchange = ev => { const onchange = ev => {
if (_bb) { if (_bb) {
_bb.setStateFromBinding(_bb.props.value, ev.target.value) _bb.setBinding("value", ev.target.value)
} }
} }
</script> </script>