Merge branch 'smtp-configuration' of github.com:Budibase/budibase into smtp-configuration
This commit is contained in:
commit
0aabcf5a47
|
@ -92,6 +92,16 @@ then `cd ` into your local copy.
|
||||||
|
|
||||||
### 3. Install and Build
|
### 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` to install project dependencies
|
||||||
|
|
||||||
`yarn bootstrap` will install all budibase modules and symlink them together using lerna.
|
`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
|
1. Open a new console
|
||||||
2. `yarn dev` (from root)
|
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.
|
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
|
## 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.
|
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.
|
||||||
|
|
|
@ -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()
|
|
@ -14,9 +14,10 @@
|
||||||
"prettier-plugin-svelte": "^2.2.0",
|
"prettier-plugin-svelte": "^2.2.0",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rollup-plugin-replace": "^2.2.0",
|
"rollup-plugin-replace": "^2.2.0",
|
||||||
"svelte": "^3.37.0"
|
"svelte": "^3.38.2"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"setup": "./hosting/scripts/setup.js && yarn && yarn bootstrap && yarn build && yarn dev",
|
||||||
"bootstrap": "lerna link && lerna bootstrap",
|
"bootstrap": "lerna link && lerna bootstrap",
|
||||||
"build": "lerna run build",
|
"build": "lerna run build",
|
||||||
"initialise": "lerna run initialise",
|
"initialise": "lerna run initialise",
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
"rollup-plugin-postcss": "^4.0.0",
|
"rollup-plugin-postcss": "^4.0.0",
|
||||||
"rollup-plugin-svelte": "^7.1.0",
|
"rollup-plugin-svelte": "^7.1.0",
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
"rollup-plugin-terser": "^7.0.2",
|
||||||
"svelte": "^3.37.0"
|
"svelte": "^3.38.2"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"svelte"
|
"svelte"
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
export let fileSizeLimit = BYTES_IN_MB * 20
|
export let fileSizeLimit = BYTES_IN_MB * 20
|
||||||
export let processFiles = null
|
export let processFiles = null
|
||||||
export let handleFileTooLarge = null
|
export let handleFileTooLarge = null
|
||||||
|
export let gallery = true
|
||||||
|
export let error = null
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const imageExtensions = [
|
const imageExtensions = [
|
||||||
|
@ -52,6 +54,8 @@
|
||||||
const newValue = [...value, ...processedFiles]
|
const newValue = [...value, ...processedFiles]
|
||||||
dispatch("change", newValue)
|
dispatch("change", newValue)
|
||||||
selectedImageIdx = newValue.length - 1
|
selectedImageIdx = newValue.length - 1
|
||||||
|
} else {
|
||||||
|
dispatch("change", fileList)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,47 +98,68 @@
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{#if selectedImage}
|
{#if selectedImage}
|
||||||
<div class="gallery">
|
{#if gallery}
|
||||||
<div class="title">
|
<div class="gallery">
|
||||||
<div class="filename">{selectedImage.name}</div>
|
<div class="title">
|
||||||
<div class="filesize">
|
<div class="filename">{selectedImage.name}</div>
|
||||||
{#if selectedImage.size <= BYTES_IN_MB}
|
<div class="filesize">
|
||||||
{`${selectedImage.size / BYTES_IN_KB} KB`}
|
{#if selectedImage.size <= BYTES_IN_MB}
|
||||||
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if}
|
{`${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>
|
</div>
|
||||||
{#if !disabled}
|
{#if isImage}
|
||||||
<div class="delete-button" on:click={removeFile}>
|
<img alt="preview" src={selectedImage.url} />
|
||||||
<Icon name="Close" />
|
{:else}
|
||||||
|
<div class="placeholder">
|
||||||
|
<div class="extension">{selectedImage.extension}</div>
|
||||||
|
<div>Preview not supported</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<div
|
||||||
{#if isImage}
|
class="nav left"
|
||||||
<img alt="preview" src={selectedImage.url} />
|
class:visible={selectedImageIdx > 0}
|
||||||
{:else}
|
on:click={navigateLeft}
|
||||||
<div class="placeholder">
|
>
|
||||||
<div class="extension">{selectedImage.extension}</div>
|
<Icon name="ChevronLeft" />
|
||||||
<div>Preview not supported</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<div
|
||||||
<div
|
class="nav right"
|
||||||
class="nav left"
|
class:visible={selectedImageIdx < fileCount - 1}
|
||||||
class:visible={selectedImageIdx > 0}
|
on:click={navigateRight}
|
||||||
on:click={navigateLeft}
|
>
|
||||||
>
|
<Icon name="ChevronRight" />
|
||||||
<Icon name="ChevronLeft" />
|
</div>
|
||||||
|
<div class="footer">File {selectedImageIdx + 1} of {fileCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
{:else if value?.length}
|
||||||
class="nav right"
|
{#each value as file}
|
||||||
class:visible={selectedImageIdx < fileCount - 1}
|
<div class="gallery">
|
||||||
on:click={navigateRight}
|
<div class="title">
|
||||||
>
|
<div class="filename">{file.name}</div>
|
||||||
<Icon name="ChevronRight" />
|
<div class="filesize">
|
||||||
</div>
|
{#if file.size <= BYTES_IN_MB}
|
||||||
<div class="footer">File {selectedImageIdx + 1} of {fileCount}</div>
|
{`${file.size / BYTES_IN_KB} KB`}
|
||||||
</div>
|
{: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}
|
{/if}
|
||||||
<div
|
<div
|
||||||
class="spectrum-Dropzone"
|
class="spectrum-Dropzone"
|
||||||
|
class:is-invalid={!!error}
|
||||||
class:disabled
|
class:disabled
|
||||||
role="region"
|
role="region"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
|
@ -245,6 +270,9 @@
|
||||||
.spectrum-Dropzone {
|
.spectrum-Dropzone {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
.spectrum-Dropzone.is-invalid {
|
||||||
|
border-color: var(--spectrum-global-color-red-400);
|
||||||
|
}
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
@ -276,7 +304,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: stretch;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.filename {
|
.filename {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
@ -331,6 +359,7 @@
|
||||||
.delete-button {
|
.delete-button {
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
.delete-button i {
|
.delete-button i {
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
|
|
|
@ -37,6 +37,7 @@
|
||||||
}
|
}
|
||||||
focus = false
|
focus = false
|
||||||
updateValue(event.target.value)
|
updateValue(event.target.value)
|
||||||
|
dispatch("blur")
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateValueOnEnter = event => {
|
const updateValueOnEnter = event => {
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
export let fileSizeLimit = undefined
|
export let fileSizeLimit = undefined
|
||||||
export let processFiles = undefined
|
export let processFiles = undefined
|
||||||
export let handleFileTooLarge = undefined
|
export let handleFileTooLarge = undefined
|
||||||
|
export let gallery = true
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
const onChange = e => {
|
const onChange = e => {
|
||||||
|
@ -27,6 +28,7 @@
|
||||||
{fileSizeLimit}
|
{fileSizeLimit}
|
||||||
{processFiles}
|
{processFiles}
|
||||||
{handleFileTooLarge}
|
{handleFileTooLarge}
|
||||||
|
{gallery}
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
@ -30,5 +30,6 @@
|
||||||
on:change={onChange}
|
on:change={onChange}
|
||||||
on:click
|
on:click
|
||||||
on:input
|
on:input
|
||||||
|
on:blur
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
|
@ -5,9 +5,11 @@
|
||||||
export let noPadding = false
|
export let noPadding = false
|
||||||
export let gap = "M"
|
export let gap = "M"
|
||||||
export let noGap = false
|
export let noGap = false
|
||||||
|
export let alignContent = "normal"
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
style="align-content:{alignContent};"
|
||||||
class:horizontal
|
class:horizontal
|
||||||
class="container paddingX-{!noPadding && paddingX} paddingY-{!noPadding &&
|
class="container paddingX-{!noPadding && paddingX} paddingY-{!noPadding &&
|
||||||
paddingY} gap-{!noGap && gap}"
|
paddingY} gap-{!noGap && gap}"
|
||||||
|
|
|
@ -8,8 +8,10 @@
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
div {
|
div {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr;
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: stretch;
|
||||||
max-width: 80ch;
|
max-width: 80ch;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: calc(var(--spacing-xl) * 2);
|
padding: calc(var(--spacing-xl) * 2);
|
||||||
|
@ -18,6 +20,7 @@
|
||||||
.wide {
|
.wide {
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin: 0;
|
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>
|
</style>
|
||||||
|
|
|
@ -7,9 +7,10 @@
|
||||||
import Context from "../context"
|
import Context from "../context"
|
||||||
|
|
||||||
export let fixed = false
|
export let fixed = false
|
||||||
|
export let inline = false
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
let visible = !!fixed
|
let visible = fixed || inline
|
||||||
$: dispatch(visible ? "show" : "hide")
|
$: dispatch(visible ? "show" : "hide")
|
||||||
|
|
||||||
export function show() {
|
export function show() {
|
||||||
|
@ -20,7 +21,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hide() {
|
export function hide() {
|
||||||
if (!visible || fixed) {
|
if (!visible || fixed || inline) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
visible = false
|
visible = false
|
||||||
|
@ -45,11 +46,17 @@
|
||||||
|
|
||||||
<svelte:window on:keydown={handleKey} />
|
<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">
|
<Portal target=".modal-container">
|
||||||
<div
|
<div
|
||||||
class="spectrum-Underlay is-open"
|
class="spectrum-Underlay is-open"
|
||||||
transition:fade={{ duration: 200 }}
|
transition:fade|local={{ duration: 200 }}
|
||||||
on:mousedown|self={hide}
|
on:mousedown|self={hide}
|
||||||
>
|
>
|
||||||
<div class="modal-wrapper" on:mousedown|self={hide}>
|
<div class="modal-wrapper" on:mousedown|self={hide}>
|
||||||
|
@ -57,7 +64,7 @@
|
||||||
<div
|
<div
|
||||||
use:focusFirstInput
|
use:focusFirstInput
|
||||||
class="spectrum-Modal is-open"
|
class="spectrum-Modal is-open"
|
||||||
transition:fly={{ y: 30, duration: 200 }}
|
transition:fly|local={{ y: 30, duration: 200 }}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
@ -98,6 +105,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.spectrum-Modal {
|
.spectrum-Modal {
|
||||||
|
background: var(--background);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
margin: 40px 0;
|
margin: 40px 0;
|
||||||
|
@ -106,4 +114,7 @@
|
||||||
--spectrum-global-dimension-size-100
|
--spectrum-global-dimension-size-100
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
:global(.spectrum--lightest .spectrum-Modal.inline) {
|
||||||
|
border: var(--border-light);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
}
|
}
|
||||||
$: type = schema?.type ?? "string"
|
$: type = schema?.type ?? "string"
|
||||||
$: customRenderer = customRenderers?.find(x => x.column === schema?.name)
|
$: customRenderer = customRenderers?.find(x => x.column === schema?.name)
|
||||||
$: renderer = customRenderer?.component ?? typeMap[type]
|
$: renderer = customRenderer?.component ?? typeMap[type] ?? StringRenderer
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if renderer && (customRenderer || (value != null && value !== ""))}
|
{#if renderer && (customRenderer || (value != null && value !== ""))}
|
||||||
|
|
|
@ -214,7 +214,7 @@
|
||||||
>
|
>
|
||||||
<div style={contentStyle}>
|
<div style={contentStyle}>
|
||||||
<table class="spectrum-Table" class:spectrum-Table--quiet={quiet}>
|
<table class="spectrum-Table" class:spectrum-Table--quiet={quiet}>
|
||||||
{#if sortedRows?.length}
|
{#if fields.length}
|
||||||
<thead class="spectrum-Table-head">
|
<thead class="spectrum-Table-head">
|
||||||
<tr>
|
<tr>
|
||||||
{#if showEditColumn}
|
{#if showEditColumn}
|
||||||
|
@ -269,7 +269,7 @@
|
||||||
</thead>
|
</thead>
|
||||||
{/if}
|
{/if}
|
||||||
<tbody class="spectrum-Table-body">
|
<tbody class="spectrum-Table-body">
|
||||||
{#if sortedRows?.length}
|
{#if sortedRows?.length && fields.length}
|
||||||
{#each sortedRows as row, idx}
|
{#each sortedRows as row, idx}
|
||||||
<tr
|
<tr
|
||||||
on:click={() => toggleSelectRow(row)}
|
on:click={() => toggleSelectRow(row)}
|
||||||
|
@ -316,15 +316,25 @@
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="placeholder">
|
<tr class="placeholder-row">
|
||||||
<svg
|
{#if showEditColumn}
|
||||||
class="spectrum-Icon spectrum-Icon--sizeXXL"
|
<td class="placeholder-offset" />
|
||||||
focusable="false"
|
{/if}
|
||||||
>
|
{#each fields as field}
|
||||||
<use xlink:href="#spectrum-icon-18-Table" />
|
<td />
|
||||||
</svg>
|
{/each}
|
||||||
<div>No rows found</div>
|
<div class="placeholder" class:has-fields={fields.length > 0}>
|
||||||
</div>
|
<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}
|
{/if}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -347,7 +357,7 @@
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.container.quiet {
|
.container.quiet {
|
||||||
border: none !important;
|
border: none;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -381,7 +391,7 @@
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background-color: var(--spectrum-alias-background-color-secondary);
|
background-color: var(--spectrum-alias-background-color-secondary);
|
||||||
border-bottom: 1px solid
|
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 {
|
.spectrum-Table-headCell-content {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
@ -396,7 +406,34 @@
|
||||||
text-overflow: ellipsis;
|
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 {
|
.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;
|
padding: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
@ -407,12 +444,13 @@
|
||||||
var(--spectrum-alias-text-color)
|
var(--spectrum-alias-text-color)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
.placeholder div {
|
.placeholder-content div {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: var(
|
font-size: var(
|
||||||
--spectrum-table-cell-text-size,
|
--spectrum-table-cell-text-size,
|
||||||
var(--spectrum-alias-font-size-default)
|
var(--spectrum-alias-font-size-default)
|
||||||
);
|
);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody {
|
tbody {
|
||||||
|
@ -431,17 +469,17 @@
|
||||||
td {
|
td {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
border-bottom: none !important;
|
border-bottom: none;
|
||||||
border-top: 1px solid
|
border-top: 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));
|
||||||
border-radius: 0 !important;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
tr:first-child td {
|
tr:first-child td {
|
||||||
border-top: none !important;
|
border-top: none;
|
||||||
}
|
}
|
||||||
tr:last-child td {
|
tr:last-child td {
|
||||||
border-bottom: 1px solid
|
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 {
|
td.spectrum-Table-cell--divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|
|
@ -2407,10 +2407,10 @@ svelte-portal@^1.0.0:
|
||||||
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-1.0.0.tgz#36a47c5578b1a4d9b4dc60fa32a904640ec4cdd3"
|
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-1.0.0.tgz#36a47c5578b1a4d9b4dc60fa32a904640ec4cdd3"
|
||||||
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q==
|
integrity sha512-nHf+DS/jZ6jjnZSleBMSaZua9JlG5rZv9lOGKgJuaZStfevtjIlUJrkLc3vbV8QdBvPPVmvcjTlazAzfKu0v3Q==
|
||||||
|
|
||||||
svelte@^3.37.0:
|
svelte@^3.38.2:
|
||||||
version "3.37.0"
|
version "3.38.2"
|
||||||
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.37.0.tgz#dc7cd24bcc275cdb3f8c684ada89e50489144ccd"
|
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
|
||||||
integrity sha512-TRF30F4W4+d+Jr2KzUUL1j8Mrpns/WM/WacxYlo5MMb2E5Qy2Pk1Guj6GylxsW9OnKQl1tnF8q3hG/hQ3h6VUA==
|
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==
|
||||||
|
|
||||||
svgo@^1.0.0:
|
svgo@^1.0.0:
|
||||||
version "1.3.2"
|
version "1.3.2"
|
||||||
|
|
|
@ -90,7 +90,7 @@
|
||||||
"@babel/preset-env": "^7.13.12",
|
"@babel/preset-env": "^7.13.12",
|
||||||
"@babel/runtime": "^7.13.10",
|
"@babel/runtime": "^7.13.10",
|
||||||
"@rollup/plugin-replace": "^2.4.2",
|
"@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",
|
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.5",
|
||||||
"@testing-library/jest-dom": "^5.11.10",
|
"@testing-library/jest-dom": "^5.11.10",
|
||||||
"@testing-library/svelte": "^3.0.0",
|
"@testing-library/svelte": "^3.0.0",
|
||||||
|
@ -106,7 +106,7 @@
|
||||||
"rollup": "^2.44.0",
|
"rollup": "^2.44.0",
|
||||||
"rollup-plugin-copy": "^3.4.0",
|
"rollup-plugin-copy": "^3.4.0",
|
||||||
"start-server-and-test": "^1.12.1",
|
"start-server-and-test": "^1.12.1",
|
||||||
"svelte": "^3.37.0",
|
"svelte": "^3.38.2",
|
||||||
"svelte-jester": "^1.3.2",
|
"svelte-jester": "^1.3.2",
|
||||||
"vite": "^2.1.5"
|
"vite": "^2.1.5"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
export const gradient = (node, config = {}) => {
|
export const gradient = (node, config = {}) => {
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
points: 10,
|
points: 12,
|
||||||
saturation: 0.8,
|
saturation: 0.85,
|
||||||
lightness: 0.75,
|
lightness: 0.7,
|
||||||
softness: 0.8,
|
softness: 0.9,
|
||||||
|
seed: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Applies a gradient background
|
// Applies a gradient background
|
||||||
|
@ -13,42 +14,67 @@ export const gradient = (node, config = {}) => {
|
||||||
...config,
|
...config,
|
||||||
}
|
}
|
||||||
const { saturation, lightness, softness, points } = 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
|
// Hash function which returns a fixed hash between specified limits
|
||||||
const rand = (min, max) => {
|
// for a given seed and a given version
|
||||||
return Math.round(min + Math.random() * (max - min))
|
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
|
// 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 lowerSaturation = Math.min(100, saturation * 100)
|
||||||
const upperSaturation = Math.min(100, (saturation + 0.2) * 100)
|
const upperSaturation = Math.min(100, (saturation + 0.2) * 100)
|
||||||
const lowerLightness = Math.min(100, lightness * 100)
|
const lowerLightness = Math.min(100, lightness * 100)
|
||||||
const upperLightness = Math.min(100, (lightness + 0.2) * 100)
|
const upperLightness = Math.min(100, (lightness + 0.2) * 100)
|
||||||
const hue = rand(0, 360)
|
const hue = rangeHash(seed, 0, 360, version)
|
||||||
const sat = `${rand(lowerSaturation, upperSaturation)}%`
|
const sat = `${rangeHash(
|
||||||
const light = `${rand(lowerLightness, upperLightness)}%`
|
seed,
|
||||||
return `hsl(${hue},${sat},${light})`
|
lowerSaturation,
|
||||||
|
upperSaturation,
|
||||||
|
version
|
||||||
|
)}%`
|
||||||
|
const light = `${rangeHash(
|
||||||
|
seed,
|
||||||
|
lowerLightness,
|
||||||
|
upperLightness,
|
||||||
|
version
|
||||||
|
)}%`
|
||||||
|
return `hsla(${hue},${sat},${light},${alpha})`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generates a radial gradient stop point
|
// Generates a radial gradient stop point
|
||||||
const randomGradientPoint = () => {
|
const randomGradientPoint = (seed, version) => {
|
||||||
const lowerTransparency = Math.min(100, softness * 100)
|
const lowerTransparency = Math.min(100, softness * 100)
|
||||||
const upperTransparency = Math.min(100, (softness + 0.2) * 100)
|
const upperTransparency = Math.min(100, (softness + 0.2) * 100)
|
||||||
const transparency = rand(lowerTransparency, upperTransparency)
|
const transparency = rangeHash(
|
||||||
|
seed,
|
||||||
|
lowerTransparency,
|
||||||
|
upperTransparency,
|
||||||
|
version
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
`radial-gradient(` +
|
`radial-gradient(at ` +
|
||||||
`at ${rand(10, 90)}% ${rand(10, 90)}%,` +
|
`${rangeHash(seed, 0, 100, version)}% ` +
|
||||||
`${randomHSL()} 0,` +
|
`${rangeHash(seed, 0, 100, version + 1)}%,` +
|
||||||
|
`${randomHSL(seed, version, saturation)} 0,` +
|
||||||
`transparent ${transparency}%)`
|
`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++) {
|
for (let i = 0; i < points - 1; i++) {
|
||||||
css += `${randomGradientPoint()},`
|
css += `${randomGradientPoint(seed, i)},`
|
||||||
}
|
}
|
||||||
css += `${randomGradientPoint()};`
|
css += `${randomGradientPoint(seed, points)};`
|
||||||
node.style = css
|
node.style = css
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,22 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
import { isActive, url, goto } from "@roxi/routify"
|
|
||||||
import { onMount } from "svelte"
|
|
||||||
import {
|
import {
|
||||||
ActionMenu,
|
ActionMenu,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Body,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Icon,
|
|
||||||
Heading,
|
Heading,
|
||||||
Avatar,
|
|
||||||
Search,
|
|
||||||
Layout,
|
|
||||||
ProgressCircle,
|
ProgressCircle,
|
||||||
SideNavigation as Navigation,
|
|
||||||
SideNavigationItem as Item,
|
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import api from "builderStore/api"
|
import { admin } from "stores/portal"
|
||||||
import { organisation, admin } from "stores/portal"
|
|
||||||
|
|
||||||
const MESSAGES = {
|
const MESSAGES = {
|
||||||
apps: "Create your first app",
|
apps: "Create your first app",
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
export let cancelText = "Cancel"
|
export let cancelText = "Cancel"
|
||||||
export let onOk = undefined
|
export let onOk = undefined
|
||||||
export let onCancel = undefined
|
export let onCancel = undefined
|
||||||
|
export let warning = true
|
||||||
|
|
||||||
let modal
|
let modal
|
||||||
|
|
||||||
|
@ -19,7 +20,13 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:this={modal} on:hide={onCancel}>
|
<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 size="S">
|
||||||
{body}
|
{body}
|
||||||
<slot />
|
<slot />
|
||||||
|
|
|
@ -7,56 +7,49 @@
|
||||||
ActionMenu,
|
ActionMenu,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Link,
|
Link,
|
||||||
notifications,
|
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import download from "downloadjs"
|
|
||||||
import { gradient } from "actions"
|
import { gradient } from "actions"
|
||||||
|
import { url } from "@roxi/routify"
|
||||||
|
|
||||||
export let name
|
export let app
|
||||||
export let _id
|
export let exportApp
|
||||||
|
export let deleteApp
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout noPadding gap="XS">
|
<div class="wrapper">
|
||||||
<div class="preview" use:gradient />
|
<Layout noPadding gap="XS" alignContent="start">
|
||||||
<div class="title">
|
<div class="preview" use:gradient={{ seed: app.name }} />
|
||||||
<Link href={`/builder/app/${_id}`}>
|
<div class="title">
|
||||||
<Heading size="XS">
|
<Link href={$url(`../../app/${app._id}`)}>
|
||||||
{name}
|
<Heading size="XS">
|
||||||
</Heading>
|
{app.name}
|
||||||
</Link>
|
</Heading>
|
||||||
<ActionMenu>
|
</Link>
|
||||||
<Icon slot="control" name="More" hoverable />
|
<ActionMenu align="right">
|
||||||
<MenuItem on:click={exportApp} icon="Download">Export</MenuItem>
|
<Icon slot="control" name="More" hoverable />
|
||||||
</ActionMenu>
|
<MenuItem on:click={() => exportApp(app)} icon="Download">
|
||||||
</div>
|
Export
|
||||||
<div class="status">
|
</MenuItem>
|
||||||
<Body noPadding size="S">
|
<MenuItem on:click={() => deleteApp(app)} icon="Delete">
|
||||||
Edited {Math.floor(1 + Math.random() * 10)} months ago
|
Delete
|
||||||
</Body>
|
</MenuItem>
|
||||||
{#if Math.random() > 0.5}
|
</ActionMenu>
|
||||||
<Icon name="LockClosed" />
|
</div>
|
||||||
{/if}
|
<div class="status">
|
||||||
</div>
|
<Body noPadding size="S">
|
||||||
</Layout>
|
Edited {Math.floor(1 + Math.random() * 10)} months ago
|
||||||
|
</Body>
|
||||||
|
{#if Math.random() > 0.5}
|
||||||
|
<Icon name="LockClosed" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.wrapper {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
.preview {
|
.preview {
|
||||||
height: 135px;
|
height: 135px;
|
||||||
border-radius: var(--border-radius-s);
|
border-radius: var(--border-radius-s);
|
||||||
|
@ -73,6 +66,15 @@
|
||||||
|
|
||||||
.title :global(a) {
|
.title :global(a) {
|
||||||
text-decoration: none;
|
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) {
|
.title :global(h1:hover) {
|
||||||
color: var(--spectrum-global-color-blue-600);
|
color: var(--spectrum-global-color-blue-600);
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -1,58 +1,50 @@
|
||||||
<script>
|
<script>
|
||||||
import { writable, get as svelteGet } from "svelte/store"
|
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 { store, automationStore, hostingStore } from "builderStore"
|
||||||
import { string, object } from "yup"
|
import { string, mixed, object } from "yup"
|
||||||
import api, { get } from "builderStore/api"
|
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 { post } from "builderStore/api"
|
||||||
import analytics from "analytics"
|
import analytics from "analytics"
|
||||||
import { onMount } from "svelte"
|
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
|
export let template
|
||||||
|
|
||||||
const currentStep = writable(0)
|
const values = writable({ name: null })
|
||||||
const values = writable({ roleId: "ADMIN" })
|
|
||||||
const errors = writable({})
|
const errors = writable({})
|
||||||
const touched = writable({})
|
const touched = writable({})
|
||||||
const steps = [Info, User]
|
const validator = {
|
||||||
let validators = [
|
name: string().required("Your application must have a name"),
|
||||||
{
|
file: template ? mixed().required("Please choose a file to import") : null,
|
||||||
applicationName: string().required("Your application must have a name"),
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
roleId: string()
|
|
||||||
.nullable()
|
|
||||||
.required("You need to select a role for this app"),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
let submitting = false
|
let submitting = false
|
||||||
let valid = false
|
let valid = false
|
||||||
$: checkValidity($values, validators[$currentStep])
|
$: checkValidity($values, validator)
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const hostingInfo = await hostingStore.actions.fetch()
|
await hostingStore.actions.fetchDeployedApps()
|
||||||
if (hostingInfo.type === "self") {
|
const existingAppNames = svelteGet(hostingStore).deployedAppNames
|
||||||
await hostingStore.actions.fetchDeployedApps()
|
validator.name = string()
|
||||||
const existingAppNames = svelteGet(hostingStore).deployedAppNames
|
.required("Your application must have a name")
|
||||||
validators[0].applicationName = string()
|
.test(
|
||||||
.required("Your application must have a name.")
|
"non-existing-app-name",
|
||||||
.test(
|
"Another app with the same name already exists",
|
||||||
"non-existing-app-name",
|
value => {
|
||||||
"App with same name already exists. Please try another app name.",
|
return !existingAppNames.some(
|
||||||
value =>
|
appName => appName.toLowerCase() === value.toLowerCase()
|
||||||
!existingAppNames.some(
|
)
|
||||||
appName => appName.toLowerCase() === value.toLowerCase()
|
}
|
||||||
)
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkValidity = async (values, validator) => {
|
const checkValidity = async (values, validator) => {
|
||||||
|
@ -70,15 +62,24 @@
|
||||||
|
|
||||||
async function createNewApp() {
|
async function createNewApp() {
|
||||||
submitting = true
|
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 {
|
try {
|
||||||
// Create form data to create app
|
// Create form data to create app
|
||||||
let data = new FormData()
|
let data = new FormData()
|
||||||
data.append("name", $values.applicationName)
|
data.append("name", $values.name)
|
||||||
data.append("useTemplate", template != null)
|
data.append("useTemplate", template != null)
|
||||||
if (template) {
|
if (template) {
|
||||||
data.append("templateName", template.name)
|
data.append("templateName", template.name)
|
||||||
data.append("templateKey", template.key)
|
data.append("templateKey", template.key)
|
||||||
data.append("templateFile", template.file)
|
data.append("templateFile", $values.file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create App
|
// Create App
|
||||||
|
@ -89,7 +90,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
analytics.captureEvent("App Created", {
|
analytics.captureEvent("App Created", {
|
||||||
name: $values.applicationName,
|
name: $values.name,
|
||||||
appId: appJson._id,
|
appId: appJson._id,
|
||||||
template,
|
template,
|
||||||
})
|
})
|
||||||
|
@ -112,7 +113,7 @@
|
||||||
}
|
}
|
||||||
const userResp = await api.post(`/api/users/metadata/self`, user)
|
const userResp = await api.post(`/api/users/metadata/self`, user)
|
||||||
await userResp.json()
|
await userResp.json()
|
||||||
window.location = `/builder/app/${appJson._id}`
|
$goto(`/builder/app/${appJson._id}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
notifications.error(error)
|
notifications.error(error)
|
||||||
|
@ -121,129 +122,33 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<ModalContent
|
||||||
<div class="sidebar">
|
title={template ? "Import app" : "Create new app"}
|
||||||
<img src={Logo} alt="budibase icon" />
|
confirmText={template ? "Import app" : "Create app"}
|
||||||
<div class="steps">
|
onConfirm={createNewApp}
|
||||||
{#each steps as component, i}
|
disabled={!valid}
|
||||||
<Indicator
|
>
|
||||||
active={$currentStep === i}
|
{#if template}
|
||||||
done={i < $currentStep}
|
<Dropzone
|
||||||
step={i + 1}
|
error={$touched.file && $errors.file}
|
||||||
/>
|
gallery={false}
|
||||||
{/each}
|
label="File to import"
|
||||||
</div>
|
value={[$values.file]}
|
||||||
</div>
|
on:change={e => {
|
||||||
<div class="body">
|
$values.file = e.detail?.[0]
|
||||||
<div class="heading">
|
$touched.file = true
|
||||||
<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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
<Body size="S">
|
||||||
|
Give your new app a name, and choose which groups have access (paid plans
|
||||||
<style>
|
only).
|
||||||
.container {
|
</Body>
|
||||||
min-height: 600px;
|
<Input
|
||||||
display: grid;
|
bind:value={$values.name}
|
||||||
grid-template-columns: 80px 1fr;
|
error={$touched.name && $errors.name}
|
||||||
position: relative;
|
on:blur={() => ($touched.name = true)}
|
||||||
}
|
label="Name"
|
||||||
.sidebar {
|
/>
|
||||||
display: flex;
|
<Checkbox label="Group access" disabled value={true} text="All users" />
|
||||||
flex-direction: column;
|
</ModalContent>
|
||||||
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>
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -1,2 +0,0 @@
|
||||||
export { default as Info } from "./Info.svelte"
|
|
||||||
export { default as User } from "./User.svelte"
|
|
|
@ -17,9 +17,6 @@
|
||||||
import { auth } from "stores/backend"
|
import { auth } from "stores/backend"
|
||||||
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
|
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
|
||||||
|
|
||||||
organisation.init()
|
|
||||||
apps.load()
|
|
||||||
|
|
||||||
let orgName
|
let orgName
|
||||||
let orgLogo
|
let orgLogo
|
||||||
let user
|
let user
|
||||||
|
@ -32,7 +29,10 @@
|
||||||
user = { name: "John Doe" }
|
user = { name: "John Doe" }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(getInfo)
|
onMount(() => {
|
||||||
|
organisation.init()
|
||||||
|
getInfo()
|
||||||
|
})
|
||||||
|
|
||||||
let menu = [
|
let menu = [
|
||||||
{ title: "Apps", href: "/builder/portal/apps" },
|
{ title: "Apps", href: "/builder/portal/apps" },
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
<script>
|
|
||||||
import { Page } from "@budibase/bbui"
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Page wide>
|
|
||||||
<slot />
|
|
||||||
</Page>
|
|
|
@ -8,18 +8,31 @@
|
||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Select,
|
Select,
|
||||||
Modal,
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Page,
|
||||||
|
notifications,
|
||||||
|
Body,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import AppList from "components/start/AppList.svelte"
|
|
||||||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||||
import api from "builderStore/api"
|
import api, { del } from "builderStore/api"
|
||||||
import analytics from "analytics"
|
import analytics from "analytics"
|
||||||
import { onMount } from "svelte"
|
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 layout = "grid"
|
||||||
let modal
|
|
||||||
let template
|
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 response = await api.get(`/api/keys/`)
|
||||||
const keys = await response.json()
|
const keys = await response.json()
|
||||||
if (keys.userId) {
|
if (keys.userId) {
|
||||||
|
@ -27,55 +40,146 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initiateAppImport() {
|
const initiateAppCreation = () => {
|
||||||
template = { fromFile: true }
|
creationModal.show()
|
||||||
modal.show()
|
creatingApp = true
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(checkKeys)
|
const initiateAppImport = () => {
|
||||||
|
template = { fromFile: true }
|
||||||
|
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(async () => {
|
||||||
|
checkKeys()
|
||||||
|
await apps.load()
|
||||||
|
loaded = true
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Layout noPadding>
|
<Page wide>
|
||||||
<div class="title">
|
{#if $apps.length}
|
||||||
<Heading>Apps</Heading>
|
<Layout noPadding>
|
||||||
<ButtonGroup>
|
<div class="title">
|
||||||
<Button secondary on:click={initiateAppImport}>Import app</Button>
|
<Heading>Apps</Heading>
|
||||||
<Button cta on:click={modal.show}>Create new app</Button>
|
<ButtonGroup>
|
||||||
</ButtonGroup>
|
<Button secondary on:click={initiateAppImport}>Import app</Button>
|
||||||
</div>
|
<Button cta on:click={initiateAppCreation}>Create new app</Button>
|
||||||
<div class="filter">
|
</ButtonGroup>
|
||||||
<div class="select">
|
</div>
|
||||||
<Select quiet placeholder="Filter by groups" />
|
<div class="filter">
|
||||||
</div>
|
<div class="select">
|
||||||
<ActionGroup>
|
<Select quiet placeholder="Filter by groups" />
|
||||||
<ActionButton
|
</div>
|
||||||
on:click={() => (layout = "grid")}
|
<ActionGroup>
|
||||||
selected={layout === "grid"}
|
<ActionButton
|
||||||
quiet
|
on:click={() => (layout = "grid")}
|
||||||
icon="ClassicGridView"
|
selected={layout === "grid"}
|
||||||
/>
|
quiet
|
||||||
<ActionButton
|
icon="ClassicGridView"
|
||||||
on:click={() => (layout = "table")}
|
/>
|
||||||
selected={layout === "table"}
|
<ActionButton
|
||||||
quiet
|
on:click={() => (layout = "table")}
|
||||||
icon="ViewRow"
|
selected={layout === "table"}
|
||||||
/>
|
quiet
|
||||||
</ActionGroup>
|
icon="ViewRow"
|
||||||
</div>
|
/>
|
||||||
{#if layout === "grid"}
|
</ActionGroup>
|
||||||
<AppList />
|
</div>
|
||||||
{:else}
|
<div
|
||||||
Table view.
|
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}
|
||||||
</Layout>
|
{#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>
|
||||||
|
{/if}
|
||||||
|
</Page>
|
||||||
<Modal
|
<Modal
|
||||||
bind:this={modal}
|
bind:this={creationModal}
|
||||||
padding={false}
|
padding={false}
|
||||||
width="600px"
|
width="600px"
|
||||||
on:hide={() => (template = null)}
|
on:hide={stopAppCreation}
|
||||||
>
|
>
|
||||||
<CreateAppModal {template} />
|
<CreateAppModal {template} />
|
||||||
</Modal>
|
</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>
|
<style>
|
||||||
.title,
|
.title,
|
||||||
|
@ -87,6 +191,41 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.select {
|
.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>
|
</style>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,5 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
|
import { goto } from "@roxi/routify"
|
||||||
import {
|
import {
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
Button,
|
Button,
|
||||||
Heading,
|
Heading,
|
||||||
Divider,
|
Divider,
|
||||||
|
@ -13,27 +16,40 @@
|
||||||
Body,
|
Body,
|
||||||
Page,
|
Page,
|
||||||
Select,
|
Select,
|
||||||
|
MenuSection,
|
||||||
|
MenuSeparator,
|
||||||
|
Table,
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
|
import { email } from "stores/portal"
|
||||||
import Editor from "components/integration/QueryEditor.svelte"
|
import Editor from "components/integration/QueryEditor.svelte"
|
||||||
|
import TemplateBindings from "./TemplateBindings.svelte"
|
||||||
|
import TemplateLink from "./TemplateLink.svelte"
|
||||||
import api from "builderStore/api"
|
import api from "builderStore/api"
|
||||||
|
|
||||||
const ConfigTypes = {
|
const ConfigTypes = {
|
||||||
SMTP: "smtp",
|
SMTP: "smtp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateSchema = {
|
||||||
|
purpose: {
|
||||||
|
displayName: "Email",
|
||||||
|
editable: false
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const customRenderers = [
|
||||||
|
{
|
||||||
|
column: "purpose",
|
||||||
|
component: TemplateLink,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
let smtpConfig
|
let smtpConfig
|
||||||
let templateIdx = 0
|
let bindingsOpen = false
|
||||||
let templateDefinition
|
|
||||||
let templates = []
|
|
||||||
let htmlModal
|
let htmlModal
|
||||||
|
let htmlEditor
|
||||||
$: templateTypes = templates.map((template, idx) => ({
|
let loading
|
||||||
label: template.purpose,
|
|
||||||
value: idx,
|
|
||||||
}))
|
|
||||||
|
|
||||||
$: selectedTemplate = templates[templateIdx]
|
|
||||||
|
|
||||||
async function saveSmtp() {
|
async function saveSmtp() {
|
||||||
try {
|
try {
|
||||||
|
@ -52,13 +68,7 @@
|
||||||
|
|
||||||
async function saveTemplate() {
|
async function saveTemplate() {
|
||||||
try {
|
try {
|
||||||
// Save your SMTP config
|
await email.templates.save(selectedTemplate)
|
||||||
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
|
|
||||||
|
|
||||||
notifications.success(`Template saved.`)
|
notifications.success(`Template saved.`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.error(`Failed to update template settings. ${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 () => {
|
onMount(async () => {
|
||||||
|
loading = true
|
||||||
await fetchSmtp()
|
await fetchSmtp()
|
||||||
await fetchTemplates()
|
await email.templates.fetch()
|
||||||
|
loading = false
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -144,8 +143,8 @@
|
||||||
<Label>From email address</Label>
|
<Label>From email address</Label>
|
||||||
<Input type="email" bind:value={smtpConfig.config.from} />
|
<Input type="email" bind:value={smtpConfig.config.from} />
|
||||||
</div>
|
</div>
|
||||||
<Button cta on:click={saveSmtp}>Save</Button>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
|
<Button cta on:click={saveSmtp}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
|
@ -155,26 +154,17 @@
|
||||||
Budibase comes out of the box with ready-made email templates to help
|
Budibase comes out of the box with ready-made email templates to help
|
||||||
with user onboarding. Please refrain from changing the links.
|
with user onboarding. Please refrain from changing the links.
|
||||||
</Body>
|
</Body>
|
||||||
<div class="template-controls">
|
|
||||||
<Select bind:value={templateIdx} options={templateTypes} />
|
|
||||||
<Button primary on:click={htmlModal.show}>Edit HTML</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Modal bind:this={htmlModal}>
|
<Table
|
||||||
<ModalContent
|
{customRenderers}
|
||||||
title="Edit Template HTML"
|
on:editrow={evt => $goto(`./${evt.detail.purpose}`)}
|
||||||
onConfirm={saveTemplate}
|
data={$email.templates}
|
||||||
size="XL"
|
schema={templateSchema}
|
||||||
>
|
{loading}
|
||||||
<Editor
|
allowEditRows={false}
|
||||||
mode="handlebars"
|
allowSelectRows={false}
|
||||||
on:change={e => {
|
allowEditColumns={false}
|
||||||
selectedTemplate.contents = e.detail.value
|
/>
|
||||||
}}
|
|
||||||
value={selectedTemplate.contents}
|
|
||||||
/>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
{/if}
|
{/if}
|
||||||
</Page>
|
</Page>
|
||||||
|
|
||||||
|
@ -199,11 +189,4 @@
|
||||||
header {
|
header {
|
||||||
margin-bottom: 42px;
|
margin-bottom: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-controls {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 80% 1fr;
|
|
||||||
grid-gap: var(--spacing-xl);
|
|
||||||
margin-bottom: var(--spacing-xl);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -91,7 +91,7 @@
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
<div>
|
<div>
|
||||||
<Button primary on:click={() => save(google)}>Save</Button>
|
<Button cta on:click={() => save(google)}>Save</Button>
|
||||||
</div>
|
</div>
|
||||||
<Divider />
|
<Divider />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -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,3 +1,4 @@
|
||||||
export { organisation } from "./organisation"
|
export { organisation } from "./organisation"
|
||||||
export { admin } from "./admin"
|
export { admin } from "./admin"
|
||||||
export { apps } from "./apps"
|
export { apps } from "./apps"
|
||||||
|
export { email } from "./email"
|
||||||
|
|
|
@ -1194,10 +1194,10 @@
|
||||||
estree-walker "^2.0.1"
|
estree-walker "^2.0.1"
|
||||||
picomatch "^2.2.2"
|
picomatch "^2.2.2"
|
||||||
|
|
||||||
"@roxi/routify@2.15.1":
|
"@roxi/routify@2.18.0":
|
||||||
version "2.15.1"
|
version "2.18.0"
|
||||||
resolved "https://registry.yarnpkg.com/@roxi/routify/-/routify-2.15.1.tgz#cbd5eafedfee7f04b154173dccd7474c177acb4f"
|
resolved "https://registry.yarnpkg.com/@roxi/routify/-/routify-2.18.0.tgz#8f88bedd936312d0dbe44cbc11ab179b1f938ec2"
|
||||||
integrity sha512-IRdoaPSfP09EwWtB+tpbHgH6ejYtowale24rgfpxRQhFNyTUK4jYXclvx3XkUD1NSupSgl1kDAsWSiRSG0WvkQ==
|
integrity sha512-MVB50HN+VQWLzfjLplcBjsSBvwOiExKOmht2DuWR3WQ60JxQi9pSejkB06tFVkFKNXz2X5iYtKDqKBTdae/gRg==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@roxi/ssr" "^0.2.1"
|
"@roxi/ssr" "^0.2.1"
|
||||||
"@types/node" ">=4.2.0 < 13"
|
"@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"
|
resolved "https://registry.yarnpkg.com/svelte-portal/-/svelte-portal-0.1.0.tgz#cc2821cc84b05ed5814e0218dcdfcbebc53c1742"
|
||||||
integrity sha512-kef+ksXVKun224mRxat+DdO4C+cGHla+fEcZfnBAvoZocwiaceOfhf5azHYOPXSSB1igWVFTEOF3CDENPnuWxg==
|
integrity sha512-kef+ksXVKun224mRxat+DdO4C+cGHla+fEcZfnBAvoZocwiaceOfhf5azHYOPXSSB1igWVFTEOF3CDENPnuWxg==
|
||||||
|
|
||||||
svelte@^3.37.0:
|
svelte@^3.38.2:
|
||||||
version "3.38.2"
|
version "3.38.2"
|
||||||
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
|
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
|
||||||
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==
|
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
"rollup-plugin-svelte": "^7.1.0",
|
"rollup-plugin-svelte": "^7.1.0",
|
||||||
"rollup-plugin-svg": "^2.0.0",
|
"rollup-plugin-svg": "^2.0.0",
|
||||||
"rollup-plugin-terser": "^7.0.2",
|
"rollup-plugin-terser": "^7.0.2",
|
||||||
"svelte": "^3.37.0"
|
"svelte": "^3.38.2"
|
||||||
},
|
},
|
||||||
"gitHead": "4b6efc42ed3273595c7a129411f4d883733d3321"
|
"gitHead": "4b6efc42ed3273595c7a129411f4d883733d3321"
|
||||||
}
|
}
|
||||||
|
|
|
@ -122,7 +122,7 @@
|
||||||
"pouchdb-find": "^7.2.2",
|
"pouchdb-find": "^7.2.2",
|
||||||
"pouchdb-replication-stream": "1.2.9",
|
"pouchdb-replication-stream": "1.2.9",
|
||||||
"server-destroy": "1.0.1",
|
"server-destroy": "1.0.1",
|
||||||
"svelte": "3.30.0",
|
"svelte": "^3.38.2",
|
||||||
"to-json-schema": "0.2.5",
|
"to-json-schema": "0.2.5",
|
||||||
"uuid": "3.3.2",
|
"uuid": "3.3.2",
|
||||||
"validate.js": "0.13.1",
|
"validate.js": "0.13.1",
|
||||||
|
|
|
@ -67,4 +67,7 @@ for (let route of mainRoutes) {
|
||||||
router.use(staticRoutes.routes())
|
router.use(staticRoutes.routes())
|
||||||
router.use(staticRoutes.allowedMethods())
|
router.use(staticRoutes.allowedMethods())
|
||||||
|
|
||||||
|
// add a redirect for when hitting server directly
|
||||||
|
router.redirect("/", "/builder")
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
@ -19,12 +19,14 @@ function request(ctx, request) {
|
||||||
if (!request.headers) {
|
if (!request.headers) {
|
||||||
request.headers = {}
|
request.headers = {}
|
||||||
}
|
}
|
||||||
if (request.body) {
|
if (request.body && Object.keys(request.body).length > 0) {
|
||||||
request.headers["Content-Type"] = "application/json"
|
request.headers["Content-Type"] = "application/json"
|
||||||
request.body =
|
request.body =
|
||||||
typeof request.body === "object"
|
typeof request.body === "object"
|
||||||
? JSON.stringify(request.body)
|
? JSON.stringify(request.body)
|
||||||
: request.body
|
: request.body
|
||||||
|
} else {
|
||||||
|
delete request.body
|
||||||
}
|
}
|
||||||
if (ctx && ctx.headers) {
|
if (ctx && ctx.headers) {
|
||||||
request.headers.cookie = ctx.headers.cookie
|
request.headers.cookie = ctx.headers.cookie
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -23,7 +23,7 @@
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.5",
|
"@sveltejs/vite-plugin-svelte": "^1.0.0-next.5",
|
||||||
"svelte": "^3.37.0",
|
"svelte": "^3.38.2",
|
||||||
"vite": "^2.1.5"
|
"vite": "^2.1.5"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|
|
@ -27,9 +27,17 @@ exports.save = async ctx => {
|
||||||
}
|
}
|
||||||
|
|
||||||
exports.definitions = async ctx => {
|
exports.definitions = async ctx => {
|
||||||
|
const bindings = {}
|
||||||
|
|
||||||
|
for (let template of TemplateMetadata.email) {
|
||||||
|
bindings[template.purpose] = template.bindings
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
purpose: TemplateMetadata,
|
bindings: {
|
||||||
bindings: Object.values(TemplateBindings),
|
...bindings,
|
||||||
|
common: Object.values(TemplateBindings),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
|
@ -2,8 +2,13 @@ const setup = require("./utilities")
|
||||||
const { EmailTemplatePurpose } = require("../../../constants")
|
const { EmailTemplatePurpose } = require("../../../constants")
|
||||||
|
|
||||||
// mock the email system
|
// mock the email system
|
||||||
|
const sendMailMock = jest.fn()
|
||||||
jest.mock("nodemailer")
|
jest.mock("nodemailer")
|
||||||
const sendMailMock = setup.emailMock()
|
const nodemailer = require("nodemailer")
|
||||||
|
nodemailer.createTransport.mockReturnValue({
|
||||||
|
sendMail: sendMailMock,
|
||||||
|
verify: jest.fn()
|
||||||
|
})
|
||||||
|
|
||||||
describe("/api/admin/email", () => {
|
describe("/api/admin/email", () => {
|
||||||
let request = setup.getRequest()
|
let request = setup.getRequest()
|
||||||
|
|
|
@ -34,22 +34,46 @@ const EmailTemplatePurpose = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const TemplateBindings = {
|
const TemplateBindings = {
|
||||||
PLATFORM_URL: "platformUrl",
|
PLATFORM_URL: {
|
||||||
COMPANY: "company",
|
name: "platformUrl",
|
||||||
LOGO_URL: "logoUrl",
|
description: "The URL used to access the budibase platform",
|
||||||
BODY: "body",
|
},
|
||||||
INVITE_URL: "inviteUrl",
|
COMPANY: {
|
||||||
EMAIL: "email",
|
name: "company",
|
||||||
RESET_URL: "resetUrl",
|
description: "The name of your organization",
|
||||||
USER: "user",
|
},
|
||||||
REQUEST: "request",
|
LOGO_URL: {
|
||||||
DOCS_URL: "docsUrl",
|
name: "logoUrl",
|
||||||
LOGIN_URL: "loginUrl",
|
description: "The URL of your organizations logo.",
|
||||||
CURRENT_YEAR: "currentYear",
|
},
|
||||||
CURRENT_DATE: "currentDate",
|
EMAIL: {
|
||||||
RESET_CODE: "resetCode",
|
name: "email",
|
||||||
INVITE_CODE: "inviteCode",
|
description: "The recipients email address.",
|
||||||
CONTENTS: "contents",
|
},
|
||||||
|
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 = {
|
const TemplateMetadata = {
|
||||||
|
@ -57,20 +81,58 @@ const TemplateMetadata = {
|
||||||
{
|
{
|
||||||
name: "Base Format",
|
name: "Base Format",
|
||||||
purpose: EmailTemplatePurpose.BASE,
|
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",
|
name: "Password Recovery",
|
||||||
purpose: EmailTemplatePurpose.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",
|
name: "New User Invitation",
|
||||||
purpose: EmailTemplatePurpose.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",
|
name: "Custom",
|
||||||
purpose: EmailTemplatePurpose.CUSTOM,
|
purpose: EmailTemplatePurpose.CUSTOM,
|
||||||
bindings: ["contents"],
|
bindings: [
|
||||||
|
{
|
||||||
|
name: "contents",
|
||||||
|
description: "Custom content body.",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue