Merge pull request #11135 from Budibase/design-collab

Multi-user collaboration for everything except automations
This commit is contained in:
Andrew Kingston 2023-07-05 16:32:24 +01:00 committed by GitHub
commit 0ef0da6b78
45 changed files with 660 additions and 783 deletions

View File

@ -16,8 +16,6 @@
export let tooltip = undefined export let tooltip = undefined
export let newStyles = true export let newStyles = true
export let id export let id
let showTooltip = false
</script> </script>
<button <button
@ -35,9 +33,6 @@
class="spectrum-Button spectrum-Button--size{size.toUpperCase()}" class="spectrum-Button spectrum-Button--size{size.toUpperCase()}"
{disabled} {disabled}
on:click|preventDefault on:click|preventDefault
on:mouseover={() => (showTooltip = true)}
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
> >
{#if icon} {#if icon}
<svg <svg
@ -52,19 +47,7 @@
{#if $$slots} {#if $$slots}
<span class="spectrum-Button-label"><slot /></span> <span class="spectrum-Button-label"><slot /></span>
{/if} {/if}
{#if !disabled && tooltip} {#if tooltip}
<div class="tooltip-icon">
<svg
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
focusable="false"
aria-hidden="true"
aria-label="Info"
>
<use xlink:href="#spectrum-icon-18-InfoOutline" />
</svg>
</div>
{/if}
{#if showTooltip && tooltip}
<div class="tooltip"> <div class="tooltip">
<Tooltip textWrapping={true} direction={"bottom"} text={tooltip} /> <Tooltip textWrapping={true} direction={"bottom"} text={tooltip} />
</div> </div>
@ -75,7 +58,6 @@
button { button {
position: relative; position: relative;
} }
.spectrum-Button-label { .spectrum-Button-label {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@ -93,11 +75,13 @@
text-align: center; text-align: center;
transform: translateX(-50%); transform: translateX(-50%);
left: 50%; left: 50%;
top: calc(100% - 3px); top: 100%;
opacity: 0;
transition: opacity 130ms ease-out;
pointer-events: none;
} }
.tooltip-icon { button:hover .tooltip {
padding-left: var(--spacing-m); opacity: 1;
line-height: 0;
} }
.spectrum-Button--primary.new-styles { .spectrum-Button--primary.new-styles {
background: var(--spectrum-global-color-gray-800); background: var(--spectrum-global-color-gray-800);

View File

@ -1,6 +1,7 @@
<script> <script>
import "@spectrum-css/link/dist/index-vars.css" import "@spectrum-css/link/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
export let href = "#" export let href = "#"
export let size = "M" export let size = "M"
@ -10,18 +11,61 @@
export let overBackground = false export let overBackground = false
export let target export let target
export let download export let download
export let disabled = false
export let tooltip = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onClick = e => {
if (!disabled) {
dispatch("click")
e.stopPropagation()
}
}
</script> </script>
<a <a
on:click={e => dispatch("click") && e.stopPropagation()} on:click={onClick}
{href} {href}
{target} {target}
{download} {download}
class:disabled
class:spectrum-Link--primary={primary} class:spectrum-Link--primary={primary}
class:spectrum-Link--secondary={secondary} class:spectrum-Link--secondary={secondary}
class:spectrum-Link--overBackground={overBackground} class:spectrum-Link--overBackground={overBackground}
class:spectrum-Link--quiet={quiet} class:spectrum-Link--quiet={quiet}
class="spectrum-Link spectrum-Link--size{size}"><slot /></a class="spectrum-Link spectrum-Link--size{size}"
> >
<slot />
{#if tooltip}
<div class="tooltip">
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</a>
<style>
a {
position: relative;
}
a.disabled {
color: var(--spectrum-global-color-gray-500);
}
a.disabled:hover {
text-decoration: none;
cursor: default;
}
.tooltip {
position: absolute;
left: 50%;
top: 100%;
transform: translateX(-50%);
opacity: 0;
transition: 130ms ease-out;
pointer-events: none;
z-index: 100;
}
a:hover .tooltip {
opacity: 1;
}
</style>

View File

@ -90,6 +90,6 @@
.spectrum-Popover { .spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000); min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
overflow: auto; overflow: visible;
} }
</style> </style>

View File

@ -3,6 +3,7 @@ import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal" import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme" import { getThemeStore } from "./store/theme"
import { getUserStore } from "./store/users" import { getUserStore } from "./store/users"
import { getDeploymentStore } from "./store/deployments"
import { derived } from "svelte/store" import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils" import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
@ -14,6 +15,7 @@ export const automationStore = getAutomationStore()
export const themeStore = getThemeStore() export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore() export const temporalStore = getTemporalStore()
export const userStore = getUserStore() export const userStore = getUserStore()
export const deploymentStore = getDeploymentStore()
// Setup history for screens // Setup history for screens
export const screenHistoryStore = createHistoryStore({ export const screenHistoryStore = createHistoryStore({
@ -118,3 +120,20 @@ export const selectedAutomation = derived(automationStore, $automationStore => {
x => x._id === $automationStore.selectedAutomationId x => x._id === $automationStore.selectedAutomationId
) )
}) })
// Derive map of resource IDs to other users.
// We only ever care about a single user in each resource, so if multiple users
// share the same datasource we can just overwrite them.
export const userSelectedResourceMap = derived(userStore, $userStore => {
let map = {}
$userStore.forEach(user => {
if (user.builderMetadata?.selectedResourceId) {
map[user.builderMetadata?.selectedResourceId] = user
}
})
return map
})
export const isOnlyUser = derived(userStore, $userStore => {
return $userStore.length === 1
})

View File

@ -0,0 +1,22 @@
import { writable } from "svelte/store"
import { API } from "api"
import { notifications } from "@budibase/bbui"
export const getDeploymentStore = () => {
let store = writable([])
const load = async () => {
try {
store.set(await API.getAppDeployments())
} catch (err) {
notifications.error("Error fetching deployments")
}
}
return {
subscribe: store.subscribe,
actions: {
load,
},
}
}

View File

@ -38,6 +38,7 @@ import {
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields" import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket" import { createBuilderWebsocket } from "builderStore/websocket"
import { BuilderSocketEvent } from "@budibase/shared-core"
const INITIAL_FRONTEND_STATE = { const INITIAL_FRONTEND_STATE = {
initialised: false, initialised: false,
@ -353,6 +354,33 @@ export const getFrontendStore = () => {
} }
return await sequentialScreenPatch(patchFn, screenId) return await sequentialScreenPatch(patchFn, screenId)
}, },
replace: async (screenId, screen) => {
if (!screenId) {
return
}
if (!screen) {
// Screen deletion
store.update(state => ({
...state,
screens: state.screens.filter(x => x._id !== screenId),
}))
} else {
const index = get(store).screens.findIndex(x => x._id === screen._id)
if (index === -1) {
// Screen addition
store.update(state => ({
...state,
screens: [...state.screens, screen],
}))
} else {
// Screen update
store.update(state => {
state.screens[index] = screen
return state
})
}
}
},
delete: async screens => { delete: async screens => {
const screensToDelete = Array.isArray(screens) ? screens : [screens] const screensToDelete = Array.isArray(screens) ? screens : [screens]
@ -1365,6 +1393,21 @@ export const getFrontendStore = () => {
}) })
}, },
}, },
websocket: {
selectResource: id => {
websocket.emit(BuilderSocketEvent.SelectResource, {
resourceId: id,
})
},
},
metadata: {
replace: metadata => {
store.update(state => ({
...state,
...metadata,
}))
},
},
} }
return store return store

View File

@ -1,10 +1,12 @@
import { createWebsocket } from "@budibase/frontend-core" import { createWebsocket } from "@budibase/frontend-core"
import { userStore, store } from "builderStore" import { userStore, store, deploymentStore } from "builderStore"
import { datasources, tables } from "stores/backend" import { datasources, tables } from "stores/backend"
import { get } from "svelte/store" import { get } from "svelte/store"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core" import { SocketEvent, BuilderSocketEvent } from "@budibase/shared-core"
import { apps } from "stores/portal"
import { notifications } from "@budibase/bbui" import { notifications } from "@budibase/bbui"
import { helpers } from "@budibase/shared-core"
export const createBuilderWebsocket = appId => { export const createBuilderWebsocket = appId => {
const socket = createWebsocket("/socket/builder") const socket = createWebsocket("/socket/builder")
@ -31,7 +33,6 @@ export const createBuilderWebsocket = appId => {
}) })
socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => { socket.onOther(BuilderSocketEvent.LockTransfer, ({ userId }) => {
if (userId === get(auth)?.user?._id) { if (userId === get(auth)?.user?._id) {
notifications.success("You can now edit screens and automations")
store.update(state => ({ store.update(state => ({
...state, ...state,
hasLock: true, hasLock: true,
@ -39,15 +40,32 @@ export const createBuilderWebsocket = appId => {
} }
}) })
// Table events // Data section events
socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => { socket.onOther(BuilderSocketEvent.TableChange, ({ id, table }) => {
tables.replaceTable(id, table) tables.replaceTable(id, table)
}) })
// Datasource events
socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => { socket.onOther(BuilderSocketEvent.DatasourceChange, ({ id, datasource }) => {
datasources.replaceDatasource(id, datasource) datasources.replaceDatasource(id, datasource)
}) })
// Design section events
socket.onOther(BuilderSocketEvent.ScreenChange, ({ id, screen }) => {
store.actions.screens.replace(id, screen)
})
socket.onOther(BuilderSocketEvent.AppMetadataChange, ({ metadata }) => {
store.actions.metadata.replace(metadata)
})
socket.onOther(
BuilderSocketEvent.AppPublishChange,
async ({ user, published }) => {
await apps.load()
if (published) {
await deploymentStore.actions.load()
}
const verb = published ? "published" : "unpublished"
notifications.success(`${helpers.getUserLabel(user)} ${verb} this app`)
}
)
return socket return socket
} }

View File

@ -13,6 +13,7 @@
} from "helpers/data/utils" } from "helpers/data/utils"
import IntegrationIcon from "./IntegrationIcon.svelte" import IntegrationIcon from "./IntegrationIcon.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import { userSelectedResourceMap } from "builderStore"
let openDataSources = [] let openDataSources = []
@ -166,6 +167,7 @@
selected={$isActive("./table/:tableId") && selected={$isActive("./table/:tableId") &&
$tables.selected?._id === TableNames.USERS} $tables.selected?._id === TableNames.USERS}
on:click={() => selectTable(TableNames.USERS)} on:click={() => selectTable(TableNames.USERS)}
selectedBy={$userSelectedResourceMap[TableNames.USERS]}
/> />
{#each enrichedDataSources as datasource, idx} {#each enrichedDataSources as datasource, idx}
<NavItem <NavItem
@ -176,6 +178,7 @@
withArrow={true} withArrow={true}
on:click={() => selectDatasource(datasource)} on:click={() => selectDatasource(datasource)}
on:iconClick={() => toggleNode(datasource)} on:iconClick={() => toggleNode(datasource)}
selectedBy={$userSelectedResourceMap[datasource._id]}
> >
<div class="datasource-icon" slot="icon"> <div class="datasource-icon" slot="icon">
<IntegrationIcon <IntegrationIcon
@ -201,6 +204,7 @@
selected={$isActive("./query/:queryId") && selected={$isActive("./query/:queryId") &&
$queries.selectedQueryId === query._id} $queries.selectedQueryId === query._id}
on:click={() => $goto(`./query/${query._id}`)} on:click={() => $goto(`./query/${query._id}`)}
selectedBy={$userSelectedResourceMap[query._id]}
> >
<EditQueryPopover {query} /> <EditQueryPopover {query} />
</NavItem> </NavItem>
@ -212,7 +216,7 @@
<style> <style>
.hierarchy-items-container { .hierarchy-items-container {
margin: 0 calc(-1 * var(--spacing-xl)); margin: 0 calc(-1 * var(--spacing-l));
} }
.datasource-icon { .datasource-icon {
display: grid; display: grid;

View File

@ -5,6 +5,7 @@
import EditViewPopover from "./popovers/EditViewPopover.svelte" import EditViewPopover from "./popovers/EditViewPopover.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import { goto, isActive } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { userSelectedResourceMap } from "builderStore"
const alphabetical = (a, b) => const alphabetical = (a, b) =>
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1 a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
@ -30,6 +31,7 @@
selected={$isActive("./table/:tableId") && selected={$isActive("./table/:tableId") &&
$tables.selected?._id === table._id} $tables.selected?._id === table._id}
on:click={() => selectTable(table._id)} on:click={() => selectTable(table._id)}
selectedBy={$userSelectedResourceMap[table._id]}
> >
{#if table._id !== TableNames.USERS} {#if table._id !== TableNames.USERS}
<EditTablePopover {table} /> <EditTablePopover {table} />
@ -42,6 +44,7 @@
text={viewName} text={viewName}
selected={$isActive("./view") && $views.selected?.name === viewName} selected={$isActive("./view") && $views.selected?.name === viewName}
on:click={() => $goto(`./view/${encodeURIComponent(viewName)}`)} on:click={() => $goto(`./view/${encodeURIComponent(viewName)}`)}
selectedBy={$userSelectedResourceMap[viewName]}
> >
<EditViewPopover <EditViewPopover
view={{ name: viewName, ...table.views[viewName] }} view={{ name: viewName, ...table.views[viewName] }}

View File

@ -208,8 +208,8 @@
async function deployApp() { async function deployApp() {
try { try {
await API.deployAppChanges() await API.publishAppChanges($store.appId)
notifications.success("Application published successfully") notifications.success("App published successfully")
} catch (error) { } catch (error) {
notifications.error("Error publishing app") notifications.error("Error publishing app")
} }

View File

@ -1,6 +1,7 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { helpers } from "@budibase/shared-core"
export let icon export let icon
export let withArrow = false export let withArrow = false
@ -18,12 +19,15 @@
export let rightAlignIcon = false export let rightAlignIcon = false
export let id export let id
export let showTooltip = false export let showTooltip = false
export let selectedBy = null
const scrollApi = getContext("scroll") const scrollApi = getContext("scroll")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let contentRef let contentRef
$: selected && contentRef && scrollToView() $: selected && contentRef && scrollToView()
$: style = getStyle(indentLevel, selectedBy)
const onClick = () => { const onClick = () => {
scrollToView() scrollToView()
@ -42,6 +46,14 @@
const bounds = contentRef.getBoundingClientRect() const bounds = contentRef.getBoundingClientRect()
scrollApi.scrollTo(bounds) scrollApi.scrollTo(bounds)
} }
const getStyle = (indentLevel, selectedBy) => {
let style = `padding-left:calc(${indentLevel * 14}px);`
if (selectedBy) {
style += `--selected-by-color:${helpers.getUserColor(selectedBy)};`
}
return style
}
</script> </script>
<div <div
@ -51,8 +63,7 @@
class:withActions class:withActions
class:scrollable class:scrollable
class:highlighted class:highlighted
style={`padding-left: calc(${indentLevel * 14}px)`} class:selectedBy
{draggable}
on:dragend on:dragend
on:dragstart on:dragstart
on:dragover on:dragover
@ -61,6 +72,8 @@
ondragover="return false" ondragover="return false"
ondragenter="return false" ondragenter="return false"
{id} {id}
{style}
{draggable}
> >
<div class="nav-item-content" bind:this={contentRef}> <div class="nav-item-content" bind:this={contentRef}>
{#if withArrow} {#if withArrow}
@ -97,6 +110,9 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if selectedBy}
<div class="selected-by-label">{helpers.getUserLabel(selectedBy)}</div>
{/if}
</div> </div>
<style> <style>
@ -111,6 +127,7 @@
flex-direction: row; flex-direction: row;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
position: relative;
} }
.nav-item.scrollable { .nav-item.scrollable {
flex-direction: column; flex-direction: column;
@ -142,6 +159,37 @@
padding-left: var(--spacing-l); padding-left: var(--spacing-l);
} }
/* Selected user styles */
.nav-item.selectedBy:after {
content: "";
position: absolute;
width: calc(100% - 4px);
height: 28px;
border: 2px solid var(--selected-by-color);
left: 0;
top: 0;
border-radius: 2px;
pointer-events: none;
}
.selected-by-label {
position: absolute;
top: 0;
right: 0;
background: var(--selected-by-color);
padding: 2px 4px;
font-size: 12px;
color: white;
transform: translateY(calc(1px - 100%));
border-top-right-radius: 2px;
border-top-left-radius: 2px;
pointer-events: none;
opacity: 0;
transition: opacity 130ms ease-out;
}
.nav-item.selectedBy:hover .selected-by-label {
opacity: 1;
}
/* Needed to fully display the actions icon */ /* Needed to fully display the actions icon */
.nav-item.scrollable .nav-item-content { .nav-item.scrollable .nav-item-content {
padding-right: 1px; padding-right: 1px;

View File

@ -14,15 +14,12 @@
import RevertModal from "components/deploy/RevertModal.svelte" import RevertModal from "components/deploy/RevertModal.svelte"
import VersionModal from "components/deploy/VersionModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte"
import UpdateAppModal from "components/start/UpdateAppModal.svelte" import UpdateAppModal from "components/start/UpdateAppModal.svelte"
import { processStringSync } from "@budibase/string-templates" import { processStringSync } from "@budibase/string-templates"
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 { checkIncomingDeploymentStatus } from "components/deploy/utils"
import { API } from "api" import { API } from "api"
import { onMount } from "svelte"
import { apps } from "stores/portal" import { apps } from "stores/portal"
import { store } from "builderStore" import { deploymentStore, store, isOnlyUser } from "builderStore"
import TourWrap from "components/portal/onboarding/TourWrap.svelte" import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
@ -34,37 +31,31 @@
let updateAppModal let updateAppModal
let revertModal let revertModal
let versionModal let versionModal
let appActionPopover let appActionPopover
let appActionPopoverOpen = false let appActionPopoverOpen = false
let appActionPopoverAnchor let appActionPopoverAnchor
let publishing = false let publishing = false
$: filteredApps = $apps.filter(app => app.devId === application) $: filteredApps = $apps.filter(app => app.devId === application)
$: selectedApp = filteredApps?.length ? filteredApps[0] : null $: selectedApp = filteredApps?.length ? filteredApps[0] : null
$: latestDeployments = $deploymentStore
$: deployments = []
$: latestDeployments = deployments
.filter(deployment => deployment.status === "SUCCESS") .filter(deployment => deployment.status === "SUCCESS")
.sort((a, b) => a.updatedAt > b.updatedAt) .sort((a, b) => a.updatedAt > b.updatedAt)
$: isPublished = $: isPublished =
selectedApp?.status === "published" && latestDeployments?.length > 0 selectedApp?.status === "published" && latestDeployments?.length > 0
$: updateAvailable = $: updateAvailable =
$store.upgradableVersion && $store.upgradableVersion &&
$store.version && $store.version &&
$store.upgradableVersion !== $store.version $store.upgradableVersion !== $store.version
$: canPublish = !publishing && loaded $: canPublish = !publishing && loaded
$: lastDeployed = getLastDeployedString($deploymentStore)
const initialiseApp = async () => { const initialiseApp = async () => {
const applicationPkg = await API.fetchAppPackage($store.devId) const applicationPkg = await API.fetchAppPackage($store.devId)
await store.actions.initialise(applicationPkg) await store.actions.initialise(applicationPkg)
} }
const updateDeploymentString = () => { const getLastDeployedString = deployments => {
return deployments?.length return deployments?.length
? processStringSync("Published {{ duration time 'millisecond' }} ago", { ? processStringSync("Published {{ duration time 'millisecond' }} ago", {
time: time:
@ -73,27 +64,6 @@
: "" : ""
} }
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 overview")
}
}
const previewApp = () => { const previewApp = () => {
store.update(state => ({ store.update(state => ({
...state, ...state,
@ -116,14 +86,11 @@
async function publishApp() { async function publishApp() {
try { try {
publishing = true publishing = true
await API.publishAppChanges($store.appId) await API.publishAppChanges($store.appId)
notifications.send("App published successfully", {
notifications.send("App published", {
type: "success", type: "success",
icon: "GlobeCheck", icon: "GlobeCheck",
}) })
await completePublish() await completePublish()
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -163,210 +130,199 @@
const completePublish = async () => { const completePublish = async () => {
try { try {
await apps.load() await apps.load()
deployments = await fetchDeployments() await deploymentStore.actions.load()
} catch (err) { } catch (err) {
notifications.error("Error refreshing app") notifications.error("Error refreshing app")
} }
} }
onMount(async () => {
if (!$apps.length) {
await apps.load()
}
deployments = await fetchDeployments()
})
</script> </script>
{#if $store.hasLock} <div class="action-top-nav">
<div class="action-top-nav" class:has-lock={$store.hasLock}> <div class="action-buttons">
<div class="action-buttons"> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-click-events-have-key-events --> {#if updateAvailable && $isOnlyUser}
{#if updateAvailable} <div class="app-action-button version" on:click={versionModal.show}>
<div class="app-action-button version" on:click={versionModal.show}>
<div class="app-action">
<ActionButton quiet>
<StatusLight notice />
Update
</ActionButton>
</div>
</div>
{/if}
<TourWrap
tourStepKey={$store.onboarding
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
>
<div class="app-action-button users">
<div class="app-action" id="builder-app-users-button">
<ActionButton
quiet
icon="UserGroup"
on:click={() => {
store.update(state => {
state.builderSidePanel = true
return state
})
}}
>
Users
</ActionButton>
</div>
</div>
</TourWrap>
<div class="app-action-button preview">
<div class="app-action"> <div class="app-action">
<ActionButton quiet icon="PlayCircle" on:click={previewApp}> <ActionButton quiet>
Preview <StatusLight notice />
Update
</ActionButton> </ActionButton>
</div> </div>
</div> </div>
{/if}
<!-- svelte-ignore a11y-click-events-have-key-events --> <TourWrap
<div tourStepKey={$store.onboarding
class="app-action-button publish app-action-popover" ? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
on:click={() => { : TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
if (!appActionPopoverOpen) { >
appActionPopover.show() <div class="app-action-button users">
} else { <div class="app-action" id="builder-app-users-button">
appActionPopover.hide() <ActionButton
} quiet
}} icon="UserGroup"
> on:click={() => {
<div bind:this={appActionPopoverAnchor}> store.update(state => {
<div class="app-action"> state.builderSidePanel = true
<Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} /> return state
<TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}> })
<span class="publish-open" id="builder-app-publish-button"> }}
Publish >
<Icon Users
name={appActionPopoverOpen ? "ChevronUp" : "ChevronDown"} </ActionButton>
size="M"
/>
</span>
</TourWrap>
</div>
</div> </div>
<Popover </div>
bind:this={appActionPopover} </TourWrap>
align="right"
disabled={!isPublished}
anchor={appActionPopoverAnchor}
offset={35}
on:close={() => {
appActionPopoverOpen = false
}}
on:open={() => {
appActionPopoverOpen = true
}}
>
<div class="app-action-popover-content">
<Layout noPadding gap="M">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Body size="M">
<span
class="app-link"
on:click={() => {
if (isPublished) {
viewApp()
} else {
appActionPopover.hide()
updateAppModal.show()
}
}}
>
{$store.url}
{#if isPublished}
<Icon size="S" name="LinkOut" />
{:else}
<Icon size="S" name="Edit" />
{/if}
</span>
</Body>
<Body size="S"> <div class="app-action-button preview">
<span class="publish-popover-status"> <div class="app-action">
{#if isPublished} <ActionButton quiet icon="PlayCircle" on:click={previewApp}>
<span class="status-text"> Preview
{updateDeploymentString(deployments)} </ActionButton>
</span>
<span class="unpublish-link">
<Link quiet on:click={unpublishApp}>Unpublish</Link>
</span>
<span class="revert-link">
<Link quiet secondary on:click={revertApp}>Revert</Link>
</span>
{:else}
<span class="status-text unpublished">Not published</span>
{/if}
</span>
</Body>
<div class="action-buttons">
{#if $store.hasLock}
{#if isPublished}
<ActionButton
quiet
icon="Code"
on:click={() => {
$goto("./settings/embed")
appActionPopover.hide()
}}
>
Embed
</ActionButton>
{/if}
<Button
cta
on:click={publishApp}
id={"builder-app-publish-button"}
disabled={!canPublish}
>
Publish
</Button>
{/if}
</div>
</Layout>
</div>
</Popover>
</div> </div>
</div> </div>
</div>
<!-- Modals --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<ConfirmDialog <div
bind:this={unpublishModal} class="app-action-button publish app-action-popover"
title="Confirm unpublish" on:click={() => {
okText="Unpublish app" if (!appActionPopoverOpen) {
onOk={confirmUnpublishApp} appActionPopover.show()
> } else {
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>? appActionPopover.hide()
</ConfirmDialog> }
<Modal bind:this={updateAppModal} padding={false} width="600px">
<UpdateAppModal
app={{
name: $store.name,
url: $store.url,
icon: $store.icon,
appId: $store.appId,
}} }}
onUpdateComplete={async () => { >
await initialiseApp() <div bind:this={appActionPopoverAnchor}>
}} <div class="app-action">
/> <Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} />
</Modal> <TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}>
<span class="publish-open" id="builder-app-publish-button">
Publish
<Icon
name={appActionPopoverOpen ? "ChevronUp" : "ChevronDown"}
size="M"
/>
</span>
</TourWrap>
</div>
</div>
<Popover
bind:this={appActionPopover}
align="right"
disabled={!isPublished}
anchor={appActionPopoverAnchor}
offset={35}
on:close={() => {
appActionPopoverOpen = false
}}
on:open={() => {
appActionPopoverOpen = true
}}
>
<div class="app-action-popover-content">
<Layout noPadding gap="M">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Body size="M">
<span
class="app-link"
on:click={() => {
if (isPublished) {
viewApp()
} else {
appActionPopover.hide()
updateAppModal.show()
}
}}
>
{$store.url}
{#if isPublished}
<Icon size="S" name="LinkOut" />
{:else}
<Icon size="S" name="Edit" />
{/if}
</span>
</Body>
<RevertModal bind:this={revertModal} /> <Body size="S">
<VersionModal hideIcon bind:this={versionModal} /> <span class="publish-popover-status">
{:else} {#if isPublished}
<div class="app-action-button preview-locked"> <span class="status-text">
<div class="app-action"> {lastDeployed}
<ActionButton quiet icon="PlayCircle" on:click={previewApp}> </span>
Preview <span class="unpublish-link">
</ActionButton> <Link quiet on:click={unpublishApp}>Unpublish</Link>
</span>
<span class="revert-link">
<Link
disabled={!$isOnlyUser}
quiet
secondary
on:click={revertApp}
tooltip="Unavailable - another user is editing this app"
>
Revert
</Link>
</span>
{:else}
<span class="status-text unpublished">Not published</span>
{/if}
</span>
</Body>
<div class="action-buttons">
{#if isPublished}
<ActionButton
quiet
icon="Code"
on:click={() => {
$goto("./settings/embed")
appActionPopover.hide()
}}
>
Embed
</ActionButton>
{/if}
<Button
cta
on:click={publishApp}
id={"builder-app-publish-button"}
disabled={!canPublish}
>
Publish
</Button>
</div>
</Layout>
</div>
</Popover>
</div> </div>
</div> </div>
{/if} </div>
<!-- Modals -->
<ConfirmDialog
bind:this={unpublishModal}
title="Confirm unpublish"
okText="Unpublish app"
onOk={confirmUnpublishApp}
>
Are you sure you want to unpublish the app <b>{selectedApp?.name}</b>?
</ConfirmDialog>
<Modal bind:this={updateAppModal} padding={false} width="600px">
<UpdateAppModal
app={{
name: $store.name,
url: $store.url,
icon: $store.icon,
appId: $store.appId,
}}
onUpdateComplete={async () => {
await initialiseApp()
}}
/>
</Modal>
<RevertModal bind:this={revertModal} />
<VersionModal hideIcon bind:this={versionModal} />
<style> <style>
.app-action-popover-content { .app-action-popover-content {
@ -450,10 +406,6 @@
gap: var(--spectrum-actionbutton-icon-gap); gap: var(--spectrum-actionbutton-icon-gap);
} }
.app-action-button.preview-locked {
padding-right: 0px;
}
.app-action { .app-action {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -1,118 +0,0 @@
<script>
import {
Button,
Modal,
notifications,
ModalContent,
Layout,
ProgressCircle,
CopyInput,
} from "@budibase/bbui"
import { API } from "api"
import analytics, { Events, EventSource } from "analytics"
import { store } from "builderStore"
import TourWrap from "../portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "../portal/onboarding/tours.js"
let publishModal
let asyncModal
let publishCompleteModal
let published
$: publishedUrl = published ? `${window.origin}/app${published.appUrl}` : ""
export let onOk
async function publishApp() {
try {
//In Progress
asyncModal.show()
publishModal.hide()
published = await API.publishAppChanges($store.appId)
if (typeof onOk === "function") {
await onOk()
}
//Request completed
asyncModal.hide()
publishCompleteModal.show()
} catch (error) {
analytics.captureException(error)
notifications.error("Error publishing app")
}
}
const viewApp = () => {
if (published) {
analytics.captureEvent(Events.APP_VIEW_PUBLISHED, {
appId: $store.appId,
eventSource: EventSource.PORTAL,
})
window.open(publishedUrl, "_blank")
}
}
</script>
<TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}>
<Button cta on:click={publishModal.show} id={"builder-app-publish-button"}>
Publish
</Button>
</TourWrap>
<Modal bind:this={publishModal}>
<ModalContent
title="Publish to production"
confirmText="Publish"
onConfirm={publishApp}
>
The changes you have made will be published to the production version of the
application.
</ModalContent>
</Modal>
<!-- Publish in progress -->
<Modal bind:this={asyncModal}>
<ModalContent
showCancelButton={false}
showConfirmButton={false}
showCloseIcon={false}
>
<Layout justifyItems="center">
<ProgressCircle size="XL" />
</Layout>
</ModalContent>
</Modal>
<!-- Publish complete -->
<Modal bind:this={publishCompleteModal}>
<ModalContent confirmText="Done" cancelText="View App" onCancel={viewApp}>
<div slot="header" class="app-published-header">
<svg
width="26px"
height="26px"
class="spectrum-Icon success-icon"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-GlobeCheck" />
</svg>
<span class="app-published-header-text">App Published!</span>
</div>
<CopyInput value={publishedUrl} label="You can view your app at:" />
</ModalContent>
</Modal>
<style>
.app-published-header {
display: flex;
flex-direction: row;
align-items: center;
}
.success-icon {
color: var(--spectrum-global-color-green-600);
}
.app-published-header .app-published-header-text {
padding-left: var(--spacing-l);
}
</style>

View File

@ -1,236 +0,0 @@
<script>
import { onMount, onDestroy } from "svelte"
import Spinner from "components/common/Spinner.svelte"
import { slide } from "svelte/transition"
import { Heading, Button, Modal, ModalContent } from "@budibase/bbui"
import { API } from "api"
import { notifications } from "@budibase/bbui"
import CreateWebhookDeploymentModal from "./CreateWebhookDeploymentModal.svelte"
import { store } from "builderStore"
import {
checkIncomingDeploymentStatus,
DeploymentStatus,
} from "components/deploy/utils"
const DATE_OPTIONS = {
fullDate: {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
},
timeOnly: {
hour: "numeric",
minute: "numeric",
hourCycle: "h12",
},
}
const POLL_INTERVAL = 5000
export let appId
let modal
let errorReasonModal
let errorReason
let poll
let deployments = []
let urlComponent = $store.url || `/${appId}`
let deploymentUrl = `${urlComponent}`
const formatDate = (date, format) =>
Intl.DateTimeFormat("en-GB", DATE_OPTIONS[format]).format(date)
async function fetchDeployments() {
try {
const newDeployments = await API.getAppDeployments()
if (deployments.length > 0) {
const pendingDeployments = checkIncomingDeploymentStatus(
deployments,
newDeployments
)
if (pendingDeployments.length) {
showErrorReasonModal(pendingDeployments[0].err)
}
}
deployments = newDeployments
} catch (err) {
clearInterval(poll)
notifications.error("Error fetching deployment overview")
}
}
function showErrorReasonModal(err) {
if (!err) return
errorReason = err
errorReasonModal.show()
}
onMount(() => {
fetchDeployments()
poll = setInterval(fetchDeployments, POLL_INTERVAL)
})
onDestroy(() => clearInterval(poll))
</script>
{#if deployments.length > 0}
<section class="deployment-history" in:slide>
<header>
<Heading>Deployment History</Heading>
<div class="deploy-div">
{#if deployments.some(deployment => deployment.status === DeploymentStatus.SUCCESS)}
<a target="_blank" href={deploymentUrl}> View Your Deployed App </a>
<Button primary on:click={() => modal.show()}>View webhooks</Button>
{/if}
</div>
</header>
<div class="deployment-list">
{#each deployments as deployment}
<article class="deployment">
<div class="deployment-info">
<span class="deploy-date">
{formatDate(deployment.updatedAt, "fullDate")}
</span>
<span class="deploy-time">
{formatDate(deployment.updatedAt, "timeOnly")}
</span>
</div>
<div class="deployment-right">
{#if deployment.status.toLowerCase() === "pending"}
<Spinner size="10" />
{/if}
<div
on:click={() => showErrorReasonModal(deployment.err)}
class={`deployment-status ${deployment.status}`}
>
<span>
{deployment.status}
{#if deployment.status === DeploymentStatus.FAILURE}
<i class="ri-information-line" />
{/if}
</span>
</div>
</div>
</article>
{/each}
</div>
</section>
{/if}
<Modal bind:this={modal} width="30%">
<CreateWebhookDeploymentModal />
</Modal>
<Modal bind:this={errorReasonModal} width="30%">
<ModalContent
title="Deployment Error"
confirmText="OK"
showCancelButton={false}
>
{errorReason}
</ModalContent>
</Modal>
<style>
section {
padding: var(--spacing-xl) 0;
}
.deployment-list {
height: 40vh;
overflow-y: auto;
}
header {
padding-left: var(--spacing-l);
padding-bottom: var(--spacing-xl);
padding-right: var(--spacing-l);
border-bottom: var(--border-light);
}
.deploy-div {
display: flex;
justify-content: space-between;
align-items: center;
}
.deployment-history {
position: absolute;
bottom: 0;
width: 100%;
background: var(--background);
}
.deployment {
padding: var(--spacing-l);
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: var(--border-light);
}
.deployment:last-child {
border-bottom: none;
}
.deployment-info {
display: flex;
flex-direction: column;
margin-right: var(--spacing-s);
}
.deploy-date {
font-size: var(--font-size-m);
}
.deploy-time {
color: var(--grey-7);
font-weight: 600;
font-size: var(--font-size-s);
}
.deployment-right {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
}
.deployment-status {
font-size: var(--font-size-s);
padding: var(--spacing-s);
border-radius: var(--border-radius-s);
font-weight: 600;
text-transform: lowercase;
width: 80px;
text-align: center;
}
.deployment-status:first-letter {
text-transform: uppercase;
}
a {
color: var(--blue);
font-weight: 600;
font-size: var(--font-size-s);
}
.SUCCESS {
color: var(--green);
background: var(--green-light);
}
.PENDING {
color: var(--yellow);
background: var(--yellow-light);
}
.FAILURE {
color: var(--red);
background: var(--red-light);
cursor: pointer;
}
i {
position: relative;
top: 2px;
}
</style>

View File

@ -1,25 +0,0 @@
export const DeploymentStatus = {
SUCCESS: "SUCCESS",
PENDING: "PENDING",
FAILURE: "FAILURE",
}
// Required to check any updated deployment statuses between polls
export function checkIncomingDeploymentStatus(current, incoming) {
return incoming.reduce((acc, incomingDeployment) => {
if (incomingDeployment.status === DeploymentStatus.FAILURE) {
const currentDeployment = current.find(
deployment => deployment._id === incomingDeployment._id
)
//We have just been notified of an ongoing deployments failure
if (
!currentDeployment ||
currentDeployment.status === DeploymentStatus.PENDING
) {
acc.push(incomingDeployment)
}
}
return acc
}, [])
}

View File

@ -1,32 +1,62 @@
<script> <script>
import { Tooltip } from "@budibase/bbui"
export let text export let text
export let url export let url
export let active = false export let active = false
export let disabled = false
export let tooltip = null
</script> </script>
{#if url} <div class="side-nav-item">
<a on:click href={url} class:active> {#if url}
{text || ""} <a class="text" on:click href={url} class:active class:disabled>
</a> {text || ""}
{:else} </a>
<!-- svelte-ignore a11y-click-events-have-key-events --> {:else}
<span on:click class:active> <!-- svelte-ignore a11y-click-events-have-key-events -->
{text || ""} <div class="text" on:click class:active class:disabled>
</span> {text || ""}
{/if} </div>
{/if}
{#if tooltip}
<div class="tooltip">
<Tooltip textWrapping direction="right" text={tooltip} />
</div>
{/if}
</div>
<style> <style>
a, .side-nav-item {
span { position: relative;
}
.text {
display: block;
padding: var(--spacing-s) var(--spacing-m); padding: var(--spacing-s) var(--spacing-m);
color: var(--spectrum-global-color-gray-900); color: var(--spectrum-global-color-gray-900);
border-radius: 4px; border-radius: 4px;
transition: background 130ms ease-out; transition: background 130ms ease-out;
} }
.active, .active,
span:hover, .text:hover {
a:hover {
background-color: var(--spectrum-global-color-gray-200); background-color: var(--spectrum-global-color-gray-200);
cursor: pointer; cursor: pointer;
} }
.disabled {
pointer-events: none;
color: var(--spectrum-global-color-gray-500) !important;
}
.tooltip {
position: absolute;
transform: translateY(-50%);
left: 100%;
top: 50%;
opacity: 0;
pointer-events: none;
transition: opacity 130ms ease-out;
z-index: 100;
}
.side-nav-item:hover .tooltip {
opacity: 1;
}
</style> </style>

View File

@ -114,26 +114,24 @@ export const syncURLToState = options => {
// Updates the URL with new state values // Updates the URL with new state values
const mapStateToUrl = state => { const mapStateToUrl = state => {
let needsUpdate = false
const urlValue = cachedParams?.[urlParam] const urlValue = cachedParams?.[urlParam]
const stateValue = state?.[stateKey] const stateValue = state?.[stateKey]
if (stateValue !== urlValue) {
needsUpdate = true // As the store updated, validate that the current state value is valid
log(`url.${urlParam} (${urlValue}) <= state.${stateKey} (${stateValue})`) if (validate && fallbackUrl) {
if (validate && fallbackUrl) { if (!validate(stateValue)) {
if (!validate(stateValue)) { log("Invalid state param!", stateValue)
log("Invalid state param!", stateValue) redirectUrl(fallbackUrl)
redirectUrl(fallbackUrl) return
return
}
} }
} }
// Avoid updating the URL if not necessary to prevent a wasted render // Avoid updating the URL if not necessary to prevent a wasted render
// cycle // cycle
if (!needsUpdate) { if (stateValue === urlValue) {
return return
} }
log(`url.${urlParam} (${urlValue}) <= state.${stateKey} (${stateValue})`)
// Navigate to the new URL // Navigate to the new URL
if (!get(isChangingPage)) { if (!get(isChangingPage)) {

View File

@ -21,6 +21,7 @@
import { Constants, Utils } from "@budibase/frontend-core" import { Constants, Utils } from "@budibase/frontend-core"
import { emailValidator } from "helpers/validation" import { emailValidator } from "helpers/validation"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { fly } from "svelte/transition"
let query = null let query = null
let loaded = false let loaded = false
@ -418,16 +419,14 @@
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
<div <div
transition:fly={{ x: 400, duration: 260 }}
id="builder-side-panel-container" id="builder-side-panel-container"
class:open={$store.builderSidePanel} use:clickOutside={() => {
use:clickOutside={$store.builderSidePanel store.update(state => {
? () => { state.builderSidePanel = false
store.update(state => { return state
state.builderSidePanel = false })
return state }}
})
}
: () => {}}
> >
<div class="builder-side-panel-header"> <div class="builder-side-panel-header">
<Heading size="S">Users</Heading> <Heading size="S">Users</Heading>
@ -737,12 +736,11 @@
flex-direction: column; flex-direction: column;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
transition: transform 130ms ease-out;
position: absolute; position: absolute;
width: 400px; width: 400px;
right: 0; right: 0;
transform: translateX(100%);
height: 100%; height: 100%;
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
} }
.builder-side-panel-header, .builder-side-panel-header,
@ -792,11 +790,6 @@
font-style: normal; font-style: normal;
} }
#builder-side-panel-container.open {
transform: translateX(0);
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
}
.builder-side-panel-header { .builder-side-panel-header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -1,7 +1,12 @@
<script> <script>
import { store, automationStore, userStore } from "builderStore" import {
store,
automationStore,
userStore,
deploymentStore,
} from "builderStore"
import { roles, flags } from "stores/backend" import { roles, flags } from "stores/backend"
import { auth } from "stores/portal" import { auth, apps } from "stores/portal"
import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags" import { TENANT_FEATURE_FLAGS, isEnabled } from "helpers/featureFlags"
import { import {
Icon, Icon,
@ -44,6 +49,8 @@
await automationStore.actions.fetch() await automationStore.actions.fetch()
await roles.fetch() await roles.fetch()
await flags.fetch() await flags.fetch()
await apps.load()
await deploymentStore.actions.load()
loaded = true loaded = true
return pkg return pkg
} catch (error) { } catch (error) {
@ -69,18 +76,13 @@
// Event handler for the command palette // Event handler for the command palette
const handleKeyDown = e => { const handleKeyDown = e => {
if (e.key === "k" && (e.ctrlKey || e.metaKey) && $store.hasLock) { if (e.key === "k" && (e.ctrlKey || e.metaKey)) {
e.preventDefault() e.preventDefault()
commandPaletteModal.toggle() commandPaletteModal.toggle()
} }
} }
const initTour = async () => { const initTour = async () => {
// Skip tour if we don't have the lock
if (!$store.hasLock) {
return
}
// Check if onboarding is enabled. // Check if onboarding is enabled.
if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) { if (isEnabled(TENANT_FEATURE_FLAGS.ONBOARDING_TOUR)) {
if (!$auth.user?.onboardedAt) { if (!$auth.user?.onboardedAt) {
@ -140,7 +142,7 @@
{/if} {/if}
<div class="root" class:blur={$store.showPreview}> <div class="root" class:blur={$store.showPreview}>
<div class="top-nav" class:has-lock={$store.hasLock}> <div class="top-nav">
{#if $store.initialised} {#if $store.initialised}
<div class="topleftnav"> <div class="topleftnav">
<span class="back-to-apps"> <span class="back-to-apps">
@ -151,37 +153,25 @@
on:click={() => $goto("../../portal/apps")} on:click={() => $goto("../../portal/apps")}
/> />
</span> </span>
{#if $store.hasLock} <Tabs {selected} size="M">
<Tabs {selected} size="M"> {#each $layout.children as { path, title }}
{#each $layout.children as { path, title }} <TourWrap tourStepKey={`builder-${title}-section`}>
<TourWrap tourStepKey={`builder-${title}-section`}> <Tab
<Tab quiet
quiet selected={$isActive(path)}
selected={$isActive(path)} on:click={topItemNavigate(path)}
on:click={topItemNavigate(path)} title={capitalise(title)}
title={capitalise(title)} id={`builder-${title}-tab`}
id={`builder-${title}-tab`} />
/> </TourWrap>
</TourWrap> {/each}
{/each} </Tabs>
</Tabs>
{:else}
<div class="secondary-editor">
<Icon name="LockClosed" />
<div
class="secondary-editor-body"
title="Another user is currently editing your screens and automations"
>
Another user is currently editing your screens and automations
</div>
</div>
{/if}
</div> </div>
<div class="topcenternav"> <div class="topcenternav">
<Heading size="XS">{$store.name}</Heading> <Heading size="XS">{$store.name}</Heading>
</div> </div>
<div class="toprightnav"> <div class="toprightnav">
<span class:nav-lock={!$store.hasLock}> <span>
<UserAvatars users={$userStore} /> <UserAvatars users={$userStore} />
</span> </span>
<AppActions {application} {loaded} /> <AppActions {application} {loaded} />
@ -248,10 +238,6 @@
z-index: 2; z-index: 2;
} }
.top-nav.has-lock {
padding-right: 0px;
}
.topcenternav { .topcenternav {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -290,23 +276,6 @@
margin-right: var(--spacing-l); margin-right: var(--spacing-l);
} }
.secondary-editor {
align-self: center;
display: flex;
flex-direction: row;
gap: 8px;
min-width: 0;
overflow: hidden;
margin-left: var(--spacing-xl);
}
.secondary-editor-body {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0px;
}
.body { .body {
flex: 1 1 auto; flex: 1 1 auto;
z-index: 1; z-index: 1;

View File

@ -8,15 +8,6 @@
import { onDestroy, onMount } from "svelte" import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify" import * as routify from "@roxi/routify"
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
// Keep URL and state in sync for selected screen ID // Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({

View File

@ -4,6 +4,10 @@
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify" import * as routify from "@roxi/routify"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { store } from "builderStore"
$: datasourceId = $datasources.selectedDatasourceId
$: store.actions.websocket.selectResource(datasourceId)
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "datasourceId", urlParam: "datasourceId",

View File

@ -7,9 +7,11 @@
import { onMount } from "svelte" import { onMount } from "svelte"
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend" import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
import { TableNames } from "constants" import { TableNames } from "constants"
import { store } from "builderStore"
let modal let modal
$: store.actions.websocket.selectResource(BUDIBASE_INTERNAL_DB_ID)
$: internalTablesBySourceId = $tables.list.filter( $: internalTablesBySourceId = $tables.list.filter(
table => table =>
table.type !== "external" && table.type !== "external" &&

View File

@ -6,8 +6,11 @@
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend" import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend"
import { onMount } from "svelte" import { onMount } from "svelte"
import { store } from "builderStore"
let modal let modal
$: store.actions.websocket.selectResource(DEFAULT_BB_DATASOURCE_ID)
$: internalTablesBySourceId = $tables.list.filter( $: internalTablesBySourceId = $tables.list.filter(
table => table =>
table.type !== "external" && table.sourceId === DEFAULT_BB_DATASOURCE_ID table.type !== "external" && table.sourceId === DEFAULT_BB_DATASOURCE_ID

View File

@ -3,6 +3,10 @@
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify" import * as routify from "@roxi/routify"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { store } from "builderStore"
$: queryId = $queries.selectedQueryId
$: store.actions.websocket.selectResource(queryId)
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "queryId", urlParam: "queryId",

View File

@ -3,6 +3,10 @@
import { tables } from "stores/backend" import { tables } from "stores/backend"
import * as routify from "@roxi/routify" import * as routify from "@roxi/routify"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { store } from "builderStore"
$: tableId = $tables.selectedTableId
$: store.actions.websocket.selectResource(tableId)
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "tableId", urlParam: "tableId",

View File

@ -3,6 +3,10 @@
import { syncURLToState } from "helpers/urlStateSync" import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify" import * as routify from "@roxi/routify"
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { store } from "builderStore"
$: viewName = $views.selectedViewName
$: store.actions.websocket.selectResource(viewName)
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "viewName", urlParam: "viewName",

View File

@ -7,6 +7,9 @@
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
const { isActive, goto } = routify const { isActive, goto } = routify
$: screenId = $store.selectedScreenId
$: store.actions.websocket.selectResource(screenId)
// Keep URL and state in sync for selected screen ID // Keep URL and state in sync for selected screen ID
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "screenId", urlParam: "screenId",

View File

@ -3,7 +3,7 @@
import ComponentTree from "./ComponentTree.svelte" import ComponentTree from "./ComponentTree.svelte"
import { dndStore } from "./dndStore.js" import { dndStore } from "./dndStore.js"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { store, selectedScreen } from "builderStore" import { store, selectedScreen, userSelectedResourceMap } from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte" import ScreenslotDropdownMenu from "./ScreenslotDropdownMenu.svelte"
import DNDPositionIndicator from "./DNDPositionIndicator.svelte" import DNDPositionIndicator from "./DNDPositionIndicator.svelte"
@ -41,6 +41,7 @@
$store.selectedComponentId = $selectedScreen?.props._id $store.selectedComponentId = $selectedScreen?.props._id
}} }}
id={`component-${$selectedScreen?.props._id}`} id={`component-${$selectedScreen?.props._id}`}
selectedBy={$userSelectedResourceMap[$selectedScreen?.props._id]}
> >
<ScreenslotDropdownMenu component={$selectedScreen?.props} /> <ScreenslotDropdownMenu component={$selectedScreen?.props} />
</NavItem> </NavItem>

View File

@ -1,5 +1,5 @@
<script> <script>
import { store } from "builderStore" import { store, userSelectedResourceMap } from "builderStore"
import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte" import ComponentDropdownMenu from "./ComponentDropdownMenu.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import { capitalise } from "helpers" import { capitalise } from "helpers"
@ -123,6 +123,7 @@
selected={$store.selectedComponentId === component._id} selected={$store.selectedComponentId === component._id}
{opened} {opened}
highlighted={isChildOfSelectedComponent(component)} highlighted={isChildOfSelectedComponent(component)}
selectedBy={$userSelectedResourceMap[component._id]}
> >
<ComponentDropdownMenu {component} /> <ComponentDropdownMenu {component} />
</NavItem> </NavItem>

View File

@ -7,6 +7,9 @@
import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte" import ComponentListPanel from "./_components/navigation/ComponentListPanel.svelte"
import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte" import ComponentSettingsPanel from "./_components/settings/ComponentSettingsPanel.svelte"
$: componentId = $store.selectedComponentId
$: store.actions.websocket.selectResource(componentId)
const cleanUrl = url => { const cleanUrl = url => {
// Strip trailing slashes // Strip trailing slashes
if (url?.endsWith("/index")) { if (url?.endsWith("/index")) {

View File

@ -12,8 +12,8 @@
$: role = $roles.find(role => role._id === roleId) $: role = $roles.find(role => role._id === roleId)
$: tooltip = $: tooltip =
roleId === Roles.PUBLIC roleId === Roles.PUBLIC
? "This screen is open to the public" ? "Open to the public"
: `Requires at least ${role?.name} access` : `Requires ${role?.name} access`
</script> </script>
<div <div
@ -44,14 +44,14 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: flex-end; justify-content: flex-end;
width: 130px; width: 200px;
pointer-events: none; pointer-events: none;
} }
.tooltip :global(.spectrum-Tooltip) { .tooltip :global(.spectrum-Tooltip) {
background: var(--color); background: var(--color);
color: white; color: white;
font-weight: 600; font-weight: 600;
max-width: 130px; max-width: 200px;
} }
.tooltip :global(.spectrum-Tooltip-tip) { .tooltip :global(.spectrum-Tooltip-tip) {
border-top-color: var(--color); border-top-color: var(--color);

View File

@ -2,7 +2,7 @@
import { Search, Layout, Select, Body, Button } from "@budibase/bbui" import { Search, Layout, Select, Body, Button } from "@budibase/bbui"
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { store, sortedScreens } from "builderStore" import { store, sortedScreens, userSelectedResourceMap } from "builderStore"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte" import ScreenDropdownMenu from "./ScreenDropdownMenu.svelte"
import ScreenWizard from "./ScreenWizard.svelte" import ScreenWizard from "./ScreenWizard.svelte"
@ -60,6 +60,7 @@
on:click={() => store.actions.screens.select(screen._id)} on:click={() => store.actions.screens.select(screen._id)}
rightAlignIcon rightAlignIcon
showTooltip showTooltip
selectedBy={$userSelectedResourceMap[screen._id]}
> >
<ScreenDropdownMenu screenId={screen._id} /> <ScreenDropdownMenu screenId={screen._id} />
<RoleIndicator slot="right" roleId={screen.routing.roleId} /> <RoleIndicator slot="right" roleId={screen.routing.roleId} />

View File

@ -1,14 +1,2 @@
<script>
import { store } from "builderStore"
import { redirect } from "@roxi/routify"
// Prevent access for other users than the lock holder
$: {
if (!$store.hasLock) {
$redirect("../data")
}
}
</script>
<!-- routify:options index=2 --> <!-- routify:options index=2 -->
<slot /> <slot />

View File

@ -3,6 +3,7 @@
import { Page, Layout } from "@budibase/bbui" import { Page, Layout } from "@budibase/bbui"
import { url, isActive } from "@roxi/routify" import { url, isActive } from "@roxi/routify"
import DeleteModal from "components/deploy/DeleteModal.svelte" import DeleteModal from "components/deploy/DeleteModal.svelte"
import { isOnlyUser } from "builderStore"
let deleteModal let deleteModal
</script> </script>
@ -49,6 +50,10 @@
on:click={() => { on:click={() => {
deleteModal.show() deleteModal.show()
}} }}
disabled={!$isOnlyUser}
tooltip={$isOnlyUser
? null
: "Unavailable - another user is editing this app"}
/> />
</div> </div>
</SideNav> </SideNav>
@ -61,7 +66,7 @@
<DeleteModal bind:this={deleteModal} /> <DeleteModal bind:this={deleteModal} />
<style> <style>
.delete-action :global(span) { .delete-action :global(.text) {
color: var(--spectrum-global-color-red-400); color: var(--spectrum-global-color-red-400);
} }
.delete-action { .delete-action {

View File

@ -10,6 +10,7 @@
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import CreateRestoreModal from "./CreateRestoreModal.svelte" import CreateRestoreModal from "./CreateRestoreModal.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { isOnlyUser } from "builderStore"
export let row export let row
@ -45,7 +46,16 @@
</div> </div>
{#if row.type !== "restore"} {#if row.type !== "restore"}
<MenuItem on:click={restoreDialog.show} icon="Revert">Restore</MenuItem> <MenuItem
on:click={restoreDialog.show}
icon="Revert"
disabled={!$isOnlyUser}
tooltip={$isOnlyUser
? null
: "Unavailable - another user is editing this app"}
>
Restore
</MenuItem>
<MenuItem on:click={deleteDialog.show} icon="Delete">Delete</MenuItem> <MenuItem on:click={deleteDialog.show} icon="Delete">Delete</MenuItem>
<MenuItem on:click={downloadExport} icon="Download">Download</MenuItem> <MenuItem on:click={downloadExport} icon="Download">Download</MenuItem>
{/if} {/if}

View File

@ -63,9 +63,8 @@
}} }}
disabled={appDeployed} disabled={appDeployed}
tooltip={appDeployed tooltip={appDeployed
? "You must unpublish your app to make changes to these settings" ? "You must unpublish your app to make changes"
: null} : null}
icon={appDeployed ? "HelpOutline" : null}
> >
Edit Edit
</Button> </Button>

View File

@ -1,6 +1,6 @@
<script> <script>
import { Layout, Heading, Body, Divider, Button } from "@budibase/bbui" import { Layout, Heading, Body, Divider, Button } from "@budibase/bbui"
import { store } from "builderStore" import { store, isOnlyUser } from "builderStore"
import VersionModal from "components/deploy/VersionModal.svelte" import VersionModal from "components/deploy/VersionModal.svelte"
let versionModal let versionModal
@ -22,7 +22,16 @@
Updates can contain new features, performance improvements and bug fixes. Updates can contain new features, performance improvements and bug fixes.
</Body> </Body>
<div> <div>
<Button cta on:click={versionModal.show}>Update app</Button> <Button
cta
on:click={versionModal.show}
disabled={!$isOnlyUser}
tooltip={$isOnlyUser
? null
: "Unavailable - another user is editing this app"}
>
Update app
</Button>
</div> </div>
{:else} {:else}
<Body> <Body>
@ -31,7 +40,15 @@
You're running the latest! You're running the latest!
</Body> </Body>
<div> <div>
<Button secondary on:click={versionModal.show}>Revert app</Button> <Button
secondary
on:click={versionModal.show}
tooltip={$isOnlyUser
? null
: "Unavailable - another user is editing this app"}
>
Revert app
</Button>
</div> </div>
{/if} {/if}
</Layout> </Layout>

View File

@ -33,7 +33,7 @@
<Tooltip <Tooltip
direction={tooltipDirection} direction={tooltipDirection}
textWrapping textWrapping
text={user.email} text={helpers.getUserLabel(user)}
size="S" size="S"
/> />
</div> </div>
@ -46,13 +46,15 @@
position: relative; position: relative;
} }
.tooltip { .tooltip {
display: none;
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
white-space: nowrap; white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 130ms ease-out;
} }
.user-avatar:hover .tooltip { .user-avatar:hover .tooltip {
display: block; opacity: 1;
} }
</style> </style>

View File

@ -30,8 +30,9 @@ export const deriveStores = context => {
([$users, $focusedCellId]) => { ([$users, $focusedCellId]) => {
let map = {} let map = {}
$users.forEach(user => { $users.forEach(user => {
if (user.focusedCellId && user.focusedCellId !== $focusedCellId) { const cellId = user.gridMetadata?.focusedCellId
map[user.focusedCellId] = user if (cellId && cellId !== $focusedCellId) {
map[cellId] = user
} }
}) })
return map return map

View File

@ -53,6 +53,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts" import { BASE_LAYOUT_PROP_IDS } from "../../constants/layouts"
import sdk from "../../sdk" import sdk from "../../sdk"
import { builderSocket } from "../../websockets"
// utility function, need to do away with this // utility function, need to do away with this
async function getLayouts() { async function getLayouts() {
@ -439,6 +440,14 @@ export async function update(ctx: UserCtx) {
await events.app.updated(app) await events.app.updated(app)
ctx.status = 200 ctx.status = 200
ctx.body = app ctx.body = app
builderSocket?.emitAppMetadataUpdate(ctx, {
theme: app.theme,
customTheme: app.customTheme,
navigation: app.navigation,
name: app.name,
url: app.url,
icon: app.icon,
})
} }
export async function updateClient(ctx: UserCtx) { export async function updateClient(ctx: UserCtx) {
@ -569,6 +578,7 @@ export async function unpublish(ctx: UserCtx) {
await unpublishApp(ctx) await unpublishApp(ctx)
await postDestroyApp(ctx) await postDestroyApp(ctx)
ctx.status = 204 ctx.status = 204
builderSocket?.emitAppUnpublish(ctx)
} }
export async function sync(ctx: UserCtx) { export async function sync(ctx: UserCtx) {

View File

@ -9,6 +9,7 @@ import {
import { backups } from "@budibase/pro" import { backups } from "@budibase/pro"
import { AppBackupTrigger } from "@budibase/types" import { AppBackupTrigger } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import { builderSocket } from "../../../websockets"
// the max time we can wait for an invalidation to complete before considering it failed // the max time we can wait for an invalidation to complete before considering it failed
const MAX_PENDING_TIME_MS = 30 * 60000 const MAX_PENDING_TIME_MS = 30 * 60000
@ -201,4 +202,5 @@ export const publishApp = async function (ctx: any) {
await events.app.published(app) await events.app.published(app)
ctx.body = deployment ctx.body = deployment
builderSocket?.emitAppPublish(ctx)
} }

View File

@ -8,6 +8,7 @@ import {
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { updateAppPackage } from "./application" import { updateAppPackage } from "./application"
import { Plugin, ScreenProps, BBContext } from "@budibase/types" import { Plugin, ScreenProps, BBContext } from "@budibase/types"
import { builderSocket } from "../../websockets"
export async function fetch(ctx: BBContext) { export async function fetch(ctx: BBContext) {
const db = context.getAppDB() const db = context.getAppDB()
@ -87,13 +88,17 @@ export async function save(ctx: BBContext) {
if (eventFn) { if (eventFn) {
await eventFn(screen) await eventFn(screen)
} }
ctx.message = `Screen ${screen.name} saved.` const savedScreen = {
ctx.body = {
...screen, ...screen,
_id: response.id, _id: response.id,
_rev: response.rev, _rev: response.rev,
}
ctx.message = `Screen ${screen.name} saved.`
ctx.body = {
...savedScreen,
pluginAdded, pluginAdded,
} }
builderSocket?.emitScreenUpdate(ctx, savedScreen)
} }
export async function destroy(ctx: BBContext) { export async function destroy(ctx: BBContext) {
@ -108,6 +113,7 @@ export async function destroy(ctx: BBContext) {
message: "Screen deleted successfully", message: "Screen deleted successfully",
} }
ctx.status = 200 ctx.status = 200
builderSocket?.emitScreenDeletion(ctx, id)
} }
function findPlugins(component: ScreenProps, foundPlugins: string[]) { function findPlugins(component: ScreenProps, foundPlugins: string[]) {

View File

@ -3,11 +3,18 @@ import { BaseSocket } from "./websocket"
import { permissions, events } from "@budibase/backend-core" import { permissions, events } from "@budibase/backend-core"
import http from "http" import http from "http"
import Koa from "koa" import Koa from "koa"
import { Datasource, Table, SocketSession, ContextUser } from "@budibase/types" import {
Datasource,
Table,
SocketSession,
ContextUser,
Screen,
App,
} from "@budibase/types"
import { gridSocket } from "./index" import { gridSocket } from "./index"
import { clearLock, updateLock } from "../utilities/redis" import { clearLock, updateLock } from "../utilities/redis"
import { Socket } from "socket.io" import { Socket } from "socket.io"
import { BuilderSocketEvent } from "@budibase/shared-core" import { BuilderSocketEvent, GridSocketEvent } from "@budibase/shared-core"
export default class BuilderSocket extends BaseSocket { export default class BuilderSocket extends BaseSocket {
constructor(app: Koa, server: http.Server) { constructor(app: Koa, server: http.Server) {
@ -32,6 +39,11 @@ export default class BuilderSocket extends BaseSocket {
// Reply with all current sessions // Reply with all current sessions
callback({ users: sessions }) callback({ users: sessions })
}) })
// Handle users selecting a new cell
socket?.on(BuilderSocketEvent.SelectResource, ({ resourceId }) => {
this.updateUser(socket, { selectedResourceId: resourceId })
})
} }
async onDisconnect(socket: Socket) { async onDisconnect(socket: Socket) {
@ -72,6 +84,15 @@ export default class BuilderSocket extends BaseSocket {
} }
} }
async updateUser(socket: Socket, patch: Object) {
await super.updateUser(socket, {
builderMetadata: {
...socket.data.builderMetadata,
...patch,
},
})
}
emitTableUpdate(ctx: any, table: Table) { emitTableUpdate(ctx: any, table: Table) {
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, {
id: table._id, id: table._id,
@ -101,4 +122,38 @@ export default class BuilderSocket extends BaseSocket {
datasource: null, datasource: null,
}) })
} }
emitScreenUpdate(ctx: any, screen: Screen) {
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.ScreenChange, {
id: screen._id,
screen,
})
}
emitScreenDeletion(ctx: any, id: string) {
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.ScreenChange, {
id,
screen: null,
})
}
emitAppMetadataUpdate(ctx: any, metadata: Partial<App>) {
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.AppMetadataChange, {
metadata,
})
}
emitAppPublish(ctx: any) {
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.AppPublishChange, {
published: true,
user: ctx.user,
})
}
emitAppUnpublish(ctx: any) {
this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.AppPublishChange, {
published: false,
user: ctx.user,
})
}
} }

View File

@ -69,6 +69,15 @@ export default class GridSocket extends BaseSocket {
}) })
} }
async updateUser(socket: Socket, patch: Object) {
await super.updateUser(socket, {
gridMetadata: {
...socket.data.gridMetadata,
...patch,
},
})
}
emitRowUpdate(ctx: any, row: Row) { emitRowUpdate(ctx: any, row: Row) {
const tableId = getTableId(ctx) const tableId = getTableId(ctx)
const room = `${ctx.appId}-${tableId}` const room = `${ctx.appId}-${tableId}`

View File

@ -86,6 +86,10 @@ export enum BuilderSocketEvent {
TableChange = "TableChange", TableChange = "TableChange",
DatasourceChange = "DatasourceChange", DatasourceChange = "DatasourceChange",
LockTransfer = "LockTransfer", LockTransfer = "LockTransfer",
ScreenChange = "ScreenChange",
AppMetadataChange = "AppMetadataChange",
SelectResource = "SelectResource",
AppPublishChange = "AppPublishChange",
} }
export const SocketSessionTTL = 60 export const SocketSessionTTL = 60