Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
598dd77
add right click context menu to workspaceselect
ConorWebb96 Mar 9, 2026
4a7b293
pass context menu to item
ConorWebb96 Mar 9, 2026
597049d
simplify workspace context-menu handling
ConorWebb96 Mar 11, 2026
d55cdfd
linting
ConorWebb96 Mar 11, 2026
62e0d42
change duplicate modal wording from app to workspace
ConorWebb96 Mar 11, 2026
201aa27
refresh and redirect correctly after workspace delete
ConorWebb96 Mar 11, 2026
8a2cd91
avoid app-id conflict when deleting non-current workspace
ConorWebb96 Mar 11, 2026
ebb6dee
add workspace delete conflict coverage for mismatched APP_ID header
ConorWebb96 Mar 11, 2026
d3346e2
Merge branch 'master' into 17335-reinstate-the-ability-to-duplicate-a…
ConorWebb96 Mar 16, 2026
66d917b
preserve context menu on non-editable workspaces
ConorWebb96 Mar 16, 2026
73eeb94
avoid false failure toast on post-delete follow-up errors
ConorWebb96 Mar 16, 2026
c6c892c
remove workspace list reload from context-menu delete callback
ConorWebb96 Mar 16, 2026
1fbdb63
cubic feedback
ConorWebb96 Mar 16, 2026
745a322
Merge branch 'master' into 17335-reinstate-the-ability-to-duplicate-a…
ConorWebb96 Mar 16, 2026
04f2fd8
Merge branch 'master' into 17335-reinstate-the-ability-to-duplicate-a…
ConorWebb96 Mar 18, 2026
c1206b2
Merge branch 'master' into 17335-reinstate-the-ability-to-duplicate-a…
ConorWebb96 Mar 18, 2026
4359dc2
Merge branch 'master' into 17335-reinstate-the-ability-to-duplicate-a…
deanhannigan Mar 18, 2026
db5eae3
Merge branch 'master' into 17335-reinstate-the-ability-to-duplicate-a…
ConorWebb96 Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/bbui/src/Menu/Item.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
<li
on:click={disabled ? null : onClick}
on:auxclick
on:contextmenu
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Guard the new contextmenu/keydown forwarding behind disabled as well; disabled menu items can still trigger parent actions through these events.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/bbui/src/Menu/Item.svelte, line 44:

<comment>Guard the new `contextmenu`/`keydown` forwarding behind `disabled` as well; disabled menu items can still trigger parent actions through these events.</comment>

<file context>
@@ -41,6 +41,8 @@
 <li
   on:click={disabled ? null : onClick}
   on:auxclick
+  on:contextmenu
+  on:keydown
   class="spectrum-Menu-item"
</file context>
Fix with Cubic

on:keydown
class="spectrum-Menu-item"
class:is-disabled={disabled}
role="menuitem"
Expand Down
14 changes: 10 additions & 4 deletions packages/builder/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ import { sdk, Header, ClientHeader } from "@budibase/shared-core"

const newClient = (opts?: { production?: boolean }) =>
createAPIClient({
attachHeaders: headers => {
attachHeaders: (headers, request) => {
const isWorkspaceDeleteRequest =
request?.method === "DELETE" &&
/^\/api\/applications\/app_dev_/.test(request.url)

// Attach app ID header from store
let appId = get(appStore).appId
if (appId) {
headers[Header.APP_ID] = opts?.production
? sdk.applications.getProdAppID(appId)
: appId
if (!isWorkspaceDeleteRequest) {
headers[Header.APP_ID] = opts?.production
? sdk.applications.getProdAppID(appId)
: appId
}
headers[Header.CLIENT] = ClientHeader.BUILDER
}

Expand Down
86 changes: 84 additions & 2 deletions packages/builder/src/components/common/WorkspaceSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import { ActionMenu, MenuItem, Icon, StatusLight } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
import { processStringSync } from "@budibase/string-templates"
import AppContextMenuModals from "@/components/start/AppContextMenuModals.svelte"
import getAppContextMenuItems from "@/components/start/getAppContextMenuItems"
import { appStore } from "@/stores/builder"
import { contextMenuStore } from "@/stores/builder/contextMenu"
import { bb } from "@/stores/bb"
import { enrichedApps, auth, licensing } from "@/stores/portal"
import { appsStore, sortBy } from "@/stores/portal/apps"
Expand Down Expand Up @@ -34,6 +37,15 @@
let filterInput: HTMLInputElement | null = null
let activeIndex = -1
let itemEls: (HTMLElement | null)[] = []
let selectedWorkspaceForMenu: EnrichedApp | null = null
let appContextMenuModals:
| {
showDuplicateModal: () => void
showExportDevModal: () => void
showExportProdModal: () => void
showDeleteModal: () => void
}
| undefined

$: apps = $enrichedApps
$: appId = $appStore.appId
Expand Down Expand Up @@ -84,10 +96,68 @@
}
}

const getWorkspaceUrl = (app: any) => {
const getWorkspaceUrl = (app: EnrichedApp) => {
return app.editable ? `/builder/workspace/${app.devId}` : `/app${app.url}`
}

const openWorkspaceContextMenu = (
ws: EnrichedApp,
position: { x: number; y: number }
) => {
if (!ws.editable) {
return
}
selectedWorkspaceForMenu = ws

const items = getAppContextMenuItems({
app: ws,
onDuplicate: () => appContextMenuModals?.showDuplicateModal(),
onExportDev: () => appContextMenuModals?.showExportDevModal(),
onExportProd: () => appContextMenuModals?.showExportProdModal(),
onDelete: () => appContextMenuModals?.showDeleteModal(),
})

contextMenuStore.open(`workspace-select-${ws.appId}`, items, {
x: position.x,
y: position.y,
})
}

const openWorkspaceContextMenuFromMouse = (
e: MouseEvent,
ws: EnrichedApp
) => {
if (!ws.editable) {
return
}
e.preventDefault()
e.stopPropagation()
openWorkspaceContextMenu(ws, {
x: e.clientX,
y: e.clientY,
})
}

const onWorkspaceItemKeydown = (
e: KeyboardEvent,
ws: EnrichedApp,
itemEl: HTMLElement | null
) => {
const isContextMenuKey = e.key === "ContextMenu"

if (!isContextMenuKey || !itemEl || !ws.editable) {
return
}

e.preventDefault()
e.stopPropagation()
const rect = itemEl.getBoundingClientRect()
openWorkspaceContextMenu(ws, {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
})
}

const matchesFilter = (name: string, term: string) =>
!term || name?.toLowerCase().includes(term.trim().toLowerCase())

Expand Down Expand Up @@ -231,7 +301,12 @@
on:click={() => {
navigateToWorkspace(ws)
}}
on:auxclick={() => {
on:contextmenu={e => openWorkspaceContextMenuFromMouse(e, ws)}
on:keydown={e => onWorkspaceItemKeydown(e, ws, itemEls[i])}
on:auxclick={e => {
if (e.button !== 1) {
return
}
window.open(getWorkspaceUrl(ws), "_blank")
}}
>
Expand Down Expand Up @@ -277,6 +352,13 @@
</div>
</ActionMenu>

{#if selectedWorkspaceForMenu}
<AppContextMenuModals
app={selectedWorkspaceForMenu}
bind:this={appContextMenuModals}
/>
{/if}

<style>
.app-items {
max-height: 500px;
Expand Down
51 changes: 43 additions & 8 deletions packages/builder/src/components/deploy/DeleteModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import { goto as gotoStore } from "@roxi/routify"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { appStore } from "@/stores/builder"
import { appsStore, enrichedApps } from "@/stores/portal"
import { API } from "@/api"
import { get } from "svelte/store"

$: goto = $gotoStore

export let appId
export let appName
export let onDeleteSuccess = () => {
goto("/")
}
export let onDeleteSuccess = async () => {}

let deleting = false

Expand All @@ -30,21 +30,56 @@
deletionConfirmationAppName = appName
}

const getPostDeleteRedirectPath = deletedAppId => {
const nextWorkspace = get(enrichedApps).find(
workspace => workspace.editable && workspace.devId !== deletedAppId
)
return nextWorkspace?.devId
? `/builder/workspace/${nextWorkspace.devId}`
: "/"
}

const redirectAfterDeletingCurrentWorkspace = async deletedAppId => {
goto(getPostDeleteRedirectPath(deletedAppId))
}

const deleteApp = async () => {
if (!appId) {
console.error("No app id provided")
return
}
deleting = true
const deletedCurrentWorkspace = $appStore.appId === appId
let deletedSuccessfully = false
try {
await API.deleteApp(appId)
// Clear the current app from appStore since it no longer exists
appStore.reset()
deletedSuccessfully = true

if (deletedCurrentWorkspace) {
appStore.reset()
}
notifications.success("Workspace deleted successfully")
deleting = false
onDeleteSuccess()
try {
await onDeleteSuccess()
} catch (err) {
console.error("Post-delete callback failed", err)
}
try {
await appsStore.load()
} catch (err) {
console.error("Post-delete workspace list refresh failed", err)
}

if (deletedCurrentWorkspace) {
await redirectAfterDeletingCurrentWorkspace(appId)
}
} catch (err) {
notifications.error("Error deleting workspace")
if (!deletedSuccessfully) {
notifications.error("Error deleting workspace")
} else {
console.error("Post-delete follow-up failed", err)
}
} finally {
deleting = false
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
import { API } from "@/api"
import { getErrorMessage } from "@/helpers/errors"

Check failure on line 3 in packages/builder/src/components/start/DuplicateAppModal.svelte

View workflow job for this annotation

GitHub Actions / lint

'getErrorMessage' is defined but never used. Allowed unused vars must match /^_/u
import { appsStore, auth } from "@/stores/portal"
import {
Input,
Expand Down Expand Up @@ -82,9 +82,9 @@
await auth.getSelf()
}
onDuplicateSuccess()
notifications.success("App duplicated successfully")
} catch (error) {
notifications.error(getErrorMessage(error) || "Error duplicating app")
notifications.success("Workspace duplicated successfully")
} catch (err) {
notifications.error("Error duplicating workspace")
duplicating = false
}
}
Expand Down Expand Up @@ -114,7 +114,7 @@
</script>

<ModalContent
title={"Duplicate App"}
title={"Duplicate Workspace"}
onConfirm={async () => {
validation.check({
...$values,
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend-core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ export const createAPIClient = (config: APIClientConfig = {}): APIClient => {
headers["Content-Type"] = "application/json"
}
if (config?.attachHeaders) {
config.attachHeaders(headers)
config.attachHeaders(headers, { url, method })
}

// Build request body
Expand Down
5 changes: 4 additions & 1 deletion packages/frontend-core/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ export type Headers = Record<string, string>

export type APIClientConfig = {
enableCaching?: boolean
attachHeaders?: (headers: Headers) => void
attachHeaders?: (
headers: Headers,
request?: { url: string; method: HTTPMethod }
) => void
onError?: (error: APIError) => void
onMigrationDetected?: (migration: string) => void
}
Expand Down
18 changes: 18 additions & 0 deletions packages/server/src/api/routes/tests/workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1570,6 +1570,24 @@ describe("/applications", () => {

migrationsModule.MIGRATIONS.pop()
})

it("should reject delete when APP_ID header conflicts with path appId", async () => {
const secondWorkspace = await config.api.workspace.create({
name: generateAppName(),
})

await config.withHeaders(
{ [Header.APP_ID]: workspace.appId },
async () => {
await config.api.workspace.delete(secondWorkspace.appId, {
status: 403,
body: { message: "App id conflict" },
})
}
)

expect(events.app.deleted).not.toHaveBeenCalled()
})
})

describe("POST /api/applications/:appId/duplicate", () => {
Expand Down
Loading