Merge pull request #13116 from Budibase/feature/app-list-actions

App list actions
This commit is contained in:
deanhannigan 2024-03-15 10:56:53 +00:00 committed by GitHub
commit bb25a6b16a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 640 additions and 125 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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