1
0
Fork 0
mirror of synced 2024-09-17 17:57:47 +12:00

Fix issues with not disconnecting users when swapping datasource and improve multi-user UI

This commit is contained in:
Andrew Kingston 2023-03-06 11:20:47 +00:00
parent 3e907af8b5
commit 4647e1bc07
20 changed files with 249 additions and 104 deletions

View file

@ -106,8 +106,13 @@
</script> </script>
<div class="wrapper"> <div class="wrapper">
<div class="buttons"> <Sheet
<div class="left-buttons"> tableId={$tables.selected?._id}
{API}
filter={filters}
on:add-column={createColumnModal.show}
>
<svelte:fragment slot="controls">
<CreateColumnButton <CreateColumnButton
highlighted={!hasCols || !hasRows} highlighted={!hasCols || !hasRows}
on:updatecolumns={null} on:updatecolumns={null}
@ -124,8 +129,6 @@
{#if isInternal} {#if isInternal}
<CreateViewButton disabled={!hasCols || !hasRows} /> <CreateViewButton disabled={!hasCols || !hasRows} />
{/if} {/if}
</div>
<div class="right-buttons">
<ManageAccessButton resourceId={$tables.selected?._id} /> <ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable} {#if isUsersTable}
<EditRolesButton /> <EditRolesButton />
@ -157,16 +160,8 @@
tableId={id} tableId={id}
/> />
{/key} {/key}
</div> </svelte:fragment>
</div> </Sheet>
<div class="sheet">
<Sheet
tableId={$tables.selected?._id}
{API}
filter={filters}
on:add-column={createColumnModal.show}
/>
</div>
</div> </div>
<!--<div>--> <!--<div>-->
@ -234,34 +229,4 @@
flex-direction: column; flex-direction: column;
background: var(--background); background: var(--background);
} }
.sheet {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: var(--spacing-xl);
}
.buttons {
flex: 0 0 48px;
border-bottom: 2px solid var(--spectrum-global-color-gray-200);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
padding: 0 8px;
}
.left-buttons,
.right-buttons {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
</style> </style>

View file

@ -0,0 +1,24 @@
<script>
export let user
</script>
<div class="user" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
<style>
div {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
div:hover {
cursor: pointer;
}
</style>

View file

@ -2,7 +2,7 @@
import { Modal, ModalContent, Button } from "@budibase/bbui" import { Modal, ModalContent, Button } from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
const { selectedRows, rows } = getContext("spreadsheet") const { selectedRows, rows } = getContext("sheet")
let modal let modal

View file

@ -5,7 +5,7 @@
import { getIconForField } from "./utils" import { getIconForField } from "./utils"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte" import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
const { visibleColumns, reorder } = getContext("spreadsheet") const { visibleColumns, reorder } = getContext("sheet")
</script> </script>
<div class="header"> <div class="header">

View file

@ -3,7 +3,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
const { visibleColumns, hoveredRowId, rows, selectedCellId, reorder } = const { visibleColumns, hoveredRowId, rows, selectedCellId, reorder } =
getContext("spreadsheet") getContext("sheet")
$: rowHovered = $hoveredRowId === "new" $: rowHovered = $hoveredRowId === "new"

View file

@ -9,7 +9,7 @@
visibleColumns, visibleColumns,
cellHeight, cellHeight,
stickyColumn, stickyColumn,
} = getContext("spreadsheet") } = getContext("sheet")
const MinColumnWidth = 100 const MinColumnWidth = 100
let initialMouseX = null let initialMouseX = null

View file

@ -11,7 +11,7 @@
maxScrollTop, maxScrollTop,
contentWidth, contentWidth,
maxScrollLeft, maxScrollLeft,
} = getContext("spreadsheet") } = getContext("sheet")
// Bar config // Bar config
const barOffset = 4 const barOffset = 4

View file

@ -1,6 +1,7 @@
<script> <script>
import { setContext, createEventDispatcher, onMount } from "svelte" import { setContext, createEventDispatcher, onMount } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { createAPIClient } from "../../api"
import { createReorderStores } from "./stores/reorder" import { createReorderStores } from "./stores/reorder"
import { createViewportStores } from "./stores/viewport" import { createViewportStores } from "./stores/viewport"
import { createRowsStore } from "./stores/rows" import { createRowsStore } from "./stores/rows"
@ -9,15 +10,15 @@
import { createBoundsStores } from "./stores/bounds" import { createBoundsStores } from "./stores/bounds"
import { createInterfaceStores } from "./stores/interface" import { createInterfaceStores } from "./stores/interface"
export { createUserStores } from "./stores/users" export { createUserStores } from "./stores/users"
import { createWebsocket } from "./websocket"
import { createUserStores } from "./stores/users"
import DeleteButton from "./DeleteButton.svelte" import DeleteButton from "./DeleteButton.svelte"
import SheetBody from "./SheetBody.svelte" import SheetBody from "./SheetBody.svelte"
import ResizeOverlay from "./ResizeOverlay.svelte" import ResizeOverlay from "./ResizeOverlay.svelte"
import HeaderRow from "./HeaderRow.svelte" import HeaderRow from "./HeaderRow.svelte"
import { createAPIClient } from "../../api"
import ScrollOverlay from "./ScrollOverlay.svelte" import ScrollOverlay from "./ScrollOverlay.svelte"
import StickyColumn from "./StickyColumn.svelte" import StickyColumn from "./StickyColumn.svelte"
import { createWebsocket } from "./websocket" import UserAvatars from "./UserAvatars.svelte"
import { createUserStores } from "./stores/users"
export let API export let API
export let tableId export let tableId
@ -65,16 +66,21 @@
}) })
// Set context for children to consume // Set context for children to consume
setContext("spreadsheet", context) setContext("sheet", context)
// Initialise websocket for multi-user // Initialise websocket for multi-user
onMount(() => { onMount(() => createWebsocket(context))
return createWebsocket(context)
})
</script> </script>
<div class="sheet" style="--cell-height:{cellHeight}px;" id="sheet-{rand}"> <div class="sheet" style="--cell-height:{cellHeight}px;" id="sheet-{rand}">
<!--<SheetControls />--> <div class="controls">
<div class="controls-left">
<slot name="controls" />
</div>
<div class="controls-right">
<UserAvatars />
</div>
</div>
<div class="sheet-data"> <div class="sheet-data">
<StickyColumn /> <StickyColumn />
<div class="sheet-main"> <div class="sheet-main">
@ -128,4 +134,24 @@
flex-direction: column; flex-direction: column;
align-self: stretch; align-self: stretch;
} }
/* Controls */
.controls {
height: var(--controls-height);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--spectrum-global-color-gray-200);
padding: var(--cell-padding);
gap: var(--cell-spacing);
}
.controls-left,
.controls-right {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--cell-spacing);
}
</style> </style>

View file

@ -4,8 +4,7 @@
import NewRow from "./NewRow.svelte" import NewRow from "./NewRow.svelte"
import SheetRow from "./SheetRow.svelte" import SheetRow from "./SheetRow.svelte"
const { selectedCellId, bounds, visibleRows, config } = const { selectedCellId, bounds, visibleRows, config } = getContext("sheet")
getContext("spreadsheet")
let ref let ref
@ -27,8 +26,8 @@
on:click|self={() => ($selectedCellId = null)} on:click|self={() => ($selectedCellId = null)}
> >
<SheetScrollWrapper> <SheetScrollWrapper>
{#each $visibleRows as row} {#each $visibleRows as row, idx}
<SheetRow {row} /> <SheetRow {row} {idx} />
{/each} {/each}
{#if $config.allowAddRows} {#if $config.allowAddRows}
<NewRow /> <NewRow />

View file

@ -1,4 +1,6 @@
<script> <script>
import Avatar from "./Avatar.svelte"
export let header = false export let header = false
export let label = false export let label = false
export let rowSelected = false export let rowSelected = false
@ -10,6 +12,18 @@
export let width = "" export let width = ""
export let center = false export let center = false
export let selectedUser = null export let selectedUser = null
export let rowIdx
$: style = getStyle(width, selectedUser)
const getStyle = (width, selectedUser) => {
let style = `flex: 0 0 ${width}px;`
if (selectedUser) {
console.log(selectedUser)
style += `--user-color:${selectedUser.color};`
}
return style
}
</script> </script>
<div <div
@ -27,14 +41,15 @@
on:focus on:focus
on:click on:click
on:mousedown on:mousedown
style="flex: 0 0 {width}px;" {style}
data-row={rowIdx}
> >
{#if selectedUser} <slot />
<div class="name"> {#if selectedUser && !selected}
{selectedUser.email} <div class="user">
{selectedUser.label} wwwwwwwwwwwwwwwwwwwwaaaaaa
</div> </div>
{/if} {/if}
<slot />
</div> </div>
<style> <style>
@ -56,10 +71,11 @@
} }
.cell.selected { .cell.selected {
box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400); box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400);
z-index: 1; z-index: 2;
} }
.cell.selected-other { .cell.selected-other:not(.selected) {
box-shadow: inset 0 0 0 2px var(--spectrum-global-color-purple-400); z-index: 1;
box-shadow: inset 0 0 0 2px var(--user-color);
} }
.cell:not(.selected) { .cell:not(.selected) {
user-select: none; user-select: none;
@ -121,13 +137,29 @@
align-items: center; align-items: center;
} }
.name { /* Other user email */
.user {
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
background: var(--spectrum-global-color-purple-400); padding: 1px 4px;
padding: 1px 4px 0 4px; background: var(--user-color);
border-radius: 2px; border-radius: 2px 2px 0 0;
display: none;
color: white;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.cell[data-row="0"] .user {
bottom: auto;
top: 100%;
border-radius: 0 0 2px 2px;
padding: 0 4px 2px 4px;
}
.cell:hover .user {
display: block;
} }
</style> </style>

View file

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
const { rows } = getContext("spreadsheet") const { rows } = getContext("sheet")
$: rowCount = $rows.length $: rowCount = $rows.length
</script> </script>

View file

@ -4,6 +4,7 @@
import { getCellRenderer } from "./renderers" import { getCellRenderer } from "./renderers"
export let row export let row
export let idx
const { const {
selectedCellId, selectedCellId,
@ -13,7 +14,7 @@
visibleColumns, visibleColumns,
hoveredRowId, hoveredRowId,
selectedCellMap, selectedCellMap,
} = getContext("spreadsheet") } = getContext("sheet")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id $: rowHovered = $hoveredRowId === row._id
@ -30,6 +31,7 @@
<SheetCell <SheetCell
{rowSelected} {rowSelected}
{rowHovered} {rowHovered}
rowIdx={idx}
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellIdx}
selectedUser={$selectedCellMap[cellIdx]} selectedUser={$selectedCellMap[cellIdx]}
reorderSource={$reorder.sourceColumn === column.name} reorderSource={$reorder.sourceColumn === column.name}

View file

@ -12,7 +12,7 @@
hoveredRowId, hoveredRowId,
maxScrollTop, maxScrollTop,
maxScrollLeft, maxScrollLeft,
} = getContext("spreadsheet") } = getContext("sheet")
export let scrollVertically = true export let scrollVertically = true
export let scrollHorizontally = true export let scrollHorizontally = true

View file

@ -16,7 +16,8 @@
scroll, scroll,
reorder, reorder,
config, config,
} = getContext("spreadsheet") selectedCellMap,
} = getContext("sheet")
$: scrollLeft = $scroll.left $: scrollLeft = $scroll.left
$: rowCount = $rows.length $: rowCount = $rows.length
@ -96,7 +97,7 @@
<div class="content" on:mouseleave={() => ($hoveredRowId = null)}> <div class="content" on:mouseleave={() => ($hoveredRowId = null)}>
<SheetScrollWrapper scrollHorizontally={false}> <SheetScrollWrapper scrollHorizontally={false}>
{#each $visibleRows as row} {#each $visibleRows as row, idx}
{@const rowSelected = !!$selectedRows[row._id]} {@const rowSelected = !!$selectedRows[row._id]}
{@const rowHovered = $hoveredRowId === row._id} {@const rowHovered = $hoveredRowId === row._id}
<div class="row" on:mouseenter={() => ($hoveredRowId = row._id)}> <div class="row" on:mouseenter={() => ($hoveredRowId = row._id)}>
@ -123,8 +124,10 @@
<SheetCell <SheetCell
{rowSelected} {rowSelected}
{rowHovered} {rowHovered}
rowIdx={idx}
sticky sticky
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellIdx}
selectedUser={$selectedCellMap[cellIdx]}
on:click={() => ($selectedCellId = cellIdx)} on:click={() => ($selectedCellId = cellIdx)}
width={$stickyColumn.width} width={$stickyColumn.width}
reorderTarget={$reorder.targetColumn === $stickyColumn.name} reorderTarget={$reorder.targetColumn === $stickyColumn.name}

View file

@ -0,0 +1,21 @@
<script>
import { getContext } from "svelte"
import Avatar from "./Avatar.svelte"
const { users } = getContext("sheet")
</script>
<div class="users">
{#each $users as user}
<Avatar {user} />
{/each}
</div>
<style>
.users {
display: flex;
flex-direction: row;
padding: 0 10px;
gap: 8px;
}
</style>

View file

@ -138,8 +138,9 @@
user-select: none; user-select: none;
} }
.arrow { .arrow {
border-right: 2px solid var(--spectrum-global-color-blue-400);
position: absolute; position: absolute;
right: 2px; right: 0;
top: 2px; top: 2px;
bottom: 2px; bottom: 2px;
padding: 0 6px 0 16px; padding: 0 6px 0 16px;

View file

@ -4,6 +4,52 @@ export const createUserStores = () => {
const users = writable([]) const users = writable([])
const userId = writable(null) 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)
}
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 updateUser = user => { const updateUser = user => {
const $users = get(users) const $users = get(users)
const index = $users.findIndex(x => x.id === user.id) const index = $users.findIndex(x => x.id === user.id)
@ -17,22 +63,35 @@ export const createUserStores = () => {
} }
} }
const selectedCellMap = derived([users, userId], ([$users, $userId]) => { const removeUser = user => {
let map = {} users.update(state => {
$users.forEach(user => { return state.filter(x => x.id !== user.id)
if (user.selectedCellId && user.id !== $userId) {
map[user.selectedCellId] = user
}
}) })
console.log(map) }
return map
}) // Generate a lookup map of cell ID to the user that has it selected, to make
// lookups inside sheet cells extremely fast
const selectedCellMap = derived(
[enrichedUsers, userId],
([$enrichedUsers, $userId]) => {
let map = {}
$enrichedUsers.forEach(user => {
if (user.selectedCellId && user.id !== $userId) {
map[user.selectedCellId] = user
}
})
return map
}
)
return { return {
users: { users: {
...users, ...enrichedUsers,
set: users.set,
update: users.update,
actions: { actions: {
updateUser, updateUser,
removeUser,
}, },
}, },
selectedCellMap, selectedCellMap,

View file

@ -1,8 +1,8 @@
export const getColor = idx => { export const getColor = (idx, opacity = 0.3) => {
if (idx == null || idx === -1) { if (idx == null || idx === -1) {
return null return null
} }
return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, 0.3)` return `hsla(${((idx + 1) * 222) % 360}, 90%, 75%, ${opacity})`
} }
export const getIconForField = field => { export const getIconForField = field => {

View file

@ -25,12 +25,11 @@ export const createWebsocket = context => {
if (!socket.connected) { if (!socket.connected) {
return return
} }
console.log("Idenifying dataspace", tableId) console.log("Identifying dataspace", tableId)
// Identify which dataspace we are editing // Identify which dataspace we are editing
socket.emit("select-dataspace", tableId, response => { socket.emit("select-dataspace", tableId, response => {
// handle initial connection info // handle initial connection info
console.log("response", response)
users.set(response.users) users.set(response.users)
userId.set(response.id) userId.set(response.id)
}) })
@ -42,17 +41,22 @@ export const createWebsocket = context => {
}) })
socket.on("row-update", data => { socket.on("row-update", data => {
console.log("row-update:", data.id) console.log("row-update", data.id)
if (data.id) { if (data.id) {
rows.actions.refreshRow(data.id) rows.actions.refreshRow(data.id)
} }
}) })
socket.on("user-update", user => { socket.on("user-update", user => {
console.log("user-update", user) console.log("user-update", user.id)
users.actions.updateUser(user) users.actions.updateUser(user)
}) })
socket.on("user-disconnect", user => {
console.log("user-disconnect", user.id)
users.actions.removeUser(user)
})
socket.on("connect_error", err => { socket.on("connect_error", err => {
console.log("Failed to connect to websocket:", err.message) console.log("Failed to connect to websocket:", err.message)
}) })

View file

@ -15,31 +15,40 @@ export default class DataspaceSocket extends Socket {
// Socket state // Socket state
let currentRoom: string let currentRoom: string
// Initial identification of conneted dataspace // Initial identification of connected dataspace
socket.on("select-dataspace", async (tableId, callback) => { socket.on("select-dataspace", async (tableId, callback) => {
// Leave current room
if (currentRoom) { if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
socket.leave(currentRoom) socket.leave(currentRoom)
} }
socket.join(tableId)
currentRoom = tableId
const sockets = await this.io.in(tableId).fetchSockets()
socket.broadcast.emit("user-update", socket.data.user)
// Join new room
currentRoom = tableId
socket.join(currentRoom)
socket.to(currentRoom).emit("user-update", socket.data.user)
// Reply with all users in current room
const sockets = await this.io.in(currentRoom).fetchSockets()
callback({ callback({
users: sockets.map(socket => socket.data.user), users: sockets.map(socket => socket.data.user),
id: user.id, id: user.id,
}) })
}) })
// Handle users selecting a new cell
socket.on("select-cell", cellId => { socket.on("select-cell", cellId => {
console.log("cell update for " + user.id + " to " + cellId)
socket.data.user.selectedCellId = cellId socket.data.user.selectedCellId = cellId
socket.broadcast.emit("user-update", socket.data.user) if (currentRoom) {
socket.to(currentRoom).emit("user-update", socket.data.user)
}
}) })
// Disconnection cleanup // Disconnection cleanup
socket.on("disconnect", reason => { socket.on("disconnect", () => {
console.log(`Disconnecting ${user.email} because of ${reason}`) if (currentRoom) {
socket.to(currentRoom).emit("user-disconnect", socket.data.user)
}
}) })
}) })
} }