Merge branch 'next' into admin/user-management-ui

This commit is contained in:
Keviin Åberg Kultalahti 2021-05-11 12:01:31 +02:00
commit 7e8bee48d8
37 changed files with 710 additions and 1938 deletions

View File

@ -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 Executable file
View File

@ -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()

View File

@ -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",

View File

@ -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"

View File

@ -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
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="nav right"
class:visible={selectedImageIdx < fileCount - 1}
on:click={navigateRight}
>
<Icon name="ChevronRight" />
</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;

View File

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

View File

@ -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>

View File

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

View File

@ -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}"

View File

@ -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>

View File

@ -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>

View File

@ -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 !== ""))}

View File

@ -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={() => dispatch("click", row)}
@ -317,15 +317,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>
@ -348,7 +358,7 @@
overflow: auto;
}
.container.quiet {
border: none !important;
border: none;
}
table {
width: 100%;
@ -382,7 +392,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;
@ -397,7 +407,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;
@ -408,12 +445,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 {
@ -432,17 +470,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;

View File

@ -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"

View File

@ -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"
},

View File

@ -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
}

View File

@ -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",

View File

@ -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 />

View File

@ -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);

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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" },
@ -161,5 +161,7 @@
}
.content {
overflow: auto;
display: grid;
grid-template-rows: 1fr;
}
</style>

View File

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

View File

@ -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() {
template = { fromFile: true }
modal.show()
const initiateAppCreation = () => {
creationModal.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>
<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" />
</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.
<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}
</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
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>

View File

@ -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==

View File

@ -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"
}

View File

@ -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",

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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": [