diff --git a/packages/builder/src/pages/builder/app/[application]/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/_layout.svelte
index ff728312c1..4da0da44f9 100644
--- a/packages/builder/src/pages/builder/app/[application]/_layout.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/_layout.svelte
@@ -1,5 +1,5 @@
@@ -134,74 +138,89 @@
-
-
-
-
-
- $goto("../../portal/apps")}>
- Exit to portal
-
- $goto(`../../portal/overview/${application}`)}
- >
- Overview
-
- $goto(`../../portal/overview/${application}/access`)}
- >
- Access
-
-
- $goto(`../../portal/overview/${application}/automation-history`)}
- >
- Automation history
-
- $goto(`../../portal/overview/${application}/backups`)}
- >
- Backups
-
+ {#if $store.initialised}
+
+
+
+
+
+ $goto("../../portal/apps")}>
+ Exit to portal
+
+ $goto(`../../portal/overview/${application}`)}
+ >
+ Overview
+
+
+ $goto(`../../portal/overview/${application}/access`)}
+ >
+ Access
+
+
+ $goto(`../../portal/overview/${application}/automation-history`)}
+ >
+ Automation history
+
+
+ $goto(`../../portal/overview/${application}/backups`)}
+ >
+ Backups
+
-
- $goto(`../../portal/overview/${application}/name-and-url`)}
- >
- Name and URL
-
- $goto(`../../portal/overview/${application}/version`)}
- >
- Version
-
-
-
{$store.name}
-
-
-
- {#each $layout.children as { path, title }}
-
-
-
- {/each}
-
-
-
+
+ $goto(`../../portal/overview/${application}/name-and-url`)}
+ >
+ Name and URL
+
+
+ $goto(`../../portal/overview/${application}/version`)}
+ >
+ Version
+
+
+
{$store.name}
+
+
+ {#if $store.hasLock}
+
+ {#each $layout.children as { path, title }}
+
+
+
+ {/each}
+
+ {:else}
+
+
+ Another user is currently editing your screens and automations
+
+ {/if}
+
+
+ {/if}
{#await promise}
{:then _}
-
+
+
+
{:catch error}
Something went wrong: {error.message}
{/await}
@@ -237,6 +256,7 @@
box-sizing: border-box;
align-items: stretch;
border-bottom: var(--border-light);
+ z-index: 2;
}
.topleftnav {
@@ -270,4 +290,18 @@
align-items: center;
gap: var(--spacing-l);
}
+
+ .secondary-editor {
+ align-self: center;
+ display: flex;
+ flex-direction: row;
+ gap: 8px;
+ }
+
+ .body {
+ flex: 1 1 auto;
+ z-index: 1;
+ display: flex;
+ flex-direction: column;
+ }
diff --git a/packages/builder/src/pages/builder/app/[application]/automate/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/automate/_layout.svelte
index 74dfe671ab..79ca5df168 100644
--- a/packages/builder/src/pages/builder/app/[application]/automate/_layout.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/automate/_layout.svelte
@@ -8,6 +8,15 @@
import { onDestroy, onMount } from "svelte"
import { syncURLToState } from "helpers/urlStateSync"
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
const stopSyncing = syncURLToState({
diff --git a/packages/builder/src/pages/builder/app/[application]/data/_DatasourceOption.svelte b/packages/builder/src/pages/builder/app/[application]/data/_DatasourceOption.svelte
new file mode 100644
index 0000000000..8afc930bae
--- /dev/null
+++ b/packages/builder/src/pages/builder/app/[application]/data/_DatasourceOption.svelte
@@ -0,0 +1,51 @@
+
+
+
+
+ {description}
+
+
+
diff --git a/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte
index 8b401866f5..87c4db81df 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte
@@ -1,21 +1,20 @@
-
-
- Add source
-
-
-
-
+ {#if !$isActive("./new")}
+
+
+ $goto("./new")}>Add source
+
+
+
+ {/if}
diff --git a/packages/builder/src/pages/builder/app/[application]/data/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/index.svelte
index b2aca1f7f3..47939f09b4 100644
--- a/packages/builder/src/pages/builder/app/[application]/data/index.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/data/index.svelte
@@ -1,22 +1,17 @@
-
-
diff --git a/packages/builder/src/pages/builder/app/[application]/data/new.svelte b/packages/builder/src/pages/builder/app/[application]/data/new.svelte
new file mode 100644
index 0000000000..f8e8fd85e7
--- /dev/null
+++ b/packages/builder/src/pages/builder/app/[application]/data/new.svelte
@@ -0,0 +1,257 @@
+
+
+
+
+
+
+
+ {#if integration?.auth?.type === "google"}
+
+ {:else}
+
+ {/if}
+
+
+
+
+ {#if hasData}
+
+ {/if}
+
+
+ Add new data source
+
+
+
+ Get started with our Budibase DB
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Or connect to an external datasource
+
+
+
+ {#each integrations as [key, value]}
+ handleIntegrationSelect(key)}
+ title={value.friendlyName}
+ description={value.type}
+ {disabled}
+ >
+
+
+ {/each}
+
+
+
+
diff --git a/packages/builder/src/pages/builder/app/[application]/design/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/design/_layout.svelte
index ec21d909aa..d23514ae6d 100644
--- a/packages/builder/src/pages/builder/app/[application]/design/_layout.svelte
+++ b/packages/builder/src/pages/builder/app/[application]/design/_layout.svelte
@@ -1,2 +1,14 @@
+
+
diff --git a/packages/builder/src/pages/builder/apps/index.svelte b/packages/builder/src/pages/builder/apps/index.svelte
index 4b77671345..1806d51b69 100644
--- a/packages/builder/src/pages/builder/apps/index.svelte
+++ b/packages/builder/src/pages/builder/apps/index.svelte
@@ -5,7 +5,6 @@
Divider,
ActionMenu,
MenuItem,
- Avatar,
Page,
Icon,
Body,
@@ -22,6 +21,8 @@
import { processStringSync } from "@budibase/string-templates"
import Spaceman from "assets/bb-space-man.svg"
import Logo from "assets/bb-emblem.svg"
+ import { UserAvatar } from "@budibase/frontend-core"
+ import { helpers } from "@budibase/shared-core"
let loaded = false
let userInfoModal
@@ -96,11 +97,7 @@
userInfoModal.show()}>
@@ -125,7 +122,7 @@
- Hey {$auth.user.firstName || $auth.user.email}
+ Hey {helpers.getUserLabel($auth.user)}
Welcome to the {$organisation.company} portal. Below you'll find the
diff --git a/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte b/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte
index 935d69812f..9faae70aa9 100644
--- a/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte
+++ b/packages/builder/src/pages/builder/portal/_components/UserDropdown.svelte
@@ -1,11 +1,12 @@
{#if row?.user?.email}
@@ -19,7 +14,7 @@
on:focus={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
>
-
+
{#if showTooltip}
-
- import getUserInitials from "helpers/userInitials.js"
- import { Avatar } from "@budibase/bbui"
+ import { UserAvatar } from "@budibase/frontend-core"
export let value
-
- $: initials = getUserInitials(value)
-
-
+
+
diff --git a/packages/frontend-core/src/components/grid/cells/GridCell.svelte b/packages/frontend-core/src/components/grid/cells/GridCell.svelte
index 7e38a989d6..9316699743 100644
--- a/packages/frontend-core/src/components/grid/cells/GridCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/GridCell.svelte
@@ -16,7 +16,7 @@
const getStyle = (width, selectedUser) => {
let style = `flex: 0 0 ${width}px;`
if (selectedUser) {
- style += `--cell-color:${selectedUser.color};`
+ style += `--user-color:${selectedUser.color};`
}
return style
}
@@ -99,14 +99,15 @@
}
/* Cell border for cells with labels */
- .cell.error:after,
- .cell.selected-other:not(.focused):after {
+ .cell.error:after {
border-radius: 0 2px 2px 2px;
}
- .cell.top.error:after,
- .cell.top.selected-other:not(.focused):after {
+ .cell.top.error:after {
border-radius: 2px 2px 2px 0;
}
+ .cell.selected-other:not(.focused):after {
+ border-radius: 2px;
+ }
/* Cell z-index */
.cell.error,
@@ -116,14 +117,8 @@
.cell.focused {
z-index: 2;
}
- .cell.focused {
- --cell-color: var(--spectrum-global-color-blue-400);
- }
- .cell.error {
- --cell-color: var(--spectrum-global-color-red-500);
- }
- .cell.readonly {
- --cell-color: var(--spectrum-global-color-gray-600);
+ .cell.selected-other:hover {
+ z-index: 2;
}
.cell:not(.focused) {
user-select: none;
@@ -131,6 +126,21 @@
.cell:hover {
cursor: default;
}
+
+ /* Cell color overrides */
+ .cell.selected-other {
+ --cell-color: var(--user-color);
+ }
+ .cell.focused {
+ --cell-color: var(--spectrum-global-color-blue-400);
+ }
+ .cell.error {
+ --cell-color: var(--spectrum-global-color-red-500);
+ }
+ .cell.focused.readonly {
+ --cell-color: var(--spectrum-global-color-gray-600);
+ }
+
.cell.highlighted:not(.focused),
.cell.focused.readonly {
--cell-background: var(--cell-background-hover);
@@ -146,7 +156,7 @@
left: 0;
padding: 1px 4px 3px 4px;
margin: 0 0 -2px 0;
- background: var(--user-color);
+ background: var(--cell-color);
border-radius: 2px;
display: block;
color: white;
@@ -160,11 +170,16 @@
.cell.top .label {
bottom: auto;
top: 100%;
- border-radius: 0 2px 2px 2px;
padding: 2px 4px 2px 4px;
margin: -2px 0 0 0;
}
.error .label {
background: var(--spectrum-global-color-red-500);
}
+ .selected-other:not(.error) .label {
+ display: none;
+ }
+ .selected-other:not(.error):hover .label {
+ display: block;
+ }
diff --git a/packages/frontend-core/src/components/grid/cells/TextCell.svelte b/packages/frontend-core/src/components/grid/cells/TextCell.svelte
index 533b030b5c..04485a6b50 100644
--- a/packages/frontend-core/src/components/grid/cells/TextCell.svelte
+++ b/packages/frontend-core/src/components/grid/cells/TextCell.svelte
@@ -52,7 +52,7 @@
{:else}
- {value || ""}
+ {value ?? ""}
{/if}
diff --git a/packages/frontend-core/src/components/grid/layout/Avatar.svelte b/packages/frontend-core/src/components/grid/layout/Avatar.svelte
deleted file mode 100644
index e3eec9f830..0000000000
--- a/packages/frontend-core/src/components/grid/layout/Avatar.svelte
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
- {user.email[0]}
-
-
-
diff --git a/packages/frontend-core/src/components/grid/layout/Grid.svelte b/packages/frontend-core/src/components/grid/layout/Grid.svelte
index e63e6d0048..08325857d6 100644
--- a/packages/frontend-core/src/components/grid/layout/Grid.svelte
+++ b/packages/frontend-core/src/components/grid/layout/Grid.svelte
@@ -1,5 +1,5 @@
-
+ {#if showAvatars}
+
+ {/if}
{#if $loaded}
diff --git a/packages/frontend-core/src/components/grid/layout/UserAvatars.svelte b/packages/frontend-core/src/components/grid/layout/UserAvatars.svelte
index be33b6713d..df705c97b4 100644
--- a/packages/frontend-core/src/components/grid/layout/UserAvatars.svelte
+++ b/packages/frontend-core/src/components/grid/layout/UserAvatars.svelte
@@ -1,13 +1,23 @@
- {#each $users as user}
-
+ {#each uniqueUsers as user}
+
{/each}
@@ -15,6 +25,6 @@
.users {
display: flex;
flex-direction: row;
- gap: 8px;
+ gap: 4px;
}
diff --git a/packages/frontend-core/src/components/grid/lib/websocket.js b/packages/frontend-core/src/components/grid/lib/websocket.js
index af2e247f98..3f1c473ea0 100644
--- a/packages/frontend-core/src/components/grid/lib/websocket.js
+++ b/packages/frontend-core/src/components/grid/lib/websocket.js
@@ -1,24 +1,9 @@
import { get } from "svelte/store"
-import { io } from "socket.io-client"
+import { createWebsocket } from "../../../utils"
-export const createWebsocket = context => {
- const { rows, tableId, users, userId, focusedCellId } = context
-
- // Determine connection info
- const tls = location.protocol === "https:"
- const proto = tls ? "wss:" : "ws:"
- const host = location.hostname
- const port = location.port || (tls ? 443 : 80)
- const socket = io(`${proto}//${host}:${port}`, {
- path: "/socket/grid",
- // Cap reconnection attempts to 3 (total of 15 seconds before giving up)
- reconnectionAttempts: 3,
- // Delay reconnection attempt by 5 seconds
- reconnectionDelay: 5000,
- reconnectionDelayMax: 5000,
- // Timeout after 4 seconds so we never stack requests
- timeout: 4000,
- })
+export const createGridWebsocket = context => {
+ const { rows, tableId, users, focusedCellId, table } = context
+ const socket = createWebsocket("/socket/grid")
const connectToTable = tableId => {
if (!socket.connected) {
@@ -28,27 +13,42 @@ export const createWebsocket = context => {
socket.emit("select-table", tableId, response => {
// handle initial connection info
users.set(response.users)
- userId.set(response.id)
})
}
- // Event handlers
+ // Connection events
socket.on("connect", () => {
connectToTable(get(tableId))
})
- socket.on("row-update", data => {
- if (data.id) {
- rows.actions.refreshRow(data.id)
- }
+ socket.on("connect_error", err => {
+ console.log("Failed to connect to grid websocket:", err.message)
})
+
+ // User events
socket.on("user-update", user => {
users.actions.updateUser(user)
})
socket.on("user-disconnect", user => {
users.actions.removeUser(user)
})
- socket.on("connect_error", err => {
- console.log("Failed to connect to grid websocket:", err.message)
+
+ // Row events
+ socket.on("row-change", async data => {
+ if (data.id) {
+ rows.actions.replaceRow(data.id, data.row)
+ } else if (data.row.id) {
+ // Handle users table edge case
+ await rows.actions.refreshRow(data.row.id)
+ }
+ })
+
+ // Table events
+ socket.on("table-change", data => {
+ // Only update table if one exists. If the table was deleted then we don't
+ // want to know - let the builder navigate away
+ if (data.table) {
+ table.set(data.table)
+ }
})
// Change websocket connection when table changes
diff --git a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte
index 6d16acc7c5..a210e125ff 100644
--- a/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte
+++ b/packages/frontend-core/src/components/grid/overlays/KeyboardManager.svelte
@@ -14,6 +14,7 @@
dispatch,
selectedRows,
config,
+ menu,
} = getContext("grid")
const ignoredOriginSelectors = [
@@ -61,6 +62,7 @@
} else {
$focusedCellId = null
}
+ menu.actions.close()
return
} else if (e.key === "Tab") {
e.preventDefault()
diff --git a/packages/frontend-core/src/components/grid/stores/columns.js b/packages/frontend-core/src/components/grid/stores/columns.js
index e953977487..b9f0b340a9 100644
--- a/packages/frontend-core/src/components/grid/stores/columns.js
+++ b/packages/frontend-core/src/components/grid/stores/columns.js
@@ -46,7 +46,7 @@ export const createStores = () => {
}
export const deriveStores = context => {
- const { table, columns, stickyColumn, API, dispatch } = context
+ const { table, columns, stickyColumn, API } = context
// Updates the tables primary display column
const changePrimaryDisplay = async column => {
@@ -90,10 +90,6 @@ export const deriveStores = context => {
// Update local state
table.set(newTable)
- // Broadcast event so that we can keep sync with external state
- // (e.g. data section which maintains a list of table definitions)
- dispatch("updatetable", newTable)
-
// Update server
await API.saveTable(newTable)
}
@@ -116,10 +112,24 @@ export const initialise = context => {
const schema = derived(
[table, schemaOverrides],
([$table, $schemaOverrides]) => {
- let newSchema = $table?.schema
- if (!newSchema) {
+ if (!$table?.schema) {
return null
}
+ let newSchema = { ...$table?.schema }
+
+ // Edge case to temporarily allow deletion of duplicated user
+ // fields that were saved with the "disabled" flag set.
+ // By overriding the saved schema we ensure only overrides can
+ // set the disabled flag.
+ // TODO: remove in future
+ Object.keys(newSchema).forEach(field => {
+ newSchema[field] = {
+ ...newSchema[field],
+ disabled: false,
+ }
+ })
+
+ // Apply schema overrides
Object.keys($schemaOverrides || {}).forEach(field => {
if (newSchema[field]) {
newSchema[field] = {
@@ -160,7 +170,7 @@ export const initialise = context => {
fields
.map(field => ({
name: field,
- label: $schema[field].name || field,
+ label: $schema[field].displayName || field,
schema: $schema[field],
width: $schema[field].width || DefaultColumnWidth,
visible: $schema[field].visible ?? true,
diff --git a/packages/frontend-core/src/components/grid/stores/rows.js b/packages/frontend-core/src/components/grid/stores/rows.js
index b6dc8c05d0..4b93e1d58f 100644
--- a/packages/frontend-core/src/components/grid/stores/rows.js
+++ b/packages/frontend-core/src/components/grid/stores/rows.js
@@ -268,27 +268,25 @@ export const deriveStores = context => {
return res?.rows?.[0]
}
- // Refreshes a specific row, handling updates, addition or deletion
- const refreshRow = async id => {
- // Fetch row from the server again
- const newRow = await fetchRow(id)
-
+ // Replaces a row in state with the newly defined row, handling updates,
+ // addition and deletion
+ const replaceRow = (id, row) => {
// Get index of row to check if it exists
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[id]
// Process as either an update, addition or deletion
- if (newRow) {
+ if (row) {
if (index != null) {
// An existing row was updated
rows.update(state => {
- state[index] = { ...newRow }
+ state[index] = { ...row }
return state
})
} else {
// A new row was created
- handleNewRows([newRow])
+ handleNewRows([row])
}
} else if (index != null) {
// A row was removed
@@ -296,6 +294,12 @@ export const deriveStores = context => {
}
}
+ // Refreshes a specific row
+ const refreshRow = async id => {
+ const row = await fetchRow(id)
+ replaceRow(id, row)
+ }
+
// Refreshes all data
const refreshData = () => {
get(fetch)?.getInitialData()
@@ -341,10 +345,15 @@ export const deriveStores = context => {
const saved = await API.saveRow({ ...row, ...get(rowChangeCache)[rowId] })
// Update state after a successful change
- rows.update(state => {
- state[index] = saved
- return state.slice()
- })
+ if (saved?._id) {
+ rows.update(state => {
+ state[index] = saved
+ return state.slice()
+ })
+ } else if (saved?.id) {
+ // Handle users table edge case
+ await refreshRow(saved.id)
+ }
rowChangeCache.update(state => {
delete state[rowId]
return state
@@ -455,6 +464,7 @@ export const deriveStores = context => {
hasRow,
loadNextPage,
refreshRow,
+ replaceRow,
refreshData,
refreshTableDefinition,
},
diff --git a/packages/frontend-core/src/components/grid/stores/users.js b/packages/frontend-core/src/components/grid/stores/users.js
index 3a6ec5fb21..5368c414ce 100644
--- a/packages/frontend-core/src/components/grid/stores/users.js
+++ b/packages/frontend-core/src/components/grid/stores/users.js
@@ -1,95 +1,50 @@
import { writable, get, derived } from "svelte/store"
+import { helpers } from "@budibase/shared-core"
export const createStores = () => {
const users = writable([])
- const userId = writable(null)
- // Enrich users with unique colours
- const enrichedUsers = derived(
- [users, userId],
- ([$users, $userId]) => {
- return (
- $users
- .slice()
- // Place current user first
- .sort((a, b) => {
- if (a.id === $userId) {
- return -1
- } else if (b.id === $userId) {
- return 1
- } else {
- return 0
- }
- })
- // Enrich users with colors
- .map((user, idx) => {
- // Generate random colour hue
- let hue = 1
- for (let i = 0; i < user.email.length && i < 5; i++) {
- hue *= user.email.charCodeAt(i + 1)
- hue /= 17
- }
- hue = hue % 360
- const color =
- idx === 0
- ? "var(--spectrum-global-color-blue-400)"
- : `hsl(${hue}, 50%, 40%)`
-
- // Generate friendly label
- let label = user.email
- if (user.firstName) {
- label = user.firstName
- if (user.lastName) {
- label += ` ${user.lastName}`
- }
- }
-
- return {
- ...user,
- color,
- label,
- }
- })
- )
- },
- []
- )
+ const enrichedUsers = derived(users, $users => {
+ return $users.map(user => ({
+ ...user,
+ color: helpers.getUserColor(user),
+ label: helpers.getUserLabel(user),
+ }))
+ })
return {
users: {
...users,
subscribe: enrichedUsers.subscribe,
},
- userId,
}
}
export const deriveStores = context => {
- const { users, userId } = context
+ const { users, focusedCellId } = context
// Generate a lookup map of cell ID to the user that has it selected, to make
// lookups inside cells extremely fast
const selectedCellMap = derived(
- [users, userId],
- ([$enrichedUsers, $userId]) => {
+ [users, focusedCellId],
+ ([$users, $focusedCellId]) => {
let map = {}
- $enrichedUsers.forEach(user => {
- if (user.focusedCellId && user.id !== $userId) {
+ $users.forEach(user => {
+ if (user.focusedCellId && user.focusedCellId !== $focusedCellId) {
map[user.focusedCellId] = user
}
})
return map
- },
- {}
+ }
)
const updateUser = user => {
const $users = get(users)
- const index = $users.findIndex(x => x.id === user.id)
- if (index === -1) {
+ if (!$users.some(x => x.sessionId === user.sessionId)) {
users.set([...$users, user])
} else {
users.update(state => {
+ const index = state.findIndex(x => x.sessionId === user.sessionId)
state[index] = user
return state.slice()
})
@@ -98,7 +53,7 @@ export const deriveStores = context => {
const removeUser = user => {
users.update(state => {
- return state.filter(x => x.id !== user.id)
+ return state.filter(x => x.sessionId !== user.sessionId)
})
}
diff --git a/packages/frontend-core/src/components/index.js b/packages/frontend-core/src/components/index.js
index 88107da535..3005c85d01 100644
--- a/packages/frontend-core/src/components/index.js
+++ b/packages/frontend-core/src/components/index.js
@@ -1,4 +1,5 @@
export { default as SplitPage } from "./SplitPage.svelte"
export { default as TestimonialPage } from "./TestimonialPage.svelte"
export { default as Testimonial } from "./Testimonial.svelte"
+export { default as UserAvatar } from "./UserAvatar.svelte"
export { Grid } from "./grid"
diff --git a/packages/frontend-core/src/utils/index.js b/packages/frontend-core/src/utils/index.js
index 6ac7c65c62..16dc4f323a 100644
--- a/packages/frontend-core/src/utils/index.js
+++ b/packages/frontend-core/src/utils/index.js
@@ -3,3 +3,4 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles"
export * as Utils from "./utils"
+export { createWebsocket } from "./websocket"
diff --git a/packages/frontend-core/src/utils/websocket.js b/packages/frontend-core/src/utils/websocket.js
new file mode 100644
index 0000000000..839fa6d73d
--- /dev/null
+++ b/packages/frontend-core/src/utils/websocket.js
@@ -0,0 +1,26 @@
+import { io } from "socket.io-client"
+
+export const createWebsocket = path => {
+ if (!path) {
+ throw "A websocket path must be provided"
+ }
+
+ // Determine connection info
+ const tls = location.protocol === "https:"
+ const proto = tls ? "wss:" : "ws:"
+ const host = location.hostname
+ const port = location.port || (tls ? 443 : 80)
+ return io(`${proto}//${host}:${port}`, {
+ path,
+ // Cap reconnection attempts to 3 (total of 15 seconds before giving up)
+ reconnectionAttempts: 3,
+ // Delay reconnection attempt by 5 seconds
+ reconnectionDelay: 5000,
+ reconnectionDelayMax: 5000,
+ // Timeout after 4 seconds so we never stack requests
+ timeout: 4000,
+ // Disable polling and rely on websocket only, as HTTP transport
+ // will only work with sticky sessions which we don't have
+ transports: ["websocket"],
+ })
+}
diff --git a/packages/server/package.json b/packages/server/package.json
index a2b7b17893..9f053401d8 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -59,6 +59,7 @@
"@koa/router": "8.0.8",
"@sendgrid/mail": "7.1.1",
"@sentry/node": "6.17.7",
+ "@socket.io/redis-adapter": "^8.2.1",
"airtable": "0.10.1",
"arangojs": "7.2.0",
"aws-sdk": "2.1030.0",
diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts
index ff0dbe015b..9c89b48b8a 100644
--- a/packages/server/src/api/controllers/application.ts
+++ b/packages/server/src/api/controllers/application.ts
@@ -26,10 +26,13 @@ import {
env as envCore,
} from "@budibase/backend-core"
import { USERS_TABLE_SCHEMA } from "../../constants"
-import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default"
+import {
+ DEFAULT_BB_DATASOURCE_ID,
+ buildDefaultDocs,
+} from "../../db/defaultData/datasource_bb_default"
import { removeAppFromUserRoles } from "../../utilities/workerRequests"
import { stringToReadStream, isQsTrue } from "../../utilities"
-import { getLocksById } from "../../utilities/redis"
+import { getLocksById, doesUserHaveLock } from "../../utilities/redis"
import {
updateClientLibrary,
backupClientLibrary,
@@ -111,11 +114,7 @@ function checkAppName(
}
}
-async function createInstance(
- appId: string,
- template: any,
- includeSampleData: boolean
-) {
+async function createInstance(appId: string, template: any) {
const db = context.getAppDB()
await db.put({
_id: "_design/database",
@@ -142,21 +141,25 @@ async function createInstance(
} else {
// create the users table
await db.put(USERS_TABLE_SCHEMA)
-
- if (includeSampleData) {
- // create ootb stock db
- await addDefaultTables(db)
- }
}
return { _id: appId }
}
-async function addDefaultTables(db: Database) {
- const defaultDbDocs = buildDefaultDocs()
+export const addSampleData = async (ctx: UserCtx) => {
+ const db = context.getAppDB()
- // add in the default db data docs - tables, datasource, rows and links
- await db.bulkDocs([...defaultDbDocs])
+ try {
+ // Check if default datasource exists before creating it
+ await sdk.datasources.get(DEFAULT_BB_DATASOURCE_ID)
+ } catch (err: any) {
+ const defaultDbDocs = buildDefaultDocs()
+
+ // add in the default db data docs - tables, datasource, rows and links
+ await db.bulkDocs([...defaultDbDocs])
+ }
+
+ ctx.status = 200
}
export async function fetch(ctx: UserCtx) {
@@ -227,6 +230,7 @@ export async function fetchAppPackage(ctx: UserCtx) {
screens,
layouts,
clientLibPath,
+ hasLock: await doesUserHaveLock(application.appId, ctx.user),
}
}
@@ -247,16 +251,11 @@ async function performAppCreate(ctx: UserCtx) {
if (ctx.request.files && ctx.request.files.templateFile) {
instanceConfig.file = ctx.request.files.templateFile
}
- const includeSampleData = isQsTrue(ctx.request.body.sampleData)
const tenantId = tenancy.isMultiTenant() ? tenancy.getTenantId() : null
const appId = generateDevAppID(generateAppID(tenantId))
return await context.doInAppContext(appId, async () => {
- const instance = await createInstance(
- appId,
- instanceConfig,
- includeSampleData
- )
+ const instance = await createInstance(appId, instanceConfig)
const db = context.getAppDB()
let newApplication: App = {
diff --git a/packages/server/src/api/controllers/datasource.ts b/packages/server/src/api/controllers/datasource.ts
index 2d7ba8224f..9852b01bc5 100644
--- a/packages/server/src/api/controllers/datasource.ts
+++ b/packages/server/src/api/controllers/datasource.ts
@@ -26,6 +26,7 @@ import {
DatasourcePlus,
} from "@budibase/types"
import sdk from "../../sdk"
+import { builderSocket } from "../../websockets"
function getErrorTables(errors: any, errorType: string) {
return Object.entries(errors)
@@ -296,6 +297,7 @@ export async function update(ctx: UserCtx
) {
ctx.body = {
datasource: await sdk.datasources.removeSecretSingle(datasource),
}
+ builderSocket.emitDatasourceUpdate(ctx, datasource)
}
export async function save(
@@ -338,6 +340,7 @@ export async function save(
response.error = schemaError
}
ctx.body = response
+ builderSocket.emitDatasourceUpdate(ctx, datasource)
}
async function destroyInternalTablesBySourceId(datasourceId: string) {
@@ -397,6 +400,7 @@ export async function destroy(ctx: UserCtx) {
ctx.message = `Datasource deleted.`
ctx.status = 200
+ builderSocket.emitDatasourceDeletion(ctx, datasourceId)
}
export async function find(ctx: UserCtx) {
diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts
index 349c4e72e7..55d2d27cce 100644
--- a/packages/server/src/api/controllers/row/index.ts
+++ b/packages/server/src/api/controllers/row/index.ts
@@ -4,6 +4,7 @@ import * as external from "./external"
import { isExternalTable } from "../../../integrations/utils"
import { Ctx } from "@budibase/types"
import * as utils from "./utils"
+import { gridSocket } from "../../../websockets"
function pickApi(tableId: any) {
if (isExternalTable(tableId)) {
@@ -12,21 +13,9 @@ function pickApi(tableId: any) {
return internal
}
-function getTableId(ctx: any) {
- if (ctx.request.body && ctx.request.body.tableId) {
- return ctx.request.body.tableId
- }
- if (ctx.params && ctx.params.tableId) {
- return ctx.params.tableId
- }
- if (ctx.params && ctx.params.viewName) {
- return ctx.params.viewName
- }
-}
-
export async function patch(ctx: any): Promise {
const appId = ctx.appId
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
const body = ctx.request.body
// if it doesn't have an _id then its save
if (body && !body._id) {
@@ -47,6 +36,7 @@ export async function patch(ctx: any): Promise {
ctx.eventEmitter.emitRow(`row:update`, appId, row, table)
ctx.message = `${table.name} updated successfully.`
ctx.body = row
+ gridSocket?.emitRowUpdate(ctx, row)
} catch (err) {
ctx.throw(400, err)
}
@@ -54,7 +44,7 @@ export async function patch(ctx: any): Promise {
export const save = async (ctx: any) => {
const appId = ctx.appId
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
const body = ctx.request.body
// if it has an ID already then its a patch
if (body && body._id) {
@@ -69,23 +59,24 @@ export const save = async (ctx: any) => {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:save`, appId, row, table)
ctx.message = `${table.name} saved successfully`
ctx.body = row
+ gridSocket?.emitRowUpdate(ctx, row)
}
export async function fetchView(ctx: any) {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).fetchView(ctx), {
datasourceId: tableId,
})
}
export async function fetch(ctx: any) {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).fetch(ctx), {
datasourceId: tableId,
})
}
export async function find(ctx: any) {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).find(ctx), {
datasourceId: tableId,
})
@@ -94,7 +85,7 @@ export async function find(ctx: any) {
export async function destroy(ctx: any) {
const appId = ctx.appId
const inputs = ctx.request.body
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
let response, row
if (inputs.rows) {
let { rows } = await quotas.addQuery(
@@ -107,6 +98,7 @@ export async function destroy(ctx: any) {
response = rows
for (let row of rows) {
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
+ gridSocket?.emitRowDeletion(ctx, row._id)
}
} else {
let resp = await quotas.addQuery(() => pickApi(tableId).destroy(ctx), {
@@ -116,6 +108,7 @@ export async function destroy(ctx: any) {
response = resp.response
row = resp.row
ctx.eventEmitter && ctx.eventEmitter.emitRow(`row:delete`, appId, row)
+ gridSocket?.emitRowDeletion(ctx, row._id)
}
ctx.status = 200
// for automations include the row that was deleted
@@ -124,7 +117,7 @@ export async function destroy(ctx: any) {
}
export async function search(ctx: any) {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
ctx.status = 200
ctx.body = await quotas.addQuery(() => pickApi(tableId).search(ctx), {
datasourceId: tableId,
@@ -132,7 +125,7 @@ export async function search(ctx: any) {
}
export async function validate(ctx: Ctx) {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
// external tables are hard to validate currently
if (isExternalTable(tableId)) {
ctx.body = { valid: true }
@@ -145,7 +138,7 @@ export async function validate(ctx: Ctx) {
}
export async function fetchEnrichedRow(ctx: any) {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(
() => pickApi(tableId).fetchEnrichedRow(ctx),
{
@@ -155,7 +148,7 @@ export async function fetchEnrichedRow(ctx: any) {
}
export const exportRows = async (ctx: any) => {
- const tableId = getTableId(ctx)
+ const tableId = utils.getTableId(ctx)
ctx.body = await quotas.addQuery(() => pickApi(tableId).exportRows(ctx), {
datasourceId: tableId,
})
diff --git a/packages/server/src/api/controllers/row/utils.ts b/packages/server/src/api/controllers/row/utils.ts
index f6a87dd24c..f1edbf538b 100644
--- a/packages/server/src/api/controllers/row/utils.ts
+++ b/packages/server/src/api/controllers/row/utils.ts
@@ -154,3 +154,15 @@ export function cleanExportRows(
return cleanRows
}
+
+export function getTableId(ctx: any) {
+ if (ctx.request.body && ctx.request.body.tableId) {
+ return ctx.request.body.tableId
+ }
+ if (ctx.params && ctx.params.tableId) {
+ return ctx.params.tableId
+ }
+ if (ctx.params && ctx.params.viewName) {
+ return ctx.params.viewName
+ }
+}
diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts
index cbbda7b930..271f3e82fa 100644
--- a/packages/server/src/api/controllers/table/index.ts
+++ b/packages/server/src/api/controllers/table/index.ts
@@ -11,6 +11,7 @@ import { context, events } from "@budibase/backend-core"
import { Table, UserCtx } from "@budibase/types"
import sdk from "../../../sdk"
import { jsonFromCsvString } from "../../../utilities/csv"
+import { builderSocket } from "../../../websockets"
function pickApi({ tableId, table }: { tableId?: string; table?: Table }) {
if (table && !tableId) {
@@ -77,6 +78,7 @@ export async function save(ctx: UserCtx) {
ctx.eventEmitter &&
ctx.eventEmitter.emitTable(`table:save`, appId, savedTable)
ctx.body = savedTable
+ builderSocket.emitTableUpdate(ctx, savedTable)
}
export async function destroy(ctx: UserCtx) {
@@ -89,6 +91,7 @@ export async function destroy(ctx: UserCtx) {
ctx.status = 200
ctx.table = deletedTable
ctx.body = { message: `Table ${tableId} deleted.` }
+ builderSocket.emitTableDeletion(ctx, tableId)
}
export async function bulkImport(ctx: UserCtx) {
diff --git a/packages/server/src/api/controllers/view/index.ts b/packages/server/src/api/controllers/view/index.ts
index ed2302f723..28b0d0a81f 100644
--- a/packages/server/src/api/controllers/view/index.ts
+++ b/packages/server/src/api/controllers/view/index.ts
@@ -16,6 +16,7 @@ import {
View,
} from "@budibase/types"
import { cleanExportRows } from "../row/utils"
+import { builderSocket } from "../../../websockets"
const { cloneDeep, isEqual } = require("lodash")
@@ -48,7 +49,7 @@ export async function save(ctx: Ctx) {
if (!view.meta.schema) {
view.meta.schema = table.schema
}
- table.views[viewName] = view.meta
+ table.views[viewName] = { ...view.meta, name: viewName }
if (originalName) {
delete table.views[originalName]
existingTable.views[viewName] = existingTable.views[originalName]
@@ -56,10 +57,8 @@ export async function save(ctx: Ctx) {
await db.put(table)
await handleViewEvents(existingTable.views[viewName], table.views[viewName])
- ctx.body = {
- ...table.views[viewToSave.name],
- name: viewToSave.name,
- }
+ ctx.body = table.views[viewName]
+ builderSocket.emitTableUpdate(ctx, table)
}
export async function calculationEvents(existingView: View, newView: View) {
@@ -128,6 +127,7 @@ export async function destroy(ctx: Ctx) {
await events.view.deleted(view)
ctx.body = view
+ builderSocket.emitTableUpdate(ctx, table)
}
export async function exportView(ctx: Ctx) {
diff --git a/packages/server/src/api/routes/application.ts b/packages/server/src/api/routes/application.ts
index 0aa88568f3..0c1fa364ff 100644
--- a/packages/server/src/api/routes/application.ts
+++ b/packages/server/src/api/routes/application.ts
@@ -38,6 +38,11 @@ router
authorized(permissions.BUILDER),
controller.revertClient
)
+ .post(
+ "/api/applications/:appId/sample",
+ authorized(permissions.BUILDER),
+ controller.addSampleData
+ )
.post(
"/api/applications/:appId/publish",
authorized(permissions.BUILDER),
diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index f8f82f9fdc..274de89c57 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -61,7 +61,6 @@ if (env.isProd()) {
const server = http.createServer(app.callback())
destroyable(server)
-initialiseWebsockets(app, server)
let shuttingDown = false,
errCode = 0
diff --git a/packages/server/src/middleware/builder.ts b/packages/server/src/middleware/builder.ts
index 5174f618a0..ffb2e2c002 100644
--- a/packages/server/src/middleware/builder.ts
+++ b/packages/server/src/middleware/builder.ts
@@ -35,12 +35,11 @@ async function checkDevAppLocks(ctx: BBContext) {
if (!appId || !appId.startsWith(APP_DEV_PREFIX)) {
return
}
- if (!(await doesUserHaveLock(appId, ctx.user))) {
- ctx.throw(400, "User does not hold app lock.")
- }
- // they do have lock, update it
- await updateLock(appId, ctx.user)
+ // If this user already owns the lock, then update it
+ if (await doesUserHaveLock(appId, ctx.user)) {
+ await updateLock(appId, ctx.user)
+ }
}
async function updateAppUpdatedAt(ctx: BBContext) {
diff --git a/packages/server/src/startup.ts b/packages/server/src/startup.ts
index 2fd59b7a8e..64a6c011c4 100644
--- a/packages/server/src/startup.ts
+++ b/packages/server/src/startup.ts
@@ -16,6 +16,7 @@ import * as bullboard from "./automations/bullboard"
import * as pro from "@budibase/pro"
import * as api from "./api"
import sdk from "./sdk"
+import { initialise as initialiseWebsockets } from "./websockets"
let STARTUP_RAN = false
@@ -64,6 +65,7 @@ export async function startup(app?: any, server?: any) {
fileSystem.init()
await redis.init()
eventInit()
+ initialiseWebsockets(app, server)
// run migrations on startup if not done via http
// not recommended in a clustered environment
diff --git a/packages/server/src/utilities/redis.ts b/packages/server/src/utilities/redis.ts
index dc37baae58..ff1c863bf7 100644
--- a/packages/server/src/utilities/redis.ts
+++ b/packages/server/src/utilities/redis.ts
@@ -4,23 +4,33 @@ import { ContextUser } from "@budibase/types"
const APP_DEV_LOCK_SECONDS = 600
const AUTOMATION_TEST_FLAG_SECONDS = 60
-let devAppClient: any, debounceClient: any, flagClient: any
+let devAppClient: any, debounceClient: any, flagClient: any, socketClient: any
-// we init this as we want to keep the connection open all the time
+// We need to maintain a duplicate client for socket.io pub/sub
+let socketSubClient: any
+
+// We init this as we want to keep the connection open all the time
// reduces the performance hit
export async function init() {
devAppClient = new redis.Client(redis.utils.Databases.DEV_LOCKS)
debounceClient = new redis.Client(redis.utils.Databases.DEBOUNCE)
flagClient = new redis.Client(redis.utils.Databases.FLAGS)
+ socketClient = new redis.Client(redis.utils.Databases.SOCKET_IO)
await devAppClient.init()
await debounceClient.init()
await flagClient.init()
+ await socketClient.init()
+
+ // Duplicate the socket client for pub/sub
+ socketSubClient = socketClient.getClient().duplicate()
}
export async function shutdown() {
if (devAppClient) await devAppClient.finish()
if (debounceClient) await debounceClient.finish()
if (flagClient) await flagClient.finish()
+ if (socketClient) await socketClient.finish()
+ if (socketSubClient) socketSubClient.disconnect()
// shutdown core clients
await redis.clients.shutdown()
console.log("Redis shutdown")
@@ -86,3 +96,10 @@ export async function checkTestFlag(id: string) {
export async function clearTestFlag(id: string) {
await devAppClient.delete(id)
}
+
+export function getSocketPubSubClients() {
+ return {
+ pub: socketClient.getClient(),
+ sub: socketSubClient,
+ }
+}
diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts
new file mode 100644
index 0000000000..de8709129d
--- /dev/null
+++ b/packages/server/src/websockets/builder.ts
@@ -0,0 +1,69 @@
+import authorized from "../middleware/authorized"
+import Socket from "./websocket"
+import { permissions } from "@budibase/backend-core"
+import http from "http"
+import Koa from "koa"
+import { Datasource, Table } from "@budibase/types"
+import { gridSocket } from "./index"
+import { clearLock } from "../utilities/redis"
+
+export default class BuilderSocket extends Socket {
+ constructor(app: Koa, server: http.Server) {
+ super(app, server, "/socket/builder", [authorized(permissions.BUILDER)])
+
+ this.io.on("connection", socket => {
+ // Join a room for this app
+ const user = socket.data.user
+ const appId = socket.data.appId
+ socket.join(appId)
+ socket.to(appId).emit("user-update", user)
+
+ // Initial identification of connected spreadsheet
+ socket.on("get-users", async (payload, callback) => {
+ const sockets = await this.io.in(appId).fetchSockets()
+ callback({
+ users: sockets.map(socket => socket.data.user),
+ })
+ })
+
+ // Disconnection cleanup
+ socket.on("disconnect", async () => {
+ socket.to(appId).emit("user-disconnect", user)
+
+ // Remove app lock from this user if they have no other connections
+ try {
+ const sockets = await this.io.in(appId).fetchSockets()
+ const hasOtherConnection = sockets.some(socket => {
+ const { _id, sessionId } = socket.data.user
+ return _id === user._id && sessionId !== user.sessionId
+ })
+ if (!hasOtherConnection) {
+ await clearLock(appId, user)
+ }
+ } catch (e) {
+ // This is fine, just means this user didn't hold the lock
+ }
+ })
+ })
+ }
+
+ emitTableUpdate(ctx: any, table: Table) {
+ this.io.in(ctx.appId).emit("table-change", { id: table._id, table })
+ gridSocket.emitTableUpdate(table)
+ }
+
+ emitTableDeletion(ctx: any, id: string) {
+ this.io.in(ctx.appId).emit("table-change", { id, table: null })
+ gridSocket.emitTableDeletion(id)
+ }
+
+ emitDatasourceUpdate(ctx: any, datasource: Datasource) {
+ this.io
+ .in(ctx.appId)
+ .emit("datasource-change", { id: datasource._id, datasource })
+ }
+
+ emitDatasourceDeletion(ctx: any, id: string) {
+ this.io.in(ctx.appId).emit("datasource-change", { id, datasource: null })
+ }
+}
diff --git a/packages/server/src/websockets/grid.ts b/packages/server/src/websockets/grid.ts
index 886784cd2c..a0272be33c 100644
--- a/packages/server/src/websockets/grid.ts
+++ b/packages/server/src/websockets/grid.ts
@@ -3,6 +3,8 @@ import Socket from "./websocket"
import { permissions } from "@budibase/backend-core"
import http from "http"
import Koa from "koa"
+import { getTableId } from "../api/controllers/row/utils"
+import { Row, Table } from "@budibase/types"
export default class GridSocket extends Socket {
constructor(app: Koa, server: http.Server) {
@@ -10,7 +12,6 @@ export default class GridSocket extends Socket {
this.io.on("connection", socket => {
const user = socket.data.user
- console.log(`Spreadsheet user connected: ${user?.id}`)
// Socket state
let currentRoom: string
@@ -19,37 +20,54 @@ export default class GridSocket extends Socket {
socket.on("select-table", async (tableId, callback) => {
// Leave current room
if (currentRoom) {
- socket.to(currentRoom).emit("user-disconnect", socket.data.user)
+ socket.to(currentRoom).emit("user-disconnect", user)
socket.leave(currentRoom)
}
// Join new room
currentRoom = tableId
socket.join(currentRoom)
- socket.to(currentRoom).emit("user-update", socket.data.user)
+ socket.to(currentRoom).emit("user-update", user)
// Reply with all users in current room
const sockets = await this.io.in(currentRoom).fetchSockets()
callback({
users: sockets.map(socket => socket.data.user),
- id: user.id,
})
})
// Handle users selecting a new cell
socket.on("select-cell", cellId => {
- socket.data.user.selectedCellId = cellId
+ socket.data.user.focusedCellId = cellId
if (currentRoom) {
- socket.to(currentRoom).emit("user-update", socket.data.user)
+ socket.to(currentRoom).emit("user-update", user)
}
})
// Disconnection cleanup
socket.on("disconnect", () => {
if (currentRoom) {
- socket.to(currentRoom).emit("user-disconnect", socket.data.user)
+ socket.to(currentRoom).emit("user-disconnect", user)
}
})
})
}
+
+ emitRowUpdate(ctx: any, row: Row) {
+ const tableId = getTableId(ctx)
+ this.io.in(tableId).emit("row-change", { id: row._id, row })
+ }
+
+ emitRowDeletion(ctx: any, id: string) {
+ const tableId = getTableId(ctx)
+ this.io.in(tableId).emit("row-change", { id, row: null })
+ }
+
+ emitTableUpdate(table: Table) {
+ this.io.in(table._id!).emit("table-change", { id: table._id, table })
+ }
+
+ emitTableDeletion(id: string) {
+ this.io.in(id).emit("table-change", { id, table: null })
+ }
}
diff --git a/packages/server/src/websockets/index.ts b/packages/server/src/websockets/index.ts
index 60cdfb8aed..b74a8adfca 100644
--- a/packages/server/src/websockets/index.ts
+++ b/packages/server/src/websockets/index.ts
@@ -1,14 +1,17 @@
import http from "http"
import Koa from "koa"
-import GridSocket from "./grid"
import ClientAppSocket from "./client"
+import GridSocket from "./grid"
+import BuilderSocket from "./builder"
let clientAppSocket: ClientAppSocket
let gridSocket: GridSocket
+let builderSocket: BuilderSocket
export const initialise = (app: Koa, server: http.Server) => {
clientAppSocket = new ClientAppSocket(app, server)
gridSocket = new GridSocket(app, server)
+ builderSocket = new BuilderSocket(app, server)
}
-export { clientAppSocket, gridSocket }
+export { clientAppSocket, gridSocket, builderSocket }
diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts
index 1b34168f14..5cac959099 100644
--- a/packages/server/src/websockets/websocket.ts
+++ b/packages/server/src/websockets/websocket.ts
@@ -5,6 +5,9 @@ import Cookies from "cookies"
import { userAgent } from "koa-useragent"
import { auth } from "@budibase/backend-core"
import currentApp from "../middleware/currentapp"
+import { createAdapter } from "@socket.io/redis-adapter"
+import { getSocketPubSubClients } from "../utilities/redis"
+import uuid from "uuid"
export default class Socket {
io: Server
@@ -12,7 +15,7 @@ export default class Socket {
constructor(
app: Koa,
server: http.Server,
- path: string,
+ path: string = "/",
additionalMiddlewares?: any[]
) {
this.io = new Server(server, {
@@ -59,13 +62,21 @@ export default class Socket {
for (let [idx, middleware] of middlewares.entries()) {
await middleware(ctx, () => {
if (idx === middlewares.length - 1) {
- // Middlewares are finished.
+ // Middlewares are finished
// Extract some data from our enriched koa context to persist
// as metadata for the socket
+ // Add user info, including a deterministic color and label
+ const { _id, email, firstName, lastName } = ctx.user
socket.data.user = {
- id: ctx.user._id,
- email: ctx.user.email,
+ _id,
+ email,
+ firstName,
+ lastName,
+ sessionId: uuid.v4(),
}
+
+ // Add app ID to help split sockets into rooms
+ socket.data.appId = ctx.appId
next()
}
})
@@ -74,6 +85,11 @@ export default class Socket {
next(error)
}
})
+
+ // Instantiate redis adapter
+ const { pub, sub } = getSocketPubSubClients()
+ const opts = { key: `socket.io-${path}` }
+ this.io.adapter(createAdapter(pub, sub, opts))
}
// Emit an event to all sockets
diff --git a/packages/shared-core/src/helpers/helpers.ts b/packages/shared-core/src/helpers/helpers.ts
index 8ecfc24b56..8b8f5a3918 100644
--- a/packages/shared-core/src/helpers/helpers.ts
+++ b/packages/shared-core/src/helpers/helpers.ts
@@ -1,3 +1,5 @@
+import { User } from "@budibase/types"
+
/**
* Gets a key within an object. The key supports dot syntax for retrieving deep
* fields - e.g. "a.b.c".
@@ -21,3 +23,60 @@ export const deepGet = (obj: { [x: string]: any }, key: string) => {
}
return obj
}
+
+/**
+ * Gets the initials to show in a user avatar.
+ * @param user the user
+ */
+export const getUserInitials = (user: User) => {
+ if (!user) {
+ return "?"
+ }
+ let initials = ""
+ initials += user.firstName ? user.firstName[0] : ""
+ initials += user.lastName ? user.lastName[0] : ""
+ return initials === "" ? user.email[0] : initials
+}
+
+/**
+ * Gets a deterministic colour for a particular user
+ * @param user the user
+ */
+export const getUserColor = (user: User) => {
+ let id = user?._id
+ if (!id) {
+ return "var(--spectrum-global-color-blue-400)"
+ }
+
+ // In order to generate the same color for global users as app users, we need
+ // to remove the app-specific table prefix
+ id = id.replace("ro_ta_users_", "")
+
+ // Generate a hue based on the ID
+ let hue = 1
+ for (let i = 0; i < id.length; i++) {
+ hue += id.charCodeAt(i)
+ hue = hue % 36
+ }
+ return `hsl(${hue * 10}, 50%, 40%)`
+}
+
+/**
+ * Gets a friendly label to describe who a user is.
+ * @param user the user
+ */
+export const getUserLabel = (user: User) => {
+ if (!user) {
+ return ""
+ }
+ const { firstName, lastName, email } = user
+ if (firstName && lastName) {
+ return `${firstName} ${lastName}`
+ } else if (firstName) {
+ return firstName
+ } else if (lastName) {
+ return lastName
+ } else {
+ return email
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 3eb037b40c..c5aad94d5b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4610,6 +4610,15 @@
resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
+"@socket.io/redis-adapter@^8.2.1":
+ version "8.2.1"
+ resolved "https://registry.yarnpkg.com/@socket.io/redis-adapter/-/redis-adapter-8.2.1.tgz#36f75afc518d0e1fa4fa7c29e6d042f53ee7563b"
+ integrity sha512-6Dt7EZgGSBP0qvXeOKGx7NnSr2tPMbVDfDyL97zerZo+v69hMfL99skMCL3RKZlWVqLyRme2T0wcy3udHhtOsg==
+ dependencies:
+ debug "~4.3.1"
+ notepack.io "~3.0.1"
+ uid2 "1.0.0"
+
"@spectrum-css/accordion@3.0.24":
version "3.0.24"
resolved "https://registry.yarnpkg.com/@spectrum-css/accordion/-/accordion-3.0.24.tgz#f89066c120c57b0cfc9aba66d60c39fc1cf69f74"
@@ -18643,6 +18652,11 @@ normalize-url@^6.0.1:
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
+notepack.io@~3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/notepack.io/-/notepack.io-3.0.1.tgz#2c2c9de1bd4e64a79d34e33c413081302a0d4019"
+ integrity sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==
+
npm-bundled@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1"
@@ -22918,7 +22932,7 @@ socket.io-adapter@~2.5.2:
dependencies:
ws "~8.11.0"
-socket.io-client@^4.5.1, socket.io-client@^4.6.1:
+socket.io-client@^4.6.1:
version "4.6.1"
resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.6.1.tgz#80d97d5eb0feca448a0fb6d69a7b222d3d547eab"
integrity sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==
@@ -24804,6 +24818,11 @@ uid2@0.0.x:
resolved "https://registry.yarnpkg.com/uid2/-/uid2-0.0.4.tgz#033f3b1d5d32505f5ce5f888b9f3b667123c0a44"
integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==
+uid2@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/uid2/-/uid2-1.0.0.tgz#ef8d95a128d7c5c44defa1a3d052eecc17a06bfb"
+ integrity sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==
+
unbox-primitive@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e"