Merge branch 'feature/app-overview-section' of github.com:Budibase/budibase into feature/automation-logs

This commit is contained in:
mike12345567 2022-05-16 12:33:49 +01:00
commit 91e65a9d65
19 changed files with 1380 additions and 99 deletions

View File

@ -7,6 +7,7 @@
export let icon = "" export let icon = ""
export let selected = false export let selected = false
export let disabled = false export let disabled = false
export let dataCy
</script> </script>
<li <li
@ -14,6 +15,7 @@
class:is-selected={selected} class:is-selected={selected}
class:is-disabled={disabled} class:is-disabled={disabled}
on:click on:click
data-cy={dataCy}
> >
{#if heading} {#if heading}
<h2 class="spectrum-SideNav-heading" id="nav-heading-{heading}"> <h2 class="spectrum-SideNav-heading" id="nav-heading-{heading}">

View File

@ -6,7 +6,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let selected = getContext("tab") let selected = getContext("tab")
let tab let tab_internal
let tabInfo let tabInfo
const setTabInfo = () => { const setTabInfo = () => {
@ -16,7 +16,7 @@
// We just need to get this off the main thread to fix this, by using // We just need to get this off the main thread to fix this, by using
// a 0ms timeout. // a 0ms timeout.
setTimeout(() => { setTimeout(() => {
tabInfo = tab?.getBoundingClientRect() tabInfo = tab_internal?.getBoundingClientRect()
if (tabInfo && $selected.title === title) { if (tabInfo && $selected.title === title) {
$selected.info = tabInfo $selected.info = tabInfo
} }
@ -27,14 +27,30 @@
setTabInfo() setTabInfo()
}) })
//Ensure that the underline is in the correct location
$: {
if ($selected.title === title && tab_internal) {
if ($selected.info?.left !== tab_internal.getBoundingClientRect().left) {
$selected = {
...$selected,
info: tab_internal.getBoundingClientRect(),
}
}
}
}
const onClick = () => { const onClick = () => {
$selected = { ...$selected, title, info: tab.getBoundingClientRect() } $selected = {
...$selected,
title,
info: tab_internal.getBoundingClientRect(),
}
dispatch("click") dispatch("click")
} }
</script> </script>
<div <div
bind:this={tab} bind:this={tab_internal}
on:click={onClick} on:click={onClick}
class:is-selected={$selected.title === title} class:is-selected={$selected.title === title}
class="spectrum-Tabs-item" class="spectrum-Tabs-item"

View File

@ -0,0 +1,292 @@
import filterTests from "../support/filterTests"
import clientPackage from "@budibase/client/package.json"
filterTests(['all'], () => {
context("Application Overview screen", () => {
before(() => {
cy.login()
cy.createTestApp()
})
it("Should be accessible from the applications list", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .title").eq(0)
.invoke('attr', 'data-cy')
.then(($dataCy) => {
const dataCy = $dataCy;
cy.get(".appTable .name").eq(0).click()
cy.location().should((loc) => {
expect(loc.pathname).to.eq('/builder/portal/overview/' + dataCy)
})
})
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .title").eq(0)
.invoke('attr', 'data-cy')
.then(($dataCy) => {
const dataCy = $dataCy;
cy.get(".appTable .app-row-actions button").contains("View").click({force: true})
cy.location().should((loc) => {
expect(loc.pathname).to.eq('/builder/portal/overview/' + dataCy)
})
})
})
// Find a more suitable place for this.
it("Should allow unlocking in the app list", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .lock-status").eq(0).contains("Locked by you").click()
cy.unlockApp({ owned : true })
cy.get(".appTable").should("exist")
cy.get(".lock-status").should('not.be.visible')
})
it("Should allow unlocking in the app overview screen", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-row-actions button").contains("Edit").eq(0).click({force: true})
cy.wait(1000)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".lock-status").eq(0).contains("Locked by you").click()
cy.unlockApp({ owned : true })
cy.get(".lock-status").should("not.be.visible")
})
it("Should reflect the deploy state of an app that hasn't been published.", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".header-right button.spectrum-Button[data-cy='view-app']").should("be.disabled")
cy.get(".spectrum-Tabs-item.is-selected").contains("Overview")
cy.get(".overview-tab").should("be.visible")
cy.get(".overview-tab [data-cy='app-status']").within(() => {
cy.get(".status-display").contains("Unpublished")
cy.get(".status-display .icon svg[aria-label='GlobeStrike']").should("exist")
cy.get(".status-text").contains("-")
})
})
it("Should reflect the app deployment state", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-row-actions button").contains("Edit").eq(0).click({force: true})
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)
});
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".header-right button.spectrum-Button[data-cy='view-app']").should("not.be.disabled")
cy.get(".overview-tab [data-cy='app-status']").within(() => {
cy.get(".status-display").contains("Published")
cy.get(".status-display .icon svg[aria-label='GlobeCheck']").should("exist")
cy.get(".status-text").contains("Last published a few seconds ago")
})
})
it("Should reflect an application that has been unpublished", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-row-actions button").contains("Edit").eq(0).click({force: true})
cy.get(".deployment-top-nav svg[aria-label='Globe']")
.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.wait(1000)
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".overview-tab [data-cy='app-status']").within(() => {
cy.get(".status-display").contains("Unpublished")
cy.get(".status-display .icon svg[aria-label='GlobeStrike']").should("exist")
cy.get(".status-text").contains("Last published a few seconds ago")
})
})
it("Should allow the editing of the application icon", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".app-logo .edit-hover").should("exist").invoke("show").click()
cy.customiseAppIcon()
cy.get(".app-logo")
.within(() => {
cy.get('[aria-label]').eq(0).children()
.should('have.attr', 'xlink:href').and('not.contain', '#spectrum-icon-18-Apps')
cy.get(".app-icon")
.should('have.attr', 'style').and('contains', 'color')
})
})
it("Should reflect the last time the application was edited", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".header-right button").contains("Edit").click({ force: true });
cy.navigateToFrontend()
cy.addComponent("Elements", "Headline").then(componentId => {
cy.getComponent(componentId).should("exist")
})
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".overview-tab [data-cy='edited-by']").within(() => {
cy.get(".editor-name").contains("You")
cy.get(".last-edit-text").contains("Last edited a few seconds ago")
})
});
it("Should reflect application version is up-to-date", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".overview-tab [data-cy='app-version']").within(() => {
cy.get(".version-status").contains("You're running the latest!")
})
});
it("Should navigate to the settings tab when clicking the App Version card header", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".spectrum-Tabs-item.is-selected").contains("Overview")
cy.get(".overview-tab").should("be.visible")
cy.get(".overview-tab [data-cy='app-version'] .dash-card-header").click({ force : true })
cy.get(".spectrum-Tabs-item.is-selected").contains("Settings")
cy.get(".settings-tab").should("be.visible")
cy.get(".overview-tab").should("not.exist")
});
it("Should allow the upgrading of an application, if available.", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.wait(500)
cy.location().then(loc => {
const params = loc.pathname.split("/")
const appId = params[params.length - 1]
cy.log(appId)
//Downgrade the app for the test
cy.alterAppVersion(appId, "0.0.1-alpha.0")
.then(()=>{
cy.reload()
cy.wait(1000)
cy.log("Current deployment version: " + clientPackage.version)
cy.get(".version-status a").contains("Update").click()
cy.get(".spectrum-Tabs-item.is-selected").contains("Settings")
cy.get(".version-section .page-action button").contains("Update").click({ force: true })
cy.intercept('POST', '**/applications/**/client/update').as('updateVersion')
cy.get(".spectrum-Modal.is-open button").contains("Update").click({ force: true })
cy.wait("@updateVersion")
.its('response.statusCode').should('eq', 200)
.then(() => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".spectrum-Tabs-item").contains("Overview").click({ force: true })
cy.get(".overview-tab [data-cy='app-version']").within(() => {
cy.get(".spectrum-Heading").contains(clientPackage.version)
cy.get(".version-status").contains("You're running the latest!")
})
})
})
});
})
it("Should allow editing of the app details.", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".spectrum-Tabs-item").contains("Settings").click()
cy.get(".spectrum-Tabs-item.is-selected").contains("Settings")
cy.get(".settings-tab").should("be.visible")
cy.get(".details-section .page-action button").contains("Edit").click({ force: true })
cy.updateAppName("sample name")
//publish and check its disabled
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-row-actions button").contains("Edit").eq(0).click({force: true})
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)
});
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .name").eq(0).click()
cy.get(".spectrum-Tabs-item").contains("Settings").click()
cy.get(".spectrum-Tabs-item.is-selected").contains("Settings")
cy.get(".details-section .page-action .spectrum-Button").scrollIntoView()
cy.wait(1000)
cy.get(".details-section .page-action .spectrum-Button").should("be.disabled")
})
after(() => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".appTable .app-row-actions button").contains("Edit").eq(0).click({force: true})
cy.get(".deployment-top-nav svg[aria-label='Globe']")
.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.wait(1000)
cy.deleteAllApps()
})
})
})

View File

@ -19,7 +19,7 @@ filterTests(['all'], () => {
cy.get(".appTable .app-row-actions").eq(0) cy.get(".appTable .app-row-actions").eq(0)
.within(() => { .within(() => {
cy.get(".spectrum-Button").contains("Preview") cy.get(".spectrum-Button").contains("View")
cy.get(".spectrum-Button").contains("Edit").click({ force: true }) cy.get(".spectrum-Button").contains("Edit").click({ force: true })
}) })
@ -57,7 +57,7 @@ filterTests(['all'], () => {
cy.get(".appTable .app-row-actions").eq(0) cy.get(".appTable .app-row-actions").eq(0)
.within(() => { .within(() => {
cy.get(".spectrum-Button").contains("View app") cy.get(".spectrum-Button").contains("View")
cy.get(".spectrum-Button").contains("Edit").click({ force: true }) cy.get(".spectrum-Button").contains("Edit").click({ force: true })
}) })
@ -66,7 +66,7 @@ filterTests(['all'], () => {
cy.get("[data-cy='publish-popover-menu']").should("be.visible") cy.get("[data-cy='publish-popover-menu']").should("be.visible")
.within(() => { .within(() => {
cy.get("[data-cy='publish-popover-action']").should("exist") cy.get("[data-cy='publish-popover-action']").should("exist")
cy.get("button").contains("View app").should("exist") cy.get("button").contains("View").should("exist")
cy.get(".publish-popover-message").should("have.text", "Last published a few seconds ago") cy.get(".publish-popover-message").should("have.text", "Last published a few seconds ago")
}) })
}) })
@ -84,7 +84,7 @@ filterTests(['all'], () => {
cy.get(".appTable .app-row-actions").eq(0) cy.get(".appTable .app-row-actions").eq(0)
.within(() => { .within(() => {
cy.get(".spectrum-Button").contains("View app") cy.get(".spectrum-Button").contains("View")
cy.get(".spectrum-Button").contains("Edit").click({ force: true }) cy.get(".spectrum-Button").contains("Edit").click({ force: true })
}) })

View File

@ -112,19 +112,9 @@ filterTests(['all'], () => {
cy.get("[data-cy='app-row-actions-menu-popover']").eq(0).within(() => { 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-Menu-item").contains("Edit").click({ force: true })
}) })
cy.get(".spectrum-Modal")
.within(() => { cy.updateAppName(changedName, noName)
if (noName == true) {
cy.get("input").clear() }
cy.get(".spectrum-Dialog-grid").click() })
.contains("App name must be letters, numbers and spaces only")
return cy
}
cy.get("input").clear()
cy.get("input").eq(0).type(changedName).should("have.value", changedName).blur()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
cy.wait(500)
})
}
})
}) })

View File

@ -32,6 +32,15 @@ Cypress.Commands.add("login", () => {
}) })
}) })
Cypress.Commands.add("logOut", () => {
cy.visit(`${Cypress.config().baseUrl}/builder`)
cy.get(".user-dropdown .avatar > .icon").click({ force: true })
cy.get(".spectrum-Popover[data-cy='user-menu']").within(() => {
cy.get("li[data-cy='user-logout']").click({ force: true })
})
cy.wait(2000)
})
Cypress.Commands.add("closeModal", () => { Cypress.Commands.add("closeModal", () => {
cy.get(".spectrum-Modal").within(() => { cy.get(".spectrum-Modal").within(() => {
cy.get(".close-icon").click() cy.get(".close-icon").click()
@ -140,6 +149,86 @@ Cypress.Commands.add("deleteAllApps", () => {
}) })
}) })
Cypress.Commands.add("customiseAppIcon", () => {
// Select random icon
cy.get(".grid").within(() => {
cy.get(".icon-item")
.eq(Math.floor(Math.random() * 23) + 1)
.click()
})
// Select random colour
cy.get(".fill").click()
cy.get(".colors").within(() => {
cy.get(".color")
.eq(Math.floor(Math.random() * 33) + 1)
.click()
})
cy.intercept("**/applications/**").as("iconChange")
cy.get(".spectrum-Button").contains("Save").click({ force: true })
cy.wait("@iconChange")
cy.get("@iconChange").its("response.statusCode").should("eq", 200)
cy.wait(1000)
})
Cypress.Commands.add("alterAppVersion", (appId, version) => {
return cy
.request("put", `${Cypress.config().baseUrl}/api/applications/${appId}`, {
version: version || "0.0.1-alpha.0",
})
.then(resp => {
expect(resp.status).to.eq(200)
})
})
Cypress.Commands.add("updateAppName", (changedName, noName) => {
cy.get(".spectrum-Modal").within(() => {
if (noName == true) {
cy.get("input").clear()
cy.get(".spectrum-Dialog-grid")
.click()
.contains("App name must be letters, numbers and spaces only")
return cy
}
cy.get("input").clear()
cy.get("input")
.eq(0)
.type(changedName)
.should("have.value", changedName)
.blur()
cy.get(".spectrum-ButtonGroup").contains("Save").click({ force: true })
cy.wait(500)
})
})
Cypress.Commands.add("unlockApp", unlock_config => {
let config = { ...unlock_config }
cy.get(".spectrum-Modal .spectrum-Dialog[data-cy='app-lock-modal']")
.should("be.visible")
.within(() => {
if (config.owned) {
cy.get(".spectrum-Dialog-heading").contains("Locked by you")
cy.get(".lock-expiry-body").contains(
"This lock will expire in 10 minutes from now"
)
cy.intercept("**/lock").as("unlockApp")
cy.get(".spectrum-Button")
.contains("Release Lock")
.click({ force: true })
cy.wait("@unlockApp")
cy.get("@unlockApp").its("response.statusCode").should("eq", 200)
cy.get("@unlockApp").its("response.body").should("deep.equal", {
message: "Lock released successfully.",
})
} else {
//Show the name ?
cy.get(".lock-expiry-body").should("not.be.visible")
cy.get(".spectrum-Button").contains("Done")
}
})
})
Cypress.Commands.add("createTestApp", () => { Cypress.Commands.add("createTestApp", () => {
const appName = "Cypress Tests" const appName = "Cypress Tests"
cy.deleteApp(appName) cy.deleteApp(appName)

View File

@ -0,0 +1,146 @@
<script>
import {
Button,
ButtonGroup,
ModalContent,
Modal,
notifications,
ProgressCircle,
} from "@budibase/bbui"
import { auth, apps } from "stores/portal"
import { processStringSync } from "@budibase/string-templates"
import { API } from "api"
export let app
export let buttonSize = "M"
let APP_DEV_LOCK_SECONDS = 600 //common area for this?
let appLockModal
let processing = false
$: lockedBy = app?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && lockedBy.firstName ? lockedBy?.firstName : lockedBy?.email
}`
$: lockedByHeading =
lockedBy && lockedByYou ? "Locked by you" : `Locked by ${lockIdentifer}`
$: lockExpiry = getExpiryDuration(app)
const getExpiryDuration = app => {
if (!app?.lockedBy?.lockedAt) {
return -1
}
let expiry =
new Date(app.lockedBy.lockedAt).getTime() + APP_DEV_LOCK_SECONDS * 1000
return expiry - new Date().getTime()
}
const releaseLock = async () => {
processing = true
if (app) {
try {
await API.releaseAppLock(app.devId)
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error("Error releasing lock")
}
} else {
notifications.error("No application is selected")
}
processing = false
}
</script>
<div class="lock-status">
{#if lockedBy}
<Button
quiet
secondary
icon="LockClosed"
size={buttonSize}
on:click={() => {
appLockModal.show()
}}
>
<span class="lock-status-text">
{lockedByHeading}
</span>
</Button>
{/if}
</div>
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
dataCy={"app-lock-modal"}
showConfirmButton={false}
showCancelButton={false}
>
<p>
Apps are locked to prevent work from being lost from overlapping changes
between your team.
</p>
{#if lockedByYou && lockExpiry > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now",
{
time: lockExpiry,
}
)}
</span>
{/if}
<div class="lock-modal-actions">
<ButtonGroup>
<Button
secondary
quiet={lockedBy && lockedByYou}
disabled={processing}
on:click={() => {
appLockModal.hide()
}}
>
<span class="cancel"
>{lockedBy && !lockedByYou ? "Done" : "Cancel"}</span
>
</Button>
{#if lockedByYou}
<Button
secondary
disabled={processing}
on:click={() => {
releaseLock()
appLockModal.hide()
}}
>
{#if processing}
<ProgressCircle overBackground={true} size="S" />
{:else}
<span class="unlock">Release Lock</span>
{/if}
</Button>
{/if}
</ButtonGroup>
</div>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {
display: flex;
justify-content: flex-end;
margin-top: var(--spacing-l);
gap: var(--spacing-xl);
}
.lock-status {
display: flex;
gap: var(--spacing-s);
max-width: 175px;
}
</style>

View File

@ -0,0 +1,63 @@
<script>
import { Icon, Detail } from "@budibase/bbui"
export let title = ""
export let actionIcon
export let action
export let dataCy
$: actionDefined = typeof action === "function"
</script>
<div class="dash-card" data-cy={dataCy}>
<div
class={actionDefined ? "dash-card-header active" : "dash-card-header"}
on:click={() => {
if (actionDefined) {
action()
}
}}
>
<span class="dash-card-title">
<Detail size="M">{title}</Detail>
</span>
<span class="dash-card-action">
{#if actionDefined}
<Icon name={actionIcon ? actionIcon : "ChevronRight"} />
{/if}
</span>
</div>
<div class="dash-card-body">
<slot />
</div>
</div>
<style>
.dash-card {
background: var(--spectrum-alias-background-color-primary);
border-radius: var(--border-radius-s);
border: 1px solid var(--spectrum-global-color-gray-300);
overflow: hidden;
min-height: 150px;
}
.dash-card-header {
padding: var(--spacing-l);
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
display: flex;
justify-content: space-between;
}
.dash-card-body {
padding: var(--spacing-l);
}
.dash-card-title :global(.spectrum-Detail) {
color: var(
--spectrum-sidenav-heading-text-color,
var(--spectrum-global-color-gray-700)
);
display: inline-block;
}
.dash-card-header.active:hover {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
</style>

View File

@ -0,0 +1,47 @@
<script>
import { Icon } from "@budibase/bbui"
import ChooseIconModal from "components/start/ChooseIconModal.svelte"
export let name
export let size
export let app
let iconModal
</script>
<div class="editable-icon">
<div
class="edit-hover"
on:click={() => {
iconModal.show()
}}
>
<Icon name={"Edit"} size={"L"} />
</div>
<div class="app-icon">
<Icon {name} {size} />
</div>
</div>
<ChooseIconModal {app} bind:this={iconModal} />
<style>
.editable-icon:hover .app-icon {
opacity: 0;
}
.editable-icon {
position: relative;
}
.editable-icon:hover .edit-hover {
opacity: 1;
}
.edit-hover {
color: var(--spectrum-global-color-gray-600);
cursor: pointer;
z-index: 100;
width: 100%;
height: 100%;
position: absolute;
opacity: 0;
/* transition: opacity var(--spectrum-global-animation-duration-100) ease; */
}
</style>

View File

@ -11,6 +11,16 @@
import { API } from "api" import { API } from "api"
import clientPackage from "@budibase/client/package.json" import clientPackage from "@budibase/client/package.json"
export function show() {
updateModal.show()
}
export function hide() {
updateModal.hide()
}
export let hideIcon = false
let updateModal let updateModal
$: appId = $store.appId $: appId = $store.appId
@ -57,9 +67,11 @@
} }
</script> </script>
<div class="icon-wrapper" class:highlight={updateAvailable}> {#if !hideIcon}
<Icon name="Refresh" hoverable on:click={updateModal.show} /> <div class="icon-wrapper" class:highlight={updateAvailable}>
</div> <Icon name="Refresh" hoverable on:click={updateModal.show} />
</div>
{/if}
<Modal bind:this={updateModal}> <Modal bind:this={updateModal}>
<ModalContent <ModalContent
title="App version" title="App version"

View File

@ -1,33 +1,26 @@
<script> <script>
import { import { Heading, Button, Icon, ActionMenu, MenuItem } from "@budibase/bbui"
Heading, import AppLockModal from "../common/AppLockModal.svelte"
Button,
Icon,
ActionMenu,
MenuItem,
StatusLight,
} from "@budibase/bbui"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
export let app export let app
export let exportApp export let exportApp
export let viewApp
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 appOverview
export let releaseLock export let releaseLock
export let editIcon export let editIcon
export let copyAppId export let copyAppId
</script> </script>
<div class="title"> <div class="title" data-cy={`${app.devId}`}>
<div style="display: flex;"> <div style="display: flex;">
<div class="app-icon" 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={() => appOverview(app)}>
<Heading size="XS"> <Heading size="XS">
{app.name} {app.name}
</Heading> </Heading>
@ -44,19 +37,7 @@
{/if} {/if}
</div> </div>
<div class="desktop"> <div class="desktop">
<StatusLight <AppLockModal {app} buttonSize="S" />
positive={!app.lockedYou && !app.lockedOther}
notice={app.lockedYou}
negative={app.lockedOther}
>
{#if app.lockedYou}
Locked by you
{:else if app.lockedOther}
Locked by {app.lockedBy.email}
{:else}
Open
{/if}
</StatusLight>
</div> </div>
<div class="desktop"> <div class="desktop">
<div class="app-status"> <div class="app-status">
@ -71,23 +52,15 @@
</div> </div>
<div data-cy={`row_actions_${app.appId}`}> <div data-cy={`row_actions_${app.appId}`}>
<div class="app-row-actions"> <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 secondary
quiet
disabled={app.lockedOther} disabled={app.lockedOther}
on:click={() => editApp(app)} on:click={() => editApp(app)}
> >Edit
Edit
</Button> </Button>
<Button size="S" cta on:click={() => appOverview(app)}>View</Button>
</div> </div>
<ActionMenu align="right" dataCy="app-row-actions-menu-popover"> <ActionMenu align="right" dataCy="app-row-actions-menu-popover">
<span slot="control" class="app-row-actions-icon"> <span slot="control" class="app-row-actions-icon">
@ -123,6 +96,7 @@
} }
.app-status { .app-status {
display: grid; display: grid;
grid-gap: var(--spacing-s);
grid-template-columns: 24px 100px; grid-template-columns: 24px 100px;
} }
.app-status span.disabled { .app-status span.disabled {

View File

@ -169,7 +169,7 @@
/> />
</div> </div>
<div class="user-dropdown"> <div class="user-dropdown">
<ActionMenu align="right"> <ActionMenu align="right" dataCy="user-menu">
<div slot="control" class="avatar"> <div slot="control" class="avatar">
<Avatar <Avatar
size="M" size="M"
@ -195,7 +195,9 @@
<MenuItem icon="UserDeveloper" on:click={() => $goto("../apps")}> <MenuItem icon="UserDeveloper" on:click={() => $goto("../apps")}>
Close developer mode Close developer mode
</MenuItem> </MenuItem>
<MenuItem icon="LogOut" on:click={logout}>Log out</MenuItem> <MenuItem dataCy="user-logout" icon="LogOut" on:click={logout}
>Log out
</MenuItem>
</ActionMenu> </ActionMenu>
</div> </div>
</div> </div>

View File

@ -2,7 +2,6 @@
import { import {
Heading, Heading,
Layout, Layout,
Detail,
Button, Button,
Input, Input,
Select, Select,
@ -183,6 +182,10 @@
window.open(`/${app.devId}`) window.open(`/${app.devId}`)
} }
const appOverview = app => {
$goto(`../overview/${app.devId}`)
}
const editApp = app => { const editApp = app => {
if (app.lockedOther) { if (app.lockedOther) {
notifications.error( notifications.error(
@ -304,7 +307,7 @@
</script> </script>
<Page wide> <Page wide>
<Layout noPadding gap="XL"> <Layout noPadding gap="M">
{#if loaded} {#if loaded}
<div class="title"> <div class="title">
<div class="welcome"> <div class="welcome">
@ -314,29 +317,17 @@
{welcomeBody} {welcomeBody}
</Body> </Body>
</Layout> </Layout>
{#if !$apps?.length}
<div class="buttons"> <div class="buttons">
<Button
dataCy="create-app-btn"
size="M"
icon="Add"
cta
on:click={initiateAppCreation}
>
{createAppButtonText}
</Button>
{#if $apps?.length > 0}
<Button <Button
icon="Experience" dataCy="create-app-btn"
size="M" size="M"
quiet icon="Add"
secondary cta
on:click={$goto("/builder/portal/apps/templates")} on:click={initiateAppCreation}
> >
Templates {createAppButtonText}
</Button> </Button>
{/if}
{#if !$apps?.length}
<Button <Button
dataCy="import-app-btn" dataCy="import-app-btn"
icon="Import" icon="Import"
@ -347,15 +338,9 @@
> >
Import app Import app
</Button> </Button>
{/if} </div>
</div> {/if}
</div> </div>
<div>
<Layout gap="S" justifyItems="center">
<img class="img-logo img-size" alt="logo" src={Logo} />
</Layout>
</div>
<Divider size="S" />
</div> </div>
{#if !$apps?.length && $templates?.length} {#if !$apps?.length && $templates?.length}
@ -365,7 +350,40 @@
{#if enrichedApps.length} {#if enrichedApps.length}
<Layout noPadding gap="S"> <Layout noPadding gap="S">
<div class="title"> <div class="title">
<Detail size="L">Apps</Detail> <div class="buttons">
<Button
dataCy="create-app-btn"
size="M"
icon="Add"
cta
on:click={initiateAppCreation}
>
{createAppButtonText}
</Button>
{#if $apps?.length > 0}
<Button
icon="Experience"
size="M"
quiet
secondary
on:click={$goto("/builder/portal/apps/templates")}
>
Templates
</Button>
{/if}
{#if !$apps?.length}
<Button
dataCy="import-app-btn"
icon="Import"
size="L"
quiet
secondary
on:click={initiateAppImport}
>
Import app
</Button>
{/if}
</div>
{#if enrichedApps.length > 1} {#if enrichedApps.length > 1}
<div class="app-actions"> <div class="app-actions">
{#if cloud} {#if cloud}
@ -396,7 +414,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<Divider size="S" />
<div class="appTable"> <div class="appTable">
{#each filteredApps as app (app.appId)} {#each filteredApps as app (app.appId)}
<AppRow <AppRow
@ -411,6 +429,7 @@
{deleteApp} {deleteApp}
{updateApp} {updateApp}
{previewApp} {previewApp}
{appOverview}
/> />
{/each} {/each}
</div> </div>
@ -478,7 +497,7 @@
margin-right: 10px; margin-right: 10px;
} }
.title .welcome > .buttons { .title .welcome > .buttons {
padding-top: 30px; padding-top: var(--spacing-l);
} }
.title { .title {
display: flex; display: flex;

View File

@ -0,0 +1,255 @@
<script>
import { goto } from "@roxi/routify"
import {
Layout,
Page,
Button,
ButtonGroup,
Heading,
Tab,
Tabs,
notifications,
ProgressCircle,
} from "@budibase/bbui"
import OverviewTab from "../_components/OverviewTab.svelte"
import SettingsTab from "../_components/SettingsTab.svelte"
import { API } from "api"
import { store } from "builderStore"
import { apps, auth } from "stores/portal"
import analytics, { Events, EventSource } from "analytics"
import { AppStatus } from "constants"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { onDestroy, onMount } from "svelte"
export let application
let promise = getPackage()
// App
$: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
// Locking
$: lockedBy = selectedApp?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && Object.prototype.hasOwnProperty.call(lockedBy, "firstName")
? lockedBy?.firstName
: lockedBy?.email
}`
// App deployments
$: deployments = []
$: latestDeployments = deployments
.filter(
deployment =>
deployment.status === "SUCCESS" && application === deployment.appId
)
.sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished =
selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0
$: appUrl = `${window.origin}/app${selectedApp?.url}`
$: tabs = ["Overview", "Automation History", "Backups", "Settings"]
$: selectedTab = "Overview"
const backToAppList = () => {
$goto(`../../../portal/`)
}
const handleTabChange = tabKey => {
if (tabKey === selectedTab) {
return
} else if (tabKey && tabs.indexOf(tabKey) > -1) {
selectedTab = tabKey
} else {
notifications.error("Invalid tab key")
}
}
async function getPackage() {
try {
const pkg = await API.fetchAppPackage(application)
await store.actions.initialise(pkg)
return pkg
} catch (error) {
notifications.error(`Error initialising app: ${error?.message}`)
}
}
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 = () => {
if (isPublished) {
analytics.captureEvent(Events.APP.VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(appUrl, "_blank")
}
}
const editApp = app => {
if (lockedBy && !lockedByYou) {
notifications.warning(
`App locked by ${lockIdentifer}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`)
}
onDestroy(() => {
store.actions.reset()
})
onMount(async () => {
try {
if (!apps.length) {
await apps.load()
}
deployments = await fetchDeployments()
} catch (error) {
notifications.error("Error initialising app overview")
}
})
</script>
<Page wide>
<Layout noPadding gap="XL">
<span>
<Button quiet secondary icon={"ChevronLeft"} on:click={backToAppList}>
Back
</Button>
</span>
{#await promise}
<div class="loading">
<ProgressCircle size="XL" />
</div>
{:then _}
<div class="overview-header">
<div class="app-title">
<div class="app-logo">
<div
class="app-icon"
style="color: {selectedApp?.icon?.color || ''}"
>
<EditableIcon
app={selectedApp}
size="XL"
name={selectedApp?.icon?.name || "Apps"}
/>
</div>
</div>
<div class="app-details">
<Heading size="M">{selectedApp?.name}</Heading>
<div class="app-url">{appUrl}</div>
</div>
</div>
<div class="header-right">
<AppLockModal app={selectedApp} />
<ButtonGroup gap="XS">
<Button
size="M"
secondary
icon="Globe"
disabled={!isPublished}
on:click={viewApp}
dataCy="view-app"
>
View app
</Button>
<Button
size="M"
cta
icon="Edit"
disabled={lockedBy && !lockedByYou}
on:click={() => {
editApp(selectedApp)
}}
>
<span>Edit</span>
</Button>
</ButtonGroup>
</div>
</div>
<Tabs
selected={selectedTab}
noPadding
on:select={e => {
selectedTab = e.detail
}}
>
<Tab title="Overview">
<OverviewTab
app={selectedApp}
deployments={latestDeployments}
navigateTab={handleTabChange}
/>
</Tab>
{#if false}
<Tab title="Automation History">
<div class="container">Automation History contents</div>
</Tab>
<Tab title="Backups">
<div class="container">Backups contents</div>
</Tab>
{/if}
<Tab title="Settings">
<SettingsTab app={selectedApp} />
</Tab>
</Tabs>
{:catch error}
<p>Something went wrong: {error.message}</p>
{/await}
</Layout>
</Page>
<style>
.app-url {
color: var(--spectrum-global-color-gray-600);
}
.loading {
display: flex;
justify-content: center;
}
.overview-header {
display: flex;
justify-content: space-between;
}
.app-title {
display: flex;
gap: var(--spacing-m);
}
.header-right {
display: flex;
align-items: center;
gap: var(--spacing-xl);
}
.app-details :global(.spectrum-Heading) {
line-height: 1em;
margin-bottom: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,11 @@
<script>
//export let app
</script>
<div class="automation-tab" />
<style>
.automation-tab {
color: pink;
}
</style>

View File

@ -0,0 +1,217 @@
<script>
import DashCard from "components/common/DashCard.svelte"
import { AppStatus } from "constants"
import { Icon, Heading, Link, Avatar, notifications } from "@budibase/bbui"
import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json"
import { processStringSync } from "@budibase/string-templates"
import { users, auth } from "stores/portal"
export let app
export let deployments
export let navigateTab
const userInit = async () => {
try {
await users.init()
} catch (error) {
notifications.error("Error getting user list")
}
}
let userPromise = userInit()
$: updateAvailable = clientPackage.version !== $store.version
$: isPublished = app && app?.status === AppStatus.DEPLOYED
$: appEditorId = !app?.updatedBy ? $auth.user._id : app?.updatedBy
$: appEditorText = appEditor?.firstName || appEditor?.email
$: filteredUsers = !appEditorId
? []
: $users.filter(user => user._id === appEditorId)
$: appEditor = filteredUsers.length ? filteredUsers[0] : null
const getInitials = user => {
let initials = ""
initials += user.firstName ? user.firstName[0] : ""
initials += user.lastName ? user.lastName[0] : ""
return initials == "" ? user.email[0] : initials
}
</script>
<div class="overview-tab">
<div class="top">
<DashCard title={"App Status"} dataCy={"app-status"}>
<div class="status-content">
<div class="status-display">
{#if isPublished}
<Icon name="GlobeCheck" size="XL" disabled={false} />
<span>Published</span>
{:else}
<Icon name="GlobeStrike" size="XL" disabled={true} />
<span class="disabled"> Unpublished </span>
{/if}
</div>
<p class="status-text">
{#if deployments?.length}
{processStringSync(
"Last published {{ duration time 'millisecond' }} ago",
{
time:
new Date().getTime() -
new Date(deployments[0].updatedAt).getTime(),
}
)}
{/if}
{#if !deployments?.length}
-
{/if}
</p>
</div>
</DashCard>
<DashCard title={"Last Edited"} dataCy={"edited-by"}>
<div class="last-edited-content">
{#await userPromise}
<Avatar size="M" initials={"-"} />
{:then _}
<div class="updated-by">
{#if appEditor}
<Avatar size="M" initials={getInitials(appEditor)} />
<div class="editor-name">
{appEditor._id === $auth.user._id ? "You" : appEditorText}
</div>
{/if}
</div>
{:catch error}
<p>Could not fetch user: {error.message}</p>
{/await}
<p class="last-edit-text">
{#if app}
{processStringSync(
"Last edited {{ duration time 'millisecond' }} ago",
{
time: new Date().getTime() - new Date(app?.updatedAt).getTime(),
}
)}
{/if}
</p>
</div>
</DashCard>
<DashCard
title={"App Version"}
showIcon={true}
action={() => {
navigateTab("Settings")
}}
dataCy={"app-version"}
>
<div class="version-content" data-cy={$store.version}>
<Heading size="XS">{$store.version}</Heading>
{#if updateAvailable}
<p class="version-status">
New version <strong>{clientPackage.version}</strong> is available -
<Link
on:click={() => {
if (typeof navigateTab === "function") {
navigateTab("Settings")
}
}}
>
Update
</Link>
</p>
{:else}
<p class="version-status">You're running the latest!</p>
{/if}
</div>
</DashCard>
</div>
{#if false}
<div class="bottom">
<DashCard
title={"Automation History"}
action={() => {
navigateTab("Automation History")
}}
dataCy={"automation-history"}
>
<div class="automation-content">
<div class="automation-metrics">
<div class="succeeded">
<Heading size="XL">0</Heading>
<div class="metric-info">
<Icon name="CheckmarkCircle" />
Success
</div>
</div>
<div class="failed">
<Heading size="XL">0</Heading>
<div class="metric-info">
<Icon name="Alert" />
Error
</div>
</div>
</div>
</div>
</DashCard>
<DashCard
title={"Backups"}
action={() => {
navigateTab("Backups")
}}
dataCy={"backups"}
>
<div class="backups-content">test</div>
</DashCard>
</div>
{/if}
</div>
<style>
/* Add in size checks */
.overview-tab {
display: grid;
grid-gap: var(--spacing-xl);
}
.overview-tab .top {
display: grid;
grid-gap: var(--spacing-xl);
grid-template-columns: repeat(auto-fill, minmax(30%, 1fr));
}
.overview-tab .bottom,
.automation-metrics {
display: grid;
grid-gap: var(--spacing-xl);
grid-template-columns: 1fr 1fr;
}
.status-display {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.status-text,
.last-edit-text {
color: var(--spectrum-global-color-gray-600);
}
.updated-by {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.succeeded :global(.icon) {
color: var(--spectrum-global-color-green-600);
}
.failed :global(.icon) {
color: var(
--spectrum-semantic-negative-color-default,
var(--spectrum-global-color-red-500)
);
}
.metric-info {
display: flex;
gap: var(--spacing-l);
margin-top: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,133 @@
<script>
import {
Layout,
Divider,
Heading,
Body,
Page,
Button,
Modal,
} from "@budibase/bbui"
import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json"
import VersionModal from "components/deploy/VersionModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { AppStatus } from "constants"
export let app
let versionModal
let updatingModal
let selfHostPath =
"https://docs.budibase.com/docs/hosting-methods#self-host-budibase"
$: updateAvailable = clientPackage.version !== $store.version
$: appUrl = `${window.origin}/app${app?.url}`
$: appDeployed = app.status === AppStatus.DEPLOYED
</script>
<div class="settings-tab">
<Page wide={false}>
<Layout gap="XL" noPadding>
<span class="details-section">
<Layout gap="XS" noPadding>
<Heading size="S">Name and Url</Heading>
<Divider />
<Body>
<div class="app-details">
<div class="app-name">
<div class="name-title detail-title">Name</div>
<div class="name">{app?.name}</div>
</div>
<div class="app-url">
<div class="url-title detail-title">Url Path</div>
<div class="url">{appUrl}</div>
</div>
</div>
<div class="page-action">
<Button
cta
secondary
on:click={() => {
updatingModal.show()
}}
disabled={appDeployed}
>
Edit
</Button>
</div>
</Body>
</Layout>
</span>
<span class="version-section">
<Layout gap="XS" noPadding>
<Heading size="S">App Version</Heading>
<Divider />
<Body>
{#if updateAvailable}
<p class="version-status">
The app is currently using version
<strong>{$store.version}</strong>
but version <strong>{clientPackage.version}</strong> is available.
</p>
{:else}
<p class="version-status">
The app is currently using version
<strong>{$store.version}</strong>. You're running the latest!
</p>
{/if}
<p>
Updates can contain new features, performance improvements and bug
fixes.
</p>
<div class="page-action">
<Button cta on:click={versionModal.show()}>Update App</Button>
</div>
</Body>
</Layout>
</span>
<span class="selfhost-section">
<Layout gap="XS" noPadding>
<Heading size="S">Self-host Budibase</Heading>
<Divider />
<Body>
<p>
Self-host Budibase for free to get unlimited apps and more - and
it only takes a few minutes!
</p>
<div class="page-action">
<Button
cta
on:click={() => {
window.open(selfHostPath, "_blank")
}}>Self-host Budibase</Button
>
</div>
</Body>
</Layout>
</span>
</Layout>
<VersionModal bind:this={versionModal} hideIcon={true} />
<Modal bind:this={updatingModal} padding={false} width="600px">
<UpdateAppModal {app} />
</Modal>
</Page>
</div>
<style>
.page-action {
padding-top: var(--spacing-xl);
}
.app-details {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
}
.detail-title {
color: var(--spectrum-global-color-gray-600);
font-size: var(
--spectrum-alias-font-size-default,
var(--spectrum-global-dimension-font-size-100)
);
}
</style>

View File

@ -6,7 +6,7 @@ const {
setDebounce, setDebounce,
} = require("../utilities/redis") } = require("../utilities/redis")
const { doWithDB } = require("@budibase/backend-core/db") const { doWithDB } = require("@budibase/backend-core/db")
const { DocumentTypes } = require("../db/utils") const { DocumentTypes, getGlobalIDFromUserMetadataID } = require("../db/utils")
const { PermissionTypes } = require("@budibase/backend-core/permissions") const { PermissionTypes } = require("@budibase/backend-core/permissions")
const { app: appCache } = require("@budibase/backend-core/cache") const { app: appCache } = require("@budibase/backend-core/cache")
@ -51,6 +51,9 @@ async function updateAppUpdatedAt(ctx) {
await doWithDB(appId, async db => { await doWithDB(appId, async db => {
const metadata = await db.get(DocumentTypes.APP_METADATA) const metadata = await db.get(DocumentTypes.APP_METADATA)
metadata.updatedAt = new Date().toISOString() metadata.updatedAt = new Date().toISOString()
metadata.updatedBy = getGlobalIDFromUserMetadataID(ctx.user.userId)
const response = await db.put(metadata) const response = await db.put(metadata)
metadata._rev = response.rev metadata._rev = response.rev
await appCache.invalidateAppMetadata(appId, metadata) await appCache.invalidateAppMetadata(appId, metadata)
@ -67,7 +70,15 @@ module.exports = async (ctx, permType) => {
} }
const isBuilderApi = permType === PermissionTypes.BUILDER const isBuilderApi = permType === PermissionTypes.BUILDER
const referer = ctx.headers["referer"] const referer = ctx.headers["referer"]
const editingApp = referer ? referer.includes(appId) : false
const overviewPath = "/builder/portal/overview/"
const overviewContext = !referer ? false : referer.includes(overviewPath)
if (overviewContext) {
return
}
const hasAppId = !referer ? false : referer.includes(appId)
const editingApp = referer ? hasAppId : false
// check this is a builder call and editing // check this is a builder call and editing
if (!isBuilderApi || !editingApp) { if (!isBuilderApi || !editingApp) {
return return

View File

@ -48,7 +48,9 @@ exports.updateLock = async (devAppId, user) => {
...user, ...user,
userId: globalId, userId: globalId,
_id: globalId, _id: globalId,
lockedAt: new Date().getTime(),
} }
await devAppClient.store(devAppId, inputUser, APP_DEV_LOCK_SECONDS) await devAppClient.store(devAppId, inputUser, APP_DEV_LOCK_SECONDS)
} }