Merge branch 'master' into feature/add-grid-to-standard-components

This commit is contained in:
kevmodrome 2020-10-12 18:43:22 +02:00
commit 28caf55ac4
No known key found for this signature in database
GPG Key ID: E8F9CD141E63BF38
21 changed files with 198 additions and 63 deletions

View File

@ -90,6 +90,8 @@ const contextToBindables = (models, walkResult) => context => {
runtimeBinding: `${contextParentPath}data.${key}`, 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}.${model.name}.${key}`, readableBinding: `${context.instance._instanceName}.${model.name}.${key}`,
// model / view info
model: context.model,
}) })
// see ModelViewSelect.svelte for the format of context.model // see ModelViewSelect.svelte for the format of context.model

View File

@ -36,7 +36,8 @@ export const saveCurrentPreviewItem = s =>
: saveScreenApi(s.currentPreviewItem, s) : saveScreenApi(s.currentPreviewItem, s)
export const savePage = async s => { export const savePage = async s => {
const page = s.pages[s.currentPageName] const pageName = s.currentPageName || "main"
const page = s.pages[pageName]
await api.post(`/_builder/api/${s.appId}/pages/${s.currentPageName}`, { await api.post(`/_builder/api/${s.appId}/pages/${s.currentPageName}`, {
page: { componentLibraries: s.pages.componentLibraries, ...page }, page: { componentLibraries: s.pages.componentLibraries, ...page },
uiFunctions: s.currentPageFunctions, uiFunctions: s.currentPageFunctions,

View File

@ -79,7 +79,7 @@
} }
function fieldOptions(field) { function fieldOptions(field) {
return viewModel.schema[field].type === "string" return viewModel.schema[field].type === "options"
? viewModel.schema[field].constraints.inclusion ? viewModel.schema[field].constraints.inclusion
: [true, false] : [true, false]
} }

View File

@ -41,7 +41,6 @@
.map(template => template.create()) .map(template => template.create())
for (let screen of screens) { for (let screen of screens) {
console.log(JSON.stringify(screen))
try { try {
await store.createScreen(screen) await store.createScreen(screen)
} catch (_) { } catch (_) {

View File

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

View File

@ -3,6 +3,7 @@
import { Input, Button, Spacer, Select, ModalContent } from "@budibase/bbui" import { Input, Button, Spacer, Select, ModalContent } from "@budibase/bbui"
import getTemplates from "builderStore/store/screenTemplates" import getTemplates from "builderStore/store/screenTemplates"
import { some } from "lodash/fp" import { some } from "lodash/fp"
import analytics from "analytics"
const CONTAINER = "@budibase/standard-components/container" const CONTAINER = "@budibase/standard-components/container"
@ -29,7 +30,7 @@
const templateChanged = newTemplateIndex => { const templateChanged = newTemplateIndex => {
if (newTemplateIndex === undefined) return if (newTemplateIndex === undefined) return
const template = templates[newTemplateIndex]
draftScreen = templates[newTemplateIndex].create() draftScreen = templates[newTemplateIndex].create()
if (draftScreen.props._instanceName) { if (draftScreen.props._instanceName) {
name = draftScreen.props._instanceName name = draftScreen.props._instanceName
@ -63,6 +64,13 @@
store.createScreen(draftScreen) store.createScreen(draftScreen)
if (templateIndex !== undefined) {
const template = templates[templateIndex]
analytics.captureEvent("Screen Created", {
template: template.id || template.name,
})
}
finished() finished()
} }

View File

@ -1,18 +1,75 @@
<script> <script>
import { DataList } from "@budibase/bbui" import { DataList } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { store } from "builderStore" import { store, backendUiStore } from "builderStore"
import fetchBindableProperties from "builderStore/fetchBindableProperties"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let value = "" export let value = ""
$: urls = getUrls()
const handleBlur = () => dispatch("change", value) const handleBlur = () => dispatch("change", value)
// this will get urls of all screens, but only
// choose detail screens that are usable in the current context
// and substitute the :id param for the actual {{ ._id }} binding
const getUrls = () => {
const urls = [
...$store.screens
.filter(screen => !screen.props._component.endsWith("/rowdetail"))
.map(screen => ({
name: screen.props._instanceName,
url: screen.route,
sort: screen.props._component,
})),
]
const bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
const detailScreens = $store.screens.filter(screen =>
screen.props._component.endsWith("/rowdetail")
)
for (let detailScreen of detailScreens) {
const idBinding = bindableProperties.find(p => {
if (
p.type === "context" &&
p.runtimeBinding.endsWith("._id") &&
p.model
) {
const modelId =
typeof p.model === "string" ? p.model : p.model.modelId
return modelId === detailScreen.props.model
}
return false
})
if (idBinding) {
urls.push({
name: detailScreen.props._instanceName,
url: detailScreen.route.replace(
":id",
`{{ ${idBinding.runtimeBinding} }}`
),
sort: detailScreen.props._component,
})
}
return urls
}
}
</script> </script>
<DataList editable secondary on:blur={handleBlur} on:change bind:value> <DataList editable secondary on:blur={handleBlur} on:change bind:value>
<option value="" /> <option value="" />
{#each $store.allScreens as screen} {#each urls as url}
<option value={screen.route}>{screen.props._instanceName}</option> <option value={url.url}>{url.name}</option>
{/each} {/each}
</DataList> </DataList>

View File

@ -1,4 +1,4 @@
import { isString, isUndefined } from "lodash/fp" import { isString, isUndefined, cloneDeep } from "lodash/fp"
import { TYPE_MAP } from "./types" import { TYPE_MAP } from "./types"
import { assign } from "lodash" import { assign } from "lodash"
import { uuid } from "builderStore/uuid" import { uuid } from "builderStore/uuid"
@ -83,13 +83,13 @@ const parsePropDef = propDef => {
if (isString(propDef)) { if (isString(propDef)) {
if (!TYPE_MAP[propDef]) return error(`Type ${propDef} is not recognised.`) if (!TYPE_MAP[propDef]) return error(`Type ${propDef} is not recognised.`)
return TYPE_MAP[propDef].default return cloneDeep(TYPE_MAP[propDef].default)
} }
const type = TYPE_MAP[propDef.type] const type = TYPE_MAP[propDef.type]
if (!type) return error(`Type ${propDef.type} is not recognised.`) if (!type) return error(`Type ${propDef.type} is not recognised.`)
return propDef.default return cloneDeep(propDef.default)
} }
export const arrayElementComponentName = (parentComponentName, arrayPropName) => export const arrayElementComponentName = (parentComponentName, arrayPropName) =>

View File

@ -10,7 +10,6 @@ export const TYPE_MAP = {
}, },
options: { options: {
default: [], default: [],
options: [],
}, },
event: { event: {
default: [], default: [],

View File

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

View File

@ -386,7 +386,7 @@ export default {
{ {
label: "destinationUrl", label: "destinationUrl",
key: "destinationUrl", key: "destinationUrl",
control: Input, control: ScreenSelect,
placeholder: "/table/_id", placeholder: "/table/_id",
}, },
], ],
@ -435,7 +435,7 @@ export default {
{ {
label: "Link Url", label: "Link Url",
key: "linkUrl", key: "linkUrl",
control: Input, control: ScreenSelect,
placeholder: "Link URL", placeholder: "Link URL",
}, },
{ {
@ -510,7 +510,7 @@ export default {
{ {
label: "Link Url", label: "Link Url",
key: "linkUrl", key: "linkUrl",
control: Input, control: ScreenSelect,
placeholder: "Link URL", placeholder: "Link URL",
}, },
{ {

View File

@ -15,7 +15,7 @@ export const FIELDS = {
type: "options", type: "options",
constraints: { constraints: {
type: "string", type: "string",
presence: { allowEmpty: true }, presence: false,
inclusion: [], inclusion: [],
}, },
}, },
@ -67,7 +67,7 @@ export const FIELDS = {
type: "link", type: "link",
constraints: { constraints: {
type: "array", type: "array",
presence: { allowEmpty: true }, presence: false,
}, },
}, },
} }

View File

@ -33,6 +33,7 @@ exports.save = async function(ctx) {
views: {}, views: {},
...rest, ...rest,
} }
let renameDocs = []
// if the model obj had an _id then it will have been retrieved // if the model obj had an _id then it will have been retrieved
const oldModel = ctx.preExisting const oldModel = ctx.preExisting
@ -49,14 +50,11 @@ exports.save = async function(ctx) {
include_docs: true, include_docs: true,
}) })
) )
renameDocs = records.rows.map(({ doc }) => {
const docs = records.rows.map(({ doc }) => {
doc[_rename.updated] = doc[_rename.old] doc[_rename.updated] = doc[_rename.old]
delete doc[_rename.old] delete doc[_rename.old]
return doc return doc
}) })
await db.bulkDocs(docs)
delete modelToSave._rename delete modelToSave._rename
} }
@ -69,9 +67,6 @@ exports.save = async function(ctx) {
modelView.schema = modelToSave.schema modelView.schema = modelToSave.schema
} }
const result = await db.post(modelToSave)
modelToSave._rev = result.rev
// update linked records // update linked records
await linkRecords.updateLinks({ await linkRecords.updateLinks({
instanceId, instanceId,
@ -82,6 +77,14 @@ exports.save = async function(ctx) {
oldModel: oldModel, oldModel: oldModel,
}) })
// don't perform any updates until relationships have been
// checked by the updateLinks function
if (renameDocs.length !== 0) {
await db.bulkDocs(renameDocs)
}
const result = await db.post(modelToSave)
modelToSave._rev = result.rev
ctx.eventEmitter && ctx.eventEmitter &&
ctx.eventEmitter.emitModel(`model:save`, instanceId, modelToSave) ctx.eventEmitter.emitModel(`model:save`, instanceId, modelToSave)
@ -105,11 +108,8 @@ exports.save = async function(ctx) {
exports.destroy = async function(ctx) { exports.destroy = async function(ctx) {
const instanceId = ctx.user.instanceId const instanceId = ctx.user.instanceId
const db = new CouchDB(instanceId) const db = new CouchDB(instanceId)
const modelToDelete = await db.get(ctx.params.modelId) const modelToDelete = await db.get(ctx.params.modelId)
await db.remove(modelToDelete)
// Delete all records for that model // Delete all records for that model
const records = await db.allDocs( const records = await db.allDocs(
getRecordParams(ctx.params.modelId, null, { getRecordParams(ctx.params.modelId, null, {
@ -117,7 +117,7 @@ exports.destroy = async function(ctx) {
}) })
) )
await db.bulkDocs( await db.bulkDocs(
records.rows.map(record => ({ _id: record.id, _deleted: true })) records.rows.map(record => ({ ...record.doc, _deleted: true }))
) )
// update linked records // update linked records
@ -127,6 +127,9 @@ exports.destroy = async function(ctx) {
model: modelToDelete, model: modelToDelete,
}) })
// don't remove the table itself until very end
await db.remove(modelToDelete)
ctx.eventEmitter && ctx.eventEmitter &&
ctx.eventEmitter.emitModel(`model:delete`, instanceId, modelToDelete) ctx.eventEmitter.emitModel(`model:delete`, instanceId, modelToDelete)
ctx.status = 200 ctx.status = 200

View File

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

View File

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

View File

@ -161,7 +161,7 @@ class LinkController {
}) })
// now add the docs to be deleted to the bulk operation // now add the docs to be deleted to the bulk operation
operations.push(...toDeleteDocs) operations.push(...toDeleteDocs)
// replace this field with a simple entry to denote there are links // remove the field from this row, link doc will be added to record on way out
delete record[fieldName] delete record[fieldName]
} }
} }
@ -234,8 +234,16 @@ class LinkController {
for (let fieldName of Object.keys(schema)) { for (let fieldName of Object.keys(schema)) {
const field = schema[fieldName] const field = schema[fieldName]
if (field.type === "link") { if (field.type === "link") {
// handle this in a separate try catch, want
// the put to bubble up as an error, if can't update
// table for some reason
let linkedModel
try {
linkedModel = await this._db.get(field.modelId)
} catch (err) {
continue
}
// create the link field in the other model // create the link field in the other model
const linkedModel = await this._db.get(field.modelId)
linkedModel.schema[field.fieldName] = { linkedModel.schema[field.fieldName] = {
name: field.fieldName, name: field.fieldName,
type: "link", type: "link",

View File

@ -42,6 +42,7 @@ exports.updateLinks = async function({
model, model,
oldModel, oldModel,
}) { }) {
const baseReturnObj = record == null ? model : record
if (instanceId == null) { if (instanceId == null) {
throw "Cannot operate without an instance ID." throw "Cannot operate without an instance ID."
} }
@ -50,12 +51,16 @@ exports.updateLinks = async function({
arguments[0].modelId = model._id arguments[0].modelId = model._id
} }
let linkController = new LinkController(arguments[0]) let linkController = new LinkController(arguments[0])
try {
if ( if (
!(await linkController.doesModelHaveLinkedFields()) && !(await linkController.doesModelHaveLinkedFields()) &&
(oldModel == null || (oldModel == null ||
!(await linkController.doesModelHaveLinkedFields(oldModel))) !(await linkController.doesModelHaveLinkedFields(oldModel)))
) { ) {
return record return baseReturnObj
}
} catch (err) {
return baseReturnObj
} }
switch (eventType) { switch (eventType) {
case EventType.RECORD_SAVE: case EventType.RECORD_SAVE:

View File

@ -57,22 +57,27 @@ exports.generateModelID = () => {
/** /**
* Gets the DB allDocs/query params for retrieving a record. * Gets the DB allDocs/query params for retrieving a record.
* @param {string} modelId The model in which the records have been stored. * @param {string|null} modelId The model in which the records have been stored.
* @param {string|null} recordId The ID of the record which is being specifically queried for. This can be * @param {string|null} recordId The ID of the record which is being specifically queried for. This can be
* left null to get all the records in the model. * left null to get all the records in the model.
* @param {object} otherProps Any other properties to add to the request. * @param {object} otherProps Any other properties to add to the request.
* @returns {object} Parameters which can then be used with an allDocs request. * @returns {object} Parameters which can then be used with an allDocs request.
*/ */
exports.getRecordParams = (modelId, recordId = null, otherProps = {}) => { exports.getRecordParams = (
modelId = null,
recordId = null,
otherProps = {}
) => {
if (modelId == null) { if (modelId == null) {
throw "Cannot build params for records without a model ID" return getDocParams(DocumentTypes.RECORD, null, otherProps)
} } else {
const endOfKey = const endOfKey =
recordId == null recordId == null
? `${modelId}${SEPARATOR}` ? `${modelId}${SEPARATOR}`
: `${modelId}${SEPARATOR}${recordId}` : `${modelId}${SEPARATOR}${recordId}`
return getDocParams(DocumentTypes.RECORD, endOfKey, otherProps) return getDocParams(DocumentTypes.RECORD, endOfKey, otherProps)
} }
}
/** /**
* Gets a new record ID for the specified model. * Gets a new record ID for the specified model.
@ -124,7 +129,14 @@ exports.generateAutomationID = () => {
* @returns {string} The new link doc ID which the automation doc can be stored under. * @returns {string} The new link doc ID which the automation doc can be stored under.
*/ */
exports.generateLinkID = (modelId1, modelId2, recordId1, recordId2) => { exports.generateLinkID = (modelId1, modelId2, recordId1, recordId2) => {
return `${DocumentTypes.AUTOMATION}${SEPARATOR}${modelId1}${SEPARATOR}${modelId2}${SEPARATOR}${recordId1}${SEPARATOR}${recordId2}` return `${DocumentTypes.LINK}${SEPARATOR}${modelId1}${SEPARATOR}${modelId2}${SEPARATOR}${recordId1}${SEPARATOR}${recordId2}`
}
/**
* Gets parameters for retrieving link docs, this is a utility function for the getDocParams function.
*/
exports.getLinkParams = (otherProps = {}) => {
return getDocParams(DocumentTypes.LINK, null, otherProps)
} }
/** /**

View File

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

View File

@ -5,9 +5,36 @@ const {
BUILDER_LEVEL_ID, BUILDER_LEVEL_ID,
BUILDER, BUILDER,
} = require("../utilities/accessLevels") } = require("../utilities/accessLevels")
const environment = require("../environment")
const { apiKeyTable } = require("../db/dynamoClient")
module.exports = (permName, getItemId) => async (ctx, next) => { 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") ctx.throw(403, "Session not authenticated")
} }

View File

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