Unpublish refactored to stop development applications being mistakenly deleted. Minor updates to the modal content component to allow the replacement of the header. Further work to implement the publishing workflow changes

This commit is contained in:
Dean 2022-04-19 14:38:09 +01:00
parent 31d2ae8c6b
commit 3e74118f81
14 changed files with 457 additions and 59 deletions

View File

@ -174,9 +174,11 @@ function getDB(key, opts) {
if (db && isEqual(opts, storedOpts)) { if (db && isEqual(opts, storedOpts)) {
return db return db
} }
const appId = exports.getAppId() const appId = exports.getAppId()
const CouchDB = getCouch() const CouchDB = getCouch()
let toUseAppId let toUseAppId
switch (key) { switch (key) {
case ContextKeys.CURRENT_DB: case ContextKeys.CURRENT_DB:
toUseAppId = appId toUseAppId = appId

View File

@ -72,12 +72,20 @@
class:header-spacing={$$slots.header} class:header-spacing={$$slots.header}
> >
{title} {title}
</h1>
{:else if $$slots.header}
<h1
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
class:noDivider={!showDivider}
class:header-spacing={$$slots.header}
>
<slot name="header" /> <slot name="header" />
</h1> </h1>
{#if showDivider} {/if}
{#if showDivider && (title || $$slots.header)}
<Divider size="M" /> <Divider size="M" />
{/if} {/if}
{/if}
<!-- TODO: Remove content-grid class once Layout components are in bbui --> <!-- TODO: Remove content-grid class once Layout components are in bbui -->
<section class="spectrum-Dialog-content content-grid"> <section class="spectrum-Dialog-content content-grid">
<slot /> <slot />

View File

@ -11,6 +11,9 @@
export let align = "right" export let align = "right"
export let portalTarget export let portalTarget
let clazz
export { clazz as class }
export const show = () => { export const show = () => {
dispatch("open") dispatch("open")
open = true open = true
@ -37,7 +40,7 @@
use:positionDropdown={{ anchor, align }} use:positionDropdown={{ anchor, align }}
use:clickOutside={hide} use:clickOutside={hide}
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class={"spectrum-Popover is-open " + clazz}
role="presentation" role="presentation"
> >
<slot /> <slot />

View File

@ -0,0 +1,81 @@
<script>
import { setContext } from "svelte"
import Popover from "../Popover/Popover.svelte"
export let disabled = false
export let align = "left"
export let anchor
export let showTip = true
export let direction = "bottom"
let dropdown
let tipSvg =
'<svg xmlns="http://www.w3.org/svg/2000" width="23" height="12" class="spectrum-Popover-tip" > <path class="spectrum-Popover-tip-triangle" d="M 0.7071067811865476 0 L 11.414213562373096 10.707106781186548 L 22.121320343559645 0" /> </svg>'
// This is needed because display: contents is considered "invisible".
// It should only ever be an action button, so should be fine.
function getAnchor(node) {
if (!anchor) {
anchor = node.firstChild
}
}
//need this for the publish/view behaviours
export const hide = () => {
dropdown.hide()
}
export const show = () => {
dropdown.show()
}
const openMenu = event => {
if (!disabled) {
event.stopPropagation()
show()
}
}
setContext("popoverMenu", { show, hide })
</script>
<div class="popover-menu">
<div use:getAnchor on:click={openMenu}>
<slot name="control" />
</div>
<Popover
bind:this={dropdown}
{anchor}
{align}
class={showTip
? `spectrum-Popover--withTip spectrum-Popover--${direction}`
: ""}
>
{#if showTip}
{@html tipSvg}
{/if}
<div class="popover-container">
<div class="popover-menu-wrap">
<slot />
</div>
</div>
</Popover>
</div>
<style>
:global(.spectrum-Popover.is-open.spectrum-Popover--withTip) {
margin-top: var(--spacing-xs);
margin-left: var(--spacing-xl);
}
.popover-menu-wrap {
padding: 10px;
}
.popover-menu :global(.icon) {
display: flex;
}
:global(.spectrum-Popover--bottom .spectrum-Popover-tip) {
left: 90%;
margin-left: calc(var(--spectrum-global-dimension-size-150) * -1);
}
</style>

View File

@ -25,6 +25,7 @@ export { default as RadioGroup } from "./Form/RadioGroup.svelte"
export { default as Checkbox } from "./Form/Checkbox.svelte" export { default as Checkbox } from "./Form/Checkbox.svelte"
export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte" export { default as DetailSummary } from "./DetailSummary/DetailSummary.svelte"
export { default as Popover } from "./Popover/Popover.svelte" export { default as Popover } from "./Popover/Popover.svelte"
export { default as PopoverMenu } from "./Popover/PopoverMenu.svelte"
export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte" export { default as ProgressBar } from "./ProgressBar/ProgressBar.svelte"
export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte" export { default as ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte"
export { default as Label } from "./Label/Label.svelte" export { default as Label } from "./Label/Label.svelte"

View File

@ -10,11 +10,11 @@
<ModalContent <ModalContent
showCloseIcon={false} showCloseIcon={false}
showConfirmButton={false} showConfirmButton={false}
title="Test Automation"
cancelText="Close" cancelText="Close"
> >
<div slot="header"> <div slot="header" class="result-modal-header">
<div style="float: right;"> <span>Test Automation</span>
<div>
{#if isTrigger || testResult[0].outputs.success} {#if isTrigger || testResult[0].outputs.success}
<div class="iconSuccess"> <div class="iconSuccess">
<Icon size="S" name="CheckmarkCircle" /> <Icon size="S" name="CheckmarkCircle" />
@ -89,6 +89,14 @@
</ModalContent> </ModalContent>
<style> <style>
.result-modal-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
.iconSuccess { .iconSuccess {
color: var(--spectrum-global-color-green-600); color: var(--spectrum-global-color-green-600);
} }

View File

@ -1,24 +1,57 @@
<script> <script>
import { Button, Modal, notifications, ModalContent } from "@budibase/bbui" import {
Button,
Modal,
notifications,
ModalContent,
Layout,
} from "@budibase/bbui"
import { API } from "api" import { API } from "api"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { store } from "builderStore" import { store } from "builderStore"
import { ProgressCircle } from "@budibase/bbui"
import CopyInput from "components/common/inputs/CopyInput.svelte"
let feedbackModal let feedbackModal
let publishModal let publishModal
let asyncModal
let publishCompleteModal
let published
$: publishedUrl = published ? `${window.origin}/app${published.appUrl}` : ""
export let onOk
async function deployApp() { async function deployApp() {
try { try {
await API.deployAppChanges() published = await API.deployAppChanges()
//In Progress
asyncModal.show()
publishModal.hide()
analytics.captureEvent(Events.APP.PUBLISHED, { analytics.captureEvent(Events.APP.PUBLISHED, {
appId: $store.appId, appId: $store.appId,
}) })
notifications.success("Application published successfully") if (typeof onOk === "function") {
await onOk()
}
//Request completed
asyncModal.hide()
publishCompleteModal.show()
} catch (error) { } catch (error) {
analytics.captureException(error) analytics.captureException(error)
notifications.error("Error publishing app") notifications.error("Error publishing app")
} }
} }
const viewApp = () => {
if (published) {
window.open(publishedUrl, "_blank")
}
}
</script> </script>
<Button secondary on:click={publishModal.show}>Publish</Button> <Button secondary on:click={publishModal.show}>Publish</Button>
@ -30,6 +63,7 @@
showCancelButton={false} showCancelButton={false}
/> />
</Modal> </Modal>
<Modal bind:this={publishModal}> <Modal bind:this={publishModal}>
<ModalContent <ModalContent
title="Publish to Production" title="Publish to Production"
@ -42,3 +76,50 @@
> >
</ModalContent> </ModalContent>
</Modal> </Modal>
<!-- Publish in progress -->
<Modal bind:this={asyncModal}>
<ModalContent
showCancelButton={false}
showConfirmButton={false}
showCloseIcon={false}
>
<Layout justifyItems="center">
<ProgressCircle size="XL" />
</Layout>
</ModalContent>
</Modal>
<!-- Publish complete -->
<span class="publish-modal-wrap">
<Modal bind:this={publishCompleteModal}>
<ModalContent confirmText="Done" cancelText="View App" onCancel={viewApp}>
<div slot="header" class="app-published-header">
<svg
width="26px"
height="26px"
class="spectrum-Icon success-icon"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-GlobeCheck" />
</svg>
<span class="app-published-header-text">App Published!</span>
</div>
<CopyInput value={publishedUrl} label="You can view your app at:" />
</ModalContent>
</Modal>
</span>
<style>
.app-published-header {
display: flex;
flex-direction: row;
align-items: center;
}
.success-icon {
color: var(--spectrum-global-color-green-600);
}
.app-published-header .app-published-header-text {
padding-left: var(--spacing-l);
}
</style>

View File

@ -7,12 +7,10 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte" import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store } from "builderStore" import { store } from "builderStore"
import {
const DeploymentStatus = { checkIncomingDeploymentStatus,
SUCCESS: "SUCCESS", DeploymentStatus,
PENDING: "PENDING", } from "components/deploy/utils"
FAILURE: "FAILURE",
}
const DATE_OPTIONS = { const DATE_OPTIONS = {
fullDate: { fullDate: {
@ -42,30 +40,17 @@
const formatDate = (date, format) => const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date) Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)
// Required to check any updated deployment statuses between polls
function checkIncomingDeploymentStatus(current, incoming) {
for (let incomingDeployment of incoming) {
if (incomingDeployment.status === DeploymentStatus.FAILURE) {
const currentDeployment = current.find(
deployment => deployment._id === incomingDeployment._id
)
// We have just been notified of an ongoing deployments failure
if (
!currentDeployment ||
currentDeployment.status === DeploymentStatus.PENDING
) {
showErrorReasonModal(incomingDeployment.err)
}
}
}
}
async function fetchDeployments() { async function fetchDeployments() {
try { try {
const newDeployments = await API.getAppDeployments() const newDeployments = await API.getAppDeployments()
if (deployments.length > 0) { if (deployments.length > 0) {
checkIncomingDeploymentStatus(deployments, newDeployments) const pendingDeployments = checkIncomingDeploymentStatus(
deployments,
newDeployments
)
if (pendingDeployments.length) {
showErrorReasonModal(incomingDeployment.err)
}
} }
deployments = newDeployments deployments = newDeployments
} catch (err) { } catch (err) {

View File

@ -0,0 +1,25 @@
export const DeploymentStatus = {
SUCCESS: "SUCCESS",
PENDING: "PENDING",
FAILURE: "FAILURE",
}
// Required to check any updated deployment statuses between polls
export function checkIncomingDeploymentStatus(current, incoming) {
return incoming.reduce((acc, incomingDeployment) => {
if (incomingDeployment.status === DeploymentStatus.FAILURE) {
const currentDeployment = current.find(
deployment => deployment._id === incomingDeployment._id
)
//We have just been notified of an ongoing deployments failure
if (
!currentDeployment ||
currentDeployment.status === DeploymentStatus.PENDING
) {
acc.push(incomingDeployment)
}
}
return acc;
}, [])
}

View File

@ -5,6 +5,7 @@
Icon, Icon,
ActionMenu, ActionMenu,
MenuItem, MenuItem,
ButtonGroup,
StatusLight, StatusLight,
} from "@budibase/bbui" } from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
@ -15,6 +16,7 @@
export let editApp export let editApp
export let updateApp export let updateApp
export let deleteApp export let deleteApp
export let previewApp
export let unpublishApp export let unpublishApp
export let releaseLock export let releaseLock
export let editIcon export let editIcon
@ -57,19 +59,36 @@
</StatusLight> </StatusLight>
</div> </div>
<div class="desktop"> <div class="desktop">
<StatusLight active={app.deployed} neutral={!app.deployed}> <div class="app-status">
{#if app.deployed}Published{:else}Unpublished{/if} {#if app.deployed}
</StatusLight> <Icon name="Globe" disabled={false} />
Published
{:else}
<Icon name="GlobeStrike" disabled={true} />
<span class="disabled"> Unpublished </span>
{/if}
</div>
</div> </div>
<div data-cy={`row_actions_${app.appId}`}> <div data-cy={`row_actions_${app.appId}`}>
<div class="app-actions">
{#if app.deployed}
<Button size="S" secondary quiet on:click={() => viewApp(app)}
>View app
</Button>
{:else}
<Button size="S" secondary quiet on:click={() => previewApp(app)}
>Preview
</Button>
{/if}
<Button <Button
size="S" size="S"
cta
disabled={app.lockedOther} disabled={app.lockedOther}
on:click={() => editApp(app)} on:click={() => editApp(app)}
secondary
> >
Open Edit
</Button> </Button>
</div>
<ActionMenu align="right"> <ActionMenu align="right">
<Icon hoverable slot="control" name="More" /> <Icon hoverable slot="control" name="More" />
{#if app.deployed} {#if app.deployed}
@ -97,6 +116,18 @@
</div> </div>
<style> <style>
.app-actions {
grid-gap: var(--spacing-s);
display: grid;
grid-template-columns: 75px 75px;
}
.app-status {
display: grid;
grid-template-columns: 24px 100px;
}
.app-status span.disabled {
opacity: 0.3;
}
.name { .name {
text-decoration: none; text-decoration: none;
overflow: hidden; overflow: hidden;

View File

@ -1,22 +1,69 @@
<script> <script>
import { store, automationStore } from "builderStore" import { store, automationStore, allScreens } from "builderStore"
import { roles, flags } from "stores/backend" import { roles, flags } from "stores/backend"
import { Icon, ActionGroup, Tabs, Tab, notifications } from "@budibase/bbui" import {
Icon,
ActionGroup,
Tabs,
Tab,
notifications,
PopoverMenu,
Layout,
Button,
Heading,
} from "@budibase/bbui"
import DeployModal from "components/deploy/DeployModal.svelte" import DeployModal from "components/deploy/DeployModal.svelte"
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { API } from "api" import { API } from "api"
import { auth, admin } from "stores/portal" import { auth, admin, apps } from "stores/portal"
import { isActive, goto, layout, redirect } from "@roxi/routify" import { isActive, goto, layout, redirect } from "@roxi/routify"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import UpgradeModal from "components/upgrade/UpgradeModal.svelte" import UpgradeModal from "components/upgrade/UpgradeModal.svelte"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { processStringSync } from "@budibase/string-templates"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
export let application export let application
// Get Package and set store // Get Package and set store
let promise = getPackage() let promise = getPackage()
let unpublishModal
let publishPopover
let notPublishedPopover
$: enrichedApps = enrichApps($apps, $auth.user)
const enrichApps = (apps, user) => {
const enrichedApps = apps
.map(app => ({
...app,
deployed: app.status === "published",
lockedYou: app.lockedBy && app.lockedBy.email === user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== user?.email,
}))
.filter(app => {
return app.devId === application
})
return enrichedApps
}
$: selectedApp = enrichedApps.length > 0 ? enrichedApps[0] : {}
$: deployments = []
$: latestDeployments = deployments
.filter(deployment => deployment.status === "SUCCESS")
.sort((a, b) => a.updatedAt > b.updatedAt)
$: console.log("Deployments ", deployments)
$: console.log("Latest Deployments ", latestDeployments)
$: isPublished =
selectedApp.deployed && latestDeployments && latestDeployments?.length
? true
: false
// Sync once when you load the app // Sync once when you load the app
let hasSynced = false let hasSynced = false
@ -24,10 +71,18 @@
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data" $layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
) )
function previewApp() { const previewApp = () => {
window.open(`/${application}`) window.open(`/${application}`)
} }
const viewApp = () => {
if (selectedApp.url) {
window.open(`/app${selectedApp.url}`)
} else {
window.open(`/${selectedApp.prodId}`)
}
}
async function getPackage() { async function getPackage() {
try { try {
const pkg = await API.fetchAppPackage(application) const pkg = await API.fetchAppPackage(application)
@ -58,20 +113,71 @@
}) })
} }
const reviewPendingDeployments = (deployments, newDeployments) => {
if (deployments.length > 0) {
const pending = checkIncomingDeploymentStatus(deployments, newDeployments)
if (pending.length) {
notifications.warning(
"Deployment has been queued and will be processed shortly"
)
}
}
}
async function fetchDeployments() {
try {
const newDeployments = await API.getAppDeployments()
reviewPendingDeployments(deployments, newDeployments)
return newDeployments
} catch (err) {
notifications.error("Error fetching deployment history")
}
}
onMount(async () => { onMount(async () => {
if (!hasSynced && application) { if (!hasSynced && application) {
try { try {
await API.syncApp(application) await API.syncApp(application)
await apps.load()
} catch (error) { } catch (error) {
notifications.error("Failed to sync with production database") notifications.error("Failed to sync with production database")
} }
hasSynced = true hasSynced = true
} }
deployments = await fetchDeployments()
}) })
onDestroy(() => { onDestroy(() => {
store.actions.reset() store.actions.reset()
}) })
const unpublishApp = () => {
publishPopover.hide()
unpublishModal.show()
}
const completePublish = async () => {
try {
await apps.load()
deployments = await fetchDeployments()
} catch (err) {
notifications.error("Error refreshing app")
}
}
const confirmUnpublishApp = async () => {
if (!application || !isPublished) {
//confirm the app has loaded.
return
}
try {
await API.unpublishApp(selectedApp.prodId)
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error("Error unpublishing app")
}
}
</script> </script>
{#await promise} {#await promise}
@ -112,23 +218,76 @@
<VersionModal /> <VersionModal />
<RevertModal /> <RevertModal />
<Icon name="Play" hoverable on:click={previewApp} /> <Icon name="Play" hoverable on:click={previewApp} />
<DeployModal />
<PopoverMenu
bind:this={publishPopover}
align="right"
disabled={!isPublished}
>
<div slot="control" class="icon">
<Icon
size="M"
hoverable
name={isPublished ? "Globe" : "GlobeStrike"}
disabled={!isPublished}
/>
</div>
<Layout gap="M">
<Heading size="XS">Your app is live!</Heading>
<div class="publish-popover-message">
{#if isPublished}
{processStringSync(
"Last Published: {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
{/if}
</div>
<div class="publish-popover-actions">
<Button
warning={true}
icon="Globe"
disabled={!isPublished}
on:click={unpublishApp}
dataCy="publish-popover-action"
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View App</Button>
</div>
</Layout>
</PopoverMenu>
<DeployModal onOk={completePublish} />
</div> </div>
</div> </div>
<slot /> <slot />
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
</div> </div>
{:catch error} {:catch error}
<p>Something went wrong: {error.message}</p> <p>Something went wrong: {error.message}</p>
{/await} {/await}
<style> <style>
.publish-popover-actions :global([data-cy="publish-popover-action"]) {
margin-right: var(--spacing-s);
}
.loading { .loading {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
width: 100%; width: 100%;
background: var(--background); background: var(--background);
} }
.root { .root {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;

View File

@ -174,6 +174,10 @@
} }
} }
const previewApp = app => {
window.open(`/${app.devId}`)
}
const editApp = app => { const editApp = app => {
if (app.lockedOther) { if (app.lockedOther) {
notifications.error( notifications.error(
@ -392,6 +396,7 @@
{exportApp} {exportApp}
{deleteApp} {deleteApp}
{updateApp} {updateApp}
{previewApp}
/> />
{/each} {/each}
</div> </div>

View File

@ -350,19 +350,25 @@ exports.revertClient = async ctx => {
} }
exports.delete = async ctx => { exports.delete = async ctx => {
const db = getAppDB() let appId = ctx.params.appId
if (ctx.query.unpublish) {
appId = getProdAppID(appId)
}
const db = ctx.query.unpublish ? getProdAppDB() : getAppDB();
const result = await db.destroy() const result = await db.destroy()
/* istanbul ignore next */ /* istanbul ignore next */
if (!env.isTest() && !ctx.query.unpublish) { if (!env.isTest() && !ctx.query.unpublish) {
await deleteApp(ctx.params.appId) await deleteApp(appId)
} }
if (ctx.query && ctx.query.unpublish) { if (ctx.query && ctx.query.unpublish) {
await cleanupAutomations(ctx.params.appId) await cleanupAutomations(appId)
} }
// make sure the app/role doesn't stick around after the app has been deleted // make sure the app/role doesn't stick around after the app has been deleted
await removeAppFromUserRoles(ctx, ctx.params.appId) await removeAppFromUserRoles(ctx, appId)
await appCache.invalidateAppMetadata(ctx.params.appId) await appCache.invalidateAppMetadata(appId)
ctx.status = 200 ctx.status = 200
ctx.body = result ctx.body = result

View File

@ -110,6 +110,9 @@ async function deployApp(deployment) {
console.log("replication complete.. replacing app meta doc") console.log("replication complete.. replacing app meta doc")
const db = getProdAppDB() const db = getProdAppDB()
const appDoc = await db.get(DocumentTypes.APP_METADATA) const appDoc = await db.get(DocumentTypes.APP_METADATA)
deployment.appUrl = appDoc.url
appDoc.appId = productionAppId appDoc.appId = productionAppId
appDoc.instance._id = productionAppId appDoc.instance._id = productionAppId
await db.put(appDoc) await db.put(appDoc)