Merge pull request #5489 from Budibase/feature/new-app-publish-workflow

Feature/new app publish workflow
This commit is contained in:
deanhannigan 2022-04-26 16:22:37 +01:00 committed by GitHub
commit 89fa229140
27 changed files with 667 additions and 118 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

@ -6,6 +6,7 @@
export let disabled = false export let disabled = false
export let align = "left" export let align = "left"
export let portalTarget export let portalTarget
export let dataCy
let anchor let anchor
let dropdown let dropdown
@ -36,7 +37,7 @@
<div use:getAnchor on:click={openMenu}> <div use:getAnchor on:click={openMenu}>
<slot name="control" /> <slot name="control" />
</div> </div>
<Popover bind:this={dropdown} {anchor} {align} {portalTarget}> <Popover bind:this={dropdown} {anchor} {align} {portalTarget} {dataCy}>
<Menu> <Menu>
<slot /> <slot />
</Menu> </Menu>

View File

@ -3,6 +3,9 @@
</script> </script>
<script> <script>
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
export let direction = "n" export let direction = "n"
export let name = "Add" export let name = "Add"
export let hidden = false export let hidden = false
@ -10,15 +13,25 @@
export let hoverable = false export let hoverable = false
export let disabled = false export let disabled = false
export let color export let color
export let tooltip
$: rotation = getRotation(direction) $: rotation = getRotation(direction)
let showTooltip = false
const getRotation = direction => { const getRotation = direction => {
return directions.indexOf(direction) * 45 return directions.indexOf(direction) * 45
} }
</script> </script>
<svg <div
class="icon"
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:click={() => (showTooltip = false)}
>
<svg
on:click on:click
class:hoverable class:hoverable
class:disabled class:disabled
@ -29,11 +42,23 @@
style={`transform: rotate(${rotation}deg); ${ style={`transform: rotate(${rotation}deg); ${
color ? `color: ${color};` : "" color ? `color: ${color};` : ""
}`} }`}
> >
<use xlink:href="#spectrum-icon-18-{name}" /> <use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" />
</svg> </svg>
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction={"bottom"} text={tooltip} />
</div>
{/if}
</div>
<style> <style>
.icon {
position: relative;
display: grid;
place-items: center;
}
svg.hoverable { svg.hoverable {
pointer-events: all; pointer-events: all;
transition: color var(--spectrum-global-animation-duration-100, 130ms); transition: color var(--spectrum-global-animation-duration-100, 130ms);
@ -47,4 +72,15 @@
color: var(--spectrum-global-color-gray-500) !important; color: var(--spectrum-global-color-gray-500) !important;
pointer-events: none !important; pointer-events: none !important;
} }
.tooltip {
position: absolute;
pointer-events: none;
left: 50%;
top: calc(100% + 4px);
width: 100vw;
max-width: 150px;
transform: translateX(-50%);
text-align: center;
}
</style> </style>

View File

@ -23,6 +23,7 @@
export let secondaryButtonText = undefined export let secondaryButtonText = undefined
export let secondaryAction = undefined export let secondaryAction = undefined
export let secondaryButtonWarning = false export let secondaryButtonWarning = false
export let dataCy = null
const { hide, cancel } = getContext(Context.Modal) const { hide, cancel } = getContext(Context.Modal)
let loading = false let loading = false
@ -63,21 +64,26 @@
role="dialog" role="dialog"
tabindex="-1" tabindex="-1"
aria-modal="true" aria-modal="true"
data-cy={dataCy}
> >
<div class="spectrum-Dialog-grid"> <div class="spectrum-Dialog-grid">
{#if title} {#if title || $$slots.header}
<h1 <h1
class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader" class="spectrum-Dialog-heading spectrum-Dialog-heading--noHeader"
class:noDivider={!showDivider} class:noDivider={!showDivider}
class:header-spacing={$$slots.header} class:header-spacing={$$slots.header}
> >
{#if title}
{title} {title}
{:else if $$slots.header}
<slot name="header" /> <slot name="header" />
{/if}
</h1> </h1>
{#if showDivider} {#if showDivider}
<Divider size="M" /> <Divider size="M" />
{/if} {/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

@ -10,6 +10,17 @@
export let anchor export let anchor
export let align = "right" export let align = "right"
export let portalTarget export let portalTarget
export let dataCy
export let direction = "bottom"
export let showTip = false
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>'
$: tooltipClasses = showTip
? `spectrum-Popover--withTip spectrum-Popover--${direction}`
: ""
export const show = () => { export const show = () => {
dispatch("open") dispatch("open")
@ -37,9 +48,14 @@
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 " + (tooltipClasses || "")}
role="presentation" role="presentation"
data-cy={dataCy}
> >
{#if showTip}
{@html tipSvg}
{/if}
<slot /> <slot />
</div> </div>
</Portal> </Portal>
@ -49,4 +65,13 @@
.spectrum-Popover { .spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000) !important; min-width: var(--spectrum-global-dimension-size-2000) !important;
} }
.spectrum-Popover.is-open.spectrum-Popover--withTip {
margin-top: var(--spacing-xs);
margin-left: var(--spacing-xl);
}
:global(.spectrum-Popover--bottom .spectrum-Popover-tip),
:global(.spectrum-Popover--top .spectrum-Popover-tip) {
left: 90%;
margin-left: calc(var(--spectrum-global-dimension-size-150) * -1);
}
</style> </style>

View File

@ -0,0 +1,112 @@
import filterTests from "../support/filterTests"
filterTests(['all'], () => {
context("Publish Application Workflow", () => {
before(() => {
cy.login()
cy.createTestApp()
})
it("Should reflect the unpublished status correctly", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.get(".appTable .app-status").eq(0)
.within(() => {
cy.contains("Unpublished")
cy.get("svg[aria-label='GlobeStrike']").should("exist")
})
cy.get(".appTable .app-row-actions").eq(0)
.within(() => {
cy.get(".spectrum-Button").contains("Preview")
cy.get(".spectrum-Button").contains("Edit").click({ force: true })
})
cy.get(".deployment-top-nav svg[aria-label='GlobeStrike']").should("exist")
cy.get(".deployment-top-nav svg[aria-label='Globe']").should("not.exist")
})
it("Should publish an application and correctly reflect that", () => {
//Assuming the previous test was run and the unpublished app is open in edit mode.
cy.get(".toprightnav button.spectrum-Button").contains("Publish").click({ force : true })
cy.get(".spectrum-Modal [data-cy='deploy-app-modal']").should("be.visible")
.within(() => {
cy.get(".spectrum-Button").contains("Publish").click({ force : true })
cy.wait(1000)
});
//Verify that the app url is presented correctly to the user
cy.get(".spectrum-Modal [data-cy='deploy-app-success-modal']")
.should("be.visible")
.within(() => {
let appUrl = Cypress.config().baseUrl + '/app/cypress-tests'
cy.get("[data-cy='deployed-app-url'] input").should('have.value', appUrl)
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.wait(1000)
cy.get(".appTable .app-status").eq(0)
.within(() => {
cy.contains("Published")
cy.get("svg[aria-label='Globe']").should("exist")
})
cy.get(".appTable .app-row-actions").eq(0)
.within(() => {
cy.get(".spectrum-Button").contains("View app")
cy.get(".spectrum-Button").contains("Edit").click({ force: true })
})
cy.get(".deployment-top-nav svg[aria-label='Globe']").should("exist").click({ force: true })
cy.get("[data-cy='publish-popover-menu']").should("be.visible")
.within(() => {
cy.get("[data-cy='publish-popover-action']").should("exist")
cy.get("button").contains("View app").should("exist")
cy.get(".publish-popover-message").should("have.text", "Last published a few seconds ago")
})
})
it("Should unpublish an application from the top navigation and reflect the status change", () => {
//Assuming the previous test app exists and is published
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-status").eq(0)
.within(() => {
cy.contains("Published")
cy.get("svg[aria-label='Globe']").should("exist")
})
cy.get(".appTable .app-row-actions").eq(0)
.within(() => {
cy.get(".spectrum-Button").contains("View app")
cy.get(".spectrum-Button").contains("Edit").click({ force: true })
})
//The published status
cy.get(".deployment-top-nav svg[aria-label='Globe']").should("exist")
.click({ force: true })
cy.get("[data-cy='publish-popover-menu']").should("be.visible")
cy.get("[data-cy='publish-popover-menu'] [data-cy='publish-popover-action']")
.click({ force : true })
cy.get("[data-cy='unpublish-modal']").should("be.visible")
.within(() => {
cy.get(".confirm-wrap button").click({ force: true }
)})
cy.get(".deployment-top-nav svg[aria-label='GlobeStrike']").should("exist")
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-status").eq(0).contains("Unpublished")
})
})
})

View File

@ -11,7 +11,7 @@ filterTests(['all'], () => {
cy.applicationInAppTable("Cypress Tests") cy.applicationInAppTable("Cypress Tests")
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get(".app-row-actions-icon").eq(0).click()
}) })
cy.get(".spectrum-Menu").contains("Edit icon").click() cy.get(".spectrum-Menu").contains("Edit icon").click()
// Select random icon // Select random icon
@ -38,6 +38,7 @@ filterTests(['all'], () => {
cy.get(".title").children().children() cy.get(".title").children().children()
.should('have.attr', 'style').and('contains', 'color') .should('have.attr', 'style').and('contains', 'color')
}) })
cy.deleteAllApps()
}) })
}) })
}) })

View File

@ -11,7 +11,7 @@ filterTests(['smoke', 'all'], () => {
cy.createTestTableWithData() cy.createTestTableWithData()
cy.wait(2000) cy.wait(2000)
cy.contains("Automate").click() cy.contains("Automate").click()
cy.get("[data-cy='new-screen'] > .spectrum-Icon").click() cy.get(".add-button .spectrum-Icon").click()
cy.get(".modal-inner-wrapper").within(() => { cy.get(".modal-inner-wrapper").within(() => {
cy.get("input").type("Add Row") cy.get("input").type("Add Row")
cy.contains("Row Created").click({ force: true }) cy.contains("Row Created").click({ force: true })

View File

@ -125,7 +125,7 @@ filterTests(['smoke', 'all'], () => {
it("renames a view", () => { it("renames a view", () => {
cy.contains(".nav-item", "Test View") cy.contains(".nav-item", "Test View")
.find(".actions .icon") .find(".actions .icon.open-popover")
.click({ force: true }) .click({ force: true })
cy.get(".spectrum-Menu-itemLabel").contains("Edit").click() cy.get(".spectrum-Menu-itemLabel").contains("Edit").click()
cy.get(".modal-inner-wrapper").within(() => { cy.get(".modal-inner-wrapper").within(() => {
@ -138,7 +138,7 @@ filterTests(['smoke', 'all'], () => {
it("deletes a view", () => { it("deletes a view", () => {
cy.contains(".nav-item", "Test View Updated") cy.contains(".nav-item", "Test View Updated")
.find(".actions .icon") .find(".actions .icon.open-popover")
.click({ force: true }) .click({ force: true })
cy.contains("Delete").click() cy.contains("Delete").click()
cy.contains("Delete View").click() cy.contains("Delete View").click()

View File

@ -99,7 +99,7 @@ filterTests(['all'], () => {
cy.searchForApplication(originalName) cy.searchForApplication(originalName)
cy.get(".appTable") cy.get(".appTable")
.within(() => { .within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[aria-label='More']").eq(0).click()
}) })
// Check for when an app is published // Check for when an app is published
if (published == true) { if (published == true) {
@ -109,7 +109,9 @@ filterTests(['all'], () => {
cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click() cy.get(".spectrum-Dialog-grid").contains("Unpublish app").click()
cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click() cy.get(".appTable > :nth-child(5) > :nth-child(2) > .spectrum-Icon").click()
} }
cy.contains("Edit").click() cy.get("[data-cy='app-row-actions-menu-popover']").eq(0).within(() => {
cy.get(".spectrum-Menu-item").contains("Edit").click({ force: true })
})
cy.get(".spectrum-Modal") cy.get(".spectrum-Modal")
.within(() => { .within(() => {
if (noName == true) { if (noName == true) {

View File

@ -10,9 +10,9 @@ filterTests(['smoke', 'all'], () => {
it("should try to revert an unpublished app", () => { it("should try to revert an unpublished app", () => {
// Click revert icon // Click revert icon
cy.get(".toprightnav").within(() => { cy.get(".toprightnav").within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[aria-label='Revert']").click({ force: true })
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Modal").within(() => {
// Enter app name before revert // Enter app name before revert
cy.get("input").type("Cypress Tests") cy.get("input").type("Cypress Tests")
cy.intercept('**/revert').as('revertApp') cy.intercept('**/revert').as('revertApp')
@ -33,11 +33,15 @@ filterTests(['smoke', 'all'], () => {
cy.get(".spectrum-ButtonGroup").within(() => { cy.get(".spectrum-ButtonGroup").within(() => {
cy.get(".spectrum-Button").contains("Publish").click({ force: true }) cy.get(".spectrum-Button").contains("Publish").click({ force: true })
}) })
cy.wait(1000)
cy.get(".spectrum-ButtonGroup").within(() => {
cy.get(".spectrum-Button").contains("Done").click({ force: true })
})
// Add second component - Button // Add second component - Button
cy.addComponent("Elements", "Button") cy.addComponent("Elements", "Button")
// Click Revert // Click Revert
cy.get(".toprightnav").within(() => { cy.get(".toprightnav").within(() => {
cy.get(".spectrum-Icon").eq(1).click() cy.get("[aria-label='Revert']").click({ force: true })
}) })
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {
// Click Revert // Click Revert
@ -54,7 +58,7 @@ filterTests(['smoke', 'all'], () => {
it("should enter incorrect app name when reverting", () => { it("should enter incorrect app name when reverting", () => {
// Click Revert // Click Revert
cy.get(".toprightnav").within(() => { cy.get(".toprightnav").within(() => {
cy.get(".spectrum-Icon").eq(1).click({ force: true }) cy.get("[aria-label='Revert']").click({ force: true })
}) })
// Enter incorrect app name // Enter incorrect app name
cy.get(".spectrum-Dialog-grid").within(() => { cy.get(".spectrum-Dialog-grid").within(() => {

View File

@ -36,6 +36,7 @@ export const Events = {
CREATED: "budibase:app_created", CREATED: "budibase:app_created",
PUBLISHED: "budibase:app_published", PUBLISHED: "budibase:app_published",
UNPUBLISHED: "budibase:app_unpublished", UNPUBLISHED: "budibase:app_unpublished",
VIEW_PUBLISHED: "budibase:view_published_app",
}, },
ANALYTICS: { ANALYTICS: {
OPT_IN: "budibase:analytics_opt_in", OPT_IN: "budibase:analytics_opt_in",
@ -51,3 +52,9 @@ export const Events = {
SAVED: "budibase:sso_saved", SAVED: "budibase:sso_saved",
}, },
} }
export const EventSource = {
PORTAL: "portal",
URL: "url",
NOTIFICATION: "notification",
}

View File

@ -2,7 +2,7 @@ import { API } from "api"
import PosthogClient from "./PosthogClient" import PosthogClient from "./PosthogClient"
import IntercomClient from "./IntercomClient" import IntercomClient from "./IntercomClient"
import SentryClient from "./SentryClient" import SentryClient from "./SentryClient"
import { Events } from "./constants" import { Events, EventSource } from "./constants"
const posthog = new PosthogClient( const posthog = new PosthogClient(
process.env.POSTHOG_TOKEN, process.env.POSTHOG_TOKEN,
@ -57,5 +57,5 @@ class AnalyticsHub {
const analytics = new AnalyticsHub() const analytics = new AnalyticsHub()
export { Events } export { Events, EventSource }
export default analytics export default analytics

View File

@ -10,11 +10,11 @@
<ModalContent <ModalContent
showCloseIcon={false} showCloseIcon={false}
showConfirmButton={false} showConfirmButton={false}
title="Test Results"
cancelText="Close" cancelText="Close"
> >
<div slot="header"> <div slot="header" class="result-modal-header">
<div style="float: right;"> <span>Test Results</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" />
@ -100,6 +100,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

@ -16,7 +16,7 @@
</Modal> </Modal>
</Tab> </Tab>
</Tabs> </Tabs>
<div class="add-button" data-cy="new-screen"> <div class="add-button">
<Icon hoverable name="AddCircle" on:click={modal.show} /> <Icon hoverable name="AddCircle" on:click={modal.show} />
</div> </div>
</div> </div>

View File

@ -40,7 +40,7 @@
</script> </script>
<ActionMenu> <ActionMenu>
<div slot="control" class="icon"> <div slot="control" class="icon open-popover">
<Icon s hoverable name="MoreSmallList" /> <Icon s hoverable name="MoreSmallList" />
</div> </div>
<MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem> <MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem>

View File

@ -9,6 +9,7 @@
export let onCancel = undefined export let onCancel = undefined
export let warning = true export let warning = true
export let disabled export let disabled
export let dataCy = null
let modal let modal
@ -28,6 +29,7 @@
{cancelText} {cancelText}
{warning} {warning}
{disabled} {disabled}
{dataCy}
> >
<Body size="S"> <Body size="S">
{body} {body}

View File

@ -4,6 +4,7 @@
export let label = null export let label = null
export let value export let value
export let copyValue export let copyValue
export let dataCy = null
const copyToClipboard = val => { const copyToClipboard = val => {
const dummy = document.createElement("textarea") const dummy = document.createElement("textarea")
@ -16,7 +17,7 @@
} }
</script> </script>
<div> <div data-cy={dataCy}>
<Input readonly {value} {label} /> <Input readonly {value} {label} />
<div class="icon" on:click={() => copyToClipboard(value || copyValue)}> <div class="icon" on:click={() => copyToClipboard(value || copyValue)}>
<Icon size="S" name="Copy" /> <Icon size="S" name="Copy" />

View File

@ -1,24 +1,61 @@
<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, EventSource } 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() //In Progress
asyncModal.show()
publishModal.hide()
published = await API.deployAppChanges()
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) {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(publishedUrl, "_blank")
}
}
</script> </script>
<Button secondary on:click={publishModal.show}>Publish</Button> <Button secondary on:click={publishModal.show}>Publish</Button>
@ -30,11 +67,13 @@
showCancelButton={false} showCancelButton={false}
/> />
</Modal> </Modal>
<Modal bind:this={publishModal}> <Modal bind:this={publishModal}>
<ModalContent <ModalContent
title="Publish to Production" title="Publish to Production"
confirmText="Publish" confirmText="Publish"
onConfirm={deployApp} onConfirm={deployApp}
dataCy={"deploy-app-modal"}
> >
<span <span
>The changes you have made will be published to the production version of >The changes you have made will be published to the production version of
@ -42,3 +81,57 @@
> >
</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 -->
<Modal bind:this={publishCompleteModal}>
<ModalContent
confirmText="Done"
cancelText="View App"
onCancel={viewApp}
dataCy={"deploy-app-success-modal"}
>
<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:"
dataCy="deployed-app-url"
/>
</ModalContent>
</Modal>
<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

@ -0,0 +1,189 @@
<script>
import {
notifications,
Popover,
Layout,
Heading,
Body,
Button,
Icon,
} from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { API } from "api"
import { onMount } from "svelte"
import DeployModal from "components/deploy/DeployModal.svelte"
import { apps } from "stores/portal"
export let application
let publishPopover
let publishPopoverAnchor
let unpublishModal
$: filteredApps = $apps.filter(
app => app.devId === application && app.status === "published"
)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: deployments = []
$: latestDeployments = deployments
.filter(deployment => deployment.status === "SUCCESS")
.sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished = selectedApp && latestDeployments?.length > 0
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")
}
}
const viewApp = () => {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: selectedApp.appId,
eventSource: EventSource.PORTAL,
})
if (selectedApp.url) {
window.open(`/app${selectedApp.url}`)
} else {
window.open(`/${selectedApp.prodId}`)
}
}
const unpublishApp = () => {
publishPopover.hide()
unpublishModal.show()
}
const confirmUnpublishApp = async () => {
if (!application || !isPublished) {
//confirm the app has loaded.
return
}
try {
analytics.captureEvent(Events.APP.UNPUBLISHED, {
appId: selectedApp.appId,
})
await API.unpublishApp(selectedApp.prodId)
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error("Error unpublishing app")
}
}
const completePublish = async () => {
try {
await apps.load()
deployments = await fetchDeployments()
} catch (err) {
notifications.error("Error refreshing app")
}
}
onMount(async () => {
if (!$apps.length) {
await apps.load()
}
deployments = await fetchDeployments()
})
</script>
<div class="deployment-top-nav">
{#if isPublished}
<div class="publish-popover">
<div bind:this={publishPopoverAnchor}>
<Icon
size="M"
hoverable
name="Globe"
tooltip="Your published app"
on:click={publishPopover.show()}
/>
</div>
<Popover
bind:this={publishPopover}
align="right"
disabled={!isPublished}
dataCy="publish-popover-menu"
showTip={true}
anchor={publishPopoverAnchor}
>
<Layout gap="M">
<Heading size="XS">Your published app</Heading>
<Body size="S">
<span class="publish-popover-message">
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(latestDeployments[0].updatedAt).getTime(),
}
)}
</span>
</Body>
<div class="publish-popover-actions">
<Button
warning={true}
icon="GlobeStrike"
disabled={!isPublished}
on:click={unpublishApp}
dataCy="publish-popover-action"
>
Unpublish
</Button>
<Button cta on:click={viewApp}>View app</Button>
</div>
</Layout>
</Popover>
</div>
{/if}
{#if !isPublished}
<Icon
size="M"
name="GlobeStrike"
disabled
tooltip="Your app has not been published yet"
/>
{/if}
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
dataCy={"unpublish-modal"}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<DeployModal onOk={completePublish} />
<style>
.publish-popover-actions :global([data-cy="publish-popover-action"]) {
margin-right: var(--spacing-s);
}
:global([data-cy="publish-popover-menu"]) {
padding: 10px;
}
</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(pendingDeployments[0].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

@ -15,6 +15,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
@ -22,7 +23,7 @@
<div class="title"> <div class="title">
<div style="display: flex;"> <div style="display: flex;">
<div style="color: {app.icon?.color || ''}"> <div class="app-icon" style="color: {app.icon?.color || ''}">
<Icon size="XL" name={app.icon?.name || "Apps"} /> <Icon size="XL" name={app.icon?.name || "Apps"} />
</div> </div>
<div class="name" on:click={() => editApp(app)}> <div class="name" on:click={() => editApp(app)}>
@ -57,26 +58,40 @@
</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-row-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>
<ActionMenu align="right"> </div>
<Icon hoverable slot="control" name="More" /> <ActionMenu align="right" dataCy="app-row-actions-menu-popover">
{#if app.deployed} <span slot="control" class="app-row-actions-icon">
<MenuItem on:click={() => viewApp(app)} icon="GlobeOutline"> <Icon hoverable name="More" />
View published app </span>
</MenuItem>
{/if}
{#if app.lockedYou} {#if app.lockedYou}
<MenuItem on:click={() => releaseLock(app)} icon="LockOpen"> <MenuItem on:click={() => releaseLock(app)} icon="LockOpen">
Release lock Release lock
@ -97,6 +112,18 @@
</div> </div>
<style> <style>
.app-row-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

@ -2,9 +2,9 @@
import { store, automationStore } from "builderStore" import { store, automationStore } 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 } from "@budibase/bbui"
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 DeployNavigation from "components/deploy/DeployNavigation.svelte"
import { API } from "api" import { API } from "api"
import { auth, admin } from "stores/portal" import { auth, admin } from "stores/portal"
import { isActive, goto, layout, redirect } from "@roxi/routify" import { isActive, goto, layout, redirect } from "@roxi/routify"
@ -24,7 +24,7 @@
$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}`)
} }
@ -112,7 +112,7 @@
<VersionModal /> <VersionModal />
<RevertModal /> <RevertModal />
<Icon name="Play" hoverable on:click={previewApp} /> <Icon name="Play" hoverable on:click={previewApp} />
<DeployModal /> <DeployNavigation {application} />
</div> </div>
</div> </div>
<slot /> <slot />
@ -128,7 +128,6 @@
width: 100%; width: 100%;
background: var(--background); background: var(--background);
} }
.root { .root {
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;

View File

@ -28,7 +28,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import analytics, { Events } from "analytics" import analytics, { Events, EventSource } from "analytics"
import Logo from "assets/bb-space-man.svg" import Logo from "assets/bb-space-man.svg"
let sortBy = "name" let sortBy = "name"
@ -167,6 +167,10 @@
} }
const viewApp = app => { const viewApp = app => {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: app.appId,
eventSource: EventSource.PORTAL,
})
if (app.url) { if (app.url) {
window.open(`/app${app.url}`) window.open(`/app${app.url}`)
} else { } else {
@ -174,6 +178,10 @@
} }
} }
const previewApp = app => {
window.open(`/${app.devId}`)
}
const editApp = app => { const editApp = app => {
if (app.lockedOther) { if (app.lockedOther) {
notifications.error( notifications.error(
@ -205,6 +213,9 @@
return return
} }
try { try {
analytics.captureEvent(Events.APP.UNPUBLISHED, {
appId: selectedApp.appId,
})
await API.unpublishApp(selectedApp.prodId) await API.unpublishApp(selectedApp.prodId)
await apps.load() await apps.load()
notifications.success("App unpublished successfully") notifications.success("App unpublished successfully")
@ -392,6 +403,7 @@
{exportApp} {exportApp}
{deleteApp} {deleteApp}
{updateApp} {updateApp}
{previewApp}
/> />
{/each} {/each}
</div> </div>
@ -444,6 +456,7 @@
title="Confirm unpublish" title="Confirm unpublish"
okText="Unpublish app" okText="Unpublish app"
onOk={confirmUnpublishApp} onOk={confirmUnpublishApp}
dataCy={"unpublish-modal"}
> >
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog> </ConfirmDialog>

View File

@ -386,24 +386,32 @@ export const revertClient = async (ctx: any) => {
} }
const destroyApp = async (ctx: any) => { const destroyApp = async (ctx: any) => {
const db = getAppDB() let appId = ctx.params.appId
let isUnpublish = ctx.query && ctx.query.unpublish
if (isUnpublish) {
appId = getProdAppID(appId)
}
const db = isUnpublish ? getProdAppDB() : getAppDB()
const result = await db.destroy() const result = await db.destroy()
if (ctx.query?.unpublish) {
if (isUnpublish) {
await quotas.removePublishedApp() await quotas.removePublishedApp()
} else { } else {
await quotas.removeApp() await quotas.removeApp()
} }
/* istanbul ignore next */ /* istanbul ignore next */
if (!env.isTest() && !ctx.query.unpublish) { if (!env.isTest() && !isUnpublish) {
await deleteApp(ctx.params.appId) await deleteApp(appId)
} }
if (ctx.query && ctx.query.unpublish) { if (isUnpublish) {
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)
return result return result
} }

View File

@ -110,6 +110,9 @@ async function deployApp(deployment: any) {
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)