Merge branch 'master' into fixes

This commit is contained in:
Michael Shanks 2020-10-12 15:10:21 +01:00
commit 0a1775b999
39 changed files with 970 additions and 339 deletions

View File

@ -112,7 +112,7 @@
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-url": "^2.2.2",
"start-server-and-test": "^1.11.0",
"svelte": "^3.24.1",
"svelte": "^3.29.0",
"svelte-jester": "^1.0.6"
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"

View File

@ -8,8 +8,9 @@ export default function(component, state) {
const findMatches = props => {
walkProps(props, c => {
if ((c._instanceName || "").startsWith(capitalised)) {
matchingComponents.push(c._instanceName)
const thisInstanceName = get_capitalised_name(c._instanceName)
if ((thisInstanceName || "").startsWith(capitalised)) {
matchingComponents.push(thisInstanceName)
}
})
}

View File

@ -124,17 +124,18 @@ const saveScreen = store => screen => {
}
const _saveScreen = async (store, s, screen) => {
const currentPageScreens = s.pages[s.currentPageName]._screens
const pageName = s.currentPageName || "main"
const currentPageScreens = s.pages[pageName]._screens
await api
.post(`/_builder/api/${s.appId}/pages/${s.currentPageName}/screen`, screen)
.post(`/_builder/api/${s.appId}/pages/${pageName}/screen`, screen)
.then(() => {
if (currentPageScreens.includes(screen)) return
const screens = [...currentPageScreens, screen]
store.update(innerState => {
innerState.pages[s.currentPageName]._screens = screens
innerState.pages[pageName]._screens = screens
innerState.screens = screens
innerState.currentPreviewItem = screen
innerState.allScreens = [...innerState.allScreens, screen]
@ -153,27 +154,17 @@ const _saveScreen = async (store, s, screen) => {
return s
}
const createScreen = store => (screenName, route, layoutComponentName) => {
const createScreen = store => async screen => {
let savePromise
store.update(state => {
const rootComponent = state.components[layoutComponentName]
const newScreen = {
description: "",
url: "",
_css: "",
props: createProps(rootComponent).props,
}
newScreen.route = route
newScreen.name = newScreen.props._id
newScreen.props._instanceName = screenName || ""
state.currentPreviewItem = newScreen
state.currentComponentInfo = newScreen.props
state.currentPreviewItem = screen
state.currentComponentInfo = screen.props
state.currentFrontEndType = "screen"
_saveScreen(store, state, newScreen)
savePromise = _saveScreen(store, state, screen)
regenerateCssForCurrentScreen(state)
return state
})
await savePromise
}
const setCurrentScreen = store => screenName => {

View File

@ -0,0 +1,22 @@
export default {
name: `Create from scratch`,
create: () => createScreen(),
}
const createScreen = () => ({
props: {
_id: "",
_component: "@budibase/standard-components/container",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
type: "div",
_children: [],
_instanceName: "",
},
route: "",
name: "screen-id",
})

View File

@ -0,0 +1,22 @@
export default {
name: `New Row (Empty)`,
create: () => createScreen(),
}
const createScreen = () => ({
props: {
_id: "",
_component: "@budibase/standard-components/newrow",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_children: [],
_instanceName: "",
model: "",
},
route: "",
name: "screen-id",
})

View File

@ -0,0 +1,22 @@
export default {
name: `Row Detail (Empty)`,
create: () => createScreen(),
}
const createScreen = () => ({
props: {
_id: "",
_component: "@budibase/standard-components/rowdetail",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_children: [],
_instanceName: "",
model: "",
},
route: "",
name: "screen-id",
})

View File

@ -0,0 +1,35 @@
import newRecordScreen from "./newRecordScreen"
import recordDetailScreen from "./recordDetailScreen"
import recordListScreen from "./recordListScreen"
import emptyNewRecordScreen from "./emptyNewRecordScreen"
import createFromScratchScreen from "./createFromScratchScreen"
import emptyRecordDetailScreen from "./emptyRecordDetailScreen"
import { generateNewIdsForComponent } from "../../storeUtils"
import { uuid } from "builderStore/uuid"
const allTemplates = models => [
createFromScratchScreen,
...newRecordScreen(models),
...recordDetailScreen(models),
...recordListScreen(models),
emptyNewRecordScreen,
emptyRecordDetailScreen,
]
// allows us to apply common behaviour to all create() functions
const createTemplateOverride = (frontendState, create) => () => {
const screen = create()
for (let component of screen.props._children) {
generateNewIdsForComponent(component, frontendState, false)
}
screen.props._id = uuid()
screen.name = screen.props._id
screen.route = screen.route.toLowerCase()
return screen
}
export default (frontendState, models) =>
allTemplates(models).map(template => ({
...template,
create: createTemplateOverride(frontendState, template.create),
}))

View File

@ -0,0 +1,135 @@
export default function(models) {
return models.map(model => {
const fields = Object.keys(model.schema)
const heading = fields.length > 0 ? `{{ data.${fields[0]} }}` : "Add Row"
return {
name: `${model.name} - New`,
create: () => createScreen(model, heading),
id: NEW_RECORD_TEMPLATE,
}
})
}
export const NEW_RECORD_TEMPLATE = "NEW_RECORD_TEMPLATE"
const createScreen = (model, heading) => ({
props: {
_id: "",
_component: "@budibase/standard-components/newrow",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
model: model._id,
_children: [
{
_id: "",
_component: "@budibase/standard-components/heading",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
className: "",
text: heading,
type: "h1",
_instanceName: "Heading 1",
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/dataform",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
_instanceName: `${model.name} Form`,
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/container",
_styles: {
normal: {
display: "flex",
"flex-direction": "row",
"align-items": "center",
"justify-content": "flex-end",
},
hover: {},
active: {},
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "div",
_instanceName: "Buttons Container",
_children: [
{
_id: "",
_component: "@budibase/standard-components/button",
_styles: {
normal: {
"margin-right": "20px",
},
hover: {},
active: {},
selected: {},
},
_code: "",
text: "Back",
className: "",
disabled: false,
onClick: [
{
parameters: {
url: `/${model.name.toLowerCase()}`,
},
"##eventHandlerType": "Navigate To",
},
],
_instanceName: "Back Button",
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/button",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
text: "Save",
className: "",
disabled: false,
onClick: [
{
parameters: {
contextPath: "data",
modelId: model._id,
},
"##eventHandlerType": "Save Record",
},
],
_instanceName: "Save Button",
_children: [],
},
],
},
],
_instanceName: `${model.name} - New`,
_code: "",
},
route: `/${model.name.toLowerCase()}/new`,
name: "",
})

View File

@ -0,0 +1,135 @@
export default function(models) {
return models.map(model => {
const fields = Object.keys(model.schema)
const heading = fields.length > 0 ? `{{ data.${fields[0]} }}` : "Detail"
return {
name: `${model.name} - Detail`,
create: () => createScreen(model, heading),
id: RECORD_DETAIL_TEMPLATE,
}
})
}
export const RECORD_DETAIL_TEMPLATE = "RECORD_DETAIL_TEMPLATE"
const createScreen = (model, heading) => ({
props: {
_id: "",
_component: "@budibase/standard-components/rowdetail",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
model: model._id,
_children: [
{
_id: "",
_component: "@budibase/standard-components/heading",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
className: "",
text: heading,
type: "h1",
_instanceName: "Heading 1",
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/dataform",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
_instanceName: `${model.name} Form`,
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/container",
_styles: {
normal: {
display: "flex",
"flex-direction": "row",
"align-items": "center",
"justify-content": "flex-end",
},
hover: {},
active: {},
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "div",
_instanceName: "Buttons Container",
_children: [
{
_id: "",
_component: "@budibase/standard-components/button",
_styles: {
normal: {
"margin-right": "20px",
},
hover: {},
active: {},
selected: {},
},
_code: "",
text: "Back",
className: "",
disabled: false,
onClick: [
{
parameters: {
url: `/${model.name.toLowerCase()}`,
},
"##eventHandlerType": "Navigate To",
},
],
_instanceName: "Back Button",
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/button",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
text: "Save",
className: "",
disabled: false,
onClick: [
{
parameters: {
contextPath: "data",
modelId: model._id,
},
"##eventHandlerType": "Save Record",
},
],
_instanceName: "Save Button",
_children: [],
},
],
},
],
_instanceName: `${model.name} - Detail`,
_code: "",
},
route: `/${model.name.toLowerCase()}/:id`,
name: "",
})

View File

@ -0,0 +1,118 @@
export default function(models) {
return models.map(model => {
return {
name: `${model.name} - List`,
create: () => createScreen(model),
id: RECORD_LIST_TEMPLATE,
}
})
}
export const RECORD_LIST_TEMPLATE = "RECORD_LIST_TEMPLATE"
const createScreen = model => ({
props: {
_id: "",
_component: "@budibase/standard-components/container",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
type: "div",
_children: [
{
_id: "",
_component: "@budibase/standard-components/container",
_styles: {
normal: {
display: "flex",
"flex-direction": "row",
"justify-content": "space-between",
"align-items": "center",
},
hover: {},
active: {},
selected: {},
},
_code: "",
className: "",
onLoad: [],
type: "div",
_instanceName: "Header",
_children: [
{
_id: "",
_component: "@budibase/standard-components/heading",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
className: "",
text: `${model.name} List`,
type: "h1",
_instanceName: "Heading 1",
_children: [],
},
{
_id: "",
_component: "@budibase/standard-components/button",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
text: "Create New",
className: "",
disabled: false,
onClick: [
{
parameters: {
url: `/${model.name}/new`,
},
"##eventHandlerType": "Navigate To",
},
],
_instanceName: "Create New Button",
_children: [],
},
],
},
{
_id: "",
_component: "@budibase/standard-components/datatable",
_styles: {
normal: {},
hover: {},
active: {},
selected: {},
},
_code: "",
datasource: {
label: "Deals",
name: `all_${model._id}`,
modelId: model._id,
isModel: true,
},
stripeColor: "",
borderColor: "",
backgroundColor: "",
color: "",
_instanceName: `${model.name} Table`,
_children: [],
},
],
_instanceName: `${model.name} - List`,
_code: "",
className: "",
onLoad: [],
},
route: `/${model.name.toLowerCase()}`,
name: "",
})

View File

@ -85,10 +85,10 @@ export const regenerateCssForCurrentScreen = state => {
return state
}
export const generateNewIdsForComponent = (c, state) =>
export const generateNewIdsForComponent = (c, state, changeName = true) =>
walkProps(c, p => {
p._id = uuid()
p._instanceName = getNewComponentName(p._component, state)
if (changeName) p._instanceName = getNewComponentName(p._component, state)
})
export const getComponentDefinition = (state, name) =>

View File

@ -1,11 +1,21 @@
<script>
import { goto } from "@sveltech/routify"
import { backendUiStore } from "builderStore"
import { backendUiStore, store } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { Button, Input, Label, ModalContent, Modal } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import TableDataImport from "../TableDataImport.svelte"
import analytics from "analytics"
import screenTemplates from "builderStore/store/screenTemplates"
import { NEW_RECORD_TEMPLATE } from "builderStore/store/screenTemplates/newRecordScreen"
import { RECORD_DETAIL_TEMPLATE } from "builderStore/store/screenTemplates/recordDetailScreen"
import { RECORD_LIST_TEMPLATE } from "builderStore/store/screenTemplates/recordListScreen"
const defaultScreens = [
NEW_RECORD_TEMPLATE,
RECORD_DETAIL_TEMPLATE,
RECORD_LIST_TEMPLATE,
]
let modal
let name
@ -25,6 +35,24 @@
notifier.success(`Table ${name} created successfully.`)
$goto(`./model/${model._id}`)
analytics.captureEvent("Table Created", { name })
const screens = screenTemplates($store, [model])
.filter(template => defaultScreens.includes(template.id))
.map(template => template.create())
for (let screen of screens) {
console.log(JSON.stringify(screen))
try {
await store.createScreen(screen)
} catch (_) {
// TODO: this is temporary
// a cypress test is failing, because I added the
// NewRecord component. So - this throws an exception
// because the currently released standard-components (on NPM)
// does not have NewRecord
// we should remove this after this has been released
}
}
}
</script>

View File

@ -1,6 +1,7 @@
<script>
import { Input } from "@budibase/bbui"
import { Input, Label } from "@budibase/bbui"
import api from "builderStore/api"
import { backendUiStore } from "builderStore"
import analytics from "analytics"
let keys = { budibase: "" }
@ -38,6 +39,10 @@
edit
value={keys.budibase}
label="Budibase API Key" />
<div>
<Label extraSmall grey>Instance ID (Webhooks)</Label>
<span>{$backendUiStore.selectedDatabase._id}</span>
</div>
</div>
<style>
@ -45,4 +50,9 @@
display: grid;
grid-gap: var(--spacing-xl);
}
span {
font-size: var(--font-size-xs);
font-weight: 500;
}
</style>

View File

@ -68,18 +68,8 @@
}
const copyComponent = () => {
store.update(s => {
const parent = getParent(s.currentPreviewItem.props, component)
const copiedComponent = cloneDeep(component)
walkProps(copiedComponent, p => {
p._id = uuid()
})
parent._children = [...parent._children, copiedComponent]
saveCurrentPreviewItem(s)
s.currentComponentInfo = copiedComponent
regenerateCssForCurrentScreen(s)
return s
})
storeComponentForCopy(false)
pasteComponent("below")
}
const deleteComponent = () => {

View File

@ -41,14 +41,6 @@
const onStyleChanged = store.setComponentStyle
function onPropChanged(key, value) {
if ($store.currentView !== "component") {
store.setPageOrScreenProp(key, value)
return
}
store.setComponentProp(key, value)
}
$: isComponentOrScreen =
$store.currentView === "component" ||
$store.currentFrontEndType === "screen"
@ -103,7 +95,8 @@
{componentDefinition}
{panelDefinition}
displayNameField={displayName}
onChange={onPropChanged}
onChange={store.setComponentProp}
onScreenPropChange={store.setPageOrScreenProp}
screenOrPageInstance={$store.currentView !== 'component' && $store.currentPreviewItem} />
{/if}

View File

@ -73,7 +73,7 @@
{#if fields}
{#each fields as field}
<Label size="m" color="dark">Field</Label>
<Label size="m" color="dark">Column</Label>
<Select secondary bind:value={field.name} on:blur={rebuildParameters}>
<option value="" />
{#each schemaFields as schemaField}

View File

@ -0,0 +1,136 @@
<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"
// parameters.contextPath used in the client handler to determine which record to save
// this could be "data" or "data.parent", "data.parent.parent" etc
export let parameters
let idFields
let schemaFields
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
$: {
if (parameters && parameters.contextPath) {
schemaFields = schemaFromContextPath(parameters.contextPath)
} else {
schemaFields = []
}
}
const idBindingToContextPath = id => id.substring(0, id.length - 4)
const contextPathToId = path => `${path}._id`
$: {
idFields = bindableProperties.filter(
bindable =>
bindable.type === "context" && bindable.runtimeBinding.endsWith("._id")
)
// ensure contextPath is always defaulted - there is usually only one option
if (idFields.length > 0 && !parameters.contextPath) {
parameters.contextPath = idBindingToContextPath(
idFields[0].runtimeBinding
)
parameters = parameters
}
}
// 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 schemaFromContextPath = contextPath => {
if (!contextPath) return []
const idBinding = bindableProperties.find(
prop => prop.runtimeBinding === contextPathToId(contextPath)
)
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]
const modelId =
typeof modelInfo === "string" ? modelInfo : modelInfo.modelId
if (!modelInfo) return []
const model = $backendUiStore.models.find(m => m._id === modelId)
parameters.modelId = modelId
return Object.keys(model.schema).map(k => ({
name: k,
type: model.schema[k].type,
}))
}
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">Datasource</Label>
<Select secondary bind:value={parameters.contextPath}>
<option value="" />
{#each idFields as idField}
<option value={idBindingToContextPath(idField.runtimeBinding)}>
{idField.instance._instanceName}
</option>
{/each}
</Select>
{/if}
{#if parameters.contextPath}
<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(> div: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

@ -1,6 +1,5 @@
import NavigateTo from "./NavigateTo.svelte"
import UpdateRecord from "./UpdateRecord.svelte"
import CreateRecord from "./CreateRecord.svelte"
import SaveRecord from "./SaveRecord.svelte"
// defines what actions are available, when adding a new one
// the component is the setup panel for the action
@ -9,15 +8,11 @@ import CreateRecord from "./CreateRecord.svelte"
export default [
{
name: "Create Record",
component: CreateRecord,
name: "Save Record",
component: SaveRecord,
},
{
name: "Navigate To",
component: NavigateTo,
},
{
name: "Update Record",
component: UpdateRecord,
},
]

View File

@ -1,27 +1,49 @@
<script>
import { store } from "builderStore"
import { Input, Select, ModalContent } from "@budibase/bbui"
import { find, filter, some } from "lodash/fp"
import { store, backendUiStore } from "builderStore"
import { Input, Button, Spacer, Select, ModalContent } from "@budibase/bbui"
import getTemplates from "builderStore/store/screenTemplates"
import { some } from "lodash/fp"
const CONTAINER = "@budibase/standard-components/container"
let dialog
let layoutComponents
let layoutComponent
let screens
let name = ""
let routeError
let baseComponent = CONTAINER
let templateIndex
let draftScreen
$: layoutComponents = Object.values($store.components).filter(
componentDefinition => componentDefinition.container
)
$: layoutComponent = layoutComponent
? layoutComponents.find(
component => component._component === layoutComponent._component
)
: layoutComponents[0]
$: templates = getTemplates($store, $backendUiStore.models)
$: route = !route && $store.screens.length === 0 ? "*" : route
$: baseComponents = Object.values($store.components)
.filter(componentDefinition => componentDefinition.baseComponent)
.map(c => c._component)
$: {
if (templates && templateIndex === undefined) {
templateIndex = 0
templateChanged(0)
}
}
const templateChanged = newTemplateIndex => {
if (newTemplateIndex === undefined) return
draftScreen = templates[newTemplateIndex].create()
if (draftScreen.props._instanceName) {
name = draftScreen.props._instanceName
}
if (draftScreen.props._component) {
baseComponent = draftScreen.props._component
}
if (draftScreen.route) {
route = draftScreen.route
}
}
const save = () => {
if (!route) {
routeError = "Url is required"
@ -32,12 +54,23 @@
routeError = ""
}
}
if (routeError) {
return false
if (routeError) return false
draftScreen.props._instanceName = name
draftScreen.props._component = baseComponent
draftScreen.route = route
store.createScreen(draftScreen)
finished()
}
store.createScreen(name, route, layoutComponent._component)
const finished = () => {
templateIndex = 0
name = ""
route = ""
baseComponent = CONTAINER
}
const routeNameExists = route => {
@ -54,15 +87,25 @@
</script>
<ModalContent title="New Screen" confirmText="Create Screen" onConfirm={save}>
<Select
label="Choose a Template"
bind:value={templateIndex}
secondary
on:change={ev => templateChanged(ev.target.value)}>
{#if templates}
{#each templates as template, index}
<option value={index}>{template.name}</option>
{/each}
{/if}
</Select>
<Input label="Name" bind:value={name} />
<Input
label="Url"
error={routeError}
bind:value={route}
on:change={routeChanged} />
<Select label="Layout Component" bind:value={layoutComponent} secondary>
{#each layoutComponents as { _component, name }}
<option value={_component}>{name}</option>
{/each}
</Select>
</ModalContent>

View File

@ -11,6 +11,7 @@
export let componentDefinition = {}
export let componentInstance = {}
export let onChange = () => {}
export let onScreenPropChange = () => {}
export let displayNameField = false
export let screenOrPageInstance
@ -91,7 +92,7 @@
label={def.label}
key={def.key}
value={screenOrPageInstance[def.key]}
{onChange}
onChange={onScreenPropChange}
props={{ ...excludeProps(def, ['control', 'label']) }} />
{/each}
<hr />

View File

@ -361,19 +361,18 @@ export const typography = [
label: "Font",
key: "font-family",
control: OptionSelect,
defaultValue: "initial",
defaultValue: "Arial",
options: [
"initial",
"Arial",
"Arial Black",
"Cursive",
"Courier",
"Comic Sans MS",
"Helvetica",
"Helvetica Neue",
"Impact",
"Inter",
"Lucida Sans Unicode",
"Open Sans",
"Roboto",
"Roboto Mono",
"Times New Roman",
@ -467,9 +466,9 @@ export const background = [
label: "Gradient",
key: "background-image",
control: OptionSelect,
defaultValue: "None",
defaultValue: "",
options: [
{ label: "None", value: "None" },
{ label: "Select option", value: "" },
{
label: "Warm Flame",
value: "linear-gradient(45deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%);",
@ -518,9 +517,9 @@ export const background = [
},
{
label: "Image",
key: "background-image",
key: "background",
control: Input,
placeholder: "Src",
placeholder: "url",
},
]
@ -665,7 +664,7 @@ export const transitions = [
control: OptionSelect,
textAlign: "center",
placeholder: "sec",
options: ["0.2ms", "0.4ms", "0.8ms", "1s", "2s", "4s"],
options: ["0.4s", "0.6s", "0.8s", "1s", "2s", "4s"],
},
{
label: "Ease",

View File

@ -583,23 +583,7 @@ export default {
icon: "ri-file-edit-line",
properties: {
design: { ...all },
settings: [
{
label: "Table",
key: "model",
control: ModelSelect,
},
{
label: "Title",
key: "title",
control: Input,
},
{
label: "Button Text",
key: "buttonText",
control: Input,
},
],
settings: [],
},
},
{
@ -608,23 +592,7 @@ export default {
icon: "ri-file-edit-line",
properties: {
design: { ...all },
settings: [
{
label: "Table",
key: "model",
control: ModelSelect,
},
{
label: "Title",
key: "title",
control: Input,
},
{
label: "Button Text",
key: "buttonText",
control: Input,
},
],
settings: [],
},
},
],
@ -1125,8 +1093,8 @@ export default {
// children: [],
// },
{
name: "Record Detail",
_component: "@budibase/standard-components/recorddetail",
name: "Row Detail",
_component: "@budibase/standard-components/rowdetail",
description:
"Loads a record, using an id from the URL, which can be used with {{ context }}, in children",
icon: "ri-profile-line",
@ -1136,6 +1104,18 @@ export default {
},
children: [],
},
{
name: "New Row",
_component: "@budibase/standard-components/newrow",
description:
"Sets up a new record for creation, which can be used with {{ context }}, in children",
icon: "ri-profile-line",
properties: {
design: { ...all },
settings: [{ label: "Table", key: "model", control: ModelSelect }],
},
children: [],
},
// {
// name: "Map",
// _component: "@budibase/standard-components/datamap",

View File

@ -5850,10 +5850,10 @@ svelte-portal@^1.0.0:
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-1.0.0.tgz#36a47c5578b1a4d9b4dc60fa32a904640ec4cdd3"
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q==
svelte@^3.24.1:
version "3.25.1"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.25.1.tgz#218def1243fea5a97af6eb60f5e232315bb57ac4"
integrity sha512-IbrVKTmuR0BvDw4ii8/gBNy8REu7nWTRy9uhUz+Yuae5lIjWgSGwKlWtJGC2Vg95s+UnXPqDu0Kk/sUwe0t2GQ==
svelte@^3.29.0:
version "3.29.0"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.29.0.tgz#80acac4254341ad8f3301e5ef03f4127ea967d96"
integrity sha512-f+A65eyOQ5ujETLy+igNXtlr6AEjAQLYd1yJE1VwNiXMQO5Z/Vmiy3rL+zblV/9jd7rtTTWqO1IcuXsP2Qv0OA==
symbol-observable@^1.1.0:
version "1.2.0"

View File

@ -52,14 +52,14 @@ const apiOpts = {
delete: del,
}
const createRecord = async params =>
const saveRecord = async (params, state) =>
await post({
url: `/api/${params.modelId}/records`,
body: makeRecordRequestBody(params),
body: makeRecordRequestBody(params, state),
})
const updateRecord = async params => {
const record = makeRecordRequestBody(params)
const updateRecord = async (params, state) => {
const record = makeRecordRequestBody(params, state)
record._id = params._id
await patch({
url: `/api/${params.modelId}/records/${params._id}`,
@ -67,8 +67,14 @@ const updateRecord = async params => {
})
}
const makeRecordRequestBody = parameters => {
const body = {}
const makeRecordRequestBody = (parameters, state) => {
// start with the record thats currently in context
const body = { ...(state.data || {}) }
// dont send the model
if (body._model) delete body._model
// then override with supplied parameters
for (let fieldName in parameters.fields) {
const field = parameters.fields[fieldName]
@ -95,6 +101,6 @@ const makeRecordRequestBody = parameters => {
export default {
authenticate: authenticate(apiOpts),
createRecord,
saveRecord,
updateRecord,
}

View File

@ -6,8 +6,8 @@ export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType"
export const eventHandlers = routeTo => {
const handlers = {
"Navigate To": param => routeTo(param && param.url),
"Create Record": api.createRecord,
"Update Record": api.updateRecord,
"Save Record": api.saveRecord,
"Trigger Workflow": api.triggerWorkflow,
}
@ -19,7 +19,7 @@ export const eventHandlers = routeTo => {
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]]
const parameters = createParameters(action.parameters, state)
if (handler) {
await handler(parameters)
await handler(parameters, state)
}
}
}

View File

@ -95,7 +95,7 @@ const getState = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey].state : rootState
const getStore = contextStoreKey =>
contextStoreKey ? contextStores[contextStoreKey] : rootStore
contextStoreKey ? contextStores[contextStoreKey].store : rootStore
export default {
subscribe,

View File

@ -136,7 +136,7 @@ exports.performLocalFileProcessing = async function(ctx) {
}
exports.serveApp = async function(ctx) {
const mainOrAuth = ctx.isAuthenticated ? "main" : "unauthenticated"
const mainOrAuth = ctx.auth.authenticated ? "main" : "unauthenticated"
// default to homedir
const appPath = resolve(
@ -154,7 +154,7 @@ exports.serveApp = async function(ctx) {
// only set the appId cookie for /appId .. we COULD check for valid appIds
// but would like to avoid that DB hit
const looksLikeAppId = /^(app_)?[0-9a-f]{32}$/.test(appId)
if (looksLikeAppId && !ctx.isAuthenticated) {
if (looksLikeAppId && !ctx.auth.authenticated) {
const anonUser = {
userId: "ANON",
accessLevelId: ANON_LEVEL_ID,
@ -200,7 +200,7 @@ exports.serveAttachment = async function(ctx) {
exports.serveAppAsset = async function(ctx) {
// default to homedir
const mainOrAuth = ctx.isAuthenticated ? "main" : "unauthenticated"
const mainOrAuth = ctx.auth.authenticated ? "main" : "unauthenticated"
const appPath = resolve(
budibaseAppsDir(),

View File

@ -24,6 +24,7 @@ app.use(
)
app.context.eventEmitter = eventEmitter
app.context.auth = {}
// api routes
app.use(api.routes())

View File

@ -1,5 +1,6 @@
const LinkController = require("./LinkController")
const { IncludeDocs, getLinkDocuments, createLinkView } = require("./linkUtils")
const _ = require("lodash")
/**
* This functionality makes sure that when records with links are created, updated or deleted they are processed
@ -88,23 +89,23 @@ exports.attachLinkInfo = async (instanceId, records) => {
records = [records]
wasArray = false
}
let modelIds = [...new Set(records.map(el => el.modelId))]
// start by getting all the link values for performance reasons
let responses = await Promise.all(
records.map(record =>
let responses = _.flatten(
await Promise.all(
modelIds.map(modelId =>
getLinkDocuments({
instanceId,
modelId: record.modelId,
recordId: record._id,
modelId: modelId,
includeDocs: IncludeDocs.EXCLUDE,
})
)
)
// can just use an index to access responses, order maintained
let index = 0
)
// now iterate through the records and all field information
for (let record of records) {
// get all links for record, ignore fieldName for now
const linkVals = responses[index++]
const linkVals = responses.filter(el => el.thisId === record._id)
for (let linkVal of linkVals) {
// work out which link pertains to this record
if (!(record[linkVal.fieldName] instanceof Array)) {

View File

@ -27,10 +27,12 @@ exports.createLinkView = async instanceId => {
let doc2 = doc.doc2
emit([doc1.modelId, doc1.recordId], {
id: doc2.recordId,
thisId: doc1.recordId,
fieldName: doc1.fieldName,
})
emit([doc2.modelId, doc2.recordId], {
id: doc1.recordId,
thisId: doc2.recordId,
fieldName: doc2.fieldName,
})
}

View File

@ -20,8 +20,10 @@ module.exports = async (ctx, next) => {
if (builderToken) {
try {
const jwtPayload = jwt.verify(builderToken, ctx.config.jwtSecret)
ctx.apiKey = jwtPayload.apiKey
ctx.isAuthenticated = jwtPayload.accessLevelId === BUILDER_LEVEL_ID
ctx.auth = {
apiKey: jwtPayload.apiKey,
authenticated: jwtPayload.accessLevelId === BUILDER_LEVEL_ID,
}
ctx.user = {
...jwtPayload,
accessLevel: await getAccessLevel(
@ -38,14 +40,13 @@ module.exports = async (ctx, next) => {
}
if (!appToken) {
ctx.isAuthenticated = false
ctx.auth.authenticated = false
await next()
return
}
try {
const jwtPayload = jwt.verify(appToken, ctx.config.jwtSecret)
ctx.apiKey = jwtPayload.apiKey
ctx.user = {
...jwtPayload,
accessLevel: await getAccessLevel(
@ -53,7 +54,10 @@ module.exports = async (ctx, next) => {
jwtPayload.accessLevelId
),
}
ctx.isAuthenticated = ctx.user.accessLevelId !== ANON_LEVEL_ID
ctx.auth = {
authenticated: ctx.user.accessLevelId !== ANON_LEVEL_ID,
apiKey: jwtPayload.apiKey,
}
} catch (err) {
ctx.throw(err.status || STATUS_CODES.FORBIDDEN, err.text)
}

View File

@ -5,9 +5,36 @@ const {
BUILDER_LEVEL_ID,
BUILDER,
} = require("../utilities/accessLevels")
const environment = require("../environment")
const { apiKeyTable } = require("../db/dynamoClient")
module.exports = (permName, getItemId) => async (ctx, next) => {
if (!ctx.isAuthenticated) {
if (
environment.CLOUD &&
ctx.headers["x-api-key"] &&
ctx.headers["x-instanceid"]
) {
// api key header passed by external webhook
const apiKeyInfo = await apiKeyTable.get({
primary: ctx.headers["x-api-key"],
})
if (apiKeyInfo) {
ctx.auth = {
authenticated: true,
external: true,
apiKey: ctx.headers["x-api-key"],
}
ctx.user = {
instanceId: ctx.headers["x-instanceid"],
}
return next()
}
ctx.throw(403, "API key invalid")
}
if (!ctx.auth.authenticated) {
ctx.throw(403, "Session not authenticated")
}

View File

@ -55,7 +55,7 @@ module.exports = async (ctx, next) => {
return next()
}
try {
await usageQuota.update(ctx.apiKey, property, usage)
await usageQuota.update(ctx.auth.apiKey, property, usage)
return next()
} catch (err) {
ctx.throw(403, err)

View File

@ -218,20 +218,12 @@
"dataform": {
"description": "an HTML table that fetches data from a table or view and displays it.",
"data": true,
"props": {
"model": "models",
"title": "string",
"buttonText": "string"
}
"props": {}
},
"dataformwide": {
"description": "an HTML table that fetches data from a table or view and displays it.",
"data": true,
"props": {
"model": "models",
"title": "string",
"buttonText": "string"
}
"props": {}
},
"datalist": {
"description": "A configurable data list that attaches to your backend models.",
@ -269,11 +261,22 @@
"destinationUrl": "string"
}
},
"recorddetail": {
"rowdetail": {
"description": "Loads a record, using an ID in the url",
"context": "model",
"children": true,
"data": true,
"baseComponent": true,
"props": {
"model": "models"
}
},
"newrow": {
"description": "Prepares a new record for creation",
"context": "model",
"children": true,
"data": true,
"baseComponent": true,
"props": {
"model": "models"
}
@ -715,7 +718,7 @@
"default": "div"
}
},
"container": true,
"baseComponent": true,
"tags": [
"div",
"container",

View File

@ -16,17 +16,17 @@
"@budibase/client": "^0.2.0",
"@rollup/plugin-commonjs": "^11.1.0",
"lodash": "^4.17.15",
"rollup": "^1.11.0",
"rollup": "^2.11.2",
"rollup-plugin-commonjs": "^10.0.2",
"rollup-plugin-json": "^4.0.0",
"rollup-plugin-livereload": "^1.0.1",
"rollup-plugin-node-resolve": "^5.0.0",
"rollup-plugin-postcss": "^3.1.5",
"rollup-plugin-svelte": "^5.0.0",
"rollup-plugin-terser": "^5.1.1",
"rollup-plugin-svelte": "^5.0.3",
"rollup-plugin-terser": "^7.0.2",
"shortid": "^2.2.15",
"sirv-cli": "^0.4.4",
"svelte": "^3.12.1"
"svelte": "^3.29.0"
},
"keywords": [
"svelte"

View File

@ -1,126 +1,25 @@
<script>
import { onMount } from "svelte"
import { fade } from "svelte/transition"
import {
Label,
DatePicker,
Input,
Select,
Button,
Toggle,
} from "@budibase/bbui"
import { Label, DatePicker, Input, Select, Toggle } from "@budibase/bbui"
import Dropzone from "./attachments/Dropzone.svelte"
import LinkedRecordSelector from "./LinkedRecordSelector.svelte"
import debounce from "lodash.debounce"
import ErrorsBox from "./ErrorsBox.svelte"
import { capitalise } from "./helpers"
export let _bb
export let model
export let title
export let buttonText
export let wide = false
const TYPE_MAP = {
string: "text",
boolean: "checkbox",
number: "number",
}
const DEFAULTS_FOR_TYPE = {
string: "",
boolean: false,
number: null,
link: [],
}
let record
let store = _bb.store
let schema = {}
let modelDef = {}
let saved = false
let recordId
let isNew = true
let errors = {}
$: schema = $store.data && $store.data._model.schema
$: fields = schema ? Object.keys(schema) : []
$: if (model && model.length !== 0) {
fetchModel()
}
async function fetchModel() {
const FETCH_MODEL_URL = `/api/models/${model}`
const response = await _bb.api.get(FETCH_MODEL_URL)
modelDef = await response.json()
schema = modelDef.schema
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]
}
}
const SAVE_RECORD_URL = `/api/${model}/records`
const response = await _bb.api.post(SAVE_RECORD_URL, record)
const json = await response.json()
if (response.status === 200) {
store.update(state => {
state[model] = state[model] ? [...state[model], json] : [json]
return state
})
errors = {}
// wipe form, if new record, otherwise update
// model to get new _rev
record = isNew ? { modelId: model } : json
// set saved, and unset after 1 second
// i.e. make the success notifier appear, then disappear again after time
saved = true
setTimeout(() => {
saved = false
}, 3000)
}
if (response.status === 400) {
errors = Object.keys(json.errors)
.map(k => ({ dataPath: k, message: json.errors[k] }))
.flat()
}
})
onMount(async () => {
const routeParams = _bb.routeParams()
recordId =
Object.keys(routeParams).length > 0 && (routeParams.id || routeParams[0])
isNew = !recordId || recordId === "new"
if (isNew) {
record = { modelId: model }
return
}
const GET_RECORD_URL = `/api/${model}/records/${recordId}`
const response = await _bb.api.get(GET_RECORD_URL)
record = await response.json()
})
</script>
<form class="form" on:submit|preventDefault>
{#if title}
<h1>{title}</h1>
{/if}
<div class="form-content">
<ErrorsBox {errors} />
<ErrorsBox errors={$store.saveRecordErrors || {}} />
{#each fields as field}
<div class="form-field" class:wide>
{#if !(schema[field].type === 'boolean' && !wide)}
@ -129,40 +28,34 @@
</Label>
{/if}
{#if schema[field].type === 'options'}
<Select secondary bind:value={record[field]}>
<Select secondary bind:value={$store.data[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === 'datetime'}
<DatePicker bind:value={record[field]} />
<DatePicker bind:value={$store.data[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle
text={wide ? null : capitalise(schema[field].name)}
bind:checked={record[field]} />
bind:checked={$store.data[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={record[field]} />
<Input type="number" bind:value={$store.data[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={record[field]} />
<Input bind:value={$store.data[field]} />
{:else if schema[field].type === 'attachment'}
<Dropzone bind:files={record[field]} />
<Dropzone bind:files={$store.data[field]} />
{:else if schema[field].type === 'link'}
<LinkedRecordSelector
secondary
showLabel={false}
bind:linkedRecords={record[field]}
bind:linkedRecords={$store.data[field]}
schema={schema[field]} />
{/if}
</div>
{/each}
<div class="buttons">
<Button primary on:click={save} green={saved}>
{#if saved}Success{:else}{buttonText || 'Submit Form'}{/if}
</Button>
</div>
</div>
</form>
<style>
.form {
@ -189,9 +82,4 @@
.form-field.wide :global(label) {
margin-bottom: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -0,0 +1,37 @@
<script>
import { onMount } from "svelte"
export let _bb
export let model
let record = {}
$: {
record.modelId = model
}
let target
async function fetchModel(id) {
const FETCH_MODEL_URL = `/api/models/${id}`
const response = await _bb.api.get(FETCH_MODEL_URL)
return await response.json()
}
onMount(async () => {
if (model) {
const modelObj = await fetchModel(model)
record.modelId = model
record._model = modelObj
_bb.attachChildren(target, {
context: record,
})
} else {
_bb.attachChildren(target, {
context: {},
})
}
})
</script>
<section bind:this={target} />

View File

@ -20,6 +20,7 @@
if (response.status === 200) {
const allRecords = await response.json()
if (allRecords.length > 0) return allRecords[0]
return { modelId: model }
}
}
@ -29,31 +30,35 @@
let record
// if srcdoc, then we assume this is the builder preview
if (pathParts.length === 0 || pathParts[0] === "srcdoc") {
record = await fetchFirstRecord()
} else {
const id = pathParts[pathParts.length - 1]
const GET_RECORD_URL = `/api/${model}/records/${id}`
if (model) record = await fetchFirstRecord()
} else if (_bb.routeParams().id) {
const GET_RECORD_URL = `/api/${model}/records/${_bb.routeParams().id}`
const response = await _bb.api.get(GET_RECORD_URL)
if (response.status === 200) {
record = await response.json()
} else {
throw new Error("Failed to fetch record.", response)
}
} else {
throw new Exception("Record ID was not supplied to RowDetail")
}
if (record) {
// Fetch model schema so we can check for linked records
const model = await fetchModel(record.modelId)
for (let key of Object.keys(model.schema)) {
if (model.schema[key].type === "link") {
const modelObj = await fetchModel(record.modelId)
for (let key of Object.keys(modelObj.schema)) {
if (modelObj.schema[key].type === "link") {
record[key] = Array.isArray(record[key]) ? record[key].length : 0
}
}
record._model = modelObj
_bb.attachChildren(target, {
hydrate: false,
context: record,
})
} else {
throw new Error("Failed to fetch record.", response)
_bb.attachChildren(target)
}
}

View File

@ -26,7 +26,8 @@ export { default as embed } from "./Embed.svelte"
export { default as stackedlist } from "./StackedList.svelte"
export { default as card } from "./Card.svelte"
export { default as cardhorizontal } from "./CardHorizontal.svelte"
export { default as recorddetail } from "./RecordDetail.svelte"
export { default as rowdetail } from "./RowDetail.svelte"
export { default as newrow } from "./NewRow.svelte"
export { default as datepicker } from "./DatePicker.svelte"
export * from "./Chart"
export { default as icon } from "./Icon.svelte"