Update app list screen to show unified app list with publish status

This commit is contained in:
Andrew Kingston 2021-05-21 10:32:16 +01:00
parent d30a9ef494
commit f63f9a7c51
7 changed files with 249 additions and 109 deletions

View File

@ -6,51 +6,65 @@
Layout,
ActionMenu,
MenuItem,
StatusLight,
} from "@budibase/bbui"
import { gradient } from "actions"
import { auth } from "stores/portal"
import { AppStatus } from "constants"
export let app
export let exportApp
export let openApp
export let viewApp
export let editApp
export let deleteApp
export let unpublishApp
export let releaseLock
export let deletable
</script>
<div class="wrapper">
<Layout noPadding gap="XS" alignContent="start">
<div class="preview" use:gradient={{ seed: app.name }} />
<div class="title">
<div class="name" on:click={() => openApp(app)}>
{#if app.lockedBy}
<Icon name="LockClosed" />
{/if}
<div class="name" on:click={() => editApp(app)}>
<Heading size="XS">
{app.name}
</Heading>
</div>
<ActionMenu align="right">
<Icon slot="control" name="More" hoverable />
<MenuItem on:click={() => exportApp(app)} icon="Download">
Export
{#if app.deployed}
<MenuItem on:click={() => viewApp(app)} icon="GlobeOutline">
View published app
</MenuItem>
{#if deletable}
<MenuItem on:click={() => unpublishApp(app)} icon="GlobeRemove">
Unpublish
</MenuItem>
{/if}
{#if app.lockedBy && app.lockedBy?.email === $auth.user?.email}
<MenuItem on:click={() => releaseLock(app)} icon="LockOpen">
Release lock
</MenuItem>
{/if}
{#if !app.deployed}
<MenuItem on:click={() => deleteApp(app)} icon="Delete">
Delete
</MenuItem>
{/if}
{#if app.lockedBy && app.lockedBy?.email === $auth.user?.email}
<MenuItem on:click={() => releaseLock(app.appId)} icon="LockOpen">
Release Lock
<MenuItem on:click={() => exportApp(app)} icon="Download">
Export
</MenuItem>
{/if}
</ActionMenu>
</div>
<div class="status">
<Body size="S">
Edited {Math.floor(1 + Math.random() * 10)} months ago
Updated {Math.floor(1 + Math.random() * 10)} months ago
</Body>
{#if app.lockedBy}
<Icon name="LockClosed" />
{/if}
<StatusLight active={app.deployed} neutral={!app.deployed}>
{#if app.deployed}Published{:else}Unpublished{/if}
</StatusLight>
</div>
</Layout>
</div>
@ -59,13 +73,17 @@
.wrapper {
overflow: hidden;
}
.wrapper :global(.spectrum-StatusLight) {
padding: 0;
min-height: 0;
}
.preview {
height: 135px;
border-radius: var(--border-radius-s);
margin-bottom: var(--spacing-s);
margin-bottom: var(--spacing-m);
}
.title,
.status {
display: flex;
flex-direction: row;
@ -74,12 +92,18 @@
}
.name {
text-decoration: none;
flex: 1 1 auto;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
margin-right: var(--spacing-m);
}
.title {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-m);
}
.title :global(.spectrum-Icon) {
flex: 0 0 auto;
}
.title :global(h1) {
overflow: hidden;

View File

@ -1,55 +1,82 @@
<script>
import { gradient } from "actions"
import { Heading, Button, Icon, ActionMenu, MenuItem } from "@budibase/bbui"
import {
Heading,
Button,
Icon,
ActionMenu,
MenuItem,
StatusLight,
} from "@budibase/bbui"
import { auth } from "stores/portal"
export let app
export let openApp
export let exportApp
export let viewApp
export let editApp
export let deleteApp
export let unpublishApp
export let releaseLock
export let last
export let deletable
</script>
<div class="title" class:last>
<div class="preview" use:gradient={{ seed: app.name }} />
<div class="name" on:click={() => openApp(app)}>
<div class="name" on:click={() => editApp(app)}>
<Heading size="XS">
{app.name}
</Heading>
</div>
</div>
<div class:last>
Edited {Math.round(Math.random() * 10 + 1)} months ago
Updated {Math.round(Math.random() * 10 + 1)} months ago
</div>
<div class:last>
{#if app.lockedBy}
{#if app.lockedBy.email === $auth.user.email}
<div class="status status--locked-you" />
<StatusLight
positive={!app.lockedYou && !app.lockedOther}
notice={app.lockedYou}
negative={app.lockedOther}
>
{#if app.lockedYou}
Locked by you
{:else}
<div class="status status--locked-other" />
{:else if app.lockedOther}
Locked by {app.lockedBy.email}
{/if}
{:else}
<div class="status status--open" />
Open
{/if}
</StatusLight>
</div>
<div class:last>
<Button on:click={() => openApp(app)} size="S" secondary>Open</Button>
<StatusLight active={app.deployed} neutral={!app.deployed}>
{#if app.deployed}Published{:else}Unpublished{/if}
</StatusLight>
</div>
<div class:last>
<Button
disabled={app.lockedOther}
on:click={() => editApp(app)}
size="S"
secondary>Open</Button
>
<ActionMenu align="right">
<Icon hoverable slot="control" name="More" />
<MenuItem on:click={() => exportApp(app)} icon="Download">Export</MenuItem>
{#if deletable}
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem>
{/if}
{#if app.lockedBy && app.lockedBy?.email === $auth.user?.email}
<MenuItem on:click={() => releaseLock(app.appId)} icon="LockOpen">
Release Lock
{#if app.deployed}
<MenuItem on:click={() => viewApp(app)} icon="GlobeOutline">
View published app
</MenuItem>
<MenuItem on:click={() => unpublishApp(app)} icon="GlobeRemove">
Unpublish
</MenuItem>
{/if}
{#if app.lockedBy && app.lockedBy?.email === $auth.user?.email}
<MenuItem on:click={() => releaseLock(app)} icon="LockOpen">
Release lock
</MenuItem>
{/if}
{#if !app.deployed}
<MenuItem on:click={() => deleteApp(app)} icon="Delete">Delete</MenuItem>
{/if}
<MenuItem on:click={() => exportApp(app)} icon="Download">Export</MenuItem>
</ActionMenu>
</div>
@ -61,24 +88,16 @@
}
.name {
text-decoration: none;
overflow: hidden;
}
.name :global(.spectrum-Heading) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.title :global(h1:hover) {
color: var(--spectrum-global-color-blue-600);
cursor: pointer;
transition: color 130ms ease;
}
.status {
height: 10px;
width: 10px;
border-radius: 50%;
}
.status--locked-you {
background-color: var(--spectrum-global-color-orange-600);
}
.status--locked-other {
background-color: var(--spectrum-global-color-red-600);
}
.status--open {
background-color: var(--spectrum-global-color-green-600);
}
</style>

View File

@ -10,8 +10,9 @@ export const FrontendTypes = {
}
export const AppStatus = {
DEV: "dev",
PUBLISHED: "published",
ALL: "all",
DEV: "development",
DEPLOYED: "published",
}
// fields on the user table that cannot be edited

View File

@ -21,9 +21,7 @@
const menu = [
{ title: "Apps", href: "/builder/portal/apps" },
{ title: "Drafts", href: "/builder/portal/drafts" },
{ title: "Users", href: "/builder/portal/manage/users", heading: "Manage" },
{ title: "Groups", href: "/builder/portal/manage/groups" },
{ title: "Auth", href: "/builder/portal/manage/auth" },
{ title: "Email", href: "/builder/portal/manage/email" },
{

View File

@ -26,15 +26,39 @@
import { AppStatus } from "constants"
let layout = "grid"
let appStatus = AppStatus.PUBLISHED
let sortBy = "name"
let template
let appToDelete
let selectedApp
let creationModal
let deletionModal
let unpublishModal
let creatingApp = false
let loaded = false
$: appStatus && apps.load(appStatus)
$: enrichedApps = enrichApps($apps, $auth.user, sortBy)
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
...app,
deployed: app.status === AppStatus.DEPLOYED,
lockedYou: app.lockedBy?.email === user.email,
lockedOther: app.lockedBy && app.lockedBy.email !== user.email,
}))
if (sortBy === "status") {
return enrichedApps.sort((a, b) => {
if (a.status === b.status) {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
}
return a.status === AppStatus.DEPLOYED ? -1 : 1
})
} else if (sortBy === "name") {
return enrichedApps.sort((a, b) => {
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
})
} else {
return enrichedApps
}
}
const checkKeys = async () => {
const response = await api.get(`/api/keys/`)
@ -60,19 +84,19 @@
creatingApp = false
}
const openApp = app => {
if (app.lockedBy && app.lockedBy?.email !== $auth.user?.email) {
const viewApp = app => {
const id = app.deployed ? app.prodId : app.devId
window.open(`/${id}`, "_blank")
}
const editApp = app => {
if (app.lockedOther) {
notifications.error(
`App locked by ${app.lockedBy.email}. Please allow lock to expire or have them unlock this app.`
)
return
}
if (appStatus === AppStatus.DEV) {
$goto(`../../app/${app.appId}`)
} else {
window.open(`/${app.appId}`, "_blank")
}
$goto(`../../app/${app.devId}`)
}
const exportApp = app => {
@ -82,36 +106,66 @@
app.name
)}`
)
notifications.success("App export complete")
notifications.success("App exported successfully")
} catch (err) {
console.error(err)
notifications.error("App export failed")
notifications.error(`Error exporting app: ${err}`)
}
}
const unpublishApp = app => {
selectedApp = app
unpublishModal.show()
}
const confirmUnpublishApp = async () => {
if (!selectedApp) {
return
}
try {
const response = await del(`/api/applications/${selectedApp.prodId}`)
if (response.status !== 200) {
const json = await response.json()
throw json.message
}
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error(`Error unpublishing app: ${err}`)
}
}
const deleteApp = app => {
appToDelete = app
selectedApp = app
deletionModal.show()
}
const confirmDeleteApp = async () => {
if (!appToDelete) {
if (!selectedApp) {
return
}
await del(`/api/applications/${appToDelete?.appId}`)
try {
const response = await del(`/api/applications/${selectedApp?.devId}`)
if (response.status !== 200) {
const json = await response.json()
throw json.message
}
await apps.load()
appToDelete = null
notifications.success("App deleted successfully.")
notifications.success("App deleted successfully")
} catch (err) {
notifications.error(`Error deleting app: ${err}`)
}
selectedApp = null
}
const releaseLock = async appId => {
const releaseLock = async app => {
try {
const response = await del(`/api/dev/${appId}/lock`)
const response = await del(`/api/dev/${app.devId}/lock`)
if (response.status !== 200) {
const json = await response.json()
if (response.status !== 200) throw json.message
notifications.success("Lock released")
await apps.load(appStatus)
throw json.message
}
await apps.load()
notifications.success("Lock released successfully")
} catch (err) {
notifications.error(`Error releasing lock: ${err}`)
}
@ -119,7 +173,7 @@
onMount(async () => {
checkKeys()
await apps.load(appStatus)
await apps.load()
loaded = true
})
</script>
@ -136,10 +190,12 @@
<div class="filter">
<div class="select">
<Select
bind:value={appStatus}
bind:value={sortBy}
placeholder={null}
options={[
{ label: "Published", value: AppStatus.PUBLISHED },
{ label: "In Development", value: AppStatus.DEV },
{ label: "Sort by name", value: "name" },
{ label: "Sort by recently updated", value: "updated" },
{ label: "Sort by status", value: "status" },
]}
/>
</div>
@ -158,27 +214,28 @@
/>
</ActionGroup>
</div>
{#if loaded && $apps.length}
{#if loaded && enrichedApps.length}
<div
class:appGrid={layout === "grid"}
class:appTable={layout === "table"}
>
{#each $apps as app, idx (app.appId)}
{#each enrichedApps as app, idx (app.appId)}
<svelte:component
this={layout === "grid" ? AppCard : AppRow}
deletable={appStatus === AppStatus.PUBLISHED}
{releaseLock}
{app}
{openApp}
{unpublishApp}
{viewApp}
{editApp}
{exportApp}
{deleteApp}
last={idx === $apps.length - 1}
last={idx === enrichedApps.length - 1}
/>
{/each}
</div>
{/if}
</Layout>
{#if !$apps.length && !creatingApp && loaded}
{#if !enrichedApps.length && !creatingApp && loaded}
<div class="empty-wrapper">
<Modal inline>
<ModalContent
@ -215,7 +272,15 @@
okText="Delete app"
onOk={confirmDeleteApp}
>
Are you sure you want to delete the app <b>{appToDelete?.name}</b>?
Are you sure you want to delete the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<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>
<style>
@ -228,7 +293,7 @@
}
.select {
width: 150px;
width: 190px;
}
.appGrid {
@ -239,7 +304,7 @@
.appTable {
display: grid;
grid-template-rows: auto;
grid-template-columns: 1fr 1fr 1fr auto;
grid-template-columns: 1fr 1fr 1fr 1fr auto;
align-items: center;
}
.appTable :global(> div) {
@ -256,7 +321,6 @@
.appTable :global(> div:not(.last)) {
border-bottom: var(--border-light);
}
.empty-wrapper {
flex: 1 1 auto;
height: 100%;

View File

@ -1,15 +1,49 @@
import { writable } from "svelte/store"
import { get } from "builderStore/api"
import { AppStatus } from "../../constants"
export function createAppStore() {
const store = writable([])
async function load(status = "") {
async function load() {
try {
const res = await get(`/api/applications?status=${status}`)
const res = await get(`/api/applications?status=all`)
const json = await res.json()
if (res.ok && Array.isArray(json)) {
store.set(json)
// Merge apps into one sensible list
let appMap = {}
let devApps = json.filter(app => app.status === AppStatus.DEV)
let deployedApps = json.filter(app => app.status === AppStatus.DEPLOYED)
// First append all dev app version
devApps.forEach(app => {
const id = app.appId.substring(8)
appMap[id] = {
...app,
devId: app.appId,
devRev: app._rev,
}
})
// Then merge with all prod app versions
deployedApps.forEach(app => {
const id = app.appId.substring(4)
appMap[id] = {
...appMap[id],
...app,
prodId: app.appId,
prodRev: app._rev,
}
})
// Transform into an array and clean up
const apps = Object.values(appMap)
apps.forEach(app => {
app.appId = app.devId.substring(8)
delete app._id
delete app._rev
})
store.set(apps)
} else {
store.set([])
}

View File

@ -18,9 +18,9 @@ const StaticDatabases = {
}
const AppStatus = {
DEV: "dev",
DEV: "development",
ALL: "all",
DEPLOYED: "PUBLISHED",
DEPLOYED: "published",
}
const DocumentTypes = {