forked from tsai/budibase
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 { 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} - ${response.statusText}`) |
||||
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 post = apiCall("POST") |
||||
const get = apiCall("GET") |
const get = apiCall("GET") |
||||
const patch = apiCall("PATCH") |
const patch = apiCall("PATCH") |
||||
const del = apiCall("DELETE") |
const del = apiCall("DELETE") |
||||
|
|
||||
const ERROR_MEMBER = "##error" |
const ERROR_MEMBER = "##error" |
||||
const error = message => { |
const error = message => { |
||||
const err = { [ERROR_MEMBER]: message } |
const err = { [ERROR_MEMBER]: message } |
||||
setState("##error_message", message) |
appStore.update(s => s["##error_message"], message) |
||||
return err |
return err |
||||
} |
} |
||||
|
|
||||
const isSuccess = obj => !obj || !obj[ERROR_MEMBER] |
const isSuccess = obj => !obj || !obj[ERROR_MEMBER] |
||||
|
|
||||
const apiOpts = { |
|
||||
setState, |
|
||||
getState, |
|
||||
isSuccess, |
|
||||
error, |
|
||||
post, |
|
||||
get, |
|
||||
patch, |
|
||||
delete: del, |
|
||||
} |
|
||||
|
|
||||
return { |
const apiOpts = { |
||||
authenticate: authenticate(apiOpts), |
isSuccess, |
||||
triggerWorkflow: triggerWorkflow(apiOpts), |
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" |
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 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