Save Record Action

This commit is contained in:
Michael Shanks 2020-10-08 22:06:44 +01:00
parent 027819bae0
commit 2e76e1f0f4
10 changed files with 212 additions and 215 deletions

View File

@ -0,0 +1,138 @@
<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
$: bindableProperties = fetchBindableProperties({
componentInstanceId: $store.currentComponentInfo._id,
components: $store.components,
screen: $store.currentPreviewItem,
models: $backendUiStore.models,
})
// we pick all the _id fields, to determine all the available
// record contexts
let idFields
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,
}))
}
let schemaFields
$: {
if (parameters && parameters.contextPath) {
schemaFields = schemaFromContextPath(parameters.contextPath)
} 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">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

@ -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: [],
},
},
],

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

@ -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.",

View File

@ -1,168 +1,61 @@
<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} />
{#each fields as field}
<div class="form-field" class:wide>
{#if !(schema[field].type === 'boolean' && !wide)}
<Label extraSmall={!wide} grey={!wide}>
{capitalise(schema[field].name)}
</Label>
{/if}
{#if schema[field].type === 'options'}
<Select secondary bind:value={record[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]} />
{:else if schema[field].type === 'boolean'}
<Toggle
text={wide ? null : capitalise(schema[field].name)}
bind:checked={record[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={record[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={record[field]} />
{:else if schema[field].type === 'attachment'}
<Dropzone bind:files={record[field]} />
{:else if schema[field].type === 'link'}
<LinkedRecordSelector
secondary
showLabel={false}
bind:linkedRecords={record[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 class="form-content">
<ErrorsBox errors={$store.saveRecordErrors || {}} />
{#each fields as field}
<div class="form-field" class:wide>
{#if !(schema[field].type === 'boolean' && !wide)}
<Label extraSmall={!wide} grey={!wide}>
{capitalise(schema[field].name)}
</Label>
{/if}
{#if schema[field].type === 'options'}
<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={$store.data[field]} />
{:else if schema[field].type === 'boolean'}
<Toggle
text={wide ? null : capitalise(schema[field].name)}
bind:checked={$store.data[field]} />
{:else if schema[field].type === 'number'}
<Input type="number" bind:value={$store.data[field]} />
{:else if schema[field].type === 'string'}
<Input bind:value={$store.data[field]} />
{:else if schema[field].type === 'attachment'}
<Dropzone bind:files={$store.data[field]} />
{:else if schema[field].type === 'link'}
<LinkedRecordSelector
secondary
showLabel={false}
bind:linkedRecords={$store.data[field]}
schema={schema[field]} />
{/if}
</div>
</div>
</form>
{/each}
</div>
<style>
.form {
@ -189,9 +82,4 @@
.form-field.wide :global(label) {
margin-bottom: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
}
</style>

View File

@ -1,6 +1,5 @@
<script>
import { onMount } from "svelte"
import { isEmpty } from "lodash/fp"
export let _bb
export let model
@ -13,8 +12,17 @@
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 (!isEmpty(record) && record.model) {
if (model) {
const modelObj = await fetchModel(modelId)
record.modelId = model
record._model = modelObj
_bb.attachChildren(target, {
context: record,
})

View File

@ -51,6 +51,8 @@
}
}
record._model = model
_bb.attachChildren(target, {
context: record,
})