Merge master.
This commit is contained in:
commit
0446450a75
|
@ -17,6 +17,7 @@ COPY error.html /usr/share/nginx/html/error.html
|
||||||
# Default environment
|
# Default environment
|
||||||
ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
|
ENV PROXY_RATE_LIMIT_WEBHOOKS_PER_SECOND=10
|
||||||
ENV PROXY_RATE_LIMIT_API_PER_SECOND=20
|
ENV PROXY_RATE_LIMIT_API_PER_SECOND=20
|
||||||
|
ENV PROXY_TIMEOUT_SECONDS=120
|
||||||
# Use docker-compose values as defaults for backwards compatibility
|
# Use docker-compose values as defaults for backwards compatibility
|
||||||
ENV APPS_UPSTREAM_URL=http://app-service:4002
|
ENV APPS_UPSTREAM_URL=http://app-service:4002
|
||||||
ENV WORKER_UPSTREAM_URL=http://worker-service:4003
|
ENV WORKER_UPSTREAM_URL=http://worker-service:4003
|
||||||
|
|
|
@ -144,9 +144,9 @@ http {
|
||||||
limit_req zone=ratelimit burst=20 nodelay;
|
limit_req zone=ratelimit burst=20 nodelay;
|
||||||
|
|
||||||
# 120s timeout on API requests
|
# 120s timeout on API requests
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||||
proxy_send_timeout 120s;
|
proxy_send_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
@ -164,9 +164,9 @@ http {
|
||||||
|
|
||||||
# Rest of configuration copied from /api/ location above
|
# Rest of configuration copied from /api/ location above
|
||||||
# 120s timeout on API requests
|
# 120s timeout on API requests
|
||||||
proxy_read_timeout 120s;
|
proxy_read_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||||
proxy_connect_timeout 120s;
|
proxy_connect_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||||
proxy_send_timeout 120s;
|
proxy_send_timeout ${PROXY_TIMEOUT_SECONDS}s;
|
||||||
|
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Connection $connection_upgrade;
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
|
|
@ -83,7 +83,7 @@ const CSP_DIRECTIVES = {
|
||||||
"https://js.intercomcdn.com",
|
"https://js.intercomcdn.com",
|
||||||
"https://cdn.budi.live",
|
"https://cdn.budi.live",
|
||||||
],
|
],
|
||||||
"worker-src": ["blob:"],
|
"worker-src": ["blob:", "'self'"],
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function contentSecurityPolicy(ctx: any, next: any) {
|
export async function contentSecurityPolicy(ctx: any, next: any) {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import * as objectStore from "../objectStore"
|
||||||
import * as cloudfront from "../cloudfront"
|
import * as cloudfront from "../cloudfront"
|
||||||
import qs from "querystring"
|
import qs from "querystring"
|
||||||
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
|
import { DEFAULT_TENANT_ID, getTenantId } from "../../context"
|
||||||
|
import { PWAManifestImage } from "@budibase/types"
|
||||||
|
|
||||||
export function clientLibraryPath(appId: string) {
|
export function clientLibraryPath(appId: string) {
|
||||||
return `${objectStore.sanitizeKey(appId)}/budibase-client.js`
|
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)
|
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)
|
return getThemeClassNames(theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onChange = (value: string | null) => {
|
const onChange = (value: string | undefined) => {
|
||||||
dispatch("change", value)
|
dispatch("change", value)
|
||||||
dropdown?.hide()
|
dropdown?.hide()
|
||||||
}
|
}
|
||||||
|
@ -243,7 +243,7 @@
|
||||||
size="S"
|
size="S"
|
||||||
name="Close"
|
name="Close"
|
||||||
hoverable
|
hoverable
|
||||||
on:click={() => onChange(null)}
|
on:click={() => onChange(undefined)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
const BYTES_IN_MB = 1000000
|
const BYTES_IN_MB = 1000000
|
||||||
|
|
||||||
export let value: File | undefined = undefined
|
export let value: File | undefined = undefined
|
||||||
|
export let statusText: string | undefined = undefined
|
||||||
export let title: string = "Upload file"
|
export let title: string = "Upload file"
|
||||||
export let disabled: boolean = false
|
export let disabled: boolean = false
|
||||||
export let allowClear: boolean | undefined = undefined
|
export let allowClear: boolean | undefined = undefined
|
||||||
|
@ -57,7 +58,16 @@
|
||||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<div class="field">
|
<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">
|
<div class="file-view">
|
||||||
{#if previewUrl}
|
{#if previewUrl}
|
||||||
<img class="preview" alt="" src={previewUrl} />
|
<img class="preview" alt="" src={previewUrl} />
|
||||||
|
@ -97,6 +107,9 @@
|
||||||
border-radius: var(--spectrum-global-dimension-size-50);
|
border-radius: var(--spectrum-global-dimension-size-50);
|
||||||
padding: 0px var(--spectrum-alias-item-padding-m);
|
padding: 0px var(--spectrum-alias-item-padding-m);
|
||||||
}
|
}
|
||||||
|
.file-view.status {
|
||||||
|
background-color: var(--spectrum-global-color-gray-100);
|
||||||
|
}
|
||||||
input[type="file"] {
|
input[type="file"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
export let error: string | undefined = undefined
|
export let error: string | undefined = undefined
|
||||||
export let title: string | undefined = undefined
|
export let title: string | undefined = undefined
|
||||||
export let value: File | undefined = undefined
|
export let value: File | undefined = undefined
|
||||||
|
export let statusText: string | undefined = undefined
|
||||||
export let tooltip: string | undefined = undefined
|
export let tooltip: string | undefined = undefined
|
||||||
export let helpText: string | undefined = undefined
|
export let helpText: string | undefined = undefined
|
||||||
|
|
||||||
|
@ -29,6 +30,7 @@
|
||||||
{allowClear}
|
{allowClear}
|
||||||
{title}
|
{title}
|
||||||
{value}
|
{value}
|
||||||
|
{statusText}
|
||||||
{previewUrl}
|
{previewUrl}
|
||||||
{handleFileTooLarge}
|
{handleFileTooLarge}
|
||||||
{extensions}
|
{extensions}
|
||||||
|
|
|
@ -238,3 +238,21 @@ export const hexToRGBA = (color: string, opacity: number): string => {
|
||||||
const b = parseInt(color.substring(4, 6), 16)
|
const b = parseInt(color.substring(4, 6), 16)
|
||||||
return `rgba(${r}, ${g}, ${b}, ${opacity})`
|
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]: {
|
[FIELDS.OPTIONS.type]: {
|
||||||
label: "Options",
|
label: "Single select",
|
||||||
value: FIELDS.OPTIONS.type,
|
value: FIELDS.OPTIONS.type,
|
||||||
config: {
|
config: {
|
||||||
type: FIELDS.OPTIONS.type,
|
type: FIELDS.OPTIONS.type,
|
||||||
|
@ -46,7 +46,7 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[FIELDS.ARRAY.type]: {
|
[FIELDS.ARRAY.type]: {
|
||||||
label: "Multi-select",
|
label: "Multi select",
|
||||||
value: FIELDS.ARRAY.type,
|
value: FIELDS.ARRAY.type,
|
||||||
config: {
|
config: {
|
||||||
type: FIELDS.ARRAY.type,
|
type: FIELDS.ARRAY.type,
|
||||||
|
|
|
@ -29,6 +29,11 @@
|
||||||
url={$url("./embed")}
|
url={$url("./embed")}
|
||||||
active={$isActive("./embed")}
|
active={$isActive("./embed")}
|
||||||
/>
|
/>
|
||||||
|
<SideNavItem
|
||||||
|
text="Progressive Web App"
|
||||||
|
url={$url("./pwa")}
|
||||||
|
active={$isActive("./pwa")}
|
||||||
|
/>
|
||||||
<SideNavItem
|
<SideNavItem
|
||||||
text="Export/Import"
|
text="Export/Import"
|
||||||
url={$url("./exportImport")}
|
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,
|
AppScript,
|
||||||
AutomationSettings,
|
AutomationSettings,
|
||||||
Plugin,
|
Plugin,
|
||||||
|
PWAManifest,
|
||||||
UpdateAppRequest,
|
UpdateAppRequest,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
|
@ -49,6 +50,7 @@ interface AppMetaState {
|
||||||
revertableVersion?: string
|
revertableVersion?: string
|
||||||
upgradableVersion?: string
|
upgradableVersion?: string
|
||||||
icon?: AppIcon
|
icon?: AppIcon
|
||||||
|
pwa?: PWAManifest
|
||||||
scripts: AppScript[]
|
scripts: AppScript[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,6 +85,16 @@ export const INITIAL_APP_META_STATE: AppMetaState = {
|
||||||
usedPlugins: [],
|
usedPlugins: [],
|
||||||
automations: {},
|
automations: {},
|
||||||
routes: {},
|
routes: {},
|
||||||
|
pwa: {
|
||||||
|
name: "",
|
||||||
|
short_name: "",
|
||||||
|
description: "",
|
||||||
|
icons: [],
|
||||||
|
background_color: "",
|
||||||
|
theme_color: "",
|
||||||
|
start_url: "",
|
||||||
|
screenshots: [],
|
||||||
|
},
|
||||||
scripts: [],
|
scripts: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,6 +127,7 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
||||||
initialised: true,
|
initialised: true,
|
||||||
automations: app.automations || {},
|
automations: app.automations || {},
|
||||||
hasAppPackage: true,
|
hasAppPackage: true,
|
||||||
|
pwa: app.pwa,
|
||||||
scripts: app.scripts || [],
|
scripts: app.scripts || [],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
@ -171,6 +184,17 @@ export class AppMetaStore extends BudiStore<AppMetaState> {
|
||||||
name,
|
name,
|
||||||
url,
|
url,
|
||||||
icon,
|
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,
|
appId,
|
||||||
url,
|
url,
|
||||||
features,
|
features,
|
||||||
|
pwa,
|
||||||
componentLibraries,
|
componentLibraries,
|
||||||
} = app
|
} = app
|
||||||
|
|
||||||
|
@ -88,6 +89,7 @@ describe("Application Meta Store", () => {
|
||||||
hasLock,
|
hasLock,
|
||||||
initialised: true,
|
initialised: true,
|
||||||
hasAppPackage: true,
|
hasAppPackage: true,
|
||||||
|
pwa,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -257,6 +257,7 @@ export const generateAppPackage = ({
|
||||||
icon: {},
|
icon: {},
|
||||||
type: "app",
|
type: "app",
|
||||||
},
|
},
|
||||||
|
pwa: {},
|
||||||
clientLibPath: `https://cdn.budibase.net/${appId}/budibase-client.js?v=${version}`,
|
clientLibPath: `https://cdn.budibase.net/${appId}/budibase-client.js?v=${version}`,
|
||||||
hasLock: true,
|
hasLock: true,
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,6 +31,7 @@ interface LicensingState {
|
||||||
groupsEnabled: boolean
|
groupsEnabled: boolean
|
||||||
backupsEnabled: boolean
|
backupsEnabled: boolean
|
||||||
brandingEnabled: boolean
|
brandingEnabled: boolean
|
||||||
|
pwaEnabled: boolean
|
||||||
scimEnabled: boolean
|
scimEnabled: boolean
|
||||||
environmentVariablesEnabled: boolean
|
environmentVariablesEnabled: boolean
|
||||||
budibaseAIEnabled: boolean
|
budibaseAIEnabled: boolean
|
||||||
|
@ -74,6 +75,7 @@ class LicensingStore extends BudiStore<LicensingState> {
|
||||||
groupsEnabled: false,
|
groupsEnabled: false,
|
||||||
backupsEnabled: false,
|
backupsEnabled: false,
|
||||||
brandingEnabled: false,
|
brandingEnabled: false,
|
||||||
|
pwaEnabled: false,
|
||||||
scimEnabled: false,
|
scimEnabled: false,
|
||||||
environmentVariablesEnabled: false,
|
environmentVariablesEnabled: false,
|
||||||
budibaseAIEnabled: false,
|
budibaseAIEnabled: false,
|
||||||
|
@ -172,6 +174,7 @@ class LicensingStore extends BudiStore<LicensingState> {
|
||||||
)
|
)
|
||||||
const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO)
|
const enforceableSSO = features.includes(Constants.Features.ENFORCEABLE_SSO)
|
||||||
const brandingEnabled = features.includes(Constants.Features.BRANDING)
|
const brandingEnabled = features.includes(Constants.Features.BRANDING)
|
||||||
|
const pwaEnabled = features.includes(Constants.Features.PWA)
|
||||||
const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS)
|
const auditLogsEnabled = features.includes(Constants.Features.AUDIT_LOGS)
|
||||||
const syncAutomationsEnabled = features.includes(
|
const syncAutomationsEnabled = features.includes(
|
||||||
Constants.Features.SYNC_AUTOMATIONS
|
Constants.Features.SYNC_AUTOMATIONS
|
||||||
|
@ -201,6 +204,7 @@ class LicensingStore extends BudiStore<LicensingState> {
|
||||||
groupsEnabled,
|
groupsEnabled,
|
||||||
backupsEnabled,
|
backupsEnabled,
|
||||||
brandingEnabled,
|
brandingEnabled,
|
||||||
|
pwaEnabled,
|
||||||
budibaseAIEnabled,
|
budibaseAIEnabled,
|
||||||
customAIConfigsEnabled,
|
customAIConfigsEnabled,
|
||||||
scimEnabled,
|
scimEnabled,
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
|
import NotificationDisplay from "./overlay/NotificationDisplay.svelte"
|
||||||
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
|
import ConfirmationDisplay from "./overlay/ConfirmationDisplay.svelte"
|
||||||
import PeekScreenDisplay from "./overlay/PeekScreenDisplay.svelte"
|
import PeekScreenDisplay from "./overlay/PeekScreenDisplay.svelte"
|
||||||
|
import InstallPrompt from "./overlay/InstallPrompt.svelte"
|
||||||
import UserBindingsProvider from "./context/UserBindingsProvider.svelte"
|
import UserBindingsProvider from "./context/UserBindingsProvider.svelte"
|
||||||
import DeviceBindingsProvider from "./context/DeviceBindingsProvider.svelte"
|
import DeviceBindingsProvider from "./context/DeviceBindingsProvider.svelte"
|
||||||
import StateBindingsProvider from "./context/StateBindingsProvider.svelte"
|
import StateBindingsProvider from "./context/StateBindingsProvider.svelte"
|
||||||
|
@ -246,6 +247,7 @@
|
||||||
<NotificationDisplay />
|
<NotificationDisplay />
|
||||||
<ConfirmationDisplay />
|
<ConfirmationDisplay />
|
||||||
<PeekScreenDisplay />
|
<PeekScreenDisplay />
|
||||||
|
<InstallPrompt />
|
||||||
</CustomThemeWrapper>
|
</CustomThemeWrapper>
|
||||||
{/if}
|
{/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 BlockComponent from "./components/BlockComponent.svelte"
|
||||||
import Block from "./components/Block.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
|
// Provide svelte and svelte/internal as globals for custom components
|
||||||
import * as svelte from "svelte"
|
import * as svelte from "svelte"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
@ -69,6 +77,9 @@ declare global {
|
||||||
// Other flags
|
// Other flags
|
||||||
MIGRATING_APP: boolean
|
MIGRATING_APP: boolean
|
||||||
|
|
||||||
|
// PWA install prompt
|
||||||
|
deferredPwaPrompt: any
|
||||||
|
|
||||||
// Client additions
|
// Client additions
|
||||||
handleBuilderRuntimeEvent: (type: string, data: any) => void
|
handleBuilderRuntimeEvent: (type: string, data: any) => void
|
||||||
registerCustomComponent: typeof componentStore.actions.registerCustomComponent
|
registerCustomComponent: typeof componentStore.actions.registerCustomComponent
|
||||||
|
|
|
@ -41,6 +41,7 @@ const createFeaturesStore = () => {
|
||||||
aiEnabled:
|
aiEnabled:
|
||||||
license?.features?.includes(Feature.AI_CUSTOM_CONFIGS) ||
|
license?.features?.includes(Feature.AI_CUSTOM_CONFIGS) ||
|
||||||
license?.features?.includes(Feature.BUDIBASE_AI),
|
license?.features?.includes(Feature.BUDIBASE_AI),
|
||||||
|
pwaEnabled: license?.features?.includes(Feature.PWA),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,9 @@ export interface AttachmentEndpoints {
|
||||||
data: any
|
data: any
|
||||||
) => Promise<ProcessAttachmentResponse>
|
) => Promise<ProcessAttachmentResponse>
|
||||||
uploadBuilderAttachment: (data: any) => Promise<ProcessAttachmentResponse>
|
uploadBuilderAttachment: (data: any) => Promise<ProcessAttachmentResponse>
|
||||||
|
uploadPWAZip: (
|
||||||
|
data: FormData
|
||||||
|
) => Promise<{ icons: Array<{ src: string; sizes: string; type: string }> }>
|
||||||
externalUpload: (
|
externalUpload: (
|
||||||
datasourceId: string,
|
datasourceId: string,
|
||||||
bucket: 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.
|
* Uploads a file to an external datasource.
|
||||||
* @param datasourceId the ID of the datasource to upload to
|
* @param datasourceId the ID of the datasource to upload to
|
||||||
|
|
|
@ -88,6 +88,7 @@
|
||||||
"dayjs": "^1.10.8",
|
"dayjs": "^1.10.8",
|
||||||
"dd-trace": "5.43.0",
|
"dd-trace": "5.43.0",
|
||||||
"dotenv": "8.2.0",
|
"dotenv": "8.2.0",
|
||||||
|
"extract-zip": "^2.0.1",
|
||||||
"form-data": "4.0.0",
|
"form-data": "4.0.0",
|
||||||
"global-agent": "3.0.0",
|
"global-agent": "3.0.0",
|
||||||
"google-auth-library": "^8.0.1",
|
"google-auth-library": "^8.0.1",
|
||||||
|
|
|
@ -265,6 +265,13 @@ export async function fetchAppPackage(
|
||||||
application.usedPlugins
|
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
|
// Only filter screens if the user is not a builder
|
||||||
if (!users.isBuilder(ctx.user, appId)) {
|
if (!users.isBuilder(ctx.user, appId)) {
|
||||||
const userRoleId = getUserRoleId(ctx)
|
const userRoleId = getUserRoleId(ctx)
|
||||||
|
@ -867,6 +874,18 @@ export async function updateAppPackage(
|
||||||
newAppPackage._rev = application._rev
|
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
|
// the locked by property is attached by server but generated from
|
||||||
// Redis, shouldn't ever store it
|
// Redis, shouldn't ever store it
|
||||||
delete newAppPackage.lockedBy
|
delete newAppPackage.lockedBy
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { getQueryParams, getTableParams } from "../../db/utils"
|
import { getQueryParams, getTableParams } from "../../db/utils"
|
||||||
import { getIntegration } from "../../integrations"
|
|
||||||
import { invalidateCachedVariable } from "../../threads/utils"
|
import { invalidateCachedVariable } from "../../threads/utils"
|
||||||
import { context, db as dbCore, events } from "@budibase/backend-core"
|
import { context, db as dbCore, events } from "@budibase/backend-core"
|
||||||
import {
|
import {
|
||||||
|
@ -174,14 +173,6 @@ export async function update(
|
||||||
await events.datasource.updated(datasource)
|
await events.datasource.updated(datasource)
|
||||||
datasource._rev = response.rev
|
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.message = "Datasource saved successfully."
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
datasource: await sdk.datasources.removeSecretSingle(datasource),
|
datasource: await sdk.datasources.removeSecretSingle(datasource),
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
|
||||||
import { PutObjectCommand, S3 } from "@aws-sdk/client-s3"
|
import { PutObjectCommand, S3 } from "@aws-sdk/client-s3"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import fsp from "fs/promises"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import * as pro from "@budibase/pro"
|
import * as pro from "@budibase/pro"
|
||||||
import {
|
import {
|
||||||
|
@ -32,6 +33,7 @@ import {
|
||||||
GetSignedUploadUrlRequest,
|
GetSignedUploadUrlRequest,
|
||||||
GetSignedUploadUrlResponse,
|
GetSignedUploadUrlResponse,
|
||||||
ProcessAttachmentResponse,
|
ProcessAttachmentResponse,
|
||||||
|
PWAManifest,
|
||||||
ServeAppResponse,
|
ServeAppResponse,
|
||||||
ServeBuilderPreviewResponse,
|
ServeBuilderPreviewResponse,
|
||||||
ServeClientLibraryResponse,
|
ServeClientLibraryResponse,
|
||||||
|
@ -45,6 +47,9 @@ import {
|
||||||
|
|
||||||
import send from "koa-send"
|
import send from "koa-send"
|
||||||
import { getThemeVariables } from "../../../constants/themes"
|
import { getThemeVariables } from "../../../constants/themes"
|
||||||
|
import path from "path"
|
||||||
|
import extract from "extract-zip"
|
||||||
|
import { tmpdir } from "os"
|
||||||
|
|
||||||
export const toggleBetaUiFeature = async function (
|
export const toggleBetaUiFeature = async function (
|
||||||
ctx: Ctx<void, ToggleBetaFeatureResponse>
|
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 requiresMigration = async (ctx: Ctx) => {
|
||||||
const appId = context.getAppId()
|
const appId = context.getAppId()
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
|
@ -199,7 +281,9 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
|
||||||
const sideNav = appInfo.navigation.navigation === "Left"
|
const sideNav = appInfo.navigation.navigation === "Left"
|
||||||
const hideFooter =
|
const hideFooter =
|
||||||
ctx?.user?.license?.features?.includes(Feature.BRANDING) || false
|
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 =
|
const addAppScripts =
|
||||||
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
|
ctx?.user?.license?.features?.includes(Feature.CUSTOM_APP_SCRIPTS) ||
|
||||||
false
|
false
|
||||||
|
@ -243,8 +327,45 @@ export const serveApp = async function (ctx: UserCtx<void, ServeAppResponse>) {
|
||||||
|
|
||||||
const { head, html, css } = AppComponent.render({ props })
|
const { head, html, css } = AppComponent.render({ props })
|
||||||
const appHbs = loadHandlebarsFile(appHbsPath)
|
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, {
|
ctx.body = await processString(appHbs, {
|
||||||
head,
|
head: `${head}${extraHead}`,
|
||||||
body: html,
|
body: html,
|
||||||
css: `:root{${themeVariables}} ${css.code}`,
|
css: `:root{${themeVariables}} ${css.code}`,
|
||||||
appId,
|
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 (
|
export const getSignedUploadURL = async function (
|
||||||
ctx: Ctx<GetSignedUploadUrlRequest, GetSignedUploadUrlResponse>
|
ctx: Ctx<GetSignedUploadUrlRequest, GetSignedUploadUrlResponse>
|
||||||
) {
|
) {
|
||||||
|
@ -397,3 +528,70 @@ export const getSignedUploadURL = async function (
|
||||||
|
|
||||||
ctx.body = { signedUrl, publicUrl }
|
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
|
router
|
||||||
.get("/api/assets/client", controller.serveClientLibrary)
|
.get("/api/assets/client", controller.serveClientLibrary)
|
||||||
|
.get("/api/apps/:appId/manifest.json", controller.servePwaManifest)
|
||||||
.post("/api/attachments/process", authorized(BUILDER), controller.uploadFile)
|
.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/beta/:feature", controller.toggleBetaUiFeature)
|
||||||
.post(
|
.post(
|
||||||
"/api/attachments/:tableId/upload",
|
"/api/attachments/:tableId/upload",
|
||||||
|
@ -22,6 +24,7 @@ router
|
||||||
controller.uploadFile
|
controller.uploadFile
|
||||||
)
|
)
|
||||||
.get("/app/preview", authorized(BUILDER), controller.serveBuilderPreview)
|
.get("/app/preview", authorized(BUILDER), controller.serveBuilderPreview)
|
||||||
|
.get("/app/service-worker.js", controller.serveServiceWorker)
|
||||||
.get("/app/:appUrl/:path*", controller.serveApp)
|
.get("/app/:appUrl/:path*", controller.serveApp)
|
||||||
.get("/:appId/:path*", controller.serveApp)
|
.get("/:appId/:path*", controller.serveApp)
|
||||||
.post(
|
.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 () => {
|
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) {
|
for (const verb of verbs) {
|
||||||
const query = await createQuery({
|
const query = await createQuery({
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
|
|
@ -3,7 +3,7 @@ import {
|
||||||
EnrichedQueryJson,
|
EnrichedQueryJson,
|
||||||
QueryJson,
|
QueryJson,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { getIntegration } from "../index"
|
import { getIntegration, isDatasourcePlusConstructor } from "../index"
|
||||||
import sdk from "../../sdk"
|
import sdk from "../../sdk"
|
||||||
import { enrichQueryJson } from "../../sdk/app/rows/utils"
|
import { enrichQueryJson } from "../../sdk/app/rows/utils"
|
||||||
|
|
||||||
|
@ -29,8 +29,7 @@ export async function makeExternalQuery(
|
||||||
|
|
||||||
const Integration = await getIntegration(json.datasource.source)
|
const Integration = await getIntegration(json.datasource.source)
|
||||||
|
|
||||||
// query is the opinionated function
|
if (!isDatasourcePlusConstructor(Integration)) {
|
||||||
if (!Integration.prototype.query) {
|
|
||||||
throw "Datasource does not support query."
|
throw "Datasource does not support query."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -626,7 +626,7 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
|
||||||
sheet: string
|
sheet: string
|
||||||
rowIndex: number
|
rowIndex: number
|
||||||
row: any
|
row: any
|
||||||
table: Table
|
table?: Table
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
await this.connect()
|
await this.connect()
|
||||||
|
@ -644,13 +644,15 @@ export class GoogleSheetsIntegration implements DatasourcePlus {
|
||||||
row.set(key, "")
|
row.set(key, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, subtype, constraints } = query.table.schema[key]
|
if (query.table) {
|
||||||
const isDeprecatedSingleUser =
|
const { type, subtype, constraints } = query.table.schema[key]
|
||||||
type === FieldType.BB_REFERENCE &&
|
const isDeprecatedSingleUser =
|
||||||
subtype === BBReferenceFieldSubType.USER &&
|
type === FieldType.BB_REFERENCE &&
|
||||||
constraints?.type !== "array"
|
subtype === BBReferenceFieldSubType.USER &&
|
||||||
if (isDeprecatedSingleUser && Array.isArray(row.get(key))) {
|
constraints?.type !== "array"
|
||||||
row.set(key, row.get(key)[0])
|
if (isDeprecatedSingleUser && Array.isArray(row.get(key))) {
|
||||||
|
row.set(key, row.get(key)[0])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await row.save()
|
await row.save()
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
Integration,
|
Integration,
|
||||||
PluginType,
|
PluginType,
|
||||||
IntegrationBase,
|
IntegrationBase,
|
||||||
|
DatasourcePlus,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { getDatasourcePlugin } from "../utilities/fileSystem"
|
import { getDatasourcePlugin } from "../utilities/fileSystem"
|
||||||
import env from "../environment"
|
import env from "../environment"
|
||||||
|
@ -48,6 +49,13 @@ const DEFINITIONS: Record<SourceName, Integration | undefined> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
type IntegrationBaseConstructor = new (...args: any[]) => IntegrationBase
|
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> =
|
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]) {
|
if (INTEGRATIONS[integration]) {
|
||||||
return INTEGRATIONS[integration]
|
return INTEGRATIONS[integration]
|
||||||
}
|
}
|
||||||
|
|
|
@ -787,4 +787,143 @@ describe("Google Sheets Integration", () => {
|
||||||
expect(ids.length).toEqual(50)
|
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")
|
throw new Error("Only row-based deletes are supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.iterateRange(range, cell => {
|
const sheet = this.getSheetById(range.sheetId)
|
||||||
cell.userEnteredValue = this.createValue(null)
|
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) {
|
private handleDeleteSheet(request: DeleteSheetRequest) {
|
||||||
|
|
|
@ -296,6 +296,9 @@ export async function save(
|
||||||
datasource: Datasource,
|
datasource: Datasource,
|
||||||
opts?: { fetchSchema?: boolean; tablesFilter?: string[] }
|
opts?: { fetchSchema?: boolean; tablesFilter?: string[] }
|
||||||
): Promise<{ datasource: Datasource; errors: Record<string, 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 db = context.getAppDB()
|
||||||
const plus = datasource.plus
|
const plus = datasource.plus
|
||||||
|
|
||||||
|
@ -329,14 +332,6 @@ export async function save(
|
||||||
await events.datasource.created(datasource)
|
await events.datasource.created(datasource)
|
||||||
datasource._rev = dbResp.rev
|
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 }
|
return { datasource, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,14 +14,21 @@ import { context, cache, auth } from "@budibase/backend-core"
|
||||||
import { getGlobalIDFromUserMetadataID } from "../db/utils"
|
import { getGlobalIDFromUserMetadataID } from "../db/utils"
|
||||||
import sdk from "../sdk"
|
import sdk from "../sdk"
|
||||||
import { cloneDeep } from "lodash/fp"
|
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 { isSQL } from "../integrations/utils"
|
||||||
import { interpolateSQL } from "../integrations/queries/sql"
|
import { interpolateSQL } from "../integrations/queries/sql"
|
||||||
|
|
||||||
class QueryRunner {
|
class QueryRunner {
|
||||||
datasource: Datasource
|
datasource: Datasource
|
||||||
queryVerb: string
|
queryVerb: QueryVerb
|
||||||
queryId: string
|
queryId: string
|
||||||
fields: any
|
fields: any
|
||||||
parameters: any
|
parameters: any
|
||||||
|
@ -116,7 +123,9 @@ class QueryRunner {
|
||||||
datasource.source,
|
datasource.source,
|
||||||
fieldsClone,
|
fieldsClone,
|
||||||
enrichedContext,
|
enrichedContext,
|
||||||
integration,
|
// Bit hacky because currently all of our SQL datasources are
|
||||||
|
// DatasourcePluses.
|
||||||
|
integration as DatasourcePlus,
|
||||||
{
|
{
|
||||||
nullDefaultSupport,
|
nullDefaultSupport,
|
||||||
}
|
}
|
||||||
|
@ -130,7 +139,14 @@ class QueryRunner {
|
||||||
query.paginationValues = this.pagination
|
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[],
|
let rows = output as Row[],
|
||||||
info = undefined,
|
info = undefined,
|
||||||
extra = undefined,
|
extra = undefined,
|
||||||
|
@ -199,7 +215,7 @@ class QueryRunner {
|
||||||
})
|
})
|
||||||
const keys: string[] = [...keysSet]
|
const keys: string[] = [...keysSet]
|
||||||
|
|
||||||
if (integration.end) {
|
if ("end" in integration && typeof integration.end === "function") {
|
||||||
integration.end()
|
integration.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -29,6 +29,7 @@ export interface App extends Document {
|
||||||
snippets?: Snippet[]
|
snippets?: Snippet[]
|
||||||
creationVersion?: string
|
creationVersion?: string
|
||||||
updatedBy?: string
|
updatedBy?: string
|
||||||
|
pwa?: PWAManifest
|
||||||
scripts?: AppScript[]
|
scripts?: AppScript[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +85,26 @@ export interface AutomationSettings {
|
||||||
chainAutomations?: boolean
|
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 {
|
export interface AppScript {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
|
@ -8,16 +8,21 @@ export interface QuerySchema {
|
||||||
subtype?: string
|
subtype?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueryVerb = "read" | "create" | "update" | "delete"
|
||||||
|
|
||||||
export interface Query extends Document {
|
export interface Query extends Document {
|
||||||
datasourceId: string
|
datasourceId: string
|
||||||
name: string
|
name: string
|
||||||
parameters: QueryParameter[]
|
parameters: QueryParameter[]
|
||||||
fields: RestQueryFields & SQLQueryFields & MongoQueryFields
|
fields: RestQueryFields &
|
||||||
|
SQLQueryFields &
|
||||||
|
MongoQueryFields &
|
||||||
|
GoogleSheetsQueryFields
|
||||||
transformer: string | null
|
transformer: string | null
|
||||||
schema: Record<string, QuerySchema | string>
|
schema: Record<string, QuerySchema | string>
|
||||||
nestedSchemaFields?: Record<string, Record<string, QuerySchema | string>>
|
nestedSchemaFields?: Record<string, Record<string, QuerySchema | string>>
|
||||||
readable: boolean
|
readable: boolean
|
||||||
queryVerb: string
|
queryVerb: QueryVerb
|
||||||
// flag to state whether the default bindings are empty strings (old behaviour) or null
|
// flag to state whether the default bindings are empty strings (old behaviour) or null
|
||||||
nullDefaultSupport?: boolean
|
nullDefaultSupport?: boolean
|
||||||
}
|
}
|
||||||
|
@ -83,6 +88,12 @@ export interface MongoQueryFields {
|
||||||
json?: object | string
|
json?: object | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GoogleSheetsQueryFields {
|
||||||
|
sheet?: string
|
||||||
|
rowIndex?: string
|
||||||
|
row?: Row
|
||||||
|
}
|
||||||
|
|
||||||
export interface PaginationConfig {
|
export interface PaginationConfig {
|
||||||
type: string
|
type: string
|
||||||
location: string
|
location: string
|
||||||
|
|
20
yarn.lock
20
yarn.lock
|
@ -7237,6 +7237,13 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/yargs-parser" "*"
|
"@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":
|
"@typescript-eslint/eslint-plugin@8.17.0":
|
||||||
version "8.17.0"
|
version "8.17.0"
|
||||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.17.0.tgz#2ee073c421f4e81e02d10e731241664b6253b23c"
|
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"
|
iconv-lite "^0.4.24"
|
||||||
tmp "^0.0.33"
|
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:
|
extsprintf@1.3.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
|
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"
|
y18n "^5.0.5"
|
||||||
yargs-parser "^21.1.1"
|
yargs-parser "^21.1.1"
|
||||||
|
|
||||||
yauzl@^2.4.2:
|
yauzl@^2.10.0, yauzl@^2.4.2:
|
||||||
version "2.10.0"
|
version "2.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
|
resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
|
||||||
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
|
integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==
|
||||||
|
|
Loading…
Reference in New Issue