Merge in master

This commit is contained in:
Andrew Kingston 2020-09-14 10:16:09 +01:00
commit d166d8072b
44 changed files with 1002 additions and 6789 deletions

View File

@ -21,7 +21,7 @@
"publishdev": "lerna run publishdev", "publishdev": "lerna run publishdev",
"publishnpm": "yarn build && lerna publish --force-publish", "publishnpm": "yarn build && lerna publish --force-publish",
"clean": "lerna clean", "clean": "lerna clean",
"dev": "node ./scripts/symlinkDev.js && lerna run --parallel --stream dev:builder", "dev": "node ./scripts/symlinkDev.js && lerna run --parallel dev:builder",
"test": "lerna run test", "test": "lerna run test",
"lint": "eslint packages", "lint": "eslint packages",
"lint:fix": "eslint --fix packages", "lint:fix": "eslint --fix packages",

View File

@ -5,4 +5,5 @@ package-lock.json
release/ release/
dist/ dist/
cypress/screenshots cypress/screenshots
cypress/videos
routify routify

View File

@ -72,7 +72,6 @@
"d3-selection": "^1.4.1", "d3-selection": "^1.4.1",
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"fast-sort": "^2.2.0", "fast-sort": "^2.2.0",
"feather-icons": "^4.21.0",
"lodash": "^4.17.13", "lodash": "^4.17.13",
"mustache": "^4.0.1", "mustache": "^4.0.1",
"posthog-js": "1.3.1", "posthog-js": "1.3.1",

View File

@ -1,3 +1,4 @@
// Array.flat needs polyfilled in < Node 11
if (!Array.prototype.flat) { if (!Array.prototype.flat) {
Object.defineProperty(Array.prototype, "flat", { Object.defineProperty(Array.prototype, "flat", {
configurable: true, configurable: true,

View File

@ -37,7 +37,9 @@ export default function({ componentInstanceId, screen, components, models }) {
.filter(isInstanceInSharedContext(walkResult)) .filter(isInstanceInSharedContext(walkResult))
.map(componentInstanceToBindable(walkResult)), .map(componentInstanceToBindable(walkResult)),
...walkResult.target._contexts.map(contextToBindables(walkResult)).flat(), ...walkResult.target._contexts
.map(contextToBindables(models, walkResult))
.flat(),
] ]
} }
@ -69,17 +71,31 @@ const componentInstanceToBindable = walkResult => i => {
} }
} }
const contextToBindables = walkResult => context => { const contextToBindables = (models, walkResult) => context => {
const contextParentPath = getParentPath(walkResult, context) const contextParentPath = getParentPath(walkResult, context)
return Object.keys(context.model.schema).map(k => ({ const newBindable = key => ({
type: "context", type: "context",
instance: context.instance, instance: context.instance,
// how the binding expression persists, and is used in the app at runtime // how the binding expression persists, and is used in the app at runtime
runtimeBinding: `${contextParentPath}data.${k}`, runtimeBinding: `${contextParentPath}data.${key}`,
// how the binding exressions looks to the user of the builder // how the binding exressions looks to the user of the builder
readableBinding: `${context.instance._instanceName}.${context.model.name}.${k}`, readableBinding: `${context.instance._instanceName}.${context.model.label}.${key}`,
})) })
// see ModelViewSelect.svelte for the format of context.model
// ... this allows us to bind to Model scheams, or View schemas
const model = models.find(m => m._id === context.model.modelId)
const schema = context.model.isModel
? model.schema
: model.views[context.model.name].schema
return (
Object.keys(schema)
.map(newBindable)
// add _id and _rev fields - not part of schema, but always valid
.concat([newBindable("_id"), newBindable("_rev")])
)
} }
const getParentPath = (walkResult, context) => { const getParentPath = (walkResult, context) => {
@ -135,7 +151,7 @@ const walk = ({ instance, targetId, components, models, result }) => {
if (contextualInstance) { if (contextualInstance) {
// add to currentContexts (ancestory of context) // add to currentContexts (ancestory of context)
// before walking children // before walking children
const model = models.find(m => m._id === instance[component.context]) const model = instance[component.context]
result.currentContexts.push({ instance, model }) result.currentContexts.push({ instance, model })
} }

View File

@ -0,0 +1,42 @@
export const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
export function readableToRuntimeBinding(bindableProperties, textWithBindings) {
// Find all instances of mustasche
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)
let result = textWithBindings
// Replace readableBindings with runtimeBindings
boundValues &&
boundValues.forEach(boundValue => {
const binding = bindableProperties.find(({ readableBinding }) => {
return boundValue === `{{ ${readableBinding} }}`
})
if (binding) {
result = textWithBindings.replace(
boundValue,
`{{ ${binding.runtimeBinding} }}`
)
}
})
return result
}
export function runtimeToReadableBinding(bindableProperties, textWithBindings) {
let temp = textWithBindings
const boundValues =
(typeof textWithBindings === "string" &&
textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)) ||
[]
// Replace runtimeBindings with readableBindings:
boundValues.forEach(v => {
const binding = bindableProperties.find(({ runtimeBinding }) => {
return v === `{{ ${runtimeBinding} }}`
})
if (binding) {
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`)
}
})
return temp
}

View File

@ -0,0 +1,12 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24">
<path fill="none" d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10
10zm0-11.414L9.172 7.757 7.757 9.172 10.586 12l-2.829 2.828 1.415 1.415L12
13.414l2.828 2.829 1.415-1.415L13.414 12l2.829-2.828-1.415-1.415L12 10.586z" />
</svg>

After

Width:  |  Height:  |  Size: 412 B

View File

@ -32,3 +32,4 @@ export { default as TwitterIcon } from "./Twitter.svelte"
export { default as InfoIcon } from "./Info.svelte" export { default as InfoIcon } from "./Info.svelte"
export { default as CloseIcon } from "./Close.svelte" export { default as CloseIcon } from "./Close.svelte"
export { default as MoreIcon } from "./More.svelte" export { default as MoreIcon } from "./More.svelte"
export { default as CloseCircleIcon } from "./CloseCircle.svelte"

View File

@ -1,4 +0,0 @@
import feather from "feather-icons"
const getIcon = (icon, size) =>
feather.icons[icon].toSvg({ height: size || "16", width: size || "16" })
export default getIcon

View File

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

View File

@ -1,168 +1,220 @@
<script> <script>
import { store } from "builderStore" import { store } from "builderStore"
import { Button, Select } from "@budibase/bbui" import { TextButton, Button, Heading, DropdownMenu } from "@budibase/bbui"
import HandlerSelector from "./HandlerSelector.svelte" import { AddIcon, ArrowDownIcon } from "components/common/Icons/"
import ActionButton from "../../common/ActionButton.svelte"
import getIcon from "../../common/icon"
import { CloseIcon } from "components/common/Icons/"
import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers" import { EVENT_TYPE_MEMBER_NAME } from "../../common/eventHandlers"
import actionTypes from "./actions"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let event export let event
export let eventOptions = []
export let onClose
let eventType = "" let addActionButton
let addActionDropdown
let selectedAction
let draftEventHandler = { parameters: [] } let draftEventHandler = { parameters: [] }
$: eventData = event || { handlers: [] } $: actions = event || []
$: if (!eventOptions.includes(eventType) && eventOptions.length > 0) $: selectedActionComponent =
eventType = eventOptions[0].name selectedAction &&
actionTypes.find(t => t.name === selectedAction[EVENT_TYPE_MEMBER_NAME])
.component
const closeModal = () => { const closeModal = () => {
onClose() dispatch("close")
draftEventHandler = { parameters: [] } draftEventHandler = { parameters: [] }
eventData = { handlers: [] } actions = []
} }
const updateEventHandler = (updatedHandler, index) => { const updateEventHandler = (updatedHandler, index) => {
eventData.handlers[index] = updatedHandler actions[index] = updatedHandler
} }
const updateDraftEventHandler = updatedHandler => { const deleteAction = index => {
draftEventHandler = updatedHandler actions.splice(index, 1)
actions = actions
} }
const deleteEventHandler = index => { const addAction = actionType => () => {
eventData.handlers.splice(index, 1) const newAction = {
eventData = eventData
}
const createNewEventHandler = handler => {
const newHandler = handler || {
parameters: {}, parameters: {},
[EVENT_TYPE_MEMBER_NAME]: "", [EVENT_TYPE_MEMBER_NAME]: actionType.name,
} }
eventData.handlers.push(newHandler) actions.push(newAction)
eventData = eventData selectedAction = newAction
actions = actions
addActionDropdown.hide()
} }
const deleteEvent = () => { const selectAction = action => () => {
store.setComponentProp(eventType, []) selectedAction = action
closeModal()
} }
const saveEventData = () => { const saveEventData = () => {
store.setComponentProp(eventType, eventData.handlers) dispatch("change", actions)
closeModal() closeModal()
} }
</script> </script>
<div class="container"> <div class="root">
<div class="body">
<div class="heading"> <div class="header">
<h3> <Heading small dark>Actions</Heading>
{eventData.name ? `${eventData.name} Event` : 'Create a New Component Event'} <div bind:this={addActionButton}>
</h3> <TextButton text small blue on:click={addActionDropdown.show}>
Add Action
<div style="height: 20px; width: 20px;">
<AddIcon />
</div>
</TextButton>
</div>
<DropdownMenu
bind:this={addActionDropdown}
anchor={addActionButton}
align="right">
<div class="available-actions-container">
{#each actionTypes as actionType}
<div class="available-action" on:click={addAction(actionType)}>
<span>{actionType.name}</span>
</div> </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} {/each}
</Select>
</div> </div>
</DropdownMenu>
</div> </div>
<div class="section"> <div class="actions-container">
<h4>Event Action(s)</h4> {#if actions && actions.length > 0}
<HandlerSelector {#each actions as action, index}
newHandler <div class="action-container">
onChanged={updateDraftEventHandler} <div class="action-header" on:click={selectAction(action)}>
onCreate={() => { <p
createNewEventHandler(draftEventHandler) class="bb-body bb-body--small bb-body--color-dark"
draftEventHandler = { parameters: [] } style="margin: var(--spacing-s) 0;">
}} {index + 1}. {action[EVENT_TYPE_MEMBER_NAME]}
handler={draftEventHandler} /> </p>
<div class="row-expander" class:rotate={action !== selectedAction}>
<ArrowDownIcon />
</div> </div>
{#if eventData}
{#each eventData.handlers as handler, index}
<HandlerSelector
{index}
onChanged={updateEventHandler}
onRemoved={() => deleteEventHandler(index)}
{handler} />
{/each}
{/if}
</div> </div>
<div class="footer"> {#if action === selectedAction}
{#if eventData.name} <div class="selected-action-container">
<Button <svelte:component
outline this={selectedActionComponent}
on:click={deleteEvent} parameters={selectedAction.parameters} />
disabled={eventData.handlers.length === 0}> <div class="delete-action-button">
<TextButton text medium on:click={() => deleteAction(index)}>
Delete Delete
</Button> </TextButton>
</div>
</div>
{/if} {/if}
<div class="save">
<Button
primary
on:click={saveEventData}
disabled={eventData.handlers.length === 0}>
Save
</Button>
</div> </div>
{/each}
{/if}
</div> </div>
<div class="close-button" on:click={closeModal}>
<CloseIcon /> <div class="footer">
<a href="https://docs.budibase.com">Learn more about Actions</a>
<Button secondary on:click={closeModal}>Cancel</Button>
<Button primary on:click={saveEventData}>Save</Button>
</div> </div>
</div> </div>
<style> <style>
.container { .root {
position: relative; max-height: 50vh;
} width: 700px;
.heading { display: flex;
margin-bottom: 20px; flex-direction: column;
} }
.close-button { .header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: var(--spacing-xl);
padding-bottom: 0;
}
.action-header {
display: flex;
flex-direction: row;
align-items: center;
}
.action-header > p {
flex: 1;
}
.row-expander {
height: 30px;
width: 30px;
}
.available-action {
padding: var(--spacing-s);
font-size: var(--font-size-m);
cursor: pointer; cursor: pointer;
position: absolute;
top: 20px;
right: 20px;
}
.close-button :global(svg) {
width: 24px;
height: 24px;
} }
h4 { .available-action:hover {
margin-bottom: 10px; background: var(--grey-2);
} }
h3 { .actions-container {
margin: 0; flex: 1;
font-size: 24px; min-height: 0px;
font-weight: bold; padding-bottom: var(--spacing-s);
padding-top: 0;
border: var(--border-light);
border-width: 0 0 1px 0;
overflow-y: auto;
} }
.body {
padding: 40px; .action-container {
display: grid; border: var(--border-light);
grid-gap: 20px; border-width: 1px 0 0 0;
padding-left: var(--spacing-xl);
padding-right: var(--spacing-xl);
padding-top: 0;
padding-bottom: 0;
} }
.footer {
.selected-action-container {
padding-bottom: var(--spacing-s);
padding-top: var(--spacing-s);
}
.delete-action-button {
padding-top: var(--spacing-l);
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 30px 40px; flex-direction: row;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 50px;
background-color: var(--grey-1);
} }
.save {
margin-left: 20px; .footer {
display: flex;
flex-direction: row;
gap: var(--spacing-s);
padding: var(--spacing-xl);
padding-top: var(--spacing-m);
}
.footer > a {
flex: 1;
color: var(--grey-5);
font-size: var(--font-size-s);
text-decoration: none;
}
.footer > a:hover {
color: var(--blue);
}
.rotate :global(svg) {
transform: rotate(90deg);
} }
</style> </style>

View File

@ -1,25 +1,24 @@
<script> <script>
import { Button, DropdownMenu } from "@budibase/bbui" import { Button, Modal } from "@budibase/bbui"
import EventEditorModal from "./EventEditorModal.svelte" import EventEditorModal from "./EventEditorModal.svelte"
import { getContext } from "svelte" import { createEventDispatcher, onMount } from "svelte"
const dispatch = createEventDispatcher()
export let value export let value
export let name export let name
let button let eventsModal
let dropdown
</script> </script>
<div bind:this={button}> <Button secondary small on:click={eventsModal.show}>Define Actions</Button>
<Button secondary small on:click={dropdown.show}>Define Actions</Button>
</div> <Modal bind:this={eventsModal} maxWidth="100vw" hideCloseButton>
<DropdownMenu bind:this={dropdown} align="right" anchor={button}>
<EventEditorModal <EventEditorModal
event={value} event={value}
eventType={name} eventType={name}
on:change on:change
on:close={dropdown.hide} /> on:close={eventsModal.hide} />
</DropdownMenu> </Modal>
<style> <style>

View File

@ -0,0 +1,75 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import SaveFields from "./SaveFields.svelte"
export let parameters
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
// just wraps binding in {{ ... }}
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
const modelFields = modelId => {
const model = $backendUiStore.models.find(m => m._id === modelId)
return Object.keys(model.schema).map(k => ({
name: k,
type: model.schema[k].type,
}))
}
$: schemaFields =
parameters && parameters.modelId ? modelFields(parameters.modelId) : []
const onFieldsChanged = e => {
parameters.fields = e.detail
}
</script>
<div class="root">
<Label size="m" color="dark">Table</Label>
<Select secondary bind:value={parameters.modelId}>
<option value="" />
{#each $backendUiStore.models as model}
<option value={model._id}>{model.name}</option>
{/each}
</Select>
{#if parameters.modelId}
<SaveFields
parameterFields={parameters.fields}
{schemaFields}
on:fieldschanged={onFieldsChanged} />
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-s);
row-gap: var(--spacing-s);
grid-template-columns: auto 1fr auto 1fr auto;
align-items: baseline;
}
.root :global(.relative:nth-child(2)) {
grid-column-start: 2;
grid-column-end: 6;
}
.cannot-use {
color: var(--red);
font-size: var(--font-size-s);
text-align: center;
width: 70%;
margin: auto;
}
</style>

View File

@ -0,0 +1,29 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { store } from "builderStore"
export let parameters
</script>
<div class="root">
<Label size="m" color="dark">Screen</Label>
<Select secondary bind:value={parameters.url}>
<option value="" />
{#each $store.screens as screen}
<option value={screen.route}>{screen.props._instanceName}</option>
{/each}
</Select>
</div>
<style>
.root {
display: flex;
flex-direction: row;
align-items: baseline;
}
.root :global(.relative) {
flex: 1;
margin-left: var(--spacing-l);
}
</style>

View File

@ -0,0 +1,119 @@
<script>
// accepts an array of field names, and outputs an object of { FieldName: value }
import { Select, Label, TextButton, Spacer } from "@budibase/bbui"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import { CloseCircleIcon, AddIcon } from "components/common/Icons"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/replaceBindings"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let parameterFields
export let schemaFields
const emptyField = () => ({ name: "", value: "" })
// this statement initialises fields from parameters.fields
$: fields =
fields ||
Object.keys(parameterFields || { "": "" }).map(name => ({
name,
value:
(parameterFields &&
runtimeToReadableBinding(
bindableProperties,
parameterFields[name].value
)) ||
"",
}))
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
const addField = () => {
const newFields = fields.filter(f => f.name)
newFields.push(emptyField())
fields = newFields
rebuildParameters()
}
const removeField = field => () => {
fields = fields.filter(f => f !== field)
rebuildParameters()
}
const rebuildParameters = () => {
// rebuilds paramters.fields every time a field name or value is added
// as UI below is bound to "fields" array, but we need to output a { key: value }
const newParameterFields = {}
for (let field of fields) {
if (field.name) {
// value and type is needed by the client, so it can parse
// a string into a correct type
newParameterFields[field.name] = {
type: schemaFields.find(f => f.name === field.name).type,
value: readableToRuntimeBinding(bindableProperties, field.value),
}
}
}
dispatch("fieldschanged", newParameterFields)
}
// just wraps binding in {{ ... }}
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
</script>
{#if fields}
{#each fields as field}
<Label size="m" color="dark">Field</Label>
<Select secondary bind:value={field.name} on:blur={rebuildParameters}>
<option value="" />
{#each schemaFields as schemaField}
<option value={schemaField.name}>{schemaField.name}</option>
{/each}
</Select>
<Label size="m" color="dark">Value</Label>
<Select
editable
secondary
bind:value={field.value}
on:blur={rebuildParameters}>
<option value="" />
{#each bindableProperties as bindableProp}
<option value={toBindingExpression(bindableProp.readableBinding)}>
{bindableProp.readableBinding}
</option>
{/each}
</Select>
<div class="remove-field-container">
<TextButton text small on:click={removeField(field)}>
<CloseCircleIcon />
</TextButton>
</div>
{/each}
<div>
<Spacer small />
<TextButton text small blue on:click={addField}>
Add Field
<div style="height: 20px; width: 20px;">
<AddIcon />
</div>
</TextButton>
</div>
{/if}
<style>
.remove-field-container :global(button) {
vertical-align: bottom;
}
</style>

View File

@ -0,0 +1,134 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
import SaveFields from "./SaveFields.svelte"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/replaceBindings"
export let parameters
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
let idFields
let recordId
$: {
idFields = bindableProperties.filter(
bindable =>
bindable.type === "context" && bindable.runtimeBinding.endsWith("._id")
)
// ensure recordId is always defaulted - there is usually only one option
if (idFields.length > 0 && !parameters._id) {
recordId = idFields[0].runtimeBinding
parameters = parameters
} else if (!recordId && parameters._id) {
recordId = parameters._id
.replace("{{", "")
.replace("}}", "")
.trim()
}
}
$: parameters._id = `{{ ${recordId} }}`
// just wraps binding in {{ ... }}
const toBindingExpression = bindingPath => `{{ ${bindingPath} }}`
// finds the selected idBinding, then reads the table/view
// from the component instance that it belongs to.
// then returns the field names for that schema
const schemaFromIdBinding = recordId => {
if (!recordId) return []
const idBinding = bindableProperties.find(
prop => prop.runtimeBinding === recordId
)
if (!idBinding) return []
const { instance } = idBinding
const component = $store.components[instance._component]
// component.context is the name of the prop that holds the modelId
const modelInfo = instance[component.context]
if (!modelInfo) return []
const model = $backendUiStore.models.find(m => m._id === modelInfo.modelId)
parameters.modelId = modelInfo.modelId
return Object.keys(model.schema).map(k => ({
name: k,
type: model.schema[k].type,
}))
}
let schemaFields
$: {
if (parameters && recordId) {
schemaFields = schemaFromIdBinding(recordId)
} else {
schemaFields = []
}
}
const onFieldsChanged = e => {
parameters.fields = e.detail
}
</script>
<div class="root">
{#if idFields.length === 0}
<div class="cannot-use">
Update record can only be used within a component that provides data, such
as a List
</div>
{:else}
<Label size="m" color="dark">Record Id</Label>
<Select secondary bind:value={recordId}>
<option value="" />
{#each idFields as idField}
<option value={idField.runtimeBinding}>
{idField.readableBinding}
</option>
{/each}
</Select>
{/if}
{#if recordId}
<SaveFields
parameterFields={parameters.fields}
{schemaFields}
on:fieldschanged={onFieldsChanged} />
{/if}
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-s);
row-gap: var(--spacing-s);
grid-template-columns: auto 1fr auto 1fr auto;
align-items: baseline;
}
.root :global(.relative:nth-child(2)) {
grid-column-start: 2;
grid-column-end: 6;
}
.cannot-use {
color: var(--red);
font-size: var(--font-size-s);
text-align: center;
width: 70%;
margin: auto;
}
</style>

View File

@ -0,0 +1,23 @@
import NavigateTo from "./NavigateTo.svelte"
import UpdateRecord from "./UpdateRecord.svelte"
import CreateRecord from "./CreateRecord.svelte"
// defines what actions are available, when adding a new one
// the component is the setup panel for the action
// NOTE that the "name" is used by the client library,
// so if you want to change it, you must change it client lib too
export default [
{
name: "Create Record",
component: CreateRecord,
},
{
name: "Navigate To",
component: NavigateTo,
},
{
name: "Update Record",
component: UpdateRecord,
},
]

View File

@ -1 +0,0 @@
export { default } from "./EventsEditor.svelte"

View File

@ -3,6 +3,11 @@
import Input from "./PropertyPanelControls/Input.svelte" import Input from "./PropertyPanelControls/Input.svelte"
import { store, backendUiStore } from "builderStore" import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties" import fetchBindableProperties from "builderStore/fetchBindableProperties"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
CAPTURE_VAR_INSIDE_MUSTACHE,
} from "builderStore/replaceBindings"
import { DropdownMenu } from "@budibase/bbui" import { DropdownMenu } from "@budibase/bbui"
import BindingDropdown from "components/userInterface/BindingDropdown.svelte" import BindingDropdown from "components/userInterface/BindingDropdown.svelte"
import { onMount, getContext } from "svelte" import { onMount, getContext } from "svelte"
@ -36,25 +41,12 @@
}) })
} }
const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
function replaceBindings(textWithBindings) { function replaceBindings(textWithBindings) {
getBindableProperties() getBindableProperties()
// Find all instances of mustasche textWithBindings = readableToRuntimeBinding(
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE) bindableProperties,
textWithBindings
// Replace with names:
boundValues &&
boundValues.forEach(boundValue => {
const binding = bindableProperties.find(({ readableBinding }) => {
return boundValue === `{{ ${readableBinding} }}`
})
if (binding) {
textWithBindings = textWithBindings.replace(
boundValue,
`{{ ${binding.runtimeBinding} }}`
) )
}
})
onChange(key, textWithBindings) onChange(key, textWithBindings)
} }
@ -67,7 +59,7 @@
innerVal = props.valueKey ? v.target[props.valueKey] : v.target.value innerVal = props.valueKey ? v.target[props.valueKey] : v.target.value
} }
} }
if (typeof innerVal !== "object") { if (typeof innerVal === "string") {
replaceBindings(innerVal) replaceBindings(innerVal)
} else { } else {
onChange(key, innerVal) onChange(key, innerVal)
@ -76,21 +68,9 @@
const safeValue = () => { const safeValue = () => {
getBindableProperties() getBindableProperties()
let temp = value
const boundValues =
(typeof value === "string" && value.match(CAPTURE_VAR_INSIDE_MUSTACHE)) ||
[]
// Replace with names: let temp = runtimeToReadableBinding(bindableProperties, value)
boundValues.forEach(v => {
const binding = bindableProperties.find(({ runtimeBinding }) => {
return v === `{{ ${runtimeBinding} }}`
})
if (binding) {
temp = temp.replace(v, `{{ ${binding.readableBinding} }}`)
}
})
// console.log(temp)
return value === undefined && props.defaultValue !== undefined return value === undefined && props.defaultValue !== undefined
? props.defaultValue ? props.defaultValue
: temp : temp

View File

@ -1051,6 +1051,16 @@ export default {
key: "tooltipTitle", key: "tooltipTitle",
control: Input, control: Input,
}, },
{
label: "X Ticks",
key: "xTicks",
control: Input,
},
{
label: "Y Ticks",
key: "yTicks",
control: Input,
},
], ],
}, },
}, },

View File

@ -28,7 +28,8 @@ describe("fetch bindable properties", () => {
...testData() ...testData()
}) })
const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context") const contextBindings = result.filter(r => r.instance._id === "list-id" && r.type==="context")
expect(contextBindings.length).toBe(2) // 2 fields + _id + _rev
expect(contextBindings.length).toBe(4)
const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name") const namebinding = contextBindings.find(b => b.runtimeBinding === "data.name")
expect(namebinding).toBeDefined() expect(namebinding).toBeDefined()
@ -37,6 +38,10 @@ describe("fetch bindable properties", () => {
const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description") const descriptionbinding = contextBindings.find(b => b.runtimeBinding === "data.description")
expect(descriptionbinding).toBeDefined() expect(descriptionbinding).toBeDefined()
expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description") expect(descriptionbinding.readableBinding).toBe("list-name.Test Model.description")
const idbinding = contextBindings.find(b => b.runtimeBinding === "data._id")
expect(idbinding).toBeDefined()
expect(idbinding.readableBinding).toBe("list-name.Test Model._id")
}) })
it("should return model schema, for grantparent context", () => { it("should return model schema, for grantparent context", () => {
@ -45,7 +50,8 @@ describe("fetch bindable properties", () => {
...testData() ...testData()
}) })
const contextBindings = result.filter(r => r.type==="context") const contextBindings = result.filter(r => r.type==="context")
expect(contextBindings.length).toBe(4) // 2 fields + _id + _rev ... x 2 models
expect(contextBindings.length).toBe(8)
const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name") const namebinding_parent = contextBindings.find(b => b.runtimeBinding === "parent.data.name")
expect(namebinding_parent).toBeDefined() expect(namebinding_parent).toBeDefined()
@ -120,7 +126,7 @@ const testData = () => {
_id: "list-id", _id: "list-id",
_component: "@budibase/standard-components/list", _component: "@budibase/standard-components/list",
_instanceName: "list-name", _instanceName: "list-name",
model: "test-model-id", model: { isModel: true, modelId: "test-model-id", label: "Test Model", name: "all_test-model-id" },
_children: [ _children: [
{ {
_id: "list-item-heading-id", _id: "list-item-heading-id",
@ -138,7 +144,7 @@ const testData = () => {
_id: "child-list-id", _id: "child-list-id",
_component: "@budibase/standard-components/list", _component: "@budibase/standard-components/list",
_instanceName: "child-list-name", _instanceName: "child-list-name",
model: "test-model-id", model: { isModel: true, modelId: "test-model-id", label: "Test Model", name: "all_test-model-id"},
_children: [ _children: [
{ {
_id: "child-list-item-heading-id", _id: "child-list-item-heading-id",

File diff suppressed because it is too large Load Diff

View File

@ -52,6 +52,49 @@ const apiOpts = {
delete: del, delete: del,
} }
const createRecord = async params =>
await post({
url: `/api/${params.modelId}/records`,
body: makeRecordRequestBody(params),
})
const updateRecord = async params => {
const record = makeRecordRequestBody(params)
record._id = params._id
await patch({
url: `/api/${params.modelId}/records/${params._id}`,
body: record,
})
}
const makeRecordRequestBody = parameters => {
const body = {}
for (let fieldName in parameters.fields) {
const field = parameters.fields[fieldName]
// ensure fields sent are of the correct type
if (field.type === "boolean") {
if (field.value === "true") body[fieldName] = true
if (field.value === "false") body[fieldName] = false
} else if (field.type === "number") {
const val = parseFloat(field.value)
if (!isNaN(val)) {
body[fieldName] = val
}
} else if (field.type === "datetime") {
const date = new Date(field.value)
if (!isNaN(date.getTime())) {
body[fieldName] = date.toISOString()
}
} else {
body[fieldName] = field.value
}
}
return body
}
export default { export default {
authenticate: authenticate(apiOpts), authenticate: authenticate(apiOpts),
createRecord,
updateRecord,
} }

View File

@ -50,7 +50,6 @@ export const createApp = ({
treeNode, treeNode,
onScreenSlotRendered, onScreenSlotRendered,
setupState: stateManager.setup, setupState: stateManager.setup,
getCurrentState: stateManager.getCurrentState,
}) })
return getInitialiseParams return getInitialiseParams

View File

@ -1,12 +1,13 @@
import setBindableComponentProp from "./setBindableComponentProp" import setBindableComponentProp from "./setBindableComponentProp"
import { attachChildren } from "../render/attachChildren" import { attachChildren } from "../render/attachChildren"
import store from "../state/store"
export const trimSlash = str => str.replace(/^\/+|\/+$/g, "") export const trimSlash = str => str.replace(/^\/+|\/+$/g, "")
export const bbFactory = ({ export const bbFactory = ({
componentLibraries, componentLibraries,
onScreenSlotRendered, onScreenSlotRendered,
getCurrentState, runEventActions,
}) => { }) => {
const apiCall = method => (url, body) => { const apiCall = method => (url, body) => {
return fetch(url, { return fetch(url, {
@ -26,13 +27,6 @@ export const bbFactory = ({
delete: apiCall("DELETE"), delete: apiCall("DELETE"),
} }
const safeCallEvent = (event, context) => {
const isFunction = obj =>
!!(obj && obj.constructor && obj.call && obj.apply)
if (isFunction(event)) event(context)
}
return (treeNode, setupState) => { return (treeNode, setupState) => {
const attachParams = { const attachParams = {
componentLibraries, componentLibraries,
@ -44,12 +38,17 @@ export const bbFactory = ({
return { return {
attachChildren: attachChildren(attachParams), attachChildren: attachChildren(attachParams),
props: treeNode.props, props: treeNode.props,
call: safeCallEvent, call: async eventName =>
eventName &&
(await runEventActions(
treeNode.props[eventName],
store.getState(treeNode.contextStoreKey)
)),
setBinding: setBindableComponentProp(treeNode), setBinding: setBindableComponentProp(treeNode),
api, api,
parent, parent,
// these parameters are populated by screenRouter // these parameters are populated by screenRouter
routeParams: () => getCurrentState()["##routeParams"], routeParams: () => store.getState()["##routeParams"],
} }
} }
} }

View File

@ -1,17 +1,38 @@
import renderTemplateString from "./renderTemplateString"
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = routeTo => { export const eventHandlers = routeTo => {
const handler = (parameters, execute) => ({ const handlers = {
execute, "Navigate To": param => routeTo(param && param.url),
parameters, }
})
return { // when an event is called, this is what gets run
"Navigate To": handler(["url"], param => routeTo(param && param.url)), const runEventActions = async (actions, state) => {
if (!actions) return
// calls event handlers sequentially
for (let action of actions) {
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]]
const parameters = createParameters(action.parameters, state)
if (handler) {
await handler(parameters)
}
} }
} }
export const isEventType = prop => return runEventActions
Array.isArray(prop) && }
prop.length > 0 &&
!prop[0][EVENT_TYPE_MEMBER_NAME] === undefined // this will take a parameters obj, iterate all keys, and do a mustache render
// for every string. It will work recursively if it encounnters an {}
const createParameters = (parameterTemplateObj, state) => {
const parameters = {}
for (let key in parameterTemplateObj) {
if (typeof parameterTemplateObj[key] === "string") {
parameters[key] = renderTemplateString(parameterTemplateObj[key], state)
} else if (typeof parameterTemplateObj[key] === "object") {
parameters[key] = createParameters(parameterTemplateObj[key], state)
}
}
return parameters
}

View File

@ -1,8 +1,4 @@
import { import { eventHandlers } from "./eventHandlers"
isEventType,
eventHandlers,
EVENT_TYPE_MEMBER_NAME,
} from "./eventHandlers"
import { bbFactory } from "./bbComponentApi" import { bbFactory } from "./bbComponentApi"
import renderTemplateString from "./renderTemplateString" import renderTemplateString from "./renderTemplateString"
import appStore from "./store" import appStore from "./store"
@ -25,33 +21,23 @@ export const createStateManager = ({
onScreenSlotRendered, onScreenSlotRendered,
routeTo, routeTo,
}) => { }) => {
let handlerTypes = eventHandlers(routeTo) let runEventActions = eventHandlers(routeTo)
// creating a reference to the current state
// this avoids doing store.get() ... which is expensive on
// hot paths, according to the svelte docs.
// the state object reference never changes (although it's internals do)
// so this should work fine for us
let currentState
appStore.subscribe(s => (currentState = s))
const getCurrentState = () => currentState
const bb = bbFactory({ const bb = bbFactory({
getCurrentState,
componentLibraries, componentLibraries,
onScreenSlotRendered, onScreenSlotRendered,
runEventActions,
}) })
const setup = _setup({ handlerTypes, getCurrentState, bb }) const setup = _setup(bb)
return { return {
setup, setup,
destroy: () => {}, destroy: () => {},
getCurrentState,
} }
} }
const _setup = ({ handlerTypes, getCurrentState, bb }) => node => { const _setup = bb => node => {
const props = node.props const props = node.props
const initialProps = { ...props } const initialProps = { ...props }
@ -70,53 +56,10 @@ const _setup = ({ handlerTypes, getCurrentState, bb }) => node => {
node.stateBound = true node.stateBound = true
} }
} }
if (isEventType(propValue)) {
const state = appStore.getState(node.contextStoreKey)
const handlersInfos = []
for (let event of propValue) {
const handlerInfo = {
handlerType: event[EVENT_TYPE_MEMBER_NAME],
parameters: event.parameters,
} }
const resolvedParams = {} const setup = _setup(bb)
for (let paramName in handlerInfo.parameters) {
const paramValue = handlerInfo.parameters[paramName]
resolvedParams[paramName] = () =>
renderTemplateString(paramValue, state)
}
handlerInfo.parameters = resolvedParams
handlersInfos.push(handlerInfo)
}
if (handlersInfos.length === 0) {
initialProps[propName] = doNothing
} else {
initialProps[propName] = async context => {
for (let handlerInfo of handlersInfos) {
const handler = makeHandler(handlerTypes, handlerInfo)
await handler(context)
}
}
}
}
}
const setup = _setup({ handlerTypes, getCurrentState, bb })
initialProps._bb = bb(node, setup) initialProps._bb = bb(node, setup)
return initialProps return initialProps
} }
const makeHandler = (handlerTypes, handlerInfo) => {
const handlerType = handlerTypes[handlerInfo.handlerType]
return async context => {
const parameters = {}
for (let paramName in handlerInfo.parameters) {
parameters[paramName] = handlerInfo.parameters[paramName](context)
}
await handlerType.execute(parameters)
}
}

View File

@ -181,8 +181,7 @@ const maketestlib = window => ({
currentProps = Object.assign(currentProps, props) currentProps = Object.assign(currentProps, props)
if (currentProps.onClick) { if (currentProps.onClick) {
node.addEventListener("click", () => { node.addEventListener("click", () => {
const testText = currentProps.testText || "hello" currentProps._bb.call("onClick")
currentProps._bb.call(props.onClick, { testText })
}) })
} }
} }

View File

@ -12,6 +12,40 @@ validateJs.extend(validateJs.validators.datetime, {
}, },
}) })
exports.patch = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const record = await db.get(ctx.params.id)
const model = await db.get(record.modelId)
const patchfields = ctx.request.body
for (let key in patchfields) {
if (!model.schema[key]) continue
record[key] = patchfields[key]
}
const validateResult = await validate({
record,
model,
})
if (!validateResult.valid) {
ctx.status = 400
ctx.body = {
status: 400,
errors: validateResult.errors,
}
return
}
const response = await db.put(record)
record._rev = response.rev
record.type = "record"
ctx.body = record
ctx.status = 200
ctx.message = `${model.name} updated successfully.`
return
}
exports.save = async function(ctx) { exports.save = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)
const record = ctx.request.body const record = ctx.request.body

View File

@ -22,6 +22,11 @@ router
authorized(WRITE_MODEL, ctx => ctx.params.modelId), authorized(WRITE_MODEL, ctx => ctx.params.modelId),
recordController.save recordController.save
) )
.patch(
"/api/:modelId/records/:id",
authorized(WRITE_MODEL, ctx => ctx.params.modelId),
recordController.patch
)
.post( .post(
"/api/:modelId/records/validate", "/api/:modelId/records/validate",
authorized(WRITE_MODEL, ctx => ctx.params.modelId), authorized(WRITE_MODEL, ctx => ctx.params.modelId),

View File

@ -51,6 +51,12 @@ exports.createModel = async (request, appId, instanceId, model) => {
type: "string", type: "string",
}, },
}, },
description: {
type: "text",
constraints: {
type: "string",
},
},
}, },
} }

View File

@ -30,13 +30,12 @@ describe("/records", () => {
model = await createModel(request, app._id, instance._id) model = await createModel(request, app._id, instance._id)
record = { record = {
name: "Test Contact", name: "Test Contact",
description: "original description",
status: "new", status: "new",
modelId: model._id modelId: model._id
} }
}) })
describe("save, load, update, delete", () => {
const createRecord = async r => const createRecord = async r =>
await request await request
.post(`/api/${model._id}/records`) .post(`/api/${model._id}/records`)
@ -45,6 +44,17 @@ describe("/records", () => {
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
const loadRecord = async id =>
await request
.get(`/api/${model._id}/records/${id}`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
describe("save, load, update, delete", () => {
it("returns a success message when the record is created", async () => { it("returns a success message when the record is created", async () => {
const res = await createRecord() const res = await createRecord()
expect(res.res.statusMessage).toEqual(`${model.name} created successfully`) expect(res.res.statusMessage).toEqual(`${model.name} created successfully`)
@ -144,6 +154,35 @@ describe("/records", () => {
}) })
}) })
describe("patch", () => {
it("should update only the fields that are supplied", async () => {
const rec = await createRecord()
const existing = rec.body
const res = await request
.patch(`/api/${model._id}/records/${existing._id}`)
.send({
_id: existing._id,
_rev: existing._rev,
modelId: model._id,
name: "Updated Name",
})
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.res.statusMessage).toEqual(`${model.name} updated successfully.`)
expect(res.body.name).toEqual("Updated Name")
expect(res.body.description).toEqual(existing.description)
const savedRecord = await loadRecord(res.body._id)
expect(savedRecord.body.description).toEqual(existing.description)
expect(savedRecord.body.name).toEqual("Updated Name")
})
})
describe("validate", () => { describe("validate", () => {
it("should return no errors on valid record", async () => { it("should return no errors on valid record", async () => {
const result = await request const result = await request

View File

@ -72,8 +72,14 @@ describe("/views", () => {
type: "text", type: "text",
constraints: { constraints: {
type: "string" type: "string"
} },
} },
description: {
type: "text",
constraints: {
type: "string"
},
},
} }
} }
}); });

View File

@ -252,7 +252,7 @@
}, },
"list": { "list": {
"description": "A configurable data list that attaches to your backend models.", "description": "A configurable data list that attaches to your backend models.",
"context": "model", "context": "datasource",
"children": true, "children": true,
"data": true, "data": true,
"props": { "props": {
@ -537,23 +537,25 @@
"height": "number", "height": "number",
"axisTimeCombinations": "string", "axisTimeCombinations": "string",
"color": "string", "color": "string",
"grid": "string", "grid": {"type":"string", "default": "horizontal"},
"aspectRatio": "number", "aspectRatio": "number",
"dateLabel": "string", "dateLabel": "string",
"isAnimated": "bool", "isAnimated": {"type": "bool", "default": true},
"lineCurve": "string", "lineCurve": "string",
"locale": "string", "locale": "string",
"numberFormat": "string", "numberFormat": "string",
"shouldShowAllDataPoints": "bool", "shouldShowAllDataPoints": {"type": "bool", "default": true},
"topicLabel": "string", "topicLabel": "string",
"valueLabel": "string", "valueLabel": "string",
"xAxisValueType": "string", "xAxisValueType": {"type":"string", "default": "date"},
"xAxisScale": "string", "xAxisScale": "string",
"xAxisFormat": "string", "xAxisFormat": {"type":"string", "default": "custom"},
"xAxisCustomFormat": "string", "xAxisCustomFormat": "string",
"xAxisLabel": "string", "xAxisLabel": "string",
"yAxisLabel": "string", "yAxisLabel": "string",
"tooltipTitle": "string" "tooltipTitle": "string",
"xTicks": "number",
"yTicks": "number"
} }
}, },
"brush": { "brush": {

File diff suppressed because one or more lines are too long

View File

@ -41,6 +41,7 @@
"d3-selection": "^1.4.2", "d3-selection": "^1.4.2",
"fast-sort": "^2.2.0", "fast-sort": "^2.2.0",
"fusioncharts": "^3.15.1-sr.1", "fusioncharts": "^3.15.1-sr.1",
"lodash.debounce": "^4.0.8",
"svelte-flatpickr": "^2.4.0", "svelte-flatpickr": "^2.4.0",
"svelte-fusioncharts": "^1.0.0" "svelte-fusioncharts": "^1.0.0"
} }

View File

@ -2,7 +2,6 @@
export let className = "default" export let className = "default"
export let disabled = false export let disabled = false
export let text export let text
export let onClick
export let _bb export let _bb
let theButton let theButton
@ -11,7 +10,7 @@
theButton && _bb.attachChildren(theButton) theButton && _bb.attachChildren(theButton)
const clickHandler = () => { const clickHandler = () => {
_bb.call(onClick) _bb.call("onClick")
} }
</script> </script>

View File

@ -62,6 +62,8 @@
export let lines = null //not handled by setting prop export let lines = null //not handled by setting prop
export let tooltipThreshold = null export let tooltipThreshold = null
export let tooltipTitle = "" export let tooltipTitle = ""
export let xTicks = ""
export let yTicks = ""
onMount(async () => { onMount(async () => {
if (!isEmpty(datasource)) { if (!isEmpty(datasource)) {
@ -73,12 +75,15 @@
bindChartEvents() bindChartEvents()
chartContainer.datum(data).call(chart) chartContainer.datum(data).call(chart)
// X Axis Label gets cut off unless we do this 👇 // Hack 🤮 X Axis Label and last tick label gets cut off unless we do this 👇
const chartSvg = document.querySelector(`.${chartClass} .britechart`) const chartSvg = document.querySelector(`.${chartClass} .britechart`)
if (chartSvg) { if (chartSvg) {
let height = chartSvg.getAttribute("height") let height = chartSvg.getAttribute("height")
let width = chartSvg.getAttribute("width")
height = parseInt(height) + 35 height = parseInt(height) + 35
width = parseInt(width) + 15
chartSvg.setAttribute("height", height) chartSvg.setAttribute("height", height)
chartSvg.setAttribute("width", width)
} }
bindTooltip() bindTooltip()
@ -145,11 +150,10 @@
} }
function bindChartUIProps() { function bindChartUIProps() {
chart.grid("horizontal")
chart.isAnimated(true)
chart.tooltipThreshold(800) chart.tooltipThreshold(800)
chart.aspectRatio(0.5) chart.aspectRatio(0.5)
chart.xAxisCustomFormat("custom") chart.xAxisCustomFormat("%e %b %Y")
chart.xTicks(data.dataByTopic.length)
if (notNull(color)) { if (notNull(color)) {
chart.colorSchema(colorSchema) chart.colorSchema(colorSchema)
@ -214,6 +218,12 @@
if (notNull(lines)) { if (notNull(lines)) {
chart.lines(lines) chart.lines(lines)
} }
if (notNull(xTicks)) {
chart.xTicks(Number(xTicks))
}
if (notNull(yTicks)) {
chart.yTicks(Number(yTicks))
}
if (notNull(tooltipTitle)) { if (notNull(tooltipTitle)) {
tooltip.title(tooltipTitle) tooltip.title(tooltipTitle)
} else if (datasource.label) { } else if (datasource.label) {
@ -243,4 +253,4 @@
$: chartGradient = getChartGradient(lineGradient) $: chartGradient = getChartGradient(lineGradient)
</script> </script>
<div bind this:👇={chartElement} class={chartClass} /> <div bind:this={chartElement} class={chartClass} />

View File

@ -14,7 +14,7 @@
if (containerElement) { if (containerElement) {
_bb.attachChildren(containerElement) _bb.attachChildren(containerElement)
if (!hasLoaded) { if (!hasLoaded) {
_bb.call(onLoad) _bb.call("onLoad")
hasLoaded = true hasLoaded = true
} }
} }

View File

@ -1,7 +1,8 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { Label } from "@budibase/bbui" import { Label, DatePicker } from "@budibase/bbui"
import debounce from "lodash.debounce"
export let _bb export let _bb
export let model export let model
@ -14,60 +15,49 @@
number: "number", number: "number",
} }
const DEFAULTS_FOR_TYPE = {
string: "",
boolean: false,
number: null,
link: [],
}
let record let record
let store = _bb.store let store = _bb.store
let schema = {} let schema = {}
let modelDef = {} let modelDef = {}
let saved = false let saved = false
let saving = false
let recordId let recordId
let isNew = true let isNew = true
let errors = {}
let inputElements = {}
$: if (model && model.length !== 0) { $: if (model && model.length !== 0) {
fetchModel() fetchModel()
} }
$: fields = Object.keys(schema) $: fields = schema ? Object.keys(schema) : []
$: Object.values(inputElements).length && setForm(record) $: errorMessages = Object.entries(errors).map(
([field, message]) => `${field} ${message}`
const createBlankRecord = () => { )
if (!schema) return
const newrecord = {
modelId: model,
}
for (let fieldName in schema) {
const field = schema[fieldName]
// defaulting to first one, as a blank value will fail validation
if (
field.type === "string" &&
field.constraints &&
field.constraints.inclusion &&
field.constraints.inclusion.length > 0
) {
newrecord[fieldName] = field.constraints.inclusion[0]
} else if (field.type === "number") newrecord[fieldName] = null
else if (field.type === "boolean") newrecord[fieldName] = false
else if (field.type === "link") newrecord[fieldName] = []
else newrecord[fieldName] = ""
}
return newrecord
}
async function fetchModel() { async function fetchModel() {
const FETCH_MODEL_URL = `/api/models/${model}` const FETCH_MODEL_URL = `/api/models/${model}`
const response = await _bb.api.get(FETCH_MODEL_URL) const response = await _bb.api.get(FETCH_MODEL_URL)
modelDef = await response.json() modelDef = await response.json()
schema = modelDef.schema schema = modelDef.schema
record = createBlankRecord() record = {
modelId: model,
}
}
const save = debounce(async () => {
for (let field of fields) {
// Assign defaults to empty fields to prevent validation issues
if (!(field in record))
record[field] = DEFAULTS_FOR_TYPE[schema[field].type]
} }
async function save() {
// prevent double clicking firing multiple requests
if (saving) return
saving = true
const SAVE_RECORD_URL = `/api/${model}/records` const SAVE_RECORD_URL = `/api/${model}/records`
const response = await _bb.api.post(SAVE_RECORD_URL, record) const response = await _bb.api.post(SAVE_RECORD_URL, record)
@ -79,13 +69,11 @@
return state return state
}) })
errors = {}
// wipe form, if new record, otherwise update // wipe form, if new record, otherwise update
// model to get new _rev // model to get new _rev
if (isNew) { record = isNew ? { modelId: model } : json
resetForm()
} else {
record = json
}
// set saved, and unset after 1 second // set saved, and unset after 1 second
// i.e. make the success notifier appear, then disappear again after time // i.e. make the success notifier appear, then disappear again after time
@ -94,69 +82,27 @@
saved = false saved = false
}, 1000) }, 1000)
} }
saving = false
}
// we cannot use svelte bind on these inputs, as it does not allow if (response.status === 400) {
// bind, when the input type is dynamic errors = json.errors
const resetForm = () => {
for (let el of Object.values(inputElements)) {
el.value = ""
if (el.checked) {
el.checked = false
}
}
record = createBlankRecord()
} }
})
const setForm = rec => { onMount(async () => {
if (isNew || !rec) return
for (let fieldName in inputElements) {
if (typeof rec[fieldName] === "boolean") {
inputElements[fieldName].checked = rec[fieldName]
} else {
inputElements[fieldName].value = rec[fieldName]
}
}
}
const handleInput = field => event => {
let value
if (event.target.type === "checkbox") {
value = event.target.checked
record[field] = value
return
}
if (event.target.type === "number") {
value = parseInt(event.target.value)
record[field] = value
return
}
value = event.target.value
record[field] = value
}
onMount(() => {
const routeParams = _bb.routeParams() const routeParams = _bb.routeParams()
recordId = recordId =
Object.keys(routeParams).length > 0 && (routeParams.id || routeParams[0]) Object.keys(routeParams).length > 0 && (routeParams.id || routeParams[0])
isNew = !recordId || recordId === "new" isNew = !recordId || recordId === "new"
if (isNew) { if (isNew) {
record = createBlankRecord() record = { modelId: model }
} else { return
const GET_RECORD_URL = `/api/${model}/records/${recordId}`
_bb.api
.get(GET_RECORD_URL)
.then(response => response.json())
.then(rec => {
record = rec
setForm(rec)
})
} }
const GET_RECORD_URL = `/api/${model}/records/${recordId}`
const response = await _bb.api.get(GET_RECORD_URL)
const json = await response.json()
record = json
}) })
</script> </script>
@ -164,23 +110,28 @@
{#if title} {#if title}
<h1>{title}</h1> <h1>{title}</h1>
{/if} {/if}
{#each errorMessages as error}
<p class="error">{error}</p>
{/each}
<hr /> <hr />
<div class="form-content"> <div class="form-content">
{#each fields as field} {#each fields as field}
<div class="form-item"> <div class="form-item">
<Label small forAttr={'form-stacked-text'}>{field}</Label> <Label small forAttr={'form-stacked-text'}>{field}</Label>
{#if schema[field].type === 'string' && schema[field].constraints.inclusion} {#if schema[field].type === 'string' && schema[field].constraints.inclusion}
<select on:blur={handleInput(field)} bind:this={inputElements[field]}> <select bind:value={record[field]}>
{#each schema[field].constraints.inclusion as opt} {#each schema[field].constraints.inclusion as opt}
<option>{opt}</option> <option>{opt}</option>
{/each} {/each}
</select> </select>
{:else} {:else if schema[field].type === 'datetime'}
<input <DatePicker bind:value={record[field]} />
bind:this={inputElements[field]} {:else if schema[field].type === 'boolean'}
class="input" <input class="input" type="checkbox" bind:checked={record[field]} />
type={TYPE_MAP[schema[field].type]} {:else if schema[field].type === 'number'}
on:change={handleInput(field)} /> <input class="input" type="number" bind:value={record[field]} />
{:else if schema[field].type === 'string'}
<input class="input" type="text" bind:value={record[field]} />
{/if} {/if}
</div> </div>
<hr /> <hr />
@ -302,4 +253,9 @@
background-position: right 17px top 1.5em, right 10px top 1.5em; background-position: right 17px top 1.5em, right 10px top 1.5em;
background-size: 7px 7px, 7px 7px; background-size: 7px 7px, 7px 7px;
} }
.error {
color: red;
font-weight: 500;
}
</style> </style>

View File

@ -1,7 +1,8 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { Label } from "@budibase/bbui" import { Label, DatePicker } from "@budibase/bbui"
import debounce from "lodash.debounce"
export let _bb export let _bb
export let model export let model
@ -14,60 +15,50 @@
number: "number", number: "number",
} }
const DEFAULTS_FOR_TYPE = {
string: "",
boolean: false,
number: null,
link: [],
}
let record let record
let store = _bb.store let store = _bb.store
let schema = {} let schema = {}
let modelDef = {} let modelDef = {}
let saved = false let saved = false
let saving = false
let recordId let recordId
let isNew = true let isNew = true
let errors = {}
let inputElements = {}
$: if (model && model.length !== 0) { $: if (model && model.length !== 0) {
fetchModel() fetchModel()
} }
$: fields = Object.keys(schema) $: fields = schema ? Object.keys(schema) : []
$: Object.values(inputElements).length && setForm(record) $: errorMessages = Object.entries(errors).map(
([field, message]) => `${field} ${message}`
)
const createBlankRecord = () => {
if (!schema) return
const newrecord = {
modelId: model,
}
for (let fieldName in schema) {
const field = schema[fieldName]
// defaulting to first one, as a blank value will fail validation
if (
field.type === "string" &&
field.constraints &&
field.constraints.inclusion &&
field.constraints.inclusion.length > 0
) {
newrecord[fieldName] = field.constraints.inclusion[0]
} else if (field.type === "number") newrecord[fieldName] = null
else if (field.type === "boolean") newrecord[fieldName] = false
else if (field.type === "link") newrecord[fieldName] = []
else newrecord[fieldName] = ""
}
return newrecord
}
async function fetchModel() { async function fetchModel() {
const FETCH_MODEL_URL = `/api/models/${model}` const FETCH_MODEL_URL = `/api/models/${model}`
const response = await _bb.api.get(FETCH_MODEL_URL) const response = await _bb.api.get(FETCH_MODEL_URL)
modelDef = await response.json() modelDef = await response.json()
schema = modelDef.schema schema = modelDef.schema
record = createBlankRecord() record = {
modelId: model,
}
}
const save = debounce(async () => {
for (let field of fields) {
// Assign defaults to empty fields to prevent validation issues
if (!(field in record))
record[field] = DEFAULTS_FOR_TYPE[schema[field].type]
} }
async function save() {
// prevent double clicking firing multiple requests
if (saving) return
saving = true
const SAVE_RECORD_URL = `/api/${model}/records` const SAVE_RECORD_URL = `/api/${model}/records`
const response = await _bb.api.post(SAVE_RECORD_URL, record) const response = await _bb.api.post(SAVE_RECORD_URL, record)
@ -79,13 +70,11 @@
return state return state
}) })
errors = {}
// wipe form, if new record, otherwise update // wipe form, if new record, otherwise update
// model to get new _rev // model to get new _rev
if (isNew) { record = isNew ? { modelId: model } : json
resetForm()
} else {
record = json
}
// set saved, and unset after 1 second // set saved, and unset after 1 second
// i.e. make the success notifier appear, then disappear again after time // i.e. make the success notifier appear, then disappear again after time
@ -94,69 +83,27 @@
saved = false saved = false
}, 1000) }, 1000)
} }
saving = false
}
// we cannot use svelte bind on these inputs, as it does not allow if (response.status === 400) {
// bind, when the input type is dynamic errors = json.errors
const resetForm = () => {
for (let el of Object.values(inputElements)) {
el.value = ""
if (el.checked) {
el.checked = false
}
}
record = createBlankRecord()
} }
})
const setForm = rec => { onMount(async () => {
if (isNew || !rec) return
for (let fieldName in inputElements) {
if (typeof rec[fieldName] === "boolean") {
inputElements[fieldName].checked = rec[fieldName]
} else {
inputElements[fieldName].value = rec[fieldName]
}
}
}
const handleInput = field => event => {
let value
if (event.target.type === "checkbox") {
value = event.target.checked
record[field] = value
return
}
if (event.target.type === "number") {
value = parseInt(event.target.value)
record[field] = value
return
}
value = event.target.value
record[field] = value
}
onMount(() => {
const routeParams = _bb.routeParams() const routeParams = _bb.routeParams()
recordId = recordId =
Object.keys(routeParams).length > 0 && (routeParams.id || routeParams[0]) Object.keys(routeParams).length > 0 && (routeParams.id || routeParams[0])
isNew = !recordId || recordId === "new" isNew = !recordId || recordId === "new"
if (isNew) { if (isNew) {
record = createBlankRecord() record = { modelId: model }
} else { return
const GET_RECORD_URL = `/api/${model}/records/${recordId}`
_bb.api
.get(GET_RECORD_URL)
.then(response => response.json())
.then(rec => {
record = rec
setForm(rec)
})
} }
const GET_RECORD_URL = `/api/${model}/records/${recordId}`
const response = await _bb.api.get(GET_RECORD_URL)
const json = await response.json()
record = json
}) })
</script> </script>
@ -164,23 +111,28 @@
{#if title} {#if title}
<h1>{title}</h1> <h1>{title}</h1>
{/if} {/if}
{#each errorMessages as error}
<p class="error">{error}</p>
{/each}
<hr /> <hr />
<div class="form-content"> <div class="form-content">
{#each fields as field} {#each fields as field}
<div class="form-item"> <div class="form-item">
<Label small forAttr={'form-stacked-text'}>{field}</Label> <Label small forAttr={'form-stacked-text'}>{field}</Label>
{#if schema[field].type === 'string' && schema[field].constraints.inclusion} {#if schema[field].type === 'string' && schema[field].constraints.inclusion}
<select on:blur={handleInput(field)} bind:this={inputElements[field]}> <select bind:value={record[field]}>
{#each schema[field].constraints.inclusion as opt} {#each schema[field].constraints.inclusion as opt}
<option>{opt}</option> <option>{opt}</option>
{/each} {/each}
</select> </select>
{:else} {:else if schema[field].type === 'datetime'}
<input <DatePicker bind:value={record[field]} />
bind:this={inputElements[field]} {:else if schema[field].type === 'boolean'}
class="input" <input class="input" type="checkbox" bind:checked={record[field]} />
type={TYPE_MAP[schema[field].type]} {:else if schema[field].type === 'number'}
on:change={handleInput(field)} /> <input class="input" type="number" bind:value={record[field]} />
{:else if schema[field].type === 'string'}
<input class="input" type="text" bind:value={record[field]} />
{/if} {/if}
</div> </div>
<hr /> <hr />
@ -293,4 +245,9 @@
background-position: right 17px top 1.5em, right 10px top 1.5em; background-position: right 17px top 1.5em, right 10px top 1.5em;
background-size: 7px 7px, 7px 7px; background-size: 7px 7px, 7px 7px;
} }
.error {
color: red;
font-weight: 500;
}
</style> </style>

View File

@ -28,7 +28,7 @@
if (itemContainer) { if (itemContainer) {
_bb.attachChildren(itemContainer) _bb.attachChildren(itemContainer)
if (!hasLoaded) { if (!hasLoaded) {
_bb.call(onLoad) _bb.call("onLoad")
hasLoaded = true hasLoaded = true
} }
} }

View File

@ -11,7 +11,10 @@
export let _bb export let _bb
const rowClickHandler = row => () => { const rowClickHandler = row => () => {
_bb.call(onRowClick, row) // call currently only accepts one argument, so passing row does nothing
// however, we do not expose this event anyway. I am leaving this
// in for the future, as can and probably should hande this
_bb.call("onRowClick", row)
} }
const cellValue = (colIndex, row) => { const cellValue = (colIndex, row) => {