Update app list screen to show unified app list with publish status
This commit is contained in:
parent
d30a9ef494
commit
f63f9a7c51
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" },
|
||||
{
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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([])
|
||||
}
|
||||
|
|
|
@ -18,9 +18,9 @@ const StaticDatabases = {
|
|||
}
|
||||
|
||||
const AppStatus = {
|
||||
DEV: "dev",
|
||||
DEV: "development",
|
||||
ALL: "all",
|
||||
DEPLOYED: "PUBLISHED",
|
||||
DEPLOYED: "published",
|
||||
}
|
||||
|
||||
const DocumentTypes = {
|
||||
|
|
Loading…
Reference in New Issue