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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,49 @@
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { get } from "builderStore/api" import { get } from "builderStore/api"
import { AppStatus } from "../../constants"
export function createAppStore() { export function createAppStore() {
const store = writable([]) const store = writable([])
async function load(status = "") { async function load() {
try { try {
const res = await get(`/api/applications?status=${status}`) const res = await get(`/api/applications?status=all`)
const json = await res.json() const json = await res.json()
if (res.ok && Array.isArray(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 { } else {
store.set([]) store.set([])
} }

View File

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