client lib - new binding

This commit is contained in:
Michael Shanks 2020-08-06 21:12:35 +01:00
parent 73b2d63c6e
commit 753fb27eb8
22 changed files with 591 additions and 295 deletions

View File

@ -1,5 +1,7 @@
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,
v = c == "x" ? r : (r & 0x3) | 0x8
return v.toString(16)

View File

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

View File

@ -3,74 +3,6 @@ import commonjs from "rollup-plugin-commonjs"
import builtins from "rollup-plugin-node-builtins"
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 {
input: "src/index.js",
output: [
@ -90,17 +22,8 @@ export default {
resolve({
preferBuiltins: true,
browser: true,
dedupe: importee => {
return coreExternal.includes(importee)
},
}),
commonjs({
namedExports: {
"lodash/fp": lodash_fp_exports,
lodash: lodash_exports,
shortid: ["generate"],
},
}),
commonjs(),
builtins(),
nodeglobals(),
],

View File

@ -1,3 +1,5 @@
import appStore from "../state/store"
export const USER_STATE_PATH = "_bbuser"
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
api.setState(USER_STATE_PATH, user)
appStore.update(s => {
s[USER_STATE_PATH] = user
return s
})
localStorage.setItem("budibase:user", JSON.stringify(user))
}

View File

@ -1,62 +1,59 @@
import { authenticate } from "./authenticate"
import { triggerWorkflow } from "./workflow"
import appStore from "../state/store"
export const createApi = ({ setState, getState }) => {
const apiCall = method => async ({ url, body }) => {
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: body && JSON.stringify(body),
credentials: "same-origin",
})
const apiCall = method => async ({ url, body }) => {
const response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: body && JSON.stringify(body),
credentials: "same-origin",
})
switch (response.status) {
case 200:
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()
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 => {
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),
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 }
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))
export default {
SET_STATE: ({ context, args, id }) => {
setState(...Object.values(args))
context = {
...context,
[id]: args,
}
return context
},
NAVIGATE: () => {
// TODO client navigation
},

View File

@ -1,6 +1,5 @@
import { get } from "svelte/store"
import mustache from "mustache"
import { appStore } from "../../state/store"
import appStore from "../../state/store"
import Orchestrator from "./orchestrator"
import clientActions from "./actions"
@ -20,7 +19,7 @@ export const clientStrategy = ({ api }) => ({
// Render the string with values from the workflow context and state
mappedArgs[arg] = mustache.render(argValue, {
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 { isScreenSlot } from "./builtinComponents"
import deepEqual from "deep-equal"
import appStore from "../state/store"
export const attachChildren = initialiseOpts => (htmlElement, options) => {
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 = []
for (let context of contextArray) {
const createChildNodes = contextStoreKey => {
for (let childProps of treeNode.props._children) {
const { componentName, libName } = splitName(childProps._component)
@ -42,25 +59,33 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
const ComponentConstructor = componentLibraries[libName][componentName]
const prepareNodes = ctx => {
const childNodesThisIteration = prepareRenderComponent({
props: childProps,
parentNode: treeNode,
ComponentConstructor,
htmlElement,
anchor,
context: ctx,
})
const childNode = prepareRenderComponent({
props: childProps,
parentNode: treeNode,
ComponentConstructor,
htmlElement,
anchor,
// in same context as parent, unless a new one was supplied
contextStoreKey,
})
for (let childNode of childNodesThisIteration) {
childNodes.push(childNode)
}
}
prepareNodes(context)
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) {
@ -81,9 +106,9 @@ export const attachChildren = initialiseOpts => (htmlElement, options) => {
}
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(
0,

View File

@ -1,5 +1,6 @@
import { appStore } from "../state/store"
import mustache from "mustache"
import appStore from "../state/store"
import hasBinding from "../state/hasBinding"
export const prepareRenderComponent = ({
ComponentConstructor,
@ -7,62 +8,54 @@ export const prepareRenderComponent = ({
anchor,
props,
parentNode,
context,
contextStoreKey,
}) => {
const parentContext = (parentNode && parentNode.context) || {}
const thisNode = createTreeNode()
thisNode.parentNode = parentNode
thisNode.props = props
thisNode.contextStoreKey = contextStoreKey
let nodesToRender = []
const createNodeAndRender = () => {
let componentContext = parentContext
if (context) {
componentContext = { ...context }
componentContext.$parent = parentContext
// 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}`)
}
const thisNode = createTreeNode()
thisNode.context = componentContext
thisNode.parentNode = parentNode
thisNode.props = props
nodesToRender.push(thisNode)
thisNode.render = initialProps => {
thisNode.component = new ComponentConstructor({
target: htmlElement,
props: initialProps,
hydrate: false,
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,
})
}
// 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] = mustache.render(propValue, state)
}
thisNode.component.$set(storeBoundProps)
})
thisNode.unsubscribe = unsubscribe
}
thisNode.component.$set(toSet)
}
}, thisNode.contextStoreKey)
thisNode.unsubscribe = unsubscribe
}
}
createNodeAndRender()
return nodesToRender
return thisNode
}
export const createTreeNode = () => ({

View File

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

View File

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

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

View File

@ -1,9 +1,104 @@
import { writable } from "svelte/store"
const appStore = writable({})
appStore.actions = {}
// 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 = {}
const routerStore = writable({})
routerStore.actions = {}
// contextProviderId is the component id that provides the data for the context
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[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)
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} />