Merge branch 'master' into component-collapse-redo
This commit is contained in:
commit
fbec6d6eff
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.21.9",
|
||||
"version": "2.22.0",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
AppVersionRevertedEvent,
|
||||
AppRevertedEvent,
|
||||
AppExportedEvent,
|
||||
AppDuplicatedEvent,
|
||||
} from "@budibase/types"
|
||||
|
||||
const created = async (app: App, timestamp?: string | number) => {
|
||||
|
@ -77,6 +78,17 @@ async function fileImported(app: App) {
|
|||
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) {
|
||||
const properties: AppTemplateImportedEvent = {
|
||||
appId: app.appId,
|
||||
|
@ -147,6 +159,7 @@ export default {
|
|||
published,
|
||||
unpublished,
|
||||
fileImported,
|
||||
duplicated,
|
||||
templateImported,
|
||||
versionUpdated,
|
||||
versionReverted,
|
||||
|
|
|
@ -15,6 +15,7 @@ beforeAll(async () => {
|
|||
|
||||
jest.spyOn(events.app, "created")
|
||||
jest.spyOn(events.app, "updated")
|
||||
jest.spyOn(events.app, "duplicated")
|
||||
jest.spyOn(events.app, "deleted")
|
||||
jest.spyOn(events.app, "published")
|
||||
jest.spyOn(events.app, "unpublished")
|
||||
|
|
|
@ -38,7 +38,7 @@
|
|||
<div use:getAnchor on:click={openMenu}>
|
||||
<slot name="control" />
|
||||
</div>
|
||||
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}>
|
||||
<Popover bind:this={dropdown} {anchor} {align} {portalTarget} on:open on:close>
|
||||
<Menu>
|
||||
<slot />
|
||||
</Menu>
|
||||
|
|
|
@ -1,60 +1,54 @@
|
|||
<script context="module">
|
||||
export const directions = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Tooltip from "../Tooltip/Tooltip.svelte"
|
||||
import { fade } from "svelte/transition"
|
||||
import {
|
||||
default as AbsTooltip,
|
||||
TooltipPosition,
|
||||
TooltipType,
|
||||
} from "../Tooltip/AbsTooltip.svelte"
|
||||
|
||||
export let direction = "n"
|
||||
export let name = "Add"
|
||||
export let hidden = false
|
||||
export let size = "M"
|
||||
export let hoverable = false
|
||||
export let disabled = false
|
||||
export let color
|
||||
export let hoverColor
|
||||
export let tooltip
|
||||
export let tooltipPosition = TooltipPosition.Bottom
|
||||
export let tooltipType = TooltipType.Default
|
||||
export let tooltipColor
|
||||
export let tooltipWrap = true
|
||||
export let newStyles = false
|
||||
|
||||
$: rotation = getRotation(direction)
|
||||
|
||||
let showTooltip = false
|
||||
|
||||
const getRotation = direction => {
|
||||
return directions.indexOf(direction) * 45
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="icon"
|
||||
class:newStyles
|
||||
on:mouseover={() => (showTooltip = true)}
|
||||
on:focus={() => (showTooltip = true)}
|
||||
on:mouseleave={() => (showTooltip = false)}
|
||||
on:click={() => (showTooltip = false)}
|
||||
<AbsTooltip
|
||||
text={tooltip}
|
||||
type={tooltipType}
|
||||
position={tooltipPosition}
|
||||
color={tooltipColor}
|
||||
noWrap={tooltipWrap}
|
||||
>
|
||||
<svg
|
||||
on:click
|
||||
class:hoverable
|
||||
class:disabled
|
||||
class="spectrum-Icon spectrum-Icon--size{size}"
|
||||
focusable="false"
|
||||
aria-hidden={hidden}
|
||||
aria-label={name}
|
||||
style={`transform: rotate(${rotation}deg); ${
|
||||
color ? `color: ${color};` : ""
|
||||
}`}
|
||||
>
|
||||
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" />
|
||||
</svg>
|
||||
{#if tooltip && showTooltip}
|
||||
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
|
||||
<Tooltip textWrapping direction="top" text={tooltip} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="icon" class:newStyles>
|
||||
<svg
|
||||
on:click
|
||||
class:hoverable
|
||||
class:disabled
|
||||
class="spectrum-Icon spectrum-Icon--size{size}"
|
||||
focusable="false"
|
||||
aria-hidden={hidden}
|
||||
aria-label={name}
|
||||
style={`${color ? `color: ${color};` : ""} ${
|
||||
hoverColor
|
||||
? `--hover-color: ${hoverColor}`
|
||||
: "--hover-color: var(--spectrum-alias-icon-color-selected-hover)"
|
||||
}`}
|
||||
>
|
||||
<use
|
||||
style="pointer-events: none;"
|
||||
xlink:href="#spectrum-icon-18-{name}"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</AbsTooltip>
|
||||
|
||||
<style>
|
||||
.icon {
|
||||
|
@ -71,7 +65,7 @@
|
|||
transition: color var(--spectrum-global-animation-duration-100, 130ms);
|
||||
}
|
||||
svg.hoverable:hover {
|
||||
color: var(--spectrum-alias-icon-color-selected-hover) !important;
|
||||
color: var(--hover-color) !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
svg.hoverable:active {
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
export let text = ""
|
||||
export let fixed = false
|
||||
export let color = null
|
||||
export let noWrap = false
|
||||
|
||||
let wrapper
|
||||
let hovered = false
|
||||
|
@ -105,6 +106,7 @@
|
|||
<Portal target=".spectrum">
|
||||
<span
|
||||
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
|
||||
class:noWrap
|
||||
style={`left:${left}px;top:${top}px;${tooltipStyle}`}
|
||||
transition:fade|local={{ duration: 130 }}
|
||||
>
|
||||
|
@ -118,6 +120,9 @@
|
|||
.abs-tooltip {
|
||||
display: contents;
|
||||
}
|
||||
.spectrum-Tooltip.noWrap .spectrum-Tooltip-label {
|
||||
width: max-content;
|
||||
}
|
||||
.spectrum-Tooltip {
|
||||
position: absolute;
|
||||
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 ButtonGroup } from "./ButtonGroup/ButtonGroup.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 Toggle } from "./Form/Toggle.svelte"
|
||||
export { default as RadioGroup } from "./Form/RadioGroup.svelte"
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
class="searchButton"
|
||||
class:hide={search}
|
||||
>
|
||||
<Icon size="S" name="Search" />
|
||||
<Icon size="S" name="Search" hoverable hoverColor="var(--ink)" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -68,7 +68,7 @@
|
|||
class="addButton"
|
||||
class:rotate={search}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
<Icon name="Add" hoverable hoverColor="var(--ink)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
export let iconTooltip
|
||||
export let withArrow = false
|
||||
export let withActions = true
|
||||
export let showActions = false
|
||||
export let indentLevel = 0
|
||||
export let text
|
||||
export let border = true
|
||||
|
@ -68,6 +69,8 @@
|
|||
class:border
|
||||
class:selected
|
||||
class:withActions
|
||||
class:showActions
|
||||
class:actionsOpen={highlighted && withActions}
|
||||
class:scrollable
|
||||
class:highlighted
|
||||
class:selectedBy
|
||||
|
@ -168,8 +171,10 @@
|
|||
--avatars-background: var(--spectrum-global-color-gray-300);
|
||||
}
|
||||
.nav-item:hover .actions,
|
||||
.hovering .actions {
|
||||
visibility: visible;
|
||||
.hovering .actions,
|
||||
.nav-item.withActions.actionsOpen .actions,
|
||||
.nav-item.withActions.showActions .actions {
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-item-content {
|
||||
flex: 1 1 auto;
|
||||
|
@ -272,7 +277,6 @@
|
|||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
visibility: hidden;
|
||||
order: 3;
|
||||
opacity: 0;
|
||||
width: 20px;
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import analytics, { Events, EventSource } from "analytics"
|
||||
import { API } from "api"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
import {
|
||||
previewStore,
|
||||
builderStore,
|
||||
|
@ -45,7 +45,7 @@
|
|||
let appActionPopoverAnchor
|
||||
let publishing = false
|
||||
|
||||
$: filteredApps = $apps.filter(app => app.devId === application)
|
||||
$: filteredApps = $appsStore.apps.filter(app => app.devId === application)
|
||||
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
|
||||
$: latestDeployments = $deploymentStore
|
||||
.filter(deployment => deployment.status === "SUCCESS")
|
||||
|
@ -129,7 +129,7 @@
|
|||
}
|
||||
try {
|
||||
await API.unpublishApp(selectedApp.prodId)
|
||||
await apps.load()
|
||||
await appsStore.load()
|
||||
notifications.send("App unpublished", {
|
||||
type: "success",
|
||||
icon: "GlobeStrike",
|
||||
|
@ -141,7 +141,7 @@
|
|||
|
||||
const completePublish = async () => {
|
||||
try {
|
||||
await apps.load()
|
||||
await appsStore.load()
|
||||
await deploymentStore.load()
|
||||
} catch (err) {
|
||||
notifications.error("Error refreshing app")
|
||||
|
|
|
@ -2,10 +2,17 @@
|
|||
import { Input, notifications } from "@budibase/bbui"
|
||||
import { goto } from "@roxi/routify"
|
||||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import { apps } from "stores/portal"
|
||||
import { appStore } from "stores/builder"
|
||||
import { appsStore } from "stores/portal"
|
||||
import { API } from "api"
|
||||
|
||||
export let appId
|
||||
export let appName
|
||||
export let onDeleteSuccess = () => {
|
||||
$goto("/builder")
|
||||
}
|
||||
|
||||
let deleting = false
|
||||
|
||||
export const show = () => {
|
||||
deletionModal.show()
|
||||
}
|
||||
|
@ -17,32 +24,52 @@
|
|||
let deletionModal
|
||||
let deletionConfirmationAppName
|
||||
|
||||
const copyName = () => {
|
||||
deletionConfirmationAppName = appName
|
||||
}
|
||||
|
||||
const deleteApp = async () => {
|
||||
if (!appId) {
|
||||
console.error("No app id provided")
|
||||
return
|
||||
}
|
||||
deleting = true
|
||||
try {
|
||||
await API.deleteApp($appStore.appId)
|
||||
apps.load()
|
||||
await API.deleteApp(appId)
|
||||
appsStore.load()
|
||||
notifications.success("App deleted successfully")
|
||||
$goto("/builder")
|
||||
onDeleteSuccess()
|
||||
} catch (err) {
|
||||
notifications.error("Error deleting app")
|
||||
deleting = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<ConfirmDialog
|
||||
bind:this={deletionModal}
|
||||
title="Delete app"
|
||||
okText="Delete"
|
||||
onOk={deleteApp}
|
||||
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 />
|
||||
Please enter the app name below to confirm.
|
||||
<br /><br />
|
||||
<Input
|
||||
bind:value={deletionConfirmationAppName}
|
||||
placeholder={$appStore.name}
|
||||
/>
|
||||
<Input bind:value={deletionConfirmationAppName} placeholder={appName} />
|
||||
</ConfirmDialog>
|
||||
|
||||
<style>
|
||||
.app-name {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -31,17 +31,11 @@
|
|||
: null}
|
||||
>
|
||||
<Body>
|
||||
You are currently on our <span class="free-plan">Free plan</span>. Upgrade
|
||||
to our Pro plan to get unlimited apps and additional features.
|
||||
You have exceeded the app limit for your current plan. Upgrade to get
|
||||
unlimited apps and additional features!
|
||||
</Body>
|
||||
{#if !$auth.user.accountPortalAccess}
|
||||
<Body>Please contact the account holder to upgrade.</Body>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.free-plan {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,10 +5,14 @@
|
|||
import { goto } from "@roxi/routify"
|
||||
import { UserAvatars } from "@budibase/frontend-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 lockedAction
|
||||
|
||||
let actionsOpen = false
|
||||
|
||||
$: editing = app.sessions?.length
|
||||
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
|
||||
$: unclickable = !isBuilder && !app.deployed
|
||||
|
@ -42,8 +46,10 @@
|
|||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="app-row"
|
||||
on:click={lockedAction || handleDefaultClick}
|
||||
class:unclickable
|
||||
class:actionsOpen
|
||||
class:favourite={app.favourite}
|
||||
on:click={lockedAction || handleDefaultClick}
|
||||
>
|
||||
<div class="title">
|
||||
<div class="app-icon">
|
||||
|
@ -74,21 +80,35 @@
|
|||
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
|
||||
</div>
|
||||
|
||||
{#if isBuilder}
|
||||
<div class="actions-wrap">
|
||||
<div class="app-row-actions">
|
||||
<Button size="S" secondary on:click={lockedAction || goToOverview}>
|
||||
Manage
|
||||
</Button>
|
||||
<Button size="S" primary on:click={lockedAction || goToBuilder}>
|
||||
Edit
|
||||
</Button>
|
||||
{#if isBuilder}
|
||||
<div class="row-action">
|
||||
<Button size="S" secondary on:click={lockedAction || goToBuilder}>
|
||||
Edit
|
||||
</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>
|
||||
{:else if app.deployed}
|
||||
<!-- this can happen if an app builder has app user access to an app -->
|
||||
<div class="app-row-actions">
|
||||
<Button size="S" secondary>View</Button>
|
||||
|
||||
<div class="favourite-icon">
|
||||
<FavouriteAppButton {app} noWrap />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
@ -108,6 +128,16 @@
|
|||
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 {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
display: flex;
|
||||
|
@ -143,11 +173,23 @@
|
|||
}
|
||||
|
||||
.app-row-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-row:hover .app-row-actions,
|
||||
.app-row.actionsOpen .app-row-actions {
|
||||
gap: var(--spacing-m);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
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 {
|
||||
|
|
|
@ -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,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
|
||||
export let app
|
||||
|
@ -49,7 +49,7 @@
|
|||
return
|
||||
}
|
||||
try {
|
||||
await apps.update(app.instance._id, {
|
||||
await appsStore.save(app.instance._id, {
|
||||
icon: { name, color },
|
||||
})
|
||||
} catch (error) {
|
||||
|
|
|
@ -9,13 +9,14 @@
|
|||
} from "@budibase/bbui"
|
||||
import { initialise } from "stores/builder"
|
||||
import { API } from "api"
|
||||
import { apps, admin, auth } from "stores/portal"
|
||||
import { appsStore, admin, auth } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { createValidationStore } from "helpers/validation/yup"
|
||||
import * as appValidation from "helpers/validation/yup/app"
|
||||
import TemplateCard from "components/common/TemplateCard.svelte"
|
||||
import { lowercase } from "helpers"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
export let template
|
||||
|
||||
|
@ -92,7 +93,7 @@
|
|||
}
|
||||
|
||||
const setupValidation = async () => {
|
||||
const applications = svelteGet(apps)
|
||||
const applications = svelteGet(appsStore).apps
|
||||
appValidation.name(validation, { apps: applications })
|
||||
appValidation.url(validation, { apps: applications })
|
||||
appValidation.file(validation, { template })
|
||||
|
@ -141,6 +142,11 @@
|
|||
// Create user
|
||||
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}`)
|
||||
} catch (error) {
|
||||
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
|
||||
type="password"
|
||||
label="Password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Type here..."
|
||||
bind:value={password}
|
||||
error={$validation.errors.password}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
Layout,
|
||||
Label,
|
||||
} from "@budibase/bbui"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
import { onMount } from "svelte"
|
||||
import { createValidationStore } from "helpers/validation/yup"
|
||||
import * as appValidation from "helpers/validation/yup/app"
|
||||
|
@ -37,7 +37,7 @@
|
|||
}
|
||||
|
||||
const setupValidation = async () => {
|
||||
const applications = svelteGet(apps)
|
||||
const applications = svelteGet(appsStore).apps
|
||||
appValidation.name(validation, {
|
||||
apps: applications,
|
||||
currentApp: {
|
||||
|
@ -62,7 +62,7 @@
|
|||
|
||||
async function updateApp() {
|
||||
try {
|
||||
await apps.update(app.appId, {
|
||||
await appsStore.save(app.appId, {
|
||||
name: $values.name?.trim(),
|
||||
url: $values.url?.trim(),
|
||||
icon: {
|
||||
|
|
|
@ -22,6 +22,7 @@ body {
|
|||
--grey-7: var(--spectrum-global-color-gray-700);
|
||||
--grey-8: var(--spectrum-global-color-gray-800);
|
||||
--grey-9: var(--spectrum-global-color-gray-900);
|
||||
--spectrum-global-color-yellow-1000: #d8b500;
|
||||
|
||||
color: var(--ink);
|
||||
background-color: var(--background-alt);
|
||||
|
|
|
@ -15,7 +15,14 @@
|
|||
FancySelect,
|
||||
} from "@budibase/bbui"
|
||||
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 {
|
||||
fetchData,
|
||||
Constants,
|
||||
|
@ -54,7 +61,7 @@
|
|||
|
||||
let inviteFailureResponse = ""
|
||||
$: validEmail = emailValidator(email) === true
|
||||
$: prodAppId = apps.getProdAppID($appStore.appId)
|
||||
$: prodAppId = appsStore.getProdAppID($appStore.appId)
|
||||
$: promptInvite = showInvite(
|
||||
filteredInvites,
|
||||
filteredUsers,
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
userStore,
|
||||
deploymentStore,
|
||||
} from "stores/builder"
|
||||
import { auth, apps } from "stores/portal"
|
||||
import { auth, appsStore } from "stores/portal"
|
||||
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
|
||||
import {
|
||||
Icon,
|
||||
|
@ -52,7 +52,7 @@
|
|||
const pkg = await API.fetchAppPackage(application)
|
||||
await initialise(pkg)
|
||||
|
||||
await apps.load()
|
||||
await appsStore.load()
|
||||
await deploymentStore.load()
|
||||
|
||||
loaded = true
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { Page, Layout, AbsTooltip, TooltipPosition } from "@budibase/bbui"
|
||||
import { url, isActive } from "@roxi/routify"
|
||||
import DeleteModal from "components/deploy/DeleteModal.svelte"
|
||||
import { isOnlyUser } from "stores/builder"
|
||||
import { isOnlyUser, appStore } from "stores/builder"
|
||||
|
||||
let deleteModal
|
||||
</script>
|
||||
|
@ -67,7 +67,11 @@
|
|||
</Page>
|
||||
</div>
|
||||
|
||||
<DeleteModal bind:this={deleteModal} />
|
||||
<DeleteModal
|
||||
bind:this={deleteModal}
|
||||
appId={$appStore.appId}
|
||||
appName={$appStore.name}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.delete-action :global(.text) {
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
import { createPaginationStore } from "helpers/pagination"
|
||||
import { getContext, onDestroy, onMount } from "svelte"
|
||||
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 Portal from "svelte-portal"
|
||||
|
||||
|
@ -36,7 +36,7 @@
|
|||
let status = null
|
||||
let timeRange = null
|
||||
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
|
||||
$: page = $pageInfo.page
|
||||
$: fetchLogs(automationId, status, page, timeRange)
|
||||
|
@ -129,7 +129,7 @@
|
|||
|
||||
async function save({ detail }) {
|
||||
try {
|
||||
await apps.update($appStore.appId, {
|
||||
await appsStore.save($appStore.appId, {
|
||||
automations: {
|
||||
chainAutomations: detail,
|
||||
},
|
||||
|
|
|
@ -10,10 +10,10 @@
|
|||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { AppStatus } from "constants"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
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] : {}
|
||||
$: appUrl = `${window.origin}/embed${app?.url}`
|
||||
$: appDeployed = app?.status === AppStatus.DEPLOYED
|
||||
|
|
|
@ -8,12 +8,12 @@
|
|||
Modal,
|
||||
} from "@budibase/bbui"
|
||||
import { AppStatus } from "constants"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
import { appStore } from "stores/builder"
|
||||
import ExportAppModal from "components/start/ExportAppModal.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] : {}
|
||||
$: appDeployed = app?.status === AppStatus.DEPLOYED
|
||||
|
||||
|
|
|
@ -11,13 +11,13 @@
|
|||
} from "@budibase/bbui"
|
||||
import { AppStatus } from "constants"
|
||||
import { appStore, initialise } from "stores/builder"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
|
||||
import { API } from "api"
|
||||
|
||||
let updatingModal
|
||||
|
||||
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId)
|
||||
$: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
|
||||
$: app = filteredApps.length ? filteredApps[0] : {}
|
||||
$: appDeployed = app?.status === AppStatus.DEPLOYED
|
||||
|
||||
|
|
|
@ -12,7 +12,14 @@
|
|||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
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 { AppStatus } from "constants"
|
||||
import { gradient } from "actions"
|
||||
|
@ -31,7 +38,9 @@
|
|||
$: userGroups = $groups.filter(group =>
|
||||
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)
|
||||
|
||||
function getUserApps(publishedApps, userGroups, user) {
|
||||
|
@ -46,12 +55,12 @@
|
|||
return userGroups.find(group => {
|
||||
return groups.actions
|
||||
.getGroupAppIds(group)
|
||||
.map(role => apps.extractAppId(role))
|
||||
.map(role => appsStore.extractAppId(role))
|
||||
.includes(app.appId)
|
||||
})
|
||||
} else {
|
||||
return Object.keys($auth.user?.roles)
|
||||
.map(x => apps.extractAppId(x))
|
||||
.map(x => appsStore.extractAppId(x))
|
||||
.includes(app.appId)
|
||||
}
|
||||
})
|
||||
|
@ -76,7 +85,7 @@
|
|||
onMount(async () => {
|
||||
try {
|
||||
await organisation.init()
|
||||
await apps.load()
|
||||
await appsStore.load()
|
||||
await groups.actions.init()
|
||||
} catch (error) {
|
||||
notifications.error("Error loading apps")
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { isActive, redirect, goto, url } from "@roxi/routify"
|
||||
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 UpgradeButton from "./_components/UpgradeButton.svelte"
|
||||
import MobileMenu from "./_components/MobileMenu.svelte"
|
||||
|
@ -16,7 +16,8 @@
|
|||
let activeTab = "Apps"
|
||||
|
||||
$: $url(), updateActiveTab($menu)
|
||||
$: isOnboarding = !$apps.length && sdk.users.hasBuilderPermissions($auth.user)
|
||||
$: isOnboarding =
|
||||
!$appsStore.apps.length && sdk.users.hasBuilderPermissions($auth.user)
|
||||
|
||||
const updateActiveTab = menu => {
|
||||
for (let entry of menu) {
|
||||
|
@ -40,7 +41,7 @@
|
|||
} else {
|
||||
try {
|
||||
// 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) {
|
||||
notifications.error("Error getting org config")
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
Divider,
|
||||
ActionButton,
|
||||
} 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 { createPaginationStore } from "helpers/pagination"
|
||||
import { onMount, setContext } from "svelte"
|
||||
|
@ -102,7 +102,7 @@
|
|||
enrich(parseEventObject($auditLogs.events), selectedEvents, "id"),
|
||||
"id"
|
||||
)
|
||||
$: sortedApps = sort(enrich($apps, selectedApps, "appId"), "name")
|
||||
$: sortedApps = sort(enrich($appsStore.apps, selectedApps, "appId"), "name")
|
||||
|
||||
const debounce = value => {
|
||||
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>
|
||||
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) {
|
||||
$redirect("../")
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
<script>
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import { apps, auth, sideBarCollapsed } from "stores/portal"
|
||||
import { Link, Body, ActionButton } from "@budibase/bbui"
|
||||
import { auth, sideBarCollapsed, enrichedApps } from "stores/portal"
|
||||
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 { API } from "api"
|
||||
import ErrorSVG from "./ErrorSVG.svelte"
|
||||
|
||||
$: app = $apps.find(app => app.appId === $params.appId)
|
||||
$: app = $enrichedApps.find(app => app.appId === $params.appId)
|
||||
$: iframeUrl = getIframeURL(app)
|
||||
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
|
||||
|
||||
|
@ -30,42 +39,63 @@
|
|||
$: fetchScreens(app?.devId)
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
{#if $sideBarCollapsed}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="Rail"
|
||||
on:click={() => sideBarCollapsed.set(false)}
|
||||
>
|
||||
Menu
|
||||
</ActionButton>
|
||||
<div class="headerButton" on:click={() => sideBarCollapsed.set(false)}>
|
||||
<Icon
|
||||
name={"Rail"}
|
||||
hoverable
|
||||
tooltip="Expand"
|
||||
tooltipPosition={TooltipPosition.Right}
|
||||
tooltipType={TooltipType.Info}
|
||||
hoverColor={"var(--ink)"}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="RailRightOpen"
|
||||
on:click={() => sideBarCollapsed.set(true)}
|
||||
>
|
||||
Collapse
|
||||
</ActionButton>
|
||||
<div class="headerButton" on:click={() => sideBarCollapsed.set(true)}>
|
||||
<Icon
|
||||
name={"RailRightOpen"}
|
||||
hoverable
|
||||
tooltip="Collapse"
|
||||
tooltipType={TooltipType.Info}
|
||||
tooltipPosition={TooltipPosition.Top}
|
||||
hoverColor={"var(--ink)"}
|
||||
size="S"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isBuilder}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="Edit"
|
||||
<Button
|
||||
size="M"
|
||||
secondary
|
||||
on:click={() => $goto(`/builder/app/${app.devId}`)}
|
||||
>
|
||||
Edit
|
||||
</ActionButton>
|
||||
</Button>
|
||||
{/if}
|
||||
<ActionButton
|
||||
disabled={noScreens}
|
||||
quiet
|
||||
icon="LinkOut"
|
||||
on:click={() => window.open(iframeUrl, "_blank")}
|
||||
>
|
||||
Fullscreen
|
||||
</ActionButton>
|
||||
<div class="headerButton">
|
||||
<FavouriteAppButton {app} />
|
||||
</div>
|
||||
<div class="headerButton" on:click={() => window.open(iframeUrl, "_blank")}>
|
||||
<Icon
|
||||
name="LinkOut"
|
||||
disabled={noScreens}
|
||||
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>
|
||||
{#if noScreens}
|
||||
<div class="noScreens">
|
||||
|
@ -83,6 +113,15 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.headerButton {
|
||||
color: var(--grey-7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.headerButton:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
|
@ -96,7 +135,7 @@
|
|||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
gap: var(--spacing-xl);
|
||||
flex: 0 0 50px;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,23 +1,21 @@
|
|||
<script>
|
||||
import { apps, sideBarCollapsed } from "stores/portal"
|
||||
import { sideBarCollapsed, enrichedApps, auth } from "stores/portal"
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import NavItem from "components/common/NavItem.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 opened
|
||||
|
||||
$: filteredApps = $apps
|
||||
.filter(app => {
|
||||
return (
|
||||
!searchString ||
|
||||
app.name.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const lowerA = a.name.toLowerCase()
|
||||
const lowerB = b.name.toLowerCase()
|
||||
return lowerA > lowerB ? 1 : -1
|
||||
})
|
||||
$: filteredApps = $enrichedApps.filter(app => {
|
||||
return (
|
||||
!searchString ||
|
||||
app.name.toLowerCase().includes(searchString.toLowerCase())
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="side-bar" class:collapsed={$sideBarCollapsed}>
|
||||
|
@ -37,13 +35,40 @@
|
|||
selected={!$params.appId}
|
||||
/>
|
||||
{#each filteredApps as app}
|
||||
<NavItem
|
||||
text={app.name}
|
||||
icon={app.icon?.name || "Apps"}
|
||||
iconColor={app.icon?.color}
|
||||
selected={$params.appId === app.appId}
|
||||
on:click={() => $goto(`./${app.appId}`)}
|
||||
/>
|
||||
<span
|
||||
class="side-bar-app-entry"
|
||||
class:favourite={app.favourite}
|
||||
class:actionsOpen={opened == 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}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -86,4 +111,23 @@
|
|||
overflow: auto;
|
||||
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>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
import { notifications } from "@budibase/bbui"
|
||||
import {
|
||||
admin,
|
||||
apps,
|
||||
appsStore,
|
||||
templates,
|
||||
licensing,
|
||||
groups,
|
||||
|
@ -14,7 +14,7 @@
|
|||
import PortalSideBar from "./_components/PortalSideBar.svelte"
|
||||
|
||||
// Don't block loading if we've already hydrated state
|
||||
let loaded = !!$apps?.length
|
||||
let loaded = !!$appsStore.apps?.length
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
|
@ -34,7 +34,10 @@
|
|||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
} catch (error) {
|
||||
|
@ -46,7 +49,7 @@
|
|||
|
||||
{#if loaded}
|
||||
<div class="page">
|
||||
{#if $apps.length > 0}
|
||||
{#if $appsStore.apps.length > 0}
|
||||
<PortalSideBar />
|
||||
{/if}
|
||||
<slot />
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
import CreateAppModal from "components/start/CreateAppModal.svelte"
|
||||
import TemplateDisplay from "components/common/TemplateDisplay.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"
|
||||
|
||||
let template
|
||||
|
@ -35,7 +35,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
{#if !$apps.length}
|
||||
{#if !$appsStore.apps.length}
|
||||
<FirstAppOnboarding />
|
||||
{:else}
|
||||
<Page>
|
||||
|
|
|
@ -19,13 +19,18 @@
|
|||
import { automationStore, initialise } from "stores/builder"
|
||||
import { API } from "api"
|
||||
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 AppRow from "components/start/AppRow.svelte"
|
||||
import { AppStatus } from "constants"
|
||||
import Logo from "assets/bb-space-man.svg"
|
||||
|
||||
let sortBy = "name"
|
||||
let template
|
||||
let creationModal
|
||||
let appLimitModal
|
||||
|
@ -33,56 +38,27 @@
|
|||
let searchTerm = ""
|
||||
let creatingFromTemplate = false
|
||||
let automationErrors
|
||||
let accessFilterList = null
|
||||
|
||||
$: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}`
|
||||
$: enrichedApps = enrichApps($apps, $auth.user, sortBy)
|
||||
$: filteredApps = enrichedApps.filter(
|
||||
app =>
|
||||
(searchTerm
|
||||
? app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
: true) &&
|
||||
(accessFilterList !== null
|
||||
? accessFilterList?.includes(
|
||||
`${app?.type}_${app?.tenantId}_${app?.appId}`
|
||||
)
|
||||
: true)
|
||||
)
|
||||
$: automationErrors = getAutomationErrors(enrichedApps)
|
||||
$: filteredApps = filterApps($enrichedApps, searchTerm)
|
||||
$: automationErrors = getAutomationErrors(filteredApps || [])
|
||||
$: 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
|
||||
? () => accountLockedModal.show()
|
||||
: 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 automationErrors = {}
|
||||
for (let app of apps) {
|
||||
|
@ -117,7 +93,7 @@
|
|||
const initiateAppCreation = async () => {
|
||||
if ($licensing?.usageMetrics?.apps >= 100) {
|
||||
appLimitModal.show()
|
||||
} else if ($apps?.length) {
|
||||
} else if ($appsStore.apps?.length) {
|
||||
$goto("/builder/portal/apps/create")
|
||||
} else {
|
||||
template = null
|
||||
|
@ -136,7 +112,7 @@
|
|||
const templateKey = template.key.split("/")[1]
|
||||
|
||||
let appName = templateKey.replace(/-/g, " ")
|
||||
const appsWithSameName = $apps.filter(app =>
|
||||
const appsWithSameName = $appsStore.apps.filter(app =>
|
||||
app.name?.startsWith(appName)
|
||||
)
|
||||
appName = `${appName} ${appsWithSameName.length + 1}`
|
||||
|
@ -217,7 +193,7 @@
|
|||
: "View error"}
|
||||
on:dismiss={async () => {
|
||||
await automationStore.actions.clearLogErrors({ appId })
|
||||
await apps.load()
|
||||
await appsStore.load()
|
||||
}}
|
||||
message={automationErrorMessage(appId)}
|
||||
/>
|
||||
|
@ -233,7 +209,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if enrichedApps.length}
|
||||
{#if $appsStore.apps.length}
|
||||
<Layout noPadding gap="L">
|
||||
<div class="title">
|
||||
{#if $auth.user && sdk.users.canCreateApps($auth.user)}
|
||||
|
@ -245,7 +221,7 @@
|
|||
>
|
||||
Create new app
|
||||
</Button>
|
||||
{#if $apps?.length > 0 && !$admin.offlineMode}
|
||||
{#if $appsStore.apps?.length > 0 && !$admin.offlineMode}
|
||||
<Button
|
||||
size="M"
|
||||
secondary
|
||||
|
@ -255,7 +231,7 @@
|
|||
View templates
|
||||
</Button>
|
||||
{/if}
|
||||
{#if !$apps?.length}
|
||||
{#if !$appsStore.apps?.length}
|
||||
<Button
|
||||
size="L"
|
||||
quiet
|
||||
|
@ -267,11 +243,14 @@
|
|||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if enrichedApps.length > 1}
|
||||
{#if $appsStore.apps.length > 1}
|
||||
<div class="app-actions">
|
||||
<Select
|
||||
autoWidth
|
||||
bind:value={sortBy}
|
||||
value={$appsStore.sortBy}
|
||||
on:change={e => {
|
||||
appsStore.updateSort(e.detail)
|
||||
}}
|
||||
placeholder={null}
|
||||
options={[
|
||||
{ label: "Sort by name", value: "name" },
|
||||
|
@ -279,7 +258,17 @@
|
|||
{ 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>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
|
||||
import { Breadcrumb, Breadcrumbs } from "components/portal/page"
|
||||
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 AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
|
||||
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
|
||||
|
@ -51,17 +51,17 @@
|
|||
$: isScimGroup = group?.scimInfo?.isSync
|
||||
$: isAdmin = sdk.users.isAdmin($auth.user)
|
||||
$: readonly = !isAdmin || isScimGroup
|
||||
$: groupApps = $apps
|
||||
$: groupApps = $appsStore.apps
|
||||
.filter(app =>
|
||||
groups.actions
|
||||
.getGroupAppIds(group)
|
||||
.includes(apps.getProdAppID(app.devId))
|
||||
.includes(appsStore.getProdAppID(app.devId))
|
||||
)
|
||||
.map(app => ({
|
||||
...app,
|
||||
role: group?.builder?.apps.includes(apps.getProdAppID(app.devId))
|
||||
role: group?.builder?.apps.includes(appsStore.getProdAppID(app.devId))
|
||||
? Constants.Roles.CREATOR
|
||||
: group?.roles?.[apps.getProdAppID(app.devId)],
|
||||
: group?.roles?.[appsStore.getProdAppID(app.devId)],
|
||||
}))
|
||||
|
||||
$: {
|
||||
|
@ -93,7 +93,7 @@
|
|||
}
|
||||
|
||||
const removeApp = async app => {
|
||||
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId))
|
||||
await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId))
|
||||
}
|
||||
setContext("roles", {
|
||||
updateRole: () => {},
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<script>
|
||||
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 RoleSelect from "components/common/RoleSelect.svelte"
|
||||
|
||||
export let group
|
||||
|
||||
$: appOptions = $apps.map(app => ({
|
||||
$: appOptions = $appsStore.apps.map(app => ({
|
||||
label: app.name,
|
||||
value: app,
|
||||
}))
|
||||
|
@ -16,7 +16,7 @@
|
|||
let selectingRole = false
|
||||
|
||||
async function appSelected() {
|
||||
const prodAppId = apps.getProdAppID(selectedApp.devId)
|
||||
const prodAppId = appsStore.getProdAppID(selectedApp.devId)
|
||||
if (!selectingRole) {
|
||||
selectingRole = true
|
||||
await roles.fetchByAppId(prodAppId)
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
Table,
|
||||
} from "@budibase/bbui"
|
||||
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 ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
|
||||
import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
|
||||
|
@ -97,7 +97,7 @@
|
|||
$: privileged = sdk.users.isAdminOrGlobalBuilder(user)
|
||||
$: nameLabel = getNameLabel(user)
|
||||
$: filteredGroups = getFilteredGroups(internalGroups, searchTerm)
|
||||
$: availableApps = getAvailableApps($apps, privileged, user?.roles)
|
||||
$: availableApps = getAvailableApps($appsStore.apps, privileged, user?.roles)
|
||||
$: userGroups = $groups.filter(x => {
|
||||
return x.users?.find(y => {
|
||||
return y._id === userId
|
||||
|
@ -111,12 +111,12 @@
|
|||
availableApps = availableApps.filter(x => {
|
||||
let roleKeys = Object.keys(roles || {})
|
||||
return roleKeys.concat(user?.builder?.apps).find(y => {
|
||||
return x.appId === apps.extractAppId(y)
|
||||
return x.appId === appsStore.extractAppId(y)
|
||||
})
|
||||
})
|
||||
}
|
||||
return availableApps.map(app => {
|
||||
const prodAppId = apps.getProdAppID(app.devId)
|
||||
const prodAppId = appsStore.getProdAppID(app.devId)
|
||||
return {
|
||||
name: app.name,
|
||||
devId: app.devId,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { apps } from "stores/portal"
|
||||
import { appsStore } from "stores/portal"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
export let value
|
||||
|
@ -10,7 +10,7 @@
|
|||
|
||||
const getCount = () => {
|
||||
if (priviliged) {
|
||||
return $apps.length
|
||||
return $appsStore.apps.length
|
||||
} else {
|
||||
return sdk.users.hasAppBuilderPermissions(row)
|
||||
? row?.builder?.apps?.length +
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { API } from "api"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore"
|
||||
|
||||
export const INITIAL_APP_META_STATE = {
|
||||
appId: "",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { get } from "svelte/store"
|
||||
import { createBuilderWebsocket } from "./websocket.js"
|
||||
import { BuilderSocketEvent } from "@budibase/shared-core"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore.js"
|
||||
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
|
||||
|
||||
export const INITIAL_BUILDER_STATE = {
|
||||
|
|
|
@ -28,7 +28,7 @@ import {
|
|||
DB_TYPE_INTERNAL,
|
||||
DB_TYPE_EXTERNAL,
|
||||
} from "constants/backend"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore"
|
||||
import { Utils } from "@budibase/frontend-core"
|
||||
|
||||
export const INITIAL_COMPONENTS_STATE = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { get } from "svelte/store"
|
||||
import { previewStore } from "stores/builder"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore"
|
||||
|
||||
export const INITIAL_HOVER_STATE = {
|
||||
componentId: null,
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { derived, get } from "svelte/store"
|
||||
import { componentStore } from "stores/builder"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore"
|
||||
import { API } from "api"
|
||||
|
||||
export const INITIAL_LAYOUT_STATE = {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { get } from "svelte/store"
|
||||
import { API } from "api"
|
||||
import { appStore } from "stores/builder"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore"
|
||||
|
||||
export const INITIAL_NAVIGATION_STATE = {
|
||||
navigation: "Top",
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "stores/builder"
|
||||
import { createHistoryStore } from "stores/builder/history"
|
||||
import { API } from "api"
|
||||
import BudiStore from "./BudiStore"
|
||||
import BudiStore from "../BudiStore"
|
||||
|
||||
export const INITIAL_SCREENS_STATE = {
|
||||
screens: [],
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
tables,
|
||||
} from "stores/builder"
|
||||
import { get } from "svelte/store"
|
||||
import { auth, apps } from "stores/portal"
|
||||
import { auth, appsStore } from "stores/portal"
|
||||
import { screenStore } from "./screens"
|
||||
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
|
||||
import { notifications } from "@budibase/bbui"
|
||||
|
@ -70,7 +70,7 @@ export const createBuilderWebsocket = appId => {
|
|||
socket.onOther(
|
||||
BuilderSocketEvent.AppPublishChange,
|
||||
async ({ user, published }) => {
|
||||
await apps.load()
|
||||
await appsStore.load()
|
||||
if (published) {
|
||||
await deploymentStore.load()
|
||||
}
|
||||
|
|
|
@ -1,39 +1,61 @@
|
|||
import { writable } from "svelte/store"
|
||||
import { derived } from "svelte/store"
|
||||
import { AppStatus } from "constants"
|
||||
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
|
||||
const DEV_PROPS = ["updatedBy", "updatedAt"]
|
||||
|
||||
const extractAppId = id => {
|
||||
const split = id?.split("_") || []
|
||||
return split.length ? split[split.length - 1] : null
|
||||
export const INITIAL_APPS_STATE = {
|
||||
apps: [],
|
||||
sortBy: "name",
|
||||
}
|
||||
|
||||
const 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}`
|
||||
}
|
||||
export class AppsStore extends BudiStore {
|
||||
constructor() {
|
||||
super({ ...INITIAL_APPS_STATE })
|
||||
|
||||
export function createAppStore() {
|
||||
const store = writable([])
|
||||
this.extractAppId = this.extractAppId.bind(this)
|
||||
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()
|
||||
if (Array.isArray(json)) {
|
||||
// Merge apps into one sensible list
|
||||
|
@ -43,7 +65,7 @@ export function createAppStore() {
|
|||
|
||||
// First append all dev app version
|
||||
devApps.forEach(app => {
|
||||
const id = extractAppId(app.appId)
|
||||
const id = this.extractAppId(app.appId)
|
||||
appMap[id] = {
|
||||
...app,
|
||||
devId: app.appId,
|
||||
|
@ -53,7 +75,7 @@ export function createAppStore() {
|
|||
|
||||
// Then merge with all prod app versions
|
||||
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
|
||||
if (!appMap[id]) {
|
||||
|
@ -81,39 +103,80 @@ export function createAppStore() {
|
|||
// Transform into an array and clean up
|
||||
const apps = Object.values(appMap)
|
||||
apps.forEach(app => {
|
||||
app.appId = extractAppId(app.devId)
|
||||
app.appId = this.extractAppId(app.devId)
|
||||
delete app._id
|
||||
delete app._rev
|
||||
})
|
||||
store.set(apps)
|
||||
this.update(state => ({
|
||||
...state,
|
||||
apps,
|
||||
}))
|
||||
} else {
|
||||
store.set([])
|
||||
this.update(state => ({
|
||||
...state,
|
||||
apps: [],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function update(appId, value) {
|
||||
async save(appId, value) {
|
||||
await API.saveAppMetadata({
|
||||
appId,
|
||||
metadata: value,
|
||||
})
|
||||
store.update(state => {
|
||||
const updatedAppIndex = state.findIndex(app => app.instance._id === appId)
|
||||
this.update(state => {
|
||||
const updatedAppIndex = state.apps.findIndex(
|
||||
app => app.instance._id === appId
|
||||
)
|
||||
if (updatedAppIndex !== -1) {
|
||||
let updatedApp = state[updatedAppIndex]
|
||||
let updatedApp = state.apps[updatedAppIndex]
|
||||
updatedApp = { ...updatedApp, ...value }
|
||||
state.apps = state.splice(updatedAppIndex, 1, updatedApp)
|
||||
state.apps = state.apps.splice(updatedAppIndex, 1, updatedApp)
|
||||
}
|
||||
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 { users } from "./users"
|
||||
export { admin } from "./admin"
|
||||
export { apps } from "./apps"
|
||||
export { appsStore, enrichedApps } from "./apps"
|
||||
export { email } from "./email"
|
||||
export { auth } from "./auth"
|
||||
export { oidc } from "./oidc"
|
||||
|
|
|
@ -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
|
||||
* should be of type FormData, with a "file" and a "password" if encrypted.
|
||||
|
|
|
@ -26,6 +26,7 @@ import {
|
|||
env as envCore,
|
||||
ErrorCode,
|
||||
events,
|
||||
HTTPError,
|
||||
migrations,
|
||||
objectStore,
|
||||
roles,
|
||||
|
@ -50,6 +51,8 @@ import {
|
|||
CreateAppRequest,
|
||||
FetchAppDefinitionResponse,
|
||||
FetchAppPackageResponse,
|
||||
DuplicateAppRequest,
|
||||
DuplicateAppResponse,
|
||||
} from "@budibase/types"
|
||||
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
|
||||
import sdk from "../../sdk"
|
||||
|
@ -122,7 +125,7 @@ interface AppTemplate {
|
|||
templateString?: string
|
||||
useTemplate?: string
|
||||
file?: {
|
||||
type: string
|
||||
type?: string
|
||||
path: string
|
||||
password?: string
|
||||
}
|
||||
|
@ -263,6 +266,10 @@ async function performAppCreate(ctx: UserCtx<CreateAppRequest, App>) {
|
|||
...(ctx.request.files.templateFile as any),
|
||||
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 appId = generateDevAppID(generateAppID(tenantId))
|
||||
|
@ -372,12 +379,20 @@ async function creationEvents(request: any, app: App) {
|
|||
else if (request.files?.templateFile) {
|
||||
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
|
||||
else {
|
||||
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) {
|
||||
await fn(app)
|
||||
|
@ -391,8 +406,10 @@ async function appPostCreate(ctx: UserCtx, app: App) {
|
|||
tenantId,
|
||||
appId: app.appId,
|
||||
})
|
||||
|
||||
await creationEvents(ctx.request, app)
|
||||
// app import & template creation
|
||||
|
||||
// app import, template creation and duplication
|
||||
if (ctx.request.body.useTemplate === "true") {
|
||||
const { rows } = await getUniqueRows([app.appId])
|
||||
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))
|
||||
await appPostCreate(ctx, newApplication)
|
||||
await cache.bustCache(cache.CacheKey.CHECKLIST)
|
||||
|
@ -626,6 +643,69 @@ export async function importToApp(ctx: UserCtx) {
|
|||
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(
|
||||
appPackage: Partial<App>,
|
||||
appId: string
|
||||
|
|
|
@ -55,9 +55,14 @@ router
|
|||
)
|
||||
.delete(
|
||||
"/api/applications/:appId",
|
||||
authorized(permissions.GLOBAL_BUILDER),
|
||||
authorized(permissions.BUILDER),
|
||||
controller.destroy
|
||||
)
|
||||
.post(
|
||||
"/api/applications/:appId/duplicate",
|
||||
authorized(permissions.BUILDER),
|
||||
controller.duplicateApp
|
||||
)
|
||||
.post(
|
||||
"/api/applications/:appId/import",
|
||||
authorized(permissions.BUILDER),
|
||||
|
|
|
@ -34,6 +34,96 @@ describe("/applications", () => {
|
|||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
// These need to go first for the app totals to make sense
|
||||
describe("permissions", () => {
|
||||
it("should only return apps a user has access to", async () => {
|
||||
let user = await config.createUser({
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(0)
|
||||
})
|
||||
|
||||
user = await config.globalUser({
|
||||
...user,
|
||||
builder: {
|
||||
apps: [config.getProdAppId()],
|
||||
},
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("should only return apps a user has access to through a custom role", async () => {
|
||||
let user = await config.createUser({
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(0)
|
||||
})
|
||||
|
||||
const role = await config.api.roles.save({
|
||||
name: "Test",
|
||||
inherits: "PUBLIC",
|
||||
permissionId: "read_only",
|
||||
version: "name",
|
||||
})
|
||||
|
||||
user = await config.globalUser({
|
||||
...user,
|
||||
roles: {
|
||||
[config.getProdAppId()]: role.name,
|
||||
},
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("should only return apps a user has access to through a custom role on a group", async () => {
|
||||
let user = await config.createUser({
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(0)
|
||||
})
|
||||
|
||||
const roleName = uuid.v4().replace(/-/g, "")
|
||||
const role = await config.api.roles.save({
|
||||
name: roleName,
|
||||
inherits: "PUBLIC",
|
||||
permissionId: "read_only",
|
||||
version: "name",
|
||||
})
|
||||
|
||||
const group = await config.createGroup(role._id!)
|
||||
|
||||
user = await config.globalUser({
|
||||
...user,
|
||||
userGroups: [group._id!],
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("creates empty app", async () => {
|
||||
const app = await config.api.application.create({ name: utils.newid() })
|
||||
|
@ -94,6 +184,20 @@ describe("/applications", () => {
|
|||
expect(events.app.created).toBeCalledTimes(1)
|
||||
expect(events.app.fileImported).toBeCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should reject with a known name", async () => {
|
||||
await config.api.application.create(
|
||||
{ name: app.name },
|
||||
{ body: { message: "App name is already in use." }, status: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject with a known url", async () => {
|
||||
await config.api.application.create(
|
||||
{ name: "made up", url: app?.url! },
|
||||
{ body: { message: "App URL is already in use." }, status: 400 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
|
@ -229,6 +333,63 @@ describe("/applications", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("POST /api/applications/:appId/duplicate", () => {
|
||||
it("should duplicate an existing app", async () => {
|
||||
const resp = await config.api.application.duplicateApp(
|
||||
app.appId,
|
||||
{
|
||||
name: "to-dupe copy",
|
||||
url: "/to-dupe-copy",
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
}
|
||||
)
|
||||
|
||||
expect(events.app.duplicated).toBeCalled()
|
||||
expect(resp.duplicateAppId).toBeDefined()
|
||||
expect(resp.sourceAppId).toEqual(app.appId)
|
||||
expect(resp.duplicateAppId).not.toEqual(app.appId)
|
||||
})
|
||||
|
||||
it("should reject an unknown app id with a 404", async () => {
|
||||
await config.api.application.duplicateApp(
|
||||
app.appId.slice(0, -1) + "a",
|
||||
{
|
||||
name: "to-dupe 123",
|
||||
url: "/to-dupe-123",
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("should reject with a known name", async () => {
|
||||
const resp = await config.api.application.duplicateApp(
|
||||
app.appId,
|
||||
{
|
||||
name: app.name,
|
||||
url: "/known-name",
|
||||
},
|
||||
{ body: { message: "App name is already in use." }, status: 400 }
|
||||
)
|
||||
expect(events.app.duplicated).not.toBeCalled()
|
||||
})
|
||||
|
||||
it("should reject with a known url", async () => {
|
||||
const resp = await config.api.application.duplicateApp(
|
||||
app.appId,
|
||||
{
|
||||
name: "this is fine",
|
||||
url: app.url,
|
||||
},
|
||||
{ body: { message: "App URL is already in use." }, status: 400 }
|
||||
)
|
||||
expect(events.app.duplicated).not.toBeCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /api/applications/:appId/sync", () => {
|
||||
it("should not sync automation logs", async () => {
|
||||
const automation = await config.createAutomation()
|
||||
|
@ -249,93 +410,4 @@ describe("/applications", () => {
|
|||
expect(devLogs.data.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("permissions", () => {
|
||||
it("should only return apps a user has access to", async () => {
|
||||
let user = await config.createUser({
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(0)
|
||||
})
|
||||
|
||||
user = await config.globalUser({
|
||||
...user,
|
||||
builder: {
|
||||
apps: [config.getProdAppId()],
|
||||
},
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it("should only return apps a user has access to through a custom role", async () => {
|
||||
let user = await config.createUser({
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(0)
|
||||
})
|
||||
|
||||
const role = await config.api.roles.save({
|
||||
name: "Test",
|
||||
inherits: "PUBLIC",
|
||||
permissionId: "read_only",
|
||||
version: "name",
|
||||
})
|
||||
|
||||
user = await config.globalUser({
|
||||
...user,
|
||||
roles: {
|
||||
[config.getProdAppId()]: role.name,
|
||||
},
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
it.only("should only return apps a user has access to through a custom role on a group", async () => {
|
||||
let user = await config.createUser({
|
||||
builder: { global: false },
|
||||
admin: { global: false },
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(0)
|
||||
})
|
||||
|
||||
const roleName = uuid.v4().replace(/-/g, "")
|
||||
const role = await config.api.roles.save({
|
||||
name: roleName,
|
||||
inherits: "PUBLIC",
|
||||
permissionId: "read_only",
|
||||
version: "name",
|
||||
})
|
||||
|
||||
const group = await config.createGroup(role._id!)
|
||||
|
||||
user = await config.globalUser({
|
||||
...user,
|
||||
userGroups: [group._id!],
|
||||
})
|
||||
|
||||
await config.withUser(user, async () => {
|
||||
const apps = await config.api.application.fetch()
|
||||
expect(apps).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
ViewV2,
|
||||
} from "@budibase/types"
|
||||
import * as setup from "./utilities"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||
|
||||
const { basicRow } = setup.structures
|
||||
const { BUILTIN_ROLE_IDS } = roles
|
||||
|
@ -44,7 +44,10 @@ describe("/permission", () => {
|
|||
|
||||
table = (await config.createTable()) as typeof table
|
||||
row = await config.createRow()
|
||||
view = await config.api.viewV2.create({ tableId: table._id })
|
||||
view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
perms = await config.api.permission.add({
|
||||
roleId: STD_ROLE_ID,
|
||||
resourceId: table._id,
|
||||
|
|
|
@ -42,6 +42,7 @@ tk.freeze(timestamp)
|
|||
jest.unmock("mysql2")
|
||||
jest.unmock("mysql2/promise")
|
||||
jest.unmock("mssql")
|
||||
jest.unmock("pg")
|
||||
|
||||
describe.each([
|
||||
["internal", undefined],
|
||||
|
@ -152,8 +153,8 @@ describe.each([
|
|||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
||||
describe("save, load, update", () => {
|
||||
it("returns a success message when the row is created", async () => {
|
||||
describe("create", () => {
|
||||
it("creates a new row successfully", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
const row = await config.api.row.save(table._id!, {
|
||||
name: "Test Contact",
|
||||
|
@ -163,7 +164,44 @@ describe.each([
|
|||
await assertRowUsage(rowUsage + 1)
|
||||
})
|
||||
|
||||
it("Increment row autoId per create row request", async () => {
|
||||
it("fails to create a row for a table that does not exist", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
await config.api.row.save("1234567", {}, { status: 404 })
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("fails to create a row if required fields are missing", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
schema: {
|
||||
required: {
|
||||
type: FieldType.STRING,
|
||||
name: "required",
|
||||
constraints: {
|
||||
type: "string",
|
||||
presence: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
await config.api.row.save(
|
||||
table._id!,
|
||||
{},
|
||||
{
|
||||
status: 500,
|
||||
body: {
|
||||
validationErrors: {
|
||||
required: ["can't be blank"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("increment row autoId per create row request", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const newTable = await config.api.table.save(
|
||||
|
@ -198,52 +236,6 @@ describe.each([
|
|||
await assertRowUsage(rowUsage + 10)
|
||||
})
|
||||
|
||||
it("updates a row successfully", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.save(table._id!, {
|
||||
_id: existing._id,
|
||||
_rev: existing._rev,
|
||||
name: "Updated Name",
|
||||
})
|
||||
|
||||
expect(res.name).toEqual("Updated Name")
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should load a row", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
|
||||
const res = await config.api.row.get(table._id!, existing._id!)
|
||||
|
||||
expect(res).toEqual({
|
||||
...existing,
|
||||
...defaultRowFields,
|
||||
})
|
||||
})
|
||||
|
||||
it("should list all rows for given tableId", async () => {
|
||||
const table = await config.api.table.save(defaultTable())
|
||||
const rows = await Promise.all([
|
||||
config.api.row.save(table._id!, {}),
|
||||
config.api.row.save(table._id!, {}),
|
||||
])
|
||||
|
||||
const res = await config.api.row.fetch(table._id!)
|
||||
expect(res.map(r => r._id)).toEqual(
|
||||
expect.arrayContaining(rows.map(r => r._id))
|
||||
)
|
||||
})
|
||||
|
||||
it("load should return 404 when row does not exist", async () => {
|
||||
const table = await config.api.table.save(defaultTable())
|
||||
await config.api.row.save(table._id!, {})
|
||||
await config.api.row.get(table._id!, "1234567", {
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
isInternal &&
|
||||
it("row values are coerced", async () => {
|
||||
const str: FieldSchema = {
|
||||
|
@ -296,8 +288,6 @@ describe.each([
|
|||
}
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
name: "TestTable2",
|
||||
type: "table",
|
||||
schema: {
|
||||
name: str,
|
||||
stringUndefined: str,
|
||||
|
@ -404,53 +394,60 @@ describe.each([
|
|||
})
|
||||
})
|
||||
|
||||
describe("view save", () => {
|
||||
it("views have extra data trimmed", async () => {
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
name: "orders",
|
||||
schema: {
|
||||
Country: {
|
||||
type: FieldType.STRING,
|
||||
name: "Country",
|
||||
},
|
||||
Story: {
|
||||
type: FieldType.STRING,
|
||||
name: "Story",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
describe("get", () => {
|
||||
it("reads an existing row successfully", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: uuid.v4(),
|
||||
schema: {
|
||||
Country: {
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const res = await config.api.row.get(table._id!, existing._id!)
|
||||
|
||||
const createRowResponse = await config.api.row.save(
|
||||
createViewResponse.id,
|
||||
{
|
||||
Country: "Aussy",
|
||||
Story: "aaaaa",
|
||||
}
|
||||
)
|
||||
|
||||
const row = await config.api.row.get(table._id!, createRowResponse._id!)
|
||||
expect(row.Story).toBeUndefined()
|
||||
expect(row).toEqual({
|
||||
expect(res).toEqual({
|
||||
...existing,
|
||||
...defaultRowFields,
|
||||
Country: "Aussy",
|
||||
id: createRowResponse.id,
|
||||
_id: createRowResponse._id,
|
||||
_rev: createRowResponse._rev,
|
||||
tableId: table._id,
|
||||
})
|
||||
})
|
||||
|
||||
it("returns 404 when row does not exist", async () => {
|
||||
const table = await config.api.table.save(defaultTable())
|
||||
await config.api.row.save(table._id!, {})
|
||||
await config.api.row.get(table._id!, "1234567", {
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetch", () => {
|
||||
it("fetches all rows for given tableId", async () => {
|
||||
const table = await config.api.table.save(defaultTable())
|
||||
const rows = await Promise.all([
|
||||
config.api.row.save(table._id!, {}),
|
||||
config.api.row.save(table._id!, {}),
|
||||
])
|
||||
|
||||
const res = await config.api.row.fetch(table._id!)
|
||||
expect(res.map(r => r._id)).toEqual(
|
||||
expect.arrayContaining(rows.map(r => r._id))
|
||||
)
|
||||
})
|
||||
|
||||
it("returns 404 when table does not exist", async () => {
|
||||
await config.api.row.fetch("1234567", { status: 404 })
|
||||
})
|
||||
})
|
||||
|
||||
describe("update", () => {
|
||||
it("updates an existing row successfully", async () => {
|
||||
const existing = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const res = await config.api.row.save(table._id!, {
|
||||
_id: existing._id,
|
||||
_rev: existing._rev,
|
||||
name: "Updated Name",
|
||||
})
|
||||
|
||||
expect(res.name).toEqual("Updated Name")
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("patch", () => {
|
||||
|
@ -722,50 +719,7 @@ describe.each([
|
|||
})
|
||||
})
|
||||
|
||||
// Legacy views are not available for external
|
||||
isInternal &&
|
||||
describe("fetchView", () => {
|
||||
beforeEach(async () => {
|
||||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
||||
it("should be able to fetch tables contents via 'view'", async () => {
|
||||
const row = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const rows = await config.api.legacyView.get(table._id!)
|
||||
expect(rows.length).toEqual(1)
|
||||
expect(rows[0]._id).toEqual(row._id)
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should throw an error if view doesn't exist", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
await config.api.legacyView.get("derp", undefined, { status: 404 })
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should be able to run on a view", async () => {
|
||||
const view = await config.createLegacyView({
|
||||
tableId: table._id!,
|
||||
name: "ViewTest",
|
||||
filters: [],
|
||||
schema: {},
|
||||
})
|
||||
const row = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const rows = await config.api.legacyView.get(view.name)
|
||||
expect(rows.length).toEqual(1)
|
||||
expect(rows[0]._id).toEqual(row._id)
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchEnrichedRows", () => {
|
||||
describe("enrich", () => {
|
||||
beforeAll(async () => {
|
||||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
@ -827,10 +781,6 @@ describe.each([
|
|||
|
||||
isInternal &&
|
||||
describe("attachments", () => {
|
||||
beforeAll(async () => {
|
||||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
||||
it("should allow enriching attachment rows", async () => {
|
||||
const table = await config.api.table.save(
|
||||
defaultTable({
|
||||
|
@ -865,7 +815,7 @@ describe.each([
|
|||
})
|
||||
})
|
||||
|
||||
describe("exportData", () => {
|
||||
describe("exportRows", () => {
|
||||
beforeAll(async () => {
|
||||
table = await config.api.table.save(defaultTable())
|
||||
})
|
||||
|
@ -947,6 +897,7 @@ describe.each([
|
|||
const table = await config.api.table.save(await userTable())
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
schema: {
|
||||
name: { visible: true },
|
||||
surname: { visible: true },
|
||||
|
@ -984,6 +935,7 @@ describe.each([
|
|||
const tableId = table._id!
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: tableId,
|
||||
name: generator.guid(),
|
||||
schema: {
|
||||
name: { visible: true },
|
||||
address: { visible: true },
|
||||
|
@ -1026,6 +978,7 @@ describe.each([
|
|||
const tableId = table._id!
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: tableId,
|
||||
name: generator.guid(),
|
||||
schema: {
|
||||
name: { visible: true },
|
||||
address: { visible: true },
|
||||
|
@ -1049,6 +1002,7 @@ describe.each([
|
|||
const tableId = table._id!
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: tableId,
|
||||
name: generator.guid(),
|
||||
schema: {
|
||||
name: { visible: true },
|
||||
address: { visible: true },
|
||||
|
@ -1109,6 +1063,7 @@ describe.each([
|
|||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
|
||||
|
@ -1155,6 +1110,7 @@ describe.each([
|
|||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
query: [
|
||||
{ operator: SearchQueryOperators.EQUAL, field: "age", value: 40 },
|
||||
],
|
||||
|
@ -1279,6 +1235,7 @@ describe.each([
|
|||
async (sortParams, expected) => {
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
sort: sortParams,
|
||||
schema: viewSchema,
|
||||
})
|
||||
|
@ -1299,6 +1256,7 @@ describe.each([
|
|||
async (sortParams, expected) => {
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
sort: {
|
||||
field: "name",
|
||||
order: SortOrder.ASCENDING,
|
||||
|
@ -1339,6 +1297,7 @@ describe.each([
|
|||
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
schema: { name: { visible: true } },
|
||||
})
|
||||
const response = await config.api.viewV2.search(view.id)
|
||||
|
@ -1361,6 +1320,7 @@ describe.each([
|
|||
const table = await config.api.table.save(await userTable())
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
const response = await config.api.viewV2.search(createViewResponse.id)
|
||||
expect(response.rows).toHaveLength(0)
|
||||
|
@ -1376,6 +1336,7 @@ describe.each([
|
|||
|
||||
const createViewResponse = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
const response = await config.api.viewV2.search(createViewResponse.id, {
|
||||
limit,
|
||||
|
@ -1392,6 +1353,7 @@ describe.each([
|
|||
)
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
const rows = (await config.api.viewV2.search(view.id)).rows
|
||||
|
||||
|
@ -1466,6 +1428,7 @@ describe.each([
|
|||
|
||||
view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ import sdk from "../../../sdk"
|
|||
import * as uuid from "uuid"
|
||||
|
||||
import tk from "timekeeper"
|
||||
import { mocks } from "@budibase/backend-core/tests"
|
||||
import { generator, mocks } from "@budibase/backend-core/tests"
|
||||
import { TableToBuild } from "../../../tests/utilities/TestConfiguration"
|
||||
|
||||
tk.freeze(mocks.date.MOCK_DATE)
|
||||
|
@ -417,8 +417,8 @@ describe("/tables", () => {
|
|||
it("should fetch views", async () => {
|
||||
const tableId = config.table!._id!
|
||||
const views = [
|
||||
await config.api.viewV2.create({ tableId }),
|
||||
await config.api.viewV2.create({ tableId }),
|
||||
await config.api.viewV2.create({ tableId, name: generator.guid() }),
|
||||
await config.api.viewV2.create({ tableId, name: generator.guid() }),
|
||||
]
|
||||
|
||||
const res = await request
|
||||
|
@ -455,7 +455,7 @@ describe("/tables", () => {
|
|||
},
|
||||
}))
|
||||
|
||||
await config.api.viewV2.create({ tableId })
|
||||
await config.api.viewV2.create({ tableId, name: generator.guid() })
|
||||
await config.createLegacyView()
|
||||
|
||||
const res = await config.api.table.fetch()
|
||||
|
|
|
@ -3,12 +3,15 @@ import * as setup from "./utilities"
|
|||
import {
|
||||
FieldType,
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
QuotaUsageType,
|
||||
SaveTableRequest,
|
||||
StaticQuotaName,
|
||||
Table,
|
||||
TableSourceType,
|
||||
View,
|
||||
ViewCalculation,
|
||||
} from "@budibase/types"
|
||||
import { quotas } from "@budibase/pro"
|
||||
|
||||
const priceTable: SaveTableRequest = {
|
||||
name: "table",
|
||||
|
@ -57,6 +60,18 @@ describe("/views", () => {
|
|||
return config.api.legacyView.save(viewToSave)
|
||||
}
|
||||
|
||||
const getRowUsage = async () => {
|
||||
const { total } = await config.doInContext(undefined, () =>
|
||||
quotas.getCurrentUsageValues(QuotaUsageType.STATIC, StaticQuotaName.ROWS)
|
||||
)
|
||||
return total
|
||||
}
|
||||
|
||||
const assertRowUsage = async (expected: number) => {
|
||||
const usage = await getRowUsage()
|
||||
expect(usage).toBe(expected)
|
||||
}
|
||||
|
||||
describe("create", () => {
|
||||
it("returns a success message when the view is successfully created", async () => {
|
||||
const res = await saveView()
|
||||
|
@ -265,6 +280,41 @@ describe("/views", () => {
|
|||
expect(views.length).toBe(1)
|
||||
expect(views.find(({ name }) => name === "TestView")).toBeDefined()
|
||||
})
|
||||
|
||||
it("should be able to fetch tables contents via 'view'", async () => {
|
||||
const row = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const rows = await config.api.legacyView.get(table._id!)
|
||||
expect(rows.length).toEqual(1)
|
||||
expect(rows[0]._id).toEqual(row._id)
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should throw an error if view doesn't exist", async () => {
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
await config.api.legacyView.get("derp", undefined, { status: 404 })
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
|
||||
it("should be able to run on a view", async () => {
|
||||
const view = await config.api.legacyView.save({
|
||||
tableId: table._id!,
|
||||
name: "ViewTest",
|
||||
filters: [],
|
||||
schema: {},
|
||||
})
|
||||
const row = await config.api.row.save(table._id!, {})
|
||||
const rowUsage = await getRowUsage()
|
||||
|
||||
const rows = await config.api.legacyView.get(view.name!)
|
||||
expect(rows.length).toEqual(1)
|
||||
expect(rows[0]._id).toEqual(row._id)
|
||||
|
||||
await assertRowUsage(rowUsage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("query", () => {
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import * as setup from "./utilities"
|
||||
import {
|
||||
CreateViewRequest,
|
||||
Datasource,
|
||||
FieldSchema,
|
||||
FieldType,
|
||||
INTERNAL_TABLE_SOURCE_ID,
|
||||
SaveTableRequest,
|
||||
SearchQueryOperators,
|
||||
SortOrder,
|
||||
SortType,
|
||||
|
@ -14,65 +16,88 @@ import {
|
|||
ViewV2,
|
||||
} from "@budibase/types"
|
||||
import { generator } from "@budibase/backend-core/tests"
|
||||
import { generateDatasourceID } from "../../../db/utils"
|
||||
import * as uuid from "uuid"
|
||||
import { databaseTestProviders } from "../../../integrations/tests/utils"
|
||||
import merge from "lodash/merge"
|
||||
|
||||
function priceTable(): Table {
|
||||
return {
|
||||
name: "table",
|
||||
type: "table",
|
||||
sourceId: INTERNAL_TABLE_SOURCE_ID,
|
||||
sourceType: TableSourceType.INTERNAL,
|
||||
schema: {
|
||||
Price: {
|
||||
type: FieldType.NUMBER,
|
||||
name: "Price",
|
||||
constraints: {},
|
||||
},
|
||||
Category: {
|
||||
type: FieldType.STRING,
|
||||
name: "Category",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const config = setup.getConfig()
|
||||
|
||||
beforeAll(async () => {
|
||||
await config.init()
|
||||
})
|
||||
jest.unmock("mysql2")
|
||||
jest.unmock("mysql2/promise")
|
||||
jest.unmock("mssql")
|
||||
jest.unmock("pg")
|
||||
|
||||
describe.each([
|
||||
["internal ds", () => config.createTable(priceTable())],
|
||||
[
|
||||
"external ds",
|
||||
async () => {
|
||||
const datasource = await config.createDatasource({
|
||||
datasource: {
|
||||
...setup.structures.basicDatasource().datasource,
|
||||
plus: true,
|
||||
_id: generateDatasourceID({ plus: true }),
|
||||
},
|
||||
})
|
||||
["internal", undefined],
|
||||
["postgres", databaseTestProviders.postgres],
|
||||
["mysql", databaseTestProviders.mysql],
|
||||
["mssql", databaseTestProviders.mssql],
|
||||
["mariadb", databaseTestProviders.mariadb],
|
||||
])("/v2/views (%s)", (_, dsProvider) => {
|
||||
const config = setup.getConfig()
|
||||
|
||||
return config.createExternalTable({
|
||||
...priceTable(),
|
||||
sourceId: datasource._id,
|
||||
sourceType: TableSourceType.EXTERNAL,
|
||||
})
|
||||
},
|
||||
],
|
||||
])("/v2/views (%s)", (_, tableBuilder) => {
|
||||
let table: Table
|
||||
let datasource: Datasource
|
||||
|
||||
function saveTableRequest(
|
||||
...overrides: Partial<SaveTableRequest>[]
|
||||
): SaveTableRequest {
|
||||
const req: SaveTableRequest = {
|
||||
name: uuid.v4().substring(0, 16),
|
||||
type: "table",
|
||||
sourceType: datasource
|
||||
? TableSourceType.EXTERNAL
|
||||
: TableSourceType.INTERNAL,
|
||||
sourceId: datasource ? datasource._id! : INTERNAL_TABLE_SOURCE_ID,
|
||||
primary: ["id"],
|
||||
schema: {
|
||||
id: {
|
||||
type: FieldType.AUTO,
|
||||
name: "id",
|
||||
autocolumn: true,
|
||||
constraints: {
|
||||
presence: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return merge(req, ...overrides)
|
||||
}
|
||||
|
||||
function priceTable(): SaveTableRequest {
|
||||
return saveTableRequest({
|
||||
schema: {
|
||||
Price: {
|
||||
type: FieldType.NUMBER,
|
||||
name: "Price",
|
||||
constraints: {},
|
||||
},
|
||||
Category: {
|
||||
type: FieldType.STRING,
|
||||
name: "Category",
|
||||
constraints: {
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
table = await tableBuilder()
|
||||
await config.init()
|
||||
|
||||
if (dsProvider) {
|
||||
datasource = await config.createDatasource({
|
||||
datasource: await dsProvider.datasource(),
|
||||
})
|
||||
}
|
||||
table = await config.api.table.save(priceTable())
|
||||
})
|
||||
|
||||
afterAll(setup.afterAll)
|
||||
afterAll(async () => {
|
||||
if (dsProvider) {
|
||||
await dsProvider.stop()
|
||||
}
|
||||
setup.afterAll()
|
||||
})
|
||||
|
||||
describe("create", () => {
|
||||
it("persist the view when the view is successfully created", async () => {
|
||||
|
@ -186,9 +211,12 @@ describe.each([
|
|||
let view: ViewV2
|
||||
|
||||
beforeEach(async () => {
|
||||
table = await tableBuilder()
|
||||
table = await config.api.table.save(priceTable())
|
||||
|
||||
view = await config.api.viewV2.create({ name: "View A" })
|
||||
view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: "View A",
|
||||
})
|
||||
})
|
||||
|
||||
it("can update an existing view data", async () => {
|
||||
|
@ -247,6 +275,9 @@ describe.each([
|
|||
...updatedData,
|
||||
schema: {
|
||||
...table.schema,
|
||||
id: expect.objectContaining({
|
||||
visible: false,
|
||||
}),
|
||||
Category: expect.objectContaining({
|
||||
visible: false,
|
||||
}),
|
||||
|
@ -320,23 +351,27 @@ describe.each([
|
|||
})
|
||||
|
||||
it("cannot update views v1", async () => {
|
||||
const viewV1 = await config.createLegacyView()
|
||||
await config.api.viewV2.update(
|
||||
{
|
||||
...viewV1,
|
||||
},
|
||||
{
|
||||
const viewV1 = await config.api.legacyView.save({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
filters: [],
|
||||
schema: {},
|
||||
})
|
||||
|
||||
await config.api.viewV2.update(viewV1 as unknown as ViewV2, {
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Only views V2 can be updated",
|
||||
status: 400,
|
||||
body: {
|
||||
message: "Only views V2 can be updated",
|
||||
status: 400,
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("cannot update the a view with unmatching ids between url and body", async () => {
|
||||
const anotherView = await config.api.viewV2.create()
|
||||
const anotherView = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
const result = await config
|
||||
.request!.put(`/api/v2/views/${anotherView.id}`)
|
||||
.send(view)
|
||||
|
@ -411,7 +446,10 @@ describe.each([
|
|||
let view: ViewV2
|
||||
|
||||
beforeAll(async () => {
|
||||
view = await config.api.viewV2.create()
|
||||
view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: generator.guid(),
|
||||
})
|
||||
})
|
||||
|
||||
it("can delete an existing view", async () => {
|
||||
|
@ -448,4 +486,43 @@ describe.each([
|
|||
expect(viewSchema.Price?.visible).toEqual(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("read", () => {
|
||||
it("views have extra data trimmed", async () => {
|
||||
const table = await config.api.table.save(
|
||||
saveTableRequest({
|
||||
name: "orders",
|
||||
schema: {
|
||||
Country: {
|
||||
type: FieldType.STRING,
|
||||
name: "Country",
|
||||
},
|
||||
Story: {
|
||||
type: FieldType.STRING,
|
||||
name: "Story",
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const view = await config.api.viewV2.create({
|
||||
tableId: table._id!,
|
||||
name: uuid.v4(),
|
||||
schema: {
|
||||
Country: {
|
||||
visible: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let row = await config.api.row.save(view.id, {
|
||||
Country: "Aussy",
|
||||
Story: "aaaaa",
|
||||
})
|
||||
|
||||
row = await config.api.row.get(table._id!, row._id!)
|
||||
expect(row.Story).toBeUndefined()
|
||||
expect(row.Country).toEqual("Aussy")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -24,7 +24,7 @@ import tar from "tar"
|
|||
|
||||
type TemplateType = {
|
||||
file?: {
|
||||
type: string
|
||||
type?: string
|
||||
path: string
|
||||
password?: string
|
||||
}
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
import * as search from "../../app/rows/search"
|
||||
|
||||
describe("removeEmptyFilters", () => {
|
||||
it("0 should not be removed", () => {
|
||||
const filters = search.removeEmptyFilters({
|
||||
equal: {
|
||||
column: 0,
|
||||
},
|
||||
})
|
||||
expect((filters.equal as any).column).toBe(0)
|
||||
})
|
||||
|
||||
it("empty string should be removed", () => {
|
||||
const filters = search.removeEmptyFilters({
|
||||
equal: {
|
||||
column: "",
|
||||
},
|
||||
})
|
||||
expect(Object.values(filters.equal as any).length).toBe(0)
|
||||
})
|
||||
})
|
|
@ -580,7 +580,7 @@ export default class TestConfiguration {
|
|||
}
|
||||
|
||||
// APP
|
||||
async createApp(appName: string): Promise<App> {
|
||||
async createApp(appName: string, url?: string): Promise<App> {
|
||||
// create dev app
|
||||
// clear any old app
|
||||
this.appId = undefined
|
||||
|
@ -589,6 +589,7 @@ export default class TestConfiguration {
|
|||
async () =>
|
||||
(await this._req(appController.create, {
|
||||
name: appName,
|
||||
url,
|
||||
})) as App
|
||||
)
|
||||
this.appId = this.app.appId
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
type CreateAppRequest,
|
||||
type FetchAppDefinitionResponse,
|
||||
type FetchAppPackageResponse,
|
||||
DuplicateAppResponse,
|
||||
} from "@budibase/types"
|
||||
import { Expectations, TestAPI } from "./base"
|
||||
import { AppStatus } from "../../../db/utils"
|
||||
|
@ -70,6 +71,22 @@ export class ApplicationAPI extends TestAPI {
|
|||
})
|
||||
}
|
||||
|
||||
duplicateApp = async (
|
||||
appId: string,
|
||||
fields: object,
|
||||
expectations?: Expectations
|
||||
): Promise<DuplicateAppResponse> => {
|
||||
let headers = {
|
||||
...this.config.defaultHeaders(),
|
||||
[constants.Header.APP_ID]: appId,
|
||||
}
|
||||
return this._post(`/api/applications/${appId}/duplicate`, {
|
||||
headers,
|
||||
fields,
|
||||
expectations,
|
||||
})
|
||||
}
|
||||
|
||||
getDefinition = async (
|
||||
appId: string,
|
||||
expectations?: Expectations
|
||||
|
|
|
@ -11,21 +11,9 @@ import sdk from "../../../sdk"
|
|||
|
||||
export class ViewV2API extends TestAPI {
|
||||
create = async (
|
||||
viewData?: Partial<CreateViewRequest>,
|
||||
view: CreateViewRequest,
|
||||
expectations?: Expectations
|
||||
): Promise<ViewV2> => {
|
||||
let tableId = viewData?.tableId
|
||||
if (!tableId && !this.config.table) {
|
||||
throw "Test requires table to be configured."
|
||||
}
|
||||
|
||||
tableId = tableId || this.config.table!._id!
|
||||
const view = {
|
||||
tableId,
|
||||
name: generator.guid(),
|
||||
...viewData,
|
||||
}
|
||||
|
||||
const exp: Expectations = {
|
||||
status: 201,
|
||||
...expectations,
|
||||
|
|
|
@ -11,6 +11,17 @@ export interface CreateAppRequest {
|
|||
includeSampleData?: boolean
|
||||
encryptionPassword?: string
|
||||
templateString?: string
|
||||
file?: { path: string }
|
||||
}
|
||||
|
||||
export interface DuplicateAppRequest {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface DuplicateAppResponse {
|
||||
duplicateAppId: string
|
||||
sourceAppId: string
|
||||
}
|
||||
|
||||
export interface FetchAppDefinitionResponse {
|
||||
|
|
|
@ -18,6 +18,7 @@ export interface UpdateSelfRequest {
|
|||
password?: string
|
||||
forceResetPassword?: boolean
|
||||
onboardedAt?: string
|
||||
appFavourites?: string[]
|
||||
tours?: Record<string, Date>
|
||||
}
|
||||
|
||||
|
|
|
@ -57,6 +57,7 @@ export interface User extends Document {
|
|||
onboardedAt?: string
|
||||
tours?: Record<string, Date>
|
||||
scimInfo?: { isSync: true } & Record<string, any>
|
||||
appFavourites?: string[]
|
||||
ssoId?: string
|
||||
}
|
||||
|
||||
|
|
|
@ -44,6 +44,14 @@ export interface AppFileImportedEvent extends BaseEvent {
|
|||
}
|
||||
}
|
||||
|
||||
export interface AppDuplicatedEvent extends BaseEvent {
|
||||
duplicateAppId: string
|
||||
appId: string
|
||||
audited: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppTemplateImportedEvent extends BaseEvent {
|
||||
appId: string
|
||||
templateKey: string
|
||||
|
|
|
@ -60,6 +60,7 @@ export enum Event {
|
|||
APP_CREATED = "app:created",
|
||||
APP_UPDATED = "app:updated",
|
||||
APP_DELETED = "app:deleted",
|
||||
APP_DUPLICATED = "app:duplicated",
|
||||
APP_PUBLISHED = "app:published",
|
||||
APP_UNPUBLISHED = "app:unpublished",
|
||||
APP_TEMPLATE_IMPORTED = "app:template:imported",
|
||||
|
@ -259,6 +260,7 @@ export const AuditedEventFriendlyName: Record<Event, string | undefined> = {
|
|||
[Event.APP_CREATED]: `App "{{ name }}" created`,
|
||||
[Event.APP_UPDATED]: `App "{{ name }}" updated`,
|
||||
[Event.APP_DELETED]: `App "{{ name }}" deleted`,
|
||||
[Event.APP_DUPLICATED]: `App "{{ name }}" duplicated`,
|
||||
[Event.APP_PUBLISHED]: `App "{{ name }}" published`,
|
||||
[Event.APP_UNPUBLISHED]: `App "{{ name }}" unpublished`,
|
||||
[Event.APP_TEMPLATE_IMPORTED]: `App "{{ name }}" template imported`,
|
||||
|
|
|
@ -9,7 +9,12 @@ import {
|
|||
} from "@budibase/backend-core"
|
||||
import env from "../../../environment"
|
||||
import { groups } from "@budibase/pro"
|
||||
import { UpdateSelfRequest, UpdateSelfResponse, UserCtx } from "@budibase/types"
|
||||
import {
|
||||
UpdateSelfRequest,
|
||||
UpdateSelfResponse,
|
||||
User,
|
||||
UserCtx,
|
||||
} from "@budibase/types"
|
||||
|
||||
const { newid } = utils
|
||||
|
||||
|
@ -105,16 +110,63 @@ export async function getSelf(ctx: any) {
|
|||
addSessionAttributesToUser(ctx)
|
||||
}
|
||||
|
||||
export const syncAppFavourites = async (processedAppIds: string[]) => {
|
||||
if (processedAppIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const apps = await fetchAppsByIds(processedAppIds)
|
||||
return apps?.reduce((acc: string[], app) => {
|
||||
const id = app.appId.replace(dbCore.APP_DEV_PREFIX, "")
|
||||
if (processedAppIds.includes(id)) {
|
||||
acc.push(id)
|
||||
}
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
|
||||
export const fetchAppsByIds = async (processedAppIds: string[]) => {
|
||||
return await dbCore.getAppsByIDs(
|
||||
processedAppIds.map(appId => `${dbCore.APP_DEV_PREFIX}${appId}`)
|
||||
)
|
||||
}
|
||||
|
||||
const processUserAppFavourites = async (
|
||||
user: User,
|
||||
update: UpdateSelfRequest
|
||||
) => {
|
||||
if (!("appFavourites" in update)) {
|
||||
// Ignore requests without an explicit update to favourites.
|
||||
return
|
||||
}
|
||||
|
||||
const userAppFavourites = user.appFavourites || []
|
||||
const requestAppFavourites = new Set(update.appFavourites || [])
|
||||
const containsAll = userAppFavourites.every(v => requestAppFavourites.has(v))
|
||||
|
||||
if (containsAll && requestAppFavourites.size === userAppFavourites.length) {
|
||||
// Ignore request if the outcome will have no change
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up the request by purging apps that no longer exist.
|
||||
const syncedAppFavourites = await syncAppFavourites([...requestAppFavourites])
|
||||
return syncedAppFavourites
|
||||
}
|
||||
|
||||
export async function updateSelf(
|
||||
ctx: UserCtx<UpdateSelfRequest, UpdateSelfResponse>
|
||||
) {
|
||||
const update = ctx.request.body
|
||||
|
||||
let user = await userSdk.db.getUser(ctx.user._id!)
|
||||
const updatedAppFavourites = await processUserAppFavourites(user, update)
|
||||
|
||||
user = {
|
||||
...user,
|
||||
...update,
|
||||
...(updatedAppFavourites ? { appFavourites: updatedAppFavourites } : {}),
|
||||
}
|
||||
|
||||
user = await userSdk.db.save(user, { requirePassword: false })
|
||||
|
||||
if (update.password) {
|
||||
|
|
|
@ -26,6 +26,7 @@ export const buildSelfSaveValidation = () => {
|
|||
firstName: OPTIONAL_STRING,
|
||||
lastName: OPTIONAL_STRING,
|
||||
onboardedAt: Joi.string().optional(),
|
||||
appFavourites: Joi.array().optional(),
|
||||
tours: Joi.object().optional(),
|
||||
}
|
||||
return auth.joiValidator.body(Joi.object(schema).required().unknown(false))
|
||||
|
|
Loading…
Reference in New Issue