1
0
Fork 0
mirror of synced 2024-09-08 21:51:58 +12:00

Add initial rewrite of app overview section

This commit is contained in:
Andrew Kingston 2022-12-16 15:54:34 +00:00
parent d4da8d83fb
commit 8832864eec
17 changed files with 646 additions and 741 deletions

View file

@ -28,6 +28,7 @@
class:spectrum-Button--quiet={quiet}
class:new-styles={newStyles}
class:active
class:disabled
class="spectrum-Button spectrum-Button--size{size.toUpperCase()}"
{disabled}
data-cy={dataCy}
@ -108,7 +109,10 @@
border-color: transparent;
color: var(--spectrum-global-color-gray-900);
}
.spectrum-Button--secondary.new-styles:hover {
.spectrum-Button--secondary.new-styles:not(.disabled):hover {
background: var(--spectrum-global-color-gray-300);
}
.spectrum-Button--secondary.new-styles.disabled {
color: var(--spectrum-global-color-gray-500);
}
</style>

View file

@ -57,8 +57,8 @@
}
</script>
<div class="lock-status">
{#if lockedBy}
{#if lockedBy}
<div class="lock-status">
<Icon
name="LockClosed"
hoverable
@ -67,70 +67,66 @@
appLockModal.show()
}}
/>
{/if}
</div>
{#key app}
<div>
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
dataCy={"app-lock-modal"}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work from being lost from overlapping
changes between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now. This lock will expire in This lock will expire in ",
{
time: getExpiryDuration(app),
}
)}
</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>
</Layout>
</ModalContent>
</Modal>
</div>
{/key}
{/if}
<Modal bind:this={appLockModal}>
<ModalContent
title={lockedByHeading}
dataCy={"app-lock-modal"}
showConfirmButton={false}
showCancelButton={false}
>
<Layout noPadding>
<Body size="S">
Apps are locked to prevent work being lost from overlapping changes
between your team.
</Body>
{#if lockedByYou && getExpiryDuration(app) > 0}
<span class="lock-expiry-body">
{processStringSync(
"This lock will expire in {{ duration time 'millisecond' }} from now.",
{
time: getExpiryDuration(app),
}
)}
</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
cta
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>
</Layout>
</ModalContent>
</Modal>
<style>
.lock-modal-actions {

View file

@ -5,6 +5,7 @@
export let name
export let size
export let app
export let color
let iconModal
</script>
@ -19,7 +20,7 @@
<Icon name={"Edit"} size={"L"} />
</div>
<div class="app-icon">
<Icon {name} {size} />
<Icon {name} {size} {color} />
</div>
</div>
<ChooseIconModal {app} bind:this={iconModal} />

View file

@ -5,6 +5,7 @@
</script>
<div class="header">
<slot name="icon" />
<Heading size="L">{title}</Heading>
<div class="buttons">
<slot name="buttons" />
@ -15,8 +16,13 @@
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
}
.header :global(.spectrum-Heading) {
flex: 1 1 auto;
margin-top: -2px;
}
.buttons {
display: flex;

View file

@ -138,6 +138,7 @@
}
$goto(`/builder/app/${createdApp.instance._id}`)
// apps.load()
} catch (error) {
creating = false
console.error(error)

View file

@ -0,0 +1,238 @@
<script>
import { url, isActive, params, goto } from "@roxi/routify"
import {
Page,
Layout,
Button,
Icon,
ActionMenu,
MenuItem,
Helpers,
Input,
Modal,
notifications,
} from "@budibase/bbui"
import {
Content,
SideNav,
SideNavItem,
Breadcrumbs,
Breadcrumb,
Header,
} from "components/portal/page"
import { apps, auth, groups, overview } from "stores/portal"
import { AppStatus } from "constants"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import AppLockModal from "components/common/AppLockModal.svelte"
import EditableIcon from "components/common/EditableIcon.svelte"
import { API } from "api"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import ExportAppModal from "components/start/ExportAppModal.svelte"
import { syncURLToState } from "../../../../../helpers/urlStateSync"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
// Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({
urlParam: "appId",
stateKey: "selectedAppId",
validate: id => $apps.some(app => app.devId === id),
fallbackUrl: "../../",
store: overview,
routify,
})
let exportModal
let deletionModal
let exportPublishedVersion = false
let deletionConfirmationAppName
$: app = $overview.selectedApp
$: appId = $overview.selectedAppId
$: initialiseApp(appId)
$: isPublished = app?.status === AppStatus.DEPLOYED
$: appLocked = !!app?.lockedBy
$: lockedByYou = $auth.user.email === app?.lockedBy?.email
const initialiseApp = async appId => {
try {
const pkg = await API.fetchAppPackage(appId)
await store.actions.initialise(pkg)
await API.syncApp(appId)
} catch (error) {
notifications.error("Error initialising app overview")
$goto("../../")
}
}
const viewApp = () => {
if (isPublished) {
analytics.captureEvent(Events.APP_VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(`/app${app?.url}`, "_blank")
}
}
const editApp = () => {
if (appLocked && !lockedByYou) {
const identifier = app?.lockedBy?.firstName || app?.lockedBy?.email
notifications.warning(
`App locked by ${identifier}. Please allow lock to expire or have them unlock this app.`
)
return
}
$goto(`../../../app/${app.devId}`)
}
const exportApp = opts => {
exportPublishedVersion = !!opts?.published
exportModal.show()
}
const copyAppId = async () => {
await Helpers.copyToClipboard(app.prodId)
notifications.success("App ID copied to clipboard")
}
const deleteApp = async () => {
try {
await API.deleteApp(app?.devId)
notifications.success("App deleted successfully")
$goto("../")
} catch (err) {
notifications.error("Error deleting app")
}
}
onDestroy(() => {
stopSyncing()
store.actions.reset()
})
</script>
{#key appId}
<Page>
<Layout noPadding gap="L">
<Breadcrumbs>
<Breadcrumb url={$url("../")} text="Apps" />
<Breadcrumb text={app?.name} />
</Breadcrumbs>
<Header title={app?.name}>
<div slot="icon">
<EditableIcon
{app}
size="XL"
name={app?.icon?.name || "Apps"}
color={app?.icon?.color}
/>
</div>
<div slot="buttons">
<AppLockModal {app} />
<Button
size="M"
quiet
secondary
disabled={!isPublished}
on:click={viewApp}
dataCy="view-app"
>
View
</Button>
<Button
size="M"
cta
disabled={appLocked && !lockedByYou}
on:click={editApp}
>
Edit
</Button>
<ActionMenu align="right" dataCy="app-overview-menu-popover">
<span slot="control" class="app-overview-actions-icon">
<Icon hoverable name="More" />
</span>
<MenuItem
on:click={() => exportApp({ published: false })}
icon="DownloadFromCloud"
>
Export latest
</MenuItem>
{#if isPublished}
<MenuItem
on:click={() => exportApp({ published: true })}
icon="DownloadFromCloudOutline"
>
Export published
</MenuItem>
<MenuItem on:click={copyAppId} icon="Copy">Copy app ID</MenuItem>
{/if}
{#if !isPublished}
<MenuItem on:click={deletionModal.show} icon="Delete">
Delete
</MenuItem>
{/if}
</ActionMenu>
</div>
</Header>
<Content>
<SideNav slot="side-nav">
<SideNavItem
text="Overview"
url={$url("./overview")}
active={$isActive("./overview")}
/>
<SideNavItem
text="Access"
url={$url("./access")}
active={$isActive("./access")}
/>
<SideNavItem
text="Automation History"
url={$url("./automation-history")}
active={$isActive("./automation-history")}
/>
<SideNavItem
text="Backups"
url={$url("./backups")}
active={$isActive("./backups")}
/>
<SideNavItem
text="Name and URL"
url={$url("./name-and-url")}
active={$isActive("./name-and-url")}
/>
<SideNavItem
text="Version"
url={$url("./version")}
active={$isActive("./version")}
/>
</SideNav>
<slot />
</Content>
</Layout>
</Page>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<ConfirmDialog
bind:this={deletionModal}
title="Delete app"
okText="Delete"
onOk={deleteApp}
onCancel={() => (deletionConfirmationAppName = null)}
disabled={deletionConfirmationAppName !== app?.name}
>
Are you sure you want to delete <b>{app?.name}</b>?
<br />
Please enter the app name below to confirm.
<br /><br />
<Input
bind:value={deletionConfirmationAppName}
data-cy="delete-app-confirmation"
placeholder={app?.name}
/>
</ConfirmDialog>
{/key}

View file

@ -0,0 +1,207 @@
<script>
import {
Layout,
Heading,
Body,
Button,
List,
ListItem,
Modal,
notifications,
Pagination,
Icon,
} from "@budibase/bbui"
import { onMount } from "svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import { users, groups, apps, licensing, overview } from "stores/portal"
import AssignmentModal from "./_components/AssignmentModal.svelte"
import { roles } from "stores/backend"
import { API } from "api"
import { fetchData } from "@budibase/frontend-core"
let assignmentModal
let appGroups
let appUsers
$: app = $overview.selectedApp
$: devAppId = app.devId
$: prodAppId = apps.getProdAppID(app.devId)
$: usersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
appId: apps.getProdAppID(devAppId),
},
},
})
$: appUsers = $usersFetch.rows
$: appGroups = $groups.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(prodAppId)
})
async function removeUser(user) {
// Remove the user role
const filteredRoles = { ...user.roles }
delete filteredRoles[prodAppId]
await users.save({
...user,
roles: {
...filteredRoles,
},
})
await usersFetch.refresh()
}
async function removeGroup(group) {
await groups.actions.removeApp(group._id, prodAppId)
await groups.actions.init()
await usersFetch.refresh()
}
async function updateUserRole(role, user) {
user.roles[prodAppId] = role
await users.save(user)
}
async function updateGroupRole(role, group) {
await groups.actions.addApp(group._id, prodAppId, role)
await usersFetch.refresh()
}
onMount(async () => {
try {
await roles.fetch()
} catch (error) {
notifications.error(error)
}
})
</script>
<Layout noPadding>
{#if appGroups.length || appUsers.length}
<div>
<Heading>Access</Heading>
<div class="subtitle">
<Body size="S">
Assign users and groups to your app and define their access here
</Body>
<Button on:click={assignmentModal.show} icon="User" cta>
Assign access
</Button>
</div>
</div>
{#if $licensing.groupsEnabled && appGroups.length}
<List title="User Groups">
{#each appGroups as group}
<ListItem
title={group.name}
icon={group.icon}
iconBackground={group.color}
>
<RoleSelect
on:change={e => updateGroupRole(e.detail, group)}
autoWidth
quiet
value={group.roles[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
]}
allowPublic={false}
/>
<Icon
on:click={() => removeGroup(group)}
hoverable
size="S"
name="Close"
/>
</ListItem>
{/each}
</List>
{/if}
{#if appUsers.length}
<div>
<List title="Users">
{#each appUsers as user}
<ListItem title={user.email} avatar>
<RoleSelect
on:change={e => updateUserRole(e.detail, user)}
autoWidth
quiet
value={user.roles[
Object.keys(user.roles).find(x => x === prodAppId)
]}
allowPublic={false}
/>
<Icon
on:click={() => removeUser(user)}
hoverable
size="S"
name="Close"
/>
</ListItem>
{/each}
</List>
<div class="pagination">
<Pagination
page={$usersFetch.pageNumber + 1}
hasPrevPage={$usersFetch.hasPrevPage}
hasNextPage={$usersFetch.hasNextPage}
goToPrevPage={$usersFetch.loading ? null : usersFetch.prevPage}
goToNextPage={$usersFetch.loading ? null : usersFetch.nextPage}
/>
</div>
</div>
{/if}
{:else}
<div class="align">
<Layout gap="S">
<Heading>No users assigned</Heading>
<div class="opacity">
<Body size="S">
Assign users/groups to your app and set their access here
</Body>
</div>
<div class="padding">
<Button on:click={() => assignmentModal.show()} cta icon="UserArrow">
Assign access
</Button>
</div>
</Layout>
</div>
{/if}
</Layout>
<Modal bind:this={assignmentModal}>
<AssignmentModal {app} {appUsers} on:update={usersFetch.refresh} />
</Modal>
<style>
.padding {
margin-top: var(--spacing-m);
}
.opacity {
opacity: 0.8;
}
.align {
text-align: center;
}
.subtitle {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: var(--spacing-xl);
}
</style>

View file

@ -0,0 +1,4 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("./overview")
</script>

View file

@ -1,47 +1,54 @@
<script>
import { onMount } from "svelte"
import DashCard from "components/common/DashCard.svelte"
import { AppStatus } from "constants"
import { Icon, Heading, Link, Avatar, Layout, Body } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import {
Icon,
Heading,
Link,
Avatar,
Layout,
Body,
notifications,
} from "@budibase/bbui"
import { store } from "builderStore"
import clientPackage from "@budibase/client/package.json"
import { processStringSync } from "@budibase/string-templates"
import { users, auth, apps, groups } from "stores/portal"
import { users, auth, apps, groups, overview } from "stores/portal"
import { createEventDispatcher } from "svelte"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
import GroupIcon from "../../users/groups/_components/GroupIcon.svelte"
export let app
export let deployments
export let navigateTab
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
const dispatch = createEventDispatcher()
const appUsersFetch = fetchData({
let appEditor
let unpublishModal
let deployments
$: app = $overview.selectedApp
$: devAppId = app.devId
$: prodAppId = apps.getProdAppID(devAppId)
$: appUsersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
appId: apps.getProdAppID(app.devId),
appId: apps.getProdAppID(devAppId),
},
},
})
let appEditor
$: updateAvailable = clientPackage.version !== $store.version
$: isPublished = app?.status === AppStatus.DEPLOYED
$: appEditorId = !app?.updatedBy ? $auth.user._id : app?.updatedBy
$: appEditorText = appEditor?.firstName || appEditor?.email
$: fetchAppEditor(appEditorId)
$: appUsers = $appUsersFetch.rows || []
$: appUsersFetch.update({
query: {
appId: apps.getProdAppID(app.devId),
},
})
$: prodAppId = apps.getProdAppID(app.devId)
$: appGroups = $groups.filter(group => {
if (!group.roles) {
return false
@ -49,10 +56,6 @@
return groups.actions.getGroupAppIds(group).includes(prodAppId)
})
const unpublishApp = () => {
dispatch("unpublish", app)
}
async function fetchAppEditor(editorId) {
appEditor = await users.get(editorId)
}
@ -64,10 +67,45 @@
return initials === "" ? user.email[0] : initials
}
const confirmUnpublishApp = async () => {
try {
await API.unpublishApp(app.prodId)
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error("Error unpublishing app")
}
}
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) {
console.log(err)
notifications.error("Error fetching deployment history")
}
}
onMount(async () => {
deployments = await fetchDeployments()
})
</script>
<div class="overview-tab">
<Layout paddingX="XXL" paddingY="XXL" gap="XL">
<Layout noPadding gap="XL">
<div class="top">
<DashCard title={"App Status"} dataCy={"app-status"}>
<div class="status-content">
@ -77,7 +115,7 @@
<span>Published</span>
{:else}
<Icon name="GlobeStrike" size="XL" disabled={true} />
<span class="disabled"> Unpublished </span>
<span class="disabled">Unpublished</span>
{/if}
</div>
@ -92,7 +130,7 @@
}
)}
{#if isPublished}
- <Link on:click={unpublishApp}>Unpublish</Link>
- <Link on:click={unpublishModal.show}>Unpublish</Link>
{/if}
{/if}
{#if !deployments?.length}
@ -127,10 +165,10 @@
</DashCard>
{/if}
<DashCard
title={"App Version"}
title={"Version"}
showIcon={true}
action={() => {
navigateTab("Settings")
$goto("../version")
}}
dataCy={"app-version"}
>
@ -142,9 +180,7 @@
-
<Link
on:click={() => {
if (typeof navigateTab === "function") {
navigateTab("Settings")
}
$goto("../version")
}}
>
Update
@ -160,7 +196,7 @@
title={"Access"}
showIcon={true}
action={() => {
navigateTab("Access")
$goto("../access")
}}
dataCy={"access"}
>
@ -211,7 +247,7 @@
<DashCard
title={"Automation History"}
action={() => {
navigateTab("Automation History")
$goto("../automation-history")
}}
dataCy={"automation-history"}
>
@ -237,7 +273,7 @@
<DashCard
title={"Backups"}
action={() => {
navigateTab("Backups")
$goto("../backups")
}}
dataCy={"backups"}
>
@ -248,6 +284,16 @@
</Layout>
</div>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
dataCy={"unpublish-modal"}
>
Are you sure you want to unpublish the app <b>{app?.name}</b>?
</ConfirmDialog>
<style>
.overview-tab {
display: grid;
@ -256,7 +302,7 @@
.overview-tab .top {
display: grid;
grid-gap: var(--spectrum-alias-grid-gutter-medium);
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
}
.access-tab-content {

View file

@ -1,417 +0,0 @@
<script>
import { goto } from "@roxi/routify"
import {
Layout,
Page,
Button,
ActionButton,
ButtonGroup,
Heading,
Tab,
Tabs,
notifications,
ProgressCircle,
Input,
ActionMenu,
MenuItem,
Icon,
Helpers,
Modal,
} from "@budibase/bbui"
import OverviewTab from "../_components/OverviewTab.svelte"
import SettingsTab from "../_components/SettingsTab.svelte"
import AccessTab from "../_components/AccessTab.svelte"
import { API } from "api"
import { store } from "builderStore"
import { apps, auth, groups } 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 ConfirmDialog from "components/common/ConfirmDialog.svelte"
import HistoryTab from "components/portal/overview/automation/HistoryTab.svelte"
import ExportAppModal from "components/start/ExportAppModal.svelte"
import { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { onDestroy, onMount } from "svelte"
import BackupsTab from "components/portal/overview/backups/BackupsTab.svelte"
export let application
let loaded = false
let deletionModal
let unpublishModal
let exportModal
let appName = ""
let deployments = []
let published
// App
$: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: loaded && !selectedApp && backToAppList()
$: isPublished =
selectedApp?.status === AppStatus.DEPLOYED && latestDeployments?.length > 0
$: appUrl = `${window.origin}/app${selectedApp?.url}`
// Locking
$: lockedBy = selectedApp?.lockedBy
$: lockedByYou = $auth.user.email === lockedBy?.email
$: lockIdentifer = `${
lockedBy && Object.prototype.hasOwnProperty.call(lockedBy, "firstName")
? lockedBy?.firstName
: lockedBy?.email
}`
// App deployments
$: latestDeployments = deployments
.filter(x => x.status === "SUCCESS" && application === x.appId)
.sort((a, b) => a.updatedAt > b.updatedAt)
// Tabs
$: tabs = ["Overview", "Automation History", "Backups", "Settings", "Access"]
$: 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")
}
}
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}`)
}
const copyAppId = async app => {
await Helpers.copyToClipboard(app.prodId)
notifications.success("App ID copied to clipboard.")
}
const exportApp = opts => {
published = opts.published
exportModal.show()
}
const unpublishApp = app => {
selectedApp = app
unpublishModal.show()
}
const confirmUnpublishApp = async () => {
if (!selectedApp) {
return
}
try {
await API.unpublishApp(selectedApp.prodId)
await apps.load()
notifications.success("App unpublished successfully")
} catch (err) {
notifications.error("Error unpublishing app")
}
}
const deleteApp = app => {
selectedApp = app
deletionModal.show()
}
const confirmDeleteApp = async () => {
if (!selectedApp) {
return
}
try {
await API.deleteApp(selectedApp?.devId)
backToAppList()
notifications.success("App deleted successfully")
} catch (err) {
notifications.error("Error deleting app")
}
selectedApp = null
appName = null
}
onMount(async () => {
const params = new URLSearchParams(window.location.search)
if (params.get("tab")) {
selectedTab = params.get("tab")
}
// Check app exists
try {
const pkg = await API.fetchAppPackage(application)
await store.actions.initialise(pkg)
} catch (error) {
// Swallow
backToAppList()
}
// Initialise application
try {
await API.syncApp(application)
deployments = await fetchDeployments()
await groups.actions.init()
if (!apps.length) {
await apps.load()
}
} catch (error) {
notifications.error("Error initialising app overview")
}
loaded = true
})
onDestroy(() => {
store.actions.reset()
})
</script>
<Modal bind:this={exportModal} padding={false} width="600px">
<ExportAppModal app={selectedApp} {published} />
</Modal>
<span class="overview-wrap">
<Page wide noPadding>
{#if !loaded || !selectedApp}
<div class="loading">
<ProgressCircle size="XL" />
</div>
{:else}
<Layout paddingX="XXL" paddingY="XL" gap="L">
<span class="page-header" class:loaded>
<ActionButton secondary icon={"ArrowLeft"} on:click={backToAppList}>
Back
</ActionButton>
</span>
<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"
quiet
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>
<ActionMenu align="right" dataCy="app-overview-menu-popover">
<span slot="control" class="app-overview-actions-icon">
<Icon hoverable name="More" />
</span>
<MenuItem
on:click={() => exportApp({ published: false })}
icon="DownloadFromCloud"
>
Export latest
</MenuItem>
{#if isPublished}
<MenuItem
on:click={() => exportApp({ published: true })}
icon="DownloadFromCloudOutline"
>
Export published
</MenuItem>
<MenuItem on:click={() => copyAppId(selectedApp)} icon="Copy">
Copy app ID
</MenuItem>
{/if}
{#if !isPublished}
<MenuItem on:click={() => deleteApp(selectedApp)} icon="Delete">
Delete
</MenuItem>
{/if}
</ActionMenu>
</div>
</div>
</Layout>
<div class="tab-wrap">
<Tabs
selected={selectedTab}
noPadding
on:select={e => {
selectedTab = e.detail
}}
>
<Tab title="Overview">
<OverviewTab
app={selectedApp}
deployments={latestDeployments}
navigateTab={handleTabChange}
on:unpublish={e => unpublishApp(e.detail)}
/>
</Tab>
<Tab title="Access">
<AccessTab app={selectedApp} />
</Tab>
<Tab title="Automation History">
<HistoryTab app={selectedApp} />
</Tab>
<Tab title="Backups">
<BackupsTab app={selectedApp} />
</Tab>
<Tab title="Settings">
<SettingsTab app={selectedApp} />
</Tab>
</Tabs>
</div>
<ConfirmDialog
bind:this={deletionModal}
title="Confirm deletion"
okText="Delete app"
onOk={confirmDeleteApp}
onCancel={() => (appName = null)}
disabled={appName !== selectedApp?.name}
>
Are you sure you want to delete the app <b>{selectedApp?.name}</b>?
<p>Please enter the app name below to confirm.</p>
<Input
bind:value={appName}
data-cy="delete-app-confirmation"
placeholder={selectedApp?.name}
/>
</ConfirmDialog>
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
dataCy={"unpublish-modal"}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
{/if}
</Page>
</span>
<style>
.app-url {
color: var(--spectrum-global-color-gray-600);
}
.loading {
display: flex;
justify-content: center;
align-items: center;
flex: 1;
}
.overview-header {
display: flex;
justify-content: space-between;
}
.page-header.loaded {
padding: 0px;
}
.overview-wrap :global(> div > .container),
.tab-wrap :global(.spectrum-Tabs) {
background-color: var(--background);
background-clip: padding-box;
}
@media (max-width: 1000px) {
.overview-header {
flex-direction: column;
gap: var(--spacing-l);
}
}
.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);
}
.tab-wrap :global(> .spectrum-Tabs) {
padding-left: var(--spectrum-alias-grid-gutter-large);
padding-right: var(--spectrum-alias-grid-gutter-large);
}
.page-header {
padding-left: var(--spectrum-alias-grid-gutter-large);
padding-right: var(--spectrum-alias-grid-gutter-large);
padding-top: var(--spectrum-alias-grid-gutter-large);
}
</style>

View file

@ -1,223 +0,0 @@
<script>
import {
Layout,
Heading,
Body,
Button,
List,
ListItem,
Modal,
notifications,
Pagination,
Icon,
} from "@budibase/bbui"
import { onMount } from "svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import { users, groups, apps, licensing } from "stores/portal"
import AssignmentModal from "./AssignmentModal.svelte"
import { roles } from "stores/backend"
import { API } from "api"
import { fetchData } from "@budibase/frontend-core"
export let app
const usersFetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
query: {
appId: apps.getProdAppID(app.devId),
},
},
})
let assignmentModal
let appGroups
let appUsers
$: fixedAppId = apps.getProdAppID(app.devId)
$: appUsers = $usersFetch.rows
$: appGroups = $groups.filter(group => {
if (!group.roles) {
return false
}
return groups.actions.getGroupAppIds(group).includes(fixedAppId)
})
async function removeUser(user) {
// Remove the user role
const filteredRoles = { ...user.roles }
delete filteredRoles[fixedAppId]
await users.save({
...user,
roles: {
...filteredRoles,
},
})
await usersFetch.refresh()
}
async function removeGroup(group) {
await groups.actions.removeApp(group._id, fixedAppId)
await groups.actions.init()
await usersFetch.refresh()
}
async function updateUserRole(role, user) {
user.roles[fixedAppId] = role
await users.save(user)
}
async function updateGroupRole(role, group) {
await groups.actions.addApp(group._id, fixedAppId, role)
await usersFetch.refresh()
}
onMount(async () => {
try {
await roles.fetch()
} catch (error) {
notifications.error(error)
}
})
</script>
<div class="access-tab">
<Layout>
{#if appGroups.length || appUsers.length}
<div>
<Heading>Access</Heading>
<div class="subtitle">
<Body size="S">
Assign users and groups to your app and define their access here
</Body>
<Button on:click={assignmentModal.show} icon="User" cta>
Assign access
</Button>
</div>
</div>
{#if $licensing.groupsEnabled && appGroups.length}
<List title="User Groups">
{#each appGroups as group}
<ListItem
title={group.name}
icon={group.icon}
iconBackground={group.color}
>
<RoleSelect
on:change={e => updateGroupRole(e.detail, group)}
autoWidth
quiet
value={group.roles[
groups.actions
.getGroupAppIds(group)
.find(x => x === fixedAppId)
]}
allowPublic={false}
/>
<Icon
on:click={() => removeGroup(group)}
hoverable
size="S"
name="Close"
/>
</ListItem>
{/each}
</List>
{/if}
{#if appUsers.length}
<div>
<List title="Users">
{#each appUsers as user}
<ListItem title={user.email} avatar>
<RoleSelect
on:change={e => updateUserRole(e.detail, user)}
autoWidth
quiet
value={user.roles[
Object.keys(user.roles).find(x => x === fixedAppId)
]}
allowPublic={false}
/>
<Icon
on:click={() => removeUser(user)}
hoverable
size="S"
name="Close"
/>
</ListItem>
{/each}
</List>
<div class="pagination">
<Pagination
page={$usersFetch.pageNumber + 1}
hasPrevPage={$usersFetch.hasPrevPage}
hasNextPage={$usersFetch.hasNextPage}
goToPrevPage={$usersFetch.loading ? null : usersFetch.prevPage}
goToNextPage={$usersFetch.loading ? null : usersFetch.nextPage}
/>
</div>
</div>
{/if}
{:else}
<div class="align">
<Layout gap="S">
<Heading>No users assigned</Heading>
<div class="opacity">
<Body size="S">
Assign users/groups to your app and set their access here
</Body>
</div>
<div class="padding">
<Button
on:click={() => assignmentModal.show()}
cta
icon="UserArrow"
>
Assign access
</Button>
</div>
</Layout>
</div>
{/if}
</Layout>
</div>
<Modal bind:this={assignmentModal}>
<AssignmentModal {app} {appUsers} on:update={usersFetch.refresh} />
</Modal>
<style>
.access-tab {
max-width: 600px;
margin: 0 auto;
padding: 40px;
}
.padding {
margin-top: var(--spacing-m);
}
.opacity {
opacity: 0.8;
}
.align {
text-align: center;
}
.subtitle {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
margin-top: var(--spacing-xl);
}
</style>

View file

@ -0,0 +1,20 @@
<script>
import { apps, groups, licensing } from "stores/portal"
import { onMount } from "svelte"
let loaded = !!$apps?.length
onMount(async () => {
if (!loaded) {
await apps.load()
if ($licensing.groupsEnabled) {
await groups.actions.init()
}
loaded = true
}
})
</script>
{#if loaded}
<slot />
{/if}

View file

@ -10,3 +10,4 @@ export { licensing } from "./licensing"
export { groups } from "./groups"
export { plugins } from "./plugins"
export { backups } from "./backups"
export { overview } from "./overview"

View file

@ -0,0 +1,21 @@
import { writable, derived } from "svelte/store"
import { apps } from "./apps"
const createOverviewStore = () => {
const store = writable({
selectedAppId: null,
})
const derivedStore = derived([store, apps], ([$store, $apps]) => {
return {
...$store,
selectedApp: $apps?.find(app => app.devId === $store.selectedAppId),
}
})
return {
update: store.update,
subscribe: derivedStore.subscribe,
}
}
export const overview = createOverviewStore()