1
0
Fork 0
mirror of synced 2024-07-08 15:56:23 +12:00

Feedback changes. Permission change for app delete from GLOBAL_BUILDER to BUILDER. Minor updates to quota behaviour for apps.

This commit is contained in:
Dean 2024-03-05 16:23:39 +00:00
parent 3716054313
commit 320b443ca4
12 changed files with 144 additions and 138 deletions

View file

@ -56,9 +56,10 @@
disabled={deletionConfirmationAppName !== appName || deleting} disabled={deletionConfirmationAppName !== appName || deleting}
> >
Are you sure you want to delete Are you sure you want to delete
<b class="app-name" role="button" tabindex={-1} on:click={copyName}> <span class="app-name" role="button" tabindex={-1} on:click={copyName}>
{appName} {appName}
</b>? </span>?
<br /> <br />
Please enter the app name below to confirm. Please enter the app name below to confirm.
<br /><br /> <br /><br />
@ -68,5 +69,7 @@
<style> <style>
.app-name { .app-name {
cursor: pointer; cursor: pointer;
font-weight: bold;
display: inline-block;
} }
</style> </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

@ -1,8 +1,10 @@
<script> <script>
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui" import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import ExportAppModal from "./ExportAppModal.svelte" import ExportAppModal from "./ExportAppModal.svelte"
import DuplicateAppModal from "./DuplicateAppModal.svelte" import DuplicateAppModal from "./DuplicateAppModal.svelte"
import { auth, licensing } from "stores/portal"
export let app export let app
export let align = "right" export let align = "right"
@ -11,21 +13,32 @@
let exportModal let exportModal
let duplicateModal let duplicateModal
let exportPublishedVersion = false let exportPublishedVersion = false
let appLimitModal
</script> </script>
<DeleteModal <DeleteModal
bind:this={deleteModal} bind:this={deleteModal}
appId={app.devId} appId={app.devId}
appName={app.name} appName={app.name}
onDeleteSuccess={() => {}} onDeleteSuccess={async () => {
await licensing.init()
}}
/> />
<AppLimitModal bind:this={appLimitModal} />
<Modal bind:this={exportModal} padding={false}> <Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} /> <ExportAppModal {app} published={exportPublishedVersion} />
</Modal> </Modal>
<Modal bind:this={duplicateModal} padding={false}> <Modal bind:this={duplicateModal} padding={false}>
<DuplicateAppModal appId={app.devId} appName={app.name} /> <DuplicateAppModal
appId={app.devId}
appName={app.name}
onDuplicateSuccess={async () => {
await licensing.init()
}}
/>
</Modal> </Modal>
<ActionMenu {align} on:open on:close> <ActionMenu {align} on:open on:close>
@ -35,7 +48,11 @@
<MenuItem <MenuItem
icon="Copy" icon="Copy"
on:click={() => { on:click={() => {
duplicateModal.show() if ($licensing?.usageMetrics?.apps < 100) {
duplicateModal.show()
} else {
appLimitModal.show()
}
}} }}
> >
Duplicate Duplicate

View file

@ -15,6 +15,7 @@
export let appId export let appId
export let appName export let appName
export let onDuplicateSuccess = () => {}
const validation = createValidationStore() const validation = createValidationStore()
const values = writable({ name: appName + " copy", url: null }) const values = writable({ name: appName + " copy", url: null })
@ -68,6 +69,7 @@
try { try {
await API.duplicateApp(data, appId) await API.duplicateApp(data, appId)
apps.load() apps.load()
onDuplicateSuccess()
notifications.success("App duplicated successfully") notifications.success("App duplicated successfully")
} catch (err) { } catch (err) {
notifications.error("Error duplicating app") notifications.error("Error duplicating app")

View file

@ -4,6 +4,7 @@
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, appStore } from "stores/builder" import { isOnlyUser, appStore } from "stores/builder"
import { auth } from "stores/portal"
let deleteModal let deleteModal
</script> </script>

View file

@ -1,10 +1,11 @@
<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 AppRowContext from "components/start/AppRowContext.svelte"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { sdk } from "@budibase/shared-core"
let searchString let searchString
let opened let opened
@ -54,16 +55,18 @@
highlighted={opened == app.appId} highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)} on:click={() => $goto(`./${app.appId}`)}
> >
<AppRowContext {#if sdk.users.isBuilder($auth.user, app?.devId)}
{app} <AppRowContext
align="left" {app}
on:open={() => { align="left"
opened = app.appId on:open={() => {
}} opened = app.appId
on:close={() => { }}
opened = null on:close={() => {
}} opened = null
/> }}
/>
{/if}
</NavItem> </NavItem>
{/each} {/each}
</div> </div>

View file

@ -51,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"
@ -378,7 +380,7 @@ async function creationEvents(request: any, app: App) {
creationFns.push(a => events.app.fileImported(a)) creationFns.push(a => events.app.fileImported(a))
} }
// from server file path // from server file path
else if (request.body.file && request.duplicate) { else if (request.body.file) {
// explicitly pass in the newly created app id // explicitly pass in the newly created app id
creationFns.push(a => events.app.duplicated(a, app.appId)) creationFns.push(a => events.app.duplicated(a, app.appId))
} }
@ -407,7 +409,7 @@ async function appPostCreate(ctx: UserCtx, app: App) {
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
@ -436,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)
@ -645,7 +647,9 @@ export async function importToApp(ctx: UserCtx) {
* Create a copy of the latest dev application. * Create a copy of the latest dev application.
* Performs an export of the app, then imports from the export dir path * Performs an export of the app, then imports from the export dir path
*/ */
export async function duplicateApp(ctx: UserCtx) { export async function duplicateApp(
ctx: UserCtx<DuplicateAppRequest, DuplicateAppResponse>
) {
const { name: appName, url: possibleUrl } = ctx.request.body const { name: appName, url: possibleUrl } = ctx.request.body
const { appId: sourceAppId } = ctx.params const { appId: sourceAppId } = ctx.params
const [app] = await dbCore.getAppsByIDs([sourceAppId]) const [app] = await dbCore.getAppsByIDs([sourceAppId])
@ -665,24 +669,28 @@ export async function duplicateApp(ctx: UserCtx) {
tar: false, tar: false,
}) })
// Build a create request that triggers an import from the export path above. const createRequestBody: CreateAppRequest = {
const createReq: any = { name: appName,
request: { url: possibleUrl,
body: { useTemplate: "true",
name: appName, // The app export path
url: possibleUrl, file: {
useTemplate: "true", path: tmpPath,
file: {
path: tmpPath,
},
},
// Mark the create as a duplicate to kick off the correct event.
duplicate: true,
}, },
} }
await create(createReq) // Build a new request
const { body: newApplication } = createReq 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) { if (!newApplication) {
ctx.throw(500, "There was a problem duplicating the application") ctx.throw(500, "There was a problem duplicating the application")

View file

@ -55,12 +55,12 @@ router
) )
.delete( .delete(
"/api/applications/:appId", "/api/applications/:appId",
authorized(permissions.GLOBAL_BUILDER), authorized(permissions.BUILDER),
controller.destroy controller.destroy
) )
.post( .post(
"/api/applications/:appId/duplicate", "/api/applications/:appId/duplicate",
authorized(permissions.GLOBAL_BUILDER), authorized(permissions.BUILDER),
controller.duplicateApp controller.duplicateApp
) )
.post( .post(

View file

@ -95,40 +95,13 @@ describe("/applications", () => {
}) })
it("should reject with a known name", async () => { it("should reject with a known name", async () => {
let createError await config.api.application.create({ name: app.name }, { status: 400 })
const app = config.getApp()
expect(app).toBeDefined()
try {
await config.createApp(app?.name!)
} catch (err: any) {
createError = err
// Must be reset back to its original value or it breaks all
// subsequent tests
config.appId = app?.appId!
}
expect(createError).toBeDefined()
expect(createError.message).toEqual(
"Error 400 - App name is already in use."
)
}) })
it("should reject with a known url", async () => { it("should reject with a known url", async () => {
let createError await config.api.application.create(
{ name: "made up", url: app?.url! },
const app = config.getApp() { status: 400 }
expect(app).toBeDefined()
try {
await config.createApp("made up", app?.url!)
} catch (err: any) {
createError = err
config.appId = app?.appId!
}
expect(createError).toBeDefined()
expect(createError.message).toEqual(
"Error 400 - App URL is already in use."
) )
}) })
}) })
@ -268,73 +241,60 @@ describe("/applications", () => {
describe("POST /api/applications/:appId/duplicate", () => { describe("POST /api/applications/:appId/duplicate", () => {
it("should duplicate an existing app", async () => { it("should duplicate an existing app", async () => {
await config.createApp("to-dupe") const resp = await config.api.application.duplicateApp(
const sourceAppId = config.getProdAppId() app.appId,
{
const resp = await config.duplicateApp(sourceAppId, { name: "to-dupe copy",
name: "to-dupe copy", url: "/to-dupe-copy",
url: "/to-dupe-copy", },
}) {
status: 200,
}
)
expect(events.app.duplicated).toBeCalled() expect(events.app.duplicated).toBeCalled()
expect(resp.duplicateAppId).toBeDefined() expect(resp.duplicateAppId).toBeDefined()
expect(resp.sourceAppId).toEqual(sourceAppId) expect(resp.sourceAppId).toEqual(app.appId)
expect(resp.duplicateAppId).not.toEqual(sourceAppId) expect(resp.duplicateAppId).not.toEqual(app.appId)
}) })
it("should reject an unknown app id with a 404", async () => { it("should reject an unknown app id with a 404", async () => {
let dupeError await config.api.application.duplicateApp(
try { app.appId.slice(0, -1) + "a",
await config.duplicateApp("app_fake", { {
name: "to-dupe copy", name: "to-dupe 123",
url: "/to-dupe-copy", url: "/to-dupe-123",
}) },
} catch (err: any) { {
dupeError = err status: 404,
} }
)
expect(dupeError).toBeDefined()
expect(dupeError.message).toEqual("Error 404 - Source app not found")
}) })
it("should reject with a known name", async () => { it("should reject with a known name", async () => {
await config.createApp("known name") const resp = await config.api.application.duplicateApp(
const sourceAppId = config.getProdAppId() app.appId,
{
let dupeError name: app.name,
try {
await config.duplicateApp(sourceAppId, {
name: "known name",
url: "/known-name", url: "/known-name",
}) },
} catch (err: any) { { status: 400 }
dupeError = err
}
expect(dupeError).toBeDefined()
expect(dupeError.message).toEqual(
"Error 400 - App name is already in use."
) )
expect(resp.message).toEqual("App name is already in use.")
}) })
it("should reject with a known url", async () => { it("should reject with a known url", async () => {
await config.createApp("known-url") const resp = await config.api.application.duplicateApp(
const sourceAppId = config.getProdAppId() app.appId,
{
let dupeError
try {
await config.duplicateApp(sourceAppId, {
name: "this is fine", name: "this is fine",
url: "/known-url", url: app.url,
}) },
} catch (err: any) { { status: 400 }
dupeError = err
}
expect(dupeError).toBeDefined()
expect(dupeError.message).toEqual(
"Error 400 - App URL is already in use."
) )
expect(resp.message).toEqual("App URL is already in use.")
}) })
}) })

View file

@ -590,16 +590,6 @@ export default class TestConfiguration {
}) })
} }
async duplicateApp(appId: string, fields: object) {
return context.doInTenant(
this.tenantId!,
async () =>
await this._req(appController.duplicateApp, fields, {
appId,
})
)
}
async publish() { async publish() {
await this._req(deployController.publishApp) await this._req(deployController.publishApp)
// @ts-ignore // @ts-ignore

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,7 +11,18 @@ export interface CreateAppRequest {
includeSampleData?: boolean includeSampleData?: boolean
encryptionPassword?: string encryptionPassword?: string
templateString?: string templateString?: string
file?: any file?: { path: string }
}
export interface DuplicateAppRequest {
name: string
url?: string
}
export interface DuplicateAppResponse {
duplicateAppId: string
sourceAppId: string
message: string
} }
export interface FetchAppDefinitionResponse { export interface FetchAppDefinitionResponse {