Merge pull request #13116 from Budibase/feature/app-list-actions
App list actions
This commit is contained in:
commit
bb25a6b16a
|
@ -13,6 +13,7 @@ import {
|
||||||
AppVersionRevertedEvent,
|
AppVersionRevertedEvent,
|
||||||
AppRevertedEvent,
|
AppRevertedEvent,
|
||||||
AppExportedEvent,
|
AppExportedEvent,
|
||||||
|
AppDuplicatedEvent,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
|
||||||
const created = async (app: App, timestamp?: string | number) => {
|
const created = async (app: App, timestamp?: string | number) => {
|
||||||
|
@ -77,6 +78,17 @@ async function fileImported(app: App) {
|
||||||
await publishEvent(Event.APP_FILE_IMPORTED, properties)
|
await publishEvent(Event.APP_FILE_IMPORTED, properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function duplicated(app: App, duplicateAppId: string) {
|
||||||
|
const properties: AppDuplicatedEvent = {
|
||||||
|
duplicateAppId,
|
||||||
|
appId: app.appId,
|
||||||
|
audited: {
|
||||||
|
name: app.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await publishEvent(Event.APP_DUPLICATED, properties)
|
||||||
|
}
|
||||||
|
|
||||||
async function templateImported(app: App, templateKey: string) {
|
async function templateImported(app: App, templateKey: string) {
|
||||||
const properties: AppTemplateImportedEvent = {
|
const properties: AppTemplateImportedEvent = {
|
||||||
appId: app.appId,
|
appId: app.appId,
|
||||||
|
@ -147,6 +159,7 @@ export default {
|
||||||
published,
|
published,
|
||||||
unpublished,
|
unpublished,
|
||||||
fileImported,
|
fileImported,
|
||||||
|
duplicated,
|
||||||
templateImported,
|
templateImported,
|
||||||
versionUpdated,
|
versionUpdated,
|
||||||
versionReverted,
|
versionReverted,
|
||||||
|
|
|
@ -15,6 +15,7 @@ beforeAll(async () => {
|
||||||
|
|
||||||
jest.spyOn(events.app, "created")
|
jest.spyOn(events.app, "created")
|
||||||
jest.spyOn(events.app, "updated")
|
jest.spyOn(events.app, "updated")
|
||||||
|
jest.spyOn(events.app, "duplicated")
|
||||||
jest.spyOn(events.app, "deleted")
|
jest.spyOn(events.app, "deleted")
|
||||||
jest.spyOn(events.app, "published")
|
jest.spyOn(events.app, "published")
|
||||||
jest.spyOn(events.app, "unpublished")
|
jest.spyOn(events.app, "unpublished")
|
||||||
|
|
|
@ -38,7 +38,7 @@
|
||||||
<div use:getAnchor on:click={openMenu}>
|
<div use:getAnchor on:click={openMenu}>
|
||||||
<slot name="control" />
|
<slot name="control" />
|
||||||
</div>
|
</div>
|
||||||
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}>
|
<Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close>
|
||||||
<Menu>
|
<Menu>
|
||||||
<slot />
|
<slot />
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
@ -71,6 +71,7 @@
|
||||||
class:scrollable
|
class:scrollable
|
||||||
class:highlighted
|
class:highlighted
|
||||||
class:selectedBy
|
class:selectedBy
|
||||||
|
class:actionsOpen={highlighted && withActions}
|
||||||
on:dragend
|
on:dragend
|
||||||
on:dragstart
|
on:dragstart
|
||||||
on:dragover
|
on:dragover
|
||||||
|
@ -168,8 +169,9 @@
|
||||||
--avatars-background: var(--spectrum-global-color-gray-300);
|
--avatars-background: var(--spectrum-global-color-gray-300);
|
||||||
}
|
}
|
||||||
.nav-item:hover .actions,
|
.nav-item:hover .actions,
|
||||||
.hovering .actions {
|
.hovering .actions,
|
||||||
visibility: visible;
|
.nav-item.withActions.actionsOpen .actions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.nav-item-content {
|
.nav-item-content {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
@ -272,7 +274,6 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
visibility: hidden;
|
|
||||||
order: 3;
|
order: 3;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
|
|
|
@ -3,9 +3,16 @@
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||||
import { apps } from "stores/portal"
|
import { apps } from "stores/portal"
|
||||||
import { appStore } from "stores/builder"
|
|
||||||
import { API } from "api"
|
import { API } from "api"
|
||||||
|
|
||||||
|
export let appId
|
||||||
|
export let appName
|
||||||
|
export let onDeleteSuccess = () => {
|
||||||
|
$goto("/builder")
|
||||||
|
}
|
||||||
|
|
||||||
|
let deleting = false
|
||||||
|
|
||||||
export const show = () => {
|
export const show = () => {
|
||||||
deletionModal.show()
|
deletionModal.show()
|
||||||
}
|
}
|
||||||
|
@ -17,32 +24,52 @@
|
||||||
let deletionModal
|
let deletionModal
|
||||||
let deletionConfirmationAppName
|
let deletionConfirmationAppName
|
||||||
|
|
||||||
|
const copyName = () => {
|
||||||
|
deletionConfirmationAppName = appName
|
||||||
|
}
|
||||||
|
|
||||||
const deleteApp = async () => {
|
const deleteApp = async () => {
|
||||||
|
if (!appId) {
|
||||||
|
console.error("No app id provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleting = true
|
||||||
try {
|
try {
|
||||||
await API.deleteApp($appStore.appId)
|
await API.deleteApp(appId)
|
||||||
apps.load()
|
apps.load()
|
||||||
notifications.success("App deleted successfully")
|
notifications.success("App deleted successfully")
|
||||||
$goto("/builder")
|
onDeleteSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.error("Error deleting app")
|
notifications.error("Error deleting app")
|
||||||
|
deleting = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
bind:this={deletionModal}
|
bind:this={deletionModal}
|
||||||
title="Delete app"
|
title="Delete app"
|
||||||
okText="Delete"
|
okText="Delete"
|
||||||
onOk={deleteApp}
|
onOk={deleteApp}
|
||||||
onCancel={() => (deletionConfirmationAppName = null)}
|
onCancel={() => (deletionConfirmationAppName = null)}
|
||||||
disabled={deletionConfirmationAppName !== $appStore.name}
|
disabled={deletionConfirmationAppName !== appName || deleting}
|
||||||
>
|
>
|
||||||
Are you sure you want to delete <b>{$appStore.name}</b>?
|
Are you sure you want to delete
|
||||||
|
<span class="app-name" role="button" tabindex={-1} on:click={copyName}>
|
||||||
|
{appName}
|
||||||
|
</span>?
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
Please enter the app name below to confirm.
|
Please enter the app name below to confirm.
|
||||||
<br /><br />
|
<br /><br />
|
||||||
<Input
|
<Input bind:value={deletionConfirmationAppName} placeholder={appName} />
|
||||||
bind:value={deletionConfirmationAppName}
|
|
||||||
placeholder={$appStore.name}
|
|
||||||
/>
|
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-name {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -31,17 +31,11 @@
|
||||||
: null}
|
: null}
|
||||||
>
|
>
|
||||||
<Body>
|
<Body>
|
||||||
You are currently on our <span class="free-plan">Free plan</span>. Upgrade
|
You have exceeded the app limit for your current plan. Upgrade to get
|
||||||
to our Pro plan to get unlimited apps and additional features.
|
unlimited apps and additional features!
|
||||||
</Body>
|
</Body>
|
||||||
{#if !$auth.user.accountPortalAccess}
|
{#if !$auth.user.accountPortalAccess}
|
||||||
<Body>Please contact the account holder to upgrade.</Body>
|
<Body>Please contact the account holder to upgrade.</Body>
|
||||||
{/if}
|
{/if}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<style>
|
|
||||||
.free-plan {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
import { goto } from "@roxi/routify"
|
import { goto } from "@roxi/routify"
|
||||||
import { UserAvatars } from "@budibase/frontend-core"
|
import { UserAvatars } from "@budibase/frontend-core"
|
||||||
import { sdk } from "@budibase/shared-core"
|
import { sdk } from "@budibase/shared-core"
|
||||||
|
import AppRowContext from "./AppRowContext.svelte"
|
||||||
|
|
||||||
export let app
|
export let app
|
||||||
export let lockedAction
|
export let lockedAction
|
||||||
|
@ -76,12 +77,10 @@
|
||||||
|
|
||||||
{#if isBuilder}
|
{#if isBuilder}
|
||||||
<div class="app-row-actions">
|
<div class="app-row-actions">
|
||||||
<Button size="S" secondary on:click={lockedAction || goToOverview}>
|
<Button size="S" secondary on:click={lockedAction || goToBuilder}>
|
||||||
Manage
|
|
||||||
</Button>
|
|
||||||
<Button size="S" primary on:click={lockedAction || goToBuilder}>
|
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
<AppRowContext {app} />
|
||||||
</div>
|
</div>
|
||||||
{:else if app.deployed}
|
{:else if app.deployed}
|
||||||
<!-- this can happen if an app builder has app user access to an app -->
|
<!-- this can happen if an app builder has app user access to an app -->
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
<script>
|
||||||
|
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
|
||||||
|
import DeleteModal from "components/deploy/DeleteModal.svelte"
|
||||||
|
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
||||||
|
import ExportAppModal from "./ExportAppModal.svelte"
|
||||||
|
import DuplicateAppModal from "./DuplicateAppModal.svelte"
|
||||||
|
import { licensing } from "stores/portal"
|
||||||
|
|
||||||
|
export let app
|
||||||
|
export let align = "right"
|
||||||
|
|
||||||
|
let deleteModal
|
||||||
|
let exportModal
|
||||||
|
let duplicateModal
|
||||||
|
let exportPublishedVersion = false
|
||||||
|
let appLimitModal
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<DeleteModal
|
||||||
|
bind:this={deleteModal}
|
||||||
|
appId={app.devId}
|
||||||
|
appName={app.name}
|
||||||
|
onDeleteSuccess={async () => {
|
||||||
|
await licensing.init()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AppLimitModal bind:this={appLimitModal} />
|
||||||
|
|
||||||
|
<Modal bind:this={exportModal} padding={false}>
|
||||||
|
<ExportAppModal {app} published={exportPublishedVersion} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal bind:this={duplicateModal} padding={false}>
|
||||||
|
<DuplicateAppModal
|
||||||
|
appId={app.devId}
|
||||||
|
appName={app.name}
|
||||||
|
onDuplicateSuccess={async () => {
|
||||||
|
await licensing.init()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ActionMenu {align} on:open on:close>
|
||||||
|
<div slot="control" class="icon">
|
||||||
|
<Icon size="S" hoverable name="MoreSmallList" />
|
||||||
|
</div>
|
||||||
|
<MenuItem
|
||||||
|
icon="Copy"
|
||||||
|
on:click={() => {
|
||||||
|
if ($licensing?.usageMetrics?.apps < 100) {
|
||||||
|
duplicateModal.show()
|
||||||
|
} else {
|
||||||
|
appLimitModal.show()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
icon="Export"
|
||||||
|
on:click={() => {
|
||||||
|
exportPublishedVersion = false
|
||||||
|
exportModal.show()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export latest edited app
|
||||||
|
</MenuItem>
|
||||||
|
{#if app.deployed}
|
||||||
|
<MenuItem
|
||||||
|
icon="Export"
|
||||||
|
on:click={() => {
|
||||||
|
exportPublishedVersion = true
|
||||||
|
exportModal.show()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Export latest published app
|
||||||
|
</MenuItem>
|
||||||
|
{/if}
|
||||||
|
<MenuItem
|
||||||
|
icon="Delete"
|
||||||
|
on:click={() => {
|
||||||
|
deleteModal.show()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
</ActionMenu>
|
|
@ -0,0 +1,158 @@
|
||||||
|
<script>
|
||||||
|
import {
|
||||||
|
ModalContent,
|
||||||
|
Input,
|
||||||
|
notifications,
|
||||||
|
Layout,
|
||||||
|
keepOpen,
|
||||||
|
} from "@budibase/bbui"
|
||||||
|
import { createValidationStore } from "helpers/validation/yup"
|
||||||
|
import { writable, get } from "svelte/store"
|
||||||
|
import * as appValidation from "helpers/validation/yup/app"
|
||||||
|
import { apps } from "stores/portal"
|
||||||
|
import { onMount } from "svelte"
|
||||||
|
import { API } from "api"
|
||||||
|
|
||||||
|
export let appId
|
||||||
|
export let appName
|
||||||
|
export let onDuplicateSuccess = () => {}
|
||||||
|
|
||||||
|
const validation = createValidationStore()
|
||||||
|
const values = writable({ name: appName + " copy", url: null })
|
||||||
|
const appPrefix = "/app"
|
||||||
|
|
||||||
|
let defaultAppName = appName + " copy"
|
||||||
|
let duplicating = false
|
||||||
|
|
||||||
|
$: {
|
||||||
|
const { url } = $values
|
||||||
|
|
||||||
|
validation.check({
|
||||||
|
...$values,
|
||||||
|
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAppName = name => {
|
||||||
|
return name ? name.trim() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveAppUrl = name => {
|
||||||
|
let parsedName
|
||||||
|
const resolvedName = resolveAppName(name)
|
||||||
|
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
|
||||||
|
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
|
||||||
|
return encodeURI(parsedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameToUrl = appName => {
|
||||||
|
let resolvedUrl = resolveAppUrl(appName)
|
||||||
|
tidyUrl(resolvedUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tidyUrl = url => {
|
||||||
|
if (url && !url.startsWith("/")) {
|
||||||
|
url = `/${url}`
|
||||||
|
}
|
||||||
|
$values.url = url === "" ? null : url
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateApp = async () => {
|
||||||
|
duplicating = true
|
||||||
|
|
||||||
|
let data = new FormData()
|
||||||
|
data.append("name", $values.name.trim())
|
||||||
|
if ($values.url) {
|
||||||
|
data.append("url", $values.url.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await API.duplicateApp(data, appId)
|
||||||
|
apps.load()
|
||||||
|
onDuplicateSuccess()
|
||||||
|
notifications.success("App duplicated successfully")
|
||||||
|
} catch (err) {
|
||||||
|
notifications.error("Error duplicating app")
|
||||||
|
duplicating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupValidation = async () => {
|
||||||
|
const applications = get(apps)
|
||||||
|
appValidation.name(validation, { apps: applications })
|
||||||
|
appValidation.url(validation, { apps: applications })
|
||||||
|
|
||||||
|
const { url } = $values
|
||||||
|
validation.check({
|
||||||
|
...$values,
|
||||||
|
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$: appUrl = `${window.location.origin}${
|
||||||
|
$values.url
|
||||||
|
? `${appPrefix}${$values.url}`
|
||||||
|
: `${appPrefix}${resolveAppUrl($values.name)}`
|
||||||
|
}`
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
nameToUrl($values.name)
|
||||||
|
await setupValidation()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ModalContent
|
||||||
|
title={"Duplicate App"}
|
||||||
|
onConfirm={async () => {
|
||||||
|
validation.check({
|
||||||
|
...$values,
|
||||||
|
})
|
||||||
|
if ($validation.valid) {
|
||||||
|
await duplicateApp()
|
||||||
|
} else {
|
||||||
|
return keepOpen
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Layout gap="S" noPadding>
|
||||||
|
<Input
|
||||||
|
autofocus={true}
|
||||||
|
bind:value={$values.name}
|
||||||
|
disabled={duplicating}
|
||||||
|
error={$validation.touched.name && $validation.errors.name}
|
||||||
|
on:blur={() => ($validation.touched.name = true)}
|
||||||
|
on:change={nameToUrl($values.name)}
|
||||||
|
label="Name"
|
||||||
|
placeholder={defaultAppName}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<Input
|
||||||
|
bind:value={$values.url}
|
||||||
|
disabled={duplicating}
|
||||||
|
error={$validation.touched.url && $validation.errors.url}
|
||||||
|
on:blur={() => ($validation.touched.url = true)}
|
||||||
|
on:change={tidyUrl($values.url)}
|
||||||
|
label="URL"
|
||||||
|
placeholder={$values.url
|
||||||
|
? $values.url
|
||||||
|
: `/${resolveAppUrl($values.name)}`}
|
||||||
|
/>
|
||||||
|
{#if $values.url && $values.url !== "" && !$validation.errors.url}
|
||||||
|
<div class="app-server" title={appUrl}>
|
||||||
|
{appUrl}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Layout>
|
||||||
|
</ModalContent>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.app-server {
|
||||||
|
color: var(--spectrum-global-color-gray-600);
|
||||||
|
margin-top: 10px;
|
||||||
|
width: 320px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -121,6 +121,7 @@
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
|
autocomplete="new-password"
|
||||||
placeholder="Type here..."
|
placeholder="Type here..."
|
||||||
bind:value={password}
|
bind:value={password}
|
||||||
error={$validation.errors.password}
|
error={$validation.errors.password}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui"
|
import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui"
|
||||||
import { url, isActive } from "@roxi/routify"
|
import { url, isActive } from "@roxi/routify"
|
||||||
import DeleteModal from "components/deploy/DeleteModal.svelte"
|
import DeleteModal from "components/deploy/DeleteModal.svelte"
|
||||||
import { isOnlyUser } from "stores/builder"
|
import { isOnlyUser, appStore } from "stores/builder"
|
||||||
|
|
||||||
let deleteModal
|
let deleteModal
|
||||||
</script>
|
</script>
|
||||||
|
@ -67,7 +67,11 @@
|
||||||
</Page>
|
</Page>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeleteModal bind:this={deleteModal} />
|
<DeleteModal
|
||||||
|
bind:this={deleteModal}
|
||||||
|
appId={$appStore.appId}
|
||||||
|
appName={$appStore.name}
|
||||||
|
/>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.delete-action :global(.text) {
|
.delete-action :global(.text) {
|
||||||
|
|
|
@ -1,10 +1,14 @@
|
||||||
<script>
|
<script>
|
||||||
import { apps, sideBarCollapsed } from "stores/portal"
|
import { apps, sideBarCollapsed, auth } from "stores/portal"
|
||||||
import { params, goto } from "@roxi/routify"
|
import { params, goto } from "@roxi/routify"
|
||||||
import NavItem from "components/common/NavItem.svelte"
|
import NavItem from "components/common/NavItem.svelte"
|
||||||
import NavHeader from "components/common/NavHeader.svelte"
|
import NavHeader from "components/common/NavHeader.svelte"
|
||||||
|
import AppRowContext from "components/start/AppRowContext.svelte"
|
||||||
|
import { AppStatus } from "constants"
|
||||||
|
import { sdk } from "@budibase/shared-core"
|
||||||
|
|
||||||
let searchString
|
let searchString
|
||||||
|
let opened
|
||||||
|
|
||||||
$: filteredApps = $apps
|
$: filteredApps = $apps
|
||||||
.filter(app => {
|
.filter(app => {
|
||||||
|
@ -13,6 +17,12 @@
|
||||||
app.name.toLowerCase().includes(searchString.toLowerCase())
|
app.name.toLowerCase().includes(searchString.toLowerCase())
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
.map(app => {
|
||||||
|
return {
|
||||||
|
...app,
|
||||||
|
deployed: app.status === AppStatus.DEPLOYED,
|
||||||
|
}
|
||||||
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const lowerA = a.name.toLowerCase()
|
const lowerA = a.name.toLowerCase()
|
||||||
const lowerB = b.name.toLowerCase()
|
const lowerB = b.name.toLowerCase()
|
||||||
|
@ -42,8 +52,22 @@
|
||||||
icon={app.icon?.name || "Apps"}
|
icon={app.icon?.name || "Apps"}
|
||||||
iconColor={app.icon?.color}
|
iconColor={app.icon?.color}
|
||||||
selected={$params.appId === app.appId}
|
selected={$params.appId === app.appId}
|
||||||
|
highlighted={opened == app.appId}
|
||||||
on:click={() => $goto(`./${app.appId}`)}
|
on:click={() => $goto(`./${app.appId}`)}
|
||||||
/>
|
>
|
||||||
|
{#if sdk.users.isBuilder($auth.user, app?.devId)}
|
||||||
|
<AppRowContext
|
||||||
|
{app}
|
||||||
|
align="left"
|
||||||
|
on:open={() => {
|
||||||
|
opened = app.appId
|
||||||
|
}}
|
||||||
|
on:close={() => {
|
||||||
|
opened = null
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</NavItem>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -83,6 +83,18 @@ export const buildAppEndpoints = API => ({
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate an existing app
|
||||||
|
* @param app the app to dupe
|
||||||
|
*/
|
||||||
|
duplicateApp: async (app, appId) => {
|
||||||
|
return await API.post({
|
||||||
|
url: `/api/applications/${appId}/duplicate`,
|
||||||
|
body: app,
|
||||||
|
json: false,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an application using an export - the body
|
* Update an application using an export - the body
|
||||||
* should be of type FormData, with a "file" and a "password" if encrypted.
|
* should be of type FormData, with a "file" and a "password" if encrypted.
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {
|
||||||
env as envCore,
|
env as envCore,
|
||||||
ErrorCode,
|
ErrorCode,
|
||||||
events,
|
events,
|
||||||
|
HTTPError,
|
||||||
migrations,
|
migrations,
|
||||||
objectStore,
|
objectStore,
|
||||||
roles,
|
roles,
|
||||||
|
@ -50,6 +51,8 @@ import {
|
||||||
CreateAppRequest,
|
CreateAppRequest,
|
||||||
FetchAppDefinitionResponse,
|
FetchAppDefinitionResponse,
|
||||||
FetchAppPackageResponse,
|
FetchAppPackageResponse,
|
||||||
|
DuplicateAppRequest,
|
||||||
|
DuplicateAppResponse,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
|
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
|
||||||
import sdk from "../../sdk"
|
import sdk from "../../sdk"
|
||||||
|
@ -122,7 +125,7 @@ interface AppTemplate {
|
||||||
templateString?: string
|
templateString?: string
|
||||||
useTemplate?: string
|
useTemplate?: string
|
||||||
file?: {
|
file?: {
|
||||||
type: string
|
type?: string
|
||||||
path: string
|
path: string
|
||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
|
@ -263,6 +266,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
||||||
...(ctx.request.files.templateFile as any),
|
...(ctx.request.files.templateFile as any),
|
||||||
password: encryptionPassword,
|
password: encryptionPassword,
|
||||||
}
|
}
|
||||||
|
} else if (typeof ctx.request.body.file?.path === "string") {
|
||||||
|
instanceConfig.file = {
|
||||||
|
path: ctx.request.body.file?.path,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
|
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
|
||||||
const appId = generateDevAppID(generateAppID(tenantId))
|
const appId = generateDevAppID(generateAppID(tenantId))
|
||||||
|
@ -372,12 +379,20 @@ async function creationEvents(request: any, app: App) {
|
||||||
else if (request.files?.templateFile) {
|
else if (request.files?.templateFile) {
|
||||||
creationFns.push(a => events.app.fileImported(a))
|
creationFns.push(a => events.app.fileImported(a))
|
||||||
}
|
}
|
||||||
|
// from server file path
|
||||||
|
else if (request.body.file) {
|
||||||
|
// explicitly pass in the newly created app id
|
||||||
|
creationFns.push(a => events.app.duplicated(a, app.appId))
|
||||||
|
}
|
||||||
// unknown
|
// unknown
|
||||||
else {
|
else {
|
||||||
console.error("Could not determine template creation event")
|
console.error("Could not determine template creation event")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
creationFns.push(a => events.app.created(a))
|
|
||||||
|
if (!request.duplicate) {
|
||||||
|
creationFns.push(a => events.app.created(a))
|
||||||
|
}
|
||||||
|
|
||||||
for (let fn of creationFns) {
|
for (let fn of creationFns) {
|
||||||
await fn(app)
|
await fn(app)
|
||||||
|
@ -391,8 +406,10 @@ async function appPostCreate(ctx: UserCtx, app: App) {
|
||||||
tenantId,
|
tenantId,
|
||||||
appId: app.appId,
|
appId: app.appId,
|
||||||
})
|
})
|
||||||
|
|
||||||
await creationEvents(ctx.request, app)
|
await creationEvents(ctx.request, app)
|
||||||
// app import & template creation
|
|
||||||
|
// app import, template creation and duplication
|
||||||
if (ctx.request.body.useTemplate === "true") {
|
if (ctx.request.body.useTemplate === "true") {
|
||||||
const { rows } = await getUniqueRows([app.appId])
|
const { rows } = await getUniqueRows([app.appId])
|
||||||
const rowCount = rows ? rows.length : 0
|
const rowCount = rows ? rows.length : 0
|
||||||
|
@ -421,7 +438,7 @@ async function appPostCreate(ctx: UserCtx, app: App) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function create(ctx: UserCtx) {
|
export async function create(ctx: UserCtx<CreateAppRequest, App>) {
|
||||||
const newApplication = await quotas.addApp(() => performAppCreate(ctx))
|
const newApplication = await quotas.addApp(() => performAppCreate(ctx))
|
||||||
await appPostCreate(ctx, newApplication)
|
await appPostCreate(ctx, newApplication)
|
||||||
await cache.bustCache(cache.CacheKey.CHECKLIST)
|
await cache.bustCache(cache.CacheKey.CHECKLIST)
|
||||||
|
@ -626,6 +643,66 @@ export async function importToApp(ctx: UserCtx) {
|
||||||
ctx.body = { message: "app updated" }
|
ctx.body = { message: "app updated" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a copy of the latest dev application.
|
||||||
|
* Performs an export of the app, then imports from the export dir path
|
||||||
|
*/
|
||||||
|
export async function duplicateApp(
|
||||||
|
ctx: UserCtx<DuplicateAppRequest, DuplicateAppResponse>
|
||||||
|
) {
|
||||||
|
const { name: appName, url: possibleUrl } = ctx.request.body
|
||||||
|
const { appId: sourceAppId } = ctx.params
|
||||||
|
const [app] = await dbCore.getAppsByIDs([sourceAppId])
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
ctx.throw(404, "Source app not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const apps = (await dbCore.getAllApps({ dev: true })) as App[]
|
||||||
|
|
||||||
|
checkAppName(ctx, apps, appName)
|
||||||
|
const url = sdk.applications.getAppUrl({ name: appName, url: possibleUrl })
|
||||||
|
checkAppUrl(ctx, apps, url)
|
||||||
|
|
||||||
|
const tmpPath = await sdk.backups.exportApp(sourceAppId, {
|
||||||
|
excludeRows: false,
|
||||||
|
tar: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createRequestBody: CreateAppRequest = {
|
||||||
|
name: appName,
|
||||||
|
url: possibleUrl,
|
||||||
|
useTemplate: "true",
|
||||||
|
// The app export path
|
||||||
|
file: {
|
||||||
|
path: tmpPath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a new request
|
||||||
|
const createRequest = {
|
||||||
|
roleId: ctx.roleId,
|
||||||
|
user: ctx.user,
|
||||||
|
request: {
|
||||||
|
body: createRequestBody,
|
||||||
|
},
|
||||||
|
} as UserCtx<CreateAppRequest, App>
|
||||||
|
|
||||||
|
// Build the new application
|
||||||
|
await create(createRequest)
|
||||||
|
const { body: newApplication } = createRequest
|
||||||
|
|
||||||
|
if (!newApplication) {
|
||||||
|
ctx.throw(500, "There was a problem duplicating the application")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
duplicateAppId: newApplication?.appId,
|
||||||
|
sourceAppId,
|
||||||
|
}
|
||||||
|
ctx.status = 200
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAppPackage(
|
export async function updateAppPackage(
|
||||||
appPackage: Partial<App>,
|
appPackage: Partial<App>,
|
||||||
appId: string
|
appId: string
|
||||||
|
|
|
@ -55,9 +55,14 @@ router
|
||||||
)
|
)
|
||||||
.delete(
|
.delete(
|
||||||
"/api/applications/:appId",
|
"/api/applications/:appId",
|
||||||
authorized(permissions.GLOBAL_BUILDER),
|
authorized(permissions.BUILDER),
|
||||||
controller.destroy
|
controller.destroy
|
||||||
)
|
)
|
||||||
|
.post(
|
||||||
|
"/api/applications/:appId/duplicate",
|
||||||
|
authorized(permissions.BUILDER),
|
||||||
|
controller.duplicateApp
|
||||||
|
)
|
||||||
.post(
|
.post(
|
||||||
"/api/applications/:appId/import",
|
"/api/applications/:appId/import",
|
||||||
authorized(permissions.BUILDER),
|
authorized(permissions.BUILDER),
|
||||||
|
|
|
@ -34,6 +34,96 @@ describe("/applications", () => {
|
||||||
jest.clearAllMocks()
|
jest.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// These need to go first for the app totals to make sense
|
||||||
|
describe("permissions", () => {
|
||||||
|
it("should only return apps a user has access to", async () => {
|
||||||
|
let user = await config.createUser({
|
||||||
|
builder: { global: false },
|
||||||
|
admin: { global: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
const apps = await config.api.application.fetch()
|
||||||
|
expect(apps).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
user = await config.globalUser({
|
||||||
|
...user,
|
||||||
|
builder: {
|
||||||
|
apps: [config.getProdAppId()],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
const apps = await config.api.application.fetch()
|
||||||
|
expect(apps).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should only return apps a user has access to through a custom role", async () => {
|
||||||
|
let user = await config.createUser({
|
||||||
|
builder: { global: false },
|
||||||
|
admin: { global: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
const apps = await config.api.application.fetch()
|
||||||
|
expect(apps).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const role = await config.api.roles.save({
|
||||||
|
name: "Test",
|
||||||
|
inherits: "PUBLIC",
|
||||||
|
permissionId: "read_only",
|
||||||
|
version: "name",
|
||||||
|
})
|
||||||
|
|
||||||
|
user = await config.globalUser({
|
||||||
|
...user,
|
||||||
|
roles: {
|
||||||
|
[config.getProdAppId()]: role.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
const apps = await config.api.application.fetch()
|
||||||
|
expect(apps).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should only return apps a user has access to through a custom role on a group", async () => {
|
||||||
|
let user = await config.createUser({
|
||||||
|
builder: { global: false },
|
||||||
|
admin: { global: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
const apps = await config.api.application.fetch()
|
||||||
|
expect(apps).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleName = uuid.v4().replace(/-/g, "")
|
||||||
|
const role = await config.api.roles.save({
|
||||||
|
name: roleName,
|
||||||
|
inherits: "PUBLIC",
|
||||||
|
permissionId: "read_only",
|
||||||
|
version: "name",
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = await config.createGroup(role._id!)
|
||||||
|
|
||||||
|
user = await config.globalUser({
|
||||||
|
...user,
|
||||||
|
userGroups: [group._id!],
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.withUser(user, async () => {
|
||||||
|
const apps = await config.api.application.fetch()
|
||||||
|
expect(apps).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
it("creates empty app", async () => {
|
it("creates empty app", async () => {
|
||||||
const app = await config.api.application.create({ name: utils.newid() })
|
const app = await config.api.application.create({ name: utils.newid() })
|
||||||
|
@ -94,6 +184,20 @@ describe("/applications", () => {
|
||||||
expect(events.app.created).toBeCalledTimes(1)
|
expect(events.app.created).toBeCalledTimes(1)
|
||||||
expect(events.app.fileImported).toBeCalledTimes(1)
|
expect(events.app.fileImported).toBeCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should reject with a known name", async () => {
|
||||||
|
await config.api.application.create(
|
||||||
|
{ name: app.name },
|
||||||
|
{ body: { message: "App name is already in use." }, status: 400 }
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject with a known url", async () => {
|
||||||
|
await config.api.application.create(
|
||||||
|
{ name: "made up", url: app?.url! },
|
||||||
|
{ body: { message: "App URL is already in use." }, status: 400 }
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("fetch", () => {
|
describe("fetch", () => {
|
||||||
|
@ -229,6 +333,63 @@ describe("/applications", () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("POST /api/applications/:appId/duplicate", () => {
|
||||||
|
it("should duplicate an existing app", async () => {
|
||||||
|
const resp = await config.api.application.duplicateApp(
|
||||||
|
app.appId,
|
||||||
|
{
|
||||||
|
name: "to-dupe copy",
|
||||||
|
url: "/to-dupe-copy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events.app.duplicated).toBeCalled()
|
||||||
|
expect(resp.duplicateAppId).toBeDefined()
|
||||||
|
expect(resp.sourceAppId).toEqual(app.appId)
|
||||||
|
expect(resp.duplicateAppId).not.toEqual(app.appId)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject an unknown app id with a 404", async () => {
|
||||||
|
await config.api.application.duplicateApp(
|
||||||
|
app.appId.slice(0, -1) + "a",
|
||||||
|
{
|
||||||
|
name: "to-dupe 123",
|
||||||
|
url: "/to-dupe-123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject with a known name", async () => {
|
||||||
|
const resp = await config.api.application.duplicateApp(
|
||||||
|
app.appId,
|
||||||
|
{
|
||||||
|
name: app.name,
|
||||||
|
url: "/known-name",
|
||||||
|
},
|
||||||
|
{ body: { message: "App name is already in use." }, status: 400 }
|
||||||
|
)
|
||||||
|
expect(events.app.duplicated).not.toBeCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should reject with a known url", async () => {
|
||||||
|
const resp = await config.api.application.duplicateApp(
|
||||||
|
app.appId,
|
||||||
|
{
|
||||||
|
name: "this is fine",
|
||||||
|
url: app.url,
|
||||||
|
},
|
||||||
|
{ body: { message: "App URL is already in use." }, status: 400 }
|
||||||
|
)
|
||||||
|
expect(events.app.duplicated).not.toBeCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("POST /api/applications/:appId/sync", () => {
|
describe("POST /api/applications/:appId/sync", () => {
|
||||||
it("should not sync automation logs", async () => {
|
it("should not sync automation logs", async () => {
|
||||||
const automation = await config.createAutomation()
|
const automation = await config.createAutomation()
|
||||||
|
@ -249,93 +410,4 @@ describe("/applications", () => {
|
||||||
expect(devLogs.data.length).toBe(0)
|
expect(devLogs.data.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("permissions", () => {
|
|
||||||
it("should only return apps a user has access to", async () => {
|
|
||||||
let user = await config.createUser({
|
|
||||||
builder: { global: false },
|
|
||||||
admin: { global: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
await config.withUser(user, async () => {
|
|
||||||
const apps = await config.api.application.fetch()
|
|
||||||
expect(apps).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
user = await config.globalUser({
|
|
||||||
...user,
|
|
||||||
builder: {
|
|
||||||
apps: [config.getProdAppId()],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await config.withUser(user, async () => {
|
|
||||||
const apps = await config.api.application.fetch()
|
|
||||||
expect(apps).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should only return apps a user has access to through a custom role", async () => {
|
|
||||||
let user = await config.createUser({
|
|
||||||
builder: { global: false },
|
|
||||||
admin: { global: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
await config.withUser(user, async () => {
|
|
||||||
const apps = await config.api.application.fetch()
|
|
||||||
expect(apps).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const role = await config.api.roles.save({
|
|
||||||
name: "Test",
|
|
||||||
inherits: "PUBLIC",
|
|
||||||
permissionId: "read_only",
|
|
||||||
version: "name",
|
|
||||||
})
|
|
||||||
|
|
||||||
user = await config.globalUser({
|
|
||||||
...user,
|
|
||||||
roles: {
|
|
||||||
[config.getProdAppId()]: role.name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await config.withUser(user, async () => {
|
|
||||||
const apps = await config.api.application.fetch()
|
|
||||||
expect(apps).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it.only("should only return apps a user has access to through a custom role on a group", async () => {
|
|
||||||
let user = await config.createUser({
|
|
||||||
builder: { global: false },
|
|
||||||
admin: { global: false },
|
|
||||||
})
|
|
||||||
|
|
||||||
await config.withUser(user, async () => {
|
|
||||||
const apps = await config.api.application.fetch()
|
|
||||||
expect(apps).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const roleName = uuid.v4().replace(/-/g, "")
|
|
||||||
const role = await config.api.roles.save({
|
|
||||||
name: roleName,
|
|
||||||
inherits: "PUBLIC",
|
|
||||||
permissionId: "read_only",
|
|
||||||
version: "name",
|
|
||||||
})
|
|
||||||
|
|
||||||
const group = await config.createGroup(role._id!)
|
|
||||||
|
|
||||||
user = await config.globalUser({
|
|
||||||
...user,
|
|
||||||
userGroups: [group._id!],
|
|
||||||
})
|
|
||||||
|
|
||||||
await config.withUser(user, async () => {
|
|
||||||
const apps = await config.api.application.fetch()
|
|
||||||
expect(apps).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -24,7 +24,7 @@ import tar from "tar"
|
||||||
|
|
||||||
type TemplateType = {
|
type TemplateType = {
|
||||||
file?: {
|
file?: {
|
||||||
type: string
|
type?: string
|
||||||
path: string
|
path: string
|
||||||
password?: string
|
password?: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -580,7 +580,7 @@ export default class TestConfiguration {
|
||||||
}
|
}
|
||||||
|
|
||||||
// APP
|
// APP
|
||||||
async createApp(appName: string): Promise<App> {
|
async createApp(appName: string, url?: string): Promise<App> {
|
||||||
// create dev app
|
// create dev app
|
||||||
// clear any old app
|
// clear any old app
|
||||||
this.appId = undefined
|
this.appId = undefined
|
||||||
|
@ -589,6 +589,7 @@ export default class TestConfiguration {
|
||||||
async () =>
|
async () =>
|
||||||
(await this._req(appController.create, {
|
(await this._req(appController.create, {
|
||||||
name: appName,
|
name: appName,
|
||||||
|
url,
|
||||||
})) as App
|
})) as App
|
||||||
)
|
)
|
||||||
this.appId = this.app.appId
|
this.appId = this.app.appId
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
type CreateAppRequest,
|
type CreateAppRequest,
|
||||||
type FetchAppDefinitionResponse,
|
type FetchAppDefinitionResponse,
|
||||||
type FetchAppPackageResponse,
|
type FetchAppPackageResponse,
|
||||||
|
DuplicateAppResponse,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { Expectations, TestAPI } from "./base"
|
import { Expectations, TestAPI } from "./base"
|
||||||
import { AppStatus } from "../../../db/utils"
|
import { AppStatus } from "../../../db/utils"
|
||||||
|
@ -70,6 +71,22 @@ export class ApplicationAPI extends TestAPI {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
duplicateApp = async (
|
||||||
|
appId: string,
|
||||||
|
fields: object,
|
||||||
|
expectations?: Expectations
|
||||||
|
): Promise<DuplicateAppResponse> => {
|
||||||
|
let headers = {
|
||||||
|
...this.config.defaultHeaders(),
|
||||||
|
[constants.Header.APP_ID]: appId,
|
||||||
|
}
|
||||||
|
return this._post(`/api/applications/${appId}/duplicate`, {
|
||||||
|
headers,
|
||||||
|
fields,
|
||||||
|
expectations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
getDefinition = async (
|
getDefinition = async (
|
||||||
appId: string,
|
appId: string,
|
||||||
expectations?: Expectations
|
expectations?: Expectations
|
||||||
|
|
|
@ -11,6 +11,17 @@ export interface CreateAppRequest {
|
||||||
includeSampleData?: boolean
|
includeSampleData?: boolean
|
||||||
encryptionPassword?: string
|
encryptionPassword?: string
|
||||||
templateString?: string
|
templateString?: string
|
||||||
|
file?: { path: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateAppRequest {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateAppResponse {
|
||||||
|
duplicateAppId: string
|
||||||
|
sourceAppId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FetchAppDefinitionResponse {
|
export interface FetchAppDefinitionResponse {
|
||||||
|
|
|
@ -44,6 +44,14 @@ export interface AppFileImportedEvent extends BaseEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppDuplicatedEvent extends BaseEvent {
|
||||||
|
duplicateAppId: string
|
||||||
|
appId: string
|
||||||
|
audited: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface AppTemplateImportedEvent extends BaseEvent {
|
export interface AppTemplateImportedEvent extends BaseEvent {
|
||||||
appId: string
|
appId: string
|
||||||
templateKey: string
|
templateKey: string
|
||||||
|
|
|
@ -60,6 +60,7 @@ export enum Event {
|
||||||
APP_CREATED = "app:created",
|
APP_CREATED = "app:created",
|
||||||
APP_UPDATED = "app:updated",
|
APP_UPDATED = "app:updated",
|
||||||
APP_DELETED = "app:deleted",
|
APP_DELETED = "app:deleted",
|
||||||
|
APP_DUPLICATED = "app:duplicated",
|
||||||
APP_PUBLISHED = "app:published",
|
APP_PUBLISHED = "app:published",
|
||||||
APP_UNPUBLISHED = "app:unpublished",
|
APP_UNPUBLISHED = "app:unpublished",
|
||||||
APP_TEMPLATE_IMPORTED = "app:template:imported",
|
APP_TEMPLATE_IMPORTED = "app:template:imported",
|
||||||
|
@ -259,6 +260,7 @@ export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
|
||||||
[Event.APP_CREATED]: `App "{{ name }}" created`,
|
[Event.APP_CREATED]: `App "{{ name }}" created`,
|
||||||
[Event.APP_UPDATED]: `App "{{ name }}" updated`,
|
[Event.APP_UPDATED]: `App "{{ name }}" updated`,
|
||||||
[Event.APP_DELETED]: `App "{{ name }}" deleted`,
|
[Event.APP_DELETED]: `App "{{ name }}" deleted`,
|
||||||
|
[Event.APP_DUPLICATED]: `App "{{ name }}" duplicated`,
|
||||||
[Event.APP_PUBLISHED]: `App "{{ name }}" published`,
|
[Event.APP_PUBLISHED]: `App "{{ name }}" published`,
|
||||||
[Event.APP_UNPUBLISHED]: `App "{{ name }}" unpublished`,
|
[Event.APP_UNPUBLISHED]: `App "{{ name }}" unpublished`,
|
||||||
[Event.APP_TEMPLATE_IMPORTED]: `App "{{ name }}" template imported`,
|
[Event.APP_TEMPLATE_IMPORTED]: `App "{{ name }}" template imported`,
|
||||||
|
|
Loading…
Reference in New Issue