1
0
Fork 0
mirror of synced 2024-07-03 21:40:55 +12:00

Merge branch 'master' into reorganise-row-tests

This commit is contained in:
Adria Navarro 2024-03-15 13:13:43 +01:00 committed by GitHub
commit 3b1242b0e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 654 additions and 345 deletions

View file

@ -1,60 +1,54 @@
<script context="module">
export const directions = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]
</script>
<script> <script>
import Tooltip from "../Tooltip/Tooltip.svelte" import {
import { fade } from "svelte/transition" default as AbsTooltip,
TooltipPosition,
TooltipType,
} from "../Tooltip/AbsTooltip.svelte"
export let direction = "n"
export let name = "Add" export let name = "Add"
export let hidden = false export let hidden = false
export let size = "M" export let size = "M"
export let hoverable = false export let hoverable = false
export let disabled = false export let disabled = false
export let color export let color
export let hoverColor
export let tooltip export let tooltip
export let tooltipPosition = TooltipPosition.Bottom
export let tooltipType = TooltipType.Default
export let tooltipColor
export let tooltipWrap = true
export let newStyles = false export let newStyles = false
$: rotation = getRotation(direction)
let showTooltip = false
const getRotation = direction => {
return directions.indexOf(direction) * 45
}
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <AbsTooltip
<!-- svelte-ignore a11y-click-events-have-key-events --> text={tooltip}
<div type={tooltipType}
class="icon" position={tooltipPosition}
class:newStyles color={tooltipColor}
on:mouseover={() => (showTooltip = true)} noWrap={tooltipWrap}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:click={() => (showTooltip = false)}
> >
<svg <div class="icon" class:newStyles>
on:click <svg
class:hoverable on:click
class:disabled class:hoverable
class="spectrum-Icon spectrum-Icon--size{size}" class:disabled
focusable="false" class="spectrum-Icon spectrum-Icon--size{size}"
aria-hidden={hidden} focusable="false"
aria-label={name} aria-hidden={hidden}
style={`transform: rotate(${rotation}deg); ${ aria-label={name}
color ? `color: ${color};` : "" style={`${color ? `color: ${color};` : ""} ${
}`} hoverColor
> ? `--hover-color: ${hoverColor}`
<use style="pointer-events: none;" xlink:href="#spectrum-icon-18-{name}" /> : "--hover-color: var(--spectrum-alias-icon-color-selected-hover)"
</svg> }`}
{#if tooltip && showTooltip} >
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}> <use
<Tooltip textWrapping direction="top" text={tooltip} /> style="pointer-events: none;"
</div> xlink:href="#spectrum-icon-18-{name}"
{/if} />
</div> </svg>
</div>
</AbsTooltip>
<style> <style>
.icon { .icon {
@ -71,7 +65,7 @@
transition: color var(--spectrum-global-animation-duration-100, 130ms); transition: color var(--spectrum-global-animation-duration-100, 130ms);
} }
svg.hoverable:hover { svg.hoverable:hover {
color: var(--spectrum-alias-icon-color-selected-hover) !important; color: var(--hover-color) !important;
cursor: pointer; cursor: pointer;
} }
svg.hoverable:active { svg.hoverable:active {

View file

@ -24,6 +24,7 @@
export let text = "" export let text = ""
export let fixed = false export let fixed = false
export let color = null export let color = null
export let noWrap = false
let wrapper let wrapper
let hovered = false let hovered = false
@ -105,6 +106,7 @@
<Portal target=".spectrum"> <Portal target=".spectrum">
<span <span
class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open" class="spectrum-Tooltip spectrum-Tooltip--{type} spectrum-Tooltip--{position} is-open"
class:noWrap
style={`left:${left}px;top:${top}px;${tooltipStyle}`} style={`left:${left}px;top:${top}px;${tooltipStyle}`}
transition:fade|local={{ duration: 130 }} transition:fade|local={{ duration: 130 }}
> >
@ -118,6 +120,9 @@
.abs-tooltip { .abs-tooltip {
display: contents; display: contents;
} }
.spectrum-Tooltip.noWrap .spectrum-Tooltip-label {
width: max-content;
}
.spectrum-Tooltip { .spectrum-Tooltip {
position: absolute; position: absolute;
z-index: 9999; z-index: 9999;

View file

@ -19,7 +19,7 @@ export { default as ActionMenu } from "./ActionMenu/ActionMenu.svelte"
export { default as Button } from "./Button/Button.svelte" export { default as Button } from "./Button/Button.svelte"
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte" export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte" export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon, directions } from "./Icon/Icon.svelte" export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte" export { default as IconAvatar } from "./Icon/IconAvatar.svelte"
export { default as Toggle } from "./Form/Toggle.svelte" export { default as Toggle } from "./Form/Toggle.svelte"
export { default as RadioGroup } from "./Form/RadioGroup.svelte" export { default as RadioGroup } from "./Form/RadioGroup.svelte"

View file

@ -59,7 +59,7 @@
class="searchButton" class="searchButton"
class:hide={search} class:hide={search}
> >
<Icon size="S" name="Search" /> <Icon size="S" name="Search" hoverable hoverColor="var(--ink)" />
</div> </div>
<div <div
@ -68,7 +68,7 @@
class="addButton" class="addButton"
class:rotate={search} class:rotate={search}
> >
<Icon name="Add" /> <Icon name="Add" hoverable hoverColor="var(--ink)" />
</div> </div>
</div> </div>

View file

@ -8,6 +8,7 @@
export let iconTooltip export let iconTooltip
export let withArrow = false export let withArrow = false
export let withActions = true export let withActions = true
export let showActions = false
export let indentLevel = 0 export let indentLevel = 0
export let text export let text
export let border = true export let border = true
@ -68,10 +69,11 @@
class:border class:border
class:selected class:selected
class:withActions class:withActions
class:showActions
class:actionsOpen={highlighted && withActions}
class:scrollable class:scrollable
class:highlighted class:highlighted
class:selectedBy class:selectedBy
class:actionsOpen={highlighted && withActions}
on:dragend on:dragend
on:dragstart on:dragstart
on:dragover on:dragover
@ -170,7 +172,8 @@
} }
.nav-item:hover .actions, .nav-item:hover .actions,
.hovering .actions, .hovering .actions,
.nav-item.withActions.actionsOpen .actions { .nav-item.withActions.actionsOpen .actions,
.nav-item.withActions.showActions .actions {
opacity: 1; opacity: 1;
} }
.nav-item-content { .nav-item-content {

View file

@ -19,7 +19,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import analytics, { Events, EventSource } from "analytics" import analytics, { Events, EventSource } from "analytics"
import { API } from "api" import { API } from "api"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { import {
previewStore, previewStore,
builderStore, builderStore,
@ -45,7 +45,7 @@
let appActionPopoverAnchor let appActionPopoverAnchor
let publishing = false let publishing = false
$: filteredApps = $apps.filter(app => app.devId === application) $: filteredApps = $appsStore.apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: latestDeployments = $deploymentStore $: latestDeployments = $deploymentStore
.filter(deployment => deployment.status === "SUCCESS") .filter(deployment => deployment.status === "SUCCESS")
@ -129,7 +129,7 @@
} }
try { try {
await API.unpublishApp(selectedApp.prodId) await API.unpublishApp(selectedApp.prodId)
await apps.load() await appsStore.load()
notifications.send("App unpublished", { notifications.send("App unpublished", {
type: "success", type: "success",
icon: "GlobeStrike", icon: "GlobeStrike",
@ -141,7 +141,7 @@
const completePublish = async () => { const completePublish = async () => {
try { try {
await apps.load() await appsStore.load()
await deploymentStore.load() await deploymentStore.load()
} catch (err) { } catch (err) {
notifications.error("Error refreshing app") notifications.error("Error refreshing app")

View file

@ -2,7 +2,7 @@
import { Input, notifications } from "@budibase/bbui" import { Input, notifications } from "@budibase/bbui"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { API } from "api" import { API } from "api"
export let appId export let appId
@ -36,7 +36,7 @@
deleting = true deleting = true
try { try {
await API.deleteApp(appId) await API.deleteApp(appId)
apps.load() appsStore.load()
notifications.success("App deleted successfully") notifications.success("App deleted successfully")
onDeleteSuccess() onDeleteSuccess()
} catch (err) { } catch (err) {

View file

@ -6,10 +6,13 @@
import { UserAvatars } from "@budibase/frontend-core" import { UserAvatars } from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import AppRowContext from "./AppRowContext.svelte" import AppRowContext from "./AppRowContext.svelte"
import FavouriteAppButton from "pages/builder/portal/apps/FavouriteAppButton.svelte"
export let app export let app
export let lockedAction export let lockedAction
let actionsOpen = false
$: editing = app.sessions?.length $: editing = app.sessions?.length
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
$: unclickable = !isBuilder && !app.deployed $: unclickable = !isBuilder && !app.deployed
@ -43,8 +46,10 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="app-row" class="app-row"
on:click={lockedAction || handleDefaultClick}
class:unclickable class:unclickable
class:actionsOpen
class:favourite={app.favourite}
on:click={lockedAction || handleDefaultClick}
> >
<div class="title"> <div class="title">
<div class="app-icon"> <div class="app-icon">
@ -75,19 +80,35 @@
<Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body> <Body size="S">{app.deployed ? "Published" : "Unpublished"}</Body>
</div> </div>
{#if isBuilder} <div class="actions-wrap">
<div class="app-row-actions"> <div class="app-row-actions">
<Button size="S" secondary on:click={lockedAction || goToBuilder}> {#if isBuilder}
Edit <div class="row-action">
</Button> <Button size="S" secondary on:click={lockedAction || goToBuilder}>
<AppRowContext {app} /> Edit
</Button>
</div>
<div class="row-action">
<AppRowContext
{app}
on:open={() => {
actionsOpen = true
}}
on:close={() => {
actionsOpen = false
}}
/>
</div>
{:else}
<!-- this can happen if an app builder has app user access to an app -->
<Button size="S" secondary>View</Button>
{/if}
</div> </div>
{:else if app.deployed}
<!-- this can happen if an app builder has app user access to an app --> <div class="favourite-icon">
<div class="app-row-actions"> <FavouriteAppButton {app} noWrap />
<Button size="S" secondary>View</Button>
</div> </div>
{/if} </div>
</div> </div>
<style> <style>
@ -107,6 +128,16 @@
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
} }
.app-row .favourite-icon {
display: none;
}
.app-row:hover .favourite-icon,
.app-row.favourite .favourite-icon,
.app-row.actionsOpen .favourite-icon {
display: flex;
}
.updated { .updated {
color: var(--spectrum-global-color-gray-700); color: var(--spectrum-global-color-gray-700);
display: flex; display: flex;
@ -142,11 +173,23 @@
} }
.app-row-actions { .app-row-actions {
display: none;
}
.app-row:hover .app-row-actions,
.app-row.actionsOpen .app-row-actions {
gap: var(--spacing-m); gap: var(--spacing-m);
display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
display: flex;
}
.actions-wrap {
gap: var(--spacing-m);
display: flex;
justify-content: flex-end;
min-height: var(--spectrum-alias-item-height-s);
} }
.name { .name {

View file

@ -4,15 +4,69 @@
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import ExportAppModal from "./ExportAppModal.svelte" import ExportAppModal from "./ExportAppModal.svelte"
import DuplicateAppModal from "./DuplicateAppModal.svelte" import DuplicateAppModal from "./DuplicateAppModal.svelte"
import { onMount } from "svelte"
import { licensing } from "stores/portal" import { licensing } from "stores/portal"
export let app export let app
export let align = "right" export let align = "right"
export let options
let deleteModal let deleteModal
let exportModal let exportModal
let duplicateModal let duplicateModal
let exportPublishedVersion = false let exportPublishedVersion = false
let loaded = false
const getActions = app => {
if (!loaded) {
return []
}
return [
{
id: "duplicate",
icon: "Copy",
onClick: duplicateModal.show,
body: "Duplicate",
},
{
id: "exportDev",
icon: "Export",
onClick: () => {
exportPublishedVersion = false
exportModal.show()
},
body: "Export latest edited app",
},
{
id: "exportProd",
icon: "Export",
onClick: () => {
exportPublishedVersion = true
exportModal.show()
},
body: "Export latest published app",
},
{
id: "delete",
icon: "Delete",
onClick: deleteModal.show,
body: "Delete",
},
].filter(action => {
if (action.id === "exportProd" && app.deployed !== true) {
return false
} else if (Array.isArray(options) && !options.includes(action.id)) {
return false
}
return true
})
}
$: actions = getActions(app, loaded)
onMount(() => {
loaded = true
})
let appLimitModal let appLimitModal
</script> </script>
@ -45,44 +99,10 @@
<div slot="control" class="icon"> <div slot="control" class="icon">
<Icon size="S" hoverable name="MoreSmallList" /> <Icon size="S" hoverable name="MoreSmallList" />
</div> </div>
<MenuItem
icon="Copy" {#each actions as action}
on:click={() => { <MenuItem icon={action.icon} on:click={action.onClick}>
if ($licensing?.usageMetrics?.apps < 100) { {action.body}
duplicateModal.show()
} else {
appLimitModal.show()
}
}}
>
Duplicate
</MenuItem>
<MenuItem
icon="Export"
on:click={() => {
exportPublishedVersion = false
exportModal.show()
}}
>
Export latest edited app
</MenuItem>
{#if app.deployed}
<MenuItem
icon="Export"
on:click={() => {
exportPublishedVersion = true
exportModal.show()
}}
>
Export latest published app
</MenuItem> </MenuItem>
{/if} {/each}
<MenuItem
icon="Delete"
on:click={() => {
deleteModal.show()
}}
>
Delete
</MenuItem>
</ActionMenu> </ActionMenu>

View file

@ -6,7 +6,7 @@
Label, Label,
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let app export let app
@ -49,7 +49,7 @@
return return
} }
try { try {
await apps.update(app.instance._id, { await appsStore.save(app.instance._id, {
icon: { name, color }, icon: { name, color },
}) })
} catch (error) { } catch (error) {

View file

@ -9,13 +9,14 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { initialise } from "stores/builder" import { initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import { apps, admin, auth } from "stores/portal" import { appsStore, admin, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
import TemplateCard from "components/common/TemplateCard.svelte" import TemplateCard from "components/common/TemplateCard.svelte"
import { lowercase } from "helpers" import { lowercase } from "helpers"
import { sdk } from "@budibase/shared-core"
export let template export let template
@ -92,7 +93,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(appsStore).apps
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications }) appValidation.url(validation, { apps: applications })
appValidation.file(validation, { template }) appValidation.file(validation, { template })
@ -141,6 +142,11 @@
// Create user // Create user
await auth.setInitInfo({}) await auth.setInitInfo({})
if (!sdk.users.isBuilder($auth.user, createdApp?.appId)) {
// Refresh for access to created applications
await auth.getSelf()
}
$goto(`/builder/app/${createdApp.instance._id}`) $goto(`/builder/app/${createdApp.instance._id}`)
} catch (error) { } catch (error) {
creating = false creating = false

View file

@ -9,9 +9,10 @@
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
import { apps } from "stores/portal" import { appsStore, auth } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { API } from "api" import { API } from "api"
import { sdk } from "@budibase/shared-core"
export let appId export let appId
export let appName export let appName
@ -67,8 +68,12 @@
} }
try { try {
await API.duplicateApp(data, appId) const app = await API.duplicateApp(data, appId)
apps.load() appsStore.load()
if (!sdk.users.isBuilder($auth.user, app?.duplicateAppId)) {
// Refresh for access to created applications
await auth.getSelf()
}
onDuplicateSuccess() onDuplicateSuccess()
notifications.success("App duplicated successfully") notifications.success("App duplicated successfully")
} catch (err) { } catch (err) {
@ -78,7 +83,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = get(apps) const applications = get(appsStore).apps
appValidation.name(validation, { apps: applications }) appValidation.name(validation, { apps: applications })
appValidation.url(validation, { apps: applications }) appValidation.url(validation, { apps: applications })

View file

@ -7,7 +7,7 @@
Layout, Layout,
Label, Label,
} from "@budibase/bbui" } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import { createValidationStore } from "helpers/validation/yup" import { createValidationStore } from "helpers/validation/yup"
import * as appValidation from "helpers/validation/yup/app" import * as appValidation from "helpers/validation/yup/app"
@ -37,7 +37,7 @@
} }
const setupValidation = async () => { const setupValidation = async () => {
const applications = svelteGet(apps) const applications = svelteGet(appsStore).apps
appValidation.name(validation, { appValidation.name(validation, {
apps: applications, apps: applications,
currentApp: { currentApp: {
@ -62,7 +62,7 @@
async function updateApp() { async function updateApp() {
try { try {
await apps.update(app.appId, { await appsStore.save(app.appId, {
name: $values.name?.trim(), name: $values.name?.trim(),
url: $values.url?.trim(), url: $values.url?.trim(),
icon: { icon: {

View file

@ -22,6 +22,7 @@ body {
--grey-7: var(--spectrum-global-color-gray-700); --grey-7: var(--spectrum-global-color-gray-700);
--grey-8: var(--spectrum-global-color-gray-800); --grey-8: var(--spectrum-global-color-gray-800);
--grey-9: var(--spectrum-global-color-gray-900); --grey-9: var(--spectrum-global-color-gray-900);
--spectrum-global-color-yellow-1000: #d8b500;
color: var(--ink); color: var(--ink);
background-color: var(--background-alt); background-color: var(--background-alt);

View file

@ -15,7 +15,14 @@
FancySelect, FancySelect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { builderStore, appStore, roles } from "stores/builder" import { builderStore, appStore, roles } from "stores/builder"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" import {
groups,
licensing,
appsStore,
users,
auth,
admin,
} from "stores/portal"
import { import {
fetchData, fetchData,
Constants, Constants,
@ -54,7 +61,7 @@
let inviteFailureResponse = "" let inviteFailureResponse = ""
$: validEmail = emailValidator(email) === true $: validEmail = emailValidator(email) === true
$: prodAppId = apps.getProdAppID($appStore.appId) $: prodAppId = appsStore.getProdAppID($appStore.appId)
$: promptInvite = showInvite( $: promptInvite = showInvite(
filteredInvites, filteredInvites,
filteredUsers, filteredUsers,

View file

@ -8,7 +8,7 @@
userStore, userStore,
deploymentStore, deploymentStore,
} from "stores/builder" } from "stores/builder"
import { auth, apps } from "stores/portal" import { auth, appsStore } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
Icon, Icon,
@ -52,7 +52,7 @@
const pkg = await API.fetchAppPackage(application) const pkg = await API.fetchAppPackage(application)
await initialise(pkg) await initialise(pkg)
await apps.load() await appsStore.load()
await deploymentStore.load() await deploymentStore.load()
loaded = true loaded = true

View file

@ -18,7 +18,7 @@
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { getContext, onDestroy, onMount } from "svelte" import { getContext, onDestroy, onMount } from "svelte"
import dayjs from "dayjs" import dayjs from "dayjs"
import { auth, licensing, admin, apps } from "stores/portal" import { auth, licensing, admin, appsStore } from "stores/portal"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import Portal from "svelte-portal" import Portal from "svelte-portal"
@ -36,7 +36,7 @@
let status = null let status = null
let timeRange = null let timeRange = null
let loaded = false let loaded = false
$: app = $apps.find(app => $appStore.appId?.includes(app.appId)) $: app = $appsStore.apps.find(app => $appStore.appId?.includes(app.appId))
$: licensePlan = $auth.user?.license?.plan $: licensePlan = $auth.user?.license?.plan
$: page = $pageInfo.page $: page = $pageInfo.page
$: fetchLogs(automationId, status, page, timeRange) $: fetchLogs(automationId, status, page, timeRange)
@ -129,7 +129,7 @@
async function save({ detail }) { async function save({ detail }) {
try { try {
await apps.update($appStore.appId, { await appsStore.save($appStore.appId, {
automations: { automations: {
chainAutomations: detail, chainAutomations: detail,
}, },

View file

@ -10,10 +10,10 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appUrl = `${window.origin}/embed${app?.url}` $: appUrl = `${window.origin}/embed${app?.url}`
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View file

@ -8,12 +8,12 @@
Modal, Modal,
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
import ExportAppModal from "components/start/ExportAppModal.svelte" import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte" import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId === $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId === $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View file

@ -11,13 +11,13 @@
} from "@budibase/bbui" } from "@budibase/bbui"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { appStore, initialise } from "stores/builder" import { appStore, initialise } from "stores/builder"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { API } from "api" import { API } from "api"
let updatingModal let updatingModal
$: filteredApps = $apps.filter(app => app.devId == $appStore.appId) $: filteredApps = $appsStore.apps.filter(app => app.devId == $appStore.appId)
$: app = filteredApps.length ? filteredApps[0] : {} $: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED $: appDeployed = app?.status === AppStatus.DEPLOYED

View file

@ -12,7 +12,14 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, organisation, auth, groups, licensing } from "stores/portal" import {
appsStore,
organisation,
auth,
groups,
licensing,
enrichedApps,
} from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { gradient } from "actions" import { gradient } from "actions"
@ -31,7 +38,9 @@
$: userGroups = $groups.filter(group => $: userGroups = $groups.filter(group =>
group.users.find(user => user._id === $auth.user?._id) group.users.find(user => user._id === $auth.user?._id)
) )
$: publishedApps = $apps.filter(app => app.status === AppStatus.DEPLOYED) $: publishedApps = $enrichedApps.filter(
app => app.status === AppStatus.DEPLOYED
)
$: userApps = getUserApps(publishedApps, userGroups, $auth.user) $: userApps = getUserApps(publishedApps, userGroups, $auth.user)
function getUserApps(publishedApps, userGroups, user) { function getUserApps(publishedApps, userGroups, user) {
@ -46,12 +55,12 @@
return userGroups.find(group => { return userGroups.find(group => {
return groups.actions return groups.actions
.getGroupAppIds(group) .getGroupAppIds(group)
.map(role => apps.extractAppId(role)) .map(role => appsStore.extractAppId(role))
.includes(app.appId) .includes(app.appId)
}) })
} else { } else {
return Object.keys($auth.user?.roles) return Object.keys($auth.user?.roles)
.map(x => apps.extractAppId(x)) .map(x => appsStore.extractAppId(x))
.includes(app.appId) .includes(app.appId)
} }
}) })
@ -76,7 +85,7 @@
onMount(async () => { onMount(async () => {
try { try {
await organisation.init() await organisation.init()
await apps.load() await appsStore.load()
await groups.actions.init() await groups.actions.init()
} catch (error) { } catch (error) {
notifications.error("Error loading apps") notifications.error("Error loading apps")

View file

@ -1,7 +1,7 @@
<script> <script>
import { isActive, redirect, goto, url } from "@roxi/routify" import { isActive, redirect, goto, url } from "@roxi/routify"
import { Icon, notifications, Tabs, Tab } from "@budibase/bbui" import { Icon, notifications, Tabs, Tab } from "@budibase/bbui"
import { organisation, auth, menu, apps } from "stores/portal" import { organisation, auth, menu, appsStore } from "stores/portal"
import { onMount } from "svelte" import { onMount } from "svelte"
import UpgradeButton from "./_components/UpgradeButton.svelte" import UpgradeButton from "./_components/UpgradeButton.svelte"
import MobileMenu from "./_components/MobileMenu.svelte" import MobileMenu from "./_components/MobileMenu.svelte"
@ -16,7 +16,8 @@
let activeTab = "Apps" let activeTab = "Apps"
$: $url(), updateActiveTab($menu) $: $url(), updateActiveTab($menu)
$: isOnboarding = !$apps.length && sdk.users.hasBuilderPermissions($auth.user) $: isOnboarding =
!$appsStore.apps.length && sdk.users.hasBuilderPermissions($auth.user)
const updateActiveTab = menu => { const updateActiveTab = menu => {
for (let entry of menu) { for (let entry of menu) {
@ -40,7 +41,7 @@
} else { } else {
try { try {
// We need to load apps to know if we need to show onboarding fullscreen // We need to load apps to know if we need to show onboarding fullscreen
await Promise.all([apps.load(), organisation.init()]) await Promise.all([appsStore.load(), organisation.init()])
} catch (error) { } catch (error) {
notifications.error("Error getting org config") notifications.error("Error getting org config")
} }

View file

@ -18,7 +18,7 @@
Divider, Divider,
ActionButton, ActionButton,
} from "@budibase/bbui" } from "@budibase/bbui"
import { licensing, users, apps, auditLogs } from "stores/portal" import { licensing, users, appsStore, auditLogs } from "stores/portal"
import LockedFeature from "../../_components/LockedFeature.svelte" import LockedFeature from "../../_components/LockedFeature.svelte"
import { createPaginationStore } from "helpers/pagination" import { createPaginationStore } from "helpers/pagination"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
@ -102,7 +102,7 @@
enrich(parseEventObject($auditLogs.events), selectedEvents, "id"), enrich(parseEventObject($auditLogs.events), selectedEvents, "id"),
"id" "id"
) )
$: sortedApps = sort(enrich($apps, selectedApps, "appId"), "name") $: sortedApps = sort(enrich($appsStore.apps, selectedApps, "appId"), "name")
const debounce = value => { const debounce = value => {
clearTimeout(timer) clearTimeout(timer)

View file

@ -0,0 +1,43 @@
<script>
import { Icon, TooltipPosition, TooltipType } from "@budibase/bbui"
import { auth } from "stores/portal"
export let app
export let size = "S"
export let position = TooltipPosition.Top
export let noWrap = false
export let hoverColor = "var(--ink)"
</script>
<Icon
name={app?.favourite ? "Star" : "StarOutline"}
hoverable
color={app?.favourite ? "var(--spectrum-global-color-yellow-1000)" : null}
tooltip={app?.favourite ? "Remove from favourites" : "Add to favourites"}
tooltipType={TooltipType.Info}
tooltipPosition={position}
tooltipWrap={noWrap}
{hoverColor}
{size}
on:click={async e => {
e.stopPropagation()
const userAppFavourites = new Set([...($auth.user.appFavourites || [])])
let processedAppIds = []
if ($auth.user.appFavourites && app?.appId) {
if (userAppFavourites.has(app.appId)) {
userAppFavourites.delete(app.appId)
} else {
userAppFavourites.add(app.appId)
}
processedAppIds = [...userAppFavourites]
} else {
processedAppIds = [app.appId]
}
await auth.updateSelf({
appFavourites: processedAppIds,
})
}}
disabled={!app}
/>

View file

@ -1,8 +1,8 @@
<script> <script>
import { params, redirect } from "@roxi/routify" import { params, redirect } from "@roxi/routify"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
$: app = $apps.find(app => app.appId === $params.appId) $: app = $appsStore.apps.find(app => app.appId === $params.appId)
$: { $: {
if (!app) { if (!app) {
$redirect("../") $redirect("../")

View file

@ -1,12 +1,21 @@
<script> <script>
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { apps, auth, sideBarCollapsed } from "stores/portal" import { auth, sideBarCollapsed, enrichedApps } from "stores/portal"
import { Link, Body, ActionButton } from "@budibase/bbui" import AppRowContext from "components/start/AppRowContext.svelte"
import FavouriteAppButton from "../FavouriteAppButton.svelte"
import {
Link,
Body,
Button,
Icon,
TooltipPosition,
TooltipType,
} from "@budibase/bbui"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import ErrorSVG from "./ErrorSVG.svelte" import ErrorSVG from "./ErrorSVG.svelte"
$: app = $apps.find(app => app.appId === $params.appId) $: app = $enrichedApps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app) $: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId) $: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
@ -30,42 +39,63 @@
$: fetchScreens(app?.devId) $: fetchScreens(app?.devId)
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="container"> <div class="container">
<div class="header"> <div class="header">
{#if $sideBarCollapsed} {#if $sideBarCollapsed}
<ActionButton <div class="headerButton" on:click={() => sideBarCollapsed.set(false)}>
quiet <Icon
icon="Rail" name={"Rail"}
on:click={() => sideBarCollapsed.set(false)} hoverable
> tooltip="Expand"
Menu tooltipPosition={TooltipPosition.Right}
</ActionButton> tooltipType={TooltipType.Info}
hoverColor={"var(--ink)"}
/>
</div>
{:else} {:else}
<ActionButton <div class="headerButton" on:click={() => sideBarCollapsed.set(true)}>
quiet <Icon
icon="RailRightOpen" name={"RailRightOpen"}
on:click={() => sideBarCollapsed.set(true)} hoverable
> tooltip="Collapse"
Collapse tooltipType={TooltipType.Info}
</ActionButton> tooltipPosition={TooltipPosition.Top}
hoverColor={"var(--ink)"}
size="S"
/>
</div>
{/if} {/if}
{#if isBuilder} {#if isBuilder}
<ActionButton <Button
quiet size="M"
icon="Edit" secondary
on:click={() => $goto(`/builder/app/${app.devId}`)} on:click={() => $goto(`/builder/app/${app.devId}`)}
> >
Edit Edit
</ActionButton> </Button>
{/if} {/if}
<ActionButton <div class="headerButton">
disabled={noScreens} <FavouriteAppButton {app} />
quiet </div>
icon="LinkOut" <div class="headerButton" on:click={() => window.open(iframeUrl, "_blank")}>
on:click={() => window.open(iframeUrl, "_blank")} <Icon
> name="LinkOut"
Fullscreen disabled={noScreens}
</ActionButton> hoverable
tooltip="Open in new tab"
tooltipType={TooltipType.Info}
tooltipPosition={TooltipPosition.Top}
hoverColor={"var(--ink)"}
size="S"
/>
</div>
<AppRowContext
{app}
options={["duplicate", "delete", "exportDev", "exportProd"]}
align="left"
/>
</div> </div>
{#if noScreens} {#if noScreens}
<div class="noScreens"> <div class="noScreens">
@ -83,6 +113,15 @@
</div> </div>
<style> <style>
.headerButton {
color: var(--grey-7);
cursor: pointer;
}
.headerButton:hover {
color: var(--ink);
}
.container { .container {
flex: 1 1 auto; flex: 1 1 auto;
display: flex; display: flex;
@ -96,7 +135,7 @@
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: var(--spacing-xs); gap: var(--spacing-xl);
flex: 0 0 50px; flex: 0 0 50px;
} }

View file

@ -1,33 +1,21 @@
<script> <script>
import { apps, sideBarCollapsed, auth } from "stores/portal" import { sideBarCollapsed, enrichedApps, auth } from "stores/portal"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import NavHeader from "components/common/NavHeader.svelte" import NavHeader from "components/common/NavHeader.svelte"
import AppRowContext from "components/start/AppRowContext.svelte" import AppRowContext from "components/start/AppRowContext.svelte"
import { AppStatus } from "constants" import FavouriteAppButton from "../FavouriteAppButton.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
let searchString let searchString
let opened let opened
$: filteredApps = $apps $: filteredApps = $enrichedApps.filter(app => {
.filter(app => { return (
return ( !searchString ||
!searchString || app.name.toLowerCase().includes(searchString.toLowerCase())
app.name.toLowerCase().includes(searchString.toLowerCase()) )
) })
})
.map(app => {
return {
...app,
deployed: app.status === AppStatus.DEPLOYED,
}
})
.sort((a, b) => {
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1
})
</script> </script>
<div class="side-bar" class:collapsed={$sideBarCollapsed}> <div class="side-bar" class:collapsed={$sideBarCollapsed}>
@ -47,27 +35,40 @@
selected={!$params.appId} selected={!$params.appId}
/> />
{#each filteredApps as app} {#each filteredApps as app}
<NavItem <span
text={app.name} class="side-bar-app-entry"
icon={app.icon?.name || "Apps"} class:favourite={app.favourite}
iconColor={app.icon?.color} class:actionsOpen={opened == app.appId}
selected={$params.appId === app.appId}
highlighted={opened == app.appId}
on:click={() => $goto(`./${app.appId}`)}
> >
{#if sdk.users.isBuilder($auth.user, app?.devId)} <NavItem
<AppRowContext text={app.name}
{app} icon={app.icon?.name || "Apps"}
align="left" iconColor={app.icon?.color}
on:open={() => { selected={$params.appId === app.appId}
opened = app.appId highlighted={opened == app.appId}
}} on:click={() => $goto(`./${app.appId}`)}
on:close={() => { withActions
opened = null showActions
}} >
/> <div class="app-entry-actions">
{/if} {#if sdk.users.isBuilder($auth.user, app?.devId)}
</NavItem> <AppRowContext
{app}
align="left"
on:open={() => {
opened = app.appId
}}
on:close={() => {
opened = null
}}
/>
{/if}
</div>
<div class="favourite-icon">
<FavouriteAppButton {app} size="XS" />
</div>
</NavItem>
</span>
{/each} {/each}
</div> </div>
</div> </div>
@ -110,4 +111,23 @@
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.side-bar-app-entry :global(.nav-item-content .actions) {
width: auto;
display: flex;
gap: var(--spacing-s);
}
.side-bar-app-entry:hover .app-entry-actions,
.side-bar-app-entry:hover .favourite-icon,
.side-bar-app-entry.favourite .favourite-icon,
.side-bar-app-entry.actionsOpen .app-entry-actions,
.side-bar-app-entry.actionsOpen .favourite-icon {
opacity: 1;
}
.side-bar-app-entry .app-entry-actions,
.side-bar-app-entry .favourite-icon {
opacity: 0;
}
</style> </style>

View file

@ -2,7 +2,7 @@
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { import {
admin, admin,
apps, appsStore,
templates, templates,
licensing, licensing,
groups, groups,
@ -14,7 +14,7 @@
import PortalSideBar from "./_components/PortalSideBar.svelte" import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state // Don't block loading if we've already hydrated state
let loaded = !!$apps?.length let loaded = !!$appsStore.apps?.length
onMount(async () => { onMount(async () => {
try { try {
@ -34,7 +34,10 @@
} }
// Go to new app page if no apps exists // Go to new app page if no apps exists
if (!$apps.length && sdk.users.hasBuilderPermissions($auth.user)) { if (
!$appsStore.apps.length &&
sdk.users.hasBuilderPermissions($auth.user)
) {
$redirect("./onboarding") $redirect("./onboarding")
} }
} catch (error) { } catch (error) {
@ -46,7 +49,7 @@
{#if loaded} {#if loaded}
<div class="page"> <div class="page">
{#if $apps.length > 0} {#if $appsStore.apps.length > 0}
<PortalSideBar /> <PortalSideBar />
{/if} {/if}
<slot /> <slot />

View file

@ -5,7 +5,7 @@
import CreateAppModal from "components/start/CreateAppModal.svelte" import CreateAppModal from "components/start/CreateAppModal.svelte"
import TemplateDisplay from "components/common/TemplateDisplay.svelte" import TemplateDisplay from "components/common/TemplateDisplay.svelte"
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte" import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
import { apps, templates, licensing } from "stores/portal" import { appsStore, templates, licensing } from "stores/portal"
import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page" import { Breadcrumbs, Breadcrumb, Header } from "components/portal/page"
let template let template
@ -35,7 +35,7 @@
} }
</script> </script>
{#if !$apps.length} {#if !$appsStore.apps.length}
<FirstAppOnboarding /> <FirstAppOnboarding />
{:else} {:else}
<Page> <Page>

View file

@ -19,13 +19,18 @@
import { automationStore, initialise } from "stores/builder" import { automationStore, initialise } from "stores/builder"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte" import { onMount } from "svelte"
import { apps, auth, admin, licensing, environment } from "stores/portal" import {
appsStore,
auth,
admin,
licensing,
environment,
enrichedApps,
} from "stores/portal"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import AppRow from "components/start/AppRow.svelte" import AppRow from "components/start/AppRow.svelte"
import { AppStatus } from "constants"
import Logo from "assets/bb-space-man.svg" import Logo from "assets/bb-space-man.svg"
let sortBy = "name"
let template let template
let creationModal let creationModal
let appLimitModal let appLimitModal
@ -33,56 +38,27 @@
let searchTerm = "" let searchTerm = ""
let creatingFromTemplate = false let creatingFromTemplate = false
let automationErrors let automationErrors
let accessFilterList = null
$: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}` $: welcomeHeader = `Welcome ${$auth?.user?.firstName || "back"}`
$: enrichedApps = enrichApps($apps, $auth.user, sortBy) $: filteredApps = filterApps($enrichedApps, searchTerm)
$: filteredApps = enrichedApps.filter( $: automationErrors = getAutomationErrors(filteredApps || [])
app =>
(searchTerm
? app?.name?.toLowerCase().includes(searchTerm.toLowerCase())
: true) &&
(accessFilterList !== null
? accessFilterList?.includes(
`${app?.type}_${app?.tenantId}_${app?.appId}`
)
: true)
)
$: automationErrors = getAutomationErrors(enrichedApps)
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
const filterApps = (apps, searchTerm) => {
return apps?.filter(app => {
const query = searchTerm?.trim()?.replace(/\s/g, "")
if (query) {
return app?.name?.toLowerCase().includes(query.toLowerCase())
} else {
return true
}
})
}
const usersLimitLockAction = $licensing?.errUserLimit const usersLimitLockAction = $licensing?.errUserLimit
? () => accountLockedModal.show() ? () => accountLockedModal.show()
: null : null
const enrichApps = (apps, user, sortBy) => {
const enrichedApps = apps.map(app => ({
...app,
deployed: app.status === AppStatus.DEPLOYED,
lockedYou: app.lockedBy && 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 === "updated") {
return enrichedApps.sort((a, b) => {
const aUpdated = a.updatedAt || "9999"
const bUpdated = b.updatedAt || "9999"
return aUpdated < bUpdated ? 1 : -1
})
} else {
return enrichedApps.sort((a, b) => {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
})
}
}
const getAutomationErrors = apps => { const getAutomationErrors = apps => {
const automationErrors = {} const automationErrors = {}
for (let app of apps) { for (let app of apps) {
@ -117,7 +93,7 @@
const initiateAppCreation = async () => { const initiateAppCreation = async () => {
if ($licensing?.usageMetrics?.apps >= 100) { if ($licensing?.usageMetrics?.apps >= 100) {
appLimitModal.show() appLimitModal.show()
} else if ($apps?.length) { } else if ($appsStore.apps?.length) {
$goto("/builder/portal/apps/create") $goto("/builder/portal/apps/create")
} else { } else {
template = null template = null
@ -136,7 +112,7 @@
const templateKey = template.key.split("/")[1] const templateKey = template.key.split("/")[1]
let appName = templateKey.replace(/-/g, " ") let appName = templateKey.replace(/-/g, " ")
const appsWithSameName = $apps.filter(app => const appsWithSameName = $appsStore.apps.filter(app =>
app.name?.startsWith(appName) app.name?.startsWith(appName)
) )
appName = `${appName} ${appsWithSameName.length + 1}` appName = `${appName} ${appsWithSameName.length + 1}`
@ -217,7 +193,7 @@
: "View error"} : "View error"}
on:dismiss={async () => { on:dismiss={async () => {
await automationStore.actions.clearLogErrors({ appId }) await automationStore.actions.clearLogErrors({ appId })
await apps.load() await appsStore.load()
}} }}
message={automationErrorMessage(appId)} message={automationErrorMessage(appId)}
/> />
@ -233,7 +209,7 @@
</div> </div>
</div> </div>
{#if enrichedApps.length} {#if $appsStore.apps.length}
<Layout noPadding gap="L"> <Layout noPadding gap="L">
<div class="title"> <div class="title">
{#if $auth.user && sdk.users.canCreateApps($auth.user)} {#if $auth.user && sdk.users.canCreateApps($auth.user)}
@ -245,7 +221,7 @@
> >
Create new app Create new app
</Button> </Button>
{#if $apps?.length > 0 && !$admin.offlineMode} {#if $appsStore.apps?.length > 0 && !$admin.offlineMode}
<Button <Button
size="M" size="M"
secondary secondary
@ -255,7 +231,7 @@
View templates View templates
</Button> </Button>
{/if} {/if}
{#if !$apps?.length} {#if !$appsStore.apps?.length}
<Button <Button
size="L" size="L"
quiet quiet
@ -267,11 +243,14 @@
{/if} {/if}
</div> </div>
{/if} {/if}
{#if enrichedApps.length > 1} {#if $appsStore.apps.length > 1}
<div class="app-actions"> <div class="app-actions">
<Select <Select
autoWidth autoWidth
bind:value={sortBy} value={$appsStore.sortBy}
on:change={e => {
appsStore.updateSort(e.detail)
}}
placeholder={null} placeholder={null}
options={[ options={[
{ label: "Sort by name", value: "name" }, { label: "Sort by name", value: "name" },
@ -279,7 +258,17 @@
{ label: "Sort by status", value: "status" }, { label: "Sort by status", value: "status" },
]} ]}
/> />
<Search placeholder="Search" bind:value={searchTerm} /> <Search
placeholder="Search"
on:input={e => {
searchTerm = e.target.value
}}
on:change={e => {
if (!e.detail) {
searchTerm = null
}
}}
/>
</div> </div>
{/if} {/if}
</div> </div>

View file

@ -13,7 +13,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { Breadcrumb, Breadcrumbs } from "components/portal/page" import { Breadcrumb, Breadcrumbs } from "components/portal/page"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import { apps, auth, groups } from "stores/portal" import { appsStore, auth, groups } from "stores/portal"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte" import AppNameTableRenderer from "../users/_components/AppNameTableRenderer.svelte"
import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte" import AppRoleTableRenderer from "../users/_components/AppRoleTableRenderer.svelte"
@ -51,17 +51,17 @@
$: isScimGroup = group?.scimInfo?.isSync $: isScimGroup = group?.scimInfo?.isSync
$: isAdmin = sdk.users.isAdmin($auth.user) $: isAdmin = sdk.users.isAdmin($auth.user)
$: readonly = !isAdmin || isScimGroup $: readonly = !isAdmin || isScimGroup
$: groupApps = $apps $: groupApps = $appsStore.apps
.filter(app => .filter(app =>
groups.actions groups.actions
.getGroupAppIds(group) .getGroupAppIds(group)
.includes(apps.getProdAppID(app.devId)) .includes(appsStore.getProdAppID(app.devId))
) )
.map(app => ({ .map(app => ({
...app, ...app,
role: group?.builder?.apps.includes(apps.getProdAppID(app.devId)) role: group?.builder?.apps.includes(appsStore.getProdAppID(app.devId))
? Constants.Roles.CREATOR ? Constants.Roles.CREATOR
: group?.roles?.[apps.getProdAppID(app.devId)], : group?.roles?.[appsStore.getProdAppID(app.devId)],
})) }))
$: { $: {
@ -93,7 +93,7 @@
} }
const removeApp = async app => { const removeApp = async app => {
await groups.actions.removeApp(groupId, apps.getProdAppID(app.devId)) await groups.actions.removeApp(groupId, appsStore.getProdAppID(app.devId))
} }
setContext("roles", { setContext("roles", {
updateRole: () => {}, updateRole: () => {},

View file

@ -1,12 +1,12 @@
<script> <script>
import { keepOpen, Body, ModalContent, Select } from "@budibase/bbui" import { keepOpen, Body, ModalContent, Select } from "@budibase/bbui"
import { apps, groups } from "stores/portal" import { appsStore, groups } from "stores/portal"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import RoleSelect from "components/common/RoleSelect.svelte" import RoleSelect from "components/common/RoleSelect.svelte"
export let group export let group
$: appOptions = $apps.map(app => ({ $: appOptions = $appsStore.apps.map(app => ({
label: app.name, label: app.name,
value: app, value: app,
})) }))
@ -16,7 +16,7 @@
let selectingRole = false let selectingRole = false
async function appSelected() { async function appSelected() {
const prodAppId = apps.getProdAppID(selectedApp.devId) const prodAppId = appsStore.getProdAppID(selectedApp.devId)
if (!selectingRole) { if (!selectingRole) {
selectingRole = true selectingRole = true
await roles.fetchByAppId(prodAppId) await roles.fetchByAppId(prodAppId)

View file

@ -18,7 +18,7 @@
Table, Table,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount, setContext } from "svelte" import { onMount, setContext } from "svelte"
import { users, auth, groups, apps, licensing } from "stores/portal" import { users, auth, groups, appsStore, licensing } from "stores/portal"
import { roles } from "stores/builder" import { roles } from "stores/builder"
import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte" import ForceResetPasswordModal from "./_components/ForceResetPasswordModal.svelte"
import UserGroupPicker from "components/settings/UserGroupPicker.svelte" import UserGroupPicker from "components/settings/UserGroupPicker.svelte"
@ -97,7 +97,7 @@
$: privileged = sdk.users.isAdminOrGlobalBuilder(user) $: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups(internalGroups, searchTerm) $: filteredGroups = getFilteredGroups(internalGroups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($appsStore.apps, privileged, user?.roles)
$: userGroups = $groups.filter(x => { $: userGroups = $groups.filter(x => {
return x.users?.find(y => { return x.users?.find(y => {
return y._id === userId return y._id === userId
@ -111,12 +111,12 @@
availableApps = availableApps.filter(x => { availableApps = availableApps.filter(x => {
let roleKeys = Object.keys(roles || {}) let roleKeys = Object.keys(roles || {})
return roleKeys.concat(user?.builder?.apps).find(y => { return roleKeys.concat(user?.builder?.apps).find(y => {
return x.appId === apps.extractAppId(y) return x.appId === appsStore.extractAppId(y)
}) })
}) })
} }
return availableApps.map(app => { return availableApps.map(app => {
const prodAppId = apps.getProdAppID(app.devId) const prodAppId = appsStore.getProdAppID(app.devId)
return { return {
name: app.name, name: app.name,
devId: app.devId, devId: app.devId,

View file

@ -1,6 +1,6 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { apps } from "stores/portal" import { appsStore } from "stores/portal"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
export let value export let value
@ -10,7 +10,7 @@
const getCount = () => { const getCount = () => {
if (priviliged) { if (priviliged) {
return $apps.length return $appsStore.apps.length
} else { } else {
return sdk.users.hasAppBuilderPermissions(row) return sdk.users.hasAppBuilderPermissions(row)
? row?.builder?.apps?.length + ? row?.builder?.apps?.length +

View file

@ -1,5 +1,5 @@
import { API } from "api" import { API } from "api"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_APP_META_STATE = { export const INITIAL_APP_META_STATE = {
appId: "", appId: "",

View file

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { createBuilderWebsocket } from "./websocket.js" import { createBuilderWebsocket } from "./websocket.js"
import { BuilderSocketEvent } from "@budibase/shared-core" import { BuilderSocketEvent } from "@budibase/shared-core"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore.js"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
export const INITIAL_BUILDER_STATE = { export const INITIAL_BUILDER_STATE = {

View file

@ -27,7 +27,7 @@ import {
DB_TYPE_INTERNAL, DB_TYPE_INTERNAL,
DB_TYPE_EXTERNAL, DB_TYPE_EXTERNAL,
} from "constants/backend" } from "constants/backend"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import componentTreeNodesStore from "stores/portal/componentTreeNodesStore" import componentTreeNodesStore from "stores/portal/componentTreeNodesStore"

View file

@ -1,6 +1,6 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { previewStore } from "stores/builder" import { previewStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_HOVER_STATE = { export const INITIAL_HOVER_STATE = {
componentId: null, componentId: null,

View file

@ -1,6 +1,6 @@
import { derived, get } from "svelte/store" import { derived, get } from "svelte/store"
import { componentStore } from "stores/builder" import { componentStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
import { API } from "api" import { API } from "api"
export const INITIAL_LAYOUT_STATE = { export const INITIAL_LAYOUT_STATE = {

View file

@ -1,7 +1,7 @@
import { get } from "svelte/store" import { get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { appStore } from "stores/builder" import { appStore } from "stores/builder"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_NAVIGATION_STATE = { export const INITIAL_NAVIGATION_STATE = {
navigation: "Top", navigation: "Top",

View file

@ -12,7 +12,7 @@ import {
} from "stores/builder" } from "stores/builder"
import { createHistoryStore } from "stores/builder/history" import { createHistoryStore } from "stores/builder/history"
import { API } from "api" import { API } from "api"
import BudiStore from "./BudiStore" import BudiStore from "../BudiStore"
export const INITIAL_SCREENS_STATE = { export const INITIAL_SCREENS_STATE = {
screens: [], screens: [],

View file

@ -11,7 +11,7 @@ import {
tables, tables,
} from "stores/builder" } from "stores/builder"
import { get } from "svelte/store" import { get } from "svelte/store"
import { auth, apps } from "stores/portal" import { auth, appsStore } from "stores/portal"
import { screenStore } from "./screens" import { screenStore } from "./screens"
import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core" import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
@ -70,7 +70,7 @@ export const createBuilderWebsocket = appId => {
socket.onOther( socket.onOther(
BuilderSocketEvent.AppPublishChange, BuilderSocketEvent.AppPublishChange,
async ({ user, published }) => { async ({ user, published }) => {
await apps.load() await appsStore.load()
if (published) { if (published) {
await deploymentStore.load() await deploymentStore.load()
} }

View file

@ -1,39 +1,61 @@
import { writable } from "svelte/store" import { derived } from "svelte/store"
import { AppStatus } from "constants" import { AppStatus } from "constants"
import { API } from "api" import { API } from "api"
import { auth } from "./auth"
import BudiStore from "../BudiStore" // move this
// properties that should always come from the dev app, not the deployed // properties that should always come from the dev app, not the deployed
const DEV_PROPS = ["updatedBy", "updatedAt"] const DEV_PROPS = ["updatedBy", "updatedAt"]
const extractAppId = id => { export const INITIAL_APPS_STATE = {
const split = id?.split("_") || [] apps: [],
return split.length ? split[split.length - 1] : null sortBy: "name",
} }
const getProdAppID = appId => { export class AppsStore extends BudiStore {
if (!appId) { constructor() {
return appId super({ ...INITIAL_APPS_STATE })
}
let rest,
separator = ""
if (appId.startsWith("app_dev")) {
// split to take off the app_dev element, then join it together incase any other app_ exist
const split = appId.split("app_dev")
split.shift()
rest = split.join("app_dev")
} else if (!appId.startsWith("app")) {
rest = appId
separator = "_"
} else {
return appId
}
return `app${separator}${rest}`
}
export function createAppStore() { this.extractAppId = this.extractAppId.bind(this)
const store = writable([]) this.getProdAppID = this.getProdAppID.bind(this)
this.updateSort = this.updateSort.bind(this)
this.load = this.load.bind(this)
this.save = this.save.bind(this)
}
async function load() { extractAppId(id) {
const split = id?.split("_") || []
return split.length ? split[split.length - 1] : null
}
getProdAppID(appId) {
if (!appId) {
return appId
}
let rest,
separator = ""
if (appId.startsWith("app_dev")) {
// split to take off the app_dev element, then join it together incase any other app_ exist
const split = appId.split("app_dev")
split.shift()
rest = split.join("app_dev")
} else if (!appId.startsWith("app")) {
rest = appId
separator = "_"
} else {
return appId
}
return `app${separator}${rest}`
}
updateSort(sortBy) {
this.update(state => ({
...state,
sortBy,
}))
}
async load() {
const json = await API.getApps() const json = await API.getApps()
if (Array.isArray(json)) { if (Array.isArray(json)) {
// Merge apps into one sensible list // Merge apps into one sensible list
@ -43,7 +65,7 @@ export function createAppStore() {
// First append all dev app version // First append all dev app version
devApps.forEach(app => { devApps.forEach(app => {
const id = extractAppId(app.appId) const id = this.extractAppId(app.appId)
appMap[id] = { appMap[id] = {
...app, ...app,
devId: app.appId, devId: app.appId,
@ -53,7 +75,7 @@ export function createAppStore() {
// Then merge with all prod app versions // Then merge with all prod app versions
deployedApps.forEach(app => { deployedApps.forEach(app => {
const id = extractAppId(app.appId) const id = this.extractAppId(app.appId)
// Skip any deployed apps which don't have a dev counterpart // Skip any deployed apps which don't have a dev counterpart
if (!appMap[id]) { if (!appMap[id]) {
@ -81,39 +103,80 @@ export function createAppStore() {
// Transform into an array and clean up // Transform into an array and clean up
const apps = Object.values(appMap) const apps = Object.values(appMap)
apps.forEach(app => { apps.forEach(app => {
app.appId = extractAppId(app.devId) app.appId = this.extractAppId(app.devId)
delete app._id delete app._id
delete app._rev delete app._rev
}) })
store.set(apps) this.update(state => ({
...state,
apps,
}))
} else { } else {
store.set([]) this.update(state => ({
...state,
apps: [],
}))
} }
} }
async function update(appId, value) { async save(appId, value) {
await API.saveAppMetadata({ await API.saveAppMetadata({
appId, appId,
metadata: value, metadata: value,
}) })
store.update(state => { this.update(state => {
const updatedAppIndex = state.findIndex(app => app.instance._id === appId) const updatedAppIndex = state.apps.findIndex(
app => app.instance._id === appId
)
if (updatedAppIndex !== -1) { if (updatedAppIndex !== -1) {
let updatedApp = state[updatedAppIndex] let updatedApp = state.apps[updatedAppIndex]
updatedApp = { ...updatedApp, ...value } updatedApp = { ...updatedApp, ...value }
state.apps = state.splice(updatedAppIndex, 1, updatedApp) state.apps = state.apps.splice(updatedAppIndex, 1, updatedApp)
} }
return state return state
}) })
} }
return {
subscribe: store.subscribe,
load,
update,
extractAppId,
getProdAppID,
}
} }
export const apps = createAppStore() export const appsStore = new AppsStore()
// Centralise any logic that enriches the apps list
export const enrichedApps = derived([appsStore, auth], ([$store, $auth]) => {
const enrichedApps = $store.apps
? $store.apps.map(app => ({
...app,
deployed: app.status === AppStatus.DEPLOYED,
lockedYou: app.lockedBy && app.lockedBy.email === $auth.user?.email,
lockedOther: app.lockedBy && app.lockedBy.email !== $auth.user?.email,
favourite: $auth?.user.appFavourites?.includes(app.appId),
}))
: []
if ($store.sortBy === "status") {
return enrichedApps.sort((a, b) => {
if (a.favourite === b.favourite) {
if (a.status === b.status) {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
}
return a.status === AppStatus.DEPLOYED ? -1 : 1
}
return a.favourite ? -1 : 1
})
} else if ($store.sortBy === "updated") {
return enrichedApps?.sort((a, b) => {
if (a.favourite === b.favourite) {
const aUpdated = a.updatedAt || "9999"
const bUpdated = b.updatedAt || "9999"
return aUpdated < bUpdated ? 1 : -1
}
return a.favourite ? -1 : 1
})
} else {
return enrichedApps?.sort((a, b) => {
if (a.favourite === b.favourite) {
return a.name?.toLowerCase() < b.name?.toLowerCase() ? -1 : 1
}
return a.favourite ? -1 : 1
})
}
})

View file

@ -3,7 +3,7 @@ import { writable } from "svelte/store"
export { organisation } from "./organisation" export { organisation } from "./organisation"
export { users } from "./users" export { users } from "./users"
export { admin } from "./admin" export { admin } from "./admin"
export { apps } from "./apps" export { appsStore, enrichedApps } from "./apps"
export { email } from "./email" export { email } from "./email"
export { auth } from "./auth" export { auth } from "./auth"
export { oidc } from "./oidc" export { oidc } from "./oidc"

View file

@ -682,7 +682,10 @@ export async function duplicateApp(
// Build a new request // Build a new request
const createRequest = { const createRequest = {
roleId: ctx.roleId, roleId: ctx.roleId,
user: ctx.user, user: {
...ctx.user,
_id: dbCore.getGlobalIDFromUserMetadataID(ctx.user._id || ""),
},
request: { request: {
body: createRequestBody, body: createRequestBody,
}, },

View file

@ -18,6 +18,7 @@ export interface UpdateSelfRequest {
password?: string password?: string
forceResetPassword?: boolean forceResetPassword?: boolean
onboardedAt?: string onboardedAt?: string
appFavourites?: string[]
tours?: Record<string, Date> tours?: Record<string, Date>
} }

View file

@ -57,6 +57,7 @@ export interface User extends Document {
onboardedAt?: string onboardedAt?: string
tours?: Record<string, Date> tours?: Record<string, Date>
scimInfo?: { isSync: true } & Record<string, any> scimInfo?: { isSync: true } & Record<string, any>
appFavourites?: string[]
ssoId?: string ssoId?: string
} }

View file

@ -9,7 +9,12 @@ import {
} from "@budibase/backend-core" } from "@budibase/backend-core"
import env from "../../../environment" import env from "../../../environment"
import { groups } from "@budibase/pro" import { groups } from "@budibase/pro"
import { UpdateSelfRequest, UpdateSelfResponse, UserCtx } from "@budibase/types" import {
UpdateSelfRequest,
UpdateSelfResponse,
User,
UserCtx,
} from "@budibase/types"
const { newid } = utils const { newid } = utils
@ -105,16 +110,63 @@ export async function getSelf(ctx: any) {
addSessionAttributesToUser(ctx) addSessionAttributesToUser(ctx)
} }
export const syncAppFavourites = async (processedAppIds: string[]) => {
if (processedAppIds.length === 0) {
return []
}
const apps = await fetchAppsByIds(processedAppIds)
return apps?.reduce((acc: string[], app) => {
const id = app.appId.replace(dbCore.APP_DEV_PREFIX, "")
if (processedAppIds.includes(id)) {
acc.push(id)
}
return acc
}, [])
}
export const fetchAppsByIds = async (processedAppIds: string[]) => {
return await dbCore.getAppsByIDs(
processedAppIds.map(appId => `${dbCore.APP_DEV_PREFIX}${appId}`)
)
}
const processUserAppFavourites = async (
user: User,
update: UpdateSelfRequest
) => {
if (!("appFavourites" in update)) {
// Ignore requests without an explicit update to favourites.
return
}
const userAppFavourites = user.appFavourites || []
const requestAppFavourites = new Set(update.appFavourites || [])
const containsAll = userAppFavourites.every(v => requestAppFavourites.has(v))
if (containsAll && requestAppFavourites.size === userAppFavourites.length) {
// Ignore request if the outcome will have no change
return
}
// Clean up the request by purging apps that no longer exist.
const syncedAppFavourites = await syncAppFavourites([...requestAppFavourites])
return syncedAppFavourites
}
export async function updateSelf( export async function updateSelf(
ctx: UserCtx<UpdateSelfRequest, UpdateSelfResponse> ctx: UserCtx<UpdateSelfRequest, UpdateSelfResponse>
) { ) {
const update = ctx.request.body const update = ctx.request.body
let user = await userSdk.db.getUser(ctx.user._id!) let user = await userSdk.db.getUser(ctx.user._id!)
const updatedAppFavourites = await processUserAppFavourites(user, update)
user = { user = {
...user, ...user,
...update, ...update,
...(updatedAppFavourites ? { appFavourites: updatedAppFavourites } : {}),
} }
user = await userSdk.db.save(user, { requirePassword: false }) user = await userSdk.db.save(user, { requirePassword: false })
if (update.password) { if (update.password) {

View file

@ -26,6 +26,7 @@ export const buildSelfSaveValidation = () => {
firstName: OPTIONAL_STRING, firstName: OPTIONAL_STRING,
lastName: OPTIONAL_STRING, lastName: OPTIONAL_STRING,
onboardedAt: Joi.string().optional(), onboardedAt: Joi.string().optional(),
appFavourites: Joi.array().optional(),
tours: Joi.object().optional(), tours: Joi.object().optional(),
} }
return auth.joiValidator.body(Joi.object(schema).required().unknown(false)) return auth.joiValidator.body(Joi.object(schema).required().unknown(false))