Merge branch 'master' into dependabot/npm_and_yarn/qa-core/follow-redirects-1.15.6

This commit is contained in:
Adria Navarro 2024-03-18 17:45:38 +01:00 committed by GitHub
commit 28d75a588b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
181 changed files with 3471 additions and 1638 deletions

View File

@ -6,6 +6,7 @@ packages/server/coverage
packages/worker/coverage packages/worker/coverage
packages/backend-core/coverage packages/backend-core/coverage
packages/server/client packages/server/client
packages/server/coverage
packages/builder/.routify packages/builder/.routify
packages/sdk/sdk packages/sdk/sdk
packages/account-portal/packages/server/build packages/account-portal/packages/server/build

View File

@ -107,9 +107,9 @@ jobs:
- name: Test - name: Test
run: | run: |
if ${{ env.USE_NX_AFFECTED }}; then if ${{ env.USE_NX_AFFECTED }}; then
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro --since=${{ env.NX_BASE_BRANCH }} yarn test --ignore=@budibase/worker --ignore=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
else else
yarn test --ignore=@budibase/worker --ignore=@budibase/server --ignore=@budibase/pro yarn test --ignore=@budibase/worker --ignore=@budibase/server
fi fi
test-worker: test-worker:
@ -160,31 +160,6 @@ jobs:
yarn test --scope=@budibase/server yarn test --scope=@budibase/server
fi fi
test-pro:
runs-on: ubuntu-latest
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v4
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: yarn
- run: yarn --frozen-lockfile
- name: Test
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/pro --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/pro
fi
integration-test: integration-test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@ -7,11 +7,12 @@ module.exports = {
if ( if (
/^@budibase\/[^/]+\/.*$/.test(importPath) && /^@budibase\/[^/]+\/.*$/.test(importPath) &&
importPath !== "@budibase/backend-core/tests" importPath !== "@budibase/backend-core/tests" &&
importPath !== "@budibase/string-templates/test/utils"
) { ) {
context.report({ context.report({
node, node,
message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests.`, message: `Importing from @budibase is not allowed, except for @budibase/backend-core/tests and @budibase/string-templates/test/utils.`,
}) })
} }
}, },

View File

@ -12,8 +12,6 @@ COPY .yarnrc .
COPY packages/server/package.json packages/server/package.json COPY packages/server/package.json packages/server/package.json
COPY packages/worker/package.json packages/worker/package.json COPY packages/worker/package.json packages/worker/package.json
# string-templates does not get bundled during the esbuild process, so we want to use the local version
COPY packages/string-templates/package.json packages/string-templates/package.json
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
@ -26,7 +24,7 @@ RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
RUN echo '' > scripts/syncProPackage.js RUN echo '' > scripts/syncProPackage.js
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile
# copy the actual code # copy the actual code
COPY packages/server/dist packages/server/dist COPY packages/server/dist packages/server/dist
@ -35,7 +33,6 @@ COPY packages/server/client packages/server/client
COPY packages/server/builder packages/server/builder COPY packages/server/builder packages/server/builder
COPY packages/worker/dist packages/worker/dist COPY packages/worker/dist packages/worker/dist
COPY packages/worker/pm2.config.js packages/worker/pm2.config.js COPY packages/worker/pm2.config.js packages/worker/pm2.config.js
COPY packages/string-templates packages/string-templates
FROM budibase/couchdb:v3.3.3 as runner FROM budibase/couchdb:v3.3.3 as runner
@ -52,11 +49,11 @@ RUN apt-get update && \
# Install postgres client for pg_dump utils # Install postgres client for pg_dump utils
RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \ RUN apt install -y software-properties-common apt-transport-https ca-certificates gnupg \
&& curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \ && curl -fsSl https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor | tee /usr/share/keyrings/postgresql.gpg > /dev/null \
&& echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \ && echo deb [arch=amd64,arm64,ppc64el signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | tee /etc/apt/sources.list.d/postgresql.list \
&& apt update -y \ && apt update -y \
&& apt install postgresql-client-15 -y \ && apt install postgresql-client-15 -y \
&& apt remove software-properties-common apt-transport-https gpg -y && apt remove software-properties-common apt-transport-https gpg -y
# We use pm2 in order to run multiple node processes in a single container # We use pm2 in order to run multiple node processes in a single container
RUN npm install --global pm2 RUN npm install --global pm2
@ -100,9 +97,6 @@ COPY --from=build /app/node_modules /node_modules
COPY --from=build /app/package.json /package.json COPY --from=build /app/package.json /package.json
COPY --from=build /app/packages/server /app COPY --from=build /app/packages/server /app
COPY --from=build /app/packages/worker /worker COPY --from=build /app/packages/worker /worker
COPY --from=build /app/packages/string-templates /string-templates
RUN cd /string-templates && yarn link && cd ../app && yarn link @budibase/string-templates && cd ../worker && yarn link @budibase/string-templates
EXPOSE 80 EXPOSE 80

View File

@ -1,5 +1,5 @@
{ {
"version": "2.21.9", "version": "2.22.1",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

@ -1 +1 @@
Subproject commit 0c050591c21d3b67dc0c9225d60cc9e2324c8dac Subproject commit 23a1219732bd778654c0bcc4f49910c511e2d51f

View File

@ -10,7 +10,7 @@ import {
StaticDatabases, StaticDatabases,
DEFAULT_TENANT_ID, DEFAULT_TENANT_ID,
} from "../constants" } from "../constants"
import { Database, IdentityContext } from "@budibase/types" import { Database, IdentityContext, Snippet, App } from "@budibase/types"
import { ContextMap } from "./types" import { ContextMap } from "./types"
let TEST_APP_ID: string | null = null let TEST_APP_ID: string | null = null
@ -122,10 +122,10 @@ export async function doInAutomationContext<T>(params: {
automationId: string automationId: string
task: () => T task: () => T
}): Promise<T> { }): Promise<T> {
const tenantId = getTenantIDFromAppID(params.appId) await ensureSnippetContext()
return newContext( return newContext(
{ {
tenantId, tenantId: getTenantIDFromAppID(params.appId),
appId: params.appId, appId: params.appId,
automationId: params.automationId, automationId: params.automationId,
}, },
@ -281,6 +281,27 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export async function ensureSnippetContext() {
const ctx = getCurrentContext()
// If we've already added snippets to context, continue
if (!ctx || ctx.snippets) {
return
}
// Otherwise get snippets for this app and update context
let snippets: Snippet[] | undefined
const db = getAppDB()
if (db && !env.isTest()) {
const app = await db.get<App>(DocumentType.APP_METADATA)
snippets = app.snippets
}
// Always set snippets to a non-null value so that we can tell we've attempted
// to load snippets
ctx.snippets = snippets || []
}
export function getEnvironmentVariables() { export function getEnvironmentVariables() {
const context = Context.get() const context = Context.get()
if (!context.environmentVariables) { if (!context.environmentVariables) {

View File

@ -1,4 +1,4 @@
import { IdentityContext, VM } from "@budibase/types" import { IdentityContext, Snippet, VM } from "@budibase/types"
// keep this out of Budibase types, don't want to expose context info // keep this out of Budibase types, don't want to expose context info
export type ContextMap = { export type ContextMap = {
@ -11,4 +11,5 @@ export type ContextMap = {
isMigrating?: boolean isMigrating?: boolean
vm?: VM vm?: VM
cleanup?: (() => void | Promise<void>)[] cleanup?: (() => void | Promise<void>)[]
snippets?: Snippet[]
} }

View File

@ -13,6 +13,7 @@ import {
AppVersionRevertedEvent, AppVersionRevertedEvent,
AppRevertedEvent, AppRevertedEvent,
AppExportedEvent, AppExportedEvent,
AppDuplicatedEvent,
} from "@budibase/types" } from "@budibase/types"
const created = async (app: App, timestamp?: string | number) => { const created = async (app: App, timestamp?: string | number) => {
@ -77,6 +78,17 @@ async function fileImported(app: App) {
await publishEvent(Event.APP_FILE_IMPORTED, properties) await publishEvent(Event.APP_FILE_IMPORTED, properties)
} }
async function duplicated(app: App, duplicateAppId: string) {
const properties: AppDuplicatedEvent = {
duplicateAppId,
appId: app.appId,
audited: {
name: app.name,
},
}
await publishEvent(Event.APP_DUPLICATED, properties)
}
async function templateImported(app: App, templateKey: string) { async function templateImported(app: App, templateKey: string) {
const properties: AppTemplateImportedEvent = { const properties: AppTemplateImportedEvent = {
appId: app.appId, appId: app.appId,
@ -147,6 +159,7 @@ export default {
published, published,
unpublished, unpublished,
fileImported, fileImported,
duplicated,
templateImported, templateImported,
versionUpdated, versionUpdated,
versionReverted, versionReverted,

View File

@ -15,6 +15,7 @@ beforeAll(async () => {
jest.spyOn(events.app, "created") jest.spyOn(events.app, "created")
jest.spyOn(events.app, "updated") jest.spyOn(events.app, "updated")
jest.spyOn(events.app, "duplicated")
jest.spyOn(events.app, "deleted") jest.spyOn(events.app, "deleted")
jest.spyOn(events.app, "published") jest.spyOn(events.app, "published")
jest.spyOn(events.app, "unpublished") jest.spyOn(events.app, "unpublished")

View File

@ -38,7 +38,7 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}> <Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

View File

@ -33,8 +33,8 @@ const handleClick = event => {
} }
// Ignore clicks for drawers, unless the handler is registered from a drawer // Ignore clicks for drawers, unless the handler is registered from a drawer
const sourceInDrawer = handler.anchor.closest(".drawer-container") != null const sourceInDrawer = handler.anchor.closest(".drawer-wrapper") != null
const clickInDrawer = event.target.closest(".drawer-container") != null const clickInDrawer = event.target.closest(".drawer-wrapper") != null
if (clickInDrawer && !sourceInDrawer) { if (clickInDrawer && !sourceInDrawer) {
return return
} }

View File

@ -57,8 +57,10 @@
</script> </script>
<script> <script>
import Portal from "svelte-portal"
import Button from "../Button/Button.svelte" import Button from "../Button/Button.svelte"
import Icon from "../Icon/Icon.svelte"
import ActionButton from "../ActionButton/ActionButton.svelte"
import Portal from "svelte-portal"
import { setContext, createEventDispatcher, onDestroy } from "svelte" import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
@ -170,7 +172,8 @@
{#if visible} {#if visible}
<Portal target=".modal-container"> <Portal target=".modal-container">
<div class="drawer-container"> <!-- This class is unstyled, but needed by click_outside -->
<div class="drawer-wrapper">
<div <div
class="underlay" class="underlay"
class:hidden={!$modal} class:hidden={!$modal}
@ -184,10 +187,24 @@
{style} {style}
> >
<header> <header>
<div class="text">{title || "Bindings"}</div> {#if $$slots.title}
<slot name="title" />
{:else}
<div class="text">{title || "Bindings"}</div>
{/if}
<div class="buttons"> <div class="buttons">
<Button secondary quiet on:click={hide}>Cancel</Button> <Button secondary quiet on:click={hide}>Cancel</Button>
<slot name="buttons" /> <slot name="buttons" />
{#if $resizable}
<ActionButton
size="M"
quiet
selected={$modal}
on:click={() => modal.set(!$modal)}
>
<Icon name={$modal ? "Minimize" : "Maximize"} size="S" />
</ActionButton>
{/if}
</div> </div>
</header> </header>
<slot name="body" /> <slot name="body" />
@ -206,7 +223,7 @@
height: 420px; height: 420px;
background: var(--background); background: var(--background);
border: var(--border-light); border: var(--border-light);
z-index: 999; z-index: 100;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
@ -233,7 +250,7 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 999; z-index: 100;
display: block; display: block;
transition: opacity 260ms ease-out; transition: opacity 260ms ease-out;
} }
@ -274,4 +291,8 @@
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.buttons :global(.icon) {
width: 16px;
display: flex;
}
</style> </style>

View File

@ -19,7 +19,6 @@
.drawer-contents { .drawer-contents {
overflow-y: auto; overflow-y: auto;
flex: 1 1 auto; flex: 1 1 auto;
height: 0;
} }
.container { .container {
height: 100%; height: 100%;

View File

@ -1,58 +1,54 @@
<script context="module">
export const directions = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]
</script>
<script> <script>
import Tooltip from "../Tooltip/Tooltip.svelte" import {
import { fade } from "svelte/transition" default as AbsTooltip,
TooltipPosition,
TooltipType,
} from "../Tooltip/AbsTooltip.svelte"
export let direction = "n"
export let name = "Add" export let name = "Add"
export let hidden = false export let hidden = false
export let size = "M" export let size = "M"
export let hoverable = false export let hoverable = false
export let disabled = false export let disabled = false
export let color export let color
export let hoverColor
export let tooltip export let tooltip
export let tooltipPosition = TooltipPosition.Bottom
$: rotation = getRotation(direction) export let tooltipType = TooltipType.Default
export let tooltipColor
let showTooltip = false export let tooltipWrap = true
export let newStyles = false
const getRotation = direction => {
return directions.indexOf(direction) * 45
}
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <AbsTooltip
<!-- svelte-ignore a11y-click-events-have-key-events --> text={tooltip}
<div type={tooltipType}
class="icon" position={tooltipPosition}
on:mouseover={() => (showTooltip = true)} color={tooltipColor}
on:focus={() => (showTooltip = true)} noWrap={tooltipWrap}
on:mouseleave={() => (showTooltip = false)}
on:click={() => (showTooltip = false)}
> >
<svg <div class="icon" class:newStyles>
on:click <svg
class:hoverable on:click
class:disabled class:hoverable
class="spectrum-Icon spectrum-Icon--size{size}" class:disabled
focusable="false" class="spectrum-Icon spectrum-Icon--size{size}"
aria-hidden={hidden} focusable="false"
aria-label={name} aria-hidden={hidden}
style={`transform: rotate(${rotation}deg); ${ aria-label={name}
color ? `color: ${color};` : "" style={`${color ? `color: ${color};` : ""} ${
}`} hoverColor
> ? `--hover-color: ${hoverColor}`
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" /> : "--hover-color: var(--spectrum-alias-icon-color-selected-hover)"
</svg> }`}
{#if tooltip && showTooltip} >
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}> <use
<Tooltip textWrapping direction="top" text={tooltip} /> style="pointer-events: none;"
</div> xlink:href="#spectrum-icon-18-{name}"
{/if} />
</div> </svg>
</div>
</AbsTooltip>
<style> <style>
.icon { .icon {
@ -60,19 +56,25 @@
display: grid; display: grid;
place-items: center; place-items: center;
} }
.newStyles {
color: var(--spectrum-global-color-gray-700);
}
svg.hoverable { svg.hoverable {
pointer-events: all; pointer-events: all;
transition: color var(--spectrum-global-animation-duration-100, 130ms); transition: color var(--spectrum-global-animation-duration-100, 130ms);
} }
svg.hoverable:hover { svg.hoverable:hover {
color: var(--spectrum-alias-icon-color-selected-hover) !important; color: var(--hover-color) !important;
cursor: pointer; cursor: pointer;
} }
svg.hoverable:active { svg.hoverable:active {
color: var(--spectrum-global-color-blue-400) !important; color: var(--spectrum-global-color-blue-400) !important;
} }
.newStyles svg.hoverable:hover,
.newStyles svg.hoverable:active {
color: var(--spectrum-global-color-gray-900) !important;
}
svg.disabled { svg.disabled {
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;

View File

@ -24,6 +24,7 @@
export let text = "" export let text = ""
export let fixed = false export let fixed = false
export let color = null export let color = null
export let noWrap = false
let wrapper let wrapper
let hovered = false let hovered = false
@ -105,6 +106,7 @@
<Portal target=".spectrum"> <Portal target=".spectrum">
<span <span
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open" class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
class:noWrap
style={`left:${left}px;top:${top}px;${tooltipStyle}`} style={`left:${left}px;top:${top}px;${tooltipStyle}`}
transition:fade|local={{ duration: 130 }} transition:fade|local={{ duration: 130 }}
> >
@ -118,6 +120,9 @@
.abs-tooltip { .abs-tooltip {
display: contents; display: contents;
} }
.spectrum-Tooltip.noWrap .spectrum-Tooltip-label {
width: max-content;
}
.spectrum-Tooltip { .spectrum-Tooltip {
position: absolute; position: absolute;
z-index: 9999; z-index: 9999;

View File

@ -19,7 +19,7 @@ export { default as ActionMenu } from "./ActionMenu/ActionMenu.svelte"
export { default as Button } from "./Button/Button.svelte" export { default as Button } from "./Button/Button.svelte"
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte" export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon, directions } from "./Icon/Icon.svelte" export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as Toggle } from "./Form/Toggle.svelte" export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte"

View File

@ -49,7 +49,7 @@
<div class="side-bar-controls"> <div class="side-bar-controls">
<NavHeader <NavHeader
title="Automations" title="Automations"
placeholder="Search for automation" placeholder="Search for automations"
bind:value={searchString} bind:value={searchString}
onAdd={() => modal.show()} onAdd={() => modal.show()}
/> />

View File

@ -40,7 +40,7 @@
indentMore, indentMore,
indentLess, indentLess,
} from "@codemirror/commands" } from "@codemirror/commands"
import { Compartment } from "@codemirror/state" import { Compartment, EditorState } from "@codemirror/state"
import { javascript } from "@codemirror/lang-javascript" import { javascript } from "@codemirror/lang-javascript"
import { EditorModes } from "./" import { EditorModes } from "./"
import { themeStore } from "stores/portal" import { themeStore } from "stores/portal"
@ -53,6 +53,7 @@
export let autocompleteEnabled = true export let autocompleteEnabled = true
export let autofocus = false export let autofocus = false
export let jsBindingWrapping = true export let jsBindingWrapping = true
export let readonly = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -82,8 +83,8 @@
}) })
} }
// For handlebars only. // Match decoration for HBS bindings
const bindStyle = new MatchDecorator({ const hbsMatchDeco = new MatchDecorator({
regexp: FIND_ANY_HBS_REGEX, regexp: FIND_ANY_HBS_REGEX,
decoration: () => { decoration: () => {
return Decoration.mark({ return Decoration.mark({
@ -94,12 +95,35 @@
}) })
}, },
}) })
const hbsMatchDecoPlugin = ViewPlugin.define(
let plugin = ViewPlugin.define(
view => ({ view => ({
decorations: bindStyle.createDeco(view), decorations: hbsMatchDeco.createDeco(view),
update(u) { update(u) {
this.decorations = bindStyle.updateDeco(u, this.decorations) this.decorations = hbsMatchDeco.updateDeco(u, this.decorations)
},
}),
{
decorations: v => v.decorations,
}
)
// Match decoration for snippets
const snippetMatchDeco = new MatchDecorator({
regexp: /snippets\.[^\s(]+/g,
decoration: () => {
return Decoration.mark({
tag: "span",
attributes: {
class: "snippet-wrap",
},
})
},
})
const snippetMatchDecoPlugin = ViewPlugin.define(
view => ({
decorations: snippetMatchDeco.createDeco(view),
update(u) {
this.decorations = snippetMatchDeco.updateDeco(u, this.decorations)
}, },
}), }),
{ {
@ -141,33 +165,21 @@
const buildBaseExtensions = () => { const buildBaseExtensions = () => {
return [ return [
...(mode.name === "handlebars" ? [plugin] : []),
history(),
drawSelection(), drawSelection(),
dropCursor(), dropCursor(),
bracketMatching(), bracketMatching(),
closeBrackets(), closeBrackets(),
highlightActiveLine(),
syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }), syntaxHighlighting(oneDarkHighlightStyle, { fallback: true }),
highlightActiveLineGutter(),
highlightSpecialChars(), highlightSpecialChars(),
lineNumbers(),
foldGutter(),
EditorView.lineWrapping, EditorView.lineWrapping,
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
keymap.of(buildKeymap()),
themeConfig.of([...(isDark ? [oneDark] : [])]), themeConfig.of([...(isDark ? [oneDark] : [])]),
] ]
} }
// None of this is reactive, but it never has been, so we just assume most
// config flags aren't changed at runtime
const buildExtensions = base => { const buildExtensions = base => {
const complete = [...base] let complete = [...base]
if (autocompleteEnabled) { if (autocompleteEnabled) {
complete.push( complete.push(
@ -175,7 +187,10 @@
override: [...completions], override: [...completions],
closeOnBlur: true, closeOnBlur: true,
icons: false, icons: false,
optionClass: () => "autocomplete-option", optionClass: completion =>
completion.simple
? "autocomplete-option-simple"
: "autocomplete-option",
}) })
) )
complete.push( complete.push(
@ -201,20 +216,49 @@
view.dispatch(tr) view.dispatch(tr)
return true return true
} }
return false return false
}) })
) )
} }
// JS only plugins
if (mode.name === "javascript") { if (mode.name === "javascript") {
complete.push(snippetMatchDecoPlugin)
complete.push(javascript()) complete.push(javascript())
complete.push(highlightWhitespace()) if (!readonly) {
complete.push(highlightWhitespace())
}
}
// HBS only plugins
else {
complete.push(hbsMatchDecoPlugin)
} }
if (placeholder) { if (placeholder) {
complete.push(placeholderFn(placeholder)) complete.push(placeholderFn(placeholder))
} }
if (readonly) {
complete.push(EditorState.readOnly.of(true))
} else {
complete = [
...complete,
history(),
highlightActiveLine(),
highlightActiveLineGutter(),
lineNumbers(),
foldGutter(),
keymap.of(buildKeymap()),
EditorView.updateListener.of(v => {
const docStr = v.state.doc?.toString()
if (docStr === value) {
return
}
dispatch("change", docStr)
}),
]
}
return complete return complete
} }
@ -300,7 +344,6 @@
/* Active line */ /* Active line */
.code-editor :global(.cm-line) { .code-editor :global(.cm-line) {
height: 16px;
padding: 0 var(--spacing-s); padding: 0 var(--spacing-s);
color: var(--spectrum-alias-text-color); color: var(--spectrum-alias-text-color);
} }
@ -318,6 +361,9 @@
background: var(--spectrum-global-color-gray-100) !important; background: var(--spectrum-global-color-gray-100) !important;
z-index: -2; z-index: -2;
} }
.code-editor :global(.cm-highlightSpace:before) {
color: var(--spectrum-global-color-gray-500);
}
/* Code selection */ /* Code selection */
.code-editor :global(.cm-selectionBackground) { .code-editor :global(.cm-selectionBackground) {
@ -360,9 +406,12 @@
font-style: italic; font-style: italic;
} }
/* Highlight bindings */ /* Highlight bindings and snippets */
.code-editor :global(.binding-wrap) { .code-editor :global(.binding-wrap) {
color: var(--spectrum-global-color-blue-700); color: var(--spectrum-global-color-blue-700) !important;
}
.code-editor :global(.snippet-wrap *) {
color: #61afef !important;
} }
/* Completion popover */ /* Completion popover */
@ -391,7 +440,8 @@
} }
/* Completion item container */ /* Completion item container */
.code-editor :global(.autocomplete-option) { .code-editor :global(.autocomplete-option),
.code-editor :global(.autocomplete-option-simple) {
padding: var(--spacing-s) var(--spacing-m) !important; padding: var(--spacing-s) var(--spacing-m) !important;
padding-left: calc(16px + 2 * var(--spacing-m)) !important; padding-left: calc(16px + 2 * var(--spacing-m)) !important;
display: flex; display: flex;
@ -399,9 +449,13 @@
align-items: center; align-items: center;
color: var(--spectrum-alias-text-color); color: var(--spectrum-alias-text-color);
} }
.code-editor :global(.autocomplete-option-simple) {
padding-left: var(--spacing-s) !important;
}
/* Highlighted completion item */ /* Highlighted completion item */
.code-editor :global(.autocomplete-option[aria-selected]) { .code-editor :global(.autocomplete-option[aria-selected]),
.code-editor :global(.autocomplete-option-simple[aria-selected]) {
background: var(--spectrum-global-color-blue-400); background: var(--spectrum-global-color-blue-400);
color: white; color: white;
} }
@ -417,6 +471,9 @@
font-family: var(--font-sans); font-family: var(--font-sans);
text-transform: capitalize; text-transform: capitalize;
} }
.code-editor :global(.autocomplete-option-simple .cm-completionLabel) {
text-transform: none;
}
/* Completion item type */ /* Completion item type */
.code-editor :global(.autocomplete-option .cm-completionDetail) { .code-editor :global(.autocomplete-option .cm-completionDetail) {
@ -454,14 +511,14 @@
.code-editor :global(.binding__example) { .code-editor :global(.binding__example) {
padding: 0; padding: 0;
margin: 0; margin: 0;
font-size: var(--font-size-s); font-size: 12px;
font-family: var(--font-mono); font-family: var(--font-mono);
white-space: pre; white-space: pre;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
max-height: 480px; max-height: 480px;
} }
.code-editor :global(.binding__example) { .code-editor :global(.binding__example.helper) {
color: var(--spectrum-global-color-blue-700); color: var(--spectrum-global-color-blue-700);
} }
.code-editor :global(.binding__example span) { .code-editor :global(.binding__example span) {

View File

@ -102,6 +102,29 @@ export const getHelperCompletions = mode => {
}, []) }, [])
} }
export const snippetAutoComplete = snippets => {
return function myCompletions(context) {
if (!snippets?.length) {
return null
}
const word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit) {
return null
}
return {
from: word.from,
options: snippets.map(snippet => ({
label: `snippets.${snippet.name}`,
type: "text",
simple: true,
apply: (view, completion, from, to) => {
insertSnippet(view, from, to, completion.label)
},
})),
}
}
}
const bindingFilter = (options, query) => { const bindingFilter = (options, query) => {
return options.filter(completion => { return options.filter(completion => {
const section_parsed = completion.section.name.toLowerCase() const section_parsed = completion.section.name.toLowerCase()
@ -247,6 +270,20 @@ export const insertBinding = (view, from, to, text, mode) => {
}) })
} }
export const insertSnippet = (view, from, to, text) => {
let cursorPos = from + text.length
view.dispatch({
changes: {
from,
to,
insert: text,
},
selection: {
anchor: cursorPos,
},
})
}
export const bindingsToCompletions = (bindings, mode) => { export const bindingsToCompletions = (bindings, mode) => {
const bindingByCategory = groupBy(bindings, "category") const bindingByCategory = groupBy(bindings, "category")
const categoryMeta = bindings?.reduce((acc, ele) => { const categoryMeta = bindings?.reduce((acc, ele) => {

View File

@ -59,7 +59,7 @@
class="searchButton" class="searchButton"
class:hide={search} class:hide={search}
> >
<Icon size="S" name="Search" /> <Icon size="S" name="Search" hoverable hoverColor="var(--ink)" />
</div> </div>
<div <div
@ -68,7 +68,7 @@
class="addButton" class="addButton"
class:rotate={search} class:rotate={search}
> >
<Icon name="Add" /> <Icon name="Add" hoverable hoverColor="var(--ink)" />
</div> </div>
</div> </div>

View File

@ -8,6 +8,7 @@
export let iconTooltip export let iconTooltip
export let withArrow = false export let withArrow = false
export let withActions = true export let withActions = true
export let showActions = false
export let indentLevel = 0 export let indentLevel = 0
export let text export let text
export let border = true export let border = true
@ -68,6 +69,8 @@
class:border class:border
class:selected class:selected
class:withActions class:withActions
class:showActions
class:actionsOpen={highlighted && withActions}
class:scrollable class:scrollable
class:highlighted class:highlighted
class:selectedBy class:selectedBy
@ -168,8 +171,10 @@
--avatars-background: var(--spectrum-global-color-gray-300); --avatars-background: var(--spectrum-global-color-gray-300);
} }
.nav-item:hover .actions, .nav-item:hover .actions,
.hovering .actions { .hovering .actions,
visibility: visible; .nav-item.withActions.actionsOpen .actions,
.nav-item.withActions.showActions .actions {
opacity: 1;
} }
.nav-item-content { .nav-item-content {
flex: 1 1 auto; flex: 1 1 auto;
@ -272,7 +277,6 @@
position: relative; position: relative;
display: grid; display: grid;
place-items: center; place-items: center;
visibility: hidden;
order: 3; order: 3;
opacity: 0; opacity: 0;
width: 20px; width: 20px;

View File

@ -7,7 +7,7 @@
Body, Body,
Button, Button,
} from "@budibase/bbui" } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { import {
decodeJSBinding, decodeJSBinding,
encodeJSBinding, encodeJSBinding,
@ -19,27 +19,33 @@
getHelperCompletions, getHelperCompletions,
jsAutocomplete, jsAutocomplete,
hbAutocomplete, hbAutocomplete,
snippetAutoComplete,
EditorModes, EditorModes,
bindingsToCompletions, bindingsToCompletions,
} from "../CodeEditor" } from "../CodeEditor"
import BindingSidePanel from "./BindingSidePanel.svelte" import BindingSidePanel from "./BindingSidePanel.svelte"
import EvaluationSidePanel from "./EvaluationSidePanel.svelte" import EvaluationSidePanel from "./EvaluationSidePanel.svelte"
import SnippetSidePanel from "./SnippetSidePanel.svelte"
import { BindingHelpers } from "./utils" import { BindingHelpers } from "./utils"
import formatHighlight from "json-format-highlight" import formatHighlight from "json-format-highlight"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { get } from "svelte/store" import { licensing } from "stores/portal"
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
export let bindings export let bindings = []
export let value = "" export let value = ""
export let allowHBS = true
export let allowJS = false export let allowJS = false
export let allowHelpers = true export let allowHelpers = true
export let allowSnippets = true
export let context = null export let context = null
export let snippets = null
export let autofocusEditor = false export let autofocusEditor = false
export let placeholder = null
export let showTabBar = true
const drawerContext = getContext("drawer")
const Modes = { const Modes = {
Text: "Text", Text: "Text",
JavaScript: "JavaScript", JavaScript: "JavaScript",
@ -47,61 +53,110 @@
const SidePanels = { const SidePanels = {
Bindings: "FlashOn", Bindings: "FlashOn",
Evaluation: "Play", Evaluation: "Play",
Snippets: "Code",
} }
let mode
let sidePanel
let initialValueJS = value?.startsWith?.("{{ js ") let initialValueJS = value?.startsWith?.("{{ js ")
let mode = initialValueJS ? Modes.JavaScript : Modes.Text
let sidePanel = SidePanels.Bindings
let getCaretPosition
let insertAtPos
let jsValue = initialValueJS ? value : null let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value let hbsValue = initialValueJS ? null : value
let getCaretPosition
let insertAtPos
let targetMode = null let targetMode = null
let expressionResult let expressionResult
let drawerIsModal
let evaluating = false let evaluating = false
$: drawerContext?.modal.subscribe(val => (drawerIsModal = val)) $: useSnippets = allowSnippets && !$licensing.isFreePlan
$: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text] $: editorModeOptions = getModeOptions(allowHBS, allowJS)
$: sideTabs = context $: sidePanelOptions = getSidePanelOptions(
? [SidePanels.Evaluation, SidePanels.Bindings] bindings,
: [SidePanels.Bindings] context,
$: enrichedBindings = enrichBindings(bindings, context) allowSnippets,
mode
)
$: enrichedBindings = enrichBindings(bindings, context, snippets)
$: usingJS = mode === Modes.JavaScript $: usingJS = mode === Modes.JavaScript
$: editorMode = $: editorMode =
mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars mode === Modes.JavaScript ? EditorModes.JS : EditorModes.Handlebars
$: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue $: editorValue = editorMode === EditorModes.JS ? jsValue : hbsValue
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value) $: runtimeExpression = readableToRuntimeBinding(enrichedBindings, value)
$: requestUpdateEvaluation(runtimeExpression, context) $: requestEval(runtimeExpression, context, snippets)
$: bindingCompletions = bindingsToCompletions(enrichedBindings, editorMode)
$: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos)
$: hbsCompletions = [ $: hbsCompletions = getHBSCompletions(bindingCompletions)
hbAutocomplete([ $: jsCompletions = getJSCompletions(bindingCompletions, snippets, useSnippets)
...bindingCompletions, $: {
...getHelperCompletions(EditorModes.Handlebars), // Ensure a valid side panel option is always selected
]), if (sidePanel && !sidePanelOptions.includes(sidePanel)) {
] sidePanel = sidePanelOptions[0]
$: jsCompletions = [ }
jsAutocomplete([ }
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
const debouncedUpdateEvaluation = Utils.debounce((expression, context) => { const getHBSCompletions = bindingCompletions => {
expressionResult = processStringSync(expression || "", context) return [
hbAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.Handlebars),
]),
]
}
const getJSCompletions = (bindingCompletions, snippets, useSnippets) => {
const completions = [
jsAutocomplete([
...bindingCompletions,
...getHelperCompletions(EditorModes.JS),
]),
]
if (useSnippets) {
completions.push(snippetAutoComplete(snippets))
}
return completions
}
const getModeOptions = (allowHBS, allowJS) => {
let options = []
if (allowHBS) {
options.push(Modes.Text)
}
if (allowJS) {
options.push(Modes.JavaScript)
}
return options
}
const getSidePanelOptions = (bindings, context, useSnippets, mode) => {
let options = []
if (bindings?.length) {
options.push(SidePanels.Bindings)
}
if (context) {
options.push(SidePanels.Evaluation)
}
if (useSnippets && mode === Modes.JavaScript) {
options.push(SidePanels.Snippets)
}
return options
}
const debouncedEval = Utils.debounce((expression, context, snippets) => {
expressionResult = processStringSync(expression || "", {
...context,
snippets,
})
evaluating = false evaluating = false
}, 260) }, 260)
const requestUpdateEvaluation = (expression, context) => { const requestEval = (expression, context, snippets) => {
evaluating = true evaluating = true
debouncedUpdateEvaluation(expression, context) debouncedEval(expression, context, snippets)
} }
const getBindingValue = (binding, context) => { const getBindingValue = (binding, context, snippets) => {
const js = `return $("${binding.runtimeBinding}")` const js = `return $("${binding.runtimeBinding}")`
const hbs = encodeJSBinding(js) const hbs = encodeJSBinding(js)
const res = processStringSync(hbs, context) const res = processStringSync(hbs, { ...context, snippets })
return JSON.stringify(res, null, 2) return JSON.stringify(res, null, 2)
} }
@ -116,12 +171,12 @@
}) })
} }
const enrichBindings = (bindings, context) => { const enrichBindings = (bindings, context, snippets) => {
return bindings.map(binding => { return bindings.map(binding => {
if (!context) { if (!context) {
return binding return binding
} }
const value = getBindingValue(binding, context) const value = getBindingValue(binding, context, snippets)
return { return {
...binding, ...binding,
value, value,
@ -133,7 +188,7 @@
const updateValue = val => { const updateValue = val => {
const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val) const runtimeExpression = readableToRuntimeBinding(enrichedBindings, val)
dispatch("change", val) dispatch("change", val)
requestUpdateEvaluation(runtimeExpression, context) requestEval(runtimeExpression, context, snippets)
} }
const onSelectHelper = (helper, js) => { const onSelectHelper = (helper, js) => {
@ -149,7 +204,14 @@
if (targetMode || newMode === mode) { if (targetMode || newMode === mode) {
return return
} }
if (editorValue) {
// Get the raw editor value to see if we are abandoning changes
let rawValue = editorValue
if (mode === Modes.JavaScript) {
rawValue = decodeJSBinding(rawValue)
}
if (rawValue?.length) {
targetMode = newMode targetMode = newMode
} else { } else {
mode = newMode mode = newMode
@ -177,47 +239,52 @@
jsValue = encodeJSBinding(e.detail) jsValue = encodeJSBinding(e.detail)
updateValue(jsValue) updateValue(jsValue)
} }
onMount(() => {
// Set the initial mode appropriately
const initialValueMode = initialValueJS ? Modes.JavaScript : Modes.Text
if (editorModeOptions.includes(initialValueMode)) {
mode = initialValueMode
} else {
mode = editorModeOptions[0]
}
// Set the initial side panel
sidePanel = sidePanelOptions[0]
})
</script> </script>
<DrawerContent padding={false}> <DrawerContent padding={false}>
<div class="binding-panel"> <div class="binding-panel">
<div class="main"> <div class="main">
<div class="tabs"> {#if showTabBar}
<div class="editor-tabs"> <div class="tabs">
{#each editorTabs as tab} <div class="editor-tabs">
<ActionButton {#each editorModeOptions as editorMode}
size="M" <ActionButton
quiet size="M"
selected={mode === tab} quiet
on:click={() => changeMode(tab)} selected={mode === editorMode}
> on:click={() => changeMode(editorMode)}
{capitalise(tab)} >
</ActionButton> {capitalise(editorMode)}
{/each} </ActionButton>
{/each}
</div>
<div class="side-tabs">
{#each sidePanelOptions as panel}
<ActionButton
size="M"
quiet
selected={sidePanel === panel}
on:click={() => changeSidePanel(panel)}
>
<Icon name={panel} size="S" />
</ActionButton>
{/each}
</div>
</div> </div>
<div class="side-tabs"> {/if}
{#each sideTabs as tab}
<ActionButton
size="M"
quiet
selected={sidePanel === tab}
on:click={() => changeSidePanel(tab)}
>
<Icon name={tab} size="S" />
</ActionButton>
{/each}
{#if drawerContext && get(drawerContext.resizable)}
<ActionButton
size="M"
quiet
selected={drawerIsModal}
on:click={() => drawerContext.modal.set(!drawerIsModal)}
>
<Icon name={drawerIsModal ? "Minimize" : "Maximize"} size="S" />
</ActionButton>
{/if}
</div>
</div>
<div class="editor"> <div class="editor">
{#if mode === Modes.Text} {#if mode === Modes.Text}
{#key hbsCompletions} {#key hbsCompletions}
@ -228,7 +295,8 @@
bind:insertAtPos bind:insertAtPos
completions={hbsCompletions} completions={hbsCompletions}
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder="Add bindings by typing &#123;&#123; or use the menu on the right" placeholder={placeholder ||
"Add bindings by typing {{ or use the menu on the right"}
jsBindingWrapping={false} jsBindingWrapping={false}
/> />
{/key} {/key}
@ -242,7 +310,8 @@
bind:getCaretPosition bind:getCaretPosition
bind:insertAtPos bind:insertAtPos
autofocus={autofocusEditor} autofocus={autofocusEditor}
placeholder="Add bindings by typing $ or use the menu on the right" placeholder={placeholder ||
"Add bindings by typing $ or use the menu on the right"}
jsBindingWrapping jsBindingWrapping
/> />
{/key} {/key}
@ -289,6 +358,11 @@
{evaluating} {evaluating}
expression={editorValue} expression={editorValue}
/> />
{:else if sidePanel === SidePanels.Snippets}
<SnippetSidePanel
addSnippet={snippet => bindingHelpers.onSelectSnippet(snippet)}
{snippets}
/>
{/if} {/if}
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@
export let context = null export let context = null
let search = "" let search = ""
let searching = false
let popover let popover
let popoverAnchor let popoverAnchor
let hoverTarget let hoverTarget
@ -74,6 +75,13 @@
if (!context || !binding.value || binding.value === "") { if (!context || !binding.value || binding.value === "") {
return return
} }
// Roles have always been broken for JS. We need to exclude them from
// showing a popover as it will show "Error while executing JS".
if (binding.category === "Role") {
return
}
stopHidingPopover() stopHidingPopover()
popoverAnchor = target popoverAnchor = target
hoverTarget = { hoverTarget = {
@ -112,6 +120,17 @@
hideTimeout = null hideTimeout = null
} }
} }
const startSearching = async () => {
searching = true
search = ""
}
const stopSearching = e => {
e.stopPropagation()
searching = false
search = ""
}
</script> </script>
<Popover <Popover
@ -141,7 +160,6 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="binding-side-panel"> <div class="binding-side-panel">
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#if selectedCategory} {#if selectedCategory}
@ -158,25 +176,34 @@
{#if !selectedCategory} {#if !selectedCategory}
<div class="header"> <div class="header">
<span class="search-input"> {#if searching}
<Input <div class="search-input">
placeholder={"Search for bindings"} <Input
autocomplete="off" placeholder="Search for bindings"
bind:value={search} autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/> />
</span> {:else}
<span <div class="title">Bindings</div>
class="search-input-icon" <Icon
on:click={() => { size="S"
search = null name="Search"
}} hoverable
class:searching={search} newStyles
> on:click={startSearching}
<Icon size="S" name={search ? "Close" : "Search"} /> />
</span> {/if}
</div> </div>
{/if} {/if}
{#if !selectedCategory && !search} {#if !selectedCategory && !search}
<ul class="category-list"> <ul class="category-list">
{#each categoryNames as categoryName} {#each categoryNames as categoryName}
@ -281,18 +308,15 @@
background: var(--background); background: var(--background);
z-index: 1; z-index: 1;
} }
.header :global(input) { .header :global(input) {
border: none; border: none;
border-radius: 0; border-radius: 0;
background: none; background: none;
padding: 0; padding: 0;
} }
.search-input { .search-input,
flex: 1; .title {
} flex: 1 1 auto;
.search-input-icon.searching {
cursor: pointer;
} }
ul.category-list { ul.category-list {

View File

@ -1,6 +1,6 @@
<script> <script>
import BindingPanel from "./BindingPanel.svelte" import BindingPanel from "./BindingPanel.svelte"
import { previewStore } from "stores/builder" import { previewStore, snippets } from "stores/builder"
import { onMount } from "svelte" import { onMount } from "svelte"
export let bindings = [] export let bindings = []
@ -28,6 +28,7 @@
<BindingPanel <BindingPanel
bindings={enrichedBindings} bindings={enrichedBindings}
context={$previewStore.selectedComponentContext} context={$previewStore.selectedComponentContext}
snippets={$snippets}
{value} {value}
{allowJS} {allowJS}
{allowHelpers} {allowHelpers}

View File

@ -55,7 +55,7 @@
</div> </div>
{/if} {/if}
<span /> <span />
<Icon name="Copy" hoverable on:click={copy} /> <Icon name="Copy" size="S" hoverable on:click={copy} />
{:else} {:else}
<div>Preview</div> <div>Preview</div>
{#if evaluating} {#if evaluating}
@ -65,7 +65,7 @@
{/if} {/if}
<span /> <span />
{#if !empty} {#if !empty}
<Icon name="Copy" hoverable on:click={copy} /> <Icon name="Copy" newStyles size="S" hoverable on:click={copy} />
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@ -1,5 +1,6 @@
<script> <script>
import BindingPanel from "./BindingPanel.svelte" import BindingPanel from "./BindingPanel.svelte"
import { snippets } from "stores/builder"
export let bindings = [] export let bindings = []
export let value = "" export let value = ""
@ -20,6 +21,7 @@
<BindingPanel <BindingPanel
bindings={enrichedBindings} bindings={enrichedBindings}
snippets={$snippets}
{value} {value}
{allowJS} {allowJS}
{context} {context}

View File

@ -0,0 +1,160 @@
<script>
import {
Button,
Drawer,
Input,
Icon,
AbsTooltip,
TooltipType,
notifications,
} from "@budibase/bbui"
import BindingPanel from "components/common/bindings/BindingPanel.svelte"
import { decodeJSBinding, encodeJSBinding } from "@budibase/string-templates"
import { snippets } from "stores/builder"
import { getSequentialName } from "helpers/duplicate"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { ValidSnippetNameRegex } from "@budibase/shared-core"
export let snippet
export const show = () => drawer.show()
export const hide = () => drawer.hide()
const firstCharNumberRegex = /^[0-9].*$/
let drawer
let name = ""
let code = ""
let loading = false
let deleteConfirmationDialog
$: defaultName = getSequentialName($snippets, "MySnippet", x => x.name)
$: key = snippet?.name
$: name = snippet?.name || defaultName
$: code = snippet?.code ? encodeJSBinding(snippet.code) : ""
$: rawJS = decodeJSBinding(code)
$: nameError = validateName(name, $snippets)
const saveSnippet = async () => {
loading = true
try {
const newSnippet = { name, code: rawJS }
await snippets.saveSnippet(newSnippet)
drawer.hide()
notifications.success(`Snippet ${newSnippet.name} saved`)
} catch (error) {
notifications.error(error.message || "Error saving snippet")
}
loading = false
}
const deleteSnippet = async () => {
loading = true
try {
await snippets.deleteSnippet(snippet.name)
drawer.hide()
} catch (error) {
notifications.error("Error deleting snippet")
}
loading = false
}
const validateName = (name, snippets) => {
if (!name?.length) {
return "Name is required"
}
if (snippets.some(snippet => snippet.name === name)) {
return "That name is already in use"
}
if (firstCharNumberRegex.test(name)) {
return "Can't start with a number"
}
if (!ValidSnippetNameRegex.test(name)) {
return "No special characters or spaces"
}
return null
}
</script>
<Drawer bind:this={drawer}>
<svelte:fragment slot="title">
{#if snippet}
{snippet.name}
{:else}
<div class="name" class:invalid={nameError != null}>
<span>Name</span>
<Input bind:value={name} />
{#if nameError}
<AbsTooltip text={nameError} type={TooltipType.Negative}>
<Icon
name="Help"
size="S"
color="var(--spectrum-global-color-red-400)"
/>
</AbsTooltip>
{/if}
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="buttons">
{#if snippet}
<Button
warning
on:click={deleteConfirmationDialog.show}
disabled={loading}
>
Delete
</Button>
{/if}
<Button
cta
on:click={saveSnippet}
disabled={!snippet && (loading || nameError)}
>
Save
</Button>
</svelte:fragment>
<svelte:fragment slot="body">
{#key key}
<BindingPanel
allowHBS={false}
allowJS
allowSnippets={false}
showTabBar={false}
placeholder="return function(input) &#10100; ... &#10101;"
value={code}
on:change={e => (code = e.detail)}
>
<div slot="tabs">
<Input placeholder="Name" />
</div>
</BindingPanel>
{/key}
</svelte:fragment>
</Drawer>
<ConfirmDialog
bind:this={deleteConfirmationDialog}
title="Delete snippet"
body={`Are you sure you want to delete ${snippet?.name}?`}
onOk={deleteSnippet}
/>
<style>
.name {
display: flex;
gap: var(--spacing-l);
align-items: center;
position: relative;
}
.name :global(input) {
width: 200px;
}
.name.invalid :global(input) {
padding-right: 32px;
}
.name :global(.icon) {
position: absolute;
right: 10px;
}
</style>

View File

@ -0,0 +1,278 @@
<script>
import {
Input,
Layout,
Icon,
Popover,
Tags,
Tag,
Body,
Button,
} from "@budibase/bbui"
import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte"
import { EditorModes } from "components/common/CodeEditor"
import SnippetDrawer from "./SnippetDrawer.svelte"
import { licensing } from "stores/portal"
import UpgradeButton from "pages/builder/portal/_components/UpgradeButton.svelte"
export let addSnippet
export let snippets
let search = ""
let searching = false
let popover
let popoverAnchor
let hoveredSnippet
let hideTimeout
let snippetDrawer
let editableSnippet
$: enableSnippets = !$licensing.isFreePlan
$: filteredSnippets = getFilteredSnippets(enableSnippets, snippets, search)
const getFilteredSnippets = (enableSnippets, snippets, search) => {
if (!enableSnippets || !snippets?.length) {
return []
}
if (!search?.length) {
return snippets
}
return snippets.filter(snippet =>
snippet.name.toLowerCase().includes(search.toLowerCase())
)
}
const showSnippet = (snippet, target) => {
stopHidingPopover()
popoverAnchor = target
hoveredSnippet = snippet
popover.show()
}
const hidePopover = () => {
hideTimeout = setTimeout(() => {
popover.hide()
popoverAnchor = null
hoveredSnippet = null
hideTimeout = null
}, 100)
}
const stopHidingPopover = () => {
if (hideTimeout) {
clearTimeout(hideTimeout)
hideTimeout = null
}
}
const startSearching = () => {
searching = true
search = ""
}
const stopSearching = () => {
searching = false
search = ""
}
const createSnippet = () => {
editableSnippet = null
snippetDrawer.show()
}
const editSnippet = (e, snippet) => {
e.preventDefault()
e.stopPropagation()
editableSnippet = snippet
snippetDrawer.show()
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="snippet-side-panel">
<Layout noPadding gap="S">
<div class="header">
{#if enableSnippets}
{#if searching}
<div class="search-input">
<Input
placeholder="Search for snippets"
autocomplete="off"
bind:value={search}
autofocus
/>
</div>
<Icon
size="S"
name="Close"
hoverable
newStyles
on:click={stopSearching}
/>
{:else}
<div class="title">Snippets</div>
<Icon
size="S"
name="Search"
hoverable
newStyles
on:click={startSearching}
/>
<Icon
size="S"
name="Add"
hoverable
newStyles
on:click={createSnippet}
/>
{/if}
{:else}
<div class="title">
Snippets
<Tags>
<Tag icon="LockClosed">Premium</Tag>
</Tags>
</div>
{/if}
</div>
<div class="snippet-list">
{#if enableSnippets && filteredSnippets?.length}
{#each filteredSnippets as snippet}
<div
class="snippet"
on:mouseenter={e => showSnippet(snippet, e.target)}
on:mouseleave={hidePopover}
on:click={() => addSnippet(snippet)}
>
{snippet.name}
<Icon
name="Edit"
hoverable
newStyles
size="S"
on:click={e => editSnippet(e, snippet)}
/>
</div>
{/each}
{:else}
<div class="upgrade">
<Body size="S">
Snippets let you create reusable JS functions and values that can
all be managed in one place
</Body>
{#if enableSnippets}
<Button cta on:click={createSnippet}>Create snippet</Button>
{:else}
<UpgradeButton />
{/if}
</div>
{/if}
</div>
</Layout>
</div>
<Popover
align="left-outside"
bind:this={popover}
anchor={popoverAnchor}
minWidth={0}
maxWidth={480}
maxHeight={480}
dismissible={false}
on:mouseenter={stopHidingPopover}
on:mouseleave={hidePopover}
>
<div class="snippet-popover">
{#key hoveredSnippet}
<CodeEditor
value={hoveredSnippet.code.trim()}
mode={EditorModes.JS}
readonly
/>
{/key}
</div>
</Popover>
<SnippetDrawer bind:this={snippetDrawer} snippet={editableSnippet} />
<style>
.snippet-side-panel {
border-left: var(--border-light);
height: 100%;
overflow: auto;
}
/* Header */
.header {
height: 53px;
padding: 0 var(--spacing-l);
display: flex;
align-items: center;
border-bottom: var(--border-light);
position: sticky;
top: 0;
gap: var(--spacing-m);
background: var(--background);
z-index: 1;
}
.header :global(input) {
border: none;
border-radius: 0;
background: none;
padding: 0;
}
.search-input,
.title {
flex: 1 1 auto;
}
.title {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
/* Upgrade */
.upgrade {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-l);
}
.upgrade :global(p) {
text-align: center;
align-self: center;
}
/* List */
.snippet-list {
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
.snippet {
font-size: var(--font-size-s);
padding: var(--spacing-m);
border-radius: 4px;
background-color: var(--spectrum-global-color-gray-200);
transition: background-color 130ms ease-out, color 130ms ease-out,
border-color 130ms ease-out;
word-wrap: break-word;
display: flex;
justify-content: space-between;
}
.snippet:hover {
color: var(--spectrum-global-color-gray-900);
background-color: var(--spectrum-global-color-gray-50);
cursor: pointer;
}
/* Popover */
.snippet-popover {
width: 400px;
}
</style>

View File

@ -38,4 +38,11 @@ export class BindingHelpers {
this.insertAtPos({ start, end, value: insertVal }) this.insertAtPos({ start, end, value: insertVal })
} }
} }
// Adds a snippet to the expression
onSelectSnippet(snippet) {
const pos = this.getCaretPosition()
const { start, end } = pos
this.insertAtPos({ start, end, value: `snippets.${snippet.name}` })
}
} }

View File

@ -19,7 +19,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { API } from "api" import { API } from "api"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { import {
previewStore, previewStore,
builderStore, builderStore,
@ -45,7 +45,7 @@
let appActionPopoverAnchor let appActionPopoverAnchor
let publishing = false let publishing = false
$: filteredApps = $apps.filter(app => app.devId === application) $: filteredApps = $appsStore.apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: latestDeployments = $deploymentStore $: latestDeployments = $deploymentStore
.filter(deployment => deployment.status === "SUCCESS") .filter(deployment => deployment.status === "SUCCESS")
@ -129,7 +129,7 @@
} }
try { try {
await API.unpublishApp(selectedApp.prodId) await API.unpublishApp(selectedApp.prodId)
await apps.load() await appsStore.load()
notifications.send("App unpublished", { notifications.send("App unpublished", {
type: "success", type: "success",
icon: "GlobeStrike", icon: "GlobeStrike",
@ -141,7 +141,7 @@
const completePublish = async () => { const completePublish = async () => {
try { try {
await apps.load() await appsStore.load()
await deploymentStore.load() await deploymentStore.load()
} catch (err) { } catch (err) {
notifications.error("Error refreshing app") notifications.error("Error refreshing app")

View File

@ -2,10 +2,17 @@
import { Input, notifications } from "@budibase/bbui" import { Input, notifications } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder"
import { API } from "api" import { API } from "api"
export let appId
export let appName
export let onDeleteSuccess = () => {
$goto("/builder")
}
let deleting = false
export const show = () => { export const show = () => {
deletionModal.show() deletionModal.show()
} }
@ -17,32 +24,52 @@
let deletionModal let deletionModal
let deletionConfirmationAppName let deletionConfirmationAppName
const copyName = () => {
deletionConfirmationAppName = appName
}
const deleteApp = async () => { const deleteApp = async () => {
if (!appId) {
console.error("No app id provided")
return
}
deleting = true
try { try {
await API.deleteApp($appStore.appId) await API.deleteApp(appId)
apps.load() appsStore.load()
notifications.success("App deleted successfully") notifications.success("App deleted successfully")
$goto("/builder") onDeleteSuccess()
} catch (err) { } catch (err) {
notifications.error("Error deleting app") notifications.error("Error deleting app")
deleting = false
} }
} }
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<ConfirmDialog <ConfirmDialog
bind:this={deletionModal} bind:this={deletionModal}
title="Delete app" title="Delete app"
okText="Delete" okText="Delete"
onOk={deleteApp} onOk={deleteApp}
onCancel={() => (deletionConfirmationAppName = null)} onCancel={() => (deletionConfirmationAppName = null)}
disabled={deletionConfirmationAppName !== $appStore.name} disabled={deletionConfirmationAppName !== appName || deleting}
> >
Are you sure you want to delete <b>{$appStore.name}</b>? Are you sure you want to delete
<span class="app-name" role="button" tabindex={-1} on:click={copyName}>
{appName}
</span>?
<br /> <br />
Please enter the app name below to confirm. Please enter the app name below to confirm.
<br /><br /> <br /><br />
<Input <Input bind:value={deletionConfirmationAppName} placeholder={appName} />
bind:value={deletionConfirmationAppName}
placeholder={$appStore.name}
/>
</ConfirmDialog> </ConfirmDialog>
<style>
.app-name {
cursor: pointer;
font-weight: bold;
display: inline-block;
}
</style>

View File

@ -31,17 +31,11 @@
: null} : null}
> >
<Body> <Body>
You are currently on our <span class="free-plan">Free plan</span>. Upgrade You have exceeded the app limit for your current plan. Upgrade to get
to our Pro plan to get unlimited apps and additional features. unlimited apps and additional features!
</Body> </Body>
{#if !$auth.user.accountPortalAccess} {#if !$auth.user.accountPortalAccess}
<Body>Please contact the account holder to upgrade.</Body> <Body>Please contact the account holder to upgrade.</Body>
{/if} {/if}
</ModalContent> </ModalContent>
</Modal> </Modal>
<style>
.free-plan {
font-weight: 600;
}
</style>

View File

@ -5,10 +5,14 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import AppRowContext from "./AppRowContext.svelte"
import FavouriteAppButton from "pages/builder/portal/apps/FavouriteAppButton.svelte"
export let app export let app
export let lockedAction export let lockedAction
let actionsOpen = false
$: editing = app.sessions?.length $: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
$: unclickable = !isBuilder && !app.deployed $: unclickable = !isBuilder && !app.deployed
@ -42,8 +46,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="app-row" class="app-row"
on:click={lockedAction || handleDefaultClick}
class:unclickable class:unclickable
class:actionsOpen
class:favourite={app.favourite}
on:click={lockedAction || handleDefaultClick}
> >
<div class="title"> <div class="title">
<div class="app-icon"> <div class="app-icon">
@ -74,21 +80,35 @@
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body> <Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
</div> </div>
{#if isBuilder} <div class="actions-wrap">
<div class="app-row-actions"> <div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToOverview}> {#if isBuilder}
Manage <div class="row-action">
</Button> <Button size="S" secondary on:click={lockedAction || goToBuilder}>
<Button size="S" primary on:click={lockedAction || goToBuilder}> Edit
Edit </Button>
</Button> </div>
<div class="row-action">
<AppRowContext
{app}
on:open={() => {
actionsOpen = true
}}
on:close={() => {
actionsOpen = false
}}
/>
</div>
{:else}
<!-- this can happen if an app builder has app user access to an app -->
<Button size="S" secondary>View</Button>
{/if}
</div> </div>
{:else if app.deployed}
<!-- this can happen if an app builder has app user access to an app --> <div class="favourite-icon">
<div class="app-row-actions"> <FavouriteAppButton {app} noWrap />
<Button size="S" secondary>View</Button>
</div> </div>
{/if} </div>
</div> </div>
<style> <style>
@ -108,6 +128,16 @@
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
} }
.app-row .favourite-icon {
display: none;
}
.app-row:hover .favourite-icon,
.app-row.favourite .favourite-icon,
.app-row.actionsOpen .favourite-icon {
display: flex;
}
.updated { .updated {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
display: flex; display: flex;
@ -143,11 +173,23 @@
} }
.app-row-actions { .app-row-actions {
display: none;
}
.app-row:hover .app-row-actions,
.app-row.actionsOpen .app-row-actions {
gap: var(--spacing-m); gap: var(--spacing-m);
display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
display: flex;
}
.actions-wrap {
gap: var(--spacing-m);
display: flex;
justify-content: flex-end;
min-height: var(--spectrum-alias-item-height-s);
} }
.name { .name {

View File

@ -0,0 +1,108 @@
<script>
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
import DeleteModal from "components/deploy/DeleteModal.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import ExportAppModal from "./ExportAppModal.svelte"
import DuplicateAppModal from "./DuplicateAppModal.svelte"
import { onMount } from "svelte"
import { licensing } from "stores/portal"
export let app
export let align = "right"
export let options
let deleteModal
let exportModal
let duplicateModal
let exportPublishedVersion = false
let loaded = false
const getActions = app => {
if (!loaded) {
return []
}
return [
{
id: "duplicate",
icon: "Copy",
onClick: duplicateModal.show,
body: "Duplicate",
},
{
id: "exportDev",
icon: "Export",
onClick: () => {
exportPublishedVersion = false
exportModal.show()
},
body: "Export latest edited app",
},
{
id: "exportProd",
icon: "Export",
onClick: () => {
exportPublishedVersion = true
exportModal.show()
},
body: "Export latest published app",
},
{
id: "delete",
icon: "Delete",
onClick: deleteModal.show,
body: "Delete",
},
].filter(action => {
if (action.id === "exportProd" && app.deployed !== true) {
return false
} else if (Array.isArray(options) && !options.includes(action.id)) {
return false
}
return true
})
}
$: actions = getActions(app, loaded)
onMount(() => {
loaded = true
})
let appLimitModal
</script>
<DeleteModal
bind:this={deleteModal}
appId={app.devId}
appName={app.name}
onDeleteSuccess={async () => {
await licensing.init()
}}
/>
<AppLimitModal bind:this={appLimitModal} />
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={duplicateModal} padding={false}>
<DuplicateAppModal
appId={app.devId}
appName={app.name}
onDuplicateSuccess={async () => {
await licensing.init()
}}
/>
</Modal>
<ActionMenu {align} on:open on:close>
<div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" />
</div>
{#each actions as action}
<MenuItem icon={action.icon} on:click={action.onClick}>
{action.body}
</MenuItem>
{/each}
</ActionMenu>

View File

@ -6,7 +6,7 @@
Label, Label,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let app export let app
@ -49,7 +49,7 @@
return return
} }
try { try {
await apps.update(app.instance._id, { await appsStore.save(app.instance._id, {
icon: { name, color }, icon: { name, color },
}) })
} catch (error) { } catch (error) {

View File

@ -9,13 +9,14 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { initialise } from "stores/builder" import { initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import { apps, admin, auth } from "stores/portal" import { appsStore, admin, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
import TemplateCard from "components/common/TemplateCard.svelte" import TemplateCard from "components/common/TemplateCard.svelte"
import { lowercase } from "helpers" import { lowercase } from "helpers"
import { sdk } from "@budibase/shared-core"
export let template export let template
@ -92,7 +93,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(appsStore).apps
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications }) appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template }) appValidation.file(validation, { template })
@ -141,6 +142,11 @@
// Create user // Create user
await auth.setInitInfo({}) await auth.setInitInfo({})
if (!sdk.users.isBuilder($auth.user, createdApp?.appId)) {
// Refresh for access to created applications
await auth.getSelf()
}
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) { } catch (error) {
creating = false creating = false

View File

@ -0,0 +1,163 @@
<script>
import {
ModalContent,
Input,
notifications,
Layout,
keepOpen,
} from "@budibase/bbui"
import { createValidationStore } from "helpers/validation/yup"
import { writable, get } from "svelte/store"
import * as appValidation from "helpers/validation/yup/app"
import { appsStore, auth } from "stores/portal"
import { onMount } from "svelte"
import { API } from "api"
import { sdk } from "@budibase/shared-core"
export let appId
export let appName
export let onDuplicateSuccess = () => {}
const validation = createValidationStore()
const values = writable({ name: appName + " copy", url: null })
const appPrefix = "/app"
let defaultAppName = appName + " copy"
let duplicating = false
$: {
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
const resolveAppName = name => {
return name ? name.trim() : null
}
const resolveAppUrl = name => {
let parsedName
const resolvedName = resolveAppName(name)
parsedName = resolvedName ? resolvedName.toLowerCase() : ""
const parsedUrl = parsedName ? parsedName.replace(/\s+/g, "-") : ""
return encodeURI(parsedUrl)
}
const nameToUrl = appName => {
let resolvedUrl = resolveAppUrl(appName)
tidyUrl(resolvedUrl)
}
const tidyUrl = url => {
if (url && !url.startsWith("/")) {
url = `/${url}`
}
$values.url = url === "" ? null : url
}
const duplicateApp = async () => {
duplicating = true
let data = new FormData()
data.append("name", $values.name.trim())
if ($values.url) {
data.append("url", $values.url.trim())
}
try {
const app = await API.duplicateApp(data, appId)
appsStore.load()
if (!sdk.users.isBuilder($auth.user, app?.duplicateAppId)) {
// Refresh for access to created applications
await auth.getSelf()
}
onDuplicateSuccess()
notifications.success("App duplicated successfully")
} catch (err) {
notifications.error("Error duplicating app")
duplicating = false
}
}
const setupValidation = async () => {
const applications = get(appsStore).apps
appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications })
const { url } = $values
validation.check({
...$values,
url: url?.[0] === "/" ? url.substring(1, url.length) : url,
})
}
$: appUrl = `${window.location.origin}${
$values.url
? `${appPrefix}${$values.url}`
: `${appPrefix}${resolveAppUrl($values.name)}`
}`
onMount(async () => {
nameToUrl($values.name)
await setupValidation()
})
</script>
<ModalContent
title={"Duplicate App"}
onConfirm={async () => {
validation.check({
...$values,
})
if ($validation.valid) {
await duplicateApp()
} else {
return keepOpen
}
}}
>
<Layout gap="S" noPadding>
<Input
autofocus={true}
bind:value={$values.name}
disabled={duplicating}
error={$validation.touched.name && $validation.errors.name}
on:blur={() => ($validation.touched.name = true)}
on:change={nameToUrl($values.name)}
label="Name"
placeholder={defaultAppName}
/>
<span>
<Input
bind:value={$values.url}
disabled={duplicating}
error={$validation.touched.url && $validation.errors.url}
on:blur={() => ($validation.touched.url = true)}
on:change={tidyUrl($values.url)}
label="URL"
placeholder={$values.url
? $values.url
: `/${resolveAppUrl($values.name)}`}
/>
{#if $values.url && $values.url !== "" && !$validation.errors.url}
<div class="app-server" title={appUrl}>
{appUrl}
</div>
{/if}
</span>
</Layout>
</ModalContent>
<style>
.app-server {
color: var(--spectrum-global-color-gray-600);
margin-top: 10px;
width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -121,6 +121,7 @@
<Input <Input
type="password" type="password"
label="Password" label="Password"
autocomplete="new-password"
placeholder="Type here..." placeholder="Type here..."
bind:value={password} bind:value={password}
error={$validation.errors.password} error={$validation.errors.password}

View File

@ -7,7 +7,7 @@
Layout, Layout,
Label, Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
@ -37,7 +37,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(appsStore).apps
appValidation.name(validation, { appValidation.name(validation, {
apps: applications, apps: applications,
currentApp: { currentApp: {
@ -62,7 +62,7 @@
async function updateApp() { async function updateApp() {
try { try {
await apps.update(app.appId, { await appsStore.save(app.appId, {
name: $values.name?.trim(), name: $values.name?.trim(),
url: $values.url?.trim(), url: $values.url?.trim(),
icon: { icon: {

View File

@ -22,6 +22,7 @@ body {
--grey-7: var(--spectrum-global-color-gray-700); --grey-7: var(--spectrum-global-color-gray-700);
--grey-8: var(--spectrum-global-color-gray-800); --grey-8: var(--spectrum-global-color-gray-800);
--grey-9: var(--spectrum-global-color-gray-900); --grey-9: var(--spectrum-global-color-gray-900);
--spectrum-global-color-yellow-1000: #d8b500;
color: var(--ink); color: var(--ink);
background-color: var(--background-alt); background-color: var(--background-alt);

View File

@ -48,3 +48,53 @@ export const duplicateName = (name, allNames) => {
return `${baseName} ${number}` return `${baseName} ${number}`
} }
/**
* More flexible alternative to the above function, which handles getting the
* next sequential name from an array of existing items while accounting for
* any type of prefix, and being able to deeply retrieve that name from the
* existing item array.
*
* Examples with a prefix of "foo":
* [] => "foo"
* ["foo"] => "foo2"
* ["foo", "foo6"] => "foo7"
*
* Examples with a prefix of "foo " (space at the end):
* [] => "foo"
* ["foo"] => "foo 2"
* ["foo", "foo 6"] => "foo 7"
*
* @param items the array of existing items
* @param prefix the string prefix of each name, including any spaces desired
* @param getName optional function to extract the name for an item, if not a
* flat array of strings
*/
export const getSequentialName = (items, prefix, getName = x => x) => {
if (!prefix?.length || !getName) {
return null
}
const trimmedPrefix = prefix.trim()
if (!items?.length) {
return trimmedPrefix
}
let max = 0
items.forEach(item => {
const name = getName(item)
if (typeof name !== "string" || !name.startsWith(trimmedPrefix)) {
return
}
const split = name.split(trimmedPrefix)
if (split.length !== 2) {
return
}
if (split[1].trim() === "") {
split[1] = "1"
}
const num = parseInt(split[1])
if (num > max) {
max = num
}
})
return max === 0 ? trimmedPrefix : `${prefix}${max + 1}`
}

View File

@ -1,5 +1,5 @@
import { expect, describe, it } from "vitest" import { expect, describe, it } from "vitest"
import { duplicateName } from "../duplicate" import { duplicateName, getSequentialName } from "../duplicate"
describe("duplicate", () => { describe("duplicate", () => {
describe("duplicates a name ", () => { describe("duplicates a name ", () => {
@ -40,3 +40,64 @@ describe("duplicate", () => {
}) })
}) })
}) })
describe("getSequentialName", () => {
it("handles nullish items", async () => {
const name = getSequentialName(null, "foo", () => {})
expect(name).toBe("foo")
})
it("handles nullish prefix", async () => {
const name = getSequentialName([], null, () => {})
expect(name).toBe(null)
})
it("handles nullish getName function", async () => {
const name = getSequentialName([], "foo", null)
expect(name).toBe(null)
})
it("handles just the prefix", async () => {
const name = getSequentialName(["foo"], "foo", x => x)
expect(name).toBe("foo2")
})
it("handles continuous ranges", async () => {
const name = getSequentialName(["foo", "foo2", "foo3"], "foo", x => x)
expect(name).toBe("foo4")
})
it("handles discontinuous ranges", async () => {
const name = getSequentialName(["foo", "foo3"], "foo", x => x)
expect(name).toBe("foo4")
})
it("handles a space inside the prefix", async () => {
const name = getSequentialName(["foo", "foo 2", "foo 3"], "foo ", x => x)
expect(name).toBe("foo 4")
})
it("handles a space inside the prefix with just the prefix", async () => {
const name = getSequentialName(["foo"], "foo ", x => x)
expect(name).toBe("foo 2")
})
it("handles no matches", async () => {
const name = getSequentialName(["aaa", "bbb"], "foo", x => x)
expect(name).toBe("foo")
})
it("handles similar names", async () => {
const name = getSequentialName(
["fooo1", "2foo", "a3foo4", "5foo5"],
"foo",
x => x
)
expect(name).toBe("foo")
})
it("handles non-string names", async () => {
const name = getSequentialName([null, 4123, [], {}], "foo", x => x)
expect(name).toBe("foo")
})
})

View File

@ -15,7 +15,14 @@
FancySelect, FancySelect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { builderStore, appStore, roles } from "stores/builder" import { builderStore, appStore, roles } from "stores/builder"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" import {
groups,
licensing,
appsStore,
users,
auth,
admin,
} from "stores/portal"
import { import {
fetchData, fetchData,
Constants, Constants,
@ -54,7 +61,7 @@
let inviteFailureResponse = "" let inviteFailureResponse = ""
$: validEmail = emailValidator(email) === true $: validEmail = emailValidator(email) === true
$: prodAppId = apps.getProdAppID($appStore.appId) $: prodAppId = appsStore.getProdAppID($appStore.appId)
$: promptInvite = showInvite( $: promptInvite = showInvite(
filteredInvites, filteredInvites,
filteredUsers, filteredUsers,

View File

@ -8,7 +8,7 @@
userStore, userStore,
deploymentStore, deploymentStore,
} from "stores/builder" } from "stores/builder"
import { auth, apps } from "stores/portal" import { auth, appsStore } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
Icon, Icon,
@ -52,7 +52,7 @@
const pkg = await API.fetchAppPackage(application) const pkg = await API.fetchAppPackage(application)
await initialise(pkg) await initialise(pkg)
await apps.load() await appsStore.load()
await deploymentStore.load() await deploymentStore.load()
loaded = true loaded = true

View File

@ -40,7 +40,7 @@
<!-- routify:options index=3 --> <!-- routify:options index=3 -->
<div class="root"> <div class="root">
<AutomationPanel {modal} {webhookModal} /> <AutomationPanel {modal} {webhookModal} />
<div class="content"> <div class="content drawer-container">
{#if $automationStore.automations?.length} {#if $automationStore.automations?.length}
<slot /> <slot />
{:else} {:else}

View File

@ -10,6 +10,7 @@
navigationStore, navigationStore,
selectedScreen, selectedScreen,
hoverStore, hoverStore,
snippets,
} from "stores/builder" } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
@ -68,6 +69,7 @@
hostname: window.location.hostname, hostname: window.location.hostname,
port: window.location.port, port: window.location.port,
}, },
snippets: $snippets,
} }
// Refresh the preview when required // Refresh the preview when required

View File

@ -3,7 +3,7 @@
import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui" import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui"
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "stores/builder" import { isOnlyUser, appStore } from "stores/builder"
let deleteModal let deleteModal
</script> </script>
@ -67,7 +67,11 @@
</Page> </Page>
</div> </div>
<DeleteModal bind:this={deleteModal} /> <DeleteModal
bind:this={deleteModal}
appId={$appStore.appId}
appName={$appStore.name}
/>
<style> <style>
.delete-action :global(.text) { .delete-action :global(.text) {

View File

@ -18,7 +18,7 @@
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte" import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import { auth, licensing, admin, apps } from "stores/portal" import { auth, licensing, admin, appsStore } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal" import Portal from "svelte-portal"
@ -36,7 +36,7 @@
let status = null let status = null
let timeRange = null let timeRange = null
let loaded = false let loaded = false
$: app = $apps.find(app => $appStore.appId?.includes(app.appId)) $: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
@ -129,7 +129,7 @@
async function save({ detail }) { async function save({ detail }) {
try { try {
await apps.update($appStore.appId, { await appsStore.save($appStore.appId, {
automations: { automations: {
chainAutomations: detail, chainAutomations: detail,
}, },

View File

@ -10,10 +10,10 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appUrl = `${window.origin}/embed${app?.url}` $: appUrl = `${window.origin}/embed${app?.url}`
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -8,12 +8,12 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
import ExportAppModal from "components/start/ExportAppModal.svelte" import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte" import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId === $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId === $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -11,13 +11,13 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { appStore, initialise } from "stores/builder" import { appStore, initialise } from "stores/builder"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { API } from "api" import { API } from "api"
let updatingModal let updatingModal
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View File

@ -12,7 +12,14 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, organisation, auth, groups, licensing } from "stores/portal" import {
appsStore,
organisation,
auth,
groups,
licensing,
enrichedApps,
} from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
@ -31,7 +38,9 @@
$: userGroups = $groups.filter(group => $: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id) group.users.find(user => user._id === $auth.user?._id)
) )
$: publishedApps = $apps.filter(app => app.status === AppStatus.DEPLOYED) $: publishedApps = $enrichedApps.filter(
app => app.status === AppStatus.DEPLOYED
)
$: userApps = getUserApps(publishedApps, userGroups, $auth.user) $: userApps = getUserApps(publishedApps, userGroups, $auth.user)
function getUserApps(publishedApps, userGroups, user) { function getUserApps(publishedApps, userGroups, user) {
@ -46,12 +55,12 @@
return userGroups.find(group => { return userGroups.find(group => {
return groups.actions return groups.actions
.getGroupAppIds(group) .getGroupAppIds(group)
.map(role => apps.extractAppId(role)) .map(role => appsStore.extractAppId(role))
.includes(app.appId) .includes(app.appId)
}) })
} else { } else {
return Object.keys($auth.user?.roles) return Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x)) .map(x => appsStore.extractAppId(x))
.includes(app.appId) .includes(app.appId)
} }
}) })
@ -76,7 +85,7 @@
onMount(async () => { onMount(async () => {
try { try {
await organisation.init() await organisation.init()
await apps.load() await appsStore.load()
await groups.actions.init() await groups.actions.init()
} catch (error) { } catch (error) {
notifications.error("Error loading apps") notifications.error("Error loading apps")

View File

@ -1,7 +1,7 @@
<script> <script>
import { isActive, redirect, goto, url } from "@roxi/routify" import { isActive, redirect, goto, url } from "@roxi/routify"
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui" import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
import { organisation, auth, menu, apps } from "stores/portal" import { organisation, auth, menu, appsStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import UpgradeButton from "./_components/UpgradeButton.svelte" import UpgradeButton from "./_components/UpgradeButton.svelte"
import MobileMenu from "./_components/MobileMenu.svelte" import MobileMenu from "./_components/MobileMenu.svelte"
@ -16,7 +16,8 @@
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: isOnboarding = !$apps.length && sdk.users.hasBuilderPermissions($auth.user) $: isOnboarding =
!$appsStore.apps.length && sdk.users.hasBuilderPermissions($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -40,7 +41,7 @@
} else { } else {
try { try {
// We need to load apps to know if we need to show onboarding fullscreen // We need to load apps to know if we need to show onboarding fullscreen
await Promise.all([apps.load(), organisation.init()]) await Promise.all([appsStore.load(), organisation.init()])
} catch (error) { } catch (error) {
notifications.error("Error getting org config") notifications.error("Error getting org config")
} }

View File

@ -18,7 +18,7 @@
Divider, Divider,
ActionButton, ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { licensing, users, apps, auditLogs } from "stores/portal" import { licensing, users, appsStore, auditLogs } from "stores/portal"
import LockedFeature from "../../_components/LockedFeature.svelte" import LockedFeature from "../../_components/LockedFeature.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
@ -102,7 +102,7 @@
enrich(parseEventObject($auditLogs.events), selectedEvents, "id"), enrich(parseEventObject($auditLogs.events), selectedEvents, "id"),
"id" "id"
) )
$: sortedApps = sort(enrich($apps, selectedApps, "appId"), "name") $: sortedApps = sort(enrich($appsStore.apps, selectedApps, "appId"), "name")
const debounce = value => { const debounce = value => {
clearTimeout(timer) clearTimeout(timer)

View File

@ -0,0 +1,43 @@
<script>
import { Icon, TooltipPosition, TooltipType } from "@budibase/bbui"
import { auth } from "stores/portal"
export let app
export let size = "S"
export let position = TooltipPosition.Top
export let noWrap = false
export let hoverColor = "var(--ink)"
</script>
<Icon
name={app?.favourite ? "Star" : "StarOutline"}
hoverable
color={app?.favourite ? "var(--spectrum-global-color-yellow-1000)" : null}
tooltip={app?.favourite ? "Remove from favourites" : "Add to favourites"}
tooltipType={TooltipType.Info}
tooltipPosition={position}
tooltipWrap={noWrap}
{hoverColor}
{size}
on:click={async e => {
e.stopPropagation()
const userAppFavourites = new Set([...($auth.user.appFavourites || [])])
let processedAppIds = []
if ($auth.user.appFavourites && app?.appId) {
if (userAppFavourites.has(app.appId)) {
userAppFavourites.delete(app.appId)
} else {
userAppFavourites.add(app.appId)
}
processedAppIds = [...userAppFavourites]
} else {
processedAppIds = [app.appId]
}
await auth.updateSelf({
appFavourites: processedAppIds,
})
}}
disabled={!app}
/>

View File

@ -1,8 +1,8 @@
<script> <script>
import { params, redirect } from "@roxi/routify" import { params, redirect } from "@roxi/routify"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
$: app = $apps.find(app => app.appId === $params.appId) $: app = $appsStore.apps.find(app => app.appId === $params.appId)
$: { $: {
if (!app) { if (!app) {
$redirect("../") $redirect("../")

View File

@ -1,12 +1,21 @@
<script> <script>
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { apps, auth, sideBarCollapsed } from "stores/portal" import { auth, sideBarCollapsed, enrichedApps } from "stores/portal"
import { Link, Body, ActionButton } from "@budibase/bbui" import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import {
Link,
Body,
Button,
Icon,
TooltipPosition,
TooltipType,
} from "@budibase/bbui"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import ErrorSVG from "./ErrorSVG.svelte" import ErrorSVG from "./ErrorSVG.svelte"
$: app = $apps.find(app => app.appId === $params.appId) $: app = $enrichedApps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app) $: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
@ -30,42 +39,63 @@
$: fetchScreens(app?.devId) $: fetchScreens(app?.devId)
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="container"> <div class="container">
<div class="header"> <div class="header">
{#if $sideBarCollapsed} {#if $sideBarCollapsed}
<ActionButton <div class="headerButton" on:click={() => sideBarCollapsed.set(false)}>
quiet <Icon
icon="Rail" name={"Rail"}
on:click={() => sideBarCollapsed.set(false)} hoverable
> tooltip="Expand"
Menu tooltipPosition={TooltipPosition.Right}
</ActionButton> tooltipType={TooltipType.Info}
hoverColor={"var(--ink)"}
/>
</div>
{:else} {:else}
<ActionButton <div class="headerButton" on:click={() => sideBarCollapsed.set(true)}>
quiet <Icon
icon="RailRightOpen" name={"RailRightOpen"}
on:click={() => sideBarCollapsed.set(true)} hoverable
> tooltip="Collapse"
Collapse tooltipType={TooltipType.Info}
</ActionButton> tooltipPosition={TooltipPosition.Top}
hoverColor={"var(--ink)"}
size="S"
/>
</div>
{/if} {/if}
{#if isBuilder} {#if isBuilder}
<ActionButton <Button
quiet size="M"
icon="Edit" secondary
on:click={() => $goto(`/builder/app/${app.devId}`)} on:click={() => $goto(`/builder/app/${app.devId}`)}
> >
Edit Edit
</ActionButton> </Button>
{/if} {/if}
<ActionButton <div class="headerButton">
disabled={noScreens} <FavouriteAppButton {app} />
quiet </div>
icon="LinkOut" <div class="headerButton" on:click={() => window.open(iframeUrl, "_blank")}>
on:click={() => window.open(iframeUrl, "_blank")} <Icon
> name="LinkOut"
Fullscreen disabled={noScreens}
</ActionButton> hoverable
tooltip="Open in new tab"
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverColor={"var(--ink)"}
size="S"
/>
</div>
<AppRowContext
{app}
options={["duplicate", "delete", "exportDev", "exportProd"]}
align="left"
/>
</div> </div>
{#if noScreens} {#if noScreens}
<div class="noScreens"> <div class="noScreens">
@ -83,6 +113,15 @@
</div> </div>
<style> <style>
.headerButton {
color: var(--grey-7);
cursor: pointer;
}
.headerButton:hover {
color: var(--ink);
}
.container { .container {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
@ -96,7 +135,7 @@
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xl);
flex: 0 0 50px; flex: 0 0 50px;
} }

View File

@ -1,23 +1,21 @@
<script> <script>
import { apps, sideBarCollapsed } from "stores/portal" import { sideBarCollapsed, enrichedApps, auth } from "stores/portal"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import NavHeader from "components/common/NavHeader.svelte" import NavHeader from "components/common/NavHeader.svelte"
import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import { sdk } from "@budibase/shared-core"
let searchString let searchString
let opened
$: filteredApps = $apps $: filteredApps = $enrichedApps.filter(app => {
.filter(app => { return (
return ( !searchString ||
!searchString || app.name.toLowerCase().includes(searchString.toLowerCase())
app.name.toLowerCase().includes(searchString.toLowerCase()) )
) })
})
.sort((a, b) => {
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1
})
</script> </script>
<div class="side-bar" class:collapsed={$sideBarCollapsed}> <div class="side-bar" class:collapsed={$sideBarCollapsed}>
@ -37,13 +35,40 @@
selected={!$params.appId} selected={!$params.appId}
/> />
{#each filteredApps as app} {#each filteredApps as app}
<NavItem <span
text={app.name} class="side-bar-app-entry"
icon={app.icon?.name || "Apps"} class:favourite={app.favourite}
iconColor={app.icon?.color} class:actionsOpen={opened == app.appId}
selected={$params.appId === app.appId} >
on:click={() => $goto(`./${app.appId}`)} <NavItem
/> text={app.name}
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)}
withActions
showActions
>
<div class="app-entry-actions">
{#if sdk.users.isBuilder($auth.user, app?.devId)}
<AppRowContext
{app}
align="left"
on:open={() => {
opened = app.appId
}}
on:close={() => {
opened = null
}}
/>
{/if}
</div>
<div class="favourite-icon">
<FavouriteAppButton {app} size="XS" />
</div>
</NavItem>
</span>
{/each} {/each}
</div> </div>
</div> </div>
@ -86,4 +111,23 @@
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.side-bar-app-entry :global(.nav-item-content .actions) {
width: auto;
display: flex;
gap: var(--spacing-s);
}
.side-bar-app-entry:hover .app-entry-actions,
.side-bar-app-entry:hover .favourite-icon,
.side-bar-app-entry.favourite .favourite-icon,
.side-bar-app-entry.actionsOpen .app-entry-actions,
.side-bar-app-entry.actionsOpen .favourite-icon {
opacity: 1;
}
.side-bar-app-entry .app-entry-actions,
.side-bar-app-entry .favourite-icon {
opacity: 0;
}
</style> </style>

View File

@ -2,7 +2,7 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { import {
admin, admin,
apps, appsStore,
templates, templates,
licensing, licensing,
groups, groups,
@ -14,7 +14,7 @@
import PortalSideBar from "./_components/PortalSideBar.svelte" import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = !!$apps?.length let loaded = !!$appsStore.apps?.length
onMount(async () => { onMount(async () => {
try { try {
@ -34,7 +34,10 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) { if (
!$appsStore.apps.length &&
sdk.users.hasBuilderPermissions($auth.user)
) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {
@ -46,7 +49,7 @@
{#if loaded} {#if loaded}
<div class="page"> <div class="page">
{#if $apps.length > 0} {#if $appsStore.apps.length > 0}
<PortalSideBar /> <PortalSideBar />
{/if} {/if}
<slot /> <slot />

View File

@ -5,7 +5,7 @@
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import TemplateDisplay from "components/common/TemplateDisplay.svelte" import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { apps, templates, licensing } from "stores/portal" import { appsStore, templates, licensing } from "stores/portal"
import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page" import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page"
let template let template
@ -35,7 +35,7 @@
} }
</script> </script>
{#if !$apps.length} {#if !$appsStore.apps.length}
<FirstAppOnboarding /> <FirstAppOnboarding />
{:else} {:else}
<Page> <Page>

View File

@ -19,13 +19,18 @@
import { automationStore, initialise } from "stores/builder" import { automationStore, initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, auth, admin, licensing, environment } from "stores/portal" import {
appsStore,
auth,
admin,
licensing,
environment,
enrichedApps,
} from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants"
import Logo from "assets/bb-space-man.svg" import Logo from "assets/bb-space-man.svg"
let sortBy = "name"
let template let template
let creationModal let creationModal
let appLimitModal let appLimitModal
@ -33,56 +38,27 @@
let searchTerm = "" let searchTerm = ""
let creatingFromTemplate = false let creatingFromTemplate = false
let automationErrors let automationErrors
let accessFilterList = null
$: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}` $: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}`
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: filteredApps = filterApps($enrichedApps, searchTerm)
$: filteredApps = enrichedApps.filter( $: automationErrors = getAutomationErrors(filteredApps || [])
app =>
(searchTerm
? app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
: true) &&
(accessFilterList !== null
? accessFilterList?.includes(
`${app?.type}_${app?.tenantId}_${app?.appId}`
)
: true)
)
$: automationErrors = getAutomationErrors(enrichedApps)
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
const filterApps = (apps, searchTerm) => {
return apps?.filter(app => {
const query = searchTerm?.trim()?.replace(/\s/g, "")
if (query) {
return app?.name?.toLowerCase().includes(query.toLowerCase())
} else {
return true
}
})
}
const usersLimitLockAction = $licensing?.errUserLimit const usersLimitLockAction = $licensing?.errUserLimit
? () => accountLockedModal.show() ? () => accountLockedModal.show()
: null : null
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
...app,
deployed: app.status === AppStatus.DEPLOYED,
lockedYou: app.lockedBy && app.lockedBy.email === user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== user?.email,
}))
if (sortBy === "status") {
return enrichedApps.sort((a, b) => {
if (a.status === b.status) {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
}
return a.status === AppStatus.DEPLOYED ? -1 : 1
})
} else if (sortBy === "updated") {
return enrichedApps.sort((a, b) => {
const aUpdated = a.updatedAt || "9999"
const bUpdated = b.updatedAt || "9999"
return aUpdated < bUpdated ? 1 : -1
})
} else {
return enrichedApps.sort((a, b) => {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
})
}
}
const getAutomationErrors = apps => { const getAutomationErrors = apps => {
const automationErrors = {} const automationErrors = {}
for (let app of apps) { for (let app of apps) {
@ -117,7 +93,7 @@
const initiateAppCreation = async () => { const initiateAppCreation = async () => {
if ($licensing?.usageMetrics?.apps >= 100) { if ($licensing?.usageMetrics?.apps >= 100) {
appLimitModal.show() appLimitModal.show()
} else if ($apps?.length) { } else if ($appsStore.apps?.length) {
$goto("/builder/portal/apps/create") $goto("/builder/portal/apps/create")
} else { } else {
template = null template = null
@ -136,7 +112,7 @@
const templateKey = template.key.split("/")[1] const templateKey = template.key.split("/")[1]
let appName = templateKey.replace(/-/g, " ") let appName = templateKey.replace(/-/g, " ")
const appsWithSameName = $apps.filter(app => const appsWithSameName = $appsStore.apps.filter(app =>
app.name?.startsWith(appName) app.name?.startsWith(appName)
) )
appName = `${appName} ${appsWithSameName.length + 1}` appName = `${appName} ${appsWithSameName.length + 1}`
@ -217,7 +193,7 @@
: "View error"} : "View error"}
on:dismiss={async () => { on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId }) await automationStore.actions.clearLogErrors({ appId })
await apps.load() await appsStore.load()
}} }}
message={automationErrorMessage(appId)} message={automationErrorMessage(appId)}
/> />
@ -233,7 +209,7 @@
</div> </div>
</div> </div>
{#if enrichedApps.length} {#if $appsStore.apps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.canCreateApps($auth.user)} {#if $auth.user && sdk.users.canCreateApps($auth.user)}
@ -245,7 +221,7 @@
> >
Create new app Create new app
</Button> </Button>
{#if $apps?.length > 0 && !$admin.offlineMode} {#if $appsStore.apps?.length > 0 && !$admin.offlineMode}
<Button <Button
size="M" size="M"
secondary secondary
@ -255,7 +231,7 @@
View templates View templates
</Button> </Button>
{/if} {/if}
{#if !$apps?.length} {#if !$appsStore.apps?.length}
<Button <Button
size="L" size="L"
quiet quiet
@ -267,11 +243,14 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if enrichedApps.length > 1} {#if $appsStore.apps.length > 1}
<div class="app-actions"> <div class="app-actions">
<Select <Select
autoWidth autoWidth
bind:value={sortBy} value={$appsStore.sortBy}
on:change={e => {
appsStore.updateSort(e.detail)
}}
placeholder={null} placeholder={null}
options={[ options={[
{ label: "Sort by name", value: "name" }, { label: "Sort by name", value: "name" },
@ -279,7 +258,17 @@
{ label: "Sort by status", value: "status" }, { label: "Sort by status", value: "status" },
]} ]}
/> />
<Search placeholder="Search" bind:value={searchTerm} /> <Search
placeholder="Search"
on:input={e => {
searchTerm = e.target.value
}}
on:change={e => {
if (!e.detail) {
searchTerm = null
}
}}
/>
</div> </div>
{/if} {/if}
</div> </div>

View File

@ -13,7 +13,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { Breadcrumb, Breadcrumbs } from "components/portal/page" import { Breadcrumb, Breadcrumbs } from "components/portal/page"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import { apps, auth, groups } from "stores/portal" import { appsStore, auth, groups } from "stores/portal"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
@ -51,17 +51,17 @@
$: isScimGroup = group?.scimInfo?.isSync $: isScimGroup = group?.scimInfo?.isSync
$: isAdmin = sdk.users.isAdmin($auth.user) $: isAdmin = sdk.users.isAdmin($auth.user)
$: readonly = !isAdmin || isScimGroup $: readonly = !isAdmin || isScimGroup
$: groupApps = $apps $: groupApps = $appsStore.apps
.filter(app => .filter(app =>
groups.actions groups.actions
.getGroupAppIds(group) .getGroupAppIds(group)
.includes(apps.getProdAppID(app.devId)) .includes(appsStore.getProdAppID(app.devId))
) )
.map(app => ({ .map(app => ({
...app, ...app,
role: group?.builder?.apps.includes(apps.getProdAppID(app.devId)) role: group?.builder?.apps.includes(appsStore.getProdAppID(app.devId))
? Constants.Roles.CREATOR ? Constants.Roles.CREATOR
: group?.roles?.[apps.getProdAppID(app.devId)], : group?.roles?.[appsStore.getProdAppID(app.devId)],
})) }))
$: { $: {
@ -93,7 +93,7 @@
} }
const removeApp = async app => { const removeApp = async app => {
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId)) await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId))
} }
setContext("roles", { setContext("roles", {
updateRole: () => {}, updateRole: () => {},

View File

@ -1,12 +1,12 @@
<script> <script>
import { keepOpen, Body, ModalContent, Select } from "@budibase/bbui" import { keepOpen, Body, ModalContent, Select } from "@budibase/bbui"
import { apps, groups } from "stores/portal" import { appsStore, groups } from "stores/portal"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
export let group export let group
$: appOptions = $apps.map(app => ({ $: appOptions = $appsStore.apps.map(app => ({
label: app.name, label: app.name,
value: app, value: app,
})) }))
@ -16,7 +16,7 @@
let selectingRole = false let selectingRole = false
async function appSelected() { async function appSelected() {
const prodAppId = apps.getProdAppID(selectedApp.devId) const prodAppId = appsStore.getProdAppID(selectedApp.devId)
if (!selectingRole) { if (!selectingRole) {
selectingRole = true selectingRole = true
await roles.fetchByAppId(prodAppId) await roles.fetchByAppId(prodAppId)

View File

@ -18,7 +18,7 @@
Table, Table,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
import { users, auth, groups, apps, licensing } from "stores/portal" import { users, auth, groups, appsStore, licensing } from "stores/portal"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
@ -97,7 +97,7 @@
$: privileged = sdk.users.isAdminOrGlobalBuilder(user) $: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups(internalGroups, searchTerm) $: filteredGroups = getFilteredGroups(internalGroups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($appsStore.apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
return x.users?.find(y => { return x.users?.find(y => {
return y._id === userId return y._id === userId
@ -111,12 +111,12 @@
availableApps = availableApps.filter(x => { availableApps = availableApps.filter(x => {
let roleKeys = Object.keys(roles || {}) let roleKeys = Object.keys(roles || {})
return roleKeys.concat(user?.builder?.apps).find(y => { return roleKeys.concat(user?.builder?.apps).find(y => {
return x.appId === apps.extractAppId(y) return x.appId === appsStore.extractAppId(y)
}) })
}) })
} }
return availableApps.map(app => { return availableApps.map(app => {
const prodAppId = apps.getProdAppID(app.devId) const prodAppId = appsStore.getProdAppID(app.devId)
return { return {
name: app.name, name: app.name,
devId: app.devId, devId: app.devId,

View File

@ -1,6 +1,6 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
export let value export let value
@ -10,7 +10,7 @@
const getCount = () => { const getCount = () => {
if (priviliged) { if (priviliged) {
return $apps.length return $appsStore.apps.length
} else { } else {
return sdk.users.hasAppBuilderPermissions(row) return sdk.users.hasAppBuilderPermissions(row)
? row?.builder?.apps?.length + ? row?.builder?.apps?.length +

View File

@ -1,5 +1,5 @@
import { API } from "api" import { API } from "api"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_APP_META_STATE = { export const INITIAL_APP_META_STATE = {
appId: "", appId: "",

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { createBuilderWebsocket } from "./websocket.js" import { createBuilderWebsocket } from "./websocket.js"
import { BuilderSocketEvent } from "@budibase/shared-core" import { BuilderSocketEvent } from "@budibase/shared-core"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore.js"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
export const INITIAL_BUILDER_STATE = { export const INITIAL_BUILDER_STATE = {

View File

@ -27,7 +27,7 @@ import {
DB_TYPE_INTERNAL, DB_TYPE_INTERNAL,
DB_TYPE_EXTERNAL, DB_TYPE_EXTERNAL,
} from "constants/backend" } from "constants/backend"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"

View File

@ -1,6 +1,6 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { previewStore } from "stores/builder" import { previewStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_HOVER_STATE = { export const INITIAL_HOVER_STATE = {
componentId: null, componentId: null,

View File

@ -18,6 +18,7 @@ import {
} from "./automations.js" } from "./automations.js"
import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js" import { userStore, userSelectedResourceMap, isOnlyUser } from "./users.js"
import { deploymentStore } from "./deployments.js" import { deploymentStore } from "./deployments.js"
import { snippets } from "./snippets"
// Backend // Backend
import { tables } from "./tables" import { tables } from "./tables"
@ -62,6 +63,7 @@ export {
queries, queries,
flags, flags,
hoverStore, hoverStore,
snippets,
} }
export const reset = () => { export const reset = () => {
@ -101,6 +103,7 @@ export const initialise = async pkg => {
builderStore.init(application) builderStore.init(application)
navigationStore.syncAppNavigation(application?.navigation) navigationStore.syncAppNavigation(application?.navigation)
themeStore.syncAppTheme(application) themeStore.syncAppTheme(application)
snippets.syncMetadata(application)
screenStore.syncAppScreens(pkg) screenStore.syncAppScreens(pkg)
layoutStore.syncAppLayouts(pkg) layoutStore.syncAppLayouts(pkg)
resetBuilderHistory() resetBuilderHistory()

View File

@ -1,6 +1,6 @@
import { derived, get } from "svelte/store" import { derived, get } from "svelte/store"
import { componentStore } from "stores/builder" import { componentStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
import { API } from "api" import { API } from "api"
export const INITIAL_LAYOUT_STATE = { export const INITIAL_LAYOUT_STATE = {

View File

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_NAVIGATION_STATE = { export const INITIAL_NAVIGATION_STATE = {
navigation: "Top", navigation: "Top",

View File

@ -12,7 +12,7 @@ import {
} from "stores/builder" } from "stores/builder"
import { createHistoryStore } from "stores/builder/history" import { createHistoryStore } from "stores/builder/history"
import { API } from "api" import { API } from "api"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_SCREENS_STATE = { export const INITIAL_SCREENS_STATE = {
screens: [], screens: [],

View File

@ -0,0 +1,41 @@
import { writable, get } from "svelte/store"
import { API } from "api"
import { appStore } from "./app"
const createsnippets = () => {
const store = writable([])
const syncMetadata = metadata => {
store.set(metadata?.snippets || [])
}
const saveSnippet = async updatedSnippet => {
const snippets = [
...get(store).filter(snippet => snippet.name !== updatedSnippet.name),
updatedSnippet,
]
const app = await API.saveAppMetadata({
appId: get(appStore).appId,
metadata: { snippets },
})
syncMetadata(app)
}
const deleteSnippet = async snippetName => {
const snippets = get(store).filter(snippet => snippet.name !== snippetName)
const app = await API.saveAppMetadata({
appId: get(appStore).appId,
metadata: { snippets },
})
syncMetadata(app)
}
return {
...store,
syncMetadata,
saveSnippet,
deleteSnippet,
}
}
export const snippets = createsnippets()

View File

@ -6,11 +6,12 @@ import {
themeStore, themeStore,
navigationStore, navigationStore,
deploymentStore, deploymentStore,
snippets,
datasources, datasources,
tables, tables,
} from "stores/builder" } from "stores/builder"
import { get } from "svelte/store" import { get } from "svelte/store"
import { auth, apps } from "stores/portal" import { auth, appsStore } from "stores/portal"
import { screenStore } from "./screens" import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core" import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -64,11 +65,12 @@ export const createBuilderWebsocket = appId => {
appStore.syncMetadata(metadata) appStore.syncMetadata(metadata)
themeStore.syncMetadata(metadata) themeStore.syncMetadata(metadata)
navigationStore.syncMetadata(metadata) navigationStore.syncMetadata(metadata)
snippets.syncMetadata(metadata)
}) })
socket.onOther( socket.onOther(
BuilderSocketEvent.AppPublishChange, BuilderSocketEvent.AppPublishChange,
async ({ user, published }) => { async ({ user, published }) => {
await apps.load() await appsStore.load()
if (published) { if (published) {
await deploymentStore.load() await deploymentStore.load()
} }

View File

@ -1,39 +1,61 @@
import { writable } from "svelte/store" import { derived } from "svelte/store"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { API } from "api" import { API } from "api"
import { auth } from "./auth"
import BudiStore from "../BudiStore" // move this
// properties that should always come from the dev app, not the deployed // properties that should always come from the dev app, not the deployed
const DEV_PROPS = ["updatedBy", "updatedAt"] const DEV_PROPS = ["updatedBy", "updatedAt"]
const extractAppId = id => { export const INITIAL_APPS_STATE = {
const split = id?.split("_") || [] apps: [],
return split.length ? split[split.length - 1] : null sortBy: "name",
} }
const getProdAppID = appId => { export class AppsStore extends BudiStore {
if (!appId) { constructor() {
return appId super({ ...INITIAL_APPS_STATE })
}
let rest,
separator = ""
if (appId.startsWith("app_dev")) {
// split to take off the app_dev element, then join it together incase any other app_ exist
const split = appId.split("app_dev")
split.shift()
rest = split.join("app_dev")
} else if (!appId.startsWith("app")) {
rest = appId
separator = "_"
} else {
return appId
}
return `app${separator}${rest}`
}
export function createAppStore() { this.extractAppId = this.extractAppId.bind(this)
const store = writable([]) this.getProdAppID = this.getProdAppID.bind(this)
this.updateSort = this.updateSort.bind(this)
this.load = this.load.bind(this)
this.save = this.save.bind(this)
}
async function load() { extractAppId(id) {
const split = id?.split("_") || []
return split.length ? split[split.length - 1] : null
}
getProdAppID(appId) {
if (!appId) {
return appId
}
let rest,
separator = ""
if (appId.startsWith("app_dev")) {
// split to take off the app_dev element, then join it together incase any other app_ exist
const split = appId.split("app_dev")
split.shift()
rest = split.join("app_dev")
} else if (!appId.startsWith("app")) {
rest = appId
separator = "_"
} else {
return appId
}
return `app${separator}${rest}`
}
updateSort(sortBy) {
this.update(state => ({
...state,
sortBy,
}))
}
async load() {
const json = await API.getApps() const json = await API.getApps()
if (Array.isArray(json)) { if (Array.isArray(json)) {
// Merge apps into one sensible list // Merge apps into one sensible list
@ -43,7 +65,7 @@ export function createAppStore() {
// First append all dev app version // First append all dev app version
devApps.forEach(app => { devApps.forEach(app => {
const id = extractAppId(app.appId) const id = this.extractAppId(app.appId)
appMap[id] = { appMap[id] = {
...app, ...app,
devId: app.appId, devId: app.appId,
@ -53,7 +75,7 @@ export function createAppStore() {
// Then merge with all prod app versions // Then merge with all prod app versions
deployedApps.forEach(app => { deployedApps.forEach(app => {
const id = extractAppId(app.appId) const id = this.extractAppId(app.appId)
// Skip any deployed apps which don't have a dev counterpart // Skip any deployed apps which don't have a dev counterpart
if (!appMap[id]) { if (!appMap[id]) {
@ -81,39 +103,80 @@ export function createAppStore() {
// Transform into an array and clean up // Transform into an array and clean up
const apps = Object.values(appMap) const apps = Object.values(appMap)
apps.forEach(app => { apps.forEach(app => {
app.appId = extractAppId(app.devId) app.appId = this.extractAppId(app.devId)
delete app._id delete app._id
delete app._rev delete app._rev
}) })
store.set(apps) this.update(state => ({
...state,
apps,
}))
} else { } else {
store.set([]) this.update(state => ({
...state,
apps: [],
}))
} }
} }
async function update(appId, value) { async save(appId, value) {
await API.saveAppMetadata({ await API.saveAppMetadata({
appId, appId,
metadata: value, metadata: value,
}) })
store.update(state => { this.update(state => {
const updatedAppIndex = state.findIndex(app => app.instance._id === appId) const updatedAppIndex = state.apps.findIndex(
app => app.instance._id === appId
)
if (updatedAppIndex !== -1) { if (updatedAppIndex !== -1) {
let updatedApp = state[updatedAppIndex] let updatedApp = state.apps[updatedAppIndex]
updatedApp = { ...updatedApp, ...value } updatedApp = { ...updatedApp, ...value }
state.apps = state.splice(updatedAppIndex, 1, updatedApp) state.apps = state.apps.splice(updatedAppIndex, 1, updatedApp)
} }
return state return state
}) })
} }
return {
subscribe: store.subscribe,
load,
update,
extractAppId,
getProdAppID,
}
} }
export const apps = createAppStore() export const appsStore = new AppsStore()
// Centralise any logic that enriches the apps list
export const enrichedApps = derived([appsStore, auth], ([$store, $auth]) => {
const enrichedApps = $store.apps
? $store.apps.map(app => ({
...app,
deployed: app.status === AppStatus.DEPLOYED,
lockedYou: app.lockedBy && app.lockedBy.email === $auth.user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== $auth.user?.email,
favourite: $auth?.user.appFavourites?.includes(app.appId),
}))
: []
if ($store.sortBy === "status") {
return enrichedApps.sort((a, b) => {
if (a.favourite === b.favourite) {
if (a.status === b.status) {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
}
return a.status === AppStatus.DEPLOYED ? -1 : 1
}
return a.favourite ? -1 : 1
})
} else if ($store.sortBy === "updated") {
return enrichedApps?.sort((a, b) => {
if (a.favourite === b.favourite) {
const aUpdated = a.updatedAt || "9999"
const bUpdated = b.updatedAt || "9999"
return aUpdated < bUpdated ? 1 : -1
}
return a.favourite ? -1 : 1
})
} else {
return enrichedApps?.sort((a, b) => {
if (a.favourite === b.favourite) {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
}
return a.favourite ? -1 : 1
})
}
})

View File

@ -3,7 +3,7 @@ import { writable } from "svelte/store"
export { organisation } from "./organisation" export { organisation } from "./organisation"
export { users } from "./users" export { users } from "./users"
export { admin } from "./admin" export { admin } from "./admin"
export { apps } from "./apps" export { appsStore, enrichedApps } from "./apps"
export { email } from "./email" export { email } from "./email"
export { auth } from "./auth" export { auth } from "./auth"
export { oidc } from "./oidc" export { oidc } from "./oidc"

View File

@ -15,7 +15,8 @@
"@budibase/types": ["../types/src"], "@budibase/types": ["../types/src"],
"@budibase/backend-core": ["../backend-core/src"], "@budibase/backend-core": ["../backend-core/src"],
"@budibase/backend-core/*": ["../backend-core/*"], "@budibase/backend-core/*": ["../backend-core/*"],
"@budibase/shared-core": ["../shared-core/src"] "@budibase/shared-core": ["../shared-core/src"],
"@budibase/string-templates": ["../string-templates/src"]
} }
}, },
"include": ["src/**/*"], "include": ["src/**/*"],

View File

@ -1,16 +1,8 @@
{ {
"extends": "./tsconfig.build.json", "extends": "./tsconfig.build.json",
"compilerOptions": {
"composite": true,
"declaration": true,
"sourceMap": true,
"baseUrl": ".",
"resolveJsonModule": true
},
"ts-node": { "ts-node": {
"require": ["tsconfig-paths/register"], "require": ["tsconfig-paths/register"],
"swc": true "swc": true
}, },
"include": ["src/**/*", "package.json"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]
} }

View File

@ -39,6 +39,7 @@
import FreeFooter from "components/FreeFooter.svelte" import FreeFooter from "components/FreeFooter.svelte"
import MaintenanceScreen from "components/MaintenanceScreen.svelte" import MaintenanceScreen from "components/MaintenanceScreen.svelte"
import licensing from "../licensing" import licensing from "../licensing"
import SnippetsProvider from "./context/SnippetsProvider.svelte"
// Provide contexts // Provide contexts
setContext("sdk", SDK) setContext("sdk", SDK)
@ -121,114 +122,116 @@
<StateBindingsProvider> <StateBindingsProvider>
<RowSelectionProvider> <RowSelectionProvider>
<QueryParamsProvider> <QueryParamsProvider>
<!-- Settings bar can be rendered outside of device preview --> <SnippetsProvider>
<!-- Key block needs to be outside the if statement or it breaks --> <!-- Settings bar can be rendered outside of device preview -->
{#key $builderStore.selectedComponentId} <!-- Key block needs to be outside the if statement or it breaks -->
{#if $builderStore.inBuilder} {#key $builderStore.selectedComponentId}
<SettingsBar /> {#if $builderStore.inBuilder}
{/if} <SettingsBar />
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice ===
"tablet"}
class:mobile-preview={$builderStore.previewDevice ===
"mobile"}
>
<!-- Actual app -->
<div id="app-root">
{#if showDevTools}
<DevToolsHeader />
{/if} {/if}
{/key}
<div id="app-body"> <!-- Clip boundary for selection indicators -->
{#if permissionError} <div
<div class="error"> id="clip-root"
<Layout justifyItems="center" gap="S"> class:preview={$builderStore.inBuilder}
<!-- eslint-disable-next-line svelte/no-at-html-tags --> class:tablet-preview={$builderStore.previewDevice ===
{@html ErrorSVG} "tablet"}
<Heading size="L"> class:mobile-preview={$builderStore.previewDevice ===
You don't have permission to use this app "mobile"}
</Heading> >
<Body size="S"> <!-- Actual app -->
Ask your administrator to grant you access <div id="app-root">
</Body> {#if showDevTools}
</Layout> <DevToolsHeader />
</div> {/if}
{:else if !$screenStore.activeLayout}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
Something went wrong rendering your app
</Heading>
<Body size="S">
Get in touch with support if this issue persists
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!-- <div id="app-body">
{#if permissionError}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
You don't have permission to use this app
</Heading>
<Body size="S">
Ask your administrator to grant you access
</Body>
</Layout>
</div>
{:else if !$screenStore.activeLayout}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
Something went wrong rendering your app
</Heading>
<Body size="S">
Get in touch with support if this issue persists
</Body>
</Layout>
</div>
{:else if embedNoScreens}
<div class="error">
<Layout justifyItems="center" gap="S">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html ErrorSVG}
<Heading size="L">
This Budibase app is not publicly accessible
</Heading>
</Layout>
</div>
{:else}
<CustomThemeWrapper>
{#key $screenStore.activeLayout._id}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!--
Flatpickr needs to be inside the theme wrapper. Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with. key events on the whole page. It is painful to work with.
--> -->
<div id="flatpickr-root" /> <div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top --> <!-- Modal container to ensure they sit on top -->
<div class="modal-container" /> <div class="modal-container" />
<!-- Layers on top of app --> <!-- Layers on top of app -->
<NotificationDisplay /> <NotificationDisplay />
<ConfirmationDisplay /> <ConfirmationDisplay />
<PeekScreenDisplay /> <PeekScreenDisplay />
</CustomThemeWrapper> </CustomThemeWrapper>
{/if} {/if}
{#if showDevTools} {#if showDevTools}
<DevTools /> <DevTools />
{/if}
</div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()}
<FreeFooter />
{/if} {/if}
</div> </div>
{#if !$builderStore.inBuilder && licensing.logoEnabled()} <!-- Preview and dev tools utilities -->
<FreeFooter /> {#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if} {/if}
</div> </div>
</SnippetsProvider>
<!-- Preview and dev tools utilities -->
{#if $appStore.isDevApp}
<SelectionIndicator />
{/if}
{#if $builderStore.inBuilder || $devToolsStore.allowSelection}
<HoverIndicator />
{/if}
{#if $builderStore.inBuilder}
<DNDHandler />
<GridDNDHandler />
{/if}
</div>
</QueryParamsProvider> </QueryParamsProvider>
</RowSelectionProvider> </RowSelectionProvider>
</StateBindingsProvider> </StateBindingsProvider>

View File

@ -565,7 +565,8 @@
// If we don't know, check and cache // If we don't know, check and cache
if (used == null) { if (used == null) {
used = bindingString.indexOf(`[${key}]`) !== -1 const searchString = key === "snippets" ? key : `[${key}]`
used = bindingString.indexOf(searchString) !== -1
knownContextKeyMap[key] = used knownContextKeyMap[key] = used
} }

View File

@ -0,0 +1,8 @@
<script>
import Provider from "./Provider.svelte"
import { snippets } from "stores"
</script>
<Provider key="snippets" data={$snippets}>
<slot />
</Provider>

View File

@ -42,6 +42,7 @@ const loadBudibase = async () => {
hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"], hiddenComponentIds: window["##BUDIBASE_HIDDEN_COMPONENT_IDS##"],
usedPlugins: window["##BUDIBASE_USED_PLUGINS##"], usedPlugins: window["##BUDIBASE_USED_PLUGINS##"],
location: window["##BUDIBASE_LOCATION##"], location: window["##BUDIBASE_LOCATION##"],
snippets: window["##BUDIBASE_SNIPPETS##"],
}) })
// Set app ID - this window flag is set by both the preview and the real // Set app ID - this window flag is set by both the preview and the real

View File

@ -18,6 +18,7 @@ const createBuilderStore = () => {
usedPlugins: null, usedPlugins: null,
eventResolvers: {}, eventResolvers: {},
metadata: null, metadata: null,
snippets: null,
// Legacy - allow the builder to specify a layout // Legacy - allow the builder to specify a layout
layout: null, layout: null,

View File

@ -4,3 +4,4 @@
export { currentRole } from "./currentRole.js" export { currentRole } from "./currentRole.js"
export { dndComponentPath } from "./dndComponentPath.js" export { dndComponentPath } from "./dndComponentPath.js"
export { devToolsEnabled } from "./devToolsEnabled.js" export { devToolsEnabled } from "./devToolsEnabled.js"
export { snippets } from "./snippets.js"

View File

@ -0,0 +1,10 @@
import { appStore } from "../app.js"
import { builderStore } from "../builder.js"
import { derivedMemo } from "@budibase/frontend-core"
export const snippets = derivedMemo(
[appStore, builderStore],
([$appStore, $builderStore]) => {
return $builderStore?.snippets || $appStore?.application?.snippets || []
}
)

View File

@ -1,23 +1,5 @@
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
import { processString, processObjectSync } from "@budibase/string-templates" import { processObjectSync } from "@budibase/string-templates"
// Regex to test inputs with to see if they are likely candidates for template strings
const looksLikeTemplate = /{{.*}}/
/**
* Enriches a given input with a row from the database.
*/
export const enrichDataBinding = async (input, context) => {
// Only accept string inputs
if (!input || typeof input !== "string") {
return input
}
// Do a fast regex check if this looks like a template string
if (!looksLikeTemplate.test(input)) {
return input
}
return processString(input, context)
}
/** /**
* Recursively enriches all props in a props object and returns the new props. * Recursively enriches all props in a props object and returns the new props.

View File

@ -83,6 +83,18 @@ export const buildAppEndpoints = API => ({
}) })
}, },
/**
* Duplicate an existing app
* @param app the app to dupe
*/
duplicateApp: async (app, appId) => {
return await API.post({
url: `/api/applications/${appId}/duplicate`,
body: app,
json: false,
})
},
/** /**
* Update an application using an export - the body * Update an application using an export - the body
* should be of type FormData, with a "file" and a "password" if encrypted. * should be of type FormData, with a "file" and a "password" if encrypted.

View File

@ -317,7 +317,7 @@
align="right" align="right"
offset={0} offset={0}
popoverTarget={document.getElementById(`grid-${rand}`)} popoverTarget={document.getElementById(`grid-${rand}`)}
customZindex={100} customZindex={50}
> >
{#if editIsOpen} {#if editIsOpen}
<div <div

View File

@ -38,7 +38,7 @@
align={$visibleColumns.length ? "right" : "left"} align={$visibleColumns.length ? "right" : "left"}
offset={0} offset={0}
popoverTarget={document.getElementById(`add-column-button`)} popoverTarget={document.getElementById(`add-column-button`)}
customZindex={100} customZindex={50}
> >
<div <div
use:clickOutside={() => { use:clickOutside={() => {

@ -1 +1 @@
Subproject commit c4c98ae70f2e936009250893898ecf11f4ddf2c3 Subproject commit 65ac3fc8a20a5244fbe47629cf79678db2d9ae8a

View File

@ -41,17 +41,9 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
WORKDIR /app WORKDIR /app
COPY packages/server/package.json . COPY packages/server/package.json .
COPY packages/server/dist/yarn.lock . COPY packages/server/dist/yarn.lock .
RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-templates
COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.sh
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN chmod +x ./scripts/removeWorkspaceDependencies.sh

View File

@ -30,6 +30,8 @@ const baseConfig: Config.InitialProjectOptions = {
"@budibase/backend-core": "<rootDir>/../backend-core/src", "@budibase/backend-core": "<rootDir>/../backend-core/src",
"@budibase/shared-core": "<rootDir>/../shared-core/src", "@budibase/shared-core": "<rootDir>/../shared-core/src",
"@budibase/types": "<rootDir>/../types/src", "@budibase/types": "<rootDir>/../types/src",
"@budibase/string-templates/(.*)": ["<rootDir>/../string-templates/$1"],
"@budibase/string-templates": ["<rootDir>/../string-templates/src"],
}, },
} }

View File

@ -13,9 +13,10 @@
"build": "node ./scripts/build.js", "build": "node ./scripts/build.js",
"postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/", "postbuild": "copyfiles -f ../client/dist/budibase-client.js ../client/manifest.json client && copyfiles -f ../../yarn.lock ./dist/",
"check:types": "tsc -p tsconfig.json --noEmit --paths null", "check:types": "tsc -p tsconfig.json --noEmit --paths null",
"build:isolated-vm-lib:snippets": "esbuild --minify --bundle src/jsRunner/bundles/snippets.ts --outfile=src/jsRunner/bundles/snippets.ivm.bundle.js --platform=node --format=iife --global-name=snippets",
"build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers", "build:isolated-vm-lib:string-templates": "esbuild --minify --bundle src/jsRunner/bundles/index-helpers.ts --outfile=src/jsRunner/bundles/index-helpers.ivm.bundle.js --platform=node --format=iife --external:handlebars --global-name=helpers",
"build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson", "build:isolated-vm-lib:bson": "esbuild --minify --bundle src/jsRunner/bundles/bsonPackage.ts --outfile=src/jsRunner/bundles/bson.ivm.bundle.js --platform=node --format=iife --global-name=bson",
"build:isolated-vm-libs": "yarn build:isolated-vm-lib:string-templates && yarn build:isolated-vm-lib:bson", "build:isolated-vm-libs": "yarn build:isolated-vm-lib:string-templates && yarn build:isolated-vm-lib:bson && yarn build:isolated-vm-lib:snippets",
"build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput", "build:dev": "yarn prebuild && tsc --build --watch --preserveWatchOutput",
"debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9222 dist/index.js",
"jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest", "jest": "NODE_OPTIONS=\"--no-node-snapshot $NODE_OPTIONS\" jest",
@ -174,6 +175,10 @@
] ]
}, },
"build": { "build": {
"inputs": [
"{projectRoot}/builder",
"{projectRoot}/client"
],
"outputs": [ "outputs": [
"{projectRoot}/builder", "{projectRoot}/builder",
"{projectRoot}/client", "{projectRoot}/client",

View File

@ -26,6 +26,7 @@ import {
env as envCore, env as envCore,
ErrorCode, ErrorCode,
events, events,
HTTPError,
migrations, migrations,
objectStore, objectStore,
roles, roles,
@ -50,6 +51,8 @@ import {
CreateAppRequest, CreateAppRequest,
FetchAppDefinitionResponse, FetchAppDefinitionResponse,
FetchAppPackageResponse, FetchAppPackageResponse,
DuplicateAppRequest,
DuplicateAppResponse,
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -122,7 +125,7 @@ interface AppTemplate {
templateString?: string templateString?: string
useTemplate?: string useTemplate?: string
file?: { file?: {
type: string type?: string
path: string path: string
password?: string password?: string
} }
@ -263,6 +266,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
...(ctx.request.files.templateFile as any), ...(ctx.request.files.templateFile as any),
password: encryptionPassword, password: encryptionPassword,
} }
} else if (typeof ctx.request.body.file?.path === "string") {
instanceConfig.file = {
path: ctx.request.body.file?.path,
}
} }
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
const appId = generateDevAppID(generateAppID(tenantId)) const appId = generateDevAppID(generateAppID(tenantId))
@ -372,12 +379,20 @@ async function creationEvents(request: any, app: App) {
else if (request.files?.templateFile) { else if (request.files?.templateFile) {
creationFns.push(a => events.app.fileImported(a)) creationFns.push(a => events.app.fileImported(a))
} }
// from server file path
else if (request.body.file) {
// explicitly pass in the newly created app id
creationFns.push(a => events.app.duplicated(a, app.appId))
}
// unknown // unknown
else { else {
console.error("Could not determine template creation event") console.error("Could not determine template creation event")
} }
} }
creationFns.push(a => events.app.created(a))
if (!request.duplicate) {
creationFns.push(a => events.app.created(a))
}
for (let fn of creationFns) { for (let fn of creationFns) {
await fn(app) await fn(app)
@ -391,8 +406,10 @@ async function appPostCreate(ctx: UserCtx, app: App) {
tenantId, tenantId,
appId: app.appId, appId: app.appId,
}) })
await creationEvents(ctx.request, app) await creationEvents(ctx.request, app)
// app import & template creation
// app import, template creation and duplication
if (ctx.request.body.useTemplate === "true") { if (ctx.request.body.useTemplate === "true") {
const { rows } = await getUniqueRows([app.appId]) const { rows } = await getUniqueRows([app.appId])
const rowCount = rows ? rows.length : 0 const rowCount = rows ? rows.length : 0
@ -421,7 +438,7 @@ async function appPostCreate(ctx: UserCtx, app: App) {
} }
} }
export async function create(ctx: UserCtx) { export async function create(ctx: UserCtx<CreateAppRequest, App>) {
const newApplication = await quotas.addApp(() => performAppCreate(ctx)) const newApplication = await quotas.addApp(() => performAppCreate(ctx))
await appPostCreate(ctx, newApplication) await appPostCreate(ctx, newApplication)
await cache.bustCache(cache.CacheKey.CHECKLIST) await cache.bustCache(cache.CacheKey.CHECKLIST)
@ -626,6 +643,69 @@ export async function importToApp(ctx: UserCtx) {
ctx.body = { message: "app updated" } ctx.body = { message: "app updated" }
} }
/**
* Create a copy of the latest dev application.
* Performs an export of the app, then imports from the export dir path
*/
export async function duplicateApp(
ctx: UserCtx<DuplicateAppRequest, DuplicateAppResponse>
) {
const { name: appName, url: possibleUrl } = ctx.request.body
const { appId: sourceAppId } = ctx.params
const [app] = await dbCore.getAppsByIDs([sourceAppId])
if (!app) {
ctx.throw(404, "Source app not found")
}
const apps = (await dbCore.getAllApps({ dev: true })) as App[]
checkAppName(ctx, apps, appName)
const url = sdk.applications.getAppUrl({ name: appName, url: possibleUrl })
checkAppUrl(ctx, apps, url)
const tmpPath = await sdk.backups.exportApp(sourceAppId, {
excludeRows: false,
tar: false,
})
const createRequestBody: CreateAppRequest = {
name: appName,
url: possibleUrl,
useTemplate: "true",
// The app export path
file: {
path: tmpPath,
},
}
// Build a new request
const createRequest = {
roleId: ctx.roleId,
user: {
...ctx.user,
_id: dbCore.getGlobalIDFromUserMetadataID(ctx.user._id || ""),
},
request: {
body: createRequestBody,
},
} as UserCtx<CreateAppRequest, App>
// Build the new application
await create(createRequest)
const { body: newApplication } = createRequest
if (!newApplication) {
ctx.throw(500, "There was a problem duplicating the application")
}
ctx.body = {
duplicateAppId: newApplication?.appId,
sourceAppId,
}
ctx.status = 200
}
export async function updateAppPackage( export async function updateAppPackage(
appPackage: Partial<App>, appPackage: Partial<App>,
appId: string appId: string

View File

@ -437,11 +437,11 @@ export class ExternalRequest<T extends Operation> {
return { row: newRow, manyRelationships } return { row: newRow, manyRelationships }
} }
processRelationshipFields( async processRelationshipFields(
table: Table, table: Table,
row: Row, row: Row,
relationships: RelationshipsJson[] relationships: RelationshipsJson[]
): Row { ): Promise<Row> {
for (let relationship of relationships) { for (let relationship of relationships) {
const linkedTable = this.tables[relationship.tableName] const linkedTable = this.tables[relationship.tableName]
if (!linkedTable || !row[relationship.column]) { if (!linkedTable || !row[relationship.column]) {
@ -457,7 +457,7 @@ export class ExternalRequest<T extends Operation> {
} }
// process additional types // process additional types
relatedRow = processDates(table, relatedRow) relatedRow = processDates(table, relatedRow)
relatedRow = processFormulas(linkedTable, relatedRow) relatedRow = await processFormulas(linkedTable, relatedRow)
row[relationship.column][key] = relatedRow row[relationship.column][key] = relatedRow
} }
} }
@ -521,7 +521,7 @@ export class ExternalRequest<T extends Operation> {
return rows return rows
} }
outputProcessing( async outputProcessing(
rows: Row[] = [], rows: Row[] = [],
table: Table, table: Table,
relationships: RelationshipsJson[] relationships: RelationshipsJson[]
@ -561,9 +561,12 @@ export class ExternalRequest<T extends Operation> {
} }
// make sure all related rows are correct // make sure all related rows are correct
let finalRowArray = Object.values(finalRows).map(row => let finalRowArray = []
this.processRelationshipFields(table, row, relationships) for (let row of Object.values(finalRows)) {
) finalRowArray.push(
await this.processRelationshipFields(table, row, relationships)
)
}
// process some additional types // process some additional types
finalRowArray = processDates(table, finalRowArray) finalRowArray = processDates(table, finalRowArray)
@ -934,7 +937,11 @@ export class ExternalRequest<T extends Operation> {
processed.manyRelationships processed.manyRelationships
) )
} }
const output = this.outputProcessing(responseRows, table, relationships) const output = await this.outputProcessing(
responseRows,
table,
relationships
)
// if reading it'll just be an array of rows, return whole thing // if reading it'll just be an array of rows, return whole thing
if (operation === Operation.READ) { if (operation === Operation.READ) {
return ( return (

View File

@ -110,7 +110,7 @@ export async function updateAllFormulasInTable(table: Table) {
(enriched: Row) => enriched._id === row._id (enriched: Row) => enriched._id === row._id
) )
if (enrichedRow) { if (enrichedRow) {
const processed = processFormulas(table, cloneDeep(row), { const processed = await processFormulas(table, cloneDeep(row), {
dynamic: false, dynamic: false,
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
@ -143,7 +143,7 @@ export async function finaliseRow(
squash: false, squash: false,
})) as Row })) as Row
// use enriched row to generate formulas for saving, specifically only use as context // use enriched row to generate formulas for saving, specifically only use as context
row = processFormulas(table, row, { row = await processFormulas(table, row, {
dynamic: false, dynamic: false,
contextRows: [enrichedRow], contextRows: [enrichedRow],
}) })
@ -179,7 +179,7 @@ export async function finaliseRow(
const response = await db.put(row) const response = await db.put(row)
// for response, calculate the formulas for the enriched row // for response, calculate the formulas for the enriched row
enrichedRow._rev = response.rev enrichedRow._rev = response.rev
enrichedRow = processFormulas(table, enrichedRow, { enrichedRow = await processFormulas(table, enrichedRow, {
dynamic: false, dynamic: false,
}) })
// this updates the related formulas in other rows based on the relations to this row // this updates the related formulas in other rows based on the relations to this row

View File

@ -1,6 +1,6 @@
import { Ctx } from "@budibase/types" import { Ctx } from "@budibase/types"
import { IsolatedVM } from "../../jsRunner/vm" import { IsolatedVM } from "../../jsRunner/vm"
import { iifeWrapper } from "../../jsRunner/utilities" import { iifeWrapper } from "@budibase/string-templates"
export async function execute(ctx: Ctx) { export async function execute(ctx: Ctx) {
const { script, context } = ctx.request.body const { script, context } = ctx.request.body

Some files were not shown because too many files have changed in this diff Show More