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 c53c350879
commit 26c19891bb
14 changed files with 457 additions and 59 deletions

View File

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

View File

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

View File

@ -11,6 +11,9 @@
export let align = "right"
export let portalTarget
let clazz
export { clazz as class }
export const show = () => {
dispatch("open")
open = true
@ -37,7 +40,7 @@
use:positionDropdown={{ anchor, align }}
use:clickOutside={hide}
on:keydown={handleEscape}
class="spectrum-Popover is-open"
class={"spectrum-Popover is-open " + clazz}
role="presentation"
>
<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 DetailSummary } from "./DetailSummary/DetailSummary.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 ProgressCircle } from "./ProgressCircle/ProgressCircle.svelte"
export { default as Label } from "./Label/Label.svelte"

View File

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

View File

@ -1,24 +1,57 @@
<script>
import { Button, Modal, notifications, ModalContent } from "@budibase/bbui"
import {
Button,
Modal,
notifications,
ModalContent,
Layout,
} from "@budibase/bbui"
import { API } from "api"
import analytics, { Events } from "analytics"
import { store } from "builderStore"
import { ProgressCircle } from "@budibase/bbui"
import CopyInput from "components/common/inputs/CopyInput.svelte"
let feedbackModal
let publishModal
let asyncModal
let publishCompleteModal
let published
$: publishedUrl = published ? `${window.origin}/app${published.appUrl}` : ""
export let onOk
async function deployApp() {
try {
await API.deployAppChanges()
published = await API.deployAppChanges()
//In Progress
asyncModal.show()
publishModal.hide()
analytics.captureEvent(Events.APP.PUBLISHED, {
appId: $store.appId,
})
notifications.success("Application published successfully")
if (typeof onOk === "function") {
await onOk()
}
//Request completed
asyncModal.hide()
publishCompleteModal.show()
} catch (error) {
analytics.captureException(error)
notifications.error("Error publishing app")
}
}
const viewApp = () => {
if (published) {
window.open(publishedUrl, "_blank")
}
}
</script>
<Button secondary on:click={publishModal.show}>Publish</Button>
@ -30,6 +63,7 @@
showCancelButton={false}
/>
</Modal>
<Modal bind:this={publishModal}>
<ModalContent
title="Publish to Production"
@ -42,3 +76,50 @@
>
</ModalContent>
</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 CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store } from "builderStore"
const DeploymentStatus = {
SUCCESS: "SUCCESS",
PENDING: "PENDING",
FAILURE: "FAILURE",
}
import {
checkIncomingDeploymentStatus,
DeploymentStatus,
} from "components/deploy/utils"
const DATE_OPTIONS = {
fullDate: {
@ -42,30 +40,17 @@
const formatDate = (date, format) =>
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() {
try {
const newDeployments = await API.getAppDeployments()
if (deployments.length > 0) {
checkIncomingDeploymentStatus(deployments, newDeployments)
const pendingDeployments = checkIncomingDeploymentStatus(
deployments,
newDeployments
)
if (pendingDeployments.length) {
showErrorReasonModal(incomingDeployment.err)
}
}
deployments = newDeployments
} 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,
ActionMenu,
MenuItem,
ButtonGroup,
StatusLight,
} from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates"
@ -15,6 +16,7 @@
export let editApp
export let updateApp
export let deleteApp
export let previewApp
export let unpublishApp
export let releaseLock
export let editIcon
@ -57,19 +59,36 @@
</StatusLight>
</div>
<div class="desktop">
<StatusLight active={app.deployed} neutral={!app.deployed}>
{#if app.deployed}Published{:else}Unpublished{/if}
</StatusLight>
<div class="app-status">
{#if app.deployed}
<Icon name="Globe" disabled={false} />
Published
{:else}
<Icon name="GlobeStrike" disabled={true} />
<span class="disabled"> Unpublished </span>
{/if}
</div>
</div>
<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
size="S"
cta
disabled={app.lockedOther}
on:click={() => editApp(app)}
secondary
>
Open
Edit
</Button>
</div>
<ActionMenu align="right">
<Icon hoverable slot="control" name="More" />
{#if app.deployed}
@ -97,6 +116,18 @@
</div>
<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 {
text-decoration: none;
overflow: hidden;

View File

@ -1,22 +1,69 @@
<script>
import { store, automationStore } from "builderStore"
import { store, automationStore, allScreens } from "builderStore"
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 RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
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 Logo from "assets/bb-emblem.svg"
import { capitalise } from "helpers"
import UpgradeModal from "components/upgrade/UpgradeModal.svelte"
import { onMount, onDestroy } from "svelte"
import { processStringSync } from "@budibase/string-templates"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
export let application
// Get Package and set store
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
let hasSynced = false
@ -24,10 +71,18 @@
$layout.children.find(layout => $isActive(layout.path))?.title ?? "data"
)
function previewApp() {
const previewApp = () => {
window.open(`/${application}`)
}
const viewApp = () => {
if (selectedApp.url) {
window.open(`/app${selectedApp.url}`)
} else {
window.open(`/${selectedApp.prodId}`)
}
}
async function getPackage() {
try {
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 () => {
if (!hasSynced && application) {
try {
await API.syncApp(application)
await apps.load()
} catch (error) {
notifications.error("Failed to sync with production database")
}
hasSynced = true
}
deployments = await fetchDeployments()
})
onDestroy(() => {
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>
{#await promise}
@ -112,23 +218,76 @@
<VersionModal />
<RevertModal />
<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>
<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>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
<style>
.publish-popover-actions :global([data-cy="publish-popover-action"]) {
margin-right: var(--spacing-s);
}
.loading {
min-height: 100%;
height: 100%;
width: 100%;
background: var(--background);
}
.root {
min-height: 100%;
height: 100%;

View File

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

View File

@ -350,19 +350,25 @@ exports.revertClient = 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()
/* istanbul ignore next */
if (!env.isTest() && !ctx.query.unpublish) {
await deleteApp(ctx.params.appId)
await deleteApp(appId)
}
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
await removeAppFromUserRoles(ctx, ctx.params.appId)
await appCache.invalidateAppMetadata(ctx.params.appId)
await removeAppFromUserRoles(ctx, appId)
await appCache.invalidateAppMetadata(appId)
ctx.status = 200
ctx.body = result

View File

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