Browse Source

email template E2E, adding preview

pull/1479/head
Martin McKeaveney 5 years ago
parent
commit
3bf3fc5e02
  1. 19
      .github/CONTRIBUTING.md
  2. 61
      hosting/scripts/setup.js
  3. 3
      package.json
  4. 2
      packages/bbui/package.json
  5. 97
      packages/bbui/src/Form/Core/Dropzone.svelte
  6. 1
      packages/bbui/src/Form/Core/TextField.svelte
  7. 2
      packages/bbui/src/Form/Dropzone.svelte
  8. 1
      packages/bbui/src/Form/Input.svelte
  9. 2
      packages/bbui/src/Layout/Layout.svelte
  10. 9
      packages/bbui/src/Layout/Page.svelte
  11. 21
      packages/bbui/src/Modal/Modal.svelte
  12. 2
      packages/bbui/src/Table/CellRenderer.svelte
  13. 76
      packages/bbui/src/Table/Table.svelte
  14. 8
      packages/bbui/yarn.lock
  15. 4
      packages/builder/package.json
  16. 66
      packages/builder/src/actions.js
  17. 12
      packages/builder/src/components/common/ConfigChecklist.svelte
  18. 9
      packages/builder/src/components/common/ConfirmDialog.svelte
  19. 88
      packages/builder/src/components/start/AppCard.svelte
  20. 25
      packages/builder/src/components/start/AppList.svelte
  21. 80
      packages/builder/src/components/start/AppRow.svelte
  22. 239
      packages/builder/src/components/start/CreateAppModal.svelte
  23. 83
      packages/builder/src/components/start/Indicator.svelte
  24. 121
      packages/builder/src/components/start/Steps/Info.svelte
  25. 29
      packages/builder/src/components/start/Steps/User.svelte
  26. 2
      packages/builder/src/components/start/Steps/index.js
  27. 0
      packages/builder/src/pages/builder/app/[application]/_layout.svelte
  28. 8
      packages/builder/src/pages/builder/portal/_layout.svelte
  29. 7
      packages/builder/src/pages/builder/portal/apps/_layout.svelte
  30. 221
      packages/builder/src/pages/builder/portal/apps/index.svelte
  31. 15
      packages/builder/src/pages/builder/portal/email/TemplateBindings.svelte
  32. 17
      packages/builder/src/pages/builder/portal/email/TemplateLink.svelte
  33. 140
      packages/builder/src/pages/builder/portal/email/[template].svelte
  34. 99
      packages/builder/src/pages/builder/portal/email/index.svelte
  35. 2
      packages/builder/src/pages/builder/portal/oauth/index.svelte
  36. 44
      packages/builder/src/stores/portal/email.js
  37. 1
      packages/builder/src/stores/portal/index.js
  38. 10
      packages/builder/yarn.lock
  39. 2
      packages/client/package.json
  40. 2
      packages/server/package.json
  41. 3
      packages/server/src/api/index.js
  42. 4
      packages/server/src/utilities/workerRequests.js
  43. 1327
      packages/server/yarn.lock
  44. 2
      packages/standard-components/package.json
  45. 12
      packages/worker/src/api/controllers/admin/templates.js
  46. 93
      packages/worker/src/api/controllers/auth.js
  47. 7
      packages/worker/src/api/routes/tests/email.spec.js
  48. 92
      packages/worker/src/constants/index.js

19
.github/CONTRIBUTING.md

@ -92,6 +92,16 @@ then `cd ` into your local copy.
### 3. Install and Build
To develop the Budibase platform you'll need [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/) installed.
#### Quick method
`yarn setup` will check that all necessary components are installed and setup the repo for usage.
#### Manual method
The following commands can be executed to manually get Budibase up and running (assuming Docker/Docker Compose has been installed).
`yarn` to install project dependencies
`yarn bootstrap` will install all budibase modules and symlink them together using lerna.
@ -112,10 +122,17 @@ To run the budibase server and builder in dev mode (i.e. with live reloading):
1. Open a new console
2. `yarn dev` (from root)
3. Access the builder on http://localhost:4001/_builder/
3. Access the builder on http://localhost:10000/builder
This will enable watch mode for both the builder app, server, client library and any component libraries.
### 5. Cleanup
If you wish to delete all the apps created in development and reset the environment then run the following:
1. `yarn nuke:docker` will wipe all the Budibase services
2. `yarn dev` will restart all the services
## Data Storage
When you are running locally, budibase stores data on disk using [PouchDB](https://pouchdb.com/), as well as some JSON on local files. After setting up budibase, you can find all of this data in the `~/.budibase` directory.

61
hosting/scripts/setup.js

@ -0,0 +1,61 @@
#!/usr/bin/env node
const os = require("os")
const exec = require("child_process").exec
const fs = require("fs")
const platform = os.platform()
const windows = platform === "win32"
const mac = platform === "darwin"
const linux = platform === "linux"
function execute(command) {
return new Promise(resolve => {
exec(command, (err, stdout) => resolve(linux ? !!stdout : true))
})
}
async function commandExistsUnix(command) {
const unixCmd = `command -v ${command} 2>/dev/null && { echo >&1 ${command}; exit 0; }`
return execute(command)
}
async function commandExistsWindows(command) {
if (/[\x00-\x1f<>:"|?*]/.test(command)) {
return false
}
return execute(`where ${command}`)
}
function commandExists(command) {
return windows ? commandExistsWindows(command) : commandExistsUnix(command)
}
async function init() {
const docker = commandExists("docker")
const dockerCompose = commandExists("docker-compose")
if (docker && dockerCompose) {
console.log("Docker installed - continuing.")
return
}
if (mac) {
console.log(
"Please install docker by visiting: https://docs.docker.com/docker-for-mac/install/"
)
} else if (windows) {
console.log(
"Please install docker by visiting: https://docs.docker.com/docker-for-windows/install/"
)
} else if (linux) {
console.log("Beginning automated linux installation.")
await execute(`./hosting/scripts/linux/get-docker.sh`)
await execute(`./hosting/scripts/linux/get-docker-compose.sh`)
} else {
console.error(
"Platform unknown - please look online for information about installing docker for our OS."
)
}
console.log("Once installation complete please re-run the setup script.")
process.exit(-1)
}
init()

3
package.json

@ -14,9 +14,10 @@
"prettier-plugin-svelte": "^2.2.0",
"rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0",
"svelte": "^3.37.0"
"svelte": "^3.38.2"
},
"scripts": {
"setup": "./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
"bootstrap": "lerna link && lerna bootstrap",
"build": "lerna run build",
"initialise": "lerna run initialise",

2
packages/bbui/package.json

@ -27,7 +27,7 @@
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"svelte": "^3.37.0"
"svelte": "^3.38.2"
},
"keywords": [
"svelte"

97
packages/bbui/src/Form/Core/Dropzone.svelte

@ -15,6 +15,8 @@
export let fileSizeLimit = BYTES_IN_MB * 20
export let processFiles = null
export let handleFileTooLarge = null
export let gallery = true
export let error = null
const dispatch = createEventDispatcher()
const imageExtensions = [
@ -52,6 +54,8 @@
const newValue = [...value, ...processedFiles]
dispatch("change", newValue)
selectedImageIdx = newValue.length - 1
} else {
dispatch("change", fileList)
}
}
@ -94,47 +98,68 @@
<div class="container">
{#if selectedImage}
<div class="gallery">
<div class="title">
<div class="filename">{selectedImage.name}</div>
<div class="filesize">
{#if selectedImage.size <= BYTES_IN_MB}
{`${selectedImage.size / BYTES_IN_KB} KB`}
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
{#if gallery}
<div class="gallery">
<div class="title">
<div class="filename">{selectedImage.name}</div>
<div class="filesize">
{#if selectedImage.size <= BYTES_IN_MB}
{`${selectedImage.size / BYTES_IN_KB} KB`}
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
</div>
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
</div>
{/if}
</div>
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
{#if isImage}
<img alt="preview" src={selectedImage.url} />
{:else}
<div class="placeholder">
<div class="extension">{selectedImage.extension}</div>
<div>Preview not supported</div>
</div>
{/if}
</div>
{#if isImage}
<img alt="preview" src={selectedImage.url} />
{:else}
<div class="placeholder">
<div class="extension">{selectedImage.extension}</div>
<div>Preview not supported</div>
<div
class="nav left"
class:visible={selectedImageIdx > 0}
on:click={navigateLeft}
>
<Icon name="ChevronLeft" />
</div>
{/if}
<div
class="nav left"
class:visible={selectedImageIdx > 0}
on:click={navigateLeft}
>
<Icon name="ChevronLeft" />
</div>
<div
class="nav right"
class:visible={selectedImageIdx < fileCount - 1}
on:click={navigateRight}
>
<Icon name="ChevronRight" />
<div
class="nav right"
class:visible={selectedImageIdx < fileCount - 1}
on:click={navigateRight}
>
<Icon name="ChevronRight" />
</div>
<div class="footer">File {selectedImageIdx + 1} of {fileCount}</div>
</div>
<div class="footer">File {selectedImageIdx + 1} of {fileCount}</div>
</div>
{:else if value?.length}
{#each value as file}
<div class="gallery">
<div class="title">
<div class="filename">{file.name}</div>
<div class="filesize">
{#if file.size <= BYTES_IN_MB}
{`${file.size / BYTES_IN_KB} KB`}
{:else}{`${file.size / BYTES_IN_MB} MB`}{/if}
</div>
{#if !disabled}
<div class="delete-button" on:click={removeFile}>
<Icon name="Close" />
</div>
{/if}
</div>
</div>
{/each}
{/if}
{/if}
<div
class="spectrum-Dropzone"
class:is-invalid={!!error}
class:disabled
role="region"
tabindex="0"
@ -245,6 +270,9 @@
.spectrum-Dropzone {
user-select: none;
}
.spectrum-Dropzone.is-invalid {
border-color: var(--spectrum-global-color-red-400);
}
input[type="file"] {
display: none;
}
@ -276,7 +304,7 @@
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
align-items: center;
}
.filename {
flex: 1 1 auto;
@ -331,6 +359,7 @@
.delete-button {
transition: all 0.3s;
margin-left: 10px;
display: flex;
}
.delete-button i {
font-size: 2em;

1
packages/bbui/src/Form/Core/TextField.svelte

@ -37,6 +37,7 @@
}
focus = false
updateValue(event.target.value)
dispatch("blur")
}
const updateValueOnEnter = event => {

2
packages/bbui/src/Form/Dropzone.svelte

@ -11,6 +11,7 @@
export let fileSizeLimit = undefined
export let processFiles = undefined
export let handleFileTooLarge = undefined
export let gallery = true
const dispatch = createEventDispatcher()
const onChange = e => {
@ -27,6 +28,7 @@
{fileSizeLimit}
{processFiles}
{handleFileTooLarge}
{gallery}
on:change={onChange}
/>
</Field>

1
packages/bbui/src/Form/Input.svelte

@ -30,5 +30,6 @@
on:change={onChange}
on:click
on:input
on:blur
/>
</Field>

2
packages/bbui/src/Layout/Layout.svelte

@ -5,9 +5,11 @@
export let noPadding = false
export let gap = "M"
export let noGap = false
export let alignContent = "normal"
</script>
<div
style="align-content:{alignContent};"
class:horizontal
class="container paddingX-{!noPadding && paddingX} paddingY-{!noPadding &&
paddingY} gap-{!noGap && gap}"

9
packages/bbui/src/Layout/Page.svelte

@ -8,8 +8,10 @@
<style>
div {
display: grid;
grid-template-columns: 1fr;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
max-width: 80ch;
margin: 0 auto;
padding: calc(var(--spacing-xl) * 2);
@ -18,6 +20,7 @@
.wide {
max-width: none;
margin: 0;
padding: var(--spacing-xl) calc(var(--spacing-xl) * 2);
padding: var(--spacing-xl) calc(var(--spacing-xl) * 2)
calc(var(--spacing-xl) * 2) calc(var(--spacing-xl) * 2);
}
</style>

21
packages/bbui/src/Modal/Modal.svelte

@ -7,9 +7,10 @@
import Context from "../context"
export let fixed = false
export let inline = false
const dispatch = createEventDispatcher()
let visible = !!fixed
let visible = fixed || inline
$: dispatch(visible ? "show" : "hide")
export function show() {
@ -20,7 +21,7 @@
}
export function hide() {
if (!visible || fixed) {
if (!visible || fixed || inline) {
return
}
visible = false
@ -45,11 +46,17 @@
<svelte:window on:keydown={handleKey} />
{#if visible}
<!-- These svelte if statements need to be defined like this. -->
<!-- The modal transitions do not work if nested inside more than one "if" -->
{#if visible && inline}
<div use:focusFirstInput class="spectrum-Modal inline is-open">
<slot />
</div>
{:else if visible}
<Portal target=".modal-container">
<div
class="spectrum-Underlay is-open"
transition:fade={{ duration: 200 }}
transition:fade|local={{ duration: 200 }}
on:mousedown|self={hide}
>
<div class="modal-wrapper" on:mousedown|self={hide}>
@ -57,7 +64,7 @@
<div
use:focusFirstInput
class="spectrum-Modal is-open"
transition:fly={{ y: 30, duration: 200 }}
transition:fly|local={{ y: 30, duration: 200 }}
>
<slot />
</div>
@ -98,6 +105,7 @@
}
.spectrum-Modal {
background: var(--background);
overflow: visible;
max-height: none;
margin: 40px 0;
@ -106,4 +114,7 @@
--spectrum-global-dimension-size-100
);
}
:global(.spectrum--lightest .spectrum-Modal.inline) {
border: var(--border-light);
}
</style>

2
packages/bbui/src/Table/CellRenderer.svelte

@ -23,7 +23,7 @@
}
$: type = schema?.type ?? "string"
$: customRenderer = customRenderers?.find(x => x.column === schema?.name)
$: renderer = customRenderer?.component ?? typeMap[type]
$: renderer = customRenderer?.component ?? typeMap[type] ?? StringRenderer
</script>
{#if renderer && (customRenderer || (value != null && value !== ""))}

76
packages/bbui/src/Table/Table.svelte

@ -214,7 +214,7 @@
>
<div style={contentStyle}>
<table class="spectrum-Table" class:spectrum-Table--quiet={quiet}>
{#if sortedRows?.length}
{#if fields.length}
<thead class="spectrum-Table-head">
<tr>
{#if showEditColumn}
@ -269,7 +269,7 @@
</thead>
{/if}
<tbody class="spectrum-Table-body">
{#if sortedRows?.length}
{#if sortedRows?.length && fields.length}
{#each sortedRows as row, idx}
<tr
on:click={() => toggleSelectRow(row)}
@ -316,15 +316,25 @@
</tr>
{/each}
{:else}
<div class="placeholder">
<svg
class="spectrum-Icon spectrum-Icon--sizeXXL"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
<div>No rows found</div>
</div>
<tr class="placeholder-row">
{#if showEditColumn}
<td class="placeholder-offset" />
{/if}
{#each fields as field}
<td />
{/each}
<div class="placeholder" class:has-fields={fields.length > 0}>
<div class="placeholder-content">
<svg
class="spectrum-Icon spectrum-Icon--sizeXXL"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
<div>No rows found</div>
</div>
</div>
</tr>
{/if}
</tbody>
</table>
@ -347,7 +357,7 @@
overflow: auto;
}
.container.quiet {
border: none !important;
border: none;
}
table {
width: 100%;
@ -381,7 +391,7 @@
z-index: 2;
background-color: var(--spectrum-alias-background-color-secondary);
border-bottom: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important;
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
}
.spectrum-Table-headCell-content {
white-space: nowrap;
@ -396,7 +406,34 @@
text-overflow: ellipsis;
}
.placeholder-row {
position: relative;
height: 150px;
}
.placeholder-row td {
border-top: none !important;
border-bottom: none !important;
}
.placeholder-offset {
width: 1px;
}
.placeholder {
top: 0;
height: 100%;
left: 0;
width: 100%;
position: absolute;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
.placeholder.has-fields {
top: var(--header-height);
height: calc(100% - var(--header-height));
}
.placeholder-content {
padding: 20px;
display: flex;
flex-direction: column;
@ -407,12 +444,13 @@
var(--spectrum-alias-text-color)
);
}
.placeholder div {
.placeholder-content div {
margin-top: 10px;
font-size: var(
--spectrum-table-cell-text-size,
var(--spectrum-alias-font-size-default)
);
text-align: center;
}
tbody {
@ -431,17 +469,17 @@
td {
padding-top: 0;
padding-bottom: 0;
border-bottom: none !important;
border-bottom: none;
border-top: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important;
border-radius: 0 !important;
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
border-radius: 0;
}
tr:first-child td {
border-top: none !important;
border-top: none;
}
tr:last-child td {
border-bottom: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)) !important;
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
}
td.spectrum-Table-cell--divider {
width: 1px;

8
packages/bbui/yarn.lock

@ -2407,10 +2407,10 @@ svelte-portal@^1.0.0:
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-1.0.0.tgz#36a47c5578b1a4d9b4dc60fa32a904640ec4cdd3"
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q==
svelte@^3.37.0:
version "3.37.0"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.37.0.tgz#dc7cd24bcc275cdb3f8c684ada89e50489144ccd"
integrity sha512-TRF30F4W4+d+Jr2KzUUL1j8Mrpns/WM/WacxYlo5MMb2E5Qy2Pk1Guj6GylxsW9OnKQl1tnF8q3hG/hQ3h6VUA==
svelte@^3.38.2:
version "3.38.2"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==
svgo@^1.0.0:
version "1.3.2"

4
packages/builder/package.json

@ -90,7 +90,7 @@
"@babel/preset-env": "^7.13.12",
"@babel/runtime": "^7.13.10",
"@rollup/plugin-replace": "^2.4.2",
"@roxi/routify": "2.15.1",
"@roxi/routify": "2.18.0",
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.5",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/svelte": "^3.0.0",
@ -106,7 +106,7 @@
"rollup": "^2.44.0",
"rollup-plugin-copy": "^3.4.0",
"start-server-and-test": "^1.12.1",
"svelte": "^3.37.0",
"svelte": "^3.38.2",
"svelte-jester": "^1.3.2",
"vite": "^2.1.5"
},

66
packages/builder/src/actions.js

@ -1,9 +1,10 @@
export const gradient = (node, config = {}) => {
const defaultConfig = {
points: 10,
saturation: 0.8,
lightness: 0.75,
softness: 0.8,
points: 12,
saturation: 0.85,
lightness: 0.7,
softness: 0.9,
seed: null,
}
// Applies a gradient background
@ -13,42 +14,67 @@ export const gradient = (node, config = {}) => {
...config,
}
const { saturation, lightness, softness, points } = config
const seed = config.seed || Math.random().toString(32).substring(2)
// Generates a random number between min and max
const rand = (min, max) => {
return Math.round(min + Math.random() * (max - min))
// Hash function which returns a fixed hash between specified limits
// for a given seed and a given version
const rangeHash = (seed, min = 0, max = 100, version = 0) => {
const range = max - min
let hash = range + version
for (let i = 0; i < seed.length * 2 + version; i++) {
hash = (hash << 5) - hash + seed.charCodeAt(i % seed.length)
hash = ((hash & hash) % range) + version
}
return min + (hash % range)
}
// Generates a random HSL colour using the options specified
const randomHSL = () => {
const randomHSL = (seed, version, alpha = 1) => {
const lowerSaturation = Math.min(100, saturation * 100)
const upperSaturation = Math.min(100, (saturation + 0.2) * 100)
const lowerLightness = Math.min(100, lightness * 100)
const upperLightness = Math.min(100, (lightness + 0.2) * 100)
const hue = rand(0, 360)
const sat = `${rand(lowerSaturation, upperSaturation)}%`
const light = `${rand(lowerLightness, upperLightness)}%`
return `hsl(${hue},${sat},${light})`
const hue = rangeHash(seed, 0, 360, version)
const sat = `${rangeHash(
seed,
lowerSaturation,
upperSaturation,
version
)}%`
const light = `${rangeHash(
seed,
lowerLightness,
upperLightness,
version
)}%`
return `hsla(${hue},${sat},${light},${alpha})`
}
// Generates a radial gradient stop point
const randomGradientPoint = () => {
const randomGradientPoint = (seed, version) => {
const lowerTransparency = Math.min(100, softness * 100)
const upperTransparency = Math.min(100, (softness + 0.2) * 100)
const transparency = rand(lowerTransparency, upperTransparency)
const transparency = rangeHash(
seed,
lowerTransparency,
upperTransparency,
version
)
return (
`radial-gradient(` +
`at ${rand(10, 90)}% ${rand(10, 90)}%,` +
`${randomHSL()} 0,` +
`radial-gradient(at ` +
`${rangeHash(seed, 0, 100, version)}% ` +
`${rangeHash(seed, 0, 100, version + 1)}%,` +
`${randomHSL(seed, version, saturation)} 0,` +
`transparent ${transparency}%)`
)
}
let css = `opacity:0.9;background-color:${randomHSL()};background-image:`
let css = `opacity:0.9;background:${randomHSL(seed, 0, 0.7)};`
css += "background-image:"
for (let i = 0; i < points - 1; i++) {
css += `${randomGradientPoint()},`
css += `${randomGradientPoint(seed, i)},`
}
css += `${randomGradientPoint()};`
css += `${randomGradientPoint(seed, points)};`
node.style = css
}

12
packages/builder/src/components/common/ConfigChecklist.svelte

@ -1,22 +1,12 @@
<script>
import { isActive, url, goto } from "@roxi/routify"
import { onMount } from "svelte"
import {
ActionMenu,
Checkbox,
Body,
MenuItem,
Icon,
Heading,
Avatar,
Search,
Layout,
ProgressCircle,
SideNavigation as Navigation,
SideNavigationItem as Item,
} from "@budibase/bbui"
import api from "builderStore/api"
import { organisation, admin } from "stores/portal"
import { admin } from "stores/portal"
const MESSAGES = {
apps: "Create your first app",

9
packages/builder/src/components/common/ConfirmDialog.svelte

@ -7,6 +7,7 @@
export let cancelText = "Cancel"
export let onOk = undefined
export let onCancel = undefined
export let warning = true
let modal
@ -19,7 +20,13 @@
</script>
<Modal bind:this={modal} on:hide={onCancel}>
<ModalContent onConfirm={onOk} {title} confirmText={okText} {cancelText} red>
<ModalContent
onConfirm={onOk}
{title}
confirmText={okText}
{cancelText}
{warning}
>
<Body size="S">
{body}
<slot />

88
packages/builder/src/components/start/AppCard.svelte

@ -7,56 +7,49 @@
ActionMenu,
MenuItem,
Link,
notifications,
} from "@budibase/bbui"
import download from "downloadjs"
import { gradient } from "actions"
import { url } from "@roxi/routify"
export let name
export let _id
let appExportLoading = false
async function exportApp() {
appExportLoading = true
try {
download(
`/api/backups/export?appId=${_id}&appname=${encodeURIComponent(name)}`
)
notifications.success("App export complete")
} catch (err) {
console.error(err)
notifications.error("App export failed")
} finally {
appExportLoading = false
}
}
export let app
export let exportApp
export let deleteApp
</script>
<Layout noPadding gap="XS">
<div class="preview" use:gradient />
<div class="title">
<Link href={`/builder/app/${_id}`}>
<Heading size="XS">
{name}
</Heading>
</Link>
<ActionMenu>
<Icon slot="control" name="More" hoverable />
<MenuItem on:click={exportApp} icon="Download">Export</MenuItem>
</ActionMenu>
</div>
<div class="status">
<Body noPadding size="S">
Edited {Math.floor(1 + Math.random() * 10)} months ago
</Body>
{#if Math.random() > 0.5}
<Icon name="LockClosed" />
{/if}
</div>
</Layout>
<div class="wrapper">
<Layout noPadding gap="XS" alignContent="start">
<div class="preview" use:gradient={{ seed: app.name }} />
<div class="title">
<Link href={$url(`../../app/${app._id}`)}>
<Heading size="XS">
{app.name}
</Heading>
</Link>
<ActionMenu align="right">
<Icon slot="control" name="More" hoverable />
<MenuItem on:click={() => exportApp(app)} icon="Download">
Export
</MenuItem>
<MenuItem on:click={() => deleteApp(app)} icon="Delete">
Delete
</MenuItem>
</ActionMenu>
</div>
<div class="status">
<Body noPadding size="S">
Edited {Math.floor(1 + Math.random() * 10)} months ago
</Body>
{#if Math.random() > 0.5}
<Icon name="LockClosed" />
{/if}
</div>
</Layout>
</div>
<style>
.wrapper {
overflow: hidden;
}
.preview {
height: 135px;
border-radius: var(--border-radius-s);
@ -73,6 +66,15 @@
.title :global(a) {
text-decoration: none;
flex: 1 1 auto;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
margin-right: var(--spacing-m);
}
.title :global(h1) {
overflow: hidden;
text-overflow: ellipsis;
}
.title :global(h1:hover) {
color: var(--spectrum-global-color-blue-600);

25
packages/builder/src/components/start/AppList.svelte

@ -1,25 +0,0 @@
<script>
import { onMount } from "svelte"
import AppCard from "./AppCard.svelte"
import { apps } from "stores/portal"
onMount(apps.load)
</script>
{#if $apps.length}
<div class="appList">
{#each $apps as app}
<AppCard {...app} />
{/each}
</div>
{:else}
<div>No apps found.</div>
{/if}
<style>
.appList {
display: grid;
grid-gap: 50px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
</style>

80
packages/builder/src/components/start/AppRow.svelte

@ -0,0 +1,80 @@
<script>
import { gradient } from "actions"
import {
Heading,
Button,
Icon,
ActionMenu,
MenuItem,
Link,
} from "@budibase/bbui"
import { url } from "@roxi/routify"
export let app
export let openApp
export let exportApp
export let deleteApp
export let last
</script>
<div class="title" class:last>
<div class="preview" use:gradient={{ seed: app.name }} />
<Link href={$url(`../../app/${app._id}`)}>
<Heading size="XS">
{app.name}
</Heading>
</Link>
</div>
<div class:last>
Edited {Math.round(Math.random() * 10 + 1)} months ago
</div>
<div class:last>
{#if Math.random() < 0.33}
<div class="status status--open" />
Open
{:else if Math.random() < 0.33}
<div class="status status--locked-other" />
Locked by Will Wheaton
{:else}
<div class="status status--locked-you" />
Locked by you
{/if}
</div>
<div class:last>
<Button on:click={() => openApp(app)} size="S" secondary>Open</Button>
<ActionMenu align="right">
<Icon hoverable slot="control" name="More" />
<MenuItem on:click={() => exportApp(app)} icon="Download">Export</MenuItem>
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem>
</ActionMenu>
</div>
<style>
.preview {
height: 40px;
width: 40px;
border-radius: var(--border-radius-s);
}
.title :global(a) {
text-decoration: none;
}
.title :global(h1:hover) {
color: var(--spectrum-global-color-blue-600);
cursor: pointer;
transition: color 130ms ease;
}
.status {
height: 10px;
width: 10px;
border-radius: 50%;
}
.status--locked-you {
background-color: var(--spectrum-global-color-orange-600);
}
.status--locked-other {
background-color: var(--spectrum-global-color-red-600);
}
.status--open {
background-color: var(--spectrum-global-color-green-600);
}
</style>

239
packages/builder/src/components/start/CreateAppModal.svelte

@ -1,58 +1,50 @@
<script>
import { writable, get as svelteGet } from "svelte/store"
import { notifications, Heading, Button } from "@budibase/bbui"
import {
notifications,
Input,
ModalContent,
Dropzone,
Body,
Checkbox,
} from "@budibase/bbui"
import { store, automationStore, hostingStore } from "builderStore"
import { string, object } from "yup"
import { string, mixed, object } from "yup"
import api, { get } from "builderStore/api"
import Spinner from "components/common/Spinner.svelte"
import { Info, User } from "./Steps"
import Indicator from "./Indicator.svelte"
import { goto } from "@roxi/routify"
import { fade } from "svelte/transition"
import { post } from "builderStore/api"
import analytics from "analytics"
import { onMount } from "svelte"
import Logo from "/assets/bb-logo.svg"
import { capitalise } from "../../helpers"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify"
export let template
const currentStep = writable(0)
const values = writable({ roleId: "ADMIN" })
const values = writable({ name: null })
const errors = writable({})
const touched = writable({})
const steps = [Info, User]
let validators = [
{
applicationName: string().required("Your application must have a name"),
},
{
roleId: string()
.nullable()
.required("You need to select a role for this app"),
},
]
const validator = {
name: string().required("Your application must have a name"),
file: template ? mixed().required("Please choose a file to import") : null,
}
let submitting = false
let valid = false
$: checkValidity($values, validators[$currentStep])
$: checkValidity($values, validator)
onMount(async () => {
const hostingInfo = await hostingStore.actions.fetch()
if (hostingInfo.type === "self") {
await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validators[0].applicationName = string()
.required("Your application must have a name.")
.test(
"non-existing-app-name",
"App with same name already exists. Please try another app name.",
value =>
!existingAppNames.some(
appName => appName.toLowerCase() === value.toLowerCase()
)
)
}
await hostingStore.actions.fetchDeployedApps()
const existingAppNames = svelteGet(hostingStore).deployedAppNames
validator.name = string()
.required("Your application must have a name")
.test(
"non-existing-app-name",
"Another app with the same name already exists",
value => {
return !existingAppNames.some(
appName => appName.toLowerCase() === value.toLowerCase()
)
}
)
})
const checkValidity = async (values, validator) => {
@ -70,15 +62,24 @@
async function createNewApp() {
submitting = true
// Check a template exists if we are important
if (template && !$values.file) {
$errors.file = "Please choose a file to import"
valid = false
submitting = false
return false
}
try {
// Create form data to create app
let data = new FormData()
data.append("name", $values.applicationName)
data.append("name", $values.name)
data.append("useTemplate", template != null)
if (template) {
data.append("templateName", template.name)
data.append("templateKey", template.key)
data.append("templateFile", template.file)
data.append("templateFile", $values.file)
}
// Create App
@ -89,7 +90,7 @@
}
analytics.captureEvent("App Created", {
name: $values.applicationName,
name: $values.name,
appId: appJson._id,
template,
})
@ -112,7 +113,7 @@
}
const userResp = await api.post(`/api/users/metadata/self`, user)
await userResp.json()
window.location = `/builder/app/${appJson._id}`
$goto(`/builder/app/${appJson._id}`)
} catch (error) {
console.error(error)
notifications.error(error)
@ -121,129 +122,33 @@
}
</script>
<div class="container">
<div class="sidebar">
<img src={Logo} alt="budibase icon" />
<div class="steps">
{#each steps as component, i}
<Indicator
active={$currentStep === i}
done={i < $currentStep}
step={i + 1}
/>
{/each}
</div>
</div>
<div class="body">
<div class="heading">
<Heading size="L">Get started with Budibase</Heading>
</div>
<div class="step">
{#each steps as component, i (i)}
<div class:hidden={$currentStep !== i}>
<svelte:component
this={component}
{template}
{values}
{errors}
{touched}
/>
</div>
{/each}
</div>
<div class="footer">
{#if $currentStep > 0}
<Button medium secondary on:click={() => $currentStep--}>Back</Button>
{/if}
{#if $currentStep < steps.length - 1}
<Button medium cta on:click={() => $currentStep++} disabled={!valid}>
Next
</Button>
{/if}
{#if $currentStep === steps.length - 1}
<Button
medium
cta
on:click={createNewApp}
disabled={!valid || submitting}
>
{submitting ? "Loading..." : "Submit"}
</Button>
{/if}
</div>
</div>
{#if submitting}
<div in:fade class="spinner-container">
<Spinner />
<span class="spinner-text">Creating your app...</span>
</div>
<ModalContent
title={template ? "Import app" : "Create new app"}
confirmText={template ? "Import app" : "Create app"}
onConfirm={createNewApp}
disabled={!valid}
>
{#if template}
<Dropzone
error={$touched.file && $errors.file}
gallery={false}
label="File to import"
value={[$values.file]}
on:change={e => {
$values.file = e.detail?.[0]
$touched.file = true
}}
/>
{/if}
</div>
<style>
.container {
min-height: 600px;
display: grid;
grid-template-columns: 80px 1fr;
position: relative;
}
.sidebar {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 40px 0;
background: var(--grey-1);
}
.steps {
flex: 1 1 auto;
display: grid;
border-bottom-left-radius: 0.5rem;
border-top-left-radius: 0.5rem;
grid-gap: 30px;
align-content: center;
}
.heading {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 20px;
}
.body {
padding: 40px 60px 40px 60px;
display: grid;
align-items: center;
grid-template-rows: auto 1fr auto;
}
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
gap: 15px;
}
.spinner-container {
background: var(--background);
position: absolute;
border-radius: 5px;
left: 0;
top: 0;
right: 0;
bottom: 0;
display: grid;
justify-items: center;
align-content: center;
grid-gap: 50px;
}
.spinner-text {
font-size: 2em;
}
.hidden {
display: none;
}
img {
height: 40px;
margin-bottom: 20px;
}
</style>
<Body size="S">
Give your new app a name, and choose which groups have access (paid plans
only).
</Body>
<Input
bind:value={$values.name}
error={$touched.name && $errors.name}
on:blur={() => ($touched.name = true)}
label="Name"
/>
<Checkbox label="Group access" disabled value={true} text="All users" />
</ModalContent>

83
packages/builder/src/components/start/Indicator.svelte

@ -1,83 +0,0 @@
<script>
export let step, done, active
</script>
<div class="container" class:active class:done>
<div class="circle" class:active class:done>
{#if done}
<svg
width="12"
height="10"
viewBox="0 0 12 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.1212 0.319527C10.327 0.115582 10.6047 0.000803464 10.8944
4.20219e-06C11.1841 -0.00079506 11.4624 0.11245 11.6693
0.315256C11.8762 0.518062 11.9949 0.794134 11.9998 1.08379C12.0048
1.37344 11.8955 1.65339 11.6957 1.86313L5.82705 9.19893C5.72619
9.30757 5.60445 9.39475 5.46913 9.45527C5.3338 9.51578 5.18766 9.54839
5.03944 9.55113C4.89123 9.55388 4.74398 9.52671 4.60651
9.47124C4.46903 9.41578 4.34416 9.33316 4.23934 9.22833L0.350925
5.33845C0.242598 5.23751 0.155712 5.11578 0.0954499 4.98054C0.0351876
4.84529 0.00278364 4.69929 0.00017159 4.55124C-0.00244046 4.4032
0.024793 4.25615 0.0802466 4.11886C0.1357 3.98157 0.218238 3.85685
0.322937 3.75215C0.427636 3.64746 0.55235 3.56492 0.68964
3.50946C0.82693 3.45401 0.973983 3.42678 1.12203 3.42939C1.27007 3.432
1.41607 3.46441 1.55132 3.52467C1.68657 3.58493 1.80829 3.67182
1.90923 3.78014L4.98762 6.85706L10.0933 0.35187C10.1024 0.340482
10.1122 0.329679 10.1227 0.319527H10.1212Z"
fill="var(--background)"
/>
</svg>
{:else}{step}{/if}
</div>
</div>
<style>
.container::before {
content: "";
position: absolute;
top: -30px;
width: 1px;
height: 30px;
background: var(--grey-5);
}
.container:first-child::before {
display: none;
}
.container {
position: relative;
height: 45px;
display: grid;
place-items: center;
}
.container.active {
box-shadow: inset 3px 0 0 0 var(--blue);
}
.circle.active {
background: var(--blue);
color: white;
border: none;
}
.circle.done {
background: var(--grey-5);
color: white;
border: none;
}
.circle {
color: var(--grey-5);
font-size: 14px;
font-weight: 500;
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--grey-5);
box-sizing: border-box;
}
</style>

121
packages/builder/src/components/start/Steps/Info.svelte

@ -1,121 +0,0 @@
<script>
import { Label, Heading, Input, notifications } from "@budibase/bbui"
const BYTES_IN_MB = 1000000
const FILE_SIZE_LIMIT = BYTES_IN_MB * 5
export let template
export let values
export let errors
export let touched
let blurred = { appName: false }
let file
function handleFile(evt) {
const fileArray = Array.from(evt.target.files)
if (fileArray.some(file => file.size >= FILE_SIZE_LIMIT)) {
notifications.error(
`Files cannot exceed ${
FILE_SIZE_LIMIT / BYTES_IN_MB
}MB. Please try again with smaller files.`
)
return
}
file = evt.target.files[0]
template.file = file
}
</script>
<div class="container">
{#if template?.fromFile}
<Heading size="L">Import your Web App</Heading>
{:else}
<Heading size="L">Create your Web App</Heading>
{/if}
{#if template?.fromFile}
<div class="template">
<Label extraSmall grey>Import File</Label>
<div class="dropzone">
<input
id="file-upload"
accept=".txt"
type="file"
on:change={handleFile}
/>
<label for="file-upload" class:uploaded={file}>
{#if file}{file.name}{:else}Import{/if}
</label>
</div>
</div>
{:else if template}
<div class="template">
<Label extraSmall grey>Selected Template</Label>
<Heading size="S">{template.name}</Heading>
</div>
{/if}
<Input
on:change={() => ($touched.applicationName = true)}
bind:value={$values.applicationName}
label="Web App Name"
placeholder="Enter name of your web application"
error={$touched.applicationName && $errors.applicationName}
/>
</div>
<style>
.container {
display: grid;
grid-gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
}
.template :global(label) {
/* Fix layout due to LH 0 on heading */
margin-bottom: 16px;
}
.dropzone {
text-align: center;
display: flex;
align-items: center;
flex-direction: column;
border-radius: 10px;
transition: all 0.3s;
}
.uploaded {
color: var(--blue);
}
input[type="file"] {
display: none;
}
label {
font-family: var(--font-sans);
cursor: pointer;
font-weight: 500;
box-sizing: border-box;
overflow: hidden;
border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s;
display: inline-flex;
text-rendering: optimizeLegibility;
min-width: auto;
outline: none;
font-feature-settings: "case" 1, "rlig" 1, "calt" 0;
-webkit-box-align: center;
user-select: none;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 100%;
background-color: var(--grey-2);
font-size: var(--font-size-xs);
line-height: normal;
border: var(--border-transparent);
}
</style>

29
packages/builder/src/components/start/Steps/User.svelte

@ -1,29 +0,0 @@
<script>
import { Select, Heading } from "@budibase/bbui"
export let values
export let errors
export let touched
</script>
<div class="container">
<Heading size="L">What's your role for this app?</Heading>
<Select
bind:value={$values.roleId}
label="Role"
options={[
{ label: "Admin", value: "ADMIN" },
{ label: "Power User", value: "POWER_USER" },
]}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
error={$errors.roleId}
/>
</div>
<style>
.container {
display: grid;
grid-gap: var(--spacing-xl);
}
</style>

2
packages/builder/src/components/start/Steps/index.js

@ -1,2 +0,0 @@
export { default as Info } from "./Info.svelte"
export { default as User } from "./User.svelte"

0
packages/builder/src/pages/builder/app/[application]/_reset.svelte → packages/builder/src/pages/builder/app/[application]/_layout.svelte

8
packages/builder/src/pages/builder/portal/_layout.svelte

@ -17,9 +17,6 @@
import { auth } from "stores/backend"
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
organisation.init()
apps.load()
let orgName
let orgLogo
let user
@ -32,7 +29,10 @@
user = { name: "John Doe" }
}
onMount(getInfo)
onMount(() => {
organisation.init()
getInfo()
})
let menu = [
{ title: "Apps", href: "/builder/portal/apps" },

7
packages/builder/src/pages/builder/portal/apps/_layout.svelte

@ -1,7 +0,0 @@
<script>
import { Page } from "@budibase/bbui"
</script>
<Page wide>
<slot />
</Page>

221
packages/builder/src/pages/builder/portal/apps/index.svelte

@ -8,18 +8,31 @@
ButtonGroup,
Select,
Modal,
ModalContent,
Page,
notifications,
Body,
} from "@budibase/bbui"
import AppList from "components/start/AppList.svelte"
import CreateAppModal from "components/start/CreateAppModal.svelte"
import api from "builderStore/api"
import api, { del } from "builderStore/api"
import analytics from "analytics"
import { onMount } from "svelte"
import { apps } from "stores/portal"
import download from "downloadjs"
import { goto } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import AppCard from "components/start/AppCard.svelte"
import AppRow from "components/start/AppRow.svelte"
let layout = "grid"
let modal
let template
let appToDelete
let creationModal
let deletionModal
let creatingApp = false
let loaded = false
async function checkKeys() {
const checkKeys = async () => {
const response = await api.get(`/api/keys/`)
const keys = await response.json()
if (keys.userId) {
@ -27,55 +40,146 @@
}
}
function initiateAppImport() {
const initiateAppCreation = () => {
creationModal.show()
creatingApp = true
}
const initiateAppImport = () => {
template = { fromFile: true }
modal.show()
creationModal.show()
creatingApp = true
}
const stopAppCreation = () => {
template = null
creatingApp = false
}
const openApp = app => {
$goto(`../../app/${app._id}`)
}
const exportApp = app => {
try {
download(
`/api/backups/export?appId=${app._id}&appname=${encodeURIComponent(
app.name
)}`
)
notifications.success("App export complete")
} catch (err) {
console.error(err)
notifications.error("App export failed")
}
}
const deleteApp = app => {
appToDelete = app
deletionModal.show()
}
const confirmDeleteApp = async () => {
if (!appToDelete) {
return
}
await del(`/api/applications/${appToDelete?._id}`)
await apps.load()
appToDelete = null
}
onMount(checkKeys)
onMount(async () => {
checkKeys()
await apps.load()
loaded = true
})
</script>
<Layout noPadding>
<div class="title">
<Heading>Apps</Heading>
<ButtonGroup>
<Button secondary on:click={initiateAppImport}>Import app</Button>
<Button cta on:click={modal.show}>Create new app</Button>
</ButtonGroup>
</div>
<div class="filter">
<div class="select">
<Select quiet placeholder="Filter by groups" />
<Page wide>
{#if $apps.length}
<Layout noPadding>
<div class="title">
<Heading>Apps</Heading>
<ButtonGroup>
<Button secondary on:click={initiateAppImport}>Import app</Button>
<Button cta on:click={initiateAppCreation}>Create new app</Button>
</ButtonGroup>
</div>
<div class="filter">
<div class="select">
<Select quiet placeholder="Filter by groups" />
</div>
<ActionGroup>
<ActionButton
on:click={() => (layout = "grid")}
selected={layout === "grid"}
quiet
icon="ClassicGridView"
/>
<ActionButton
on:click={() => (layout = "table")}
selected={layout === "table"}
quiet
icon="ViewRow"
/>
</ActionGroup>
</div>
<div
class:appGrid={layout === "grid"}
class:appTable={layout === "table"}
>
{#each $apps as app, idx (app._id)}
<svelte:component
this={layout === "grid" ? AppCard : AppRow}
{app}
{openApp}
{exportApp}
{deleteApp}
last={idx === $apps.length - 1}
/>
{/each}
</div>
</Layout>
{/if}
{#if !$apps.length && !creatingApp && loaded}
<div class="empty-wrapper">
<Modal inline>
<ModalContent
title="Create your first app"
confirmText="Create app"
showCancelButton={false}
showCloseIcon={false}
onConfirm={initiateAppCreation}
size="M"
>
<div slot="footer">
<Button on:click={initiateAppImport} secondary>Import app</Button>
</div>
<Body size="S">
The purpose of the Budibase builder is to help you build beautiful,
powerful applications quickly and easily.
</Body>
</ModalContent>
</Modal>
</div>
<ActionGroup>
<ActionButton
on:click={() => (layout = "grid")}
selected={layout === "grid"}
quiet
icon="ClassicGridView"
/>
<ActionButton
on:click={() => (layout = "table")}
selected={layout === "table"}
quiet
icon="ViewRow"
/>
</ActionGroup>
</div>
{#if layout === "grid"}
<AppList />
{:else}
Table view.
{/if}
</Layout>
</Page>
<Modal
bind:this={modal}
bind:this={creationModal}
padding={false}
width="600px"
on:hide={() => (template = null)}
on:hide={stopAppCreation}
>
<CreateAppModal {template} />
</Modal>
<ConfirmDialog
bind:this={deletionModal}
title="Confirm deletion"
okText="Delete app"
onOk={confirmDeleteApp}
>
Are you sure you want to delete the app <b>{appToDelete?.name}</b>?
</ConfirmDialog>
<style>
.title,
@ -87,6 +191,41 @@
}
.select {
width: 110px;
width: 120px;
}
.appGrid {
display: grid;
grid-gap: 50px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.appTable {
display: grid;
grid-template-rows: auto;
grid-template-columns: 1fr 1fr 1fr auto;
align-items: center;
}
.appTable :global(> div) {
height: 70px;
display: grid;
align-items: center;
gap: var(--spacing-xl);
grid-template-columns: auto 1fr;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0 var(--spacing-s);
}
.appTable :global(> div:not(.last)) {
border-bottom: var(--border-light);
}
.empty-wrapper {
flex: 1 1 auto;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

15
packages/builder/src/pages/builder/portal/email/TemplateBindings.svelte

@ -0,0 +1,15 @@
<script>
import { Body, Menu, MenuItem, Detail, MenuSection, DetailSummary } from "@budibase/bbui"
export let bindings
export let onBindingClick = () => {}
</script>
<Menu>
{#each bindings as binding}
<MenuItem on:click={() => onBindingClick(binding)}>
<Detail size="M">{binding.name}</Detail>
<Body size="XS" noPadding>{binding.description}</Body>
</MenuItem>
{/each}
</Menu>

17
packages/builder/src/pages/builder/portal/email/TemplateLink.svelte

@ -0,0 +1,17 @@
<script>
import { url } from "@roxi/routify"
import {
Link,
} from "@budibase/bbui"
import { roles } from "stores/backend"
export let value
</script>
<Link quiet href={$url(`./${value}`)}><span>{value}</span></Link>
<style>
span {
text-transform: capitalize;
}
</style>

140
packages/builder/src/pages/builder/portal/email/[template].svelte

@ -0,0 +1,140 @@
<script>
import {
Menu,
MenuItem,
Button,
Detail,
Heading,
Divider,
Label,
Modal,
ModalContent,
notifications,
Layout,
Icon,
Body,
Page,
Select,
Tabs,
Tab,
MenuSection,
MenuSeparator,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { onMount } from "svelte"
import { fade } from "svelte/transition"
import { email } from "stores/portal"
import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./TemplateBindings.svelte"
import api from "builderStore/api"
const ConfigTypes = {
SMTP: "smtp",
}
export let template
let selected = "Edit"
let selectedBindingTab = "Template"
let htmlEditor
$: selectedTemplate = $email.templates.find(
({ purpose }) => purpose === template
)
$: templateBindings =
$email.definitions?.bindings[selectedTemplate.purpose] || []
async function saveTemplate() {
try {
// Save your template config
await email.templates.save(selectedTemplate)
notifications.success(`Template saved.`)
} catch (err) {
notifications.error(`Failed to update template settings. ${err}`)
}
}
function setTemplateBinding(binding) {
htmlEditor.update((selectedTemplate.contents += `{{ ${binding.name} }}`))
}
</script>
<Page wide gap="L">
<div class="backbutton" on:click={() => $goto("./")}>
<Icon name="BackAndroid" />
<span>Back</span>
</div>
<header>
<Heading>
Email Template: {template}
</Heading>
<Button cta on:click={saveTemplate}>Save</Button>
</header>
<Tabs {selected}>
<Tab title="Edit">
<div class="template-editor">
<Editor
editorHeight={800}
bind:this={htmlEditor}
mode="handlebars"
on:change={e => {
selectedTemplate.contents = e.detail.value
}}
value={selectedTemplate.contents}
/>
<div class="bindings-editor">
<Detail size="L">Bindings</Detail>
<Tabs selected={selectedBindingTab}>
<Tab title="Template">
<TemplateBindings
title="Template Bindings"
bindings={templateBindings}
onBindingClick={setTemplateBinding}
/>
</Tab>
<Tab title="Common">
<TemplateBindings
title="Common Bindings"
bindings={$email.definitions.bindings.common}
onBindingClick={setTemplateBinding}
/>
</Tab>
</Tabs>
</div>
</Tab>
<Tab title="Preview">
<div class="preview" transition:fade>
{@html selectedTemplate.contents}
</div>
</Tab>
</Tabs>
</Page>
<style>
.template-editor {
display: grid;
grid-template-columns: 1fr 20%;
grid-gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
}
header {
display: flex;
width: 100%;
justify-content: space-between;
}
.preview {
background: white;
height: 800px;
padding: var(--spacing-xl);
}
.backbutton {
display: flex;
gap: var(--spacing-m);
margin-bottom: var(--spacing-xl);
cursor: pointer;
}
</style>

99
packages/builder/src/pages/builder/portal/email/index.svelte

@ -1,5 +1,8 @@
<script>
import { goto } from "@roxi/routify"
import {
Menu,
MenuItem,
Button,
Heading,
Divider,
@ -13,27 +16,40 @@
Body,
Page,
Select,
MenuSection,
MenuSeparator,
Table,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { email } from "stores/portal"
import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./TemplateBindings.svelte"
import TemplateLink from "./TemplateLink.svelte"
import api from "builderStore/api"
const ConfigTypes = {
SMTP: "smtp",
}
let smtpConfig
let templateIdx = 0
let templateDefinition
let templates = []
let htmlModal
const templateSchema = {
purpose: {
displayName: "Email",
editable: false
},
}
$: templateTypes = templates.map((template, idx) => ({
label: template.purpose,
value: idx,
}))
const customRenderers = [
{
column: "purpose",
component: TemplateLink,
},
]
$: selectedTemplate = templates[templateIdx]
let smtpConfig
let bindingsOpen = false
let htmlModal
let htmlEditor
let loading
async function saveSmtp() {
try {
@ -52,13 +68,7 @@
async function saveTemplate() {
try {
// Save your SMTP config
const response = await api.post(`/api/admin/template`, selectedTemplate)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
selectedTemplate._rev = json._rev
selectedTemplate._id = json._id
await email.templates.save(selectedTemplate)
notifications.success(`Template saved.`)
} catch (err) {
notifications.error(`Failed to update template settings. ${err}`)
@ -84,22 +94,11 @@
}
}
async function fetchTemplates() {
// fetch the email template definitions
const templatesResponse = await api.get(`/api/admin/template/definitions`)
const templateDefDoc = await templatesResponse.json()
// fetch the email templates themselves
const emailTemplatesResponse = await api.get(`/api/admin/template/email`)
const emailTemplates = await emailTemplatesResponse.json()
templateDefinition = templateDefDoc
templates = emailTemplates
}
onMount(async () => {
loading = true
await fetchSmtp()
await fetchTemplates()
await email.templates.fetch()
loading = false
})
</script>
@ -144,8 +143,8 @@
<Label>From email address</Label>
<Input type="email" bind:value={smtpConfig.config.from} />
</div>
<Button cta on:click={saveSmtp}>Save</Button>
</Layout>
<Button cta on:click={saveSmtp}>Save</Button>
</div>
<Divider />
@ -155,26 +154,17 @@
Budibase comes out of the box with ready-made email templates to help
with user onboarding. Please refrain from changing the links.
</Body>
<div class="template-controls">
<Select bind:value={templateIdx} options={templateTypes} />
<Button primary on:click={htmlModal.show}>Edit HTML</Button>
</div>
</div>
<Modal bind:this={htmlModal}>
<ModalContent
title="Edit Template HTML"
onConfirm={saveTemplate}
size="XL"
>
<Editor
mode="handlebars"
on:change={e => {
selectedTemplate.contents = e.detail.value
}}
value={selectedTemplate.contents}
/>
</ModalContent>
</Modal>
<Table
{customRenderers}
on:editrow={evt => $goto(`./${evt.detail.purpose}`)}
data={$email.templates}
schema={templateSchema}
{loading}
allowEditRows={false}
allowSelectRows={false}
allowEditColumns={false}
/>
{/if}
</Page>
@ -199,11 +189,4 @@
header {
margin-bottom: 42px;
}
.template-controls {
display: grid;
grid-template-columns: 80% 1fr;
grid-gap: var(--spacing-xl);
margin-bottom: var(--spacing-xl);
}
</style>

2
packages/builder/src/pages/builder/portal/oauth/index.svelte

@ -91,7 +91,7 @@
</div>
{/each}
<div>
<Button primary on:click={() => save(google)}>Save</Button>
<Button cta on:click={() => save(google)}>Save</Button>
</div>
<Divider />
{/if}

44
packages/builder/src/stores/portal/email.js

@ -0,0 +1,44 @@
import { writable } from "svelte/store"
import api from "builderStore/api"
export function createEmailStore() {
const store = writable([])
return {
subscribe: store.subscribe,
templates: {
fetch: async () => {
// fetch the email template definitions
const response = await api.get(`/api/admin/template/definitions`)
const definitions = await response.json()
// fetch the email templates themselves
const templatesResponse = await api.get(`/api/admin/template/email`)
const templates = await templatesResponse.json()
store.set({
definitions,
templates,
})
},
save: async template => {
// Save your template config
const response = await api.post(`/api/admin/template`, template)
const json = await response.json()
if (response.status !== 200) throw new Error(json.message)
template._rev = json._rev
template._id = json._id
store.update(state => {
const currentIdx = state.templates.findIndex(
template => template.purpose === json.purpose
)
state.templates.splice(currentIdx, 1, template)
return state
})
},
},
}
}
export const email = createEmailStore()

1
packages/builder/src/stores/portal/index.js

@ -1,3 +1,4 @@
export { organisation } from "./organisation"
export { admin } from "./admin"
export { apps } from "./apps"
export { email } from "./email"

10
packages/builder/yarn.lock

@ -1194,10 +1194,10 @@
estree-walker "^2.0.1"
picomatch "^2.2.2"
"@roxi/routify@2.15.1":
version "2.15.1"
resolved "https://registry.yarnpkg.com/@roxi/routify/-/routify-2.15.1.tgz#cbd5eafedfee7f04b154173dccd7474c177acb4f"
integrity sha512-IRdoaPSfP09EwWtB+tpbHgH6ejYtowale24rgfpxRQhFNyTUK4jYXclvx3XkUD1NSupSgl1kDAsWSiRSG0WvkQ==
"@roxi/routify@2.18.0":
version "2.18.0"
resolved "https://registry.yarnpkg.com/@roxi/routify/-/routify-2.18.0.tgz#8f88bedd936312d0dbe44cbc11ab179b1f938ec2"
integrity sha512-MVB50HN+VQWLzfjLplcBjsSBvwOiExKOmht2DuWR3WQ60JxQi9pSejkB06tFVkFKNXz2X5iYtKDqKBTdae/gRg==
dependencies:
"@roxi/ssr" "^0.2.1"
"@types/node" ">=4.2.0 < 13"
@ -5821,7 +5821,7 @@ svelte-portal@0.1.0:
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-0.1.0.tgz#cc2821cc84b05ed5814e0218dcdfcbebc53c1742"
integrity sha512-kef+ksXVKun224mRxat+DdO4C+cGHla+fEcZfnBAvoZocwiaceOfhf5azHYOPXSSB1igWVFTEOF3CDENPnuWxg==
svelte@^3.37.0:
svelte@^3.38.2:
version "3.38.2"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==

2
packages/client/package.json

@ -38,7 +38,7 @@
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-svg": "^2.0.0",
"rollup-plugin-terser": "^7.0.2",
"svelte": "^3.37.0"
"svelte": "^3.38.2"
},
"gitHead": "4b6efc42ed3273595c7a129411f4d883733d3321"
}

2
packages/server/package.json

@ -122,7 +122,7 @@
"pouchdb-find": "^7.2.2",
"pouchdb-replication-stream": "1.2.9",
"server-destroy": "1.0.1",
"svelte": "3.30.0",
"svelte": "^3.38.2",
"to-json-schema": "0.2.5",
"uuid": "3.3.2",
"validate.js": "0.13.1",

3
packages/server/src/api/index.js

@ -67,4 +67,7 @@ for (let route of mainRoutes) {
router.use(staticRoutes.routes())
router.use(staticRoutes.allowedMethods())
// add a redirect for when hitting server directly
router.redirect("/", "/builder")
module.exports = router

4
packages/server/src/utilities/workerRequests.js

@ -19,12 +19,14 @@ function request(ctx, request) {
if (!request.headers) {
request.headers = {}
}
if (request.body) {
if (request.body && Object.keys(request.body).length > 0) {
request.headers["Content-Type"] = "application/json"
request.body =
typeof request.body === "object"
? JSON.stringify(request.body)
: request.body
} else {
delete request.body
}
if (ctx.headers) {
request.headers.cookie = ctx.headers.cookie

1327
packages/server/yarn.lock

File diff suppressed because it is too large

2
packages/standard-components/package.json

@ -23,7 +23,7 @@
],
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.5",
"svelte": "^3.37.0",
"svelte": "^3.38.2",
"vite": "^2.1.5"
},
"keywords": [

12
packages/worker/src/api/controllers/admin/templates.js

@ -27,9 +27,17 @@ exports.save = async ctx => {
}
exports.definitions = async ctx => {
const bindings = {}
for (let template of TemplateMetadata.email) {
bindings[template.purpose] = template.bindings
}
ctx.body = {
purpose: TemplateMetadata,
bindings: Object.values(TemplateBindings),
bindings: {
...bindings,
common: Object.values(TemplateBindings),
},
}
}

93
packages/worker/src/api/controllers/auth.js

@ -0,0 +1,93 @@
const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware")
const { Configs } = require("../../constants")
const CouchDB = require("../../db")
const { clearCookie } = authPkg.utils
const { Cookies } = authPkg.constants
const { passport } = authPkg.auth
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
delete user.token
ctx.body = { user }
})(ctx, next)
}
exports.logout = async ctx => {
clearCookie(ctx, Cookies.Auth)
ctx.body = { message: "User logged out" }
}
/**
* The initial call that google authentication makes to take you to the google login screen.
* On a successful login, you will be redirected to the googleAuth callback route.
*/
exports.googlePreAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedFullConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(strategy, {
scope: ["profile", "email"],
})(ctx, next)
}
exports.googleAuth = async (ctx, next) => {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedFullConfig(db, {
type: Configs.GOOGLE,
group: ctx.query.group,
})
const strategy = await google.strategyFactory(config)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, user) => {
if (err) {
return ctx.throw(403, "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
expires,
path: "/",
httpOnly: false,
overwrite: true,
})
ctx.redirect("/")
}
)(ctx, next)
}

7
packages/worker/src/api/routes/tests/email.spec.js

@ -2,8 +2,13 @@ const setup = require("./utilities")
const { EmailTemplatePurpose } = require("../../../constants")
// mock the email system
const sendMailMock = jest.fn()
jest.mock("nodemailer")
const sendMailMock = setup.emailMock()
const nodemailer = require("nodemailer")
nodemailer.createTransport.mockReturnValue({
sendMail: sendMailMock,
verify: jest.fn()
})
describe("/api/admin/email", () => {
let request = setup.getRequest()

92
packages/worker/src/constants/index.js

@ -35,22 +35,46 @@ const EmailTemplatePurpose = {
}
const TemplateBindings = {
PLATFORM_URL: "platformUrl",
COMPANY: "company",
LOGO_URL: "logoUrl",
STYLES: "styles",
BODY: "body",
INVITE_URL: "inviteUrl",
EMAIL: "email",
RESET_URL: "resetUrl",
USER: "user",
REQUEST: "request",
DOCS_URL: "docsUrl",
LOGIN_URL: "loginUrl",
CURRENT_YEAR: "currentYear",
CURRENT_DATE: "currentDate",
RESET_CODE: "resetCode",
INVITE_CODE: "inviteCode",
PLATFORM_URL: {
name: "platformUrl",
description: "The URL used to access the budibase platform",
},
COMPANY: {
name: "company",
description: "The name of your organization",
},
LOGO_URL: {
name: "logoUrl",
description: "The URL of your organizations logo.",
},
EMAIL: {
name: "email",
description: "The recipients email address.",
},
USER: {
name: "user",
description: "The recipients user object.",
},
REQUEST: {
name: "request",
description: "Additional request metadata.",
},
DOCS_URL: {
name: "docsUrl",
description: "Organization documentation URL.",
},
LOGIN_URL: {
name: "loginUrl",
description: "The URL used to log into the organization budibase instance.",
},
CURRENT_YEAR: {
name: "currentYear",
description: "The current year.",
},
CURRENT_DATE: {
name: "currentDate",
description: "The current date.",
},
}
const TemplateMetadata = {
@ -58,20 +82,52 @@ const TemplateMetadata = {
{
name: "Styling",
purpose: EmailTemplatePurpose.STYLES,
bindings: ["url", "company", "companyUrl", "styles", "body"],
},
{
name: "Base Format",
purpose: EmailTemplatePurpose.BASE,
bindings: ["company", "inviteUrl"],
bindings: [
{
name: "body",
description: "The main body of another email template.",
},
{
name: "styles",
description: "The contents of the Styling email template.",
},
],
},
{
name: "Password Recovery",
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
bindings: [
{
name: "resetUrl",
description:
"The URL the recipient must click to reset their password.",
},
{
name: "resetCode",
description:
"The temporary password reset code used in the recipients password reset URL.",
},
],
},
{
name: "New User Invitation",
purpose: EmailTemplatePurpose.INVITATION,
bindings: [
{
name: "inviteUrl",
description:
"The URL the recipient must click to accept the invitation and activate their account.",
},
{
name: "inviteCode",
description:
"The temporary invite code used in the recipients invitation URL.",
},
],
},
{
name: "Custom",

Loading…
Cancel
Save