1
0
Fork 0
mirror of synced 2024-06-27 02:20:35 +12:00

Merge pull request #1269 from Budibase/tests/upping-coverage

Tests/upping coverage
This commit is contained in:
Michael Drury 2021-03-11 15:12:13 +00:00 committed by GitHub
commit f71b92e54f
71 changed files with 2941 additions and 453 deletions

View file

@ -16,7 +16,7 @@ async function activate() {
// this was an issue as NODE_ENV = 'cypress' on the server,
// but 'production' on the client
const response = await api.get("/api/analytics")
analyticsEnabled = (await response.json()) === true
analyticsEnabled = (await response.json()).enabled === true
}
if (!analyticsEnabled) return
if (sentryConfigured) Sentry.init({ dsn: process.env.SENTRY_DSN })

View file

@ -1,190 +1,186 @@
<script>
import groupBy from "lodash/fp/groupBy"
import { Input, TextArea, Heading, Spacer, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { isValid } from "@budibase/string-templates"
import { handlebarsCompletions } from "constants/completions"
const dispatch = createEventDispatcher()
export let value = ""
export let bindingDrawer
export let bindableProperties = []
let originalValue = value
let helpers = handlebarsCompletions()
let getCaretPosition
let search = ""
let validity = true
$: categories = Object.entries(groupBy("category", bindableProperties))
$: value && checkValid()
$: dispatch("update", value)
$: searchRgx = new RegExp(search, "ig")
function checkValid() {
validity = isValid(value)
}
function addToText(binding) {
const position = getCaretPosition()
const toAdd = `{{ ${binding.path} }}`
if (position.start) {
value =
value.substring(0, position.start) +
toAdd +
value.substring(position.end, value.length)
} else {
value += toAdd
}
}
export function cancel() {
dispatch("update", originalValue)
bindingDrawer.close()
import groupBy from "lodash/fp/groupBy"
import { Input, TextArea, Heading, Spacer, Label } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { isValid } from "@budibase/string-templates"
import { handlebarsCompletions } from "constants/completions"
const dispatch = createEventDispatcher()
export let value = ""
export let bindingDrawer
export let bindableProperties = []
let originalValue = value
let helpers = handlebarsCompletions()
let getCaretPosition
let search = ""
let validity = true
$: categories = Object.entries(groupBy("category", bindableProperties))
$: value && checkValid()
$: dispatch("update", value)
$: searchRgx = new RegExp(search, "ig")
function checkValid() {
validity = isValid(value)
}
function addToText(binding) {
const position = getCaretPosition()
const toAdd = `{{ ${binding.path} }}`
if (position.start) {
value =
value.substring(0, position.start) +
toAdd +
value.substring(position.end, value.length)
} else {
value += toAdd
}
}
export function cancel() {
dispatch("update", originalValue)
bindingDrawer.close()
}
</script>
<div class="container">
<div class="list">
<Heading small>Available bindings</Heading>
<Spacer medium />
<Input extraThin placeholder="Search" bind:value={search} />
<Spacer medium />
{#each categories as [categoryName, bindings]}
<Heading extraSmall>{categoryName}</Heading>
<Spacer extraSmall />
{#each bindableProperties.filter(binding =>
binding.label.match(searchRgx)
) as binding}
<div class="binding" on:click={() => addToText(binding)}>
<span class="binding__label">{binding.label}</span>
<span class="binding__type">{binding.type}</span>
<br />
<div class="binding__description">
{binding.description || ''}
</div>
</div>
{/each}
{/each}
<Heading extraSmall>Helpers</Heading>
<Spacer extraSmall />
{#each helpers.filter(helper => helper.label.match(searchRgx) || helper.description.match(searchRgx)) as helper}
<div class="binding" on:click={() => addToText(helper)}>
<span class="binding__label">{helper.label}</span>
<br />
<div class="binding__description">
{@html helper.description || ''}
</div>
<pre>{helper.example || ''}</pre>
</div>
{/each}
</div>
<div class="text">
<TextArea
bind:getCaretPosition
thin
bind:value
placeholder="Add text, or click the objects on the left to add them to the textbox." />
{#if !validity}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
</div>
<div class="list">
<Heading small>Available bindings</Heading>
<Spacer medium />
<Input extraThin placeholder="Search" bind:value={search} />
<Spacer medium />
{#each categories as [categoryName, bindings]}
<Heading extraSmall>{categoryName}</Heading>
<Spacer extraSmall />
{#each bindableProperties.filter(binding =>
binding.label.match(searchRgx)
) as binding}
<div class="binding" on:click={() => addToText(binding)}>
<span class="binding__label">{binding.label}</span>
<span class="binding__type">{binding.type}</span>
<br />
<div class="binding__description">{binding.description || ''}</div>
</div>
{/each}
{/each}
<Heading extraSmall>Helpers</Heading>
<Spacer extraSmall />
{#each helpers.filter(helper => helper.label.match(searchRgx) || helper.description.match(searchRgx)) as helper}
<div class="binding" on:click={() => addToText(helper)}>
<span class="binding__label">{helper.label}</span>
<br />
<div class="binding__description">
{@html helper.description || ''}
</div>
<pre>{helper.example || ''}</pre>
</div>
{/each}
</div>
<div class="text">
<TextArea
bind:getCaretPosition
thin
bind:value
placeholder="Add text, or click the objects on the left to add them to the textbox." />
{#if !validity}
<p class="syntax-error">
Current Handlebars syntax is invalid, please check the guide
<a href="https://handlebarsjs.com/guide/">here</a>
for more details.
</p>
{/if}
</div>
</div>
<style>
.container {
height: 40vh;
overflow-y: auto;
display: grid;
grid-template-columns: 280px 1fr;
}
.list {
border-right: var(--border-light);
padding: var(--spacing-l);
overflow: auto;
}
.list {
border-right: var(--border-light);
padding: var(--spacing-l);
overflow: auto;
}
.container {
height: 40vh;
overflow-y: auto;
display: grid;
grid-template-columns: 280px 1fr;
}
.list::-webkit-scrollbar {
display: none;
}
.list {
border-right: var(--border-light);
padding: var(--spacing-l);
overflow: auto;
}
.list {
border-right: var(--border-light);
padding: var(--spacing-l);
overflow: auto;
}
.text {
padding: var(--spacing-l);
font-family: var(--font-sans);
}
.text :global(textarea) {
min-height: 100px;
}
.text :global(p) {
margin: 0;
}
.binding {
font-size: 12px;
padding: var(--spacing-s);
border-radius: var(--border-radius-m);
}
.binding:hover {
background-color: var(--grey-2);
cursor: pointer;
}
.binding__label {
font-weight: 500;
text-transform: capitalize;
}
.binding__description {
color: var(--grey-8);
margin-top: 2px;
white-space: normal;
}
pre {
white-space: normal;
}
.binding__type {
font-family: monospace;
background-color: var(--grey-2);
border-radius: var(--border-radius-m);
padding: 2px;
margin-left: 2px;
font-weight: 500;
}
.editor {
padding-left: var(--spacing-l);
}
.editor :global(textarea) {
min-height: 60px;
}
.controls {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: var(--spacing-l);
align-items: center;
margin-top: var(--spacing-m);
}
.syntax-error {
color: var(--red);
font-size: 12px;
}
.syntax-error a {
color: var(--red);
text-decoration: underline;
}
</style>
.list::-webkit-scrollbar {
display: none;
}
.text {
padding: var(--spacing-l);
font-family: var(--font-sans);
}
.text :global(textarea) {
min-height: 100px;
}
.text :global(p) {
margin: 0;
}
.binding {
font-size: 12px;
padding: var(--spacing-s);
border-radius: var(--border-radius-m);
}
.binding:hover {
background-color: var(--grey-2);
cursor: pointer;
}
.binding__label {
font-weight: 500;
text-transform: capitalize;
}
.binding__description {
color: var(--grey-8);
margin-top: 2px;
white-space: normal;
}
pre {
white-space: normal;
}
.binding__type {
font-family: monospace;
background-color: var(--grey-2);
border-radius: var(--border-radius-m);
padding: 2px;
margin-left: 2px;
font-weight: 500;
}
.editor {
padding-left: var(--spacing-l);
}
.editor :global(textarea) {
min-height: 60px;
}
.controls {
display: grid;
grid-template-columns: 1fr auto;
grid-gap: var(--spacing-l);
align-items: center;
margin-top: var(--spacing-m);
}
.syntax-error {
color: var(--red);
font-size: 12px;
}
.syntax-error a {
color: var(--red);
text-decoration: underline;
}
</style>

View file

@ -6,7 +6,7 @@
import { automationStore } from "builderStore"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import DrawerBindableInput from "../../common/DrawerBindableInput.svelte"
import AutomationBindingPanel from './AutomationBindingPanel.svelte'
import AutomationBindingPanel from "./AutomationBindingPanel.svelte"
export let block
export let webhookModal
@ -70,7 +70,7 @@
type={'email'}
extraThin
value={block.inputs[key]}
on:change={e => block.inputs[key] = e.detail}
on:change={e => (block.inputs[key] = e.detail)}
{bindings} />
{:else if value.customType === 'table'}
<TableSelector bind:value={block.inputs[key]} />
@ -86,7 +86,7 @@
type={value.customType}
extraThin
value={block.inputs[key]}
on:change={e => block.inputs[key] = e.detail}
on:change={e => (block.inputs[key] = e.detail)}
{bindings} />
{/if}
</div>

View file

@ -2,7 +2,7 @@
import { backendUiStore } from "builderStore"
import { Select } from "@budibase/bbui"
import DrawerBindableInput from "../../common/DrawerBindableInput.svelte"
import AutomationBindingPanel from './AutomationBindingPanel.svelte'
import AutomationBindingPanel from "./AutomationBindingPanel.svelte"
export let value
export let bindings
@ -44,7 +44,7 @@
panel={AutomationBindingPanel}
extraThin
value={value[field]}
on:change={e => value[field] = e.detail}
on:change={e => (value[field] = e.detail)}
label={field}
type="string"
{bindings} />

View file

@ -25,10 +25,14 @@
]
const transitions = [
'none', 'fade', 'blur', 'fly', 'scale' // slide is hidden because it does not seem to result in any effect
"none",
"fade",
"blur",
"fly",
"scale", // slide is hidden because it does not seem to result in any effect
]
const capitalize = ([first,...rest]) => first.toUpperCase() + rest.join('');
const capitalize = ([first, ...rest]) => first.toUpperCase() + rest.join("")
$: groups = componentDefinition?.styleable ? Object.keys(allStyles) : []
</script>
@ -72,13 +76,19 @@
</div>
</div>
{#if componentDefinition?.transitionable}
<div class="transitions">
<Select value={componentInstance._transition} on:change={event => onUpdateTransition(event.target.value)} name="transition" label="Transition" secondary thin>
{#each transitions as transition}
<option value={transition}>{capitalize(transition)}</option>
{/each}
</Select>
</div>
<div class="transitions">
<Select
value={componentInstance._transition}
on:change={event => onUpdateTransition(event.target.value)}
name="transition"
label="Transition"
secondary
thin>
{#each transitions as transition}
<option value={transition}>{capitalize(transition)}</option>
{/each}
</Select>
</div>
{/if}
</div>

View file

@ -1,10 +1,10 @@
<script>
import {flip} from "svelte/animate";
import {dndzone} from "svelte-dnd-action";
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { Button, DropdownMenu, Spacer } from "@budibase/bbui"
import actionTypes from "./actions"
const flipDurationMs = 150;
const flipDurationMs = 150
const EVENT_TYPE_KEY = "##eventHandlerType"
@ -13,7 +13,7 @@
// dndzone needs an id on the array items, so this adds some temporary ones.
if (actions) {
actions = actions.map((action, i) => {
return {...action, id: i}
return { ...action, id: i }
})
}
@ -41,7 +41,7 @@
const newAction = {
parameters: {},
[EVENT_TYPE_KEY]: actionType.name,
id: actions ? actions.length + 1 : 0
id: actions ? actions.length + 1 : 0,
}
if (!actions) {
actions = []
@ -56,11 +56,11 @@
}
function handleDndConsider(e) {
actions = e.detail.items;
}
function handleDndFinalize(e) {
actions = e.detail.items;
}
actions = e.detail.items
}
function handleDndFinalize(e) {
actions = e.detail.items
}
</script>
<div class="actions-container">
@ -87,9 +87,15 @@
</div>
{#if actions && actions.length > 0}
<div class="action-dnd-container" use:dndzone={{items: actions, flipDurationMs, dropTargetStyle: { outline: 'none'}}} on:consider={handleDndConsider} on:finalize={handleDndFinalize}>
<div
class="action-dnd-container"
use:dndzone={{ items: actions, flipDurationMs, dropTargetStyle: { outline: 'none' } }}
on:consider={handleDndConsider}
on:finalize={handleDndFinalize}>
{#each actions as action, index (action.id)}
<div class="action-container" animate:flip={{duration: flipDurationMs}}>
<div
class="action-container"
animate:flip={{ duration: flipDurationMs }}>
<div
class="action-header"
class:selected={action === selectedAction}

View file

@ -162,7 +162,7 @@
{#if componentDefinition?.component?.endsWith('/fieldgroup')}
<Button secondary wide on:click={() => confirmResetFieldsDialog?.show()}>
Update Form Fields
Update Form Fields
</Button>
{/if}
</div>

View file

@ -43,7 +43,7 @@
.topnavitemright:hover i {
color: var(--ink);
}
.content {
padding: var(--spacing-xl);
}

View file

@ -41,7 +41,7 @@
id,
children: children.length,
styles: { ...styles, id },
transition
transition,
})
// Gets the component constructor for the specified component

View file

@ -0,0 +1,17 @@
const fetch = jest.requireActual("node-fetch")
module.exports = async (url, opts) => {
// mocked data based on url
if (url.includes("api/apps")) {
return {
json: async () => {
return {
app1: {
url: "/app1",
},
}
},
}
}
return fetch(url, opts)
}

View file

@ -0,0 +1,21 @@
const pg = {}
// constructor
function Client() {}
Client.prototype.query = async function() {
return {
rows: [
{
a: "string",
b: 1,
},
],
}
}
Client.prototype.connect = async function() {}
pg.Client = Client
module.exports = pg

View file

@ -33,7 +33,7 @@
},
"scripts": {
"test": "jest --testPathIgnorePatterns=routes && npm run test:integration",
"test:integration": "jest routes --runInBand --coverage",
"test:integration": "jest --runInBand --coverage",
"test:watch": "jest --watch",
"run:docker": "node src/index",
"dev:builder": "cross-env PORT=4001 nodemon src/index.js",
@ -53,7 +53,11 @@
"src/**/*.js",
"!**/node_modules/**",
"!src/db/views/*.js",
"!src/api/routes/tests"
"!src/api/routes/tests/**/*.js",
"!src/api/controllers/deploy/**/*.js",
"!src/api/controllers/static/templates/**/*",
"!src/api/controllers/static/selfhost/**/*",
"!src/*.js"
],
"coverageReporters": [
"lcov",
@ -122,6 +126,7 @@
"zlib": "1.0.5"
},
"devDependencies": {
"@budibase/standard-components": "^0.8.5",
"@jest/test-sequencer": "^24.8.0",
"cross-env": "^7.0.3",
"electron": "10.1.3",

View file

@ -1,5 +1,7 @@
const env = require("../../environment")
exports.isEnabled = async function(ctx) {
ctx.body = JSON.stringify(env.ENABLE_ANALYTICS === "true")
ctx.body = {
enabled: env.ENABLE_ANALYTICS === "true",
}
}

View file

@ -3,20 +3,13 @@ const { join } = require("../../utilities/centralPath")
const readline = require("readline")
const { budibaseAppsDir } = require("../../utilities/budibaseDir")
const env = require("../../environment")
const selfhost = require("../../selfhost")
const ENV_FILE_PATH = "/.env"
exports.fetch = async function(ctx) {
ctx.status = 200
if (env.SELF_HOSTED) {
ctx.body = {
selfhost: await selfhost.getSelfHostAPIKey(),
}
} else {
ctx.body = {
budibase: env.BUDIBASE_API_KEY,
userId: env.USERID_API_KEY,
}
ctx.body = {
budibase: env.BUDIBASE_API_KEY,
userId: env.USERID_API_KEY,
}
}

View file

@ -104,9 +104,10 @@ async function createInstance(template) {
await createRoutingView(appId)
// replicate the template data to the instance DB
// this is currently very hard to test, downloading and importing template files
/* istanbul ignore next */
if (template) {
let dbDumpReadStream
if (template.fileImportPath) {
dbDumpReadStream = fs.createReadStream(template.fileImportPath)
} else {
@ -181,8 +182,9 @@ exports.create = async function(ctx) {
const instanceDb = new CouchDB(appId)
await instanceDb.put(newApplication)
const newAppFolder = await createEmptyAppPackage(ctx, newApplication)
/* istanbul ignore next */
if (env.NODE_ENV !== "jest") {
const newAppFolder = await createEmptyAppPackage(ctx, newApplication)
await downloadExtractComponentLibraries(newAppFolder)
}

View file

@ -46,6 +46,7 @@ exports.authenticate = async ctx => {
version: app.version,
}
// if in cloud add the user api key, unless self hosted
/* istanbul ignore next */
if (env.CLOUD && !env.SELF_HOSTED) {
const { apiKey } = await getAPIKey(ctx.user.appId)
payload.apiKey = apiKey
@ -70,6 +71,7 @@ exports.authenticate = async ctx => {
exports.fetchSelf = async ctx => {
const { userId, appId } = ctx.user
/* istanbul ignore next */
if (!userId || !appId) {
ctx.body = {}
return

View file

@ -98,6 +98,11 @@ exports.create = async function(ctx) {
let automation = ctx.request.body
automation.appId = ctx.user.appId
// call through to update if already exists
if (automation._id && automation._rev) {
return exports.update(ctx)
}
automation._id = generateAutomationID()
automation.type = "automation"

View file

@ -1,5 +1,4 @@
const CouchDB = require("../../db")
const bcrypt = require("../../utilities/bcrypt")
const {
generateDatasourceID,
getDatasourceParams,
@ -26,35 +25,12 @@ exports.save = async function(ctx) {
...ctx.request.body,
}
try {
const response = await db.post(datasource)
datasource._rev = response.rev
ctx.status = 200
ctx.message = "Datasource saved successfully."
ctx.body = datasource
} catch (err) {
ctx.throw(err.status, err)
}
}
exports.update = async function(ctx) {
const db = new CouchDB(ctx.user.appId)
const user = ctx.request.body
const dbUser = await db.get(ctx.request.body._id)
if (user.password) {
user.password = await bcrypt.hash(user.password)
} else {
delete user.password
}
const newData = { ...dbUser, ...user }
const response = await db.put(newData)
user._rev = response.rev
const response = await db.post(datasource)
datasource._rev = response.rev
ctx.status = 200
ctx.message = `User ${ctx.request.body.email} updated successfully.`
ctx.body = response
ctx.message = "Datasource saved successfully."
ctx.body = datasource
}
exports.destroy = async function(ctx) {
@ -73,6 +49,5 @@ exports.destroy = async function(ctx) {
exports.find = async function(ctx) {
const database = new CouchDB(ctx.user.appId)
const datasource = await database.get(ctx.params.datasourceId)
ctx.body = datasource
ctx.body = await database.get(ctx.params.datasourceId)
}

View file

@ -38,6 +38,6 @@ exports.destroy = async function(ctx) {
}
await db.remove(layoutId, layoutRev)
ctx.message = "Layout deleted successfully"
ctx.body = { message: "Layout deleted successfully" }
ctx.status = 200
}

View file

@ -110,7 +110,6 @@ exports.preview = async function(ctx) {
if (!Integration) {
ctx.throw(400, "Integration type does not exist.")
return
}
const { fields, parameters, queryVerb } = ctx.request.body
@ -140,7 +139,6 @@ exports.execute = async function(ctx) {
if (!Integration) {
ctx.throw(400, "Integration type does not exist.")
return
}
const enrichedQuery = await enrichQueryFields(

View file

@ -224,6 +224,7 @@ exports.fetchView = async function(ctx) {
try {
table = await db.get(viewInfo.meta.tableId)
} catch (err) {
/* istanbul ignore next */
table = {
schema: {},
}
@ -255,16 +256,24 @@ exports.fetchView = async function(ctx) {
exports.search = async function(ctx) {
const appId = ctx.user.appId
const db = new CouchDB(appId)
const {
query,
pagination: { pageSize = 10, page },
} = ctx.request.body
query.tableId = ctx.params.tableId
// make all strings a starts with operation rather than pure equality
for (const [key, queryVal] of Object.entries(query)) {
if (typeof queryVal === "string") {
query[key] = {
$gt: queryVal,
$lt: `${queryVal}\uffff`,
}
}
}
// pure equality for table
query.tableId = ctx.params.tableId
const response = await db.find({
selector: query,
limit: pageSize,
@ -324,7 +333,6 @@ exports.destroy = async function(ctx) {
const row = await db.get(ctx.params.rowId)
if (row.tableId !== ctx.params.tableId) {
ctx.throw(400, "Supplied tableId doesn't match the row's tableId")
return
}
await linkRows.updateLinks({
appId,
@ -376,15 +384,6 @@ exports.fetchEnrichedRow = async function(ctx) {
const db = new CouchDB(appId)
const tableId = ctx.params.tableId
const rowId = ctx.params.rowId
if (appId == null || tableId == null || rowId == null) {
ctx.status = 400
ctx.body = {
status: 400,
error:
"Cannot handle request, URI params have not been successfully prepared.",
}
return
}
// need table to work out where links go in row
let [table, row] = await Promise.all([
db.get(tableId),

View file

@ -41,6 +41,8 @@ exports.save = async ctx => {
exports.destroy = async ctx => {
const db = new CouchDB(ctx.user.appId)
await db.remove(ctx.params.screenId, ctx.params.screenRev)
ctx.message = "Screen deleted successfully"
ctx.body = {
message: "Screen deleted successfully",
}
ctx.status = 200
}

View file

@ -24,6 +24,8 @@ exports.fetch = async function(ctx) {
}
}
// can't currently test this, have to ignore from coverage
/* istanbul ignore next */
exports.downloadTemplate = async function(ctx) {
const { type, name } = ctx.params

View file

@ -52,7 +52,7 @@ exports.create = async function(ctx) {
const response = await db.post(user)
ctx.status = 200
ctx.message = "User created successfully."
ctx.userId = response._id
ctx.userId = response.id
ctx.body = {
_rev: response.rev,
email,
@ -70,6 +70,9 @@ exports.update = async function(ctx) {
const db = new CouchDB(ctx.user.appId)
const user = ctx.request.body
let dbUser
if (user.email && !user._id) {
user._id = generateUserID(user.email)
}
// get user incase password removed
if (user._id) {
dbUser = await db.get(user._id)
@ -87,14 +90,15 @@ exports.update = async function(ctx) {
user._rev = response.rev
ctx.status = 200
ctx.message = `User ${ctx.request.body.email} updated successfully.`
ctx.body = response
}
exports.destroy = async function(ctx) {
const database = new CouchDB(ctx.user.appId)
await database.destroy(generateUserID(ctx.params.email))
ctx.message = `User ${ctx.params.email} deleted.`
ctx.body = {
message: `User ${ctx.params.email} deleted.`,
}
ctx.status = 200
}

View file

@ -43,12 +43,10 @@ exports.save = async ctx => {
webhook._id = generateWebhookID()
}
const response = await db.put(webhook)
webhook._rev = response.rev
ctx.body = {
message: "Webhook created successfully",
webhook: {
...webhook,
...response,
},
webhook,
}
}
@ -95,5 +93,7 @@ exports.trigger = async ctx => {
})
}
ctx.status = 200
ctx.body = "Webhook trigger fired successfully"
ctx.body = {
message: "Webhook trigger fired successfully",
}
}

View file

@ -41,13 +41,15 @@ router.use(async (ctx, next) => {
try {
await next()
} catch (err) {
ctx.log.error(err)
ctx.status = err.status || err.statusCode || 500
ctx.body = {
message: err.message,
status: ctx.status,
}
console.trace(err)
if (env.NODE_ENV !== "jest") {
ctx.log.error(err)
console.trace(err)
}
}
})

View file

@ -12,7 +12,7 @@ const router = Router()
router
.get("/api/datasources", authorized(BUILDER), datasourceController.fetch)
.get(
"/api/datasources/:id",
"/api/datasources/:datasourceId",
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
datasourceController.find
)

View file

@ -11,11 +11,7 @@ router
.get("/api/hosting/urls", authorized(BUILDER), controller.fetchUrls)
.get("/api/hosting", authorized(BUILDER), controller.fetch)
.post("/api/hosting", authorized(BUILDER), controller.save)
.get(
"/api/hosting/apps",
authorized(BUILDER),
selfhost,
controller.getDeployedApps
)
// this isn't risky, doesn't return anything about apps other than names and URLs
.get("/api/hosting/apps", selfhost, controller.getDeployedApps)
module.exports = router

View file

@ -8,6 +8,7 @@ const usage = require("../../middleware/usageQuota")
const router = Router()
/* istanbul ignore next */
router.param("file", async (file, ctx, next) => {
ctx.file = file && file.includes(".") ? file : "index.html"

View file

@ -0,0 +1,59 @@
const setup = require("./utilities")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
const fs = require("fs")
const path = require("path")
describe("/api/keys", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
describe("fetch", () => {
it("should allow fetching", async () => {
const res = await request
.get(`/api/keys`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toBeDefined()
})
it("should check authorization for builder", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/keys`,
})
})
})
describe("update", () => {
it("should allow updating a value", async () => {
fs.writeFileSync(path.join(budibaseAppsDir(), ".env"), "TEST_API_KEY=thing")
const res = await request
.put(`/api/keys/TEST`)
.send({
value: "test"
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body["TEST"]).toEqual("test")
expect(process.env.TEST_API_KEY).toEqual("test")
})
it("should check authorization for builder", async () => {
await checkBuilderEndpoint({
config,
method: "PUT",
url: `/api/keys/TEST`,
})
})
})
})

View file

@ -58,4 +58,43 @@ describe("/applications", () => {
})
})
describe("fetchAppDefinition", () => {
it("should be able to get an apps definition", async () => {
const res = await request
.get(`/api/applications/${config.getAppId()}/definition`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
// should have empty packages
expect(res.body.screens.length).toEqual(2)
expect(res.body.layouts.length).toEqual(2)
})
})
describe("fetchAppPackage", () => {
it("should be able to fetch the app package", async () => {
const res = await request
.get(`/api/applications/${config.getAppId()}/appPackage`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.application).toBeDefined()
expect(res.body.screens.length).toEqual(2)
expect(res.body.layouts.length).toEqual(2)
})
})
describe("update", () => {
it("should be able to fetch the app package", async () => {
const res = await request
.put(`/api/applications/${config.getAppId()}`)
.send({
name: "TEST_APP"
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.rev).toBeDefined()
})
})
})

View file

@ -0,0 +1,106 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
describe("/authenticate", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
describe("authenticate", () => {
it("should be able to create a layout", async () => {
await config.createUser("test@test.com", "p4ssw0rd")
const res = await request
.post(`/api/authenticate`)
.send({
email: "test@test.com",
password: "p4ssw0rd",
})
.set(config.publicHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.token).toBeDefined()
expect(res.body.email).toEqual("test@test.com")
expect(res.body.password).toBeUndefined()
})
it("should error if no app specified", async () => {
await request
.post(`/api/authenticate`)
.expect(400)
})
it("should error if no email specified", async () => {
await request
.post(`/api/authenticate`)
.send({
password: "test",
})
.set(config.publicHeaders())
.expect(400)
})
it("should error if no password specified", async () => {
await request
.post(`/api/authenticate`)
.send({
email: "test",
})
.set(config.publicHeaders())
.expect(400)
})
it("should error if invalid user specified", async () => {
await request
.post(`/api/authenticate`)
.send({
email: "test",
password: "test",
})
.set(config.publicHeaders())
.expect(401)
})
it("should throw same error if wrong password specified", async () => {
await config.createUser("test@test.com", "password")
await request
.post(`/api/authenticate`)
.send({
email: "test@test.com",
password: "test",
})
.set(config.publicHeaders())
.expect(401)
})
it("should throw an error for inactive users", async () => {
await config.createUser("test@test.com", "password")
await config.makeUserInactive("test@test.com")
await request
.post(`/api/authenticate`)
.send({
email: "test@test.com",
password: "password",
})
.set(config.publicHeaders())
.expect(401)
})
})
describe("fetch self", () => {
it("should be able to delete the layout", async () => {
await config.createUser("test@test.com", "p4ssw0rd")
const headers = await config.login("test@test.com", "p4ssw0rd")
const res = await request
.get(`/api/self`)
.set(headers)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.email).toEqual("test@test.com")
})
})
})

View file

@ -73,7 +73,7 @@ describe("/automations", () => {
.expect('Content-Type', /json/)
.expect(200)
expect(Object.keys(res.body.action).length).toEqual(Object.keys(ACTION_DEFINITIONS).length)
expect(Object.keys(res.body.action).length).toBeGreaterThanOrEqual(Object.keys(ACTION_DEFINITIONS).length)
expect(Object.keys(res.body.trigger).length).toEqual(Object.keys(TRIGGER_DEFINITIONS).length)
expect(Object.keys(res.body.logic).length).toEqual(Object.keys(LOGIC_DEFINITIONS).length)
})
@ -109,6 +109,35 @@ describe("/automations", () => {
automation = res.body.automation
})
it("should be able to create an automation with a webhook trigger", async () => {
const autoConfig = basicAutomation()
autoConfig.definition.trigger = TRIGGER_DEFINITIONS["WEBHOOK"]
autoConfig.definition.trigger.id = "webhook_trigger_id"
const res = await request
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(autoConfig)
.expect('Content-Type', /json/)
.expect(200)
const originalAuto = res.body.automation
expect(originalAuto._id).toBeDefined()
expect(originalAuto._rev).toBeDefined()
// try removing the webhook trigger
const newConfig = originalAuto
newConfig.definition.trigger = TRIGGER_DEFINITIONS["ROW_SAVED"]
newConfig.definition.trigger.id = "row_saved_id"
const newRes = await request
.post(`/api/automations`)
.set(config.defaultHeaders())
.send(newConfig)
.expect('Content-Type', /json/)
.expect(200)
const newAuto = newRes.body.automation
expect(newAuto._id).toEqual(originalAuto._id)
expect(newAuto._rev).toBeDefined()
expect(newAuto._rev).not.toEqual(originalAuto._rev)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
@ -119,6 +148,19 @@ describe("/automations", () => {
})
})
describe("find", () => {
it("should be able to find the automation", async () => {
const automation = await config.createAutomation()
const res = await request
.get(`/api/automations/${automation._id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body._id).toEqual(automation._id)
expect(res.body._rev).toEqual(automation._rev)
})
})
describe("trigger", () => {
it("trigger the automation successfully", async () => {
let table = await config.createTable()

View file

@ -0,0 +1,32 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
describe("/backups", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
describe("exportAppDump", () => {
it("should be able to export app", async () => {
const res = await request
.get(`/api/backups/export?appId=${config.getAppId()}`)
.set(config.defaultHeaders())
.expect(200)
expect(res.text).toBeDefined()
expect(res.text.includes(`"db_name":"${config.getAppId()}"`)).toEqual(true)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/backups/export?appId=${config.getAppId()}`,
})
})
})
})

View file

@ -0,0 +1,16 @@
const setup = require("./utilities")
describe("test things in the Cloud/Self hosted", () => {
describe("test self hosted static page", () => {
it("should be able to load the static page", async () => {
await setup.switchToCloudForFunction(async () => {
let request = setup.getRequest()
let config = setup.getConfig()
await config.init()
const res = await request.get(`/`).expect(200)
expect(res.text.includes("<title>Budibase self hosting</title>")).toEqual(true)
setup.afterAll()
})
})
})
})

View file

@ -0,0 +1,49 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
const fs = require("fs")
const { resolve, join } = require("path")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
describe("/component", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
function mock() {
const manifestFile = "manifest.json"
const appId = config.getAppId()
const libraries = ["@budibase/standard-components"]
for (let library of libraries) {
let appDirectory = resolve(budibaseAppsDir(), appId, "node_modules", library, "package")
fs.mkdirSync(appDirectory, { recursive: true })
const file = require.resolve(library).split("dist/index.js")[0] + manifestFile
fs.copyFileSync(file, join(appDirectory, manifestFile))
}
}
describe("fetch definitions", () => {
it("should be able to fetch definitions", async () => {
// have to "mock" the files required
mock()
const res = await request
.get(`/${config.getAppId()}/components/definitions`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body["@budibase/standard-components/container"]).toBeDefined()
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/${config.getAppId()}/components/definitions`,
})
})
})
})

View file

@ -1,15 +1,17 @@
let { basicDatasource } = require("./utilities/structures")
let { checkBuilderEndpoint } = require("./utilities/TestFunctions")
let {basicDatasource} = require("./utilities/structures")
let {checkBuilderEndpoint} = require("./utilities/TestFunctions")
let setup = require("./utilities")
describe("/datasources", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let datasource
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
datasource = await config.createDatasource()
})
describe("create", () => {
@ -21,22 +23,12 @@ describe("/datasources", () => {
.expect('Content-Type', /json/)
.expect(200)
expect(res.res.statusMessage).toEqual("Datasource saved successfully.");
expect(res.body.name).toEqual("Test");
})
});
expect(res.res.statusMessage).toEqual("Datasource saved successfully.")
expect(res.body.name).toEqual("Test")
})
})
describe("fetch", () => {
let datasource
beforeEach(async () => {
datasource = await config.createDatasource()
});
afterEach(() => {
delete datasource._rev
});
it("returns all the datasources from the server", async () => {
const res = await request
.get(`/api/datasources`)
@ -44,36 +36,37 @@ describe("/datasources", () => {
.expect('Content-Type', /json/)
.expect(200)
const datasources = res.body
expect(datasources).toEqual([
{
"_id": datasources[0]._id,
"_rev": datasources[0]._rev,
...basicDatasource()
}
]);
const datasources = res.body
expect(datasources).toEqual([
{
"_id": datasources[0]._id,
"_rev": datasources[0]._rev,
...basicDatasource()
}
])
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/datasources`,
})
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/datasources`,
})
});
})
})
describe("find", () => {
it("should be able to find a datasource", async () => {
const res = await request
.get(`/api/datasources/${datasource._id}`)
.set(config.defaultHeaders())
.expect(200)
expect(res.body._rev).toBeDefined()
expect(res.body._id).toEqual(datasource._id)
})
})
describe("destroy", () => {
let datasource
beforeEach(async () => {
datasource = await config.createDatasource()
});
afterEach(() => {
delete datasource._rev
});
it("deletes queries for the datasource after deletion and returns a success message", async () => {
await config.createQuery()
@ -87,8 +80,8 @@ describe("/datasources", () => {
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body).toEqual([])
expect(res.body).toEqual([])
})
it("should apply authorization to endpoint", async () => {
@ -99,5 +92,5 @@ describe("/datasources", () => {
})
})
});
});
})
})

View file

@ -0,0 +1,130 @@
// mock out node fetch for this
jest.mock("node-fetch")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
describe("/hosting", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let app
afterAll(setup.afterAll)
beforeEach(async () => {
app = await config.init()
})
describe("fetchInfo", () => {
it("should be able to fetch hosting information", async () => {
const res = await request
.get(`/api/hosting/info`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toEqual({ types: ["cloud", "self"]})
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/hosting/info`,
})
})
})
describe("fetchUrls", () => {
it("should be able to fetch current app URLs", async () => {
const res = await request
.get(`/api/hosting/urls`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.app).toEqual(`https://${config.getAppId()}.app.budi.live`)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/hosting/urls`,
})
})
})
describe("fetch", () => {
it("should be able to fetch the current hosting information", async () => {
const res = await request
.get(`/api/hosting`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toBeDefined()
expect(res.body.hostingUrl).toBeDefined()
expect(res.body.type).toEqual("cloud")
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/hosting`,
})
})
})
describe("save", () => {
it("should be able to update the hosting information", async () => {
const res = await request
.post(`/api/hosting`)
.send({
type: "self",
selfHostKey: "budibase",
hostingUrl: "localhost:10000",
useHttps: false,
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.ok).toEqual(true)
// make sure URL updated
const urlRes = await request
.get(`/api/hosting/urls`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(urlRes.body.app).toEqual(`http://localhost:10000/app`)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/hosting`,
})
})
})
describe("getDeployedApps", () => {
it("should get apps when in builder", async () => {
const res = await request
.get(`/api/hosting/apps`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.app1).toEqual({url: "/app1"})
})
it("should get apps when in cloud", async () => {
await setup.switchToCloudForFunction(async () => {
const res = await request
.get(`/api/hosting/apps`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.app1).toEqual({url: "/app1"})
})
})
})
})

View file

@ -0,0 +1,52 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
describe("/integrations", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
describe("fetch", () => {
it("should be able to get all integration definitions", async () => {
const res = await request
.get(`/api/integrations`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.POSTGRES).toBeDefined()
expect(res.body.POSTGRES.friendlyName).toEqual("PostgreSQL")
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/integrations`,
})
})
})
describe("find", () => {
it("should be able to get postgres definition", async () => {
const res = await request
.get(`/api/integrations/POSTGRES`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.friendlyName).toEqual("PostgreSQL")
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/integrations/POSTGRES`,
})
})
})
})

View file

@ -0,0 +1,55 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
const { basicLayout } = require("./utilities/structures")
describe("/layouts", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let layout
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
layout = await config.createLayout()
})
describe("save", () => {
it("should be able to create a layout", async () => {
const res = await request
.post(`/api/layouts`)
.send(basicLayout())
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._rev).toBeDefined()
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/layouts`,
})
})
})
describe("destroy", () => {
it("should be able to delete the layout", async () => {
const res = await request
.delete(`/api/layouts/${layout._id}/${layout._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBeDefined()
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
url: `/api/layouts/${layout._id}/${layout._rev}`,
})
})
})
})

View file

@ -0,0 +1,38 @@
const setup = require("./utilities")
describe("/analytics", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
describe("isEnabled", () => {
it("check if analytics enabled", async () => {
const res = await request
.get(`/api/analytics`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(typeof res.body.enabled).toEqual("boolean")
})
})
})
describe("/health", () => {
it("should confirm healthy", async () => {
let config = setup.getConfig()
await config.getRequest().get("/health").expect(200)
})
})
describe("/version", () => {
it("should confirm version", async () => {
const config = setup.getConfig()
const res = await config.getRequest().get("/version").expect(200)
expect(res.text.split(".").length).toEqual(3)
})
})

View file

@ -107,4 +107,19 @@ describe("/permission", () => {
expect(res.status).toEqual(403)
})
})
describe("fetch builtins", () => {
it("should be able to fetch builtin definitions", async () => {
const res = await request
.get(`/api/permission/builtin`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(Array.isArray(res.body)).toEqual(true)
const publicPerm = res.body.find(perm => perm._id === "public")
expect(publicPerm).toBeDefined()
expect(publicPerm.permissions).toBeDefined()
expect(publicPerm.name).toBeDefined()
})
})
})

View file

@ -1,17 +1,32 @@
// mock out postgres for this
jest.mock("pg")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const { basicQuery } = require("./utilities/structures")
const { basicQuery, basicDatasource } = require("./utilities/structures")
const setup = require("./utilities")
describe("/queries", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let datasource, query
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
datasource = await config.createDatasource()
query = await config.createQuery()
})
async function createInvalidIntegration() {
const datasource = await config.createDatasource({
...basicDatasource(),
source: "INVALID_INTEGRATION",
})
const query = await config.createQuery()
return { datasource, query }
}
describe("create", () => {
it("should create a new query", async () => {
const { _id } = await config.createDatasource()
@ -35,18 +50,7 @@ describe("/queries", () => {
})
describe("fetch", () => {
let datasource
beforeEach(async () => {
datasource = await config.createDatasource()
})
afterEach(() => {
delete datasource._rev
})
it("returns all the queries from the server", async () => {
const query = await config.createQuery()
const res = await request
.get(`/api/queries`)
.set(config.defaultHeaders())
@ -73,20 +77,34 @@ describe("/queries", () => {
})
})
describe("destroy", () => {
let datasource
beforeEach(async () => {
datasource = await config.createDatasource()
})
afterEach(() => {
delete datasource._rev
})
it("deletes a query and returns a success message", async () => {
describe("find", () => {
it("should find a query in builder", async () => {
const query = await config.createQuery()
const res = await request
.get(`/api/queries/${query._id}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._id).toEqual(query._id)
})
it("should find a query in cloud", async () => {
await setup.switchToCloudForFunction(async () => {
const query = await config.createQuery()
const res = await request
.get(`/api/queries/${query._id}`)
.set(await config.roleHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.fields).toBeUndefined()
expect(res.body.parameters).toBeUndefined()
expect(res.body.schema).toBeUndefined()
})
})
})
describe("destroy", () => {
it("deletes a query and returns a success message", async () => {
await request
.delete(`/api/queries/${query._id}/${query._rev}`)
.set(config.defaultHeaders())
@ -105,8 +123,74 @@ describe("/queries", () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
url: `/api/datasources/${datasource._id}/${datasource._rev}`,
url: `/api/queries/${config._id}/${config._rev}`,
})
})
})
describe("preview", () => {
it("should be able to preview the query", async () => {
const res = await request
.post(`/api/queries/preview`)
.send({
datasourceId: datasource._id,
parameters: {},
fields: {},
queryVerb: "read",
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
// these responses come from the mock
expect(res.body.schemaFields).toEqual(["a", "b"])
expect(res.body.rows.length).toEqual(1)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/queries/preview`,
})
})
it("should fail with invalid integration type", async () => {
const { datasource } = await createInvalidIntegration()
await request
.post(`/api/queries/preview`)
.send({
datasourceId: datasource._id,
parameters: {},
fields: {},
queryVerb: "read",
})
.set(config.defaultHeaders())
.expect(400)
})
})
describe("execute", () => {
it("should be able to execute the query", async () => {
const res = await request
.post(`/api/queries/${query._id}`)
.send({
parameters: {},
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(1)
})
it("should fail with invalid integration type", async () => {
const { query } = await createInvalidIntegration()
await request
.post(`/api/queries/${query._id}`)
.send({
parameters: {},
})
.set(config.defaultHeaders())
.expect(400)
})
})
})

View file

@ -34,14 +34,7 @@ describe("/roles", () => {
describe("fetch", () => {
it("should list custom roles, plus 2 default roles", async () => {
const createRes = await request
.post(`/api/roles`)
.send(basicRole())
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const customRole = createRes.body
const customRole = await config.createRole()
const res = await request
.get(`/api/roles`)
@ -68,24 +61,31 @@ describe("/roles", () => {
BUILTIN_PERMISSION_IDS.READ_ONLY
)
})
it("should be able to get the role with a permission added", async () => {
const table = await config.createTable()
await config.addPermission(BUILTIN_ROLE_IDS.POWER, table._id)
const res = await request
.get(`/api/roles`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toBeGreaterThan(0)
const power = res.body.find(role => role._id === BUILTIN_ROLE_IDS.POWER)
expect(power.permissions[table._id]).toEqual("read")
})
})
describe("destroy", () => {
it("should delete custom roles", async () => {
const createRes = await request
.post(`/api/roles`)
.send({ name: "user", permissionId: BUILTIN_PERMISSION_IDS.READ_ONLY })
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
const customRole = createRes.body
const customRole = await config.createRole({
name: "user",
permissionId: BUILTIN_PERMISSION_IDS.READ_ONLY
})
await request
.delete(`/api/roles/${customRole._id}/${customRole._rev}`)
.set(config.defaultHeaders())
.expect(200)
await request
.get(`/api/roles/${customRole._id}`)
.set(config.defaultHeaders())

View file

@ -0,0 +1,84 @@
const setup = require("./utilities")
const { basicScreen } = require("./utilities/structures")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const { BUILTIN_ROLE_IDS } = require("../../../utilities/security/roles")
const route = "/test"
describe("/routing", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let screen, screen2
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
screen = basicScreen()
screen.routing.route = route
screen = await config.createScreen(screen)
screen2 = basicScreen()
screen2.routing.roleId = BUILTIN_ROLE_IDS.POWER
screen2.routing.route = route
screen2 = await config.createScreen(screen2)
})
describe("fetch", () => {
it("returns the correct routing for basic user", async () => {
const res = await request
.get(`/api/routing/client`)
.set(await config.roleHeaders("basic@test.com", BUILTIN_ROLE_IDS.BASIC))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.routes).toBeDefined()
expect(res.body.routes[route]).toEqual({
subpaths: {
[route]: {
screenId: screen._id,
roleId: screen.routing.roleId
}
}
})
})
it("returns the correct routing for power user", async () => {
const res = await request
.get(`/api/routing/client`)
.set(await config.roleHeaders("basic@test.com", BUILTIN_ROLE_IDS.POWER))
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.routes).toBeDefined()
expect(res.body.routes[route]).toEqual({
subpaths: {
[route]: {
screenId: screen2._id,
roleId: screen2.routing.roleId
}
}
})
})
})
describe("fetch all", () => {
it("should fetch all routes for builder", async () => {
const res = await request
.get(`/api/routing`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.routes).toBeDefined()
expect(res.body.routes[route].subpaths[route]).toBeDefined()
const subpath = res.body.routes[route].subpaths[route]
expect(subpath.screens[screen2.routing.roleId]).toEqual(screen2._id)
expect(subpath.screens[screen.routing.roleId]).toEqual(screen._id)
})
it("make sure it is a builder only endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/routing`,
})
})
})
})

View file

@ -17,15 +17,15 @@ describe("/rows", () => {
row = basicRow(table._id)
})
const loadRow = async id =>
const loadRow = async (id, status = 200) =>
await request
.get(`/api/${table._id}/rows/${id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
.expect(status)
describe("save, load, update, delete", () => {
describe("save, load, update", () => {
it("returns a success message when the row is created", async () => {
const res = await request
.post(`/api/${row.tableId}/rows`)
@ -217,38 +217,152 @@ describe("/rows", () => {
expect(savedRow.body.description).toEqual(existing.description)
expect(savedRow.body.name).toEqual("Updated Name")
})
it("should throw an error when given improper types", async () => {
const existing = await config.createRow()
await request
.patch(`/api/${table._id}/rows/${existing._id}`)
.send({
_id: existing._id,
_rev: existing._rev,
tableId: table._id,
name: 1,
})
.set(config.defaultHeaders())
.expect(400)
})
})
describe("destroy", () => {
it("should be able to delete a row", async () => {
const createdRow = await config.createRow(row)
const res = await request
.delete(`/api/${table._id}/rows/${createdRow._id}/${createdRow._rev}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.ok).toEqual(true)
})
it("shouldn't allow deleting a row in a table which is different to the one the row was created on", async () => {
const createdRow = await config.createRow(row)
await request
.delete(`/api/wrong_table/rows/${createdRow._id}/${createdRow._rev}`)
.set(config.defaultHeaders())
.expect(400)
})
})
describe("validate", () => {
it("should return no errors on valid row", async () => {
const result = await request
const res = await request
.post(`/api/${table._id}/rows/validate`)
.send({ name: "ivan" })
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(result.body.valid).toBe(true)
expect(Object.keys(result.body.errors)).toEqual([])
expect(res.body.valid).toBe(true)
expect(Object.keys(res.body.errors)).toEqual([])
})
it("should errors on invalid row", async () => {
const result = await request
const res = await request
.post(`/api/${table._id}/rows/validate`)
.send({ name: 1 })
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(result.body.valid).toBe(false)
expect(Object.keys(result.body.errors)).toEqual(["name"])
expect(res.body.valid).toBe(false)
expect(Object.keys(res.body.errors)).toEqual(["name"])
})
})
describe("enrich row unit test", () => {
describe("bulkDelete", () => {
it("should be able to delete a bulk set of rows", async () => {
const row1 = await config.createRow()
const row2 = await config.createRow()
const res = await request
.post(`/api/${table._id}/rows`)
.send({
type: "delete",
rows: [
row1,
row2,
]
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.length).toEqual(2)
await loadRow(row1._id, 404)
})
})
describe("search", () => {
it("should run a search on the table", async () => {
const row = await config.createRow()
// add another row that shouldn't be found
await config.createRow({
...basicRow(),
name: "Other Contact",
})
const res = await request
.post(`/api/${table._id}/rows/search`)
.send({
query: {
name: "Test",
},
pagination: { pageSize: 25, page: 0 }
})
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id)
})
})
describe("fetchView", () => {
it("should be able to fetch tables contents via 'view'", async () => {
const row = await config.createRow()
const res = await request
.get(`/api/views/all_${table._id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id)
})
it("should throw an error if view doesn't exist", async () => {
await request
.get(`/api/views/derp`)
.set(config.defaultHeaders())
.expect(400)
})
it("should be able to run on a view", async () => {
const view = await config.createView()
const row = await config.createRow()
const res = await request
.get(`/api/views/${view._id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.length).toEqual(1)
expect(res.body[0]._id).toEqual(row._id)
})
})
describe("user testing", () => {
})
describe("fetchEnrichedRows", () => {
it("should allow enriching some linked rows", async () => {
const table = await config.createLinkedTable()
const firstRow = await config.createRow({
@ -262,30 +376,45 @@ describe("/rows", () => {
link: [{_id: firstRow._id}],
tableId: table._id,
})
const enriched = await outputProcessing(config.getAppId(), table, [secondRow])
expect(enriched[0].link.length).toBe(1)
expect(enriched[0].link[0]._id).toBe(firstRow._id)
expect(enriched[0].link[0].primaryDisplay).toBe("Test Contact")
// test basic enrichment
const resBasic = await request
.get(`/api/${table._id}/rows/${secondRow._id}`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(resBasic.body.link[0]._id).toBe(firstRow._id)
expect(resBasic.body.link[0].primaryDisplay).toBe("Test Contact")
// test full enrichment
const resEnriched = await request
.get(`/api/${table._id}/${secondRow._id}/enrich`)
.set(config.defaultHeaders())
.expect('Content-Type', /json/)
.expect(200)
expect(resEnriched.body.link.length).toBe(1)
expect(resEnriched.body.link[0]._id).toBe(firstRow._id)
expect(resEnriched.body.link[0].name).toBe("Test Contact")
expect(resEnriched.body.link[0].description).toBe("original description")
})
})
it("should allow enriching attachment rows", async () => {
const table = await config.createAttachmentTable()
const row = await config.createRow({
name: "test",
description: "test",
attachment: [{
url: "/test/thing",
}],
tableId: table._id,
describe("attachments", () => {
it("should allow enriching attachment rows", async () => {
const table = await config.createAttachmentTable()
const row = await config.createRow({
name: "test",
description: "test",
attachment: [{
url: "/test/thing",
}],
tableId: table._id,
})
// the environment needs configured for this
await setup.switchToCloudForFunction(async () => {
const enriched = await outputProcessing(config.getAppId(), table, [row])
expect(enriched[0].attachment[0].url).toBe(`/app-assets/assets/${config.getAppId()}/test/thing`)
})
})
// the environment needs configured for this
env.CLOUD = 1
env.SELF_HOSTED = 1
const enriched = await outputProcessing(config.getAppId(), table, [row])
expect(enriched[0].attachment[0].url).toBe(`/app-assets/assets/${config.getAppId()}/test/thing`)
// remove env config
env.CLOUD = undefined
env.SELF_HOSTED = undefined
})
})

View file

@ -0,0 +1,77 @@
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const setup = require("./utilities")
const { basicScreen } = require("./utilities/structures")
describe("/screens", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let screen
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
screen = await config.createScreen()
})
describe("fetch", () => {
it("should be able to create a layout", async () => {
const res = await request
.get(`/api/screens`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.length).toEqual(3)
expect(res.body.some(s => s._id === screen._id)).toEqual(true)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/screens`,
})
})
})
describe("save", () => {
it("should be able to save a screen", async () => {
const screenCfg = basicScreen()
const res = await request
.post(`/api/screens`)
.send(screenCfg)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body._rev).toBeDefined()
expect(res.body.name).toEqual(screenCfg.name)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/screens`,
})
})
})
describe("destroy", () => {
it("should be able to delete the screen", async () => {
const res = await request
.delete(`/api/screens/${screen._id}/${screen._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBeDefined()
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
url: `/api/screens/${screen._id}/${screen._rev}`,
})
})
})
})

View file

@ -0,0 +1,49 @@
const setup = require("./utilities")
const { budibaseAppsDir } = require("../../../utilities/budibaseDir")
const fs = require("fs")
const { join } = require("path")
describe("/templates", () => {
let request = setup.getRequest()
let config = setup.getConfig()
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
})
describe("fetch", () => {
it("should be able to fetch templates", async () => {
const res = await request
.get(`/api/templates`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
// this test is quite light right now, templates aren't heavily utilised yet
expect(Array.isArray(res.body)).toEqual(true)
})
})
describe("export", () => {
it("should be able to export the basic app", async () => {
const res = await request
.post(`/api/templates`)
.send({
templateName: "test",
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toEqual("Created template: test")
const dir = join(
budibaseAppsDir(),
"templates",
"app",
"test",
"db"
)
expect(fs.existsSync(dir)).toEqual(true)
})
})
})

View file

@ -42,15 +42,19 @@ describe("/users", () => {
})
describe("create", () => {
async function create(user, status = 200) {
return request
.post(`/api/users`)
.set(config.defaultHeaders())
.send(user)
.expect(status)
.expect("Content-Type", /json/)
}
it("returns a success message when a user is successfully created", async () => {
const body = basicUser(BUILTIN_ROLE_IDS.POWER)
body.email = "bill@budibase.com"
const res = await request
.post(`/api/users`)
.set(config.defaultHeaders())
.send(body)
.expect(200)
.expect("Content-Type", /json/)
const res = await create(body)
expect(res.res.statusMessage).toEqual("User created successfully.")
expect(res.body._id).toBeUndefined()
@ -68,5 +72,65 @@ describe("/users", () => {
failRole: BUILTIN_ROLE_IDS.PUBLIC,
})
})
it("should error if no email provided", async () => {
const user = basicUser(BUILTIN_ROLE_IDS.POWER)
delete user.email
await create(user, 400)
})
it("should error if no role provided", async () => {
const user = basicUser(null)
await create(user, 400)
})
it("should throw error if user exists already", async () => {
await config.createUser("test@test.com")
const user = basicUser(BUILTIN_ROLE_IDS.POWER)
user.email = "test@test.com"
await create(user, 400)
})
})
describe("update", () => {
it("should be able to update the user", async () => {
const user = await config.createUser()
user.roleId = BUILTIN_ROLE_IDS.BASIC
const res = await request
.put(`/api/users`)
.set(config.defaultHeaders())
.send(user)
.expect(200)
.expect("Content-Type", /json/)
expect(res.body.ok).toEqual(true)
})
})
describe("destroy", () => {
it("should be able to delete the user", async () => {
const email = "test@test.com"
await config.createUser(email)
const res = await request
.delete(`/api/users/${email}`)
.set(config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
expect(res.body.message).toBeDefined()
})
})
describe("find", () => {
it("should be able to find the user", async () => {
const email = "test@test.com"
await config.createUser(email)
const res = await request
.get(`/api/users/${email}`)
.set(config.defaultHeaders())
.expect(200)
.expect("Content-Type", /json/)
expect(res.body.email).toEqual(email)
expect(res.body.roleId).toEqual(BUILTIN_ROLE_IDS.POWER)
expect(res.body.tableId).toBeDefined()
})
})
})

View file

@ -8,9 +8,15 @@ const {
basicAutomation,
basicDatasource,
basicQuery,
basicScreen,
basicLayout,
basicWebhook,
} = require("./structures")
const controllers = require("./controllers")
const supertest = require("supertest")
const fs = require("fs")
const { budibaseAppsDir } = require("../../../../utilities/budibaseDir")
const { join } = require("path")
const EMAIL = "babs@babs.com"
const PASSWORD = "babs_password"
@ -22,6 +28,7 @@ class TestConfiguration {
// we need the request for logging in, involves cookies, hard to fake
this.request = supertest(this.server)
this.appId = null
this.allApps = []
}
getRequest() {
@ -55,6 +62,13 @@ class TestConfiguration {
end() {
this.server.close()
const appDir = budibaseAppsDir()
const files = fs.readdirSync(appDir)
for (let file of files) {
if (this.allApps.some(app => file.includes(app._id))) {
fs.rmdirSync(join(appDir, file), { recursive: true })
}
}
}
defaultHeaders() {
@ -83,9 +97,19 @@ class TestConfiguration {
return headers
}
async roleHeaders(email = EMAIL, roleId = BUILTIN_ROLE_IDS.ADMIN) {
try {
await this.createUser(email, PASSWORD, roleId)
} catch (err) {
// allow errors here
}
return this.login(email, PASSWORD)
}
async createApp(appName) {
this.app = await this._req({ name: appName }, null, controllers.app.create)
this.appId = this.app._id
this.allApps.push(this.app)
return this.app
}
@ -208,6 +232,24 @@ class TestConfiguration {
return this._req(config, null, controllers.query.save)
}
async createScreen(config = null) {
config = config || basicScreen()
return this._req(config, null, controllers.screen.save)
}
async createWebhook(config = null) {
if (!this.automation) {
throw "Must create an automation before creating webhook."
}
config = config || basicWebhook(this.automation._id)
return (await this._req(config, null, controllers.webhook.save)).webhook
}
async createLayout(config = null) {
config = config || basicLayout()
return await this._req(config, null, controllers.layout.save)
}
async createUser(
email = EMAIL,
password = PASSWORD,
@ -224,6 +266,24 @@ class TestConfiguration {
)
}
async makeUserInactive(email) {
const user = await this._req(
null,
{
email,
},
controllers.user.find
)
return this._req(
{
...user,
status: "inactive",
},
null,
controllers.user.update
)
}
async login(email, password) {
if (!email || !password) {
await this.createUser()
@ -241,6 +301,7 @@ class TestConfiguration {
return {
Accept: "application/json",
Cookie: result.headers["set-cookie"],
"x-budibase-app-id": this.appId,
}
}
}

View file

@ -9,4 +9,7 @@ module.exports = {
automation: require("../../../controllers/automation"),
datasource: require("../../../controllers/datasource"),
query: require("../../../controllers/query"),
screen: require("../../../controllers/screen"),
webhook: require("../../../controllers/webhook"),
layout: require("../../../controllers/layout"),
}

View file

@ -1,4 +1,5 @@
const TestConfig = require("./TestConfiguration")
const env = require("../../../../environment")
exports.delay = ms => new Promise(resolve => setTimeout(resolve, ms))
@ -13,6 +14,8 @@ exports.afterAll = () => {
if (config) {
config.end()
}
// clear app files
request = null
config = null
}
@ -30,3 +33,21 @@ exports.getConfig = () => {
}
return config
}
exports.switchToCloudForFunction = async func => {
// self hosted stops any attempts to Dynamo
env.CLOUD = true
env.SELF_HOSTED = true
let error
try {
await func()
} catch (err) {
error = err
}
env.CLOUD = false
env.SELF_HOSTED = false
// don't throw error until after reset
if (error) {
throw error
}
}

View file

@ -2,6 +2,9 @@ const { BUILTIN_ROLE_IDS } = require("../../../../utilities/security/roles")
const {
BUILTIN_PERMISSION_IDS,
} = require("../../../../utilities/security/permissions")
const { createHomeScreen } = require("../../../../constants/screens")
const { EMPTY_LAYOUT } = require("../../../../constants/layouts")
const { cloneDeep } = require("lodash/fp")
exports.basicTable = () => {
return {
@ -85,3 +88,22 @@ exports.basicUser = role => {
roleId: role,
}
}
exports.basicScreen = () => {
return createHomeScreen()
}
exports.basicLayout = () => {
return cloneDeep(EMPTY_LAYOUT)
}
exports.basicWebhook = automationId => {
return {
live: true,
name: "webhook",
action: {
type: "automation",
target: automationId,
},
}
}

View file

@ -0,0 +1,130 @@
const setup = require("./utilities")
const { checkBuilderEndpoint } = require("./utilities/TestFunctions")
const { basicWebhook, basicAutomation } = require("./utilities/structures")
describe("/webhooks", () => {
let request = setup.getRequest()
let config = setup.getConfig()
let webhook
afterAll(setup.afterAll)
beforeEach(async () => {
await config.init()
const autoConfig = basicAutomation()
autoConfig.definition.trigger = {
schema: { outputs: { properties: {} } },
inputs: {},
}
await config.createAutomation(autoConfig)
webhook = await config.createWebhook()
})
describe("create", () => {
it("should create a webhook successfully", async () => {
const automation = await config.createAutomation()
const res = await request
.put(`/api/webhooks`)
.send(basicWebhook(automation._id))
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.webhook).toBeDefined()
expect(typeof res.body.webhook._id).toEqual("string")
expect(typeof res.body.webhook._rev).toEqual("string")
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "PUT",
url: `/api/webhooks`,
})
})
})
describe("fetch", () => {
it("returns the correct routing for basic user", async () => {
const res = await request
.get(`/api/webhooks`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(Array.isArray(res.body)).toEqual(true)
expect(res.body[0]._id).toEqual(webhook._id)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "GET",
url: `/api/webhooks`,
})
})
})
describe("delete", () => {
it("should successfully delete", async () => {
const res = await request
.delete(`/api/webhooks/${webhook._id}/${webhook._rev}`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toBeDefined()
expect(res.body.ok).toEqual(true)
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "DELETE",
url: `/api/webhooks/${webhook._id}/${webhook._rev}`,
})
})
})
describe("build schema", () => {
it("should allow building a schema", async () => {
const res = await request
.post(`/api/webhooks/schema/${config.getAppId()}/${webhook._id}`)
.send({
a: 1
})
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(res.body).toBeDefined()
// fetch to see if the schema has been updated
const fetch = await request
.get(`/api/webhooks`)
.set(config.defaultHeaders())
.expect("Content-Type", /json/)
.expect(200)
expect(fetch.body[0]).toBeDefined()
expect(fetch.body[0].bodySchema).toEqual({
properties: {
a: { type: "integer" }
},
type: "object",
})
})
it("should apply authorization to endpoint", async () => {
await checkBuilderEndpoint({
config,
method: "POST",
url: `/api/webhooks/schema/${config.getAppId()}/${webhook._id}`,
})
})
})
describe("trigger", () => {
it("should allow triggering from public", async () => {
const res = await request
.post(`/api/webhooks/trigger/${config.getAppId()}/${webhook._id}`)
.expect("Content-Type", /json/)
.expect(200)
expect(res.body.message).toBeDefined()
})
})
})

View file

@ -21,7 +21,7 @@ router
controller.find
)
.put(
"/api/users/",
"/api/users",
authorized(PermissionTypes.USER, PermissionLevels.WRITE),
controller.update
)

View file

@ -56,7 +56,11 @@ if (electron.app && electron.app.isPackaged) {
const server = http.createServer(app.callback())
destroyable(server)
server.on("close", () => console.log("Server Closed"))
server.on("close", () => {
if (env.NODE_ENV !== "jest") {
console.log("Server Closed")
}
})
module.exports = server.listen(env.PORT || 0, async () => {
console.log(`Budibase running on ${JSON.stringify(server.address())}`)

View file

@ -31,6 +31,7 @@ module.exports = async (ctx, next) => {
token = ctx.cookies.get(getCookieName())
authType = AuthTypes.BUILDER
}
if (!token && appId) {
token = ctx.cookies.get(getCookieName(appId))
authType = AuthTypes.APP
@ -58,6 +59,7 @@ module.exports = async (ctx, next) => {
role: await getRole(appId, jwtPayload.roleId),
}
} catch (err) {
console.log(err)
if (authType === AuthTypes.BUILDER) {
clearCookie(ctx)
ctx.status = 200

View file

@ -13,7 +13,7 @@ const { AuthTypes } = require("../constants")
const ADMIN_ROLES = [BUILTIN_ROLE_IDS.ADMIN, BUILTIN_ROLE_IDS.BUILDER]
const LOCAL_PASS = new RegExp(["webhooks/trigger", "webhooks/schema"].join("|"))
const LOCAL_PASS = new RegExp(["webhooks/trigger"].join("|"))
function hasResource(ctx) {
return ctx.resourceId != null
@ -24,6 +24,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
if (!env.CLOUD && LOCAL_PASS.test(ctx.request.url)) {
return next()
}
if (env.CLOUD && ctx.headers["x-api-key"] && ctx.headers["x-instanceid"]) {
// api key header passed by external webhook
if (await isAPIKeyValid(ctx.headers["x-api-key"])) {
@ -37,14 +38,14 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
return next()
}
ctx.throw(403, "API key invalid")
return ctx.throw(403, "API key invalid")
}
// don't expose builder endpoints in the cloud
if (env.CLOUD && permType === PermissionTypes.BUILDER) return
if (!ctx.user) {
ctx.throw(403, "No user info found")
return ctx.throw(403, "No user info found")
}
const role = ctx.user.role
@ -52,7 +53,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
ctx.appId,
role._id
)
const isAdmin = ADMIN_ROLES.indexOf(role._id) !== -1
const isAdmin = ADMIN_ROLES.includes(role._id)
const isAuthed = ctx.auth.authenticated
// this may need to change in the future, right now only admins
@ -61,7 +62,7 @@ module.exports = (permType, permLevel = null) => async (ctx, next) => {
if (isAdmin && isAuthed) {
return next()
} else if (permType === PermissionTypes.BUILDER) {
ctx.throw(403, "Not Authorized")
return ctx.throw(403, "Not Authorized")
}
if (

View file

@ -36,6 +36,8 @@ class ResourceIdGetter {
}
}
module.exports.ResourceIdGetter = ResourceIdGetter
module.exports.paramResource = main => {
return new ResourceIdGetter("params").mainResource(main).build()
}

View file

@ -0,0 +1,28 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Authenticated middleware sets the correct APP auth type information when the user is not in the builder 1`] = `
Object {
"apiKey": "1234",
"appId": "budibase:app:local",
"role": Role {
"_id": "ADMIN",
"inherits": "POWER",
"name": "Admin",
"permissionId": "admin",
},
"roleId": "ADMIN",
}
`;
exports[`Authenticated middleware sets the correct BUILDER auth type information when the x-budibase-type header is not 'client' 1`] = `
Object {
"apiKey": "1234",
"appId": "budibase:builder:local",
"role": Role {
"_id": "BUILDER",
"name": "Builder",
"permissionId": "admin",
},
"roleId": "BUILDER",
}
`;

View file

@ -0,0 +1,125 @@
const { AuthTypes } = require("../../constants")
const authenticatedMiddleware = require("../authenticated")
const jwt = require("jsonwebtoken")
jest.mock("jsonwebtoken")
class TestConfiguration {
constructor(middleware) {
this.middleware = authenticatedMiddleware
this.ctx = {
config: {},
auth: {},
cookies: {
set: jest.fn(),
get: jest.fn()
},
headers: {},
params: {},
path: "",
request: {
headers: {}
},
throw: jest.fn()
}
this.next = jest.fn()
}
setHeaders(headers) {
this.ctx.headers = headers
}
executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
afterEach() {
jest.resetAllMocks()
}
}
describe("Authenticated middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
it("calls next() when on the builder path", async () => {
config.ctx.path = "/_builder"
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
it("sets a new cookie when the current cookie does not match the app id from context", async () => {
const appId = "app_123"
config.setHeaders({
"x-budibase-app-id": appId
})
config.ctx.cookies.get.mockImplementation(() => "cookieAppId")
await config.executeMiddleware()
expect(config.ctx.cookies.set).toHaveBeenCalledWith(
"budibase:currentapp:local",
appId,
expect.any(Object)
)
})
it("sets the correct BUILDER auth type information when the x-budibase-type header is not 'client'", async () => {
config.ctx.cookies.get.mockImplementation(() => "budibase:builder:local")
jwt.verify.mockImplementationOnce(() => ({
apiKey: "1234",
roleId: "BUILDER"
}))
await config.executeMiddleware()
expect(config.ctx.auth.authenticated).toEqual(AuthTypes.BUILDER)
expect(config.ctx.user).toMatchSnapshot()
})
it("sets the correct APP auth type information when the user is not in the builder", async () => {
config.setHeaders({
"x-budibase-type": "client"
})
config.ctx.cookies.get.mockImplementation(() => `budibase:app:local`)
jwt.verify.mockImplementationOnce(() => ({
apiKey: "1234",
roleId: "ADMIN"
}))
await config.executeMiddleware()
expect(config.ctx.auth.authenticated).toEqual(AuthTypes.APP)
expect(config.ctx.user).toMatchSnapshot()
})
it("marks the user as unauthenticated when a token cannot be determined from the users cookie", async () => {
config.executeMiddleware()
expect(config.ctx.auth.authenticated).toBe(false)
expect(config.ctx.user.role).toEqual({
_id: "PUBLIC",
name: "Public",
permissionId: "public"
})
})
it("clears the cookie when there is an error authenticating in the builder", async () => {
config.ctx.cookies.get.mockImplementation(() => "budibase:builder:local")
jwt.verify.mockImplementationOnce(() => {
throw new Error()
})
await config.executeMiddleware()
expect(config.ctx.cookies.set).toBeCalledWith("budibase:builder:local")
})
})

View file

@ -0,0 +1,196 @@
const authorizedMiddleware = require("../authorized")
const env = require("../../environment")
const apiKey = require("../../utilities/security/apikey")
const { AuthTypes } = require("../../constants")
const { PermissionTypes, PermissionLevels } = require("../../utilities/security/permissions")
const { Test } = require("supertest")
jest.mock("../../environment")
jest.mock("../../utilities/security/apikey")
class TestConfiguration {
constructor(role) {
this.middleware = authorizedMiddleware(role)
this.next = jest.fn()
this.throw = jest.fn()
this.ctx = {
headers: {},
request: {
url: ""
},
auth: {},
next: this.next,
throw: this.throw
}
}
executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
setUser(user) {
this.ctx.user = user
}
setMiddlewareRequiredPermission(...perms) {
this.middleware = authorizedMiddleware(...perms)
}
setResourceId(id) {
this.ctx.resourceId = id
}
setAuthenticated(isAuthed) {
this.ctx.auth = { authenticated: isAuthed }
}
setRequestUrl(url) {
this.ctx.request.url = url
}
setCloudEnv(isCloud) {
env.CLOUD = isCloud
}
setRequestHeaders(headers) {
this.ctx.headers = headers
}
afterEach() {
jest.clearAllMocks()
}
}
describe("Authorization middleware", () => {
const next = jest.fn()
let config
afterEach(() => {
config.afterEach()
})
beforeEach(() => {
config = new TestConfiguration()
})
it("passes the middleware for local webhooks", async () => {
config.setRequestUrl("https://something/webhooks/trigger")
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
describe("external web hook call", () => {
let ctx = {}
let middleware
beforeEach(() => {
config = new TestConfiguration()
config.setCloudEnv(true)
config.setRequestHeaders({
"x-api-key": "abc123",
"x-instanceid": "instance123",
})
})
it("passes to next() if api key is valid", async () => {
apiKey.isAPIKeyValid.mockResolvedValueOnce(true)
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
expect(config.ctx.auth).toEqual({
authenticated: AuthTypes.EXTERNAL,
apiKey: config.ctx.headers["x-api-key"],
})
expect(config.ctx.user).toEqual({
appId: config.ctx.headers["x-instanceid"],
})
})
it("throws if api key is invalid", async () => {
apiKey.isAPIKeyValid.mockResolvedValueOnce(false)
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(403, "API key invalid")
})
})
describe("non-webhook call", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
config.setCloudEnv(true)
config.setAuthenticated(true)
})
it("throws when no user data is present in context", async () => {
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(403, "No user info found")
})
it("passes on to next() middleware if user is an admin", async () => {
config.setUser({
role: {
_id: "ADMIN",
}
})
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
it("throws if the user has only builder permissions", async () => {
config.setCloudEnv(false)
config.setMiddlewareRequiredPermission(PermissionTypes.BUILDER)
config.setUser({
role: {
_id: ""
}
})
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(403, "Not Authorized")
})
it("passes on to next() middleware if the user has resource permission", async () => {
config.setResourceId(PermissionTypes.QUERY)
config.setUser({
role: {
_id: ""
}
})
config.setMiddlewareRequiredPermission(PermissionTypes.QUERY)
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
it("throws if the user session is not authenticated after permission checks", async () => {
config.setUser({
role: {
_id: ""
},
})
config.setAuthenticated(false)
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(403, "Session not authenticated")
})
it("throws if the user does not have base permissions to perform the operation", async () => {
config.setUser({
role: {
_id: ""
},
})
config.setMiddlewareRequiredPermission(PermissionTypes.ADMIN, PermissionLevels.BASIC)
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(403, "User does not have permission")
})
})
})

View file

@ -0,0 +1,105 @@
const {
paramResource,
paramSubResource,
bodyResource,
bodySubResource,
ResourceIdGetter
} = require("../resourceId")
class TestConfiguration {
constructor(middleware) {
this.middleware = middleware
this.ctx = {
request: {},
}
this.next = jest.fn()
}
setParams(params) {
this.ctx.params = params
}
setBody(body) {
this.ctx.body = body
}
executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
}
describe("resourceId middleware", () => {
it("calls next() when there is no request object to parse", () => {
const config = new TestConfiguration(paramResource("main"))
config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
expect(config.ctx.resourceId).toBeUndefined()
})
it("generates a resourceId middleware for context query parameters", () => {
const config = new TestConfiguration(paramResource("main"))
config.setParams({
main: "test"
})
config.executeMiddleware()
expect(config.ctx.resourceId).toEqual("test")
})
it("generates a resourceId middleware for context query sub parameters", () => {
const config = new TestConfiguration(paramSubResource("main", "sub"))
config.setParams({
main: "main",
sub: "test"
})
config.executeMiddleware()
expect(config.ctx.resourceId).toEqual("main")
expect(config.ctx.subResourceId).toEqual("test")
})
it("generates a resourceId middleware for context request body", () => {
const config = new TestConfiguration(bodyResource("main"))
config.setBody({
main: "test"
})
config.executeMiddleware()
expect(config.ctx.resourceId).toEqual("test")
})
it("generates a resourceId middleware for context request body sub fields", () => {
const config = new TestConfiguration(bodySubResource("main", "sub"))
config.setBody({
main: "main",
sub: "test"
})
config.executeMiddleware()
expect(config.ctx.resourceId).toEqual("main")
expect(config.ctx.subResourceId).toEqual("test")
})
it("parses resourceIds correctly for custom middlewares", () => {
const middleware = new ResourceIdGetter("body")
.mainResource("custom")
.subResource("customSub")
.build()
config = new TestConfiguration(middleware)
config.setBody({
custom: "test",
customSub: "subtest"
})
config.executeMiddleware()
expect(config.ctx.resourceId).toEqual("test")
expect(config.ctx.subResourceId).toEqual("subtest")
})
})

View file

@ -0,0 +1,75 @@
const selfHostMiddleware = require("../selfhost");
const env = require("../../environment")
const hosting = require("../../utilities/builder/hosting");
jest.mock("../../environment")
jest.mock("../../utilities/builder/hosting")
class TestConfiguration {
constructor() {
this.next = jest.fn()
this.throw = jest.fn()
this.middleware = selfHostMiddleware
this.ctx = {
next: this.next,
throw: this.throw
}
}
executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
setCloudHosted() {
env.CLOUD = 1
env.SELF_HOSTED = 0
}
setSelfHosted() {
env.CLOUD = 0
env.SELF_HOSTED = 1
}
afterEach() {
jest.clearAllMocks()
}
}
describe("Self host middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
afterEach(() => {
config.afterEach()
})
it("calls next() when CLOUD and SELF_HOSTED env vars are set", async () => {
env.CLOUD = 1
env.SELF_HOSTED = 1
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
it("throws when hostingInfo type is cloud", async () => {
config.setSelfHosted()
hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.CLOUD }))
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(400, "Endpoint unavailable in cloud hosting.")
expect(config.next).not.toHaveBeenCalled()
})
it("calls the self hosting middleware to pass through to next() when the hostingInfo type is self", async () => {
config.setSelfHosted()
hosting.getHostingInfo.mockImplementationOnce(() => ({ type: hosting.HostingTypes.SELF }))
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
})

View file

@ -0,0 +1,129 @@
const usageQuotaMiddleware = require("../usageQuota")
const usageQuota = require("../../utilities/usageQuota")
const CouchDB = require("../../db")
const env = require("../../environment")
jest.mock("../../db");
jest.mock("../../utilities/usageQuota")
jest.mock("../../environment")
class TestConfiguration {
constructor() {
this.throw = jest.fn()
this.next = jest.fn()
this.middleware = usageQuotaMiddleware
this.ctx = {
throw: this.throw,
next: this.next,
user: {
appId: "test"
},
request: {
body: {}
},
req: {
method: "POST",
url: "/rows"
}
}
}
executeMiddleware() {
return this.middleware(this.ctx, this.next)
}
cloudHosted(bool) {
if (bool) {
env.CLOUD = 1
this.ctx.auth = { apiKey: "test" }
} else {
env.CLOUD = 0
}
}
setMethod(method) {
this.ctx.req.method = method
}
setUrl(url) {
this.ctx.req.url = url
}
setBody(body) {
this.ctx.request.body = body
}
setFiles(files) {
this.ctx.request.files = { file: files }
}
}
describe("usageQuota middleware", () => {
let config
beforeEach(() => {
config = new TestConfiguration()
})
it("skips the middleware if there is no usage property or method", async () => {
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
})
it("passes through to next middleware if document already exists", async () => {
config.setBody({
_id: "test"
})
CouchDB.mockImplementationOnce(() => ({
get: async () => true
}))
await config.executeMiddleware()
expect(config.next).toHaveBeenCalled()
expect(config.ctx.preExisting).toBe(true)
})
it("throws if request has _id, but the document no longer exists", async () => {
config.setBody({
_id: "123"
})
CouchDB.mockImplementationOnce(() => ({
get: async () => {
throw new Error()
}
}))
await config.executeMiddleware()
expect(config.throw).toHaveBeenCalledWith(404, `${config.ctx.request.body._id} does not exist`)
})
it("calculates and persists the correct usage quota for the relevant action", async () => {
config.setUrl("/rows")
config.cloudHosted(true)
await config.executeMiddleware()
expect(usageQuota.update).toHaveBeenCalledWith("test", "rows", 1)
expect(config.next).toHaveBeenCalled()
})
it("calculates the correct file size from a file upload call and adds it to quota", async () => {
config.setUrl("/upload")
config.cloudHosted(true)
config.setFiles([
{
size: 100
},
{
size: 10000
},
])
await config.executeMiddleware()
expect(usageQuota.update).toHaveBeenCalledWith("test", "storage", 10100)
expect(config.next).toHaveBeenCalled()
})
})

View file

@ -43,6 +43,7 @@ module.exports = async (ctx, next) => {
return
}
}
// if running in builder or a self hosted cloud usage quotas should not be executed
if (!env.CLOUD || env.SELF_HOSTED) {
return next()

View file

@ -205,7 +205,8 @@ class AccessController {
tryingRoleId == null ||
tryingRoleId === "" ||
tryingRoleId === userRoleId ||
tryingRoleId === BUILTIN_IDS.BUILDER
tryingRoleId === BUILTIN_IDS.BUILDER ||
userRoleId === BUILTIN_IDS.BUILDER
) {
return true
}

View file

@ -31,6 +31,8 @@ exports.getLocalTemplates = function() {
return templateObj
}
// can't really test this, downloading is just not something we should do in a behavioural test
/* istanbul ignore next */
exports.downloadTemplate = async function(type, name) {
const dirName = join(budibaseAppsDir(), "templates", type, name)
if (env.LOCAL_TEMPLATES) {
@ -67,8 +69,7 @@ exports.performDump = performDump
exports.exportTemplateFromApp = async function({ templateName, appId }) {
// Copy frontend files
const templatesDir = join(
os.homedir(),
".budibase",
budibaseAppsDir(),
"templates",
"app",
templateName,

View file

@ -7,6 +7,11 @@
resolved "https://registry.yarnpkg.com/7zip-bin/-/7zip-bin-5.0.3.tgz#bc5b5532ecafd923a61f2fb097e3b108c0106a3f"
integrity sha512-GLyWIFBbGvpKPGo55JyRZAo4lVbnBiD52cKlw/0Vt+wnmKvWJkpZvsjVoaIolyBXDeAQKSicRtqFNPem9w0WYA==
"@adobe/spectrum-css-workflow-icons@^1.1.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@adobe/spectrum-css-workflow-icons/-/spectrum-css-workflow-icons-1.2.0.tgz#cda8bbe873ba9317160458858ae979e5393e5550"
integrity sha512-STSQQHvoBM0kf1JrNL3KEt88RklIctaGyGOzwUTnhtTkT1jHLaF4FgxrPDCvr1AT8VOq1nGplKUCeyZ9vdUUmA==
"@azure/ms-rest-azure-env@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@azure/ms-rest-azure-env/-/ms-rest-azure-env-1.1.2.tgz#8505873afd4a1227ec040894a64fdd736b4a101f"
@ -249,12 +254,24 @@
lodash "^4.17.19"
to-fast-properties "^2.0.0"
"@budibase/client@^0.8.3":
version "0.8.3"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.8.3.tgz#944a745cc82845987cabd48e2ce3a7e58b387865"
integrity sha512-gEOmHlqStsFTtotduRRz9bld2s/066pSwM3CWRuspsz5yycPLMhWKcA3CdfxVlNoR9y7I7IFC9+pfM5STDJAMQ==
"@budibase/bbui@^1.58.13":
version "1.58.13"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.58.13.tgz#59df9c73def2d81c75dcbd2266c52c19db88dbd7"
integrity sha512-Zk6CKXdBfKsTVzA1Xs5++shdSSZLfphVpZuKVbjfzkgtuhyH7ruucexuSHEpFsxjW5rEKgKIBoRFzCK5vPvN0w==
dependencies:
"@budibase/string-templates" "^0.8.3"
markdown-it "^12.0.2"
quill "^1.3.7"
sirv-cli "^0.4.6"
svelte-flatpickr "^2.4.0"
svelte-portal "^1.0.0"
turndown "^7.0.0"
"@budibase/client@^0.8.5":
version "0.8.5"
resolved "https://registry.yarnpkg.com/@budibase/client/-/client-0.8.5.tgz#31a6bbf8e7ff2a5ab635e8987357c310dcedf555"
integrity sha512-igiHyFpqbYm2EyCy0aUlBlaPibpFa5DtQow1kFBAjUW2cyZdEt84JV4Mei77NueGo7zHcr6/ByF6ycdyeBgXQw==
dependencies:
"@budibase/string-templates" "^0.8.5"
deep-equal "^2.0.1"
regexparam "^1.3.0"
shortid "^2.2.15"
@ -292,10 +309,42 @@
to-gfm-code-block "^0.1.1"
year "^0.2.1"
"@budibase/string-templates@^0.8.3":
version "0.8.3"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-0.8.3.tgz#f3b1f31ef914b926fb5285bc701e1200568dc92d"
integrity sha512-X4Z9/1TS5PtO5sF1CDoyp8xSJhXFWIhOldTNBzPeCjAaD+c9Q8gOgcwECWugJh2d05RjiVI6gDbeirT8Q2QMig==
"@budibase/standard-components@^0.8.5":
version "0.8.5"
resolved "https://registry.yarnpkg.com/@budibase/standard-components/-/standard-components-0.8.5.tgz#4b94653110e4f20a8cb252b6421b620fd5ac31bc"
integrity sha512-wDEuxiu/DyPQYR2zQSt7TdPlAzdjjePitfKDzdIxm/WM7umXDSvLkA39nRzicEXikti34+waS7H96xGNuednVw==
dependencies:
"@adobe/spectrum-css-workflow-icons" "^1.1.0"
"@budibase/bbui" "^1.58.13"
"@budibase/svelte-ag-grid" "^1.0.4"
"@spectrum-css/actionbutton" "^1.0.0-beta.1"
"@spectrum-css/button" "^3.0.0-beta.6"
"@spectrum-css/checkbox" "^3.0.0-beta.6"
"@spectrum-css/fieldlabel" "^3.0.0-beta.7"
"@spectrum-css/icon" "^3.0.0-beta.2"
"@spectrum-css/inputgroup" "^3.0.0-beta.7"
"@spectrum-css/menu" "^3.0.0-beta.5"
"@spectrum-css/page" "^3.0.0-beta.0"
"@spectrum-css/picker" "^1.0.0-beta.3"
"@spectrum-css/popover" "^3.0.0-beta.6"
"@spectrum-css/stepper" "^3.0.0-beta.7"
"@spectrum-css/textfield" "^3.0.0-beta.6"
"@spectrum-css/vars" "^3.0.0-beta.2"
apexcharts "^3.22.1"
flatpickr "^4.6.6"
loadicons "^1.0.0"
lodash.debounce "^4.0.8"
markdown-it "^12.0.2"
quill "^1.3.7"
remixicon "^2.5.0"
svelte-apexcharts "^1.0.2"
svelte-flatpickr "^3.1.0"
turndown "^7.0.0"
"@budibase/string-templates@^0.8.5":
version "0.8.5"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-0.8.5.tgz#ad30e318f7486d4256b1165099fe2bd8004ef472"
integrity sha512-PcpiiDlYJFIVwtFGIRqZQtRl8wbO6yr0/+1Gca0TwR2WhyUyAs/ojO+jLIj97JWh/hE5zKaZW7d4cMOf+BDI/A==
dependencies:
"@budibase/handlebars-helpers" "^0.11.3"
dayjs "^1.10.4"
@ -303,6 +352,13 @@
handlebars-utils "^1.0.6"
lodash "^4.17.20"
"@budibase/svelte-ag-grid@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@budibase/svelte-ag-grid/-/svelte-ag-grid-1.0.4.tgz#41cceec4bde2c4aea8b9da8f610fe36055c7709f"
integrity sha512-JZm6qujxnZpqw7Twbegr6se4sHhyWzN0Cibrk5bVBH32hBgzD6dd33fxwrjHKkWFxjys9wRT+cqYgYVlSt9E3w==
dependencies:
ag-grid-community "^24.0.0"
"@cnakazawa/watch@^1.0.3":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a"
@ -830,6 +886,11 @@
path-to-regexp "^1.1.1"
urijs "^1.19.0"
"@polka/url@^0.5.0":
version "0.5.0"
resolved "https://registry.yarnpkg.com/@polka/url/-/url-0.5.0.tgz#b21510597fd601e5d7c95008b76bf0d254ebfd31"
integrity sha512-oZLYFEAzUKyi3SKnXvj32ZCEGH6RDnao7COuCVhDydMS9NrCSVXhM79VaKyP5+Zc33m0QXEd2DN3UkU7OsHcfw==
"@sendgrid/client@^7.1.1":
version "7.4.2"
resolved "https://registry.yarnpkg.com/@sendgrid/client/-/client-7.4.2.tgz#204a9fbb5dc05a721a5d8cd8930f57f9f8e612b1"
@ -942,6 +1003,73 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==
"@spectrum-css/actionbutton@^1.0.0-beta.1":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/actionbutton/-/actionbutton-1.0.0.tgz#c2f0939f6d49de0a855f08a9466e3f27105a1747"
integrity sha512-klE5CGJEJXkc4DMLF8W+VPlLZ6SFr4WXI5Tc9NarOtbAc7mqhs2gWA8HpsPT717FWdxRVVt3sSuAydgKC/T0UA==
"@spectrum-css/button@^3.0.0-beta.6":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/button/-/button-3.0.0.tgz#eebdd7a05eac9a40f297f802aca3efeb95931e83"
integrity sha512-CUGkuHOhqgfIRYTEceybcW1YsUN61F9BgDhqymhVd1yJFsuh1xkwnmv3IIodukgS+1e3L0JY6ifU86IWX/Dx5w==
"@spectrum-css/checkbox@^3.0.0-beta.6":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/checkbox/-/checkbox-3.0.0.tgz#6ed15f433bed31a63818d154960aca044ce62182"
integrity sha512-FpxxftMzuWT8qq3XB4oBQgWglXuCCEGBfgX82EI9VtrJmw9j0Lm/nThMLX353p9awM4GfT3l2LNOneHbNetaRQ==
"@spectrum-css/fieldlabel@^3.0.0-beta.7":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/fieldlabel/-/fieldlabel-3.0.0.tgz#01be5e5a7b024516574820962b9a656f7b2e20d4"
integrity sha512-dEOvDEigL9E60kQ9fT6MLyRzPKrPXAKulqDYOYpZaK2bsKrbIvsKb7NcuQynPAOE26FiuqQsp2khv5VqF4KzrA==
"@spectrum-css/icon@^3.0.0-beta.2":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/icon/-/icon-3.0.0.tgz#a822c901ca049f487420053dfeaaf71c0850c848"
integrity sha512-0VVx34WECxe+acSZsB+zk8T+AG8YimlCfUothuqLzcUgY6MnBESHJKOEuKKihxnihEm6EJiMc2NYA7+09kPv/A==
"@spectrum-css/inputgroup@^3.0.0-beta.7":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/inputgroup/-/inputgroup-3.0.0.tgz#c2790c2c0a4c435ca3fff9ba04f64dd2b252f980"
integrity sha512-dlF8LmMwTa5G6Rl4zUiNCmRv7p2v+88jINnSwZHucgKZL0/HJZBRxjF1neeSfRFrc8R6cemoVXDHRDtZFaVtXQ==
"@spectrum-css/menu@^3.0.0-beta.5":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/menu/-/menu-3.0.0.tgz#78153ea60a36c87e9d815ce51dc7f84d6b9b9abd"
integrity sha512-E6L6s1/cwh6hn4yhUHegiJ+Su03Bpa7qP5a6nEccpYePZxPAAN2FjZBWdMOPlGtv1e70vudAsoejli9nVthC2w==
"@spectrum-css/page@^3.0.0-beta.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/page/-/page-3.0.0.tgz#159e79fd376e2def1a7a25a8b8a8fcfa94bd79d1"
integrity sha512-4rNpGq99cfNSq/IOQNCiXio5gF/EEfjcSmihHBJlh7/VOB9zE84kMNW1Gux4cGEmdP14U1Zo1ZwnPIVs5ZuPgg==
dependencies:
"@spectrum-css/vars" "^3.0.0"
"@spectrum-css/picker@^1.0.0-beta.3":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/picker/-/picker-1.0.0.tgz#5758a128da081becd425b8d9433b24541f12b4b3"
integrity sha512-aSoin2SVYl5W2R3nFp+V/Er6rAJUnwygO4E3g/tfDuImq8p5U3FKZj4sggSqfuD2U1PIwNSwX0D1RdxuGXsnUQ==
"@spectrum-css/popover@^3.0.0-beta.6":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/popover/-/popover-3.0.0.tgz#ec1ab86a66cc59bd522d3de2b7febe41e2a9fe46"
integrity sha512-Lr2FZSJbDbDMp3bOlLtvDjOw6AwzRu3g0BbQ7NGK1l5MB06AhnqJX+TPB2iEDTfPdNyaDc5SCp55lBHP3RzHuw==
"@spectrum-css/stepper@^3.0.0-beta.7":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/stepper/-/stepper-3.0.0.tgz#ab5af818c86f2bc5050d0caee8b0a1c75201bfaf"
integrity sha512-Gwvb4YLEBy/YtnFQ4aySnlve+pBrgPIm5LSq5IkeyjAKy7ZalQm9IIEkrVERHO1b+vbRZ6DW/aj2zYgzKgGMrA==
"@spectrum-css/textfield@^3.0.0-beta.6":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/textfield/-/textfield-3.0.0.tgz#2f2d341b8d2c6f74e074b7e8df4a28307561cbbf"
integrity sha512-ooXiSc5TZuZCFr3wl1JB60nS9FBBkGgqsml7kAS/7bOwRTCUPH7cY80SoaabRL8Z9Clml+K1Pa7I/r+Wphb53g==
"@spectrum-css/vars@^3.0.0", "@spectrum-css/vars@^3.0.0-beta.2":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@spectrum-css/vars/-/vars-3.0.0.tgz#c3ef4c2f07bd4f0d2734e730233ca81cb18106e7"
integrity sha512-fNXU6qmcCbSiUoWGe/m9A8/THRHbpzwZ+iN8o/27tWIzcQIyZBZgjmV/kIMdF1dHpu5CuWik7mGV1Ex8tlzATg==
"@szmarczak/http-timer@^1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
@ -1226,6 +1354,11 @@ adal-node@^0.1.28:
xmldom ">= 0.1.x"
xpath.js "~1.1.0"
ag-grid-community@^24.0.0:
version "24.1.0"
resolved "https://registry.yarnpkg.com/ag-grid-community/-/ag-grid-community-24.1.0.tgz#1e3cab51211822e08d56f03a491b7c0deaa398e6"
integrity sha512-pWnWphuDcejZ8ahf6C734EpCx3XQ6dHEZWMWTlCdHNT0mZBLJ4YKCGACX+ttAEtSX2MGM3G13JncvuratUlYag==
agent-base@6:
version "6.0.1"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.1.tgz#808007e4e5867decb0ab6ab2f928fbdb5a596db4"
@ -1563,6 +1696,18 @@ anymatch@~3.1.1:
normalize-path "^3.0.0"
picomatch "^2.0.4"
apexcharts@^3.19.2, apexcharts@^3.22.1:
version "3.25.0"
resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.25.0.tgz#f3f0f9f344f997230f5c7f2918408aa072627496"
integrity sha512-uM7OF+jLL4ba79noYcrMwMgJW8DI+Ff28CCQoGq23g25z8nGSQEoU+u12YWlECA9gBA5tbmdaQhMxjlK+M6B9Q==
dependencies:
svg.draggable.js "^2.2.2"
svg.easing.js "^2.0.0"
svg.filter.js "^2.0.2"
svg.pathmorphing.js "^0.1.3"
svg.resize.js "^1.4.3"
svg.select.js "^3.0.1"
app-builder-bin@3.5.10:
version "3.5.10"
resolved "https://registry.yarnpkg.com/app-builder-bin/-/app-builder-bin-3.5.10.tgz#4a7f9999fccc0c435b6284ae1366bc76a17c4a7d"
@ -1621,6 +1766,11 @@ argparse@^1.0.10, argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
argparse@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
args@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/args/-/args-5.0.1.tgz#4bf298df90a4799a09521362c579278cc2fdd761"
@ -2356,6 +2506,11 @@ clone-response@1.0.2, clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
clone@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
co-body@^5.1.1:
version "5.2.0"
resolved "https://registry.yarnpkg.com/co-body/-/co-body-5.2.0.tgz#5a0a658c46029131e0e3a306f67647302f71c124"
@ -2489,6 +2644,11 @@ configstore@^5.0.1:
write-file-atomic "^3.0.0"
xdg-basedir "^4.0.0"
console-clear@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/console-clear/-/console-clear-1.1.1.tgz#995e20cbfbf14dd792b672cde387bd128d674bf7"
integrity sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==
content-disposition@^0.5.2, content-disposition@~0.5.2:
version "0.5.3"
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
@ -2752,6 +2912,18 @@ decompress@^4.2.1:
pify "^2.3.0"
strip-dirs "^2.0.0"
deep-equal@^1.0.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a"
integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==
dependencies:
is-arguments "^1.0.4"
is-date-object "^1.0.1"
is-regex "^1.0.4"
object-is "^1.0.1"
object-keys "^1.1.1"
regexp.prototype.flags "^1.2.0"
deep-equal@^2.0.1:
version "2.0.5"
resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.0.5.tgz#55cd2fe326d83f9cbf7261ef0e060b3f724c5cb9"
@ -2944,6 +3116,11 @@ domexception@^1.0.1:
dependencies:
webidl-conversions "^4.0.2"
domino@^2.1.6:
version "2.1.6"
resolved "https://registry.yarnpkg.com/domino/-/domino-2.1.6.tgz#fe4ace4310526e5e7b9d12c7de01b7f485a57ffe"
integrity sha512-3VdM/SXBZX2omc9JF9nOPCtDaYQ67BGp5CoLpIQlO2KCAPETs8TcDHacF26jXadGbvUteZzRTeos2fhID5+ucQ==
dot-prop@^5.2.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
@ -3165,6 +3342,11 @@ ent@^2.2.0:
resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
integrity sha1-6WQhkyWiHQX0RGai9obtbOX13R0=
entities@~2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
env-paths@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43"
@ -3450,6 +3632,11 @@ event-target-shim@^5.0.0:
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
eventemitter3@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba"
integrity sha1-teEHm1n7XhuidxwKmTvgYKWMmbo=
events@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/events/-/events-1.1.1.tgz#9ebdb7635ad099c70dcc4c2a1f5004288e8bd924"
@ -3551,7 +3738,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
assign-symbols "^1.0.0"
is-extendable "^1.0.1"
extend@^3.0.0, extend@~3.0.2:
extend@^3.0.0, extend@^3.0.2, extend@~3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
@ -3621,6 +3808,11 @@ fast-deep-equal@^3.1.1:
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-diff@1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154"
integrity sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==
fast-json-stable-stringify@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
@ -3800,6 +3992,11 @@ flat-cache@^2.0.1:
rimraf "2.6.3"
write "1.0.3"
flatpickr@^4.5.2, flatpickr@^4.6.6:
version "4.6.9"
resolved "https://registry.yarnpkg.com/flatpickr/-/flatpickr-4.6.9.tgz#9a13383e8a6814bda5d232eae3fcdccb97dc1499"
integrity sha512-F0azNNi8foVWKSF+8X+ZJzz8r9sE1G4hl06RyceIaLvyltKvDl6vqk9Lm/6AUUCi5HWaIjiUbk7UpeE/fOXOpw==
flatstr@^1.0.12:
version "1.0.12"
resolved "https://registry.yarnpkg.com/flatstr/-/flatstr-1.0.12.tgz#c2ba6a08173edbb6c9640e3055b95e287ceb5931"
@ -3988,6 +4185,11 @@ get-object@^0.2.0:
is-number "^2.0.2"
isobject "^0.2.0"
get-port@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc"
integrity sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=
get-stream@3.0.0, get-stream@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@ -4859,7 +5061,7 @@ is-plain-object@^2.0.3, is-plain-object@^2.0.4:
dependencies:
isobject "^3.0.1"
is-regex@^1.1.1, is-regex@^1.1.2:
is-regex@^1.0.4, is-regex@^1.1.1, is-regex@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.2.tgz#81c8ebde4db142f2cf1c53fc86d6a45788266251"
integrity sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==
@ -5693,7 +5895,7 @@ kind-of@^6.0.0, kind-of@^6.0.2:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
kleur@^3.0.3:
kleur@^3.0.0, kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
@ -6002,6 +6204,13 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
linkify-it@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8"
integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==
dependencies:
uc.micro "^1.0.1"
load-bmfont@^1.3.1, load-bmfont@^1.4.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/load-bmfont/-/load-bmfont-1.4.1.tgz#c0f5f4711a1e2ccff725a7b6078087ccfcddd3e9"
@ -6026,6 +6235,16 @@ load-json-file@^4.0.0:
pify "^3.0.0"
strip-bom "^3.0.0"
loadicons@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/loadicons/-/loadicons-1.0.0.tgz#79fd9b08ef2933988c94068cbd246ef3f21cbd04"
integrity sha512-KSywiudfuOK5sTdhNMM8hwRpMxZ5TbQlU4ZijMxUFwRW7jpxUmb9YJoLIzDn7+xuxeLzCZWBmLJS2JDjDWCpsw==
local-access@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/local-access/-/local-access-1.1.0.tgz#e007c76ba2ca83d5877ba1a125fc8dfe23ba4798"
integrity sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==
locate-path@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
@ -6280,6 +6499,17 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
markdown-it@^12.0.2:
version "12.0.4"
resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-12.0.4.tgz#eec8247d296327eac3ba9746bdeec9cfcc751e33"
integrity sha512-34RwOXZT8kyuOJy25oJNJoulO8L0bTHYWXcdZBYZqFnjIy3NgjeoM3FmPXIOFQ26/lSHYMr8oc62B6adxXcb3Q==
dependencies:
argparse "^2.0.1"
entities "~2.1.0"
linkify-it "^3.0.1"
mdurl "^1.0.1"
uc.micro "^1.0.5"
matcher@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca"
@ -6287,6 +6517,11 @@ matcher@^3.0.0:
dependencies:
escape-string-regexp "^4.0.0"
mdurl@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@ -6396,6 +6631,11 @@ mime@^1.3.4, mime@^1.4.1:
resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
mime@^2.3.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
mime@^2.4.6:
version "2.4.6"
resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.6.tgz#e5b407c90db442f2beb5b162373d07b69affa4d1"
@ -6473,6 +6713,11 @@ mri@1.1.4:
resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.4.tgz#7cb1dd1b9b40905f1fac053abe25b6720f44744a"
integrity sha512-6y7IjGPm8AzlvoUrwAaw1tLnUBudaS3752vcd8JtrpGGQn+rXIe63LFVHm/YMwtqAuh+LJPCFdlLYPWM1nYn6w==
mri@^1.1.0:
version "1.1.6"
resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.6.tgz#49952e1044db21dbf90f6cd92bc9c9a777d415a6"
integrity sha512-oi1b3MfbyGa7FJMP9GmLTttni5JoICpYBRlq+x5V16fZbLsnL9N3wFqqIm/nIG43FjUFkFh9Epzp/kzUGUnJxQ==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
@ -6755,7 +7000,7 @@ object-inspect@^1.9.0:
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.9.0.tgz#c90521d74e1127b67266ded3394ad6116986533a"
integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==
object-is@^1.1.4:
object-is@^1.0.1, object-is@^1.1.4:
version "1.1.5"
resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac"
integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==
@ -6973,6 +7218,11 @@ pako@^1.0.5:
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
parchment@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/parchment/-/parchment-1.1.4.tgz#aeded7ab938fe921d4c34bc339ce1168bc2ffde5"
integrity sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==
parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@ -7646,6 +7896,27 @@ quick-format-unescaped@^4.0.1:
resolved "https://registry.yarnpkg.com/quick-format-unescaped/-/quick-format-unescaped-4.0.1.tgz#437a5ea1a0b61deb7605f8ab6a8fd3858dbeb701"
integrity sha512-RyYpQ6Q5/drsJyOhrWHYMWTedvjTIat+FTwv0K4yoUxzvekw2aRHMQJLlnvt8UantkZg2++bEzD9EdxXqkWf4A==
quill-delta@^3.6.2:
version "3.6.3"
resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-3.6.3.tgz#b19fd2b89412301c60e1ff213d8d860eac0f1032"
integrity sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==
dependencies:
deep-equal "^1.0.1"
extend "^3.0.2"
fast-diff "1.1.2"
quill@^1.3.7:
version "1.3.7"
resolved "https://registry.yarnpkg.com/quill/-/quill-1.3.7.tgz#da5b2f3a2c470e932340cdbf3668c9f21f9286e8"
integrity sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==
dependencies:
clone "^2.1.1"
deep-equal "^1.0.1"
eventemitter3 "^2.0.3"
extend "^3.0.2"
parchment "^1.1.4"
quill-delta "^3.6.2"
raw-body@^2.2.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c"
@ -7822,7 +8093,7 @@ regex-not@^1.0.0, regex-not@^1.0.2:
extend-shallow "^3.0.2"
safe-regex "^1.1.0"
regexp.prototype.flags@^1.3.0:
regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0:
version "1.3.1"
resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.1.tgz#7ef352ae8d159e758c0eadca6f8fcb4eef07be26"
integrity sha512-JiBdRBq91WlY7uRJ0ds7R+dU02i6LKi8r3BuQhNXn+kmeLN+EfHhfjqMRis1zJxnlu88hq/4dx0P2OP3APRTOA==
@ -7869,6 +8140,11 @@ remarkable@^1.6.2, remarkable@^1.7.1:
argparse "^1.0.10"
autolinker "~0.28.0"
remixicon@^2.5.0:
version "2.5.0"
resolved "https://registry.yarnpkg.com/remixicon/-/remixicon-2.5.0.tgz#b5e245894a1550aa23793f95daceadbf96ad1a41"
integrity sha512-q54ra2QutYDZpuSnFjmeagmEiN9IMo56/zz5dDNitzKD23oFRw77cWo4TsrAdmdkPiEn8mxlrTqxnkujDbEGww==
remove-trailing-separator@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
@ -8055,6 +8331,13 @@ rxjs@^6.6.0:
dependencies:
tslib "^1.9.0"
sade@^1.4.0:
version "1.7.4"
resolved "https://registry.yarnpkg.com/sade/-/sade-1.7.4.tgz#ea681e0c65d248d2095c90578c03ca0bb1b54691"
integrity sha512-y5yauMD93rX840MwUJr7C1ysLFBgMspsdTo4UVrDg3fXDvtwOyIqykhVAAm6fk/3au77773itJStObgK+LKaiA==
dependencies:
mri "^1.1.0"
safe-buffer@*, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
@ -8287,6 +8570,27 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
sirv-cli@^0.4.6:
version "0.4.6"
resolved "https://registry.yarnpkg.com/sirv-cli/-/sirv-cli-0.4.6.tgz#c28ab20deb3b34637f5a60863dc350f055abca04"
integrity sha512-/Vj85/kBvPL+n9ibgX6FicLE8VjidC1BhlX67PYPBfbBAphzR6i0k0HtU5c2arejfU3uzq8l3SYPCwl1x7z6Ww==
dependencies:
console-clear "^1.1.0"
get-port "^3.2.0"
kleur "^3.0.0"
local-access "^1.0.1"
sade "^1.4.0"
sirv "^0.4.6"
tinydate "^1.0.0"
sirv@^0.4.6:
version "0.4.6"
resolved "https://registry.yarnpkg.com/sirv/-/sirv-0.4.6.tgz#185e44eb93d24009dd183b7494285c5180b81f22"
integrity sha512-rYpOXlNbpHiY4nVXxuDf4mXPvKz1reZGap/LkWp9TvcZ84qD/nPBjjH/6GZsgIjVMbOslnY8YYULAyP8jMn1GQ==
dependencies:
"@polka/url" "^0.5.0"
mime "^2.3.1"
sisteransi@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
@ -8749,6 +9053,32 @@ supports-color@^7.1.0:
dependencies:
has-flag "^4.0.0"
svelte-apexcharts@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/svelte-apexcharts/-/svelte-apexcharts-1.0.2.tgz#4e000f8b8f7c901c05658c845457dfc8314d54c1"
integrity sha512-6qlx4rE+XsonZ0FZudfwqOQ34Pq+3wpxgAD75zgEmGoYhYBJcwmikTuTf3o8ZBsZue9U/pAwhNy3ed1Bkq1gmA==
dependencies:
apexcharts "^3.19.2"
svelte-flatpickr@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-2.4.0.tgz#190871fc3305956c8c8fd3601cd036b8ac71ef49"
integrity sha512-UUC5Te+b0qi4POg7VDwfGh0m5W3Hf64OwkfOTj6FEe/dYZN4cBzpQ82EuuQl0CTbbBAsMkcjJcixV1d2V6EHCQ==
dependencies:
flatpickr "^4.5.2"
svelte-flatpickr@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/svelte-flatpickr/-/svelte-flatpickr-3.1.0.tgz#ad83588430dbd55196a1a258b8ba27e7f9c1ee37"
integrity sha512-zKyV+ukeVuJ8CW0Ing3T19VSekc4bPkou/5Riutt1yATrLvSsanNqcgqi7Q5IePvIoOF9GJ5OtHvn1qK9Wx9BQ==
dependencies:
flatpickr "^4.5.2"
svelte-portal@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-1.0.0.tgz#36a47c5578b1a4d9b4dc60fa32a904640ec4cdd3"
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q==
svelte-spa-router@^3.0.5:
version "3.1.0"
resolved "https://registry.yarnpkg.com/svelte-spa-router/-/svelte-spa-router-3.1.0.tgz#a929f0def7e12c41f32bc356f91685aeadcd75bf"
@ -8761,6 +9091,61 @@ svelte@3.30.0:
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.30.0.tgz#cbde341e96bf34f4ac73c8f14f8a014e03bfb7d6"
integrity sha512-z+hdIACb9TROGvJBQWcItMtlr4s0DBUgJss6qWrtFkOoIInkG+iAMo/FJZQFyDBQZc+dul2+TzYSi/tpTT5/Ag==
svg.draggable.js@^2.2.2:
version "2.2.2"
resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba"
integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==
dependencies:
svg.js "^2.0.1"
svg.easing.js@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12"
integrity sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI=
dependencies:
svg.js ">=2.3.x"
svg.filter.js@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203"
integrity sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM=
dependencies:
svg.js "^2.2.5"
svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5:
version "2.7.1"
resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d"
integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==
svg.pathmorphing.js@^0.1.3:
version "0.1.3"
resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65"
integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==
dependencies:
svg.js "^2.4.0"
svg.resize.js@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332"
integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==
dependencies:
svg.js "^2.6.5"
svg.select.js "^2.1.2"
svg.select.js@^2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73"
integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==
dependencies:
svg.js "^2.2.5"
svg.select.js@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917"
integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==
dependencies:
svg.js "^2.6.5"
symbol-tree@^3.2.2:
version "3.2.4"
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@ -8940,6 +9325,11 @@ tinycolor2@^1.4.1:
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.2.tgz#3f6a4d1071ad07676d7fa472e1fac40a719d8803"
integrity sha512-vJhccZPs965sV/L2sU4oRQVAos0pQXwsvTLkWYdqJ+a8Q5kPFzJTuOFwy7UniPli44NKQGAglksjvOcpo95aZA==
tinydate@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/tinydate/-/tinydate-1.3.0.tgz#e6ca8e5a22b51bb4ea1c3a2a4fd1352dbd4c57fb"
integrity sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@ -9083,6 +9473,13 @@ tunnel@0.0.6, tunnel@^0.0.6:
resolved "https://registry.yarnpkg.com/tunnel/-/tunnel-0.0.6.tgz#72f1314b34a5b192db012324df2cc587ca47f92c"
integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==
turndown@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/turndown/-/turndown-7.0.0.tgz#19b2a6a2d1d700387a1e07665414e4af4fec5225"
integrity sha512-G1FfxfR0mUNMeGjszLYl3kxtopC4O9DRRiMlMDDVHvU1jaBkGFg4qxIyjIk2aiKLHyDyZvZyu4qBO2guuYBy3Q==
dependencies:
domino "^2.1.6"
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
@ -9142,6 +9539,11 @@ typeof-article@^0.1.1:
dependencies:
kind-of "^3.1.0"
uc.micro@^1.0.1, uc.micro@^1.0.5:
version "1.0.6"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
uglify-js@^3.1.4:
version "3.13.0"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.13.0.tgz#66ed69f7241f33f13531d3d51d5bcebf00df7f69"

View file

@ -8,55 +8,81 @@
</script>
{#if type === 'div'}
<div in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<div
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</div>
{:else if type === 'header'}
<header in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<header
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</header>
{:else if type === 'main'}
<main in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<main
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</main>
{:else if type === 'footer'}
<footer in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<footer
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</footer>
{:else if type === 'aside'}
<aside in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<aside
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</aside>
{:else if type === 'summary'}
<summary in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<summary
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</summary>
{:else if type === 'details'}
<details in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<details
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</details>
{:else if type === 'article'}
<article in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<article
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</article>
{:else if type === 'nav'}
<nav in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<nav
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</nav>
{:else if type === 'mark'}
<mark in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<mark
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</mark>
{:else if type === 'figure'}
<figure in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<figure
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</figure>
{:else if type === 'figcaption'}
<figcaption in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<figcaption
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</figcaption>
{:else if type === 'paragraph'}
<p in:transition={{type: $component.transition}} use:styleable={$component.styles}>
<p
in:transition={{ type: $component.transition }}
use:styleable={$component.styles}>
<slot />
</p>
{/if}

View file

@ -152,7 +152,9 @@
{#if selectedRows.length > 0}
<DeleteButton text small on:click={modal.show()}>
<Icon name="addrow" />
Delete {selectedRows.length} row(s)
Delete
{selectedRows.length}
row(s)
</DeleteButton>
{/if}
</div>