merge from master
This commit is contained in:
parent
2f8d63959d
commit
5ab905067f
|
@ -17,7 +17,7 @@
|
|||
"cy:test": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run",
|
||||
"cy:ci": "start-server-and-test cy:setup http://localhost:4001/_builder cy:run:ci"
|
||||
},
|
||||
"jest": {
|
||||
"jest": {
|
||||
"globals": {
|
||||
"GLOBALS": {
|
||||
"client": "web"
|
||||
|
@ -93,14 +93,14 @@
|
|||
"@sveltech/routify": "1.7.11",
|
||||
"@testing-library/jest-dom": "^5.11.0",
|
||||
"@testing-library/svelte": "^3.0.0",
|
||||
"babel-jest": "^24.8.0",
|
||||
"babel-jest": "^26.2.2",
|
||||
"browser-sync": "^2.26.7",
|
||||
"cypress": "^4.8.0",
|
||||
"cypress-terminal-report": "^1.4.1",
|
||||
"eslint-plugin-cypress": "^2.11.1",
|
||||
"http-proxy-middleware": "^0.19.1",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^24.8.0",
|
||||
"jest": "^26.2.2",
|
||||
"ncp": "^2.0.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rimraf": "^3.0.2",
|
||||
|
|
|
@ -0,0 +1,156 @@
|
|||
import { cloneDeep, difference } from "lodash"
|
||||
|
||||
/**
|
||||
* parameter for fetchBindableProperties function
|
||||
* @typedef {Object} fetchBindablePropertiesParameter
|
||||
* @property {string} componentInstanceId - an _id of a component that has been added to a screen, whihc you want to fetch bindable props for
|
||||
* @propperty {Object} screen - current screen - where componentInstanceId lives
|
||||
* @property {Object} components - dictionary of component definitions
|
||||
* @property {Array} models - array of all models
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @typedef {Object} BindableProperty
|
||||
* @property {string} type - either "instance" (binding to a component instance) or "context" (binding to data in context e.g. List Item)
|
||||
* @property {Object} instance - relevant component instance. If "context" type, this instance is the component that provides the context... e.g. the List
|
||||
* @property {string} runtimeBinding - a binding string that is a) saved against the string, and b) used at runtime to read/write the value
|
||||
* @property {string} readableBinding - a binding string that is displayed to the user, in the builder
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generates all allowed bindings from within any particular component instance
|
||||
* @param {fetchBindablePropertiesParameter} param
|
||||
* @returns {Array.<BindableProperty>}
|
||||
*/
|
||||
export default function({ componentInstanceId, screen, components, models }) {
|
||||
const walkResult = walk({
|
||||
// cloning so we are free to mutate props (e.g. by adding _contexts)
|
||||
instance: cloneDeep(screen.props),
|
||||
targetId: componentInstanceId,
|
||||
components,
|
||||
models,
|
||||
})
|
||||
|
||||
return [
|
||||
...walkResult.bindableInstances
|
||||
.filter(isInstanceInSharedContext(walkResult))
|
||||
.map(componentInstanceToBindable(walkResult)),
|
||||
|
||||
...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(),
|
||||
]
|
||||
}
|
||||
|
||||
const isInstanceInSharedContext = walkResult => i =>
|
||||
// should cover
|
||||
// - neither are in any context
|
||||
// - both in same context
|
||||
// - instance is in ancestor context of target
|
||||
i.instance._contexts.length <= walkResult.target._contexts.length &&
|
||||
difference(i.instance._contexts, walkResult.target._contexts).length === 0
|
||||
|
||||
// turns a component instance prop into binding expressions
|
||||
// used by the UI
|
||||
const componentInstanceToBindable = walkResult => i => {
|
||||
const lastContext =
|
||||
i.instance._contexts.length &&
|
||||
i.instance._contexts[i.instance._contexts.length - 1]
|
||||
const contextParentPath = lastContext
|
||||
? getParentPath(walkResult, lastContext)
|
||||
: ""
|
||||
|
||||
return {
|
||||
type: "instance",
|
||||
instance: i.instance,
|
||||
// how the binding expression persists, and is used in the app at runtime
|
||||
runtimeBinding: `${contextParentPath}${i.instance._id}.${i.prop}`,
|
||||
// how the binding exressions looks to the user of the builder
|
||||
readableBinding: `${i.instance._instanceName}`,
|
||||
}
|
||||
}
|
||||
|
||||
const contextToBindables = walkResult => c => {
|
||||
const contextParentPath = getParentPath(walkResult, c)
|
||||
|
||||
return Object.keys(c.model.schema).map(k => ({
|
||||
type: "context",
|
||||
instance: c.instance,
|
||||
// how the binding expression persists, and is used in the app at runtime
|
||||
runtimeBinding: `${contextParentPath}data.${k}`,
|
||||
// how the binding exressions looks to the user of the builder
|
||||
readableBinding: `${c.instance._instanceName}.${c.model.name}.${k}`,
|
||||
}))
|
||||
}
|
||||
|
||||
const getParentPath = (walkResult, context) => {
|
||||
// describes the number of "parent" in the path
|
||||
// clone array first so original array is not mtated
|
||||
const contextParentNumber = [...walkResult.target._contexts]
|
||||
.reverse()
|
||||
.indexOf(context)
|
||||
|
||||
return (
|
||||
new Array(contextParentNumber).fill("parent").join(".") +
|
||||
// trailing . if has parents
|
||||
(contextParentNumber ? "." : "")
|
||||
)
|
||||
}
|
||||
|
||||
const walk = ({ instance, targetId, components, models, result }) => {
|
||||
if (!result) {
|
||||
result = {
|
||||
target: null,
|
||||
bindableInstances: [],
|
||||
allContexts: [],
|
||||
currentContexts: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (!instance._contexts) instance._contexts = []
|
||||
|
||||
// "component" is the component definition (object in component.json)
|
||||
const component = components[instance._component]
|
||||
|
||||
if (instance._id === targetId) {
|
||||
// found it
|
||||
result.target = instance
|
||||
} else {
|
||||
if (component.bindable) {
|
||||
// pushing all components in here initially
|
||||
// but this will not be correct, as some of
|
||||
// these components will be in another context
|
||||
// but we dont know this until the end of the walk
|
||||
// so we will filter in another metod
|
||||
result.bindableInstances.push({
|
||||
instance,
|
||||
prop: component.bindable,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// a component that provides context to it's children
|
||||
const contextualInstance = component.context && instance[component.context]
|
||||
|
||||
if (contextualInstance) {
|
||||
// add to currentContexts (ancestory of context)
|
||||
// before walking children
|
||||
const model = models.find(m => m._id === instance[component.context])
|
||||
result.currentContexts.push({ instance, model })
|
||||
}
|
||||
|
||||
const currentContexts = [...result.currentContexts]
|
||||
for (let child of instance._children || []) {
|
||||
// attaching _contexts of components, for eas comparison later
|
||||
// these have been deep cloned above, so shouln't modify the
|
||||
// original component instances
|
||||
child._contexts = currentContexts
|
||||
walk({ instance: child, targetId, components, models, result })
|
||||
}
|
||||
|
||||
if (contextualInstance) {
|
||||
// child walk done, remove from currentContexts
|
||||
result.currentContexts.pop()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { values, cloneDeep } from "lodash/fp"
|
||||
import { get_capitalised_name } from "../../helpers"
|
||||
import getNewComponentName from "../getNewComponentName"
|
||||
import { backendUiStore } from "builderStore"
|
||||
import * as backendStoreActions from "./backend"
|
||||
import { writable, get } from "svelte/store"
|
||||
|
@ -281,7 +281,7 @@ const addChildComponent = store => (componentToAdd, presetProps = {}) => {
|
|||
const component = getComponentDefinition(state, componentToAdd)
|
||||
|
||||
const instanceId = get(backendUiStore).selectedDatabase._id
|
||||
const instanceName = get_capitalised_name(componentToAdd)
|
||||
const instanceName = getNewComponentName(componentToAdd, state)
|
||||
|
||||
const newComponent = createProps(
|
||||
component,
|
||||
|
@ -487,7 +487,7 @@ const pasteComponent = store => (targetComponent, mode) => {
|
|||
// in case we paste a second time
|
||||
s.componentToPaste.isCut = false
|
||||
} else {
|
||||
generateNewIdsForComponent(componentToPaste)
|
||||
generateNewIdsForComponent(componentToPaste, s)
|
||||
}
|
||||
delete componentToPaste.isCut
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import { makePropsSafe } from "components/userInterface/pagesParsing/createProps
|
|||
import api from "./api"
|
||||
import { generate_screen_css } from "./generate_css"
|
||||
import { uuid } from "./uuid"
|
||||
import getNewComponentName from "./getNewComponentName"
|
||||
|
||||
export const selectComponent = (state, component) => {
|
||||
const componentDef = component._component.startsWith("##")
|
||||
|
@ -81,7 +82,8 @@ export const regenerateCssForCurrentScreen = state => {
|
|||
return state
|
||||
}
|
||||
|
||||
export const generateNewIdsForComponent = c =>
|
||||
export const generateNewIdsForComponent = (c, state) =>
|
||||
walkProps(c, p => {
|
||||
p._id = uuid()
|
||||
p._instanceName = getNewComponentName(p._component, state)
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
} from "components/common/Icons/"
|
||||
import CodeEditor from "./CodeEditor.svelte"
|
||||
import LayoutEditor from "./LayoutEditor.svelte"
|
||||
import EventsEditor from "./EventsEditor"
|
||||
import panelStructure from "./temporaryPanelStructure.js"
|
||||
import CategoryTab from "./CategoryTab.svelte"
|
||||
import DesignView from "./DesignView.svelte"
|
||||
|
@ -25,7 +24,6 @@
|
|||
let categories = [
|
||||
{ value: "settings", name: "Settings" },
|
||||
{ value: "design", name: "Design" },
|
||||
{ value: "events", name: "Events" },
|
||||
]
|
||||
let selectedCategory = categories[0]
|
||||
|
||||
|
@ -113,8 +111,6 @@
|
|||
displayNameField={displayName}
|
||||
onChange={onPropChanged}
|
||||
screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} />
|
||||
{:else if selectedCategory.value === 'events'}
|
||||
<EventsEditor component={componentInstance} />
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
|
|
@ -1,37 +1,28 @@
|
|||
<script>
|
||||
import { store } from "builderStore"
|
||||
import { Button } from "@budibase/bbui"
|
||||
import Modal from "../../common/Modal.svelte"
|
||||
import HandlerSelector from "./HandlerSelector.svelte"
|
||||
import IconButton from "../../common/IconButton.svelte"
|
||||
import ActionButton from "../../common/ActionButton.svelte"
|
||||
import PlusButton from "../../common/PlusButton.svelte"
|
||||
import Select from "../../common/Select.svelte"
|
||||
import Input from "../../common/Input.svelte"
|
||||
import getIcon from "../../common/icon"
|
||||
import { CloseIcon } from "components/common/Icons/"
|
||||
|
||||
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let event
|
||||
export let eventOptions = []
|
||||
export let onClose
|
||||
export let event = []
|
||||
export let eventType
|
||||
|
||||
let eventType = ""
|
||||
const dispatch = createEventDispatcher()
|
||||
let draftEventHandler = { parameters: [] }
|
||||
|
||||
$: eventData = event || { handlers: [] }
|
||||
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0)
|
||||
eventType = eventOptions[0].name
|
||||
$: handlers = (event && [...event]) || []
|
||||
|
||||
const closeModal = () => {
|
||||
onClose()
|
||||
dispatch("close")
|
||||
draftEventHandler = { parameters: [] }
|
||||
eventData = { handlers: [] }
|
||||
handlers = []
|
||||
}
|
||||
|
||||
const updateEventHandler = (updatedHandler, index) => {
|
||||
eventData.handlers[index] = updatedHandler
|
||||
handlers[index] = updatedHandler
|
||||
dispatch("change", handlers)
|
||||
}
|
||||
|
||||
const updateDraftEventHandler = updatedHandler => {
|
||||
|
@ -39,8 +30,8 @@
|
|||
}
|
||||
|
||||
const deleteEventHandler = index => {
|
||||
eventData.handlers.splice(index, 1)
|
||||
eventData = eventData
|
||||
handlers.splice(index, 1)
|
||||
dispatch("change", handlers)
|
||||
}
|
||||
|
||||
const createNewEventHandler = handler => {
|
||||
|
@ -48,37 +39,15 @@
|
|||
parameters: {},
|
||||
[EVENT_TYPE_MEMBER_NAME]: "",
|
||||
}
|
||||
eventData.handlers.push(newHandler)
|
||||
eventData = eventData
|
||||
}
|
||||
|
||||
const deleteEvent = () => {
|
||||
store.setComponentProp(eventType, [])
|
||||
closeModal()
|
||||
}
|
||||
|
||||
const saveEventData = () => {
|
||||
store.setComponentProp(eventType, eventData.handlers)
|
||||
closeModal()
|
||||
handlers.push(newHandler)
|
||||
dispatch("change", handlers)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="body">
|
||||
<div class="heading">
|
||||
<h3>
|
||||
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="event-options">
|
||||
<div class="section">
|
||||
<h4>Event Type</h4>
|
||||
<Select bind:value={eventType}>
|
||||
{#each eventOptions as option}
|
||||
<option value={option.name}>{option.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
</div>
|
||||
<h3>{eventType} Event</h3>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
|
@ -92,35 +61,16 @@
|
|||
}}
|
||||
handler={draftEventHandler} />
|
||||
</div>
|
||||
{#if eventData}
|
||||
{#each eventData.handlers as handler, index}
|
||||
<HandlerSelector
|
||||
{index}
|
||||
onChanged={updateEventHandler}
|
||||
onRemoved={() => deleteEventHandler(index)}
|
||||
{handler} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#each handlers as handler, index}
|
||||
<HandlerSelector
|
||||
{index}
|
||||
onChanged={updateEventHandler}
|
||||
onRemoved={() => deleteEventHandler(index)}
|
||||
{handler} />
|
||||
{/each}
|
||||
|
||||
</div>
|
||||
<div class="footer">
|
||||
{#if eventData.name}
|
||||
<Button
|
||||
outline
|
||||
on:click={deleteEvent}
|
||||
disabled={eventData.handlers.length === 0}>
|
||||
Delete
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="save">
|
||||
<Button
|
||||
primary
|
||||
on:click={saveEventData}
|
||||
disabled={eventData.handlers.length === 0}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="close-button" on:click={closeModal}>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
|
@ -129,6 +79,7 @@
|
|||
<style>
|
||||
.container {
|
||||
position: relative;
|
||||
width: 600px;
|
||||
}
|
||||
.heading {
|
||||
margin-bottom: 20px;
|
||||
|
|
|
@ -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>
|
|
@ -1,161 +0,0 @@
|
|||
<script>
|
||||
import { getContext } from "svelte"
|
||||
import {
|
||||
keys,
|
||||
map,
|
||||
some,
|
||||
includes,
|
||||
cloneDeep,
|
||||
isEqual,
|
||||
sortBy,
|
||||
filter,
|
||||
difference,
|
||||
} from "lodash/fp"
|
||||
import { pipe } from "components/common/core"
|
||||
import Checkbox from "components/common/Checkbox.svelte"
|
||||
import Textbox from "components/common/Textbox.svelte"
|
||||
import Dropdown from "components/common/Dropdown.svelte"
|
||||
import PlusButton from "components/common/PlusButton.svelte"
|
||||
import IconButton from "components/common/IconButton.svelte"
|
||||
import EventEditorModal from "./EventEditorModal.svelte"
|
||||
|
||||
import { PencilIcon } from "components/common/Icons"
|
||||
import { EVENT_TYPE_MEMBER_NAME } from "components/common/eventHandlers"
|
||||
|
||||
export const EVENT_TYPE = "event"
|
||||
|
||||
export let component
|
||||
|
||||
let events = []
|
||||
let selectedEvent = null
|
||||
|
||||
$: {
|
||||
events = Object.keys(component)
|
||||
// TODO: use real events
|
||||
.filter(propName => ["onChange", "onClick", "onLoad"].includes(propName))
|
||||
.map(propName => ({
|
||||
name: propName,
|
||||
handlers: component[propName] || [],
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle create app modal
|
||||
const { open, close } = getContext("simple-modal")
|
||||
|
||||
const openModal = event => {
|
||||
selectedEvent = event
|
||||
open(
|
||||
EventEditorModal,
|
||||
{
|
||||
eventOptions: events,
|
||||
event: selectedEvent,
|
||||
onClose: () => {
|
||||
close()
|
||||
selectedEvent = null
|
||||
},
|
||||
},
|
||||
{
|
||||
closeButton: false,
|
||||
closeOnEsc: false,
|
||||
styleContent: { padding: 0 },
|
||||
closeOnOuterClick: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<button class="newevent" on:click={() => openModal()}>
|
||||
<i class="icon ri-add-circle-fill" />
|
||||
Create New Event
|
||||
</button>
|
||||
|
||||
<div class="root">
|
||||
<form on:submit|preventDefault class="uk-form-stacked form-root">
|
||||
{#each events as event, index}
|
||||
{#if event.handlers.length > 0}
|
||||
<div
|
||||
class:selected={selectedEvent && selectedEvent.index === index}
|
||||
class="handler-container budibase__nav-item"
|
||||
on:click={() => openModal({ ...event, index })}>
|
||||
<span class="event-name">{event.name}</span>
|
||||
<span class="edit-text">EDIT</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
font-size: 10pt;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.newevent {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--grey-4);
|
||||
border-radius: 3px;
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
margin: 0px 0px 12px 0px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: white;
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 2ms;
|
||||
}
|
||||
|
||||
.newevent:hover {
|
||||
background: var(--grey-1);
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.form-root {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.handler-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
border: 2px solid #f9f9f9;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.event-name {
|
||||
margin-top: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
color: rgba(22, 48, 87, 0.6);
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.edit-text {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-weight: bold;
|
||||
align-self: end;
|
||||
justify-self: end;
|
||||
font-size: 10px;
|
||||
color: rgba(35, 65, 105, 0.4);
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--blue);
|
||||
background: var(--grey-1) !important;
|
||||
}
|
||||
</style>
|
|
@ -1,8 +1,5 @@
|
|||
<script>
|
||||
import { Button } from "@budibase/bbui"
|
||||
import IconButton from "components/common/IconButton.svelte"
|
||||
import PlusButton from "components/common/PlusButton.svelte"
|
||||
import Select from "components/common/Select.svelte"
|
||||
import { Button, Select } from "@budibase/bbui"
|
||||
import StateBindingCascader from "./StateBindingCascader.svelte"
|
||||
import { find, map, keys, reduce, keyBy } from "lodash/fp"
|
||||
import { pipe } from "components/common/core"
|
||||
|
@ -96,7 +93,7 @@
|
|||
{#if newHandler}
|
||||
<Button primary thin on:click={onCreate}>Add Action</Button>
|
||||
{:else}
|
||||
<Button outline thin on:click={onRemoved}>Remove Action</Button>
|
||||
<Button secondary thin on:click={onRemoved}>Remove Action</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
import Input from "../common/Input.svelte"
|
||||
import { goto } from "@sveltech/routify"
|
||||
import { excludeProps } from "./propertyCategories.js"
|
||||
import { store } from "builderStore"
|
||||
import { walkProps } from "builderStore/storeUtils"
|
||||
|
||||
export let panelDefinition = []
|
||||
export let componentDefinition = {}
|
||||
|
@ -13,6 +15,7 @@
|
|||
export let screenOrPageInstance
|
||||
|
||||
let pageScreenProps = ["title", "favicon", "description", "route"]
|
||||
let duplicateName = false
|
||||
|
||||
const propExistsOnComponentDef = prop =>
|
||||
pageScreenProps.includes(prop) || prop in componentDefinition.props
|
||||
|
@ -33,6 +36,43 @@
|
|||
|
||||
$: isPage = screenOrPageInstance && screenOrPageInstance.favicon
|
||||
$: screenOrPageDefinition = isPage ? pageDefinition : screenDefinition
|
||||
|
||||
const isDuplicateName = name => {
|
||||
let duplicate = false
|
||||
|
||||
const lookForDuplicate = rootPops => {
|
||||
walkProps(rootPops, (inst, cancel) => {
|
||||
if (inst._instanceName === name && inst._id !== componentInstance._id) {
|
||||
duplicate = true
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
// check page first
|
||||
lookForDuplicate($store.pages[$store.currentPageName].props)
|
||||
if (duplicate) return true
|
||||
|
||||
// if viwing screen, check current screen for duplicate
|
||||
if ($store.currentFrontEndType === "screen") {
|
||||
lookForDuplicate($store.currentPreviewItem.props)
|
||||
} else {
|
||||
// viewing master page - need to dedupe against all screens
|
||||
for (let screen of $store.screens) {
|
||||
lookForDuplicate(screen.props)
|
||||
}
|
||||
}
|
||||
|
||||
return duplicate
|
||||
}
|
||||
|
||||
const onInstanceNameChange = (_, name) => {
|
||||
if (isDuplicateName(name)) {
|
||||
duplicateName = true
|
||||
} else {
|
||||
duplicateName = false
|
||||
onChange("_instanceName", name)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if screenOrPageInstance}
|
||||
|
@ -54,7 +94,10 @@
|
|||
label="Name"
|
||||
key="_instanceName"
|
||||
value={componentInstance._instanceName}
|
||||
{onChange} />
|
||||
onChange={onInstanceNameChange} />
|
||||
{#if duplicateName}
|
||||
<span class="duplicate-name">Name must be unique</span>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if panelDefinition && panelDefinition.length > 0}
|
||||
|
@ -79,4 +122,11 @@
|
|||
div {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duplicate-name {
|
||||
color: var(--red);
|
||||
font-size: var(--font-size-xs);
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -2,6 +2,7 @@ import Input from "../common/Input.svelte"
|
|||
import OptionSelect from "./OptionSelect.svelte"
|
||||
import Checkbox from "../common/Checkbox.svelte"
|
||||
import ModelSelect from "components/userInterface/ModelSelect.svelte"
|
||||
import Event from "components/userInterface/EventsEditor/EventPropertyControl.svelte"
|
||||
|
||||
import { all } from "./propertyCategories.js"
|
||||
/*
|
||||
|
@ -201,6 +202,7 @@ export default {
|
|||
valueKey: "checked",
|
||||
control: Checkbox,
|
||||
},
|
||||
{ label: "onClick", key: "onClick", control: Event },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
@ -0,0 +1,200 @@
|
|||
import fetchBindableProperties from "../src/builderStore/fetchBindableProperties"
|
||||
describe("fetch bindable properties", () => {
|
||||
|
||||
it("should return bindable properties from screen components", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "heading-id",
|
||||
...testData()
|
||||
})
|
||||
const componentBinding = result.find(r => r.instance._id === "search-input-id" && r.type === "instance")
|
||||
expect(componentBinding).toBeDefined()
|
||||
expect(componentBinding.type).toBe("instance")
|
||||
expect(componentBinding.runtimeBinding).toBe("search-input-id.value")
|
||||
})
|
||||
|
||||
it("should not return bindable components when not in their context", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "heading-id",
|
||||
...testData()
|
||||
})
|
||||
const componentBinding = result.find(r => r.instance._id === "list-item-input-id")
|
||||
expect(componentBinding).not.toBeDefined()
|
||||
})
|
||||
|
||||
it("should return model schema, when inside a context", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "list-item-input-id",
|
||||
...testData()
|
||||
})
|
||||
const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context")
|
||||
expect(contextBindings.length).toBe(2)
|
||||
|
||||
const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name")
|
||||
expect(namebinding).toBeDefined()
|
||||
expect(namebinding.readableBinding).toBe("list-name.Test Model.name")
|
||||
|
||||
const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description")
|
||||
expect(descriptionbinding).toBeDefined()
|
||||
expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description")
|
||||
})
|
||||
|
||||
it("should return model schema, for grantparent context", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "child-list-item-input-id",
|
||||
...testData()
|
||||
})
|
||||
const contextBindings = result.filter(r => r.type==="context")
|
||||
expect(contextBindings.length).toBe(4)
|
||||
|
||||
const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name")
|
||||
expect(namebinding_parent).toBeDefined()
|
||||
expect(namebinding_parent.readableBinding).toBe("list-name.Test Model.name")
|
||||
|
||||
const descriptionbinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.description")
|
||||
expect(descriptionbinding_parent).toBeDefined()
|
||||
expect(descriptionbinding_parent.readableBinding).toBe("list-name.Test Model.description")
|
||||
|
||||
const namebinding_own = contextBindings.find(b => b.runtimeBinding === "data.name")
|
||||
expect(namebinding_own).toBeDefined()
|
||||
expect(namebinding_own.readableBinding).toBe("child-list-name.Test Model.name")
|
||||
|
||||
const descriptionbinding_own = contextBindings.find(b => b.runtimeBinding === "data.description")
|
||||
expect(descriptionbinding_own).toBeDefined()
|
||||
expect(descriptionbinding_own.readableBinding).toBe("child-list-name.Test Model.description")
|
||||
})
|
||||
|
||||
it("should return bindable component props, from components in same context", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "list-item-heading-id",
|
||||
...testData()
|
||||
})
|
||||
const componentBinding = result.find(r => r.instance._id === "list-item-input-id" && r.type === "instance")
|
||||
expect(componentBinding).toBeDefined()
|
||||
expect(componentBinding.runtimeBinding).toBe("list-item-input-id.value")
|
||||
})
|
||||
|
||||
it("should not return components from child context", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "list-item-heading-id",
|
||||
...testData()
|
||||
})
|
||||
const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance")
|
||||
expect(componentBinding).not.toBeDefined()
|
||||
})
|
||||
|
||||
it("should return bindable component props, from components in same context (when nested context)", () => {
|
||||
const result = fetchBindableProperties({
|
||||
componentInstanceId: "child-list-item-heading-id",
|
||||
...testData()
|
||||
})
|
||||
const componentBinding = result.find(r => r.instance._id === "child-list-item-input-id" && r.type === "instance")
|
||||
expect(componentBinding).toBeDefined()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
const testData = () => {
|
||||
|
||||
const screen = {
|
||||
instanceName: "test screen",
|
||||
name: "screen-id",
|
||||
route: "/",
|
||||
props: {
|
||||
_id:"screent-root-id",
|
||||
_component: "@budibase/standard-components/container",
|
||||
_children: [
|
||||
{
|
||||
_id: "heading-id",
|
||||
_instanceName: "list item heading",
|
||||
_component: "@budibase/standard-components/heading",
|
||||
text: "Screen Title"
|
||||
},
|
||||
{
|
||||
_id: "search-input-id",
|
||||
_instanceName: "Search Input",
|
||||
_component: "@budibase/standard-components/input",
|
||||
value: "search phrase"
|
||||
},
|
||||
{
|
||||
_id: "list-id",
|
||||
_component: "@budibase/standard-components/list",
|
||||
_instanceName: "list-name",
|
||||
model: "test-model-id",
|
||||
_children: [
|
||||
{
|
||||
_id: "list-item-heading-id",
|
||||
_instanceName: "list item heading",
|
||||
_component: "@budibase/standard-components/heading",
|
||||
text: "hello"
|
||||
},
|
||||
{
|
||||
_id: "list-item-input-id",
|
||||
_instanceName: "List Item Input",
|
||||
_component: "@budibase/standard-components/input",
|
||||
value: "list item"
|
||||
},
|
||||
{
|
||||
_id: "child-list-id",
|
||||
_component: "@budibase/standard-components/list",
|
||||
_instanceName: "child-list-name",
|
||||
model: "test-model-id",
|
||||
_children: [
|
||||
{
|
||||
_id: "child-list-item-heading-id",
|
||||
_instanceName: "child list item heading",
|
||||
_component: "@budibase/standard-components/heading",
|
||||
text: "hello"
|
||||
},
|
||||
{
|
||||
_id: "child-list-item-input-id",
|
||||
_instanceName: "Child List Item Input",
|
||||
_component: "@budibase/standard-components/input",
|
||||
value: "child list item"
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const models = [{
|
||||
_id: "test-model-id",
|
||||
name: "Test Model",
|
||||
schema: {
|
||||
name: {
|
||||
type: "string"
|
||||
},
|
||||
description: {
|
||||
type: "string"
|
||||
}
|
||||
}
|
||||
}]
|
||||
|
||||
const components = {
|
||||
"@budibase/standard-components/container" : {
|
||||
props: {},
|
||||
},
|
||||
"@budibase/standard-components/list" : {
|
||||
context: "model",
|
||||
props: {
|
||||
model: "string"
|
||||
},
|
||||
},
|
||||
"@budibase/standard-components/input" : {
|
||||
bindable: "value",
|
||||
props: {
|
||||
value: "string"
|
||||
},
|
||||
},
|
||||
"@budibase/standard-components/heading" : {
|
||||
props: {
|
||||
text: "string"
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return { screen, models, components }
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
],
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { get } from "svelte/store"
|
||||
import mustache from "mustache"
|
||||
import { appStore } from "../../state/store"
|
||||
import renderTemplateString from "../../state/renderTemplateString"
|
||||
import appStore from "../../state/store"
|
||||
import Orchestrator from "./orchestrator"
|
||||
import clientActions from "./actions"
|
||||
|
||||
|
@ -18,9 +17,9 @@ export const clientStrategy = ({ api }) => ({
|
|||
if (typeof argValue !== "string") continue
|
||||
|
||||
// Render the string with values from the workflow context and state
|
||||
mappedArgs[arg] = mustache.render(argValue, {
|
||||
mappedArgs[arg] = renderTemplateString(argValue, {
|
||||
context: this.context,
|
||||
state: get(appStore),
|
||||
state: appStore.get(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { appStore } from "../state/store"
|
||||
import mustache from "mustache"
|
||||
import renderTemplateString from "../state/renderTemplateString"
|
||||
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] = renderTemplateString(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 = () => ({
|
||||
|
|
|
@ -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 }) => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
|
@ -4,9 +4,9 @@ import {
|
|||
EVENT_TYPE_MEMBER_NAME,
|
||||
} from "./eventHandlers"
|
||||
import { bbFactory } from "./bbComponentApi"
|
||||
import mustache from "mustache"
|
||||
import { get } from "svelte/store"
|
||||
import { appStore } from "./store"
|
||||
import renderTemplateString from "./renderTemplateString"
|
||||
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] = renderTemplateString(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 = {
|
||||
|
@ -90,10 +84,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => {
|
|||
for (let paramName in handlerInfo.parameters) {
|
||||
const paramValue = handlerInfo.parameters[paramName]
|
||||
resolvedParams[paramName] = () =>
|
||||
mustache.render(paramValue, {
|
||||
state: getCurrentState(),
|
||||
context,
|
||||
})
|
||||
renderTemplateString(paramValue, state)
|
||||
}
|
||||
|
||||
handlerInfo.parameters = resolvedParams
|
||||
|
@ -113,7 +104,7 @@ const _setup = ({ handlerTypes, getCurrentState, bb, store }) => node => {
|
|||
}
|
||||
}
|
||||
|
||||
const setup = _setup({ handlerTypes, getCurrentState, bb, store })
|
||||
const setup = _setup({ handlerTypes, getCurrentState, bb })
|
||||
initialProps._bb = bb(node, setup)
|
||||
|
||||
return initialProps
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
})
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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} />
|
|
@ -131,6 +131,7 @@
|
|||
},
|
||||
"select": {
|
||||
"name": "Select",
|
||||
"bindable": "value",
|
||||
"description": "An HTML <select> (dropdown)",
|
||||
"props": {
|
||||
"value": "string",
|
||||
|
@ -171,6 +172,7 @@
|
|||
},
|
||||
"checkbox": {
|
||||
"name": "Checkbox",
|
||||
"bindable": "value",
|
||||
"description": "A selectable checkbox component",
|
||||
"props": {
|
||||
"label": "string",
|
||||
|
@ -181,6 +183,7 @@
|
|||
},
|
||||
"radiobutton": {
|
||||
"name": "Radiobutton",
|
||||
"bindable": "value",
|
||||
"description": "A selectable radiobutton component",
|
||||
"props": {
|
||||
"label": "string",
|
||||
|
@ -244,8 +247,9 @@
|
|||
}
|
||||
},
|
||||
"list": {
|
||||
"description": "A configurable data list that attaches to your backend models.",
|
||||
"description": "configurable data list that attaches to your backend models.",
|
||||
"children": true,
|
||||
"context": "model",
|
||||
"data": true,
|
||||
"props": {
|
||||
"model": "models"
|
||||
|
@ -266,6 +270,7 @@
|
|||
"recorddetail": {
|
||||
"description": "Loads a record, using an ID in the url",
|
||||
"children": true,
|
||||
"context": "model",
|
||||
"data": true,
|
||||
"props": {
|
||||
"model": "models"
|
||||
|
|
|
@ -12,7 +12,8 @@
|
|||
|
||||
const onchange = ev => {
|
||||
if (_bb) {
|
||||
_bb.setStateFromBinding(_bb.props.value, ev.target.value)
|
||||
const val = type === "checkbox" ? ev.target.checked : ev.target.value
|
||||
_bb.setBinding("value", val)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
const onchange = ev => {
|
||||
if (_bb) {
|
||||
_bb.setStateFromBinding(_bb.props.value, ev.target.value)
|
||||
_bb.setBinding("value", ev.target.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
Loading…
Reference in New Issue