mirror of https://github.com/Budibase/budibase.git
committed by
GitHub
163 changed files with 8484 additions and 5560 deletions
@ -1,127 +0,0 @@ |
|||
<script> |
|||
import { keys, map, includes, filter } from "lodash/fp" |
|||
import EventEditorModal from "./EventEditorModal.svelte" |
|||
import { Modal } from "@budibase/bbui" |
|||
|
|||
export const EVENT_TYPE = "event" |
|||
export let component |
|||
|
|||
let events = [] |
|||
let selectedEvent = null |
|||
let modal |
|||
|
|||
$: { |
|||
events = Object.keys(component) |
|||
// TODO: use real events |
|||
.filter(propName => ["onChange", "onClick", "onLoad"].includes(propName)) |
|||
.map(propName => ({ |
|||
name: propName, |
|||
handlers: component[propName] || [], |
|||
})) |
|||
} |
|||
|
|||
const openModal = event => { |
|||
selectedEvent = event |
|||
modal.show() |
|||
} |
|||
</script> |
|||
|
|||
<button class="newevent" on:click={() => openModal()}> |
|||
<i class="icon ri-add-circle-fill" /> |
|||
Create New Event |
|||
</button> |
|||
|
|||
<div class="root"> |
|||
<form on:submit|preventDefault class="form-root"> |
|||
{#each events as event, index} |
|||
{#if event.handlers.length > 0} |
|||
<div |
|||
class:selected={selectedEvent && selectedEvent.index === index} |
|||
class="handler-container budibase__nav-item" |
|||
on:click={() => openModal({ ...event, index })}> |
|||
<span class="event-name">{event.name}</span> |
|||
<span class="edit-text">EDIT</span> |
|||
</div> |
|||
{/if} |
|||
{/each} |
|||
</form> |
|||
</div> |
|||
|
|||
<Modal bind:this={modal} width="600px"> |
|||
<EventEditorModal eventOptions={events} event={selectedEvent} /> |
|||
</Modal> |
|||
|
|||
<style> |
|||
.root { |
|||
font-size: 10pt; |
|||
width: 100%; |
|||
} |
|||
|
|||
.newevent { |
|||
cursor: pointer; |
|||
border: 1px solid var(--grey-4); |
|||
border-radius: 3px; |
|||
width: 100%; |
|||
padding: 8px 16px; |
|||
margin: 0px 0px 12px 0px; |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
background: var(--background); |
|||
color: var(--ink); |
|||
font-size: 14px; |
|||
font-weight: 500; |
|||
transition: all 2ms; |
|||
} |
|||
|
|||
.newevent:hover { |
|||
background: var(--grey-1); |
|||
} |
|||
|
|||
.icon { |
|||
color: var(--ink); |
|||
font-size: 16px; |
|||
margin-right: 4px; |
|||
} |
|||
|
|||
.form-root { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.handler-container { |
|||
display: grid; |
|||
grid-template-columns: repeat(2, 1fr); |
|||
border: 2px solid var(--grey-1); |
|||
height: 80px; |
|||
width: 100%; |
|||
} |
|||
|
|||
.event-name { |
|||
margin-top: 5px; |
|||
font-weight: bold; |
|||
font-size: 16px; |
|||
color: rgba(22, 48, 87, 0.6); |
|||
align-self: end; |
|||
} |
|||
|
|||
.edit-text { |
|||
font-family: Arial, Helvetica, sans-serif; |
|||
font-weight: bold; |
|||
align-self: end; |
|||
justify-self: end; |
|||
font-size: 10px; |
|||
color: rgba(35, 65, 105, 0.4); |
|||
} |
|||
|
|||
.selected { |
|||
color: var(--blue); |
|||
background: var(--grey-1) !important; |
|||
} |
|||
</style> |
|||
@ -1,53 +0,0 @@ |
|||
<script> |
|||
import { Input, DataList, Select } from "@budibase/bbui" |
|||
import { automationStore, allScreens } from "builderStore" |
|||
|
|||
export let parameter |
|||
|
|||
let isOpen = false |
|||
|
|||
const capitalize = s => { |
|||
if (typeof s !== "string") return "" |
|||
return s.charAt(0).toUpperCase() + s.slice(1) |
|||
} |
|||
</script> |
|||
|
|||
<div class="handler-option"> |
|||
{#if parameter.name === 'automation'}<span>{parameter.name}</span>{/if} |
|||
{#if parameter.name === 'automation'} |
|||
<Select on:change bind:value={parameter.value}> |
|||
<option value="" /> |
|||
{#each $automationStore.automations.filter(wf => wf.live) as automation} |
|||
<option value={automation._id}>{automation.name}</option> |
|||
{/each} |
|||
</Select> |
|||
{:else if parameter.name === 'url'} |
|||
<DataList on:change bind:value={parameter.value}> |
|||
<option value="" /> |
|||
{#each $allScreens as screen} |
|||
<option value={screen.routing.route}> |
|||
{screen.props._instanceName} |
|||
</option> |
|||
{/each} |
|||
</DataList> |
|||
{:else} |
|||
<Input |
|||
name={parameter.name} |
|||
label={capitalize(parameter.name)} |
|||
on:change |
|||
value={parameter.value} /> |
|||
{/if} |
|||
</div> |
|||
|
|||
<style> |
|||
.handler-option { |
|||
display: flex; |
|||
flex-direction: column; |
|||
} |
|||
|
|||
span { |
|||
font-size: 18px; |
|||
margin-bottom: 10px; |
|||
font-weight: 500; |
|||
} |
|||
</style> |
|||
File diff suppressed because it is too large
@ -1,6 +1,5 @@ |
|||
.DS_Store |
|||
node_modules |
|||
package-lock.json |
|||
yarn.lock |
|||
release/ |
|||
dist/ |
|||
@ -1,13 +0,0 @@ |
|||
module.exports = { |
|||
presets: ["@babel/preset-env"], |
|||
sourceMaps: "inline", |
|||
retainLines: true, |
|||
plugins: [ |
|||
[ |
|||
"@babel/plugin-transform-runtime", |
|||
{ |
|||
regenerator: true, |
|||
}, |
|||
], |
|||
], |
|||
} |
|||
@ -0,0 +1,96 @@ |
|||
import { getAppId } from "../utils/getAppId" |
|||
|
|||
/** |
|||
* API cache for cached request responses. |
|||
*/ |
|||
let cache = {} |
|||
|
|||
/** |
|||
* Makes a fully formatted URL based on the SDK configuration. |
|||
*/ |
|||
const makeFullURL = path => { |
|||
return `/${path}`.replace("//", "/") |
|||
} |
|||
|
|||
/** |
|||
* Handler for API errors. |
|||
*/ |
|||
const handleError = error => { |
|||
return { error } |
|||
} |
|||
|
|||
/** |
|||
* Performs an API call to the server. |
|||
* App ID header is always correctly set. |
|||
*/ |
|||
const makeApiCall = async ({ method, url, body, json = true }) => { |
|||
try { |
|||
const requestBody = json ? JSON.stringify(body) : body |
|||
let headers = { |
|||
Accept: "application/json", |
|||
"Content-Type": "application/json", |
|||
"x-budibase-app-id": getAppId(), |
|||
} |
|||
if (!window["##BUDIBASE_IN_BUILDER##"]) { |
|||
headers["x-budibase-type"] = "client" |
|||
} |
|||
const response = await fetch(url, { |
|||
method, |
|||
headers, |
|||
body: requestBody, |
|||
credentials: "same-origin", |
|||
}) |
|||
switch (response.status) { |
|||
case 200: |
|||
return response.json() |
|||
case 404: |
|||
return handleError(`${url}: Not Found`) |
|||
case 400: |
|||
return handleError(`${url}: Bad Request`) |
|||
case 403: |
|||
return handleError(`${url}: Forbidden`) |
|||
default: |
|||
if (response.status >= 200 && response.status < 400) { |
|||
return response.json() |
|||
} |
|||
return handleError(`${url} - ${response.statusText}`) |
|||
} |
|||
} catch (error) { |
|||
return handleError(error) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Performs an API call to the server and caches the response. |
|||
* Future invocation for this URL will return the cached result instead of |
|||
* hitting the server again. |
|||
*/ |
|||
const makeCachedApiCall = async params => { |
|||
const identifier = params.url |
|||
if (!identifier) { |
|||
return null |
|||
} |
|||
if (!cache[identifier]) { |
|||
cache[identifier] = makeApiCall(params) |
|||
cache[identifier] = await cache[identifier] |
|||
} |
|||
return await cache[identifier] |
|||
} |
|||
|
|||
/** |
|||
* Constructs an API call function for a particular HTTP method. |
|||
*/ |
|||
const requestApiCall = method => async params => { |
|||
const { url, cache = false } = params |
|||
const fullURL = makeFullURL(url) |
|||
const enrichedParams = { ...params, method, url: fullURL } |
|||
return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams) |
|||
} |
|||
|
|||
export default { |
|||
post: requestApiCall("POST"), |
|||
get: requestApiCall("GET"), |
|||
patch: requestApiCall("PATCH"), |
|||
del: requestApiCall("DELETE"), |
|||
error: handleError, |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import API from "./api" |
|||
|
|||
/** |
|||
* Fetches screen definition for an app. |
|||
*/ |
|||
export const fetchAppDefinition = async appId => { |
|||
return await API.get({ |
|||
url: `/api/applications/${appId}/definition`, |
|||
}) |
|||
} |
|||
@ -0,0 +1,12 @@ |
|||
import API from "./api" |
|||
|
|||
/** |
|||
* Uploads an attachment to the server. |
|||
*/ |
|||
export const uploadAttachment = async data => { |
|||
return await API.post({ |
|||
url: "/api/attachments/upload", |
|||
body: data, |
|||
json: false, |
|||
}) |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
import API from "./api" |
|||
|
|||
/** |
|||
* Performs a log in request. |
|||
*/ |
|||
export const logIn = async ({ username, password }) => { |
|||
if (!username) { |
|||
return API.error("Please enter your username") |
|||
} |
|||
if (!password) { |
|||
return API.error("Please enter your password") |
|||
} |
|||
return await API.post({ |
|||
url: "/api/authenticate", |
|||
body: { username, password }, |
|||
}) |
|||
} |
|||
@ -1,28 +0,0 @@ |
|||
import appStore from "../state/store" |
|||
|
|||
export const USER_STATE_PATH = "_bbuser" |
|||
|
|||
export const authenticate = api => async ({ username, password }) => { |
|||
if (!username) { |
|||
api.error("Authenticate: username not set") |
|||
return |
|||
} |
|||
|
|||
if (!password) { |
|||
api.error("Authenticate: password not set") |
|||
return |
|||
} |
|||
|
|||
const user = await api.post({ |
|||
url: "/api/authenticate", |
|||
body: { username, password }, |
|||
}) |
|||
|
|||
// set user even if error - so it is defined at least
|
|||
appStore.update(s => { |
|||
s[USER_STATE_PATH] = user |
|||
return s |
|||
}) |
|||
|
|||
localStorage.setItem("budibase:user", JSON.stringify(user)) |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
import { fetchTableData } from "./tables" |
|||
import { fetchViewData } from "./views" |
|||
import { fetchRelationshipData } from "./relationships" |
|||
import { enrichRows } from "./rows" |
|||
|
|||
/** |
|||
* Fetches all rows for a particular Budibase data source. |
|||
*/ |
|||
export const fetchDatasource = async (datasource, dataContext) => { |
|||
if (!datasource || !datasource.type) { |
|||
return [] |
|||
} |
|||
|
|||
// Fetch all rows in data source
|
|||
const { type, tableId, fieldName } = datasource |
|||
let rows = [] |
|||
if (type === "table") { |
|||
rows = await fetchTableData(tableId) |
|||
} else if (type === "view") { |
|||
rows = await fetchViewData(datasource) |
|||
} else if (type === "link") { |
|||
const row = dataContext[datasource.providerId] |
|||
rows = await fetchRelationshipData({ |
|||
rowId: row?._id, |
|||
tableId: row?.tableId, |
|||
fieldName, |
|||
}) |
|||
} |
|||
|
|||
// Enrich rows
|
|||
return await enrichRows(rows, tableId) |
|||
} |
|||
@ -1,120 +1,9 @@ |
|||
import { authenticate } from "./authenticate" |
|||
import { getAppId } from "../render/getAppId" |
|||
|
|||
export async function baseApiCall(method, url, body) { |
|||
return await fetch(url, { |
|||
method: method, |
|||
headers: { |
|||
"Content-Type": "application/json", |
|||
"x-budibase-app-id": getAppId(window.document.cookie), |
|||
"x-budibase-type": "client", |
|||
}, |
|||
body: body && JSON.stringify(body), |
|||
credentials: "same-origin", |
|||
}) |
|||
} |
|||
|
|||
const apiCall = method => async ({ url, body }) => { |
|||
const response = await baseApiCall(method, url, body) |
|||
|
|||
switch (response.status) { |
|||
case 200: |
|||
return response.json() |
|||
case 404: |
|||
return error(`${url} Not found`) |
|||
case 400: |
|||
return error(`${url} Bad Request`) |
|||
case 403: |
|||
return error(`${url} Forbidden`) |
|||
default: |
|||
if (response.status >= 200 && response.status < 400) { |
|||
return response.json() |
|||
} |
|||
|
|||
return error(`${url} - ${response.statusText}`) |
|||
} |
|||
} |
|||
|
|||
const post = apiCall("POST") |
|||
const get = apiCall("GET") |
|||
const patch = apiCall("PATCH") |
|||
const del = apiCall("DELETE") |
|||
|
|||
const ERROR_MEMBER = "##error" |
|||
const error = message => { |
|||
// appStore.update(s => s["##error_message"], message)
|
|||
return { [ERROR_MEMBER]: message } |
|||
} |
|||
|
|||
const isSuccess = obj => !obj || !obj[ERROR_MEMBER] |
|||
|
|||
const apiOpts = { |
|||
isSuccess, |
|||
error, |
|||
post, |
|||
get, |
|||
patch, |
|||
delete: del, |
|||
} |
|||
|
|||
const saveRow = async (params, state) => |
|||
await post({ |
|||
url: `/api/${params.tableId}/rows`, |
|||
body: makeRowRequestBody(params, state), |
|||
}) |
|||
|
|||
const updateRow = async (params, state) => { |
|||
const row = makeRowRequestBody(params, state) |
|||
row._id = params._id |
|||
await patch({ |
|||
url: `/api/${params.tableId}/rows/${params._id}`, |
|||
body: row, |
|||
}) |
|||
} |
|||
|
|||
const deleteRow = async params => |
|||
await del({ |
|||
url: `/api/${params.tableId}/rows/${params.rowId}/${params.revId}`, |
|||
}) |
|||
|
|||
const makeRowRequestBody = (parameters, state) => { |
|||
// start with the row thats currently in context
|
|||
const body = { ...(state.data || {}) } |
|||
|
|||
// dont send the table
|
|||
if (body._table) delete body._table |
|||
|
|||
// then override with supplied parameters
|
|||
if (parameters.fields) { |
|||
for (let fieldName of Object.keys(parameters.fields)) { |
|||
const field = parameters.fields[fieldName] |
|||
|
|||
// ensure fields sent are of the correct type
|
|||
if (field.type === "boolean") { |
|||
if (field.value === "true") body[fieldName] = true |
|||
if (field.value === "false") body[fieldName] = false |
|||
} else if (field.type === "number") { |
|||
const val = parseFloat(field.value) |
|||
if (!isNaN(val)) { |
|||
body[fieldName] = val |
|||
} |
|||
} else if (field.type === "datetime") { |
|||
const date = new Date(field.value) |
|||
if (!isNaN(date.getTime())) { |
|||
body[fieldName] = date.toISOString() |
|||
} |
|||
} else { |
|||
body[fieldName] = field.value |
|||
} |
|||
} |
|||
} |
|||
|
|||
return body |
|||
} |
|||
|
|||
export default { |
|||
authenticate: authenticate(apiOpts), |
|||
saveRow, |
|||
updateRow, |
|||
deleteRow, |
|||
} |
|||
export * from "./rows" |
|||
export * from "./auth" |
|||
export * from "./datasources" |
|||
export * from "./tables" |
|||
export * from "./attachments" |
|||
export * from "./views" |
|||
export * from "./relationships" |
|||
export * from "./routes" |
|||
export * from "./app" |
|||
|
|||
@ -0,0 +1,14 @@ |
|||
import API from "./api" |
|||
import { enrichRows } from "./rows" |
|||
|
|||
/** |
|||
* Fetches related rows for a certain field of a certain row. |
|||
*/ |
|||
export const fetchRelationshipData = async ({ tableId, rowId, fieldName }) => { |
|||
if (!tableId || !rowId || !fieldName) { |
|||
return [] |
|||
} |
|||
const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` }) |
|||
const rows = response[fieldName] || [] |
|||
return await enrichRows(rows, tableId) |
|||
} |
|||
@ -0,0 +1,10 @@ |
|||
import API from "./api" |
|||
|
|||
/** |
|||
* Fetches available routes for the client app. |
|||
*/ |
|||
export const fetchRoutes = async () => { |
|||
return await API.get({ |
|||
url: `/api/routing/client`, |
|||
}) |
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
import API from "./api" |
|||
import { fetchTableDefinition } from "./tables" |
|||
|
|||
/** |
|||
* Fetches data about a certain row in a table. |
|||
*/ |
|||
export const fetchRow = async ({ tableId, rowId }) => { |
|||
const row = await API.get({ |
|||
url: `/api/${tableId}/rows/${rowId}`, |
|||
}) |
|||
return (await enrichRows([row], tableId))[0] |
|||
} |
|||
|
|||
/** |
|||
* Creates a row in a table. |
|||
*/ |
|||
export const saveRow = async row => { |
|||
return await API.post({ |
|||
url: `/api/${row.tableId}/rows`, |
|||
body: row, |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Updates a row in a table. |
|||
*/ |
|||
export const updateRow = async row => { |
|||
return await API.patch({ |
|||
url: `/api/${row.tableId}/rows/${row._id}`, |
|||
body: row, |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Deletes a row from a table. |
|||
*/ |
|||
export const deleteRow = async ({ tableId, rowId, revId }) => { |
|||
return await API.del({ |
|||
url: `/api/${tableId}/rows/${rowId}/${revId}`, |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Deletes many rows from a table. |
|||
*/ |
|||
export const deleteRows = async ({ tableId, rows }) => { |
|||
return await API.post({ |
|||
url: `/api/${tableId}/rows`, |
|||
body: { |
|||
rows, |
|||
type: "delete", |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
/** |
|||
* Enriches rows which contain certain field types so that they can |
|||
* be properly displayed. |
|||
*/ |
|||
export const enrichRows = async (rows, tableId) => { |
|||
if (rows && rows.length && tableId) { |
|||
// Fetch table schema so we can check column types
|
|||
const tableDefinition = await fetchTableDefinition(tableId) |
|||
const schema = tableDefinition && tableDefinition.schema |
|||
if (schema) { |
|||
const keys = Object.keys(schema) |
|||
rows.forEach(row => { |
|||
for (let key of keys) { |
|||
const type = schema[key].type |
|||
if (type === "link") { |
|||
// Enrich row with the count of any relationship fields
|
|||
row[`${key}_count`] = Array.isArray(row[key]) ? row[key].length : 0 |
|||
} else if (type === "attachment") { |
|||
// Enrich row with the first image URL for any attachment fields
|
|||
let url = null |
|||
if (Array.isArray(row[key]) && row[key][0] != null) { |
|||
url = row[key][0].url |
|||
} |
|||
row[`${key}_first`] = url |
|||
} |
|||
} |
|||
}) |
|||
} |
|||
} |
|||
return rows |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
import API from "./api" |
|||
import { enrichRows } from "./rows" |
|||
|
|||
/** |
|||
* Fetches a table definition. |
|||
* Since definitions cannot change at runtime, the result is cached. |
|||
*/ |
|||
export const fetchTableDefinition = async tableId => { |
|||
return await API.get({ url: `/api/tables/${tableId}`, cache: true }) |
|||
} |
|||
|
|||
/** |
|||
* Fetches all rows from a table. |
|||
*/ |
|||
export const fetchTableData = async tableId => { |
|||
const rows = await API.get({ url: `/api/${tableId}/rows` }) |
|||
return await enrichRows(rows, tableId) |
|||
} |
|||
@ -0,0 +1,30 @@ |
|||
import API from "./api" |
|||
import { enrichRows } from "./rows" |
|||
|
|||
/** |
|||
* Fetches all rows in a view. |
|||
*/ |
|||
export const fetchViewData = async ({ |
|||
name, |
|||
field, |
|||
groupBy, |
|||
calculation, |
|||
tableId, |
|||
}) => { |
|||
const params = new URLSearchParams() |
|||
|
|||
if (calculation) { |
|||
params.set("field", field) |
|||
params.set("calculation", calculation) |
|||
} |
|||
if (groupBy) { |
|||
params.set("group", groupBy) |
|||
} |
|||
|
|||
const QUERY_VIEW_URL = field |
|||
? `/api/views/${name}?${params}` |
|||
: `/api/views/${name}` |
|||
|
|||
const rows = await API.get({ url: QUERY_VIEW_URL }) |
|||
return await enrichRows(rows, tableId) |
|||
} |
|||
@ -0,0 +1,25 @@ |
|||
<script> |
|||
import { writable } from "svelte/store" |
|||
import { setContext, onMount } from "svelte" |
|||
import Component from "./Component.svelte" |
|||
import SDK from "../sdk" |
|||
import { createDataStore, routeStore, screenStore } from "../store" |
|||
|
|||
// Provide contexts |
|||
setContext("sdk", SDK) |
|||
setContext("component", writable({})) |
|||
setContext("data", createDataStore()) |
|||
|
|||
let loaded = false |
|||
|
|||
// Load app config |
|||
onMount(async () => { |
|||
await routeStore.actions.fetchRoutes() |
|||
await screenStore.actions.fetchScreens() |
|||
loaded = true |
|||
}) |
|||
</script> |
|||
|
|||
{#if loaded} |
|||
<Component definition={$screenStore.page.props} /> |
|||
{/if} |
|||
@ -0,0 +1,52 @@ |
|||
<script> |
|||
import { getContext, setContext } from "svelte" |
|||
import { writable } from "svelte/store" |
|||
import * as ComponentLibrary from "@budibase/standard-components" |
|||
import Router from "./Router.svelte" |
|||
import { enrichProps } from "../utils/componentProps" |
|||
import { bindingStore, builderStore } from "../store" |
|||
|
|||
export let definition = {} |
|||
|
|||
// Get local data binding context |
|||
const dataContext = getContext("data") |
|||
|
|||
// Create component context |
|||
const componentStore = writable({}) |
|||
setContext("component", componentStore) |
|||
|
|||
// Extract component definition info |
|||
$: constructor = getComponentConstructor(definition._component) |
|||
$: children = definition._children |
|||
$: id = definition._id |
|||
$: enrichedProps = enrichProps(definition, $dataContext, $bindingStore) |
|||
$: selected = id === $builderStore.selectedComponentId |
|||
|
|||
// Update component context |
|||
$: componentStore.set({ id, styles: { ...definition._styles, selected } }) |
|||
|
|||
// Gets the component constructor for the specified component |
|||
const getComponentConstructor = component => { |
|||
const split = component?.split("/") |
|||
const name = split?.[split.length - 1] |
|||
return name === "screenslot" ? Router : ComponentLibrary[name] |
|||
} |
|||
|
|||
// Returns a unique key to let svelte know when to remount components. |
|||
// If a component is selected we want to remount it every time any props |
|||
// change. |
|||
const getChildKey = childId => { |
|||
const selected = childId === $builderStore.selectedComponentId |
|||
return selected ? `${childId}-${$builderStore.previewId}` : childId |
|||
} |
|||
</script> |
|||
|
|||
{#if constructor} |
|||
<svelte:component this={constructor} {...enrichedProps}> |
|||
{#if children && children.length} |
|||
{#each children as child (getChildKey(child._id))} |
|||
<svelte:self definition={child} /> |
|||
{/each} |
|||
{/if} |
|||
</svelte:component> |
|||
{/if} |
|||
@ -0,0 +1,15 @@ |
|||
<script> |
|||
import { getContext, setContext } from "svelte" |
|||
import { createDataStore } from "../store" |
|||
|
|||
export let row |
|||
|
|||
// Clone and create new data context for this component tree |
|||
const dataContext = getContext("data") |
|||
const component = getContext("component") |
|||
const newData = createDataStore($dataContext) |
|||
setContext("data", newData) |
|||
$: newData.actions.addContext(row, $component.id) |
|||
</script> |
|||
|
|||
<slot /> |
|||
@ -0,0 +1,38 @@ |
|||
<script> |
|||
import { getContext } from "svelte" |
|||
import Router from "svelte-spa-router" |
|||
import { routeStore } from "../store" |
|||
import Screen from "./Screen.svelte" |
|||
|
|||
const { styleable } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
$: routerConfig = getRouterConfig($routeStore.routes) |
|||
|
|||
const getRouterConfig = routes => { |
|||
let config = {} |
|||
routes.forEach(route => { |
|||
config[route.path] = Screen |
|||
}) |
|||
|
|||
// Add catch-all route so that we serve the Screen component always |
|||
config["*"] = Screen |
|||
return config |
|||
} |
|||
|
|||
const onRouteLoading = ({ detail }) => { |
|||
routeStore.actions.setActiveRoute(detail.route) |
|||
} |
|||
</script> |
|||
|
|||
{#if routerConfig} |
|||
<div use:styleable={$component.styles}> |
|||
<Router on:routeLoading={onRouteLoading} routes={routerConfig} /> |
|||
</div> |
|||
{/if} |
|||
|
|||
<style> |
|||
div { |
|||
position: relative; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,35 @@ |
|||
<script> |
|||
import { fade } from "svelte/transition" |
|||
import { screenStore, routeStore } from "../store" |
|||
import Component from "./Component.svelte" |
|||
|
|||
// Keep route params up to date |
|||
export let params = {} |
|||
$: routeStore.actions.setRouteParams(params || {}) |
|||
|
|||
// Get the screen definition for the current route |
|||
$: screenDefinition = $screenStore.activeScreen?.props |
|||
|
|||
// Redirect to home page if no matching route |
|||
$: screenDefinition == null && routeStore.actions.navigate("/") |
|||
|
|||
// Make a screen array so we can use keying to properly re-render each screen |
|||
$: screens = screenDefinition ? [screenDefinition] : [] |
|||
</script> |
|||
|
|||
{#each screens as screen (screen._id)} |
|||
<div in:fade> |
|||
<Component definition={screen} /> |
|||
</div> |
|||
{/each} |
|||
|
|||
<style> |
|||
div { |
|||
flex: 1 1 auto; |
|||
align-self: stretch; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
align-items: stretch; |
|||
} |
|||
</style> |
|||
@ -1,91 +0,0 @@ |
|||
import { attachChildren } from "./render/attachChildren" |
|||
import { createTreeNode } from "./render/prepareRenderComponent" |
|||
import { screenRouter } from "./render/screenRouter" |
|||
import { createStateManager } from "./state/stateManager" |
|||
import { getAppId } from "./render/getAppId" |
|||
|
|||
export const createApp = ({ |
|||
componentLibraries, |
|||
frontendDefinition, |
|||
window, |
|||
}) => { |
|||
let routeTo |
|||
let currentUrl |
|||
let screenStateManager |
|||
|
|||
const onScreenSlotRendered = screenSlotNode => { |
|||
const onScreenSelected = (screen, url) => { |
|||
const stateManager = createStateManager({ |
|||
componentLibraries, |
|||
onScreenSlotRendered: () => {}, |
|||
routeTo, |
|||
}) |
|||
const getAttachChildrenParams = attachChildrenParams(stateManager) |
|||
screenSlotNode.props._children = [screen.props] |
|||
const initialiseChildParams = getAttachChildrenParams(screenSlotNode) |
|||
attachChildren(initialiseChildParams)(screenSlotNode.rootElement, { |
|||
hydrate: true, |
|||
force: true, |
|||
}) |
|||
if (screenStateManager) screenStateManager.destroy() |
|||
screenStateManager = stateManager |
|||
currentUrl = url |
|||
} |
|||
|
|||
routeTo = screenRouter({ |
|||
screens: frontendDefinition.screens, |
|||
onScreenSelected, |
|||
window, |
|||
}) |
|||
const fallbackPath = window.location.pathname.replace( |
|||
getAppId(window.document.cookie), |
|||
"" |
|||
) |
|||
routeTo(currentUrl || fallbackPath) |
|||
} |
|||
|
|||
const attachChildrenParams = stateManager => { |
|||
const getInitialiseParams = treeNode => ({ |
|||
componentLibraries, |
|||
treeNode, |
|||
onScreenSlotRendered, |
|||
setupState: stateManager.setup, |
|||
}) |
|||
|
|||
return getInitialiseParams |
|||
} |
|||
|
|||
let rootTreeNode |
|||
const pageStateManager = createStateManager({ |
|||
componentLibraries, |
|||
onScreenSlotRendered, |
|||
// seems weird, but the routeTo variable may not be available at this point
|
|||
routeTo: url => routeTo(url), |
|||
}) |
|||
|
|||
const initialisePage = (page, target, urlPath) => { |
|||
currentUrl = urlPath |
|||
|
|||
rootTreeNode = createTreeNode() |
|||
rootTreeNode.props = { |
|||
_children: [page.props], |
|||
} |
|||
const getInitialiseParams = attachChildrenParams(pageStateManager) |
|||
const initChildParams = getInitialiseParams(rootTreeNode) |
|||
|
|||
attachChildren(initChildParams)(target, { |
|||
hydrate: true, |
|||
force: true, |
|||
}) |
|||
|
|||
return rootTreeNode |
|||
} |
|||
|
|||
return { |
|||
initialisePage, |
|||
screenStore: () => screenStateManager.store, |
|||
pageStore: () => pageStateManager.store, |
|||
routeTo: () => routeTo, |
|||
rootNode: () => rootTreeNode, |
|||
} |
|||
} |
|||
@ -1,59 +1,25 @@ |
|||
import { createApp } from "./createApp" |
|||
import { builtins, builtinLibName } from "./render/builtinComponents" |
|||
import { getAppId } from "./render/getAppId" |
|||
|
|||
/** |
|||
* create a web application from static budibase definition files. |
|||
* @param {object} opts - configuration options for budibase client libary |
|||
*/ |
|||
export const loadBudibase = async opts => { |
|||
const _window = (opts && opts.window) || window |
|||
// const _localStorage = (opts && opts.localStorage) || localStorage
|
|||
const appId = getAppId(window.document.cookie) |
|||
const frontendDefinition = _window["##BUDIBASE_FRONTEND_DEFINITION##"] |
|||
|
|||
const user = {} |
|||
|
|||
const componentLibraryModules = (opts && opts.componentLibraries) || {} |
|||
|
|||
const libraries = frontendDefinition.libraries || [] |
|||
|
|||
for (let library of libraries) { |
|||
// fetch the JavaScript for the component libraries from the server
|
|||
componentLibraryModules[library] = await import( |
|||
`/componentlibrary?library=${encodeURI(library)}&appId=${appId}` |
|||
) |
|||
} |
|||
|
|||
componentLibraryModules[builtinLibName] = builtins(_window) |
|||
|
|||
const { |
|||
initialisePage, |
|||
screenStore, |
|||
pageStore, |
|||
routeTo, |
|||
rootNode, |
|||
} = createApp({ |
|||
componentLibraries: componentLibraryModules, |
|||
frontendDefinition, |
|||
user, |
|||
window: _window, |
|||
import ClientApp from "./components/ClientApp.svelte" |
|||
import { builderStore } from "./store" |
|||
|
|||
let app |
|||
|
|||
const loadBudibase = () => { |
|||
// Update builder store with any builder flags
|
|||
builderStore.set({ |
|||
inBuilder: !!window["##BUDIBASE_IN_BUILDER##"], |
|||
page: window["##BUDIBASE_PREVIEW_PAGE##"], |
|||
screen: window["##BUDIBASE_PREVIEW_SCREEN##"], |
|||
selectedComponentId: window["##BUDIBASE_SELECTED_COMPONENT_ID##"], |
|||
previewId: window["##BUDIBASE_PREVIEW_ID##"], |
|||
}) |
|||
|
|||
const route = _window.location |
|||
? _window.location.pathname.replace(`${appId}/`, "").replace(appId, "") |
|||
: "" |
|||
|
|||
initialisePage(frontendDefinition.page, _window.document.body, route) |
|||
|
|||
return { |
|||
screenStore, |
|||
pageStore, |
|||
routeTo, |
|||
rootNode, |
|||
// Create app if one hasn't been created yet
|
|||
if (!app) { |
|||
app = new ClientApp({ |
|||
target: window.document.body, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
if (window) { |
|||
window.loadBudibase = loadBudibase |
|||
} |
|||
// Attach to window so the HTML template can call this when it loads
|
|||
window.loadBudibase = loadBudibase |
|||
|
|||
@ -1,138 +0,0 @@ |
|||
import { prepareRenderComponent } from "./prepareRenderComponent" |
|||
import { isScreenSlot } from "./builtinComponents" |
|||
import deepEqual from "deep-equal" |
|||
import appStore from "../state/store" |
|||
|
|||
export const attachChildren = initialiseOpts => (htmlElement, options) => { |
|||
const { |
|||
componentLibraries, |
|||
treeNode, |
|||
onScreenSlotRendered, |
|||
setupState, |
|||
} = initialiseOpts |
|||
|
|||
const anchor = options && options.anchor ? options.anchor : null |
|||
const force = options ? options.force : false |
|||
const hydrate = options ? options.hydrate : true |
|||
const context = options && options.context |
|||
|
|||
if (!force && treeNode.children.length > 0) return treeNode.children |
|||
|
|||
for (let childNode of treeNode.children) { |
|||
childNode.destroy() |
|||
} |
|||
|
|||
if (!htmlElement) return |
|||
|
|||
if (hydrate) { |
|||
while (htmlElement.firstChild) { |
|||
htmlElement.removeChild(htmlElement.firstChild) |
|||
} |
|||
} |
|||
|
|||
const contextStoreKeys = [] |
|||
|
|||
// create new context if supplied
|
|||
if (context) { |
|||
let childIndex = 0 |
|||
// if context is an array, map to new structure
|
|||
const contextArray = Array.isArray(context) ? context : [context] |
|||
for (let ctx of contextArray) { |
|||
const key = appStore.create( |
|||
ctx, |
|||
treeNode.props._id, |
|||
childIndex, |
|||
treeNode.contextStoreKey |
|||
) |
|||
contextStoreKeys.push(key) |
|||
childIndex++ |
|||
} |
|||
} |
|||
|
|||
const childNodes = [] |
|||
|
|||
const createChildNodes = contextStoreKey => { |
|||
for (let childProps of treeNode.props._children) { |
|||
const { componentName, libName } = splitName(childProps._component) |
|||
|
|||
if (!componentName || !libName) return |
|||
|
|||
const ComponentConstructor = componentLibraries[libName][componentName] |
|||
|
|||
const childNode = prepareRenderComponent({ |
|||
props: childProps, |
|||
parentNode: treeNode, |
|||
ComponentConstructor, |
|||
htmlElement, |
|||
anchor, |
|||
// in same context as parent, unless a new one was supplied
|
|||
contextStoreKey, |
|||
}) |
|||
|
|||
childNodes.push(childNode) |
|||
} |
|||
} |
|||
|
|||
if (context) { |
|||
// if new context(s) is supplied, then create nodes
|
|||
// with keys to new context stores
|
|||
for (let contextStoreKey of contextStoreKeys) { |
|||
createChildNodes(contextStoreKey) |
|||
} |
|||
} else { |
|||
// otherwise, use same context store as parent
|
|||
// which maybe undefined (therfor using the root state)
|
|||
createChildNodes(treeNode.contextStoreKey) |
|||
} |
|||
|
|||
// if everything is equal, then don't re-render
|
|||
if (areTreeNodesEqual(treeNode.children, childNodes)) return treeNode.children |
|||
|
|||
for (let node of childNodes) { |
|||
const initialProps = setupState(node) |
|||
node.render(initialProps) |
|||
} |
|||
|
|||
const screenSlot = childNodes.find(n => isScreenSlot(n.props._component)) |
|||
|
|||
if (onScreenSlotRendered && screenSlot) { |
|||
// assuming there is only ever one screen slot
|
|||
onScreenSlotRendered(screenSlot) |
|||
} |
|||
|
|||
treeNode.children = childNodes |
|||
|
|||
return childNodes |
|||
} |
|||
|
|||
const splitName = fullname => { |
|||
const nameParts = fullname.split("/") |
|||
|
|||
const componentName = nameParts[nameParts.length - 1] |
|||
|
|||
const libName = fullname.substring( |
|||
0, |
|||
fullname.length - componentName.length - 1 |
|||
) |
|||
|
|||
return { libName, componentName } |
|||
} |
|||
|
|||
const areTreeNodesEqual = (children1, children2) => { |
|||
if (children1.length !== children2.length) return false |
|||
if (children1 === children2) return true |
|||
|
|||
let isEqual = false |
|||
for (let i = 0; i < children1.length; i++) { |
|||
// same context and same children, then nothing has changed
|
|||
isEqual = |
|||
deepEqual(children1[i].context, children2[i].context) && |
|||
areTreeNodesEqual(children1[i].children, children2[i].children) |
|||
if (!isEqual) return false |
|||
if (isScreenSlot(children1[i].parentNode.props._component)) { |
|||
isEqual = deepEqual(children1[i].props, children2[i].props) |
|||
} |
|||
if (!isEqual) return false |
|||
} |
|||
return true |
|||
} |
|||
@ -1,10 +0,0 @@ |
|||
import { screenSlotComponent } from "./screenSlotComponent" |
|||
|
|||
export const builtinLibName = "##builtin" |
|||
|
|||
export const isScreenSlot = componentName => |
|||
componentName === "##builtin/screenslot" |
|||
|
|||
export const builtins = window => ({ |
|||
screenslot: screenSlotComponent(window), |
|||
}) |
|||
@ -1,88 +0,0 @@ |
|||
import renderTemplateString from "../state/renderTemplateString" |
|||
import appStore from "../state/store" |
|||
import hasBinding from "../state/hasBinding" |
|||
|
|||
export const prepareRenderComponent = ({ |
|||
ComponentConstructor, |
|||
htmlElement, |
|||
anchor, |
|||
props, |
|||
parentNode, |
|||
contextStoreKey, |
|||
}) => { |
|||
const thisNode = createTreeNode() |
|||
thisNode.parentNode = parentNode |
|||
thisNode.props = props |
|||
thisNode.contextStoreKey = contextStoreKey |
|||
|
|||
// the treeNode is first created (above), and then this
|
|||
// render method is add. The treeNode is returned, and
|
|||
// render is called later (in attachChildren)
|
|||
thisNode.render = initialProps => { |
|||
thisNode.component = new ComponentConstructor({ |
|||
target: htmlElement, |
|||
props: initialProps, |
|||
hydrate: false, |
|||
anchor, |
|||
}) |
|||
|
|||
// finds the root element of the component, which was created by the contructor above
|
|||
// we use this later to attach a className to. This is how styles
|
|||
// are applied by the builder
|
|||
thisNode.rootElement = htmlElement.children[htmlElement.children.length - 1] |
|||
|
|||
let [componentName] = props._component.match(/[a-z]*$/) |
|||
if (props._id && thisNode.rootElement) { |
|||
thisNode.rootElement.classList.add(`${componentName}-${props._id}`) |
|||
} |
|||
|
|||
// make this node listen to the store
|
|||
if (thisNode.stateBound) { |
|||
const unsubscribe = appStore.subscribe(state => { |
|||
const storeBoundProps = Object.keys(initialProps._bb.props).filter(p => |
|||
hasBinding(initialProps._bb.props[p]) |
|||
) |
|||
if (storeBoundProps.length > 0) { |
|||
const toSet = {} |
|||
for (let prop of storeBoundProps) { |
|||
const propValue = initialProps._bb.props[prop] |
|||
toSet[prop] = renderTemplateString(propValue, state) |
|||
} |
|||
thisNode.component.$set(toSet) |
|||
} |
|||
}, thisNode.contextStoreKey) |
|||
thisNode.unsubscribe = unsubscribe |
|||
} |
|||
} |
|||
|
|||
return thisNode |
|||
} |
|||
|
|||
export const createTreeNode = () => ({ |
|||
context: {}, |
|||
props: {}, |
|||
rootElement: null, |
|||
parentNode: null, |
|||
children: [], |
|||
bindings: [], |
|||
component: null, |
|||
unsubscribe: () => {}, |
|||
render: () => {}, |
|||
get destroy() { |
|||
const node = this |
|||
return () => { |
|||
if (node.children) { |
|||
// destroy children first - from leaf nodes up
|
|||
for (let child of node.children) { |
|||
child.destroy() |
|||
} |
|||
} |
|||
if (node.unsubscribe) node.unsubscribe() |
|||
if (node.component && node.component.$destroy) node.component.$destroy() |
|||
for (let onDestroyItem of node.onDestroy) { |
|||
onDestroyItem() |
|||
} |
|||
} |
|||
}, |
|||
onDestroy: [], |
|||
}) |
|||
@ -1,124 +0,0 @@ |
|||
import regexparam from "regexparam" |
|||
import appStore from "../state/store" |
|||
import { getAppId } from "./getAppId" |
|||
|
|||
export const screenRouter = ({ screens, onScreenSelected, window }) => { |
|||
function sanitize(url) { |
|||
if (!url) return url |
|||
return url |
|||
.split("/") |
|||
.map(part => { |
|||
// if parameter, then use as is
|
|||
if (part.startsWith(":")) return part |
|||
return encodeURIComponent(part) |
|||
}) |
|||
.join("/") |
|||
.toLowerCase() |
|||
} |
|||
|
|||
const isRunningLocally = () => { |
|||
const hostname = (window.location && window.location.hostname) || "" |
|||
return ( |
|||
hostname === "localhost" || |
|||
hostname === "127.0.0.1" || |
|||
hostname.startsWith("192.168") |
|||
) |
|||
} |
|||
|
|||
const makeRootedPath = url => { |
|||
if (isRunningLocally()) { |
|||
const appId = getAppId(window.document.cookie) |
|||
if (url) { |
|||
url = sanitize(url) |
|||
if (!url.startsWith("/")) { |
|||
url = `/${url}` |
|||
} |
|||
if (url.startsWith(`/${appId}`)) { |
|||
return url |
|||
} |
|||
return `/${appId}${url}` |
|||
} |
|||
return `/${appId}` |
|||
} |
|||
return sanitize(url) |
|||
} |
|||
|
|||
const routes = screens.map(screen => |
|||
makeRootedPath(screen.routing ? screen.routing.route : null) |
|||
) |
|||
let fallback = routes.findIndex(([p]) => p === makeRootedPath("*")) |
|||
if (fallback < 0) fallback = 0 |
|||
|
|||
let current |
|||
|
|||
function route(url) { |
|||
const _url = makeRootedPath(url.state || url) |
|||
current = routes.findIndex( |
|||
p => |
|||
p !== makeRootedPath("*") && |
|||
new RegExp("^" + p.toLowerCase() + "$").test(_url.toLowerCase()) |
|||
) |
|||
|
|||
const params = {} |
|||
|
|||
if (current === -1) { |
|||
routes.forEach((p, i) => { |
|||
// ignore home - which matched everything
|
|||
if (p === makeRootedPath("*")) return |
|||
const pm = regexparam(p) |
|||
const matches = pm.pattern.exec(_url) |
|||
|
|||
if (!matches) return |
|||
|
|||
let j = 0 |
|||
while (j < pm.keys.length) { |
|||
params[pm.keys[j]] = matches[++j] || null |
|||
} |
|||
|
|||
current = i |
|||
}) |
|||
} |
|||
|
|||
appStore.update(state => { |
|||
state["##routeParams"] = params |
|||
return state |
|||
}) |
|||
|
|||
const screenIndex = current !== -1 ? current : fallback |
|||
|
|||
try { |
|||
!url.state && history.pushState(_url, null, _url) |
|||
} catch (_) { |
|||
// ignoring an exception here as the builder runs an iframe, which does not like this
|
|||
} |
|||
|
|||
onScreenSelected(screens[screenIndex], _url) |
|||
} |
|||
|
|||
function click(e) { |
|||
const x = e.target.closest("a") |
|||
const y = x && x.getAttribute("href") |
|||
|
|||
if ( |
|||
e.ctrlKey || |
|||
e.metaKey || |
|||
e.altKey || |
|||
e.shiftKey || |
|||
e.button || |
|||
e.defaultPrevented |
|||
) |
|||
return |
|||
|
|||
const target = (x && x.target) || "_self" |
|||
if (!y || target !== "_self" || x.host !== location.host) return |
|||
|
|||
e.preventDefault() |
|||
route(y) |
|||
} |
|||
|
|||
addEventListener("popstate", route) |
|||
addEventListener("pushstate", route) |
|||
addEventListener("click", click) |
|||
|
|||
return route |
|||
} |
|||
@ -1,14 +0,0 @@ |
|||
export const screenSlotComponent = window => { |
|||
return function(opts) { |
|||
const node = window.document.createElement("DIV") |
|||
const $set = props => { |
|||
props._bb.attachChildren(node) |
|||
} |
|||
const $destroy = () => { |
|||
if (opts.target && node) opts.target.removeChild(node) |
|||
} |
|||
this.$set = $set |
|||
this.$destroy = $destroy |
|||
opts.target.appendChild(node) |
|||
} |
|||
} |
|||
@ -0,0 +1,18 @@ |
|||
import * as API from "./api" |
|||
import { authStore, routeStore, screenStore, bindingStore } from "./store" |
|||
import { styleable } from "./utils/styleable" |
|||
import { getAppId } from "./utils/getAppId" |
|||
import { link as linkable } from "svelte-spa-router" |
|||
import DataProvider from "./components/DataProvider.svelte" |
|||
|
|||
export default { |
|||
API, |
|||
authStore, |
|||
routeStore, |
|||
screenStore, |
|||
styleable, |
|||
linkable, |
|||
getAppId, |
|||
DataProvider, |
|||
setBindableValue: bindingStore.actions.setBindableValue, |
|||
} |
|||
@ -1,43 +0,0 @@ |
|||
import setBindableComponentProp from "./setBindableComponentProp" |
|||
import { attachChildren } from "../render/attachChildren" |
|||
import store from "../state/store" |
|||
import { baseApiCall } from "../api/index" |
|||
|
|||
export const bbFactory = ({ |
|||
componentLibraries, |
|||
onScreenSlotRendered, |
|||
runEventActions, |
|||
}) => { |
|||
const api = { |
|||
post: (url, body) => baseApiCall("POST", url, body), |
|||
get: (url, body) => baseApiCall("GET", url, body), |
|||
patch: (url, body) => baseApiCall("PATCH", url, body), |
|||
delete: (url, body) => baseApiCall("DELETE", url, body), |
|||
} |
|||
|
|||
return (treeNode, setupState) => { |
|||
const attachParams = { |
|||
componentLibraries, |
|||
treeNode, |
|||
onScreenSlotRendered, |
|||
setupState, |
|||
} |
|||
|
|||
return { |
|||
attachChildren: attachChildren(attachParams), |
|||
props: treeNode.props, |
|||
call: async eventName => |
|||
eventName && |
|||
(await runEventActions( |
|||
treeNode.props[eventName], |
|||
store.getState(treeNode.contextStoreKey) |
|||
)), |
|||
setBinding: setBindableComponentProp(treeNode), |
|||
api, |
|||
parent, |
|||
store: store.getStore(treeNode.contextStoreKey), |
|||
// these parameters are populated by screenRouter
|
|||
routeParams: () => store.getState()["##routeParams"], |
|||
} |
|||
} |
|||
} |
|||
@ -1,43 +0,0 @@ |
|||
import api from "../api" |
|||
import renderTemplateString from "./renderTemplateString" |
|||
|
|||
export const EVENT_TYPE_MEMBER_NAME = "##eventHandlerType" |
|||
|
|||
export const eventHandlers = routeTo => { |
|||
const handlers = { |
|||
"Navigate To": param => routeTo(param && param.url), |
|||
"Update Row": api.updateRow, |
|||
"Save Row": api.saveRow, |
|||
"Delete Row": api.deleteRow, |
|||
"Trigger Workflow": api.triggerWorkflow, |
|||
} |
|||
|
|||
// when an event is called, this is what gets run
|
|||
const runEventActions = async (actions, state) => { |
|||
if (!actions) return |
|||
// calls event handlers sequentially
|
|||
for (let action of actions) { |
|||
const handler = handlers[action[EVENT_TYPE_MEMBER_NAME]] |
|||
const parameters = createParameters(action.parameters, state) |
|||
if (handler) { |
|||
await handler(parameters, state) |
|||
} |
|||
} |
|||
} |
|||
|
|||
return runEventActions |
|||
} |
|||
|
|||
// this will take a parameters obj, iterate all keys, and do a mustache render
|
|||
// for every string. It will work recursively if it encounnters an {}
|
|||
const createParameters = (parameterTemplateObj, state) => { |
|||
const parameters = {} |
|||
for (let key in parameterTemplateObj) { |
|||
if (typeof parameterTemplateObj[key] === "string") { |
|||
parameters[key] = renderTemplateString(parameterTemplateObj[key], state) |
|||
} else if (typeof parameterTemplateObj[key] === "object") { |
|||
parameters[key] = createParameters(parameterTemplateObj[key], state) |
|||
} |
|||
} |
|||
return parameters |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
export const setContext = treeNode => (key, value) => |
|||
(treeNode.context[key] = value) |
|||
|
|||
export const getContext = treeNode => key => { |
|||
if (treeNode.context && treeNode.context[key] !== undefined) |
|||
return treeNode.context[key] |
|||
|
|||
if (!treeNode.context.$parent) return |
|||
|
|||
return getContext(treeNode.parentNode)(key) |
|||
} |
|||
@ -1 +0,0 @@ |
|||
export default value => typeof value === "string" && value.includes("{{") |
|||
@ -1,17 +0,0 @@ |
|||
import mustache from "mustache" |
|||
|
|||
// this is a much more liberal version of mustache's escape function
|
|||
// ...just ignoring < and > to prevent tags from user input
|
|||
// original version here https://github.com/janl/mustache.js/blob/4b7908f5c9fec469a11cfaed2f2bed23c84e1c5c/mustache.js#L78
|
|||
|
|||
const entityMap = { |
|||
"<": "<", |
|||
">": ">", |
|||
} |
|||
|
|||
mustache.escape = text => |
|||
String(text).replace(/[&<>"'`=/]/g, function fromEntityMap(s) { |
|||
return entityMap[s] || s |
|||
}) |
|||
|
|||
export default mustache.render |
|||
@ -1,13 +0,0 @@ |
|||
import appStore from "./store" |
|||
|
|||
export default treeNode => (propName, value) => { |
|||
if (!propName || propName.length === 0) return |
|||
if (!treeNode) return |
|||
const componentId = treeNode.props._id |
|||
|
|||
appStore.update(state => { |
|||
state[componentId] = state[componentId] || {} |
|||
state[componentId][propName] = value |
|||
return state |
|||
}, treeNode.contextStoreKey) |
|||
} |
|||
@ -1,65 +0,0 @@ |
|||
import { eventHandlers } from "./eventHandlers" |
|||
import { bbFactory } from "./bbComponentApi" |
|||
import renderTemplateString from "./renderTemplateString" |
|||
import appStore from "./store" |
|||
import hasBinding from "./hasBinding" |
|||
|
|||
const doNothing = () => {} |
|||
doNothing.isPlaceholder = true |
|||
|
|||
const isMetaProp = propName => |
|||
propName === "_component" || |
|||
propName === "_children" || |
|||
propName === "_id" || |
|||
propName === "_style" || |
|||
propName === "_code" || |
|||
propName === "_codeMeta" || |
|||
propName === "_styles" |
|||
|
|||
export const createStateManager = ({ |
|||
componentLibraries, |
|||
onScreenSlotRendered, |
|||
routeTo, |
|||
}) => { |
|||
let runEventActions = eventHandlers(routeTo) |
|||
|
|||
const bb = bbFactory({ |
|||
componentLibraries, |
|||
onScreenSlotRendered, |
|||
runEventActions, |
|||
}) |
|||
|
|||
const setup = _setup(bb) |
|||
|
|||
return { |
|||
setup, |
|||
destroy: () => {}, |
|||
} |
|||
} |
|||
|
|||
const _setup = bb => node => { |
|||
const props = node.props |
|||
const initialProps = { ...props } |
|||
|
|||
for (let propName in props) { |
|||
if (isMetaProp(propName)) continue |
|||
|
|||
const propValue = props[propName] |
|||
|
|||
const isBound = hasBinding(propValue) |
|||
|
|||
if (isBound) { |
|||
const state = appStore.getState(node.contextStoreKey) |
|||
initialProps[propName] = renderTemplateString(propValue, state) |
|||
|
|||
if (!node.stateBound) { |
|||
node.stateBound = true |
|||
} |
|||
} |
|||
} |
|||
|
|||
const setup = _setup(bb) |
|||
initialProps._bb = bb(node, setup) |
|||
|
|||
return initialProps |
|||
} |
|||
@ -1,108 +0,0 @@ |
|||
import { writable } from "svelte/store" |
|||
|
|||
// we assume that the reference to this state object
|
|||
// will remain for the life of the application
|
|||
const rootState = {} |
|||
const rootStore = writable(rootState) |
|||
const contextStores = {} |
|||
|
|||
// contextProviderId is the component id that provides the data for the context
|
|||
const contextStoreKey = (dataProviderId, childIndex) => |
|||
`${dataProviderId}${childIndex >= 0 ? ":" + childIndex : ""}` |
|||
|
|||
// creates a store for a datacontext (e.g. each item in a list component)
|
|||
// overrides store if already exists
|
|||
const create = (data, dataProviderId, childIndex, parentContextStoreId) => { |
|||
const key = contextStoreKey(dataProviderId, childIndex) |
|||
const state = { data } |
|||
|
|||
// add reference to parent state object,
|
|||
// so we can use bindings like state.parent.parent
|
|||
// (if no parent, then parent is rootState )
|
|||
state.parent = parentContextStoreId |
|||
? contextStores[parentContextStoreId].state |
|||
: rootState |
|||
|
|||
contextStores[key] = { |
|||
store: writable(state), |
|||
subscriberCount: 0, |
|||
state, |
|||
parentContextStoreId, |
|||
} |
|||
|
|||
return key |
|||
} |
|||
|
|||
const subscribe = (subscription, storeKey) => { |
|||
if (!storeKey) { |
|||
return rootStore.subscribe(subscription) |
|||
} |
|||
const contextStore = contextStores[storeKey] |
|||
|
|||
// we are subscribing to multiple stores,
|
|||
// we dont want to run our listener for every subscription, the first time
|
|||
// as this could repeatedly run $set on the same component
|
|||
// ... which already has its initial properties set properly
|
|||
const ignoreFirstSubscription = () => { |
|||
let hasRunOnce = false |
|||
return () => { |
|||
if (hasRunOnce) subscription(contextStore.state) |
|||
hasRunOnce = true |
|||
} |
|||
} |
|||
|
|||
const unsubscribes = [rootStore.subscribe(ignoreFirstSubscription())] |
|||
|
|||
// we subscribe to all stores in the hierarchy
|
|||
const ancestorSubscribe = ctxStore => { |
|||
// unsubscribe func returned by svelte store
|
|||
const svelteUnsub = ctxStore.store.subscribe(ignoreFirstSubscription()) |
|||
|
|||
// we wrap the svelte unsubscribe, so we can
|
|||
// cleanup stores when they are no longer subscribed to
|
|||
const unsub = () => { |
|||
ctxStore.subscriberCount = contextStore.subscriberCount - 1 |
|||
// when no subscribers left, we delete the store
|
|||
if (ctxStore.subscriberCount === 0) { |
|||
delete ctxStore[storeKey] |
|||
} |
|||
svelteUnsub() |
|||
} |
|||
unsubscribes.push(unsub) |
|||
if (ctxStore.parentContextStoreId) { |
|||
ancestorSubscribe(contextStores[ctxStore.parentContextStoreId]) |
|||
} |
|||
} |
|||
|
|||
ancestorSubscribe(contextStore) |
|||
|
|||
// our final unsubscribe function calls unsubscribe on all stores
|
|||
return () => unsubscribes.forEach(u => u()) |
|||
} |
|||
|
|||
const findStore = (dataProviderId, childIndex) => |
|||
dataProviderId |
|||
? contextStores[contextStoreKey(dataProviderId, childIndex)].store |
|||
: rootStore |
|||
|
|||
const update = (updatefunc, dataProviderId, childIndex) => |
|||
findStore(dataProviderId, childIndex).update(updatefunc) |
|||
|
|||
const set = (value, dataProviderId, childIndex) => |
|||
findStore(dataProviderId, childIndex).set(value) |
|||
|
|||
const getState = contextStoreKey => |
|||
contextStoreKey ? contextStores[contextStoreKey].state : rootState |
|||
|
|||
const getStore = contextStoreKey => |
|||
contextStoreKey ? contextStores[contextStoreKey].store : rootStore |
|||
|
|||
export default { |
|||
subscribe, |
|||
update, |
|||
set, |
|||
getState, |
|||
create, |
|||
contextStoreKey, |
|||
getStore, |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
import * as API from "../api" |
|||
import { getAppId } from "../utils/getAppId" |
|||
import { writable } from "svelte/store" |
|||
|
|||
const createAuthStore = () => { |
|||
const store = writable("") |
|||
|
|||
const logIn = async ({ username, password }) => { |
|||
const user = await API.logIn({ username, password }) |
|||
if (!user.error) { |
|||
store.set(user.token) |
|||
location.reload() |
|||
} |
|||
} |
|||
const logOut = () => { |
|||
store.set("") |
|||
const appId = getAppId() |
|||
if (appId) { |
|||
for (let environment of ["local", "cloud"]) { |
|||
window.document.cookie = `budibase:${appId}:${environment}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;` |
|||
} |
|||
} |
|||
location.reload() |
|||
} |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
actions: { logIn, logOut }, |
|||
} |
|||
} |
|||
|
|||
export const authStore = createAuthStore() |
|||
@ -0,0 +1,21 @@ |
|||
import { writable } from "svelte/store" |
|||
|
|||
const createBindingStore = () => { |
|||
const store = writable({}) |
|||
|
|||
const setBindableValue = (value, componentId) => { |
|||
store.update(state => { |
|||
if (componentId) { |
|||
state[componentId] = value |
|||
} |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
actions: { setBindableValue }, |
|||
} |
|||
} |
|||
|
|||
export const bindingStore = createBindingStore() |
|||
@ -0,0 +1,14 @@ |
|||
import { writable } from "svelte/store" |
|||
|
|||
const createBuilderStore = () => { |
|||
const initialState = { |
|||
inBuilder: false, |
|||
page: null, |
|||
screen: null, |
|||
selectedComponentId: null, |
|||
previewId: null, |
|||
} |
|||
return writable(initialState) |
|||
} |
|||
|
|||
export const builderStore = createBuilderStore() |
|||
@ -0,0 +1,26 @@ |
|||
import { writable } from "svelte/store" |
|||
import { cloneDeep } from "lodash/fp" |
|||
|
|||
export const createDataStore = existingContext => { |
|||
const store = writable({ ...existingContext }) |
|||
|
|||
// Adds a context layer to the data context tree
|
|||
const addContext = (row, componentId) => { |
|||
store.update(state => { |
|||
if (componentId) { |
|||
state[componentId] = row |
|||
state[`${componentId}_draft`] = cloneDeep(row) |
|||
state.closestComponentId = componentId |
|||
} |
|||
return state |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
update: store.update, |
|||
actions: { addContext }, |
|||
} |
|||
} |
|||
|
|||
export const dataStore = createDataStore() |
|||
@ -0,0 +1,8 @@ |
|||
export { authStore } from "./auth" |
|||
export { routeStore } from "./routes" |
|||
export { screenStore } from "./screens" |
|||
export { builderStore } from "./builder" |
|||
export { bindingStore } from "./binding" |
|||
|
|||
// Data stores are layered and duplicated, so it is not a singleton
|
|||
export { createDataStore, dataStore } from "./data" |
|||
@ -0,0 +1,49 @@ |
|||
import { writable } from "svelte/store" |
|||
import { push } from "svelte-spa-router" |
|||
import * as API from "../api" |
|||
|
|||
const createRouteStore = () => { |
|||
const initialState = { |
|||
routes: [], |
|||
routeParams: {}, |
|||
activeRoute: null, |
|||
} |
|||
const store = writable(initialState) |
|||
|
|||
const fetchRoutes = async () => { |
|||
const routeConfig = await API.fetchRoutes() |
|||
let routes = [] |
|||
Object.values(routeConfig.routes).forEach(route => { |
|||
Object.entries(route.subpaths).forEach(([path, config]) => { |
|||
routes.push({ |
|||
path, |
|||
screenId: config.screenId, |
|||
}) |
|||
}) |
|||
}) |
|||
store.update(state => { |
|||
state.routes = routes |
|||
return state |
|||
}) |
|||
} |
|||
const setRouteParams = routeParams => { |
|||
store.update(state => { |
|||
state.routeParams = routeParams |
|||
return state |
|||
}) |
|||
} |
|||
const setActiveRoute = route => { |
|||
store.update(state => { |
|||
state.activeRoute = route |
|||
return state |
|||
}) |
|||
} |
|||
const navigate = push |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
actions: { fetchRoutes, navigate, setRouteParams, setActiveRoute }, |
|||
} |
|||
} |
|||
|
|||
export const routeStore = createRouteStore() |
|||
@ -0,0 +1,51 @@ |
|||
import { writable, derived } from "svelte/store" |
|||
import { routeStore } from "./routes" |
|||
import { builderStore } from "./builder" |
|||
import * as API from "../api" |
|||
import { getAppId } from "../utils/getAppId" |
|||
|
|||
const createScreenStore = () => { |
|||
const config = writable({ |
|||
screens: [], |
|||
page: {}, |
|||
}) |
|||
const store = derived( |
|||
[config, routeStore, builderStore], |
|||
([$config, $routeStore, $builderStore]) => { |
|||
let page |
|||
let activeScreen |
|||
if ($builderStore.inBuilder) { |
|||
// Use builder defined definitions if inside the builder preview
|
|||
page = $builderStore.page |
|||
activeScreen = $builderStore.screen |
|||
} else { |
|||
// Otherwise find the correct screen by matching the current route
|
|||
page = $config.page |
|||
const { screens } = $config |
|||
if (screens.length === 1) { |
|||
activeScreen = screens[0] |
|||
} else { |
|||
activeScreen = screens.find( |
|||
screen => screen.routing.route === $routeStore.activeRoute |
|||
) |
|||
} |
|||
} |
|||
return { page, activeScreen } |
|||
} |
|||
) |
|||
|
|||
const fetchScreens = async () => { |
|||
const appDefinition = await API.fetchAppDefinition(getAppId()) |
|||
config.set({ |
|||
screens: appDefinition.screens, |
|||
page: appDefinition.page, |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
subscribe: store.subscribe, |
|||
actions: { fetchScreens }, |
|||
} |
|||
} |
|||
|
|||
export const screenStore = createScreenStore() |
|||
@ -0,0 +1,45 @@ |
|||
import { enrichDataBinding } from "./enrichDataBinding" |
|||
import { routeStore } from "../store" |
|||
import { saveRow, deleteRow } from "../api" |
|||
|
|||
const saveRowHandler = async (action, context) => { |
|||
let draft = context[`${action.parameters.contextPath}_draft`] |
|||
if (action.parameters.fields) { |
|||
Object.entries(action.parameters.fields).forEach(([key, entry]) => { |
|||
draft[key] = enrichDataBinding(entry.value, context) |
|||
}) |
|||
} |
|||
await saveRow(draft) |
|||
} |
|||
|
|||
const deleteRowHandler = async (action, context) => { |
|||
const { tableId, revId, rowId } = action.parameters |
|||
await deleteRow({ |
|||
tableId: enrichDataBinding(tableId, context), |
|||
rowId: enrichDataBinding(rowId, context), |
|||
revId: enrichDataBinding(revId, context), |
|||
}) |
|||
} |
|||
|
|||
const navigationHandler = action => { |
|||
routeStore.actions.navigate(action.parameters.url) |
|||
} |
|||
|
|||
const handlerMap = { |
|||
["Save Row"]: saveRowHandler, |
|||
["Delete Row"]: deleteRowHandler, |
|||
["Navigate To"]: navigationHandler, |
|||
} |
|||
|
|||
/** |
|||
* Parses an array of actions and returns a function which will execute the |
|||
* actions in the current context. |
|||
*/ |
|||
export const enrichButtonActions = (actions, context) => { |
|||
const handlers = actions.map(def => handlerMap[def["##eventHandlerType"]]) |
|||
return async () => { |
|||
for (let i = 0; i < handlers.length; i++) { |
|||
await handlers[i](actions[i], context) |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
import { enrichDataBindings } from "./enrichDataBinding" |
|||
import { enrichButtonActions } from "./buttonActions" |
|||
|
|||
/** |
|||
* Enriches component props. |
|||
* Data bindings are enriched, and button actions are enriched. |
|||
*/ |
|||
export const enrichProps = (props, dataContexts, dataBindings) => { |
|||
// Exclude all private props that start with an underscore
|
|||
let validProps = {} |
|||
Object.entries(props) |
|||
.filter(([name]) => !name.startsWith("_")) |
|||
.forEach(([key, value]) => { |
|||
validProps[key] = value |
|||
}) |
|||
|
|||
// Create context of all bindings and data contexts
|
|||
// Duplicate the closest context as "data" which the builder requires
|
|||
const context = { |
|||
...dataContexts, |
|||
...dataBindings, |
|||
data: dataContexts[dataContexts.closestComponentId], |
|||
data_draft: dataContexts[`${dataContexts.closestComponentId}_draft`], |
|||
} |
|||
|
|||
// Enrich all data bindings in top level props
|
|||
let enrichedProps = enrichDataBindings(validProps, context) |
|||
|
|||
// Enrich button actions if they exist
|
|||
if (props._component.endsWith("/button") && enrichedProps.onClick) { |
|||
enrichedProps.onClick = enrichButtonActions(enrichedProps.onClick, context) |
|||
} |
|||
|
|||
return enrichedProps |
|||
} |
|||
@ -0,0 +1,46 @@ |
|||
import mustache from "mustache" |
|||
|
|||
// this is a much more liberal version of mustache's escape function
|
|||
// ...just ignoring < and > to prevent tags from user input
|
|||
// original version here https://github.com/janl/mustache.js/blob/4b7908f5c9fec469a11cfaed2f2bed23c84e1c5c/mustache.js#L78
|
|||
const entityMap = { |
|||
"<": "<", |
|||
">": ">", |
|||
} |
|||
mustache.escape = text => { |
|||
if (text == null || typeof text !== "string") { |
|||
return text |
|||
} |
|||
return text.replace(/[<>]/g, function fromEntityMap(s) { |
|||
return entityMap[s] || s |
|||
}) |
|||
} |
|||
|
|||
// Regex to test inputs with to see if they are likely candidates for mustache
|
|||
const looksLikeMustache = /{{.*}}/ |
|||
|
|||
/** |
|||
* Enriches a given input with a row from the database. |
|||
*/ |
|||
export const enrichDataBinding = (input, context) => { |
|||
// Only accept string inputs
|
|||
if (!input || typeof input !== "string") { |
|||
return input |
|||
} |
|||
// Do a fast regex check if this looks like a mustache string
|
|||
if (!looksLikeMustache.test(input)) { |
|||
return input |
|||
} |
|||
return mustache.render(input, context) |
|||
} |
|||
|
|||
/** |
|||
* Enriches each prop in a props object |
|||
*/ |
|||
export const enrichDataBindings = (props, context) => { |
|||
let enrichedProps = {} |
|||
Object.entries(props).forEach(([key, value]) => { |
|||
enrichedProps[key] = enrichDataBinding(value, context) |
|||
}) |
|||
return enrichedProps |
|||
} |
|||
@ -0,0 +1,69 @@ |
|||
/** |
|||
* Helper to build a CSS string from a style object |
|||
*/ |
|||
const buildStyleString = (styles, selected) => { |
|||
let str = "" |
|||
if (selected) { |
|||
styles.border = "2px solid #0055ff !important" |
|||
} |
|||
Object.entries(styles).forEach(([style, value]) => { |
|||
if (style && value != null) { |
|||
str += `${style}: ${value}; ` |
|||
} |
|||
}) |
|||
return str |
|||
} |
|||
|
|||
/** |
|||
* Svelte action to apply correct component styles. |
|||
*/ |
|||
export const styleable = (node, styles = {}) => { |
|||
let applyNormalStyles |
|||
let applyHoverStyles |
|||
|
|||
// Creates event listeners and applies initial styles
|
|||
const setupStyles = newStyles => { |
|||
const selected = newStyles.selected |
|||
const normalStyles = newStyles.normal || {} |
|||
const hoverStyles = { |
|||
...normalStyles, |
|||
...newStyles.hover, |
|||
} |
|||
|
|||
applyNormalStyles = () => { |
|||
node.style = buildStyleString(normalStyles, selected) |
|||
} |
|||
|
|||
applyHoverStyles = () => { |
|||
node.style = buildStyleString(hoverStyles, selected) |
|||
} |
|||
|
|||
// Add listeners to toggle hover styles
|
|||
node.addEventListener("mouseover", applyHoverStyles) |
|||
node.addEventListener("mouseout", applyNormalStyles) |
|||
|
|||
// Apply initial normal styles
|
|||
applyNormalStyles() |
|||
} |
|||
|
|||
// Removes the current event listeners
|
|||
const removeListeners = () => { |
|||
node.removeEventListener("mouseover", applyHoverStyles) |
|||
node.removeEventListener("mouseout", applyNormalStyles) |
|||
} |
|||
|
|||
// Apply initial styles
|
|||
setupStyles(styles) |
|||
|
|||
return { |
|||
// Clean up old listeners and apply new ones on update
|
|||
update: newStyles => { |
|||
removeListeners() |
|||
setupStyles(newStyles) |
|||
}, |
|||
// Clean up listeners when component is destroyed
|
|||
destroy: () => { |
|||
removeListeners() |
|||
}, |
|||
} |
|||
} |
|||
@ -1,209 +0,0 @@ |
|||
import { load, makePage, makeScreen } from "./testAppDef" |
|||
|
|||
describe("binding", () => { |
|||
|
|||
|
|||
it("should bind to data in context", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/list", |
|||
data: dataArray, |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "{{data.name}}", |
|||
} |
|||
], |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children[0].children.length).toBe(2) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe(dataArray[0].name) |
|||
expect(screenRoot.children[0].children[1].innerText).toBe(dataArray[1].name) |
|||
}) |
|||
|
|||
it("should bind to input in root", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "{{inputid.value}}", |
|||
}, |
|||
{ |
|||
_id: "inputid", |
|||
_component: "testlib/input", |
|||
value: "hello" |
|||
} |
|||
], |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children[0].children.length).toBe(2) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("hello") |
|||
|
|||
// change value of input
|
|||
const input = dom.window.document.getElementsByClassName("input-inputid")[0] |
|||
|
|||
changeInputValue(dom, input, "new value") |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("new value") |
|||
|
|||
}) |
|||
|
|||
it("should bind to input in context", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/list", |
|||
data: dataArray, |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "{{inputid.value}}", |
|||
}, |
|||
{ |
|||
_id: "inputid", |
|||
_component: "testlib/input", |
|||
value: "hello" |
|||
} |
|||
], |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
expect(screenRoot.children[0].children.length).toBe(4) |
|||
|
|||
const firstHeader = screenRoot.children[0].children[0] |
|||
const firstInput = screenRoot.children[0].children[1] |
|||
const secondHeader = screenRoot.children[0].children[2] |
|||
const secondInput = screenRoot.children[0].children[3] |
|||
|
|||
expect(firstHeader.innerText).toBe("hello") |
|||
expect(secondHeader.innerText).toBe("hello") |
|||
|
|||
changeInputValue(dom, firstInput, "first input value") |
|||
expect(firstHeader.innerText).toBe("first input value") |
|||
|
|||
changeInputValue(dom, secondInput, "second input value") |
|||
expect(secondHeader.innerText).toBe("second input value") |
|||
|
|||
}) |
|||
|
|||
it("should bind contextual component, to input in root context", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_id: "inputid", |
|||
_component: "testlib/input", |
|||
value: "hello" |
|||
}, |
|||
{ |
|||
_component: "testlib/list", |
|||
data: dataArray, |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "{{parent.inputid.value}}", |
|||
}, |
|||
], |
|||
} |
|||
] |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
expect(screenRoot.children[0].children.length).toBe(2) |
|||
|
|||
const input = screenRoot.children[0].children[0] |
|||
|
|||
const firstHeader = screenRoot.children[0].children[1].children[0] |
|||
const secondHeader = screenRoot.children[0].children[1].children[0] |
|||
|
|||
expect(firstHeader.innerText).toBe("hello") |
|||
expect(secondHeader.innerText).toBe("hello") |
|||
|
|||
changeInputValue(dom, input, "new input value") |
|||
expect(firstHeader.innerText).toBe("new input value") |
|||
expect(secondHeader.innerText).toBe("new input value") |
|||
|
|||
}) |
|||
|
|||
const changeInputValue = (dom, input, newValue) => { |
|||
var event = new dom.window.Event("change") |
|||
input.value = newValue |
|||
input.dispatchEvent(event) |
|||
} |
|||
|
|||
const dataArray = [ |
|||
{ |
|||
name: "katherine", |
|||
age: 30, |
|||
}, |
|||
{ |
|||
name: "steve", |
|||
age: 41, |
|||
}, |
|||
] |
|||
}) |
|||
@ -1,172 +0,0 @@ |
|||
import { load, makePage, makeScreen } from "./testAppDef" |
|||
|
|||
describe("initialiseApp", () => { |
|||
it("should populate simple div with initial props", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
className: "my-test-class", |
|||
}) |
|||
) |
|||
|
|||
expect(dom.window.document.body.children.length).toBe(1) |
|||
const child = dom.window.document.body.children[0] |
|||
expect(child.className.includes("my-test-class")).toBeTruthy() |
|||
}) |
|||
|
|||
it("should populate child component with props", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header one", |
|||
}, |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header two", |
|||
}, |
|||
], |
|||
}) |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
|
|||
expect(rootDiv.children.length).toBe(2) |
|||
expect(rootDiv.children[0].tagName).toBe("H1") |
|||
expect(rootDiv.children[0].innerText).toBe("header one") |
|||
expect(rootDiv.children[1].tagName).toBe("H1") |
|||
expect(rootDiv.children[1].innerText).toBe("header two") |
|||
}) |
|||
|
|||
it("should append children when told to do so", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header one", |
|||
}, |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header two", |
|||
}, |
|||
], |
|||
append: true, |
|||
}) |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
|
|||
expect(rootDiv.children.length).toBe(3) |
|||
expect(rootDiv.children[0].tagName).toBe("DIV") |
|||
expect(rootDiv.children[0].className).toBe("default-child") |
|||
expect(rootDiv.children[1].tagName).toBe("H1") |
|||
expect(rootDiv.children[1].innerText).toBe("header one") |
|||
expect(rootDiv.children[2].tagName).toBe("H1") |
|||
expect(rootDiv.children[2].innerText).toBe("header two") |
|||
}) |
|||
|
|||
it("should populate page with correct screen", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/div", |
|||
className: "screen-class", |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
|
|||
expect(rootDiv.children.length).toBe(1) |
|||
expect(rootDiv.children[0].children.length).toBe(1) |
|||
expect( |
|||
rootDiv.children[0].children[0].className.includes("screen-class") |
|||
).toBeTruthy() |
|||
}) |
|||
|
|||
it("should populate screen with children", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/div", |
|||
className: "screen-class", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header one", |
|||
}, |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header two", |
|||
}, |
|||
], |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children.length).toBe(1) |
|||
expect(screenRoot.children[0].children.length).toBe(2) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("header one") |
|||
expect(screenRoot.children[0].children[1].innerText).toBe("header two") |
|||
}) |
|||
|
|||
it("should repeat elements that pass an array of contexts", async () => { |
|||
const { dom } = await load( |
|||
makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
[ |
|||
makeScreen("/", { |
|||
_component: "testlib/list", |
|||
data: [1,2,3,4], |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "header", |
|||
} |
|||
], |
|||
}), |
|||
] |
|||
) |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children[0].children.length).toBe(4) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("header") |
|||
}) |
|||
}) |
|||
@ -1,174 +0,0 @@ |
|||
import { load, makePage, makeScreen, walkComponentTree } from "./testAppDef" |
|||
import { isScreenSlot } from "../src/render/builtinComponents" |
|||
jest.mock("../src/render/getAppId", () => ({ |
|||
getAppId: () => "TEST_APP_ID" |
|||
})) |
|||
|
|||
describe("screenRouting", () => { |
|||
it("should load correct screen, for initial URL", async () => { |
|||
const { page, screens } = pageWith3Screens() |
|||
const { dom } = await load(page, screens, "/screen2") |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children.length).toBe(1) |
|||
expect(screenRoot.children[0].children.length).toBe(1) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("screen 2") |
|||
}) |
|||
|
|||
it("should load correct screen, for initial URL, when appRootPath is something", async () => { |
|||
const { page, screens } = pageWith3Screens() |
|||
const { dom } = await load(page, screens, "/TEST_APP_ID/screen2", "127.0.0.1") |
|||
|
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children.length).toBe(1) |
|||
expect(screenRoot.children[0].children.length).toBe(1) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("screen 2") |
|||
}) |
|||
|
|||
it("should be able to route to the correct screen", async () => { |
|||
const { page, screens } = pageWith3Screens() |
|||
const { dom, app } = await load(page, screens, "/screen2") |
|||
|
|||
app.routeTo()("/screen3") |
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children.length).toBe(1) |
|||
expect(screenRoot.children[0].children.length).toBe(1) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("screen 3") |
|||
}) |
|||
|
|||
it("should be able to route to the correct screen, when appRootPath is something", async () => { |
|||
const { page, screens } = pageWith3Screens() |
|||
const { dom, app } = await load( |
|||
page, |
|||
screens, |
|||
"/TEST_APP_ID/screen2", |
|||
"127.0.0.1" |
|||
) |
|||
|
|||
app.routeTo()("/screen3") |
|||
const rootDiv = dom.window.document.body.children[0] |
|||
expect(rootDiv.children.length).toBe(1) |
|||
|
|||
const screenRoot = rootDiv.children[0] |
|||
|
|||
expect(screenRoot.children.length).toBe(1) |
|||
expect(screenRoot.children[0].children.length).toBe(1) |
|||
expect(screenRoot.children[0].children[0].innerText).toBe("screen 3") |
|||
}) |
|||
|
|||
it("should destroy and unsubscribe all components on a screen whe screen is changed", async () => { |
|||
const { page, screens } = pageWith3Screens() |
|||
const { app } = await load(page, screens, "/screen2") |
|||
|
|||
const nodes = createTrackerNodes(app) |
|||
|
|||
app.routeTo()("/screen3") |
|||
|
|||
expect(nodes.length > 0).toBe(true) |
|||
expect( |
|||
nodes.some(n => n.isDestroyed === false && isUnderScreenSlot(n.node)) |
|||
).toBe(false) |
|||
expect( |
|||
nodes.some(n => n.isUnsubscribed === false && isUnderScreenSlot(n.node)) |
|||
).toBe(false) |
|||
}) |
|||
|
|||
it("should not destroy and unsubscribe page and screenslot components when screen is changed", async () => { |
|||
const { page, screens } = pageWith3Screens() |
|||
const { app } = await load(page, screens, "/screen2") |
|||
|
|||
const nodes = createTrackerNodes(app) |
|||
|
|||
app.routeTo()("/screen3") |
|||
|
|||
expect(nodes.length > 0).toBe(true) |
|||
expect( |
|||
nodes.some(n => n.isDestroyed === true && !isUnderScreenSlot(n.node)) |
|||
).toBe(false) |
|||
}) |
|||
}) |
|||
|
|||
const createTrackerNodes = app => { |
|||
const nodes = [] |
|||
walkComponentTree(app.rootNode(), n => { |
|||
if (!n.component) return |
|||
const tracker = { node: n, isDestroyed: false, isUnsubscribed: false } |
|||
const _destroy = n.component.$destroy |
|||
n.component.$destroy = () => { |
|||
_destroy() |
|||
tracker.isDestroyed = true |
|||
} |
|||
const _unsubscribe = n.unsubscribe |
|||
if (!_unsubscribe) { |
|||
tracker.isUnsubscribed = undefined |
|||
} else { |
|||
n.unsubscribe = () => { |
|||
_unsubscribe() |
|||
tracker.isUnsubscribed = true |
|||
} |
|||
} |
|||
nodes.push(tracker) |
|||
}) |
|||
return nodes |
|||
} |
|||
|
|||
const isUnderScreenSlot = node => |
|||
node.parentNode && |
|||
(isScreenSlot(node.parentNode.props._component) || |
|||
isUnderScreenSlot(node.parentNode)) |
|||
|
|||
const pageWith3Screens = () => ({ |
|||
page: makePage({ |
|||
_component: "testlib/div", |
|||
_children: [ |
|||
{ |
|||
_component: "##builtin/screenslot", |
|||
text: "header one", |
|||
}, |
|||
], |
|||
}), |
|||
screens: [ |
|||
makeScreen("/", { |
|||
_component: "testlib/div", |
|||
className: "screen-class", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "screen 1", |
|||
}, |
|||
], |
|||
}), |
|||
makeScreen("/screen2", { |
|||
_component: "testlib/div", |
|||
className: "screen-class", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "screen 2", |
|||
}, |
|||
], |
|||
}), |
|||
makeScreen("/screen3", { |
|||
_component: "testlib/div", |
|||
className: "screen-class", |
|||
_children: [ |
|||
{ |
|||
_component: "testlib/h1", |
|||
text: "screen 3", |
|||
}, |
|||
], |
|||
}), |
|||
], |
|||
}) |
|||
@ -1,244 +0,0 @@ |
|||
import jsdom, { JSDOM } from "jsdom" |
|||
import { loadBudibase } from "../src/index" |
|||
|
|||
export const APP_ID = "TEST_APP_ID" |
|||
|
|||
export const load = async (page, screens, url, host = "test.com") => { |
|||
screens = screens || [] |
|||
url = url || "/" |
|||
|
|||
const fullUrl = `http://${host}${url}` |
|||
const cookieJar = new jsdom.CookieJar() |
|||
const cookie = `${btoa("{}")}.${btoa(`{"appId":"${APP_ID}"}`)}.signature` |
|||
cookieJar.setCookie( |
|||
`budibase:${APP_ID}:local=${cookie};domain=${host};path=/`, |
|||
fullUrl, |
|||
{ |
|||
looseMode: false, |
|||
}, |
|||
() => {} |
|||
) |
|||
|
|||
const dom = new JSDOM("<!DOCTYPE html><html><body></body><html>", { |
|||
url: fullUrl, |
|||
cookieJar, |
|||
}) |
|||
|
|||
autoAssignIds(page.props) |
|||
for (let s of screens) { |
|||
autoAssignIds(s.props) |
|||
} |
|||
setAppDef(dom.window, page, screens) |
|||
addWindowGlobals(dom.window, page, screens, { |
|||
hierarchy: {}, |
|||
actions: [], |
|||
triggers: [], |
|||
}) |
|||
setComponentCodeMeta(page, screens) |
|||
const app = await loadBudibase({ |
|||
componentLibraries: allLibs(dom.window), |
|||
window: dom.window, |
|||
localStorage: createLocalStorage(), |
|||
}) |
|||
return { dom, app } |
|||
} |
|||
|
|||
const addWindowGlobals = (window, page, screens) => { |
|||
window["##BUDIBASE_FRONTEND_DEFINITION##"] = { |
|||
page, |
|||
screens, |
|||
} |
|||
} |
|||
|
|||
export const makePage = props => ({ props }) |
|||
export const makeScreen = (route, props) => ({ |
|||
props, |
|||
routing: { route, accessLevelId: "" }, |
|||
}) |
|||
|
|||
export const timeout = ms => new Promise(resolve => setTimeout(resolve, ms)) |
|||
|
|||
export const walkComponentTree = (node, action) => { |
|||
action(node) |
|||
|
|||
// works for nodes or props
|
|||
const children = node.children || node._children |
|||
|
|||
if (children) { |
|||
for (let child of children) { |
|||
walkComponentTree(child, action) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// this happens for real by the builder...
|
|||
// ..this only assigns _ids when missing
|
|||
const autoAssignIds = (props, count = 0) => { |
|||
if (!props._id) { |
|||
props._id = `auto_id_${count}` |
|||
} |
|||
if (props._children) { |
|||
for (let child of props._children) { |
|||
count += 1 |
|||
autoAssignIds(child, count) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// any component with an id that include "based_on_store" is
|
|||
// assumed to have code that depends on store value
|
|||
const setComponentCodeMeta = (page, screens) => { |
|||
const setComponentCodeMeta_single = props => { |
|||
walkComponentTree(props, c => { |
|||
if (c._id.indexOf("based_on_store") >= 0) { |
|||
c._codeMeta = { dependsOnStore: true } |
|||
} |
|||
}) |
|||
} |
|||
setComponentCodeMeta_single(page.props) |
|||
for (let s of screens || []) { |
|||
setComponentCodeMeta_single(s.props) |
|||
} |
|||
} |
|||
|
|||
const setAppDef = (window, page, screens) => { |
|||
window["##BUDIBASE_FRONTEND_DEFINITION##"] = { |
|||
componentLibraries: [], |
|||
page, |
|||
screens, |
|||
} |
|||
} |
|||
|
|||
const allLibs = window => ({ |
|||
testlib: maketestlib(window), |
|||
}) |
|||
|
|||
const createLocalStorage = () => { |
|||
const data = {} |
|||
return { |
|||
getItem: key => data[key], |
|||
setItem: (key, value) => (data[key] = value), |
|||
} |
|||
} |
|||
|
|||
const maketestlib = window => ({ |
|||
div: function(opts) { |
|||
const node = window.document.createElement("DIV") |
|||
const defaultChild = window.document.createElement("DIV") |
|||
defaultChild.className = "default-child" |
|||
node.appendChild(defaultChild) |
|||
|
|||
let currentProps = { ...opts.props } |
|||
let childNodes = [] |
|||
|
|||
const set = props => { |
|||
currentProps = Object.assign(currentProps, props) |
|||
node.className = currentProps.className || "" |
|||
if (currentProps._children && currentProps._children.length > 0) { |
|||
if (currentProps.append) { |
|||
for (let c of childNodes) { |
|||
node.removeChild(c) |
|||
} |
|||
const components = currentProps._bb.attachChildren(node, { |
|||
hydrate: false, |
|||
}) |
|||
childNodes = components.map(c => c.component._element) |
|||
} else { |
|||
currentProps._bb.attachChildren(node) |
|||
} |
|||
} |
|||
} |
|||
|
|||
this.$destroy = () => opts.target.removeChild(node) |
|||
|
|||
this.$set = set |
|||
this._element = node |
|||
set(opts.props) |
|||
opts.target.appendChild(node) |
|||
}, |
|||
|
|||
h1: function(opts) { |
|||
const node = window.document.createElement("H1") |
|||
|
|||
let currentProps = { ...opts.props } |
|||
|
|||
const set = props => { |
|||
currentProps = Object.assign(currentProps, props) |
|||
if (currentProps.text) { |
|||
node.innerText = currentProps.text |
|||
} |
|||
} |
|||
|
|||
this.$destroy = () => opts.target.removeChild(node) |
|||
|
|||
this.$set = set |
|||
this._element = node |
|||
set(opts.props) |
|||
opts.target.appendChild(node) |
|||
}, |
|||
|
|||
button: function(opts) { |
|||
const node = window.document.createElement("BUTTON") |
|||
|
|||
let currentProps = { ...opts.props } |
|||
|
|||
const set = props => { |
|||
currentProps = Object.assign(currentProps, props) |
|||
if (currentProps.onClick) { |
|||
node.addEventListener("click", () => { |
|||
currentProps._bb.call("onClick") |
|||
}) |
|||
} |
|||
} |
|||
|
|||
this.$destroy = () => opts.target.removeChild(node) |
|||
|
|||
this.$set = set |
|||
this._element = node |
|||
set(opts.props) |
|||
opts.target.appendChild(node) |
|||
}, |
|||
|
|||
list: function(opts) { |
|||
const node = window.document.createElement("DIV") |
|||
|
|||
let currentProps = { ...opts.props } |
|||
|
|||
const set = props => { |
|||
currentProps = Object.assign(currentProps, props) |
|||
if (currentProps._children && currentProps._children.length > 0) { |
|||
currentProps._bb.attachChildren(node, { |
|||
context: currentProps.data || {}, |
|||
}) |
|||
} |
|||
} |
|||
|
|||
this.$destroy = () => opts.target.removeChild(node) |
|||
|
|||
this.$set = set |
|||
this._element = node |
|||
set(opts.props) |
|||
opts.target.appendChild(node) |
|||
}, |
|||
|
|||
input: function(opts) { |
|||
const node = window.document.createElement("INPUT") |
|||
let currentProps = { ...opts.props } |
|||
|
|||
const set = props => { |
|||
currentProps = Object.assign(currentProps, props) |
|||
opts.props._bb.setBinding("value", props.value) |
|||
} |
|||
|
|||
node.addEventListener("change", e => { |
|||
opts.props._bb.setBinding("value", e.target.value) |
|||
}) |
|||
|
|||
this.$destroy = () => opts.target.removeChild(node) |
|||
|
|||
this.$set = set |
|||
this._element = node |
|||
set(opts.props) |
|||
opts.target.appendChild(node) |
|||
}, |
|||
}) |
|||
@ -1,16 +0,0 @@ |
|||
<script> |
|||
export let _bb |
|||
export let className = "" |
|||
|
|||
let containerElement |
|||
let hasLoaded |
|||
|
|||
$: { |
|||
if (containerElement) { |
|||
_bb.attachChildren(containerElement) |
|||
hasLoaded = true |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<div bind:this={containerElement} class={className} /> |
|||
File diff suppressed because it is too large
File diff suppressed because it is too large
@ -1,5 +1,4 @@ |
|||
.DS_Store |
|||
node_modules |
|||
yarn.lock |
|||
package-lock.json |
|||
dist/ |
|||
|
|||
@ -1,38 +1,32 @@ |
|||
import svelte from "rollup-plugin-svelte" |
|||
import resolve from "rollup-plugin-node-resolve" |
|||
import commonjs from "@rollup/plugin-commonjs" |
|||
import resolve from "@rollup/plugin-node-resolve" |
|||
import svelte from "rollup-plugin-svelte" |
|||
import postcss from "rollup-plugin-postcss" |
|||
import { terser } from "rollup-plugin-terser" |
|||
|
|||
const production = !process.env.ROLLUP_WATCH |
|||
const lodash_fp_exports = ["isEmpty"] |
|||
const externals = ["svelte", "svelte/internal"] |
|||
|
|||
export default { |
|||
external: externals, |
|||
input: "src/index.js", |
|||
output: [ |
|||
{ |
|||
file: "dist/index.js", |
|||
format: "esm", |
|||
name: "budibaseStandardComponents", |
|||
sourcemap: true, |
|||
sourcemap: false, |
|||
}, |
|||
], |
|||
plugins: [ |
|||
// Only run terser in production environments
|
|||
production && terser(), |
|||
postcss({ |
|||
plugins: [], |
|||
}), |
|||
postcss(), |
|||
svelte({ |
|||
hydratable: true, |
|||
dev: !production, |
|||
}), |
|||
resolve({ |
|||
browser: true, |
|||
skip: externals, |
|||
}), |
|||
commonjs({ |
|||
namedExports: { |
|||
"lodash/fp": lodash_fp_exports, |
|||
}, |
|||
}), |
|||
commonjs(), |
|||
], |
|||
} |
|||
|
|||
@ -1,138 +0,0 @@ |
|||
import svelte from "rollup-plugin-svelte" |
|||
import resolve from "rollup-plugin-node-resolve" |
|||
import commonjs from "rollup-plugin-commonjs" |
|||
import livereload from "rollup-plugin-livereload" |
|||
import { terser } from "rollup-plugin-terser" |
|||
import json from "rollup-plugin-json" |
|||
|
|||
const production = !process.env.ROLLUP_WATCH |
|||
|
|||
const lodash_fp_exports = [ |
|||
"find", |
|||
"isUndefined", |
|||
"split", |
|||
"max", |
|||
"last", |
|||
"union", |
|||
"reduce", |
|||
"isObject", |
|||
"cloneDeep", |
|||
"some", |
|||
"isArray", |
|||
"map", |
|||
"filter", |
|||
"keys", |
|||
"isFunction", |
|||
"isEmpty", |
|||
"countBy", |
|||
"join", |
|||
"includes", |
|||
"flatten", |
|||
"constant", |
|||
"first", |
|||
"intersection", |
|||
"take", |
|||
"has", |
|||
"mapValues", |
|||
"isString", |
|||
"isBoolean", |
|||
"isNull", |
|||
"isNumber", |
|||
"isObjectLike", |
|||
"isDate", |
|||
"clone", |
|||
"values", |
|||
"keyBy", |
|||
"isNaN", |
|||
"isInteger", |
|||
"toNumber", |
|||
] |
|||
|
|||
const lodash_exports = [ |
|||
"flow", |
|||
"head", |
|||
"find", |
|||
"each", |
|||
"tail", |
|||
"findIndex", |
|||
"startsWith", |
|||
"dropRight", |
|||
"takeRight", |
|||
"trim", |
|||
"split", |
|||
"replace", |
|||
"merge", |
|||
"assign", |
|||
] |
|||
|
|||
const coreExternal = [ |
|||
"lodash", |
|||
"lodash/fp", |
|||
"date-fns", |
|||
"lunr", |
|||
"safe-buffer", |
|||
"shortid", |
|||
"@nx-js/compiler-util", |
|||
"bcryptjs", |
|||
] |
|||
|
|||
export default { |
|||
input: "src/Test/testMain.js", |
|||
output: { |
|||
sourcemap: true, |
|||
format: "iife", |
|||
name: "app", |
|||
file: "public/bundle.js", |
|||
globals: { |
|||
crypto: "crypto", |
|||
}, |
|||
}, |
|||
plugins: [ |
|||
svelte({ |
|||
// enable run-time checks when not in production
|
|||
dev: !production, |
|||
// we'll extract any component CSS out into
|
|||
// a separate file — better for performance
|
|||
css: css => { |
|||
css.write("bundle.css") |
|||
}, |
|||
|
|||
hydratable: true, |
|||
}), |
|||
|
|||
// If you have external dependencies installed from
|
|||
// npm, you'll most likely need these plugins. In
|
|||
// some cases you'll need additional configuration —
|
|||
// consult the documentation for details:
|
|||
// https://github.com/rollup/rollup-plugin-commonjs
|
|||
resolve({ |
|||
browser: true, |
|||
dedupe: importee => { |
|||
return ( |
|||
importee === "svelte" || |
|||
importee.startsWith("svelte/") || |
|||
coreExternal.includes(importee) |
|||
) |
|||
}, |
|||
}), |
|||
commonjs({ |
|||
namedExports: { |
|||
"lodash/fp": lodash_fp_exports, |
|||
lodash: lodash_exports, |
|||
shortid: ["generate"], |
|||
}, |
|||
}), |
|||
json(), |
|||
|
|||
// Watch the `public` directory and refresh the
|
|||
// browser on changes when not in production
|
|||
!production && livereload("public"), |
|||
|
|||
// If we're building for production (npm run build
|
|||
// instead of npm run dev), minify
|
|||
production && terser(), |
|||
], |
|||
watch: { |
|||
clearScreen: false, |
|||
}, |
|||
} |
|||
@ -1,11 +0,0 @@ |
|||
<script> |
|||
import Input from "./Input.svelte" |
|||
export let _bb |
|||
|
|||
export let label = "" |
|||
export let checked = false |
|||
export let value = "" |
|||
export let onchange = () => {} |
|||
</script> |
|||
|
|||
<Input type="checkbox" {_bb} {checked} {label} {value} {onchange} /> |
|||
@ -1,50 +1,62 @@ |
|||
<script> |
|||
import { cssVars, createClasses } from "./cssVars" |
|||
import { getContext } from "svelte" |
|||
|
|||
export let className = "" |
|||
export let onLoad |
|||
export let type = "div" |
|||
export let _bb |
|||
|
|||
let containerElement |
|||
let hasLoaded |
|||
let currentChildren |
|||
const { styleable } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
$: { |
|||
if (containerElement) { |
|||
_bb.attachChildren(containerElement) |
|||
if (!hasLoaded) { |
|||
_bb.call("onLoad") |
|||
hasLoaded = true |
|||
} |
|||
} |
|||
} |
|||
export let type = "div" |
|||
</script> |
|||
|
|||
{#if type === 'div'} |
|||
<div bind:this={containerElement} /> |
|||
<div use:styleable={$component.styles}> |
|||
<slot /> |
|||
</div> |
|||
{:else if type === 'header'} |
|||
<header bind:this={containerElement} /> |
|||
<header use:styleable={$component.styles}> |
|||
<slot /> |
|||
</header> |
|||
{:else if type === 'main'} |
|||
<main bind:this={containerElement} /> |
|||
<main use:styleable={$component.styles}> |
|||
<slot /> |
|||
</main> |
|||
{:else if type === 'footer'} |
|||
<footer bind:this={containerElement} /> |
|||
<footer use:styleable={$component.styles}> |
|||
<slot /> |
|||
</footer> |
|||
{:else if type === 'aside'} |
|||
<aside bind:this={containerElement} /> |
|||
<aside use:styleable={$component.styles}> |
|||
<slot /> |
|||
</aside> |
|||
{:else if type === 'summary'} |
|||
<summary bind:this={containerElement} /> |
|||
<summary use:styleable={$component.styles}> |
|||
<slot /> |
|||
</summary> |
|||
{:else if type === 'details'} |
|||
<details bind:this={containerElement} /> |
|||
<details use:styleable={$component.styles}> |
|||
<slot /> |
|||
</details> |
|||
{:else if type === 'article'} |
|||
<article bind:this={containerElement} /> |
|||
<article use:styleable={$component.styles}> |
|||
<slot /> |
|||
</article> |
|||
{:else if type === 'nav'} |
|||
<nav bind:this={containerElement} /> |
|||
<nav use:styleable={$component.styles}> |
|||
<slot /> |
|||
</nav> |
|||
{:else if type === 'mark'} |
|||
<mark bind:this={containerElement} /> |
|||
<mark use:styleable={$component.styles}> |
|||
<slot /> |
|||
</mark> |
|||
{:else if type === 'figure'} |
|||
<figure bind:this={containerElement} /> |
|||
<figure use:styleable={$component.styles}> |
|||
<slot /> |
|||
</figure> |
|||
{:else if type === 'figcaption'} |
|||
<figcaption bind:this={containerElement} /> |
|||
<figcaption use:styleable={$component.styles}> |
|||
<slot /> |
|||
</figcaption> |
|||
{:else if type === 'paragraph'} |
|||
<p bind:this={containerElement} /> |
|||
<p use:styleable={$component.styles}> |
|||
<slot /> |
|||
</p> |
|||
{/if} |
|||
|
|||
@ -1,10 +1,5 @@ |
|||
<script> |
|||
import Form from "./Form.svelte" |
|||
|
|||
export let _bb |
|||
export let table |
|||
export let title |
|||
export let buttonText |
|||
</script> |
|||
|
|||
<Form {_bb} {table} {title} {buttonText} wide={false} /> |
|||
<Form wide={false} /> |
|||
|
|||
@ -1,10 +1,5 @@ |
|||
<script> |
|||
import Form from "./Form.svelte" |
|||
|
|||
export let _bb |
|||
export let table |
|||
export let title |
|||
export let buttonText |
|||
</script> |
|||
|
|||
<Form {_bb} {table} {title} {buttonText} wide={true} /> |
|||
<Form wide /> |
|||
|
|||
@ -1,20 +0,0 @@ |
|||
import api from "../../api" |
|||
|
|||
let cache = {} |
|||
|
|||
async function fetchTable(id) { |
|||
const FETCH_TABLE_URL = `/api/tables/${id}` |
|||
const response = await api.get(FETCH_TABLE_URL) |
|||
return await response.json() |
|||
} |
|||
|
|||
export async function getTable(tableId) { |
|||
if (!tableId) { |
|||
return null |
|||
} |
|||
if (!cache[tableId]) { |
|||
cache[tableId] = fetchTable(tableId) |
|||
cache[tableId] = await cache[tableId] |
|||
} |
|||
return await cache[tableId] |
|||
} |
|||
@ -1,82 +0,0 @@ |
|||
<script> |
|||
import { onMount } from "svelte" |
|||
|
|||
export let _bb |
|||
export let table |
|||
export let layout = "list" |
|||
|
|||
let headers = [] |
|||
let store = _bb.store |
|||
|
|||
async function fetchData() { |
|||
if (!table || !table.length) return |
|||
|
|||
const FETCH_ROWS_URL = `/api/views/all_${table}` |
|||
const response = await _bb.api.get(FETCH_ROWS_URL) |
|||
if (response.status === 200) { |
|||
const json = await response.json() |
|||
|
|||
store.update(state => { |
|||
state[table] = json |
|||
return state |
|||
}) |
|||
} else { |
|||
throw new Error("Failed to fetch rows.", response) |
|||
} |
|||
} |
|||
|
|||
$: data = $store[table] || [] |
|||
$: if (table) fetchData() |
|||
|
|||
onMount(async () => { |
|||
await fetchData() |
|||
}) |
|||
</script> |
|||
|
|||
<section class:grid={layout === 'grid'} class:list={layout === 'list'}> |
|||
{#each data as data} |
|||
<div class="data-card"> |
|||
<ul> |
|||
{#each Object.keys(data) as key} |
|||
<li> |
|||
<span class="data-key">{key}:</span> |
|||
<span class="data-value">{data[key]}</span> |
|||
</li> |
|||
{/each} |
|||
</ul> |
|||
</div> |
|||
{/each} |
|||
</section> |
|||
|
|||
<style> |
|||
.list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
font-family: Inter; |
|||
} |
|||
|
|||
.grid { |
|||
display: grid; |
|||
grid-template-columns: 1fr 1fr 1fr; |
|||
} |
|||
|
|||
ul { |
|||
list-style-type: none; |
|||
} |
|||
|
|||
li { |
|||
margin: 5px 0 5px 0; |
|||
} |
|||
|
|||
.data-card { |
|||
border: 1px solid #ccc; |
|||
border-radius: 5px; |
|||
padding: 10px; |
|||
} |
|||
|
|||
.data-key { |
|||
font-weight: bold; |
|||
font-size: 20px; |
|||
text-transform: capitalize; |
|||
} |
|||
</style> |
|||
@ -1,15 +0,0 @@ |
|||
<script> |
|||
export let _bb |
|||
export let table |
|||
|
|||
let searchValue = "" |
|||
|
|||
function search() { |
|||
const SEARCH_URL = _bb.api.get(``) |
|||
} |
|||
</script> |
|||
|
|||
<div> |
|||
<input type="text" bind:value={searchValue} /> |
|||
<button on:click={search}>Search</button> |
|||
</div> |
|||
@ -1,18 +1,21 @@ |
|||
<script> |
|||
import Flatpickr from "svelte-flatpickr" |
|||
import { DatePicker } from "@budibase/bbui" |
|||
import { getContext } from "svelte" |
|||
|
|||
const { styleable, setBindableValue } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
export let placeholder |
|||
export let value |
|||
|
|||
export let _bb |
|||
let value |
|||
$: setBindableValue(value, $component.id) |
|||
|
|||
function handleChange(event) { |
|||
const [fullDate, dateStr, instance] = event.detail |
|||
if (_bb) { |
|||
_bb.setBinding("value", fullDate) |
|||
} |
|||
const [fullDate] = event.detail |
|||
value = fullDate |
|||
} |
|||
</script> |
|||
|
|||
<DatePicker {placeholder} on:change={handleChange} {value} /> |
|||
<div use:styleable={$component.styles}> |
|||
<DatePicker {placeholder} on:change={handleChange} {value} /> |
|||
</div> |
|||
|
|||
@ -1,5 +1,23 @@ |
|||
<script> |
|||
import { getContext } from "svelte" |
|||
|
|||
const { styleable } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
export let embed |
|||
</script> |
|||
|
|||
{@html embed} |
|||
<div use:styleable={$component.styles}> |
|||
{@html embed} |
|||
</div> |
|||
|
|||
<style> |
|||
div { |
|||
position: relative; |
|||
} |
|||
div :global(> *) { |
|||
width: 100%; |
|||
height: 100%; |
|||
position: absolute; |
|||
} |
|||
</style> |
|||
|
|||
@ -1,30 +1,24 @@ |
|||
<script> |
|||
import { buildStyle } from "./buildStyle.js" |
|||
import { getContext } from "svelte" |
|||
|
|||
const { styleable } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
export let className = "" |
|||
export let type |
|||
export let text = "" |
|||
|
|||
export let _bb |
|||
|
|||
let containerElement |
|||
|
|||
$: containerElement && |
|||
!text && |
|||
_bb.props.children && |
|||
_bb.props.children.length && |
|||
_bb.attachChildren(containerElement) |
|||
</script> |
|||
|
|||
{#if type === 'h1'} |
|||
<h1 class={className} bind:this={containerElement}>{text}</h1> |
|||
<h1 class={className} use:styleable={$component.styles}>{text}</h1> |
|||
{:else if type === 'h2'} |
|||
<h2 class={className} bind:this={containerElement}>{text}</h2> |
|||
<h2 class={className} use:styleable={$component.styles}>{text}</h2> |
|||
{:else if type === 'h3'} |
|||
<h3 class={className} bind:this={containerElement}>{text}</h3> |
|||
<h3 class={className} use:styleable={$component.styles}>{text}</h3> |
|||
{:else if type === 'h4'} |
|||
<h4 class={className} bind:this={containerElement}>{text}</h4> |
|||
<h4 class={className} use:styleable={$component.styles}>{text}</h4> |
|||
{:else if type === 'h5'} |
|||
<h5 class={className} bind:this={containerElement}>{text}</h5> |
|||
<h5 class={className} use:styleable={$component.styles}>{text}</h5> |
|||
{:else if type === 'h6'} |
|||
<h6 class={className} bind:this={containerElement}>{text}</h6> |
|||
<h6 class={className} use:styleable={$component.styles}>{text}</h6> |
|||
{/if} |
|||
|
|||
@ -1,9 +1,16 @@ |
|||
<script> |
|||
import "@fortawesome/fontawesome-free/js/all.js" |
|||
import { getContext } from "svelte" |
|||
|
|||
const { styleable } = getContext("sdk") |
|||
const component = getContext("component") |
|||
|
|||
export let icon = "" |
|||
export let size = "fa-lg" |
|||
export let color = "#000" |
|||
</script> |
|||
|
|||
<i style={`color: ${color};`} class={`${icon} ${size}`} /> |
|||
<i |
|||
style={`color: ${color};`} |
|||
class={`${icon} ${size}`} |
|||
use:styleable={$component.styles} /> |
|||
|
|||
@ -1,9 +0,0 @@ |
|||
<script> |
|||
export let icon = "" |
|||
export let fontSize = "1em" |
|||
export let _bb |
|||
|
|||
$: style = { fontSize } |
|||
</script> |
|||
|
|||
<i class={icon} {style} /> |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue