mirror of https://github.com/Budibase/budibase.git
committed by
GitHub
41 changed files with 1399 additions and 337 deletions
@ -0,0 +1,18 @@ |
|||
context('Create a Binding', () => { |
|||
before(() => { |
|||
cy.visit('localhost:4001/_builder') |
|||
cy.createApp('Binding App', 'Binding App Description') |
|||
cy.navigateToFrontend() |
|||
}) |
|||
|
|||
it('add an input binding', () => { |
|||
cy.get(".nav-items-container").contains('Home').click() |
|||
cy.contains("Add").click() |
|||
cy.get("[data-cy=Input]").click() |
|||
cy.get("[data-cy=Textfield]").click() |
|||
cy.contains("Heading").click() |
|||
cy.get("[data-cy=text-binding-button]").click() |
|||
cy.get("[data-cy=binding-dropdown-modal]").contains('Input 1').click() |
|||
cy.get("[data-cy=binding-dropdown-modal] textarea").should('have.value', 'Home{{ Input 1 }}') |
|||
}) |
|||
}) |
|||
@ -0,0 +1,25 @@ |
|||
if (!Array.prototype.flat) { |
|||
Object.defineProperty(Array.prototype, "flat", { |
|||
configurable: true, |
|||
value: function flat() { |
|||
var depth = isNaN(arguments[0]) ? 1 : Number(arguments[0]) |
|||
|
|||
return depth |
|||
? Array.prototype.reduce.call( |
|||
this, |
|||
function(acc, cur) { |
|||
if (Array.isArray(cur)) { |
|||
acc.push.apply(acc, flat.call(cur, depth - 1)) |
|||
} else { |
|||
acc.push(cur) |
|||
} |
|||
|
|||
return acc |
|||
}, |
|||
[] |
|||
) |
|||
: Array.prototype.slice.call(this) |
|||
}, |
|||
writable: true, |
|||
}) |
|||
} |
|||
@ -0,0 +1,157 @@ |
|||
import { cloneDeep, difference } from "lodash/fp" |
|||
|
|||
/** |
|||
* parameter for fetchBindableProperties function |
|||
* @typedef {Object} fetchBindablePropertiesParameter |
|||
* @property {string} componentInstanceId - an _id of a component that has been added to a screen, which 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 => context => { |
|||
const contextParentPath = getParentPath(walkResult, context) |
|||
|
|||
return Object.keys(context.model.schema).map(k => ({ |
|||
type: "context", |
|||
instance: context.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: `${context.instance._instanceName}.${context.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 && 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 method
|
|||
result.bindableInstances.push({ |
|||
instance, |
|||
prop: component.bindable, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
// a component that provides context to it's children
|
|||
const contextualInstance = |
|||
component && 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 |
|||
} |
|||
@ -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 |
|||
} |
|||
@ -0,0 +1,116 @@ |
|||
<script> |
|||
import groupBy from "lodash/fp/groupBy" |
|||
import { Button, TextArea, Label, Body } from "@budibase/bbui" |
|||
import { createEventDispatcher } from "svelte" |
|||
|
|||
const dispatch = createEventDispatcher() |
|||
export let bindableProperties |
|||
console.log("Bindable Props: ", bindableProperties) |
|||
export let value = "" |
|||
export let close |
|||
|
|||
function addToText(readableBinding) { |
|||
value = value + `{{ ${readableBinding} }}` |
|||
} |
|||
let originalValue = value |
|||
|
|||
$: dispatch("update", value) |
|||
|
|||
function cancel() { |
|||
dispatch("update", originalValue) |
|||
close() |
|||
} |
|||
|
|||
$: ({ instance, context } = groupBy("type", bindableProperties)) |
|||
</script> |
|||
|
|||
<div class="container" data-cy="binding-dropdown-modal"> |
|||
<div class="list"> |
|||
<Label size="l" color="dark">Objects</Label> |
|||
{#if context} |
|||
<Label size="s" color="dark">Table</Label> |
|||
<ul> |
|||
{#each context as { readableBinding }} |
|||
<li on:click={() => addToText(readableBinding)}>{readableBinding}</li> |
|||
{/each} |
|||
</ul> |
|||
{/if} |
|||
{#if instance} |
|||
<Label size="s" color="dark">Components</Label> |
|||
<ul> |
|||
{#each instance as { readableBinding }} |
|||
<li on:click={() => addToText(readableBinding)}>{readableBinding}</li> |
|||
{/each} |
|||
</ul> |
|||
{/if} |
|||
</div> |
|||
<div class="text"> |
|||
<Label size="l" color="dark">Data binding</Label> |
|||
<Body size="s" color="dark"> |
|||
Binding connects one piece of data to another and makes it dynamic. Click |
|||
the objects on the left, to add them to the textbox. |
|||
</Body> |
|||
<TextArea bind:value placeholder="" /> |
|||
<div class="controls"> |
|||
<a href="#"> |
|||
<Body size="s" color="light">Learn more about binding</Body> |
|||
</a> |
|||
<Button on:click={cancel} secondary>Cancel</Button> |
|||
<Button on:click={close} primary>Done</Button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<style> |
|||
.container { |
|||
display: grid; |
|||
grid-template-columns: auto auto; |
|||
} |
|||
.list, |
|||
.text { |
|||
padding: var(--spacing-m); |
|||
} |
|||
.controls { |
|||
margin-top: var(--spacing-m); |
|||
display: grid; |
|||
align-items: center; |
|||
grid-gap: var(--spacing-l); |
|||
grid-template-columns: 1fr auto auto; |
|||
} |
|||
.list { |
|||
width: 150px; |
|||
border-right: 1.5px solid var(--grey-4); |
|||
} |
|||
.text { |
|||
width: 600px; |
|||
display: grid; |
|||
} |
|||
.text :global(p) { |
|||
margin: 0; |
|||
} |
|||
ul { |
|||
list-style: none; |
|||
padding-left: 0; |
|||
margin: 0; |
|||
padding: 0; |
|||
} |
|||
|
|||
li { |
|||
display: flex; |
|||
font-family: var(--font-sans); |
|||
font-size: var(--font-size-xs); |
|||
color: var(--ink); |
|||
padding: var(--spacing-s) var(--spacing-m); |
|||
margin: auto 0px; |
|||
align-items: center; |
|||
cursor: pointer; |
|||
} |
|||
|
|||
li:hover { |
|||
background-color: var(--grey-2); |
|||
} |
|||
|
|||
li:active { |
|||
color: var(--blue); |
|||
} |
|||
</style> |
|||
@ -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> |
|||
@ -0,0 +1,201 @@ |
|||
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 } |
|||
|
|||
} |
|||
@ -1,62 +1,59 @@ |
|||
import { authenticate } from "./authenticate" |
|||
import { triggerWorkflow } from "./workflow" |
|||
|
|||
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", |
|||
}) |
|||
|
|||
switch (response.status) { |
|||
case 200: |
|||
import appStore from "../state/store" |
|||
|
|||
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: |
|||
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}`) |
|||
} |
|||
} |
|||
|
|||
return error(`${url} - ${response.statusText}`) |
|||
} |
|||
} |
|||
|
|||
const post = apiCall("POST") |
|||
const get = apiCall("GET") |
|||
const patch = apiCall("PATCH") |
|||
const del = apiCall("DELETE") |
|||
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 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 = { |
|||
setState, |
|||
getState, |
|||
isSuccess, |
|||
error, |
|||
post, |
|||
get, |
|||
patch, |
|||
delete: del, |
|||
} |
|||
const isSuccess = obj => !obj || !obj[ERROR_MEMBER] |
|||
|
|||
return { |
|||
authenticate: authenticate(apiOpts), |
|||
triggerWorkflow: triggerWorkflow(apiOpts), |
|||
} |
|||
const apiOpts = { |
|||
isSuccess, |
|||
error, |
|||
post, |
|||
get, |
|||
patch, |
|||
delete: del, |
|||
} |
|||
|
|||
export default { |
|||
authenticate: authenticate(apiOpts), |
|||
triggerWorkflow: triggerWorkflow(apiOpts), |
|||
} |
|||
|
|||
@ -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)) |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export default value => typeof value === "string" && value.includes("{{") |
|||
@ -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 = { |
|||
"<": "<", |
|||
">": ">", |
|||
} |
|||
|
|||
mustache.escape = text => |
|||
String(text).replace(/[&<>"'`=/]/g, function fromEntityMap(s) { |
|||
return entityMap[s] |
|||
}) |
|||
|
|||
export default mustache.render |
|||
@ -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) |
|||
} |
|||
@ -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 |
|||
}) |
|||
} |
|||
@ -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 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 |
|||
|
|||
export default { |
|||
subscribe, |
|||
update, |
|||
set, |
|||
getState, |
|||
create, |
|||
contextStoreKey, |
|||
} |
|||
|
|||
@ -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, |
|||
}, |
|||
] |
|||
}) |
|||
@ -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} /> |
|||
Loading…
Reference in new issue