Merge master.
This commit is contained in:
commit
0446450a75
|
@ -17,6 +17,7 @@ COPY error.html /usr/share/nginx/html/error.html
|
|||
# Default environment
|
||||
ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
|
||||
ENV PROXY_RATE_LIMIT_API_PER_SECOND=20
|
||||
ENV PROXY_TIMEOUT_SECONDS=120
|
||||
# Use docker-compose values as defaults for backwards compatibility
|
||||
ENV APPS_UPSTREAM_URL=http://app-service:4002
|
||||
ENV WORKER_UPSTREAM_URL=http://worker-service:4003
|
||||
|
|
|
@ -144,9 +144,9 @@ http {
|
|||
limit_req zone=ratelimit burst=20 nodelay;
|
||||
|
||||
# 120s timeout on API requests
|
||||
proxy_read_timeout 120s;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||
proxy_connect_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||
proxy_send_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
@ -164,9 +164,9 @@ http {
|
|||
|
||||
# Rest of configuration copied from /api/ location above
|
||||
# 120s timeout on API requests
|
||||
proxy_read_timeout 120s;
|
||||
proxy_connect_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_read_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||
proxy_connect_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||
proxy_send_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
|
|
@ -83,7 +83,7 @@ const CSP_DIRECTIVES = {
|
|||
"https://js.intercomcdn.com",
|
||||
"https://cdn.budi.live",
|
||||
],
|
||||
"worker-src": ["blob:"],
|
||||
"worker-src": ["blob:", "'self'"],
|
||||
}
|
||||
|
||||
export async function contentSecurityPolicy(ctx: any, next: any) {
|
||||
|
|
|
@ -3,6 +3,7 @@ import * as objectStore from "../objectStore"
|
|||
import * as cloudfront from "../cloudfront"
|
||||
import qs from "querystring"
|
||||
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
|
||||
import { PWAManifestImage } from "@budibase/types"
|
||||
|
||||
export function clientLibraryPath(appId: string) {
|
||||
return `${objectStore.sanitizeKey(appId)}/budibase-client.js`
|
||||
|
@ -51,3 +52,26 @@ export async function getAppFileUrl(s3Key: string) {
|
|||
return await objectStore.getPresignedUrl(env.APPS_BUCKET_NAME, s3Key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function enrichPWAImages(
|
||||
images: PWAManifestImage[]
|
||||
): Promise<PWAManifestImage[]> {
|
||||
if (images.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
return await Promise.all(
|
||||
images.map(async image => {
|
||||
return {
|
||||
...image,
|
||||
src: await getAppFileUrl(image.src),
|
||||
type: image.type || "image/png",
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error enriching PWA images:", error)
|
||||
return images
|
||||
}
|
||||
}
|
||||
|
|
|
@ -134,7 +134,7 @@
|
|||
return getThemeClassNames(theme)
|
||||
}
|
||||
|
||||
const onChange = (value: string | null) => {
|
||||
const onChange = (value: string | undefined) => {
|
||||
dispatch("change", value)
|
||||
dropdown?.hide()
|
||||
}
|
||||
|
@ -243,7 +243,7 @@
|
|||
size="S"
|
||||
name="Close"
|
||||
hoverable
|
||||
on:click={() => onChange(null)}
|
||||
on:click={() => onChange(undefined)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
const BYTES_IN_MB = 1000000
|
||||
|
||||
export let value: File | undefined = undefined
|
||||
export let statusText: string | undefined = undefined
|
||||
export let title: string = "Upload file"
|
||||
export let disabled: boolean = false
|
||||
export let allowClear: boolean | undefined = undefined
|
||||
|
@ -57,7 +58,16 @@
|
|||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="field">
|
||||
{#if value}
|
||||
{#if statusText}
|
||||
<div class="file-view status">
|
||||
<div class="filename">{statusText}</div>
|
||||
{#if !disabled || (allowClear === true && disabled)}
|
||||
<div class="delete-button" on:click={clearFile}>
|
||||
<Icon name="Close" size="XS" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if value}
|
||||
<div class="file-view">
|
||||
{#if previewUrl}
|
||||
<img class="preview" alt="" src={previewUrl} />
|
||||
|
@ -97,6 +107,9 @@
|
|||
border-radius: var(--spectrum-global-dimension-size-50);
|
||||
padding: 0px var(--spectrum-alias-item-padding-m);
|
||||
}
|
||||
.file-view.status {
|
||||
background-color: var(--spectrum-global-color-gray-100);
|
||||
}
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
export let error: string | undefined = undefined
|
||||
export let title: string | undefined = undefined
|
||||
export let value: File | undefined = undefined
|
||||
export let statusText: string | undefined = undefined
|
||||
export let tooltip: string | undefined = undefined
|
||||
export let helpText: string | undefined = undefined
|
||||
|
||||
|
@ -29,6 +30,7 @@
|
|||
{allowClear}
|
||||
{title}
|
||||
{value}
|
||||
{statusText}
|
||||
{previewUrl}
|
||||
{handleFileTooLarge}
|
||||
{extensions}
|
||||
|
|
|
@ -238,3 +238,21 @@ export const hexToRGBA = (color: string, opacity: number): string => {
|
|||
const b = parseInt(color.substring(4, 6), 16)
|
||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
||||
}
|
||||
|
||||
export function rgbToHex(rgbStr: string | undefined): string {
|
||||
if (rgbStr?.startsWith("#")) return rgbStr
|
||||
|
||||
const rgbMatch = rgbStr?.match(
|
||||
/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*[\d.]+)?\)/
|
||||
)
|
||||
if (!rgbMatch) return rgbStr || "#FFFFFF"
|
||||
|
||||
const r = parseInt(rgbMatch[1])
|
||||
const g = parseInt(rgbMatch[2])
|
||||
const b = parseInt(rgbMatch[3])
|
||||
|
||||
return `#${((1 << 24) | (r << 16) | (g << 8) | b)
|
||||
.toString(16)
|
||||
.slice(1)
|
||||
.toUpperCase()}`
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
},
|
||||
},
|
||||
[FIELDS.OPTIONS.type]: {
|
||||
label: "Options",
|
||||
label: "Single select",
|
||||
value: FIELDS.OPTIONS.type,
|
||||
config: {
|
||||
type: FIELDS.OPTIONS.type,
|
||||
|
@ -46,7 +46,7 @@
|
|||
},
|
||||
},
|
||||
[FIELDS.ARRAY.type]: {
|
||||
label: "Multi-select",
|
||||
label: "Multi select",
|
||||
value: FIELDS.ARRAY.type,
|
||||
config: {
|
||||
type: FIELDS.ARRAY.type,
|
||||
|
|
|
@ -29,6 +29,11 @@
|
|||
url={$url("./embed")}
|
||||
active={$isActive("./embed")}
|
||||
/>
|
||||
<SideNavItem
|
||||
text="Progressive Web App"
|
||||
url={$url("./pwa")}
|
||||
active={$isActive("./pwa")}
|
||||
/>
|
||||
<SideNavItem
|
||||
text="Export/Import"
|
||||
url={$url("./exportImport")}
|
||||
|
|
|
@ -0,0 +1,300 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
Layout,
|
||||
Divider,
|
||||
Heading,
|
||||
Body,
|
||||
Input,
|
||||
Icon,
|
||||
ColorPicker,
|
||||
Button,
|
||||
Label,
|
||||
File,
|
||||
notifications,
|
||||
Select,
|
||||
Tags,
|
||||
Tag,
|
||||
Helpers,
|
||||
} from "@budibase/bbui"
|
||||
import { appStore } from "@/stores/builder"
|
||||
import { licensing } from "@/stores/portal"
|
||||
import { API } from "@/api"
|
||||
|
||||
const DISPLAY_OPTIONS = [
|
||||
{ label: "Standalone", value: "standalone" },
|
||||
{ label: "Fullscreen", value: "fullscreen" },
|
||||
{ label: "Minimal UI", value: "minimal-ui" },
|
||||
]
|
||||
|
||||
let pwaEnabled = $licensing.pwaEnabled
|
||||
let uploadingIcons = false
|
||||
|
||||
let pwaConfig = $appStore.pwa || {
|
||||
name: "",
|
||||
short_name: "",
|
||||
description: "",
|
||||
icons: [],
|
||||
screenshots: [],
|
||||
background_color: "#FFFFFF",
|
||||
theme_color: "#FFFFFF",
|
||||
display: "standalone",
|
||||
start_url: "",
|
||||
}
|
||||
|
||||
$: iconCount = pwaConfig.icons?.length || 0
|
||||
$: iconStatusText = iconCount ? `${iconCount} icons uploaded` : undefined
|
||||
|
||||
function getCssVariableValue(cssVar: string) {
|
||||
try {
|
||||
if (cssVar?.startsWith("#")) return cssVar
|
||||
|
||||
const varMatch = cssVar?.match(/var\((.*?)\)/)
|
||||
if (!varMatch) return "#FFFFFF"
|
||||
|
||||
const varName = varMatch?.[1]
|
||||
const cssValue =
|
||||
varName &&
|
||||
getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim()
|
||||
|
||||
return Helpers.rgbToHex(cssValue || "#FFFFFF")
|
||||
} catch (error) {
|
||||
console.error("Error converting CSS variable:", error)
|
||||
return "#FFFFFF"
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePWAZip(file: File) {
|
||||
if (!file) {
|
||||
notifications.error("No file selected")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
uploadingIcons = true
|
||||
const data = new FormData()
|
||||
data.append("file", file as any)
|
||||
const result = await API.uploadPWAZip(data)
|
||||
|
||||
pwaConfig.icons = result.icons
|
||||
notifications.success(`Processed ${pwaConfig.icons.length} icons`)
|
||||
} catch (error: any) {
|
||||
notifications.error("Failed to process zip: " + error.message)
|
||||
} finally {
|
||||
uploadingIcons = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const pwaConfigToSave = {
|
||||
...pwaConfig,
|
||||
background_color: getCssVariableValue(pwaConfig.background_color),
|
||||
theme_color: getCssVariableValue(pwaConfig.theme_color),
|
||||
}
|
||||
|
||||
await API.saveAppMetadata($appStore.appId, { pwa: pwaConfigToSave })
|
||||
appStore.update(state => ({ ...state, pwa: pwaConfigToSave }))
|
||||
|
||||
notifications.success("PWA settings saved successfully")
|
||||
} catch (error) {
|
||||
notifications.error("Error saving PWA settings")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Layout noPadding>
|
||||
<Layout gap="XS" noPadding>
|
||||
<div class="title-section">
|
||||
<Heading>Progressive Web App</Heading>
|
||||
{#if !pwaEnabled}
|
||||
<Tags>
|
||||
<Tag icon="LockClosed">Enterprise</Tag>
|
||||
</Tags>
|
||||
{/if}
|
||||
</div>
|
||||
<Body>
|
||||
Transform your app into an installable, app-like experience with a
|
||||
Progressive Web App (PWA). Developers can configure app details and
|
||||
visuals to create a branded, professional experience for their users.
|
||||
</Body>
|
||||
</Layout>
|
||||
<Divider />
|
||||
|
||||
<div class="form" class:disabled={!pwaEnabled}>
|
||||
<div class="fields">
|
||||
<!-- App Details Section -->
|
||||
<div class="section">
|
||||
<Heading size="S">App details</Heading>
|
||||
<Body size="S">
|
||||
Define the identity of your app, including its name, description, and
|
||||
how it will appear to users when installed.
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">App name</Label>
|
||||
<div>
|
||||
<Input
|
||||
bind:value={pwaConfig.name}
|
||||
placeholder="Full name of your app"
|
||||
disabled={!pwaEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Short name</Label>
|
||||
<div>
|
||||
<Input
|
||||
bind:value={pwaConfig.short_name}
|
||||
placeholder="Short name for app icon"
|
||||
disabled={!pwaEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Description</Label>
|
||||
<div>
|
||||
<Input
|
||||
bind:value={pwaConfig.description}
|
||||
placeholder="Describe your app"
|
||||
disabled={!pwaEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<!-- Appearance Section -->
|
||||
<div class="section">
|
||||
<Heading size="S">Appearance</Heading>
|
||||
<Body size="S">
|
||||
Make your app visually appealing with a custom icon and theme. These
|
||||
settings control how your app appears on splash screens and device
|
||||
interfaces.
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div
|
||||
style="display: flex; align-items: center; gap: var(--spacing-xs);"
|
||||
>
|
||||
<Label size="L">App icons</Label>
|
||||
<Icon
|
||||
size="XS"
|
||||
name="Info"
|
||||
tooltip="Please check our docs for details on a valid ZIP file"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<File
|
||||
title="Upload zip"
|
||||
handleFileTooLarge={() =>
|
||||
notifications.error("File too large. 20mb limit")}
|
||||
extensions={[".zip"]}
|
||||
on:change={e => e.detail && handlePWAZip(e.detail)}
|
||||
statusText={iconStatusText}
|
||||
disabled={!pwaEnabled || uploadingIcons}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Background color</Label>
|
||||
<div>
|
||||
<ColorPicker
|
||||
value={pwaConfig.background_color}
|
||||
on:change={e => (pwaConfig.background_color = e.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Theme color</Label>
|
||||
<div>
|
||||
<ColorPicker
|
||||
value={pwaConfig.theme_color}
|
||||
on:change={e => (pwaConfig.theme_color = e.detail)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Divider />
|
||||
|
||||
<!-- Manifest Settings Section -->
|
||||
<div class="section">
|
||||
<Heading size="S">Manifest settings</Heading>
|
||||
<Body size="S">
|
||||
The manifest settings control how your app behaves once installed.
|
||||
These settings define how it appears on the user's device. Configuring
|
||||
these fields ensures your app is treated as a native-like application.
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<Label size="L">Display mode</Label>
|
||||
<div>
|
||||
<Select
|
||||
bind:value={pwaConfig.display}
|
||||
options={DISPLAY_OPTIONS}
|
||||
disabled={!pwaEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button cta on:click={handleSubmit} disabled={!pwaEnabled}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<style>
|
||||
.form {
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-l);
|
||||
opacity: var(--form-opacity, 1);
|
||||
}
|
||||
|
||||
.disabled .fields {
|
||||
--form-opacity: 0.2;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
grid-gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.field > div {
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--spacing-m);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
</style>
|
|
@ -7,6 +7,7 @@ import {
|
|||
AppScript,
|
||||
AutomationSettings,
|
||||
Plugin,
|
||||
PWAManifest,
|
||||
UpdateAppRequest,
|
||||
} from "@budibase/types"
|
||||
import { get } from "svelte/store"
|
||||
|
@ -49,6 +50,7 @@ interface AppMetaState {
|
|||
revertableVersion?: string
|
||||
upgradableVersion?: string
|
||||
icon?: AppIcon
|
||||
pwa?: PWAManifest
|
||||
scripts: AppScript[]
|
||||
}
|
||||
|
||||
|
@ -83,6 +85,16 @@ export const INITIAL_APP_META_STATE: AppMetaState = {
|
|||
usedPlugins: [],
|
||||
automations: {},
|
||||
routes: {},
|
||||
pwa: {
|
||||
name: "",
|
||||
short_name: "",
|
||||
description: "",
|
||||
icons: [],
|
||||
background_color: "",
|
||||
theme_color: "",
|
||||
start_url: "",
|
||||
screenshots: [],
|
||||
},
|
||||
scripts: [],
|
||||
}
|
||||
|
||||
|
@ -115,6 +127,7 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
|||
initialised: true,
|
||||
automations: app.automations || {},
|
||||
hasAppPackage: true,
|
||||
pwa: app.pwa,
|
||||
scripts: app.scripts || [],
|
||||
}))
|
||||
}
|
||||
|
@ -171,6 +184,17 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
|||
name,
|
||||
url,
|
||||
icon,
|
||||
pwa: {
|
||||
...state.pwa,
|
||||
name: state.pwa?.name || "",
|
||||
short_name: state.pwa?.short_name || "",
|
||||
description: state.pwa?.description || "",
|
||||
icons: state.pwa?.icons || [],
|
||||
background_color: state.pwa?.background_color || "",
|
||||
theme_color: state.pwa?.theme_color || "",
|
||||
start_url: state.pwa?.start_url || "",
|
||||
screenshots: state.pwa?.screenshots || [],
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,7 @@ describe("Application Meta Store", () => {
|
|||
appId,
|
||||
url,
|
||||
features,
|
||||
pwa,
|
||||
componentLibraries,
|
||||
} = app
|
||||
|
||||
|
@ -88,6 +89,7 @@ describe("Application Meta Store", () => {
|
|||
hasLock,
|
||||
initialised: true,
|
||||
hasAppPackage: true,
|
||||
pwa,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -257,6 +257,7 @@ export const generateAppPackage = ({
|
|||
icon: {},
|
||||
type: "app",
|
||||
},
|
||||
pwa: {},
|
||||
clientLibPath: `https://cdn.budibase.net/${appId}/budibase-client.js?v=${version}`,
|
||||
hasLock: true,
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@ interface LicensingState {
|
|||
groupsEnabled: boolean
|
||||
backupsEnabled: boolean
|
||||
brandingEnabled: boolean
|
||||
pwaEnabled: boolean
|
||||
scimEnabled: boolean
|
||||
environmentVariablesEnabled: boolean
|
||||
budibaseAIEnabled: boolean
|
||||
|
@ -74,6 +75,7 @@ class LicensingStore extends BudiStore<LicensingState> {
|
|||
groupsEnabled: false,
|
||||
backupsEnabled: false,
|
||||
brandingEnabled: false,
|
||||
pwaEnabled: false,
|
||||
scimEnabled: false,
|
||||
environmentVariablesEnabled: false,
|
||||
budibaseAIEnabled: false,
|
||||
|
@ -172,6 +174,7 @@ class LicensingStore extends BudiStore<LicensingState> {
|
|||
)
|
||||
const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO)
|
||||
const brandingEnabled = features.includes(Constants.Features.BRANDING)
|
||||
const pwaEnabled = features.includes(Constants.Features.PWA)
|
||||
const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS)
|
||||
const syncAutomationsEnabled = features.includes(
|
||||
Constants.Features.SYNC_AUTOMATIONS
|
||||
|
@ -201,6 +204,7 @@ class LicensingStore extends BudiStore<LicensingState> {
|
|||
groupsEnabled,
|
||||
backupsEnabled,
|
||||
brandingEnabled,
|
||||
pwaEnabled,
|
||||
budibaseAIEnabled,
|
||||
customAIConfigsEnabled,
|
||||
scimEnabled,
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
|
||||
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
|
||||
import PeekScreenDisplay from "./overlay/PeekScreenDisplay.svelte"
|
||||
import InstallPrompt from "./overlay/InstallPrompt.svelte"
|
||||
import UserBindingsProvider from "./context/UserBindingsProvider.svelte"
|
||||
import DeviceBindingsProvider from "./context/DeviceBindingsProvider.svelte"
|
||||
import StateBindingsProvider from "./context/StateBindingsProvider.svelte"
|
||||
|
@ -246,6 +247,7 @@
|
|||
<NotificationDisplay />
|
||||
<ConfirmationDisplay />
|
||||
<PeekScreenDisplay />
|
||||
<InstallPrompt />
|
||||
</CustomThemeWrapper>
|
||||
{/if}
|
||||
|
||||
|
|
|
@ -0,0 +1,90 @@
|
|||
<script>
|
||||
import { onMount } from "svelte"
|
||||
import { appStore, featuresStore } from "@/stores"
|
||||
|
||||
const STORAGE_KEY_PREFIX = "pwa-install-declined"
|
||||
|
||||
let showButton = false
|
||||
$: pwaEnabled = $featuresStore.pwaEnabled
|
||||
function checkForDeferredPrompt() {
|
||||
if (!pwaEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
const appId = $appStore.appId
|
||||
const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`
|
||||
|
||||
if (localStorage.getItem(storageKey) === "true") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && window.deferredPwaPrompt) {
|
||||
showButton = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function installPWA() {
|
||||
if (!window.deferredPwaPrompt) return
|
||||
|
||||
window.deferredPwaPrompt.prompt()
|
||||
const { outcome } = await window.deferredPwaPrompt.userChoice
|
||||
|
||||
if (outcome === "accepted") {
|
||||
showButton = false
|
||||
} else if (outcome === "dismissed") {
|
||||
const appId = $appStore.appId
|
||||
const storageKey = `${STORAGE_KEY_PREFIX}-${appId}`
|
||||
localStorage.setItem(storageKey, "true")
|
||||
showButton = false
|
||||
}
|
||||
|
||||
window.deferredPwaPrompt = null
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
await navigator.serviceWorker.register("/app/service-worker.js", {
|
||||
scope: "/app/",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Service worker registration failed:", error)
|
||||
}
|
||||
}
|
||||
|
||||
checkForDeferredPrompt()
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if showButton}
|
||||
<div class="install-prompt">
|
||||
<button class="openMenu" on:click={installPWA}>Install app</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.install-prompt {
|
||||
position: fixed;
|
||||
bottom: 5px;
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.openMenu {
|
||||
cursor: pointer;
|
||||
background-color: var(--bb-indigo);
|
||||
border-radius: 100px;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 10px 18px;
|
||||
transition: background-color 130ms ease-out;
|
||||
}
|
||||
|
||||
.openMenu:hover {
|
||||
background-color: var(--bb-indigo-light);
|
||||
}
|
||||
</style>
|
|
@ -34,6 +34,14 @@ import { APIClient } from "@budibase/frontend-core"
|
|||
import BlockComponent from "./components/BlockComponent.svelte"
|
||||
import Block from "./components/Block.svelte"
|
||||
|
||||
// Set up global PWA install prompt handler
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("beforeinstallprompt", e => {
|
||||
e.preventDefault()
|
||||
window.deferredPwaPrompt = e
|
||||
})
|
||||
}
|
||||
|
||||
// Provide svelte and svelte/internal as globals for custom components
|
||||
import * as svelte from "svelte"
|
||||
// @ts-ignore
|
||||
|
@ -69,6 +77,9 @@ declare global {
|
|||
// Other flags
|
||||
MIGRATING_APP: boolean
|
||||
|
||||
// PWA install prompt
|
||||
deferredPwaPrompt: any
|
||||
|
||||
// Client additions
|
||||
handleBuilderRuntimeEvent: (type: string, data: any) => void
|
||||
registerCustomComponent: typeof componentStore.actions.registerCustomComponent
|
||||
|
|
|
@ -41,6 +41,7 @@ const createFeaturesStore = () => {
|
|||
aiEnabled:
|
||||
license?.features?.includes(Feature.AI_CUSTOM_CONFIGS) ||
|
||||
license?.features?.includes(Feature.BUDIBASE_AI),
|
||||
pwaEnabled: license?.features?.includes(Feature.PWA),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -22,6 +22,9 @@ export interface AttachmentEndpoints {
|
|||
data: any
|
||||
) => Promise<ProcessAttachmentResponse>
|
||||
uploadBuilderAttachment: (data: any) => Promise<ProcessAttachmentResponse>
|
||||
uploadPWAZip: (
|
||||
data: FormData
|
||||
) => Promise<{ icons: Array<{ src: string; sizes: string; type: string }> }>
|
||||
externalUpload: (
|
||||
datasourceId: string,
|
||||
bucket: string,
|
||||
|
@ -79,6 +82,14 @@ export const buildAttachmentEndpoints = (
|
|||
})
|
||||
},
|
||||
|
||||
uploadPWAZip: async data => {
|
||||
return await API.post({
|
||||
url: "/api/pwa/process-zip",
|
||||
body: data,
|
||||
json: false,
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Uploads a file to an external datasource.
|
||||
* @param datasourceId the ID of the datasource to upload to
|
||||
|
|
|
@ -88,6 +88,7 @@
|
|||
"dayjs": "^1.10.8",
|
||||
"dd-trace": "5.43.0",
|
||||
"dotenv": "8.2.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "4.0.0",
|
||||
"global-agent": "3.0.0",
|
||||
"google-auth-library": "^8.0.1",
|
||||
|
|
|
@ -265,6 +265,13 @@ export async function fetchAppPackage(
|
|||
application.usedPlugins
|
||||
)
|
||||
|
||||
// Enrich PWA icon URLs if they exist
|
||||
if (application.pwa?.icons && application.pwa.icons.length > 0) {
|
||||
application.pwa.icons = await objectStore.enrichPWAImages(
|
||||
application.pwa.icons
|
||||
)
|
||||
}
|
||||
|
||||
// Only filter screens if the user is not a builder
|
||||
if (!users.isBuilder(ctx.user, appId)) {
|
||||
const userRoleId = getUserRoleId(ctx)
|
||||
|
@ -867,6 +874,18 @@ export async function updateAppPackage(
|
|||
newAppPackage._rev = application._rev
|
||||
}
|
||||
|
||||
// Make sure that when saving down pwa settings, we don't override the keys with the enriched url
|
||||
if (appPackage.pwa && application.pwa) {
|
||||
if (appPackage.pwa.icons) {
|
||||
appPackage.pwa.icons = appPackage.pwa.icons.map((icon, i) =>
|
||||
icon.src.startsWith(objectStore.SIGNED_FILE_PREFIX) &&
|
||||
application?.pwa?.icons?.[i]
|
||||
? { ...icon, src: application?.pwa?.icons?.[i].src }
|
||||
: icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// the locked by property is attached by server but generated from
|
||||
// Redis, shouldn't ever store it
|
||||
delete newAppPackage.lockedBy
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import { getQueryParams, getTableParams } from "../../db/utils"
|
||||
import { getIntegration } from "../../integrations"
|
||||
import { invalidateCachedVariable } from "../../threads/utils"
|
||||
import { context, db as dbCore, events } from "@budibase/backend-core"
|
||||
import {
|
||||
|
@ -174,14 +173,6 @@ export async function update(
|
|||
await events.datasource.updated(datasource)
|
||||
datasource._rev = response.rev
|
||||
|
||||
// Drain connection pools when configuration is changed
|
||||
if (datasource.source && !isBudibaseSource) {
|
||||
const source = await getIntegration(datasource.source)
|
||||
if (source && source.pool) {
|
||||
await source.pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.message = "Datasource saved successfully."
|
||||
ctx.body = {
|
||||
datasource: await sdk.datasources.removeSecretSingle(datasource),
|
||||
|
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||
import { PutObjectCommand, S3 } from "@aws-sdk/client-s3"
|
||||
import fs from "fs"
|
||||
import fsp from "fs/promises"
|
||||
import sdk from "../../../sdk"
|
||||
import * as pro from "@budibase/pro"
|
||||
import {
|
||||
|
@ -32,6 +33,7 @@ import {
|
|||
GetSignedUploadUrlRequest,
|
||||
GetSignedUploadUrlResponse,
|
||||
ProcessAttachmentResponse,
|
||||
PWAManifest,
|
||||
ServeAppResponse,
|
||||
ServeBuilderPreviewResponse,
|
||||
ServeClientLibraryResponse,
|
||||
|
@ -45,6 +47,9 @@ import {
|
|||
|
||||
import send from "koa-send"
|
||||
import { getThemeVariables } from "../../../constants/themes"
|
||||
import path from "path"
|
||||
import extract from "extract-zip"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
export const toggleBetaUiFeature = async function (
|
||||
ctx: Ctx<void, ToggleBetaFeatureResponse>
|
||||
|
@ -133,6 +138,83 @@ export const uploadFile = async function (
|
|||
)
|
||||
}
|
||||
|
||||
export async function processPWAZip(ctx: UserCtx) {
|
||||
const file = ctx.request.files?.file
|
||||
if (!file || Array.isArray(file)) {
|
||||
ctx.throw(400, "No file or multiple files provided")
|
||||
}
|
||||
|
||||
if (!file.path || !file.name?.toLowerCase().endsWith(".zip")) {
|
||||
ctx.throw(400, "Invalid file - must be a zip file")
|
||||
}
|
||||
|
||||
const tempDir = join(tmpdir(), `pwa-${Date.now()}`)
|
||||
try {
|
||||
await fsp.mkdir(tempDir, { recursive: true })
|
||||
|
||||
await extract(file.path, { dir: tempDir })
|
||||
const iconsJsonPath = join(tempDir, "icons.json")
|
||||
|
||||
if (!fs.existsSync(iconsJsonPath)) {
|
||||
ctx.throw(400, "Invalid zip structure - missing icons.json")
|
||||
}
|
||||
|
||||
let iconsData
|
||||
try {
|
||||
const iconsContent = await fsp.readFile(iconsJsonPath, "utf-8")
|
||||
iconsData = JSON.parse(iconsContent)
|
||||
} catch (error) {
|
||||
ctx.throw(400, "Invalid icons.json file - could not parse JSON")
|
||||
}
|
||||
|
||||
if (!iconsData.icons || !Array.isArray(iconsData.icons)) {
|
||||
ctx.throw(400, "Invalid icons.json file - missing icons array")
|
||||
}
|
||||
|
||||
const icons = []
|
||||
const baseDir = path.dirname(iconsJsonPath)
|
||||
const appId = context.getProdAppId()
|
||||
|
||||
for (const icon of iconsData.icons) {
|
||||
if (!icon.src || !icon.sizes || !fs.existsSync(join(baseDir, icon.src))) {
|
||||
continue
|
||||
}
|
||||
|
||||
const extension = path.extname(icon.src) || ".png"
|
||||
const key = `${appId}/pwa/${uuid.v4()}${extension}`
|
||||
const mimeType =
|
||||
icon.type || (extension === ".png" ? "image/png" : "image/jpeg")
|
||||
|
||||
try {
|
||||
const result = await objectStore.upload({
|
||||
bucket: ObjectStoreBuckets.APPS,
|
||||
filename: key,
|
||||
path: join(baseDir, icon.src),
|
||||
type: mimeType,
|
||||
})
|
||||
|
||||
if (result.Key) {
|
||||
icons.push({
|
||||
src: result.Key,
|
||||
sizes: icon.sizes,
|
||||
type: mimeType,
|
||||
})
|
||||
}
|
||||
} catch (uploadError) {
|
||||
throw new Error(`Failed to upload icon ${icon.src}: ${uploadError}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (icons.length === 0) {
|
||||
ctx.throw(400, "No valid icons found in the zip file")
|
||||
}
|
||||
|
||||
ctx.body = { icons }
|
||||
} catch (error: any) {
|
||||
ctx.throw(500, `Error processing zip: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const requiresMigration = async (ctx: Ctx) => {
|
||||
const appId = context.getAppId()
|
||||
if (!appId) {
|
||||
|
@ -199,7 +281,9 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
|
|||
const sideNav = appInfo.navigation.navigation === "Left"
|
||||
const hideFooter =
|
||||
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
|
||||
const themeVariables = getThemeVariables(appInfo?.theme)
|
||||
const themeVariables = getThemeVariables(appInfo?.theme || {})
|
||||
const hasPWA = Object.keys(appInfo.pwa || {}).length > 0
|
||||
const manifestUrl = hasPWA ? `/api/apps/${appId}/manifest.json` : ""
|
||||
const addAppScripts =
|
||||
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
|
||||
false
|
||||
|
@ -243,8 +327,45 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
|
|||
|
||||
const { head, html, css } = AppComponent.render({ props })
|
||||
const appHbs = loadHandlebarsFile(appHbsPath)
|
||||
|
||||
let extraHead = ""
|
||||
const pwaEnabled = await pro.features.isPWAEnabled()
|
||||
if (hasPWA && pwaEnabled) {
|
||||
extraHead = `<link rel="manifest" href="${manifestUrl}">`
|
||||
extraHead += `<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content=${
|
||||
appInfo.pwa.theme_color
|
||||
}>
|
||||
<meta name="apple-mobile-web-app-title" content="${
|
||||
appInfo.pwa.short_name || appInfo.name
|
||||
}">`
|
||||
|
||||
if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) {
|
||||
try {
|
||||
// Enrich all icons
|
||||
const enrichedIcons = await objectStore.enrichPWAImages(
|
||||
appInfo.pwa.icons
|
||||
)
|
||||
|
||||
let appleTouchIcon = enrichedIcons.find(
|
||||
icon => icon.sizes === "180x180"
|
||||
)
|
||||
|
||||
if (!appleTouchIcon && enrichedIcons.length > 0) {
|
||||
appleTouchIcon = enrichedIcons[0]
|
||||
}
|
||||
|
||||
if (appleTouchIcon) {
|
||||
extraHead += `<link rel="apple-touch-icon" sizes="${appleTouchIcon.sizes}" href="${appleTouchIcon.src}">`
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Error enriching PWA icons: " + error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = await processString(appHbs, {
|
||||
head,
|
||||
head: `${head}${extraHead}`,
|
||||
body: html,
|
||||
css: `:root{${themeVariables}} ${css.code}`,
|
||||
appId,
|
||||
|
@ -346,6 +467,16 @@ export const serveClientLibrary = async function (
|
|||
}
|
||||
}
|
||||
|
||||
export const serveServiceWorker = async function (ctx: Ctx) {
|
||||
const serviceWorkerContent = `
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});`
|
||||
|
||||
ctx.set("Content-Type", "application/javascript")
|
||||
ctx.body = serviceWorkerContent
|
||||
}
|
||||
|
||||
export const getSignedUploadURL = async function (
|
||||
ctx: Ctx<GetSignedUploadUrlRequest, GetSignedUploadUrlResponse>
|
||||
) {
|
||||
|
@ -397,3 +528,70 @@ export const getSignedUploadURL = async function (
|
|||
|
||||
ctx.body = { signedUrl, publicUrl }
|
||||
}
|
||||
|
||||
export async function servePwaManifest(ctx: UserCtx<void, any>) {
|
||||
const appId = context.getAppId()
|
||||
if (!appId) {
|
||||
ctx.throw(404)
|
||||
}
|
||||
|
||||
try {
|
||||
const db = context.getAppDB({ skip_setup: true })
|
||||
const appInfo = await db.get<App>(DocumentType.APP_METADATA)
|
||||
|
||||
if (!appInfo.pwa) {
|
||||
ctx.throw(404)
|
||||
}
|
||||
|
||||
const manifest: PWAManifest = {
|
||||
name: appInfo.pwa.name || appInfo.name,
|
||||
short_name: appInfo.pwa.short_name || appInfo.name,
|
||||
description: appInfo.pwa.description || "",
|
||||
start_url: `/app${appInfo.url}`,
|
||||
display: appInfo.pwa.display || "standalone",
|
||||
background_color: appInfo.pwa.background_color || "#FFFFFF",
|
||||
theme_color: appInfo.pwa.theme_color || "#FFFFFF",
|
||||
icons: [],
|
||||
screenshots: [],
|
||||
}
|
||||
|
||||
if (appInfo.pwa.icons && appInfo.pwa.icons.length > 0) {
|
||||
try {
|
||||
manifest.icons = await objectStore.enrichPWAImages(appInfo.pwa.icons)
|
||||
|
||||
const desktopScreenshot = manifest.icons.find(
|
||||
icon => icon.sizes === "1240x600" || icon.sizes === "2480x1200"
|
||||
)
|
||||
if (desktopScreenshot) {
|
||||
manifest.screenshots.push({
|
||||
src: desktopScreenshot.src,
|
||||
sizes: desktopScreenshot.sizes,
|
||||
type: "image/png",
|
||||
form_factor: "wide",
|
||||
label: "Desktop view",
|
||||
})
|
||||
}
|
||||
|
||||
const mobileScreenshot = manifest.icons.find(
|
||||
icon => icon.sizes === "620x620" || icon.sizes === "1024x1024"
|
||||
)
|
||||
if (mobileScreenshot) {
|
||||
manifest.screenshots.push({
|
||||
src: mobileScreenshot.src,
|
||||
sizes: mobileScreenshot.sizes,
|
||||
type: "image/png",
|
||||
label: "Mobile view",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Error processing manifest icons: " + error)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.set("Content-Type", "application/json")
|
||||
ctx.body = manifest
|
||||
} catch (error) {
|
||||
ctx.status = 500
|
||||
ctx.body = { message: "Error generating manifest" }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,9 @@ addFileManagement(router)
|
|||
|
||||
router
|
||||
.get("/api/assets/client", controller.serveClientLibrary)
|
||||
.get("/api/apps/:appId/manifest.json", controller.servePwaManifest)
|
||||
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
|
||||
.post("/api/pwa/process-zip", authorized(BUILDER), controller.processPWAZip)
|
||||
.post("/api/beta/:feature", controller.toggleBetaUiFeature)
|
||||
.post(
|
||||
"/api/attachments/:tableId/upload",
|
||||
|
@ -22,6 +24,7 @@ router
|
|||
controller.uploadFile
|
||||
)
|
||||
.get("/app/preview", authorized(BUILDER), controller.serveBuilderPreview)
|
||||
.get("/app/service-worker.js", controller.serveServiceWorker)
|
||||
.get("/app/:appUrl/:path*", controller.serveApp)
|
||||
.get("/:appId/:path*", controller.serveApp)
|
||||
.post(
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
import { context, objectStore } from "@budibase/backend-core"
|
||||
import { App, DocumentType } from "@budibase/types"
|
||||
import { getRequest, getConfig, afterAll as _afterAll } from "./utilities"
|
||||
|
||||
describe("PWA Manifest", () => {
|
||||
let request = getRequest()
|
||||
let config = getConfig()
|
||||
|
||||
afterAll(() => {
|
||||
_afterAll()
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
it("should serve a valid manifest.json with properly configured PWA", async () => {
|
||||
await context.doInAppContext(config.getAppId(), async () => {
|
||||
const appDb = context.getAppDB()
|
||||
let appDoc = await appDb.get<App>(DocumentType.APP_METADATA)
|
||||
|
||||
const pwaConfig = {
|
||||
name: "Test App",
|
||||
short_name: "TestApp",
|
||||
description: "Test app description",
|
||||
background_color: "#FFFFFF",
|
||||
theme_color: "#4285F4",
|
||||
display: "standalone",
|
||||
icons: [
|
||||
{
|
||||
src: `${config.appId}/pwa/icon-small.png`,
|
||||
sizes: "144x144",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: `${config.appId}/pwa/icon-large.png`,
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: `${config.appId}/pwa/icon-screenshot.png`,
|
||||
sizes: "1240x600",
|
||||
type: "image/png",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// Upload fake icons to minio
|
||||
await objectStore.upload({
|
||||
bucket: "apps",
|
||||
filename: `${config.appId}/pwa/icon-small.png`,
|
||||
body: Buffer.from("fake-image-data"),
|
||||
type: "image/png",
|
||||
})
|
||||
|
||||
await objectStore.upload({
|
||||
bucket: "apps",
|
||||
filename: `${config.appId}/pwa/icon-large.png`,
|
||||
body: Buffer.from("fake-image-data"),
|
||||
type: "image/png",
|
||||
})
|
||||
|
||||
await appDb.put({
|
||||
...appDoc,
|
||||
pwa: pwaConfig,
|
||||
})
|
||||
|
||||
const res = await request
|
||||
.get(`/api/apps/${config.appId}/manifest.json`)
|
||||
.set(config.defaultHeaders())
|
||||
.expect("Content-Type", /json/)
|
||||
.expect(200)
|
||||
|
||||
expect(res.body).toMatchObject({
|
||||
name: "Test App",
|
||||
short_name: "TestApp",
|
||||
description: "Test app description",
|
||||
background_color: "#FFFFFF",
|
||||
theme_color: "#4285F4",
|
||||
display: "standalone",
|
||||
start_url: expect.stringContaining(appDoc.url!),
|
||||
icons: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
sizes: "144x144",
|
||||
type: "image/png",
|
||||
src: expect.stringContaining("icon-small.png"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
src: expect.stringContaining("icon-large.png"),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
expect(res.body.screenshots.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -761,7 +761,7 @@ if (descriptions.length) {
|
|||
})
|
||||
|
||||
it("should throw an error if the incorrect actionType is specified", async () => {
|
||||
const verbs = ["read", "create", "update", "delete"]
|
||||
const verbs = ["read", "create", "update", "delete"] as const
|
||||
for (const verb of verbs) {
|
||||
const query = await createQuery({
|
||||
// @ts-expect-error
|
||||
|
|
|
@ -3,7 +3,7 @@ import {
|
|||
EnrichedQueryJson,
|
||||
QueryJson,
|
||||
} from "@budibase/types"
|
||||
import { getIntegration } from "../index"
|
||||
import { getIntegration, isDatasourcePlusConstructor } from "../index"
|
||||
import sdk from "../../sdk"
|
||||
import { enrichQueryJson } from "../../sdk/app/rows/utils"
|
||||
|
||||
|
@ -29,8 +29,7 @@ export async function makeExternalQuery(
|
|||
|
||||
const Integration = await getIntegration(json.datasource.source)
|
||||
|
||||
// query is the opinionated function
|
||||
if (!Integration.prototype.query) {
|
||||
if (!isDatasourcePlusConstructor(Integration)) {
|
||||
throw "Datasource does not support query."
|
||||
}
|
||||
|
||||
|
|
|
@ -626,7 +626,7 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
|
|||
sheet: string
|
||||
rowIndex: number
|
||||
row: any
|
||||
table: Table
|
||||
table?: Table
|
||||
}) {
|
||||
try {
|
||||
await this.connect()
|
||||
|
@ -644,13 +644,15 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
|
|||
row.set(key, "")
|
||||
}
|
||||
|
||||
const { type, subtype, constraints } = query.table.schema[key]
|
||||
const isDeprecatedSingleUser =
|
||||
type === FieldType.BB_REFERENCE &&
|
||||
subtype === BBReferenceFieldSubType.USER &&
|
||||
constraints?.type !== "array"
|
||||
if (isDeprecatedSingleUser && Array.isArray(row.get(key))) {
|
||||
row.set(key, row.get(key)[0])
|
||||
if (query.table) {
|
||||
const { type, subtype, constraints } = query.table.schema[key]
|
||||
const isDeprecatedSingleUser =
|
||||
type === FieldType.BB_REFERENCE &&
|
||||
subtype === BBReferenceFieldSubType.USER &&
|
||||
constraints?.type !== "array"
|
||||
if (isDeprecatedSingleUser && Array.isArray(row.get(key))) {
|
||||
row.set(key, row.get(key)[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
await row.save()
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
Integration,
|
||||
PluginType,
|
||||
IntegrationBase,
|
||||
DatasourcePlus,
|
||||
} from "@budibase/types"
|
||||
import { getDatasourcePlugin } from "../utilities/fileSystem"
|
||||
import env from "../environment"
|
||||
|
@ -48,6 +49,13 @@ const DEFINITIONS: Record<SourceName, Integration | undefined> = {
|
|||
}
|
||||
|
||||
type IntegrationBaseConstructor = new (...args: any[]) => IntegrationBase
|
||||
type DatasourcePlusConstructor = new (...args: any[]) => DatasourcePlus
|
||||
|
||||
export function isDatasourcePlusConstructor(
|
||||
integration: IntegrationBaseConstructor
|
||||
): integration is DatasourcePlusConstructor {
|
||||
return !!integration.prototype.query
|
||||
}
|
||||
|
||||
const INTEGRATIONS: Record<SourceName, IntegrationBaseConstructor | undefined> =
|
||||
{
|
||||
|
@ -106,7 +114,9 @@ export async function getDefinitions() {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getIntegration(integration: SourceName) {
|
||||
export async function getIntegration(
|
||||
integration: SourceName
|
||||
): Promise<IntegrationBaseConstructor> {
|
||||
if (INTEGRATIONS[integration]) {
|
||||
return INTEGRATIONS[integration]
|
||||
}
|
||||
|
|
|
@ -787,4 +787,143 @@ describe("Google Sheets Integration", () => {
|
|||
expect(ids.length).toEqual(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe("queries", () => {
|
||||
let table: Table
|
||||
let rows: Row[]
|
||||
beforeEach(async () => {
|
||||
table = await config.api.table.save({
|
||||
name: "Test Table",
|
||||
type: "table",
|
||||
sourceId: datasource._id!,
|
||||
sourceType: TableSourceType.EXTERNAL,
|
||||
schema: {
|
||||
name: {
|
||||
name: "name",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
description: {
|
||||
name: "description",
|
||||
type: FieldType.STRING,
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rows = []
|
||||
rows.push(
|
||||
await config.api.row.save(table._id!, {
|
||||
name: "Test Contact 1",
|
||||
description: "original description 1",
|
||||
})
|
||||
)
|
||||
rows.push(
|
||||
await config.api.row.save(table._id!, {
|
||||
name: "Test Contact 2",
|
||||
description: "original description 2",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
describe("read", () => {
|
||||
it("should be able to read data from a sheet", async () => {
|
||||
const { rows } = await config.api.query.preview({
|
||||
fields: {
|
||||
sheet: table.name,
|
||||
},
|
||||
datasourceId: datasource._id!,
|
||||
parameters: [],
|
||||
transformer: null,
|
||||
queryVerb: "read",
|
||||
name: datasource.name!,
|
||||
schema: {},
|
||||
readable: true,
|
||||
})
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].name).toEqual("Test Contact 2")
|
||||
expect(rows[0].description).toEqual("original description 2")
|
||||
expect(rows[1].name).toEqual("Test Contact 1")
|
||||
expect(rows[1].description).toEqual("original description 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
it("should be able to update data in a sheet", async () => {
|
||||
await config.api.query.preview({
|
||||
fields: {
|
||||
sheet: table.name,
|
||||
rowIndex: "2",
|
||||
row: { name: "updated name", description: "updated description" },
|
||||
},
|
||||
datasourceId: datasource._id!,
|
||||
parameters: [],
|
||||
transformer: null,
|
||||
queryVerb: "update",
|
||||
name: datasource.name!,
|
||||
schema: {},
|
||||
readable: true,
|
||||
})
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0].name).toEqual("updated name")
|
||||
expect(rows[0].description).toEqual("updated description")
|
||||
expect(rows[1].name).toEqual("Test Contact 1")
|
||||
expect(rows[1].description).toEqual("original description 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("should be able to create new rows", async () => {
|
||||
await config.api.query.preview({
|
||||
fields: {
|
||||
sheet: table.name,
|
||||
row: { name: "new name", description: "new description" },
|
||||
},
|
||||
datasourceId: datasource._id!,
|
||||
parameters: [],
|
||||
transformer: null,
|
||||
queryVerb: "create",
|
||||
name: datasource.name!,
|
||||
schema: {},
|
||||
readable: true,
|
||||
})
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0].name).toEqual("new name")
|
||||
expect(rows[0].description).toEqual("new description")
|
||||
})
|
||||
})
|
||||
|
||||
describe("delete", () => {
|
||||
it("should be able to delete rows", async () => {
|
||||
await config.api.query.preview({
|
||||
fields: {
|
||||
sheet: table.name,
|
||||
rowIndex: "2",
|
||||
},
|
||||
datasourceId: datasource._id!,
|
||||
parameters: [],
|
||||
transformer: null,
|
||||
queryVerb: "delete",
|
||||
name: datasource.name!,
|
||||
schema: {},
|
||||
readable: true,
|
||||
})
|
||||
|
||||
const rows = await config.api.row.fetch(table._id!)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].name).toEqual("Test Contact 1")
|
||||
expect(rows[0].description).toEqual("original description 1")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -540,9 +540,28 @@ export class GoogleSheetsMock {
|
|||
throw new Error("Only row-based deletes are supported")
|
||||
}
|
||||
|
||||
this.iterateRange(range, cell => {
|
||||
cell.userEnteredValue = this.createValue(null)
|
||||
})
|
||||
const sheet = this.getSheetById(range.sheetId)
|
||||
if (!sheet) {
|
||||
throw new Error(`Sheet ${range.sheetId} not found`)
|
||||
}
|
||||
|
||||
if (range.startRowIndex === undefined || range.endRowIndex === undefined) {
|
||||
throw new Error("Range must have start and end row indexes")
|
||||
}
|
||||
|
||||
const totalRows = sheet.data[0].rowData.length
|
||||
if (totalRows < range.endRowIndex) {
|
||||
throw new Error(
|
||||
`Cannot delete range ${JSON.stringify(range)} from sheet ${
|
||||
sheet.properties.title
|
||||
}. Only ${totalRows} rows exist.`
|
||||
)
|
||||
}
|
||||
|
||||
const rowsToDelete = range.endRowIndex - range.startRowIndex
|
||||
sheet.data[0].rowData.splice(range.startRowIndex, rowsToDelete)
|
||||
sheet.data[0].rowMetadata.splice(range.startRowIndex, rowsToDelete)
|
||||
sheet.properties.gridProperties.rowCount -= rowsToDelete
|
||||
}
|
||||
|
||||
private handleDeleteSheet(request: DeleteSheetRequest) {
|
||||
|
|
|
@ -296,6 +296,9 @@ export async function save(
|
|||
datasource: Datasource,
|
||||
opts?: { fetchSchema?: boolean; tablesFilter?: string[] }
|
||||
): Promise<{ datasource: Datasource; errors: Record<string, string> }> {
|
||||
// getIntegration throws an error if the integration is not found
|
||||
await getIntegration(datasource.source)
|
||||
|
||||
const db = context.getAppDB()
|
||||
const plus = datasource.plus
|
||||
|
||||
|
@ -329,14 +332,6 @@ export async function save(
|
|||
await events.datasource.created(datasource)
|
||||
datasource._rev = dbResp.rev
|
||||
|
||||
// Drain connection pools when configuration is changed
|
||||
if (datasource.source) {
|
||||
const source = await getIntegration(datasource.source)
|
||||
if (source && source.pool) {
|
||||
await source.pool.end()
|
||||
}
|
||||
}
|
||||
|
||||
return { datasource, errors }
|
||||
}
|
||||
|
||||
|
|
|
@ -14,14 +14,21 @@ import { context, cache, auth } from "@budibase/backend-core"
|
|||
import { getGlobalIDFromUserMetadataID } from "../db/utils"
|
||||
import sdk from "../sdk"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { Datasource, Query, SourceName, Row } from "@budibase/types"
|
||||
import {
|
||||
Datasource,
|
||||
Query,
|
||||
SourceName,
|
||||
Row,
|
||||
QueryVerb,
|
||||
DatasourcePlus,
|
||||
} from "@budibase/types"
|
||||
|
||||
import { isSQL } from "../integrations/utils"
|
||||
import { interpolateSQL } from "../integrations/queries/sql"
|
||||
|
||||
class QueryRunner {
|
||||
datasource: Datasource
|
||||
queryVerb: string
|
||||
queryVerb: QueryVerb
|
||||
queryId: string
|
||||
fields: any
|
||||
parameters: any
|
||||
|
@ -116,7 +123,9 @@ class QueryRunner {
|
|||
datasource.source,
|
||||
fieldsClone,
|
||||
enrichedContext,
|
||||
integration,
|
||||
// Bit hacky because currently all of our SQL datasources are
|
||||
// DatasourcePluses.
|
||||
integration as DatasourcePlus,
|
||||
{
|
||||
nullDefaultSupport,
|
||||
}
|
||||
|
@ -130,7 +139,14 @@ class QueryRunner {
|
|||
query.paginationValues = this.pagination
|
||||
}
|
||||
|
||||
let output = threadUtils.formatResponse(await integration[queryVerb](query))
|
||||
const fn = integration[queryVerb]
|
||||
if (!fn) {
|
||||
throw new Error(
|
||||
`Datasource integration does not support verb: ${queryVerb}`
|
||||
)
|
||||
}
|
||||
|
||||
let output = threadUtils.formatResponse(await fn.bind(integration)(query))
|
||||
let rows = output as Row[],
|
||||
info = undefined,
|
||||
extra = undefined,
|
||||
|
@ -199,7 +215,7 @@ class QueryRunner {
|
|||
})
|
||||
const keys: string[] = [...keysSet]
|
||||
|
||||
if (integration.end) {
|
||||
if ("end" in integration && typeof integration.end === "function") {
|
||||
integration.end()
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ export interface App extends Document {
|
|||
snippets?: Snippet[]
|
||||
creationVersion?: string
|
||||
updatedBy?: string
|
||||
pwa?: PWAManifest
|
||||
scripts?: AppScript[]
|
||||
}
|
||||
|
||||
|
@ -84,6 +85,26 @@ export interface AutomationSettings {
|
|||
chainAutomations?: boolean
|
||||
}
|
||||
|
||||
export interface PWAManifest {
|
||||
name: string
|
||||
short_name: string
|
||||
description: string
|
||||
icons: PWAManifestImage[]
|
||||
screenshots: PWAManifestImage[]
|
||||
background_color: string
|
||||
theme_color: string
|
||||
display?: string
|
||||
start_url: string
|
||||
}
|
||||
|
||||
export interface PWAManifestImage {
|
||||
src: string
|
||||
sizes: string
|
||||
type: string
|
||||
form_factor?: "wide" | "narrow" | undefined
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface AppScript {
|
||||
id: string
|
||||
name: string
|
||||
|
|
|
@ -8,16 +8,21 @@ export interface QuerySchema {
|
|||
subtype?: string
|
||||
}
|
||||
|
||||
export type QueryVerb = "read" | "create" | "update" | "delete"
|
||||
|
||||
export interface Query extends Document {
|
||||
datasourceId: string
|
||||
name: string
|
||||
parameters: QueryParameter[]
|
||||
fields: RestQueryFields & SQLQueryFields & MongoQueryFields
|
||||
fields: RestQueryFields &
|
||||
SQLQueryFields &
|
||||
MongoQueryFields &
|
||||
GoogleSheetsQueryFields
|
||||
transformer: string | null
|
||||
schema: Record<string, QuerySchema | string>
|
||||
nestedSchemaFields?: Record<string, Record<string, QuerySchema | string>>
|
||||
readable: boolean
|
||||
queryVerb: string
|
||||
queryVerb: QueryVerb
|
||||
// flag to state whether the default bindings are empty strings (old behaviour) or null
|
||||
nullDefaultSupport?: boolean
|
||||
}
|
||||
|
@ -83,6 +88,12 @@ export interface MongoQueryFields {
|
|||
json?: object | string
|
||||
}
|
||||
|
||||
export interface GoogleSheetsQueryFields {
|
||||
sheet?: string
|
||||
rowIndex?: string
|
||||
row?: Row
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
type: string
|
||||
location: string
|
||||
|
|
20
yarn.lock
20
yarn.lock
|
@ -7237,6 +7237,13 @@
|
|||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@types/yauzl@^2.9.1":
|
||||
version "2.10.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
|
||||
integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.17.0":
|
||||
version "8.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz#2ee073c421f4e81e02d10e731241664b6253b23c"
|
||||
|
@ -11658,6 +11665,17 @@ external-editor@^3.0.3:
|
|||
iconv-lite "^0.4.24"
|
||||
tmp "^0.0.33"
|
||||
|
||||
extract-zip@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a"
|
||||
integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==
|
||||
dependencies:
|
||||
debug "^4.1.1"
|
||||
get-stream "^5.1.0"
|
||||
yauzl "^2.10.0"
|
||||
optionalDependencies:
|
||||
"@types/yauzl" "^2.9.1"
|
||||
|
||||
extsprintf@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
|
||||
|
@ -22575,7 +22593,7 @@ yargs@^17.3.1, yargs@^17.6.2, yargs@^17.7.2:
|
|||
y18n "^5.0.5"
|
||||
yargs-parser "^21.1.1"
|
||||
|
||||
yauzl@^2.4.2:
|
||||
yauzl@^2.10.0, yauzl@^2.4.2:
|
||||
version "2.10.0"
|
||||
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
|
||||
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
|
||||
|
|
Loading…
Reference in New Issue