Merge pull request #15762 from Budibase/head-tag-scripts

Custom app scripts
This commit is contained in:
Andrew Kingston 2025-03-25 13:55:51 +00:00 committed by GitHub
commit d0ca14d7e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 314 additions and 42 deletions

View File

@ -55,6 +55,11 @@
active={$isActive("./oauth2")}
/>
{/if}
<SideNavItem
text="App scripts"
url={$url("./scripts")}
active={$isActive("./scripts")}
/>
<div class="delete-action">
<AbsTooltip
position={TooltipPosition.Bottom}

View File

@ -170,7 +170,7 @@
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Automations</Heading>
<Body size="S">See your automation history and edit advanced settings</Body>
<Body>See your automation history and edit advanced settings</Body>
</Layout>
<Divider />
@ -251,7 +251,6 @@
data={runHistory}
{customRenderers}
placeholderText="No history found"
border={false}
/>
<div class="pagination">
<Pagination

View File

@ -11,6 +11,7 @@
Tags,
Tag,
Table,
ButtonGroup,
} from "@budibase/bbui"
import { backups, licensing, auth, admin } from "@/stores/portal"
import { appStore } from "@/stores/builder"
@ -180,25 +181,17 @@
{#if !$auth.accountPortalAccess && $admin.cloud}
<Body>Contact your account holder to upgrade your plan.</Body>
{/if}
<div class="pro-buttons">
{#if $auth.accountPortalAccess}
<Button
primary
disabled={!$auth.accountPortalAccess && $admin.cloud}
on:click={$licensing.goToUpgradePage()}
>
Upgrade
</Button>
<ButtonGroup>
{#if $admin.cloud && $auth.accountPortalAccess}
<Button primary on:click={$licensing.goToUpgradePage}>Upgrade</Button>
{/if}
<Button
secondary
on:click={() => {
window.open("https://budibase.com/pricing/", "_blank")
}}
on:click={() => window.open("https://budibase.com/pricing/", "_blank")}
>
View plans
</Button>
</div>
</ButtonGroup>
{:else if !backupData?.length && !loading && !filterOpt && !dateRange?.length}
<div class="center">
<Layout noPadding gap="S" justifyItems="center">
@ -311,12 +304,7 @@
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-m);
}
.pro-buttons {
display: flex;
gap: var(--spacing-m);
gap: var(--spacing-l);
}
.center {

View File

@ -0,0 +1,202 @@
<script lang="ts">
import {
Body,
Button,
Link,
Divider,
Heading,
Layout,
Table,
Label,
Input,
TextArea,
Select,
Helpers,
notifications,
Tag,
Tags,
ButtonGroup,
} from "@budibase/bbui"
import { appStore } from "@/stores/builder"
import { type AppScript } from "@budibase/types"
import { getSequentialName } from "@/helpers/duplicate"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import { licensing, auth, admin } from "@/stores/portal"
const schema = {
name: {
type: "string",
label: "Name",
},
location: {
type: "string",
label: "Location",
},
}
let selectedScript: AppScript | undefined
let isNew = false
let confirmDeleteModal: any
$: nameError = selectedScript?.name ? undefined : "Please enter a name"
$: invalid = !!nameError
$: enabled = $licensing.customAppScriptsEnabled
const addScript = () => {
const name = getSequentialName($appStore.scripts, "Script ", {
getName: script => script.name,
numberFirstItem: true,
})
selectedScript = { id: Helpers.uuid(), location: "Head", name }
isNew = true
}
const editScript = (e: any) => {
selectedScript = { ...(e.detail as AppScript) }
isNew = false
}
const cancel = () => {
selectedScript = undefined
}
const save = async () => {
if (!selectedScript) {
return
}
const newScripts = $appStore.scripts
.filter(script => script.id !== selectedScript!.id)
.concat([selectedScript])
await appStore.updateApp({ scripts: newScripts })
notifications.success("Script saved successfully")
selectedScript = undefined
}
const requestDeletion = () => {
confirmDeleteModal?.show()
}
const deleteScript = async () => {
if (!selectedScript) {
return
}
const newScripts = $appStore.scripts.filter(
script => script.id !== selectedScript!.id
)
await appStore.updateApp({ scripts: newScripts })
notifications.success("Script deleted successfully")
selectedScript = undefined
}
</script>
<Layout noPadding>
<Layout gap="XS" noPadding>
<div class="title">
<Heading>App scripts</Heading>
{#if !enabled}
<Tags>
<Tag icon="LockClosed">Enterprise</Tag>
</Tags>
{/if}
</div>
<div class="subtitle">
<Body>
Inject analytics, scripts or stylesheets into your app<br />
<Link href="https://docs.budibase.com/docs/app-scripts" target="_blank">
Learn more about script injection in the docs
</Link>
</Body>
{#if !selectedScript && enabled}
<Button cta on:click={addScript}>Add script</Button>
{/if}
</div>
</Layout>
<Divider />
{#if !enabled}
{#if $admin.cloud && !$auth.accountPortalAccess}
<Body>Contact your account holder to upgrade your plan.</Body>
{/if}
<ButtonGroup>
{#if $admin.cloud && $auth.accountPortalAccess}
<Button cta on:click={$licensing.goToUpgradePage}>Upgrade</Button>
{/if}
<Button
secondary
on:click={() => window.open("https://budibase.com/pricing/", "_blank")}
>
View plans
</Button>
</ButtonGroup>
{:else if selectedScript}
<Heading size="S">{isNew ? "Add new script" : "Edit script"}</Heading>
<div class="form">
<Label size="L">Name</Label>
<Input bind:value={selectedScript.name} error={nameError} />
<Label size="L">Location</Label>
<Select
bind:value={selectedScript.location}
options={["Head", "Body"]}
placeholder={false}
/>
<Label size="L">HTML</Label>
<TextArea
bind:value={selectedScript.html}
minHeight={200}
placeholder="&lt;script&gt;...&lt;/script&gt;"
/>
<div />
<div class="buttons">
{#if !isNew}
<Button warning quiet on:click={requestDeletion}>Delete</Button>
{/if}
<Button secondary on:click={cancel}>Cancel</Button>
<Button cta disabled={invalid} on:click={save}>Save</Button>
</div>
</div>
{:else}
<Table
on:click={editScript}
{schema}
data={$appStore.scripts}
allowSelectRows={false}
allowEditColumns={false}
allowEditRows={false}
placeholderText="You haven't added any scripts yet"
/>
{/if}
</Layout>
<ConfirmDialog
bind:this={confirmDeleteModal}
title="Delete script"
body="Are you sure you want to delete this script?"
onOk={deleteScript}
/>
<style>
.title,
.subtitle {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-l);
}
.subtitle {
justify-content: space-between;
}
.form {
display: grid;
grid-template-columns: 100px 480px;
row-gap: var(--spacing-l);
}
.form :global(.spectrum-FieldLabel) {
padding-top: 7px;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: var(--spacing-l);
}
</style>

View File

@ -4,9 +4,12 @@ import {
App,
AppFeatures,
AppIcon,
AppScript,
AutomationSettings,
Plugin,
UpdateAppRequest,
} from "@budibase/types"
import { get } from "svelte/store"
interface ClientFeatures {
spectrumThemes: boolean
@ -46,6 +49,7 @@ interface AppMetaState {
revertableVersion?: string
upgradableVersion?: string
icon?: AppIcon
scripts: AppScript[]
}
export const INITIAL_APP_META_STATE: AppMetaState = {
@ -79,6 +83,7 @@ export const INITIAL_APP_META_STATE: AppMetaState = {
usedPlugins: [],
automations: {},
routes: {},
scripts: [],
}
export class AppMetaStore extends BudiStore<AppMetaState> {
@ -90,20 +95,12 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
this.store.set({ ...INITIAL_APP_META_STATE })
}
syncAppPackage(pkg: {
application: App
clientLibPath: string
hasLock: boolean
}) {
const { application: app, clientLibPath, hasLock } = pkg
syncApp(app: App) {
this.update(state => ({
...state,
name: app.name,
appId: app.appId,
url: app.url || "",
hasLock,
clientLibPath,
libraries: app.componentLibraries,
version: app.version,
appInstance: app.instance,
@ -118,9 +115,24 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
initialised: true,
automations: app.automations || {},
hasAppPackage: true,
scripts: app.scripts || [],
}))
}
syncAppPackage(pkg: {
application: App
clientLibPath: string
hasLock: boolean
}) {
const { application, clientLibPath, hasLock } = pkg
this.update(state => ({
...state,
hasLock,
clientLibPath,
}))
this.syncApp(application)
}
syncClientFeatures(features: Partial<ClientFeatures>) {
this.update(state => ({
...state,
@ -146,6 +158,11 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
}))
}
async updateApp(updates: UpdateAppRequest) {
const app = await API.saveAppMetadata(get(this.store).appId, updates)
this.syncApp(app)
}
// Returned from socket
syncMetadata(metadata: { name: string; url: string; icon?: AppIcon }) {
const { name, url, icon } = metadata

View File

@ -36,6 +36,7 @@ interface LicensingState {
budibaseAIEnabled: boolean
customAIConfigsEnabled: boolean
auditLogsEnabled: boolean
customAppScriptsEnabled: boolean
syncAutomationsEnabled: boolean
triggerAutomationRunEnabled: boolean
// the currently used quotas from the db
@ -77,6 +78,7 @@ class LicensingStore extends BudiStore<LicensingState> {
budibaseAIEnabled: false,
customAIConfigsEnabled: false,
auditLogsEnabled: false,
customAppScriptsEnabled: false,
syncAutomationsEnabled: false,
triggerAutomationRunEnabled: false,
// the currently used quotas from the db
@ -182,6 +184,9 @@ class LicensingStore extends BudiStore<LicensingState> {
const customAIConfigsEnabled = features.includes(
Constants.Features.AI_CUSTOM_CONFIGS
)
const customAppScriptsEnabled = features.includes(
Constants.Features.CUSTOM_APP_SCRIPTS
)
this.update(state => {
return {
...state,
@ -202,6 +207,7 @@ class LicensingStore extends BudiStore<LicensingState> {
syncAutomationsEnabled,
triggerAutomationRunEnabled,
perAppBuildersEnabled,
customAppScriptsEnabled,
}
})
}

View File

@ -154,6 +154,21 @@ const requiresMigration = async (ctx: Ctx) => {
return latestMigrationApplied !== latestMigration
}
const getAppScriptHTML = (
app: App,
location: "Head" | "Body",
nonce: string
) => {
if (!app.scripts?.length) {
return ""
}
return app.scripts
.filter(script => script.location === location && script.html?.length)
.map(script => script.html)
.join("\n")
.replaceAll("<script", `<script nonce="${nonce}"`)
}
export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
if (ctx.url.includes("apple-touch-icon.png")) {
ctx.redirect("/builder/bblogo.png")
@ -190,6 +205,9 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
const hideFooter =
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
const themeVariables = getThemeVariables(appInfo?.theme)
const addAppScripts =
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
false
if (!env.isJest()) {
const plugins = await objectStore.enrichPluginURLs(appInfo.usedPlugins)
@ -199,7 +217,8 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
* BudibaseApp.svelte file as we can never detect if the types are correct. To get around this
* I've created a type which expects what the app will expect to receive.
*/
const props: BudibaseAppProps = {
const nonce = ctx.state.nonce || ""
let props: BudibaseAppProps = {
title: branding?.platformTitle || `${appInfo.name}`,
showSkeletonLoader: appInfo.features?.skeletonLoader ?? false,
hideDevTools,
@ -218,7 +237,13 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
? await objectStore.getGlobalFileUrl("settings", "faviconUrl")
: "",
appMigrating: needMigrations,
nonce: ctx.state.nonce,
nonce,
}
// Add custom app scripts if enabled
if (addAppScripts) {
props.headAppScripts = getAppScriptHTML(appInfo, "Head", nonce)
props.bodyAppScripts = getAppScriptHTML(appInfo, "Body", nonce)
}
const { head, html, css } = AppComponent.render({ props })
@ -273,10 +298,22 @@ export const serveBuilderPreview = async function (
const templateLoc = join(__dirname, "templates")
const previewLoc = fs.existsSync(templateLoc) ? templateLoc : __dirname
const previewHbs = loadHandlebarsFile(join(previewLoc, "preview.hbs"))
ctx.body = await processString(previewHbs, {
const nonce = ctx.state.nonce || ""
const addAppScripts =
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
false
let props: any = {
clientLibPath: objectStore.clientLibraryUrl(appId!, appInfo.version),
nonce: ctx.state.nonce,
})
nonce,
}
// Add custom app scripts if enabled
if (addAppScripts) {
props.headAppScripts = getAppScriptHTML(appInfo, "Head", nonce)
props.bodyAppScripts = getAppScriptHTML(appInfo, "Body", nonce)
}
ctx.body = await processString(previewHbs, props)
} else {
// just return the app info for jest to assert on
ctx.body = { ...appInfo, builderPreview: true }

View File

@ -88,6 +88,9 @@
font-weight: 400;
}
</style>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html props.headAppScripts || ""}
</svelte:head>
<body id="app">
@ -135,4 +138,7 @@
document.getElementById("error").style.display = "flex"
}
</script>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html props.bodyAppScripts || ""}
</body>

View File

@ -5,14 +5,12 @@
})
</script>
<head>
<script nonce="{{ nonce }}">
window["##BUDIBASE_APP_ID##"] = "{{appId}}"
window["##BUDIBASE_APP_EMBEDDED##"] = "{{embedded}}"
</script>
{{{head}}}
<style>{{{css}}}</style>
</head>
<script nonce="{{ nonce }}">
window["##BUDIBASE_APP_ID##"] = "{{appId}}"
window["##BUDIBASE_APP_EMBEDDED##"] = "{{embedded}}"
</script>
{{{body}}}
</html>

View File

@ -108,6 +108,9 @@
window.addEventListener("message", receiveMessage)
window.parent.postMessage({ type: "ready" })
</script>
{{ headAppScripts }}
</head>
<body></body>
<body>
{{ bodyAppScripts }}
</body>
</html>

View File

@ -29,6 +29,7 @@ export interface App extends Document {
snippets?: Snippet[]
creationVersion?: string
updatedBy?: string
scripts?: AppScript[]
}
export interface AppInstance {
@ -82,3 +83,10 @@ export interface AppFeatures {
export interface AutomationSettings {
chainAutomations?: boolean
}
export interface AppScript {
id: string
name: string
location: "Head" | "Body"
html?: string
}

View File

@ -13,6 +13,7 @@ export enum Feature {
APP_BUILDERS = "appBuilders",
OFFLINE = "offline",
EXPANDED_PUBLIC_API = "expandedPublicApi",
CUSTOM_APP_SCRIPTS = "customAppScripts",
// deprecated - no longer licensed
VIEW_PERMISSIONS = "viewPermissions",
VIEW_READONLY_COLUMNS = "viewReadonlyColumns",

View File

@ -14,4 +14,6 @@ export interface BudibaseAppProps {
sideNav?: boolean
hideFooter?: boolean
nonce?: string
headAppScripts?: string
bodyAppScripts?: string
}