forked from tsai/budibase
55 changed files with 1872 additions and 152 deletions
@ -0,0 +1,50 @@ |
|||||
|
<script> |
||||
|
import { notifications, ModalContent, Dropzone, Body } from "@budibase/bbui" |
||||
|
import { post } from "builderStore/api" |
||||
|
|
||||
|
let submitting = false |
||||
|
|
||||
|
$: value = { file: null } |
||||
|
|
||||
|
async function importApps() { |
||||
|
submitting = true |
||||
|
|
||||
|
try { |
||||
|
// Create form data to create app |
||||
|
let data = new FormData() |
||||
|
data.append("importFile", value.file) |
||||
|
|
||||
|
// Create App |
||||
|
const importResp = await post("/api/cloud/import", data, {}) |
||||
|
const importJson = await importResp.json() |
||||
|
if (!importResp.ok) { |
||||
|
throw new Error(importJson.message) |
||||
|
} |
||||
|
// now reload to get to login |
||||
|
window.location.reload() |
||||
|
} catch (error) { |
||||
|
notifications.error(error) |
||||
|
submitting = false |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<ModalContent |
||||
|
title="Import apps" |
||||
|
confirmText="Import apps" |
||||
|
onConfirm={importApps} |
||||
|
disabled={!value.file} |
||||
|
> |
||||
|
<Body |
||||
|
>Please upload the file that was exported from your Cloud environment to get |
||||
|
started</Body |
||||
|
> |
||||
|
<Dropzone |
||||
|
gallery={false} |
||||
|
label="File to import" |
||||
|
value={[value.file]} |
||||
|
on:change={e => { |
||||
|
value.file = e.detail?.[0] |
||||
|
}} |
||||
|
/> |
||||
|
</ModalContent> |
||||
@ -0,0 +1,240 @@ |
|||||
|
<script context="module"> |
||||
|
export const Sides = { |
||||
|
Top: "Top", |
||||
|
Right: "Right", |
||||
|
Bottom: "Bottom", |
||||
|
Left: "Left", |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<script> |
||||
|
import { onMount } from "svelte" |
||||
|
import { get } from "svelte/store" |
||||
|
import IndicatorSet from "./IndicatorSet.svelte" |
||||
|
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" |
||||
|
import { builderStore } from "stores" |
||||
|
|
||||
|
let dragInfo |
||||
|
let dropInfo |
||||
|
|
||||
|
const getEdges = (bounds, mousePoint) => { |
||||
|
const { width, height, top, left } = bounds |
||||
|
return { |
||||
|
[Sides.Top]: [mousePoint[0], top], |
||||
|
[Sides.Right]: [left + width, mousePoint[1]], |
||||
|
[Sides.Bottom]: [mousePoint[0], top + height], |
||||
|
[Sides.Left]: [left, mousePoint[1]], |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const calculatePointDelta = (point1, point2) => { |
||||
|
const deltaX = Math.abs(point1[0] - point2[0]) |
||||
|
const deltaY = Math.abs(point1[1] - point2[1]) |
||||
|
return Math.sqrt(deltaX * deltaX + deltaY * deltaY) |
||||
|
} |
||||
|
|
||||
|
const getDOMNodeForComponent = component => { |
||||
|
const parent = component.closest(".component") |
||||
|
const children = Array.from(parent.childNodes) |
||||
|
return children?.find(node => node?.nodeType === 1) |
||||
|
} |
||||
|
|
||||
|
// Callback when initially starting a drag on a draggable component |
||||
|
const onDragStart = e => { |
||||
|
const parent = e.target.closest(".component") |
||||
|
if (!parent?.classList.contains("draggable")) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Update state |
||||
|
dragInfo = { |
||||
|
target: parent.dataset.id, |
||||
|
parent: parent.dataset.parent, |
||||
|
} |
||||
|
builderStore.actions.selectComponent(dragInfo.target) |
||||
|
builderStore.actions.setDragging(true) |
||||
|
|
||||
|
// Highlight being dragged by setting opacity |
||||
|
const child = getDOMNodeForComponent(e.target) |
||||
|
if (child) { |
||||
|
child.style.opacity = "0.5" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Callback when drag stops (whether dropped or not) |
||||
|
const onDragEnd = e => { |
||||
|
// Reset opacity style |
||||
|
if (dragInfo) { |
||||
|
const child = getDOMNodeForComponent(e.target) |
||||
|
if (child) { |
||||
|
child.style.opacity = "" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Reset state and styles |
||||
|
dragInfo = null |
||||
|
dropInfo = null |
||||
|
builderStore.actions.setDragging(false) |
||||
|
} |
||||
|
|
||||
|
// Callback when on top of a component |
||||
|
const onDragOver = e => { |
||||
|
// Skip if we aren't validly dragging currently |
||||
|
if (!dragInfo || !dropInfo) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
e.preventDefault() |
||||
|
const { droppableInside, bounds } = dropInfo |
||||
|
const { top, left, height, width } = bounds |
||||
|
const mouseY = e.clientY |
||||
|
const mouseX = e.clientX |
||||
|
const snapFactor = droppableInside ? 0.33 : 0.5 |
||||
|
const snapLimitV = Math.min(40, height * snapFactor) |
||||
|
const snapLimitH = Math.min(40, width * snapFactor) |
||||
|
|
||||
|
// Determine all sies we are within snap range of |
||||
|
let sides = [] |
||||
|
if (mouseY <= top + snapLimitV) { |
||||
|
sides.push(Sides.Top) |
||||
|
} else if (mouseY >= top + height - snapLimitV) { |
||||
|
sides.push(Sides.Bottom) |
||||
|
} |
||||
|
if (mouseX < left + snapLimitH) { |
||||
|
sides.push(Sides.Left) |
||||
|
} else if (mouseX > left + width - snapLimitH) { |
||||
|
sides.push(Sides.Right) |
||||
|
} |
||||
|
|
||||
|
// When no edges match, drop inside if possible |
||||
|
if (!sides.length) { |
||||
|
dropInfo.mode = droppableInside ? "inside" : null |
||||
|
dropInfo.side = null |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// When one edge matches, use that edge |
||||
|
if (sides.length === 1) { |
||||
|
dropInfo.side = sides[0] |
||||
|
if ([Sides.Top, Sides.Left].includes(sides[0])) { |
||||
|
dropInfo.mode = "above" |
||||
|
} else { |
||||
|
dropInfo.mode = "below" |
||||
|
} |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// When 2 edges match, work out which is closer |
||||
|
const mousePoint = [mouseX, mouseY] |
||||
|
const edges = getEdges(bounds, mousePoint) |
||||
|
const edge1 = edges[sides[0]] |
||||
|
const delta1 = calculatePointDelta(mousePoint, edge1) |
||||
|
const edge2 = edges[sides[1]] |
||||
|
const delta2 = calculatePointDelta(mousePoint, edge2) |
||||
|
const edge = delta1 < delta2 ? sides[0] : sides[1] |
||||
|
dropInfo.side = edge |
||||
|
if ([Sides.Top, Sides.Left].includes(edge)) { |
||||
|
dropInfo.mode = "above" |
||||
|
} else { |
||||
|
dropInfo.mode = "below" |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Callback when entering a potential drop target |
||||
|
const onDragEnter = e => { |
||||
|
// Skip if we aren't validly dragging currently |
||||
|
if (!dragInfo) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
const element = e.target.closest(".component") |
||||
|
if ( |
||||
|
element && |
||||
|
element.classList.contains("droppable") && |
||||
|
element.dataset.id !== dragInfo.target |
||||
|
) { |
||||
|
// Do nothing if this is the same target |
||||
|
if (element.dataset.id === dropInfo?.target) { |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
// Ensure the dragging flag is always set. |
||||
|
// There's a bit of a race condition between the app reinitialisation |
||||
|
// after selecting the DND component and setting this the first time |
||||
|
if (!get(builderStore).isDragging) { |
||||
|
builderStore.actions.setDragging(true) |
||||
|
} |
||||
|
|
||||
|
// Store target ID |
||||
|
const target = element.dataset.id |
||||
|
|
||||
|
// Precompute and store some info to avoid recalculating everything in |
||||
|
// dragOver |
||||
|
const child = getDOMNodeForComponent(e.target) |
||||
|
const bounds = child.getBoundingClientRect() |
||||
|
dropInfo = { |
||||
|
target, |
||||
|
name: element.dataset.name, |
||||
|
droppableInside: element.classList.contains("empty"), |
||||
|
bounds, |
||||
|
} |
||||
|
} else { |
||||
|
dropInfo = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Callback when leaving a potential drop target. |
||||
|
// Since we don't style our targets, we don't need to unset anything. |
||||
|
const onDragLeave = () => {} |
||||
|
|
||||
|
// Callback when dropping a drag on top of some component |
||||
|
const onDrop = e => { |
||||
|
e.preventDefault() |
||||
|
if (dropInfo?.mode) { |
||||
|
builderStore.actions.moveComponent( |
||||
|
dragInfo.target, |
||||
|
dropInfo.target, |
||||
|
dropInfo.mode |
||||
|
) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
onMount(() => { |
||||
|
// Events fired on the draggable target |
||||
|
document.addEventListener("dragstart", onDragStart, false) |
||||
|
document.addEventListener("dragend", onDragEnd, false) |
||||
|
|
||||
|
// Events fired on the drop targets |
||||
|
document.addEventListener("dragover", onDragOver, false) |
||||
|
document.addEventListener("dragenter", onDragEnter, false) |
||||
|
document.addEventListener("dragleave", onDragLeave, false) |
||||
|
document.addEventListener("drop", onDrop, false) |
||||
|
|
||||
|
return () => { |
||||
|
// Events fired on the draggable target |
||||
|
document.removeEventListener("dragstart", onDragStart, false) |
||||
|
document.removeEventListener("dragend", onDragEnd, false) |
||||
|
|
||||
|
// Events fired on the drop targets |
||||
|
document.removeEventListener("dragover", onDragOver, false) |
||||
|
document.removeEventListener("dragenter", onDragEnter, false) |
||||
|
document.removeEventListener("dragleave", onDragLeave, false) |
||||
|
document.removeEventListener("drop", onDrop, false) |
||||
|
} |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<IndicatorSet |
||||
|
componentId={dropInfo?.mode === "inside" ? dropInfo.target : null} |
||||
|
color="var(--spectrum-global-color-static-green-500)" |
||||
|
zIndex="930" |
||||
|
transition |
||||
|
prefix="Inside" |
||||
|
/> |
||||
|
|
||||
|
<DNDPositionIndicator |
||||
|
{dropInfo} |
||||
|
color="var(--spectrum-global-color-static-green-500)" |
||||
|
zIndex="940" |
||||
|
transition |
||||
|
/> |
||||
@ -0,0 +1,54 @@ |
|||||
|
<script> |
||||
|
import Indicator from "./Indicator.svelte" |
||||
|
import { Sides } from "./DNDHandler.svelte" |
||||
|
|
||||
|
export let dropInfo |
||||
|
export let zIndex |
||||
|
export let color |
||||
|
export let transition |
||||
|
|
||||
|
$: dimensions = getDimensions(dropInfo) |
||||
|
$: prefix = dropInfo?.mode === "above" ? "Before" : "After" |
||||
|
$: text = `${prefix} ${dropInfo?.name}` |
||||
|
$: renderKey = `${dropInfo?.target}-${dropInfo?.side}` |
||||
|
|
||||
|
const getDimensions = info => { |
||||
|
const { bounds, side } = info ?? {} |
||||
|
if (!bounds || !side) { |
||||
|
return null |
||||
|
} |
||||
|
const { left, top, width, height } = bounds |
||||
|
if (side === Sides.Top || side === Sides.Bottom) { |
||||
|
return { |
||||
|
top: side === Sides.Top ? top - 4 : top + height, |
||||
|
left: left - 2, |
||||
|
width: width + 4, |
||||
|
height: 0, |
||||
|
} |
||||
|
} else { |
||||
|
return { |
||||
|
top: top - 2, |
||||
|
left: side === Sides.Left ? left - 4 : left + width, |
||||
|
width: 0, |
||||
|
height: height + 4, |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
{#key renderKey} |
||||
|
{#if dimensions && dropInfo?.mode !== "inside"} |
||||
|
<Indicator |
||||
|
left={Math.round(dimensions.left)} |
||||
|
top={Math.round(dimensions.top)} |
||||
|
width={dimensions.width} |
||||
|
height={dimensions.height} |
||||
|
{text} |
||||
|
{zIndex} |
||||
|
{color} |
||||
|
{transition} |
||||
|
alignRight={dropInfo?.side === Sides.Right} |
||||
|
line |
||||
|
/> |
||||
|
{/if} |
||||
|
{/key} |
||||
File diff suppressed because it is too large
@ -0,0 +1,92 @@ |
|||||
|
const env = require("../../environment") |
||||
|
const { getAllApps } = require("@budibase/auth/db") |
||||
|
const CouchDB = require("../../db") |
||||
|
const { |
||||
|
exportDB, |
||||
|
sendTempFile, |
||||
|
readFileSync, |
||||
|
} = require("../../utilities/fileSystem") |
||||
|
const { stringToReadStream } = require("../../utilities") |
||||
|
const { getGlobalDBName, getGlobalDB } = require("@budibase/auth/tenancy") |
||||
|
const { create } = require("./application") |
||||
|
const { getDocParams, DocumentTypes, isDevAppID } = require("../../db/utils") |
||||
|
|
||||
|
async function createApp(appName, appImport) { |
||||
|
const ctx = { |
||||
|
request: { |
||||
|
body: { |
||||
|
templateString: appImport, |
||||
|
name: appName, |
||||
|
}, |
||||
|
}, |
||||
|
} |
||||
|
return create(ctx) |
||||
|
} |
||||
|
|
||||
|
exports.exportApps = async ctx => { |
||||
|
if (env.SELF_HOSTED || !env.MULTI_TENANCY) { |
||||
|
ctx.throw(400, "Exporting only allowed in multi-tenant cloud environments.") |
||||
|
} |
||||
|
const apps = await getAllApps(CouchDB, { all: true }) |
||||
|
const globalDBString = await exportDB(getGlobalDBName()) |
||||
|
let allDBs = { |
||||
|
global: globalDBString, |
||||
|
} |
||||
|
for (let app of apps) { |
||||
|
// only export the dev apps as they will be the latest, the user can republish the apps
|
||||
|
// in their self hosted environment
|
||||
|
if (isDevAppID(app._id)) { |
||||
|
allDBs[app.name] = await exportDB(app._id) |
||||
|
} |
||||
|
} |
||||
|
const filename = `cloud-export-${new Date().getTime()}.txt` |
||||
|
ctx.attachment(filename) |
||||
|
ctx.body = sendTempFile(JSON.stringify(allDBs)) |
||||
|
} |
||||
|
|
||||
|
async function getAllDocType(db, docType) { |
||||
|
const response = await db.allDocs( |
||||
|
getDocParams(docType, null, { |
||||
|
include_docs: true, |
||||
|
}) |
||||
|
) |
||||
|
return response.rows.map(row => row.doc) |
||||
|
} |
||||
|
|
||||
|
exports.importApps = async ctx => { |
||||
|
if (!env.SELF_HOSTED || env.MULTI_TENANCY) { |
||||
|
ctx.throw(400, "Importing only allowed in self hosted environments.") |
||||
|
} |
||||
|
const apps = await getAllApps(CouchDB, { all: true }) |
||||
|
if ( |
||||
|
apps.length !== 0 || |
||||
|
!ctx.request.files || |
||||
|
!ctx.request.files.importFile |
||||
|
) { |
||||
|
ctx.throw( |
||||
|
400, |
||||
|
"Import file is required and environment must be fresh to import apps." |
||||
|
) |
||||
|
} |
||||
|
const importFile = ctx.request.files.importFile |
||||
|
const importString = readFileSync(importFile.path) |
||||
|
const dbs = JSON.parse(importString) |
||||
|
const globalDbImport = dbs.global |
||||
|
// remove from the list of apps
|
||||
|
delete dbs.global |
||||
|
const globalDb = getGlobalDB() |
||||
|
// load the global db first
|
||||
|
await globalDb.load(stringToReadStream(globalDbImport)) |
||||
|
for (let [appName, appImport] of Object.entries(dbs)) { |
||||
|
await createApp(appName, appImport) |
||||
|
} |
||||
|
// once apps are created clean up the global db
|
||||
|
let users = await getAllDocType(globalDb, DocumentTypes.USER) |
||||
|
for (let user of users) { |
||||
|
delete user.tenantId |
||||
|
} |
||||
|
await globalDb.bulkDocs(users) |
||||
|
ctx.body = { |
||||
|
message: "Apps successfully imported.", |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
const Router = require("@koa/router") |
||||
|
const controller = require("../controllers/cloud") |
||||
|
const authorized = require("../../middleware/authorized") |
||||
|
const { BUILDER } = require("@budibase/auth/permissions") |
||||
|
|
||||
|
const router = Router() |
||||
|
|
||||
|
router |
||||
|
.get("/api/cloud/export", authorized(BUILDER), controller.exportApps) |
||||
|
// has to be public, only run if apps don't exist
|
||||
|
.post("/api/cloud/import", controller.importApps) |
||||
|
|
||||
|
module.exports = router |
||||
Loading…
Reference in new issue