Merge branch 'master' into dependabot/npm_and_yarn/qa-core/follow-redirects-1.15.6
This commit is contained in:
commit
28d75a588b
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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) {
|
||||||
|
|
|
@ -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[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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%;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 {{ 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>
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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) ❴ ... ❵"
|
||||||
|
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>
|
|
@ -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>
|
|
@ -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}` })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
@ -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}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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}`
|
||||||
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
|
@ -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("../")
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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: () => {},
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 +
|
||||||
|
|
|
@ -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: "",
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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: [],
|
||||||
|
|
|
@ -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()
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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/**/*"],
|
||||||
|
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
<script>
|
||||||
|
import Provider from "./Provider.svelte"
|
||||||
|
import { snippets } from "stores"
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Provider key="snippets" data={$snippets}>
|
||||||
|
<slot />
|
||||||
|
</Provider>
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 || []
|
||||||
|
}
|
||||||
|
)
|
|
@ -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.
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue