mirror of https://github.com/Budibase/budibase.git
43 changed files with 473 additions and 1045 deletions
@ -1,17 +1,16 @@ |
|||||
<script> |
<script> |
||||
import { TextButton as Button, Icon } from "@budibase/bbui" |
import { TextButton as Button, Icon, Modal } from "@budibase/bbui" |
||||
import CreateEditRecordModal from "../modals/CreateEditRecordModal.svelte" |
import CreateEditRecordModal from "../modals/CreateEditRecordModal.svelte" |
||||
import { Modal } from "components/common/Modal" |
|
||||
|
|
||||
let modalVisible |
let modal |
||||
</script> |
</script> |
||||
|
|
||||
<div> |
<div> |
||||
<Button text small on:click={() => (modalVisible = true)}> |
<Button text small on:click={modal.show}> |
||||
<Icon name="addrow" /> |
<Icon name="addrow" /> |
||||
Create New Row |
Create New Row |
||||
</Button> |
</Button> |
||||
</div> |
</div> |
||||
{#if modalVisible} |
<Modal bind:this={modal}> |
||||
<CreateEditRecordModal bind:visible={modalVisible} /> |
<CreateEditRecordModal /> |
||||
{/if} |
</Modal> |
||||
|
|||||
@ -0,0 +1,48 @@ |
|||||
|
<script> |
||||
|
import { goto } from "@sveltech/routify" |
||||
|
import { backendUiStore } 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" |
||||
|
|
||||
|
let modal |
||||
|
let name |
||||
|
let dataImport |
||||
|
|
||||
|
function resetState() { |
||||
|
name = "" |
||||
|
dataImport = undefined |
||||
|
} |
||||
|
|
||||
|
async function saveTable() { |
||||
|
const model = await backendUiStore.actions.models.save({ |
||||
|
name, |
||||
|
schema: dataImport.schema || {}, |
||||
|
dataImport, |
||||
|
}) |
||||
|
notifier.success(`Table ${name} created successfully.`) |
||||
|
$goto(`./model/${model._id}`) |
||||
|
analytics.captureEvent("Table Created", { name }) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<Button primary wide on:click={modal.show}>Create New Table</Button> |
||||
|
<Modal bind:this={modal} on:hide={resetState}> |
||||
|
<ModalContent |
||||
|
title="Create Table" |
||||
|
confirmText="Create" |
||||
|
onConfirm={saveTable} |
||||
|
disabled={!name || (dataImport && !dataImport.valid)}> |
||||
|
<Input |
||||
|
data-cy="table-name-input" |
||||
|
thin |
||||
|
label="Table Name" |
||||
|
bind:value={name} /> |
||||
|
<div> |
||||
|
<Label grey extraSmall>Create Table from CSV (Optional)</Label> |
||||
|
<TableDataImport bind:dataImport /> |
||||
|
</div> |
||||
|
</ModalContent> |
||||
|
</Modal> |
||||
@ -1,84 +0,0 @@ |
|||||
<script> |
|
||||
import { goto } from "@sveltech/routify" |
|
||||
import { backendUiStore } from "builderStore" |
|
||||
import { notifier } from "builderStore/store/notifications" |
|
||||
import { Popover, Button, Icon, Input, Select, Label } from "@budibase/bbui" |
|
||||
import Spinner from "components/common/Spinner.svelte" |
|
||||
import TableDataImport from "../TableDataImport.svelte" |
|
||||
import analytics from "analytics" |
|
||||
|
|
||||
let anchor |
|
||||
let dropdown |
|
||||
let name |
|
||||
let dataImport |
|
||||
let loading |
|
||||
|
|
||||
async function saveTable() { |
|
||||
loading = true |
|
||||
const model = await backendUiStore.actions.models.save({ |
|
||||
name, |
|
||||
schema: dataImport.schema || {}, |
|
||||
dataImport, |
|
||||
}) |
|
||||
notifier.success(`Table ${name} created successfully.`) |
|
||||
$goto(`./model/${model._id}`) |
|
||||
analytics.captureEvent("Table Created", { name }) |
|
||||
name = "" |
|
||||
dropdown.hide() |
|
||||
loading = false |
|
||||
} |
|
||||
|
|
||||
const onClosed = () => { |
|
||||
name = "" |
|
||||
dropdown.hide() |
|
||||
} |
|
||||
</script> |
|
||||
|
|
||||
<div bind:this={anchor}> |
|
||||
<Button primary wide on:click={dropdown.show}>Create New Table</Button> |
|
||||
</div> |
|
||||
<Popover bind:this={dropdown} {anchor} align="left"> |
|
||||
<div class="actions"> |
|
||||
<h5>Create Table</h5> |
|
||||
<Input |
|
||||
data-cy="table-name-input" |
|
||||
thin |
|
||||
label="Table Name" |
|
||||
bind:value={name} /> |
|
||||
<div> |
|
||||
<Label grey extraSmall>Create Table from CSV (Optional)</Label> |
|
||||
<TableDataImport bind:dataImport /> |
|
||||
</div> |
|
||||
<footer> |
|
||||
<Button secondary on:click={onClosed}>Cancel</Button> |
|
||||
<Button |
|
||||
disabled={!name || (dataImport && !dataImport.valid)} |
|
||||
primary |
|
||||
on:click={saveTable}> |
|
||||
<span style={`margin-right: ${loading ? '10px' : 0};`}>Save</span> |
|
||||
{#if loading} |
|
||||
<Spinner size="10" /> |
|
||||
{/if} |
|
||||
</Button> |
|
||||
</footer> |
|
||||
</div> |
|
||||
</Popover> |
|
||||
|
|
||||
<style> |
|
||||
.actions { |
|
||||
display: grid; |
|
||||
grid-gap: var(--spacing-xl); |
|
||||
min-width: 400px; |
|
||||
} |
|
||||
|
|
||||
h5 { |
|
||||
margin: 0; |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
|
|
||||
footer { |
|
||||
display: flex; |
|
||||
justify-content: flex-end; |
|
||||
gap: var(--spacing-m); |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,215 +0,0 @@ |
|||||
<script> |
|
||||
/** |
|
||||
* Confirmation is handled as a callback rather than an event to allow |
|
||||
* handling the result - meaning a parent can prevent the modal closing. |
|
||||
* |
|
||||
* A show/hide API is exposed as part of the modal and also via context for |
|
||||
* children inside the modal. |
|
||||
* "show" and "hide" events are emitted as visibility changes. |
|
||||
* |
|
||||
* Modals are rendered at the top of the DOM tree. |
|
||||
*/ |
|
||||
import { createEventDispatcher, setContext } from "svelte" |
|
||||
import { fade, fly } from "svelte/transition" |
|
||||
import Portal from "svelte-portal" |
|
||||
import { Button } from "@budibase/bbui" |
|
||||
import { ContextKey } from "./context" |
|
||||
const dispatch = createEventDispatcher() |
|
||||
|
|
||||
export let wide = false |
|
||||
export let padded = true |
|
||||
export let title = undefined |
|
||||
export let cancelText = "Cancel" |
|
||||
export let confirmText = "Confirm" |
|
||||
export let showCancelButton = true |
|
||||
export let showConfirmButton = true |
|
||||
export let onConfirm = () => {} |
|
||||
export let visible = false |
|
||||
|
|
||||
let loading = false |
|
||||
|
|
||||
function show() { |
|
||||
if (visible) { |
|
||||
return |
|
||||
} |
|
||||
visible = true |
|
||||
dispatch("show") |
|
||||
} |
|
||||
|
|
||||
function hide() { |
|
||||
if (!visible) { |
|
||||
return |
|
||||
} |
|
||||
visible = false |
|
||||
dispatch("hide") |
|
||||
} |
|
||||
|
|
||||
async function confirm() { |
|
||||
loading = true |
|
||||
if (!onConfirm || (await onConfirm()) !== false) { |
|
||||
hide() |
|
||||
} |
|
||||
loading = false |
|
||||
} |
|
||||
|
|
||||
setContext(ContextKey, { show, hide }) |
|
||||
</script> |
|
||||
|
|
||||
{#if visible} |
|
||||
<Portal target="#modal-container"> |
|
||||
<div |
|
||||
class="overlay" |
|
||||
on:click|self={hide} |
|
||||
transition:fade={{ duration: 200 }}> |
|
||||
<div |
|
||||
class="scroll-wrapper" |
|
||||
on:click|self={hide} |
|
||||
transition:fly={{ y: 50 }}> |
|
||||
<div class="content-wrapper" on:click|self={hide}> |
|
||||
<div class="modal" class:wide class:padded> |
|
||||
{#if title} |
|
||||
<header> |
|
||||
<h5>{title}</h5> |
|
||||
<div class="header-content"> |
|
||||
<slot name="header" /> |
|
||||
</div> |
|
||||
</header> |
|
||||
{/if} |
|
||||
<slot /> |
|
||||
{#if showCancelButton || showConfirmButton} |
|
||||
<footer> |
|
||||
<div class="footer-content"> |
|
||||
<slot name="footer" /> |
|
||||
</div> |
|
||||
<div class="buttons"> |
|
||||
{#if showCancelButton} |
|
||||
<Button secondary on:click={hide}>{cancelText}</Button> |
|
||||
{/if} |
|
||||
{#if showConfirmButton} |
|
||||
<Button |
|
||||
primary |
|
||||
{...$$restProps} |
|
||||
disabled={$$restProps.disabled || loading} |
|
||||
on:click={confirm}> |
|
||||
{confirmText} |
|
||||
</Button> |
|
||||
{/if} |
|
||||
</div> |
|
||||
</footer> |
|
||||
{/if} |
|
||||
<i class="ri-close-line" on:click={hide} /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
</Portal> |
|
||||
{/if} |
|
||||
|
|
||||
<style> |
|
||||
.overlay { |
|
||||
position: fixed; |
|
||||
left: 0; |
|
||||
right: 0; |
|
||||
top: 0; |
|
||||
bottom: 0; |
|
||||
overflow-x: hidden; |
|
||||
overflow-y: auto; |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: center; |
|
||||
align-items: center; |
|
||||
background-color: rgba(0, 0, 0, 0.25); |
|
||||
} |
|
||||
|
|
||||
.scroll-wrapper { |
|
||||
flex: 1 1 auto; |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: center; |
|
||||
align-items: flex-start; |
|
||||
max-height: 100%; |
|
||||
} |
|
||||
|
|
||||
.content-wrapper { |
|
||||
flex: 1 1 auto; |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: center; |
|
||||
align-items: flex-start; |
|
||||
width: 0; |
|
||||
} |
|
||||
|
|
||||
.modal { |
|
||||
background-color: white; |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
justify-content: flex-start; |
|
||||
align-items: stretch; |
|
||||
box-shadow: 0 0 2.4rem 1.5rem rgba(0, 0, 0, 0.15); |
|
||||
position: relative; |
|
||||
flex: 0 0 400px; |
|
||||
margin: 2rem 0; |
|
||||
border-radius: var(--border-radius-m); |
|
||||
gap: var(--spacing-xl); |
|
||||
} |
|
||||
.modal.wide { |
|
||||
flex: 0 0 600px; |
|
||||
} |
|
||||
.modal.padded { |
|
||||
padding: var(--spacing-xl); |
|
||||
} |
|
||||
|
|
||||
header { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: space-between; |
|
||||
align-items: center; |
|
||||
margin-right: 40px; |
|
||||
} |
|
||||
header h5 { |
|
||||
margin: 0; |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
|
|
||||
.header-content { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-end; |
|
||||
align-items: center; |
|
||||
} |
|
||||
|
|
||||
i { |
|
||||
position: absolute; |
|
||||
top: var(--spacing-xl); |
|
||||
right: var(--spacing-xl); |
|
||||
color: var(--ink); |
|
||||
font-size: var(--font-size-xl); |
|
||||
} |
|
||||
i:hover { |
|
||||
color: var(--grey-6); |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
|
|
||||
footer { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: space-between; |
|
||||
align-items: center; |
|
||||
gap: var(--spacing-m); |
|
||||
} |
|
||||
|
|
||||
.footer-content { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-start; |
|
||||
align-items: center; |
|
||||
} |
|
||||
|
|
||||
.buttons { |
|
||||
display: flex; |
|
||||
flex-direction: row; |
|
||||
justify-content: flex-end; |
|
||||
align-items: center; |
|
||||
gap: var(--spacing-m); |
|
||||
} |
|
||||
</style> |
|
||||
@ -1,10 +0,0 @@ |
|||||
<div id="modal-container" /> |
|
||||
|
|
||||
<style> |
|
||||
#modal-container { |
|
||||
position: fixed; |
|
||||
top: 0; |
|
||||
left: 0; |
|
||||
z-index: 999; |
|
||||
} |
|
||||
</style> |
|
||||
@ -1 +0,0 @@ |
|||||
export const ContextKey = "budibase-modal" |
|
||||
@ -1,3 +0,0 @@ |
|||||
export { default as Modal } from "./Modal.svelte" |
|
||||
export { default as ModalContainer } from "./ModalContainer.svelte" |
|
||||
export { ContextKey } from "./context" |
|
||||
@ -1,23 +1,17 @@ |
|||||
<script> |
<script> |
||||
import { Button, Modal } from "@budibase/bbui" |
import { Button, Modal } from "@budibase/bbui" |
||||
import EventEditorModal from "./EventEditorModal.svelte" |
import EventEditorModal from "./EventEditorModal.svelte" |
||||
import { createEventDispatcher, onMount } from "svelte" |
import { createEventDispatcher } from "svelte" |
||||
const dispatch = createEventDispatcher() |
const dispatch = createEventDispatcher() |
||||
|
|
||||
export let value |
export let value |
||||
export let name |
export let name |
||||
|
|
||||
let modalVisible = false |
let modal |
||||
</script> |
</script> |
||||
|
|
||||
<Button secondary small on:click={() => (modalVisible = true)}> |
<Button secondary small on:click={modal.show}>Define Actions</Button> |
||||
Define Actions |
|
||||
</Button> |
|
||||
|
|
||||
{#if modalVisible} |
<Modal bind:this={modal} width="600px"> |
||||
<EventEditorModal |
<EventEditorModal event={value} eventType={name} on:change /> |
||||
bind:visible={modalVisible} |
</Modal> |
||||
event={value} |
|
||||
eventType={name} |
|
||||
on:change /> |
|
||||
{/if} |
|
||||
|
|||||
@ -1,189 +1,10 @@ |
|||||
<script> |
<script> |
||||
import { onMount } from "svelte" |
import Form from "./Form.svelte" |
||||
import { fade } from "svelte/transition" |
|
||||
import { |
|
||||
Label, |
|
||||
DatePicker, |
|
||||
Input, |
|
||||
Select, |
|
||||
Button, |
|
||||
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 _bb |
||||
export let model |
export let model |
||||
export let title |
export let title |
||||
export let buttonText |
export let buttonText |
||||
|
|
||||
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 = {} |
|
||||
|
|
||||
$: 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> |
</script> |
||||
|
|
||||
<form class="form" on:submit|preventDefault> |
<Form {_bb} {model} {title} {buttonText} wide={false} /> |
||||
{#if title} |
|
||||
<h1>{title}</h1> |
|
||||
{/if} |
|
||||
<div class="form-content"> |
|
||||
<ErrorsBox {errors} /> |
|
||||
{#each fields as field} |
|
||||
{#if schema[field].type === 'options'} |
|
||||
<Select |
|
||||
secondary |
|
||||
label={capitalise(schema[field].name)} |
|
||||
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 |
|
||||
label={capitalise(schema[field].name)} |
|
||||
bind:value={record[field]} /> |
|
||||
{:else if schema[field].type === 'boolean'} |
|
||||
<Toggle |
|
||||
text={capitalise(schema[field].name)} |
|
||||
bind:checked={record[field]} /> |
|
||||
{:else if schema[field].type === 'number'} |
|
||||
<Input |
|
||||
label={capitalise(schema[field].name)} |
|
||||
type="number" |
|
||||
bind:value={record[field]} /> |
|
||||
{:else if schema[field].type === 'string'} |
|
||||
<Input |
|
||||
label={capitalise(schema[field].name)} |
|
||||
bind:value={record[field]} /> |
|
||||
{:else if schema[field].type === 'attachment'} |
|
||||
<div> |
|
||||
<Label extraSmall grey>{schema[field].name}</Label> |
|
||||
<Dropzone bind:files={record[field]} /> |
|
||||
</div> |
|
||||
{:else if schema[field].type === 'link'} |
|
||||
<LinkedRecordSelector |
|
||||
secondary |
|
||||
bind:linkedRecords={record[field]} |
|
||||
schema={schema[field]} /> |
|
||||
{/if} |
|
||||
{/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 { |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
align-items: center; |
|
||||
} |
|
||||
|
|
||||
.form-content { |
|
||||
margin-bottom: var(--spacing-xl); |
|
||||
display: grid; |
|
||||
gap: var(--spacing-xl); |
|
||||
width: 100%; |
|
||||
} |
|
||||
|
|
||||
.buttons { |
|
||||
display: flex; |
|
||||
justify-content: flex-end; |
|
||||
} |
|
||||
</style> |
|
||||
|
|||||
@ -1,252 +1,10 @@ |
|||||
<script> |
<script> |
||||
import { onMount } from "svelte" |
import Form from "./Form.svelte" |
||||
import { fade } from "svelte/transition" |
|
||||
import { Label, DatePicker } from "@budibase/bbui" |
|
||||
import debounce from "lodash.debounce" |
|
||||
|
|
||||
export let _bb |
export let _bb |
||||
export let model |
export let model |
||||
export let title |
export let title |
||||
export let buttonText |
export let buttonText |
||||
|
|
||||
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 = {} |
|
||||
|
|
||||
$: if (model && model.length !== 0) { |
|
||||
fetchModel() |
|
||||
} |
|
||||
|
|
||||
$: fields = schema ? Object.keys(schema) : [] |
|
||||
|
|
||||
$: errorMessages = Object.entries(errors).map( |
|
||||
([field, message]) => `${field} ${message}` |
|
||||
) |
|
||||
|
|
||||
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 |
|
||||
}, 1000) |
|
||||
} |
|
||||
|
|
||||
if (response.status === 400) { |
|
||||
errors = json.errors |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
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) |
|
||||
const json = await response.json() |
|
||||
record = json |
|
||||
}) |
|
||||
</script> |
</script> |
||||
|
|
||||
<form class="form" on:submit|preventDefault> |
<Form {_bb} {model} {title} {buttonText} wide={true} /> |
||||
{#if title} |
|
||||
<h1>{title}</h1> |
|
||||
{/if} |
|
||||
{#each errorMessages as error} |
|
||||
<p class="error">{error}</p> |
|
||||
{/each} |
|
||||
<hr /> |
|
||||
<div class="form-content"> |
|
||||
{#each fields as field} |
|
||||
<div class="form-item"> |
|
||||
<Label small forAttr={'form-stacked-text'}>{field}</Label> |
|
||||
{#if schema[field].type === 'string' && schema[field].constraints.inclusion} |
|
||||
<select bind:value={record[field]}> |
|
||||
{#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'} |
|
||||
<input class="input" type="checkbox" bind:checked={record[field]} /> |
|
||||
{:else if schema[field].type === 'number'} |
|
||||
<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} |
|
||||
</div> |
|
||||
<hr /> |
|
||||
{/each} |
|
||||
<div class="button-block"> |
|
||||
<button on:click={save} class:saved> |
|
||||
{#if saved} |
|
||||
<div in:fade> |
|
||||
<span class:saved>Success</span> |
|
||||
</div> |
|
||||
{:else} |
|
||||
<div>{buttonText || 'Submit Form'}</div> |
|
||||
{/if} |
|
||||
</button> |
|
||||
</div> |
|
||||
</div> |
|
||||
</form> |
|
||||
|
|
||||
<style> |
|
||||
.form { |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
justify-content: center; |
|
||||
margin: auto; |
|
||||
padding: 40px; |
|
||||
} |
|
||||
.form-content { |
|
||||
margin-bottom: 20px; |
|
||||
} |
|
||||
.input { |
|
||||
border-radius: 5px; |
|
||||
border: 1px solid #e6e6e6; |
|
||||
padding: 1em; |
|
||||
font-size: 16px; |
|
||||
} |
|
||||
|
|
||||
.form-item { |
|
||||
display: grid; |
|
||||
grid-template-columns: 30% 1fr; |
|
||||
margin-bottom: 16px; |
|
||||
align-items: center; |
|
||||
gap: 1em; |
|
||||
} |
|
||||
|
|
||||
hr { |
|
||||
border: 1px solid #f5f5f5; |
|
||||
margin: 40px 0px; |
|
||||
} |
|
||||
hr:nth-last-child(2) { |
|
||||
border: 1px solid #f5f5f5; |
|
||||
margin: 40px 0px; |
|
||||
} |
|
||||
.button-block { |
|
||||
display: flex; |
|
||||
justify-content: flex-end; |
|
||||
} |
|
||||
button { |
|
||||
font-size: 16px; |
|
||||
padding: 8px 16px; |
|
||||
box-sizing: border-box; |
|
||||
border-radius: 4px; |
|
||||
color: white; |
|
||||
background-color: black; |
|
||||
outline: none; |
|
||||
height: 40px; |
|
||||
cursor: pointer; |
|
||||
transition: all 0.2s ease 0s; |
|
||||
overflow: hidden; |
|
||||
outline: none; |
|
||||
user-select: none; |
|
||||
white-space: nowrap; |
|
||||
text-align: center; |
|
||||
} |
|
||||
|
|
||||
button.saved { |
|
||||
background-color: #84c991; |
|
||||
border: none; |
|
||||
} |
|
||||
|
|
||||
button:hover { |
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), |
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05); |
|
||||
} |
|
||||
|
|
||||
input[type="checkbox"] { |
|
||||
transform: scale(2); |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
|
|
||||
select::-ms-expand { |
|
||||
display: none; |
|
||||
} |
|
||||
select { |
|
||||
cursor: pointer; |
|
||||
display: inline-block; |
|
||||
align-items: baseline; |
|
||||
box-sizing: border-box; |
|
||||
padding: 1em 1em; |
|
||||
border: 1px solid #eaeaea; |
|
||||
border-radius: 5px; |
|
||||
font: inherit; |
|
||||
line-height: inherit; |
|
||||
-webkit-appearance: none; |
|
||||
-moz-appearance: none; |
|
||||
-ms-appearance: none; |
|
||||
appearance: none; |
|
||||
background-repeat: no-repeat; |
|
||||
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), |
|
||||
linear-gradient(135deg, currentColor 50%, transparent 50%); |
|
||||
background-position: right 17px top 1.5em, right 10px top 1.5em; |
|
||||
background-size: 7px 7px, 7px 7px; |
|
||||
} |
|
||||
|
|
||||
.error { |
|
||||
color: red; |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
</style> |
|
||||
|
|||||
@ -0,0 +1,197 @@ |
|||||
|
<script> |
||||
|
import { onMount } from "svelte" |
||||
|
import { fade } from "svelte/transition" |
||||
|
import { |
||||
|
Label, |
||||
|
DatePicker, |
||||
|
Input, |
||||
|
Select, |
||||
|
Button, |
||||
|
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 = {} |
||||
|
|
||||
|
$: 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> |
||||
|
</div> |
||||
|
</form> |
||||
|
|
||||
|
<style> |
||||
|
.form { |
||||
|
display: flex; |
||||
|
flex-direction: column; |
||||
|
align-items: center; |
||||
|
} |
||||
|
|
||||
|
.form-content { |
||||
|
margin-bottom: var(--spacing-xl); |
||||
|
display: grid; |
||||
|
gap: var(--spacing-xl); |
||||
|
width: 100%; |
||||
|
} |
||||
|
|
||||
|
.form-field { |
||||
|
display: grid; |
||||
|
} |
||||
|
.form-field.wide { |
||||
|
align-items: center; |
||||
|
grid-template-columns: 30% 1fr; |
||||
|
gap: var(--spacing-xl); |
||||
|
} |
||||
|
.form-field.wide :global(label) { |
||||
|
margin-bottom: 0; |
||||
|
} |
||||
|
|
||||
|
.buttons { |
||||
|
display: flex; |
||||
|
justify-content: flex-end; |
||||
|
} |
||||
|
</style> |
||||
Loading…
Reference in new issue