Add context menu to sheets with deletion and duplication features

This commit is contained in:
Andrew Kingston 2023-03-07 17:03:37 +00:00
parent ef54813764
commit 3c71acd68e
11 changed files with 207 additions and 67 deletions

View File

@ -1,5 +1,10 @@
<script> <script>
import { Modal, ModalContent, ActionButton } from "@budibase/bbui" import {
Modal,
ModalContent,
ActionButton,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte" import { getContext } from "svelte"
const { selectedRows, rows } = getContext("sheet") const { selectedRows, rows } = getContext("sheet")
@ -19,7 +24,9 @@
// Deletion callback when confirmed // Deletion callback when confirmed
const performDeletion = async () => { const performDeletion = async () => {
const count = rowsToDelete.length
await rows.actions.deleteRows(rowsToDelete) await rows.actions.deleteRows(rowsToDelete)
notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
} }
</script> </script>
@ -33,7 +40,7 @@
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent <ModalContent
title="Add screens" title="Delete rows"
confirmText="Continue" confirmText="Continue"
cancelText="Cancel" cancelText="Cancel"
onConfirm={performDeletion} onConfirm={performDeletion}

View File

@ -0,0 +1,73 @@
<script>
import {
clickOutside,
Menu,
MenuItem,
Modal,
ModalContent,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte"
const { selectedCellRow, menu, rows, columns, selectedCellId } =
getContext("sheet")
let modal
$: style = makeStyle($menu)
const makeStyle = menu => {
return `left:${menu.left}px; top:${menu.top}px;`
}
const deleteRow = () => {
rows.actions.deleteRows([$selectedCellRow])
menu.actions.close()
notifications.success("Deleted 1 row")
}
const duplicate = async () => {
let clone = { ...$selectedCellRow }
delete clone._id
delete clone._rev
delete clone.__idx
const newRow = await rows.actions.addRow(clone, $selectedCellRow.__idx + 1)
if (newRow) {
$selectedCellId = `${newRow._id}-${$columns[0].name}`
menu.actions.close()
}
}
</script>
{#if $menu.visible}
<div class="menu" {style} use:clickOutside={() => menu.actions.close()}>
<Menu>
<MenuItem icon="Delete" on:click={modal.show}>Delete row</MenuItem>
<MenuItem icon="Duplicate" on:click={duplicate}>Duplicate row</MenuItem>
</Menu>
</div>
{/if}
<Modal bind:this={modal}>
<ModalContent
title="Delete row"
confirmText="Continue"
cancelText="Cancel"
onConfirm={deleteRow}
size="M"
>
Are you sure you want to delete this row?
</ModalContent>
</Modal>
<style>
.menu {
position: absolute;
background: var(--cell-background);
border: var(--cell-border);
width: 160px;
border-radius: 4px;
display: flex;
flex-direction: column;
}
</style>

View File

@ -14,11 +14,13 @@
import { createWebsocket } from "./websocket" import { createWebsocket } from "./websocket"
import { createUserStores } from "./stores/users" import { createUserStores } from "./stores/users"
import { createResizeStores } from "./stores/resize" import { createResizeStores } from "./stores/resize"
import { createMenuStores } from "./stores/menu"
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 ScrollOverlay from "./ScrollOverlay.svelte" import ScrollOverlay from "./ScrollOverlay.svelte"
import MenuOverlay from "./MenuOverlay.svelte"
import StickyColumn from "./StickyColumn.svelte" import StickyColumn from "./StickyColumn.svelte"
import UserAvatars from "./UserAvatars.svelte" import UserAvatars from "./UserAvatars.svelte"
@ -60,6 +62,7 @@
context = { ...context, ...createReorderStores(context) } context = { ...context, ...createReorderStores(context) }
context = { ...context, ...createInterfaceStores(context) } context = { ...context, ...createInterfaceStores(context) }
context = { ...context, ...createUserStores(context) } context = { ...context, ...createUserStores(context) }
context = { ...context, ...createMenuStores(context) }
// Reference some stores for local use // Reference some stores for local use
const { isResizing, isReordering } = context const { isResizing, isReordering } = context
@ -107,6 +110,7 @@
</div> </div>
<ResizeOverlay /> <ResizeOverlay />
<ScrollOverlay /> <ScrollOverlay />
<MenuOverlay />
</div> </div>
</div> </div>
@ -122,7 +126,7 @@
/* Variables */ /* Variables */
--cell-background: var(--spectrum-global-color-gray-50); --cell-background: var(--spectrum-global-color-gray-50);
--cell-background-hover: var(--spectrum-global-color-gray-75); --cell-background-hover: var(--spectrum-global-color-gray-100);
--cell-padding: 10px; --cell-padding: 10px;
--cell-spacing: 4px; --cell-spacing: 4px;
--cell-font-size: 14px; --cell-font-size: 14px;

View File

@ -4,27 +4,23 @@
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 } = getContext("sheet") const { bounds, visibleRows, config } = getContext("sheet")
let ref let body
onMount(() => { onMount(() => {
// Observe and record the height of the body // Observe and record the height of the body
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
bounds.set(ref.getBoundingClientRect()) bounds.set(body.getBoundingClientRect())
}) })
observer.observe(ref) observer.observe(body)
return () => { return () => {
observer.disconnect() observer.disconnect()
} }
}) })
</script> </script>
<div <div bind:this={body} class="sheet-body">
bind:this={ref}
class="sheet-body"
on:click|self={() => ($selectedCellId = null)}
>
<SheetScrollWrapper> <SheetScrollWrapper>
{#each $visibleRows as row, idx} {#each $visibleRows as row, idx}
<SheetRow {row} {idx} /> <SheetRow {row} {idx} />

View File

@ -14,10 +14,13 @@
visibleColumns, visibleColumns,
hoveredRowId, hoveredRowId,
selectedCellMap, selectedCellMap,
selectedCellRow,
menu,
} = getContext("sheet") } = getContext("sheet")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
$: rowHovered = $hoveredRowId === row._id $: rowHovered = $hoveredRowId === row._id
$: containsSelectedCell = $selectedCellRow?._id === row._id
</script> </script>
<div <div
@ -27,23 +30,24 @@
on:mouseleave={() => ($hoveredRowId = null)} on:mouseleave={() => ($hoveredRowId = null)}
> >
{#each $visibleColumns as column (column.name)} {#each $visibleColumns as column (column.name)}
{@const cellIdx = `${row._id}-${column.name}`} {@const cellId = `${row._id}-${column.name}`}
<SheetCell <SheetCell
{rowSelected} rowSelected={rowSelected || containsSelectedCell}
{rowHovered} {rowHovered}
rowIdx={idx} rowIdx={idx}
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellId}
selectedUser={$selectedCellMap[cellIdx]} selectedUser={$selectedCellMap[cellId]}
reorderSource={$reorder.sourceColumn === column.name} reorderSource={$reorder.sourceColumn === column.name}
reorderTarget={$reorder.targetColumn === column.name} reorderTarget={$reorder.targetColumn === column.name}
on:click={() => ($selectedCellId = cellIdx)} on:click={() => ($selectedCellId = cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)}
width={column.width} width={column.width}
> >
<svelte:component <svelte:component
this={getCellRenderer(column)} this={getCellRenderer(column)}
value={row[column.name]} value={row[column.name]}
schema={column.schema} schema={column.schema}
selected={$selectedCellId === cellIdx} selected={$selectedCellId === cellId}
onChange={val => rows.actions.updateRow(row._id, column, val)} onChange={val => rows.actions.updateRow(row._id, column, val)}
readonly={column.schema.autocolumn} readonly={column.schema.autocolumn}
/> />

View File

@ -12,6 +12,7 @@
hoveredRowId, hoveredRowId,
maxScrollTop, maxScrollTop,
maxScrollLeft, maxScrollLeft,
selectedCellId,
} = getContext("sheet") } = getContext("sheet")
export let scrollVertically = true export let scrollVertically = true
@ -71,7 +72,11 @@
}) })
</script> </script>
<div class="outer" on:wheel={wheelInteractive ? handleWheel : null}> <div
class="outer"
on:wheel={wheelInteractive ? handleWheel : null}
on:click|self={() => ($selectedCellId = null)}
>
<div {style}> <div {style}>
<slot /> <slot />
</div> </div>

View File

@ -1,7 +1,6 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Checkbox, Icon } from "@budibase/bbui" import { Checkbox, Icon } from "@budibase/bbui"
import { getIconForField } from "./utils"
import SheetCell from "./cells/SheetCell.svelte" import SheetCell from "./cells/SheetCell.svelte"
import { getCellRenderer } from "./renderers" import { getCellRenderer } from "./renderers"
import SheetScrollWrapper from "./SheetScrollWrapper.svelte" import SheetScrollWrapper from "./SheetScrollWrapper.svelte"
@ -18,6 +17,7 @@
reorder, reorder,
config, config,
selectedCellMap, selectedCellMap,
selectedCellRow,
} = getContext("sheet") } = getContext("sheet")
$: scrollLeft = $scroll.left $: scrollLeft = $scroll.left
@ -88,8 +88,14 @@
{#each $visibleRows as row, idx} {#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}
{@const containsSelectedRow = $selectedCellRow?._id === row._id}
<div class="row" on:mouseenter={() => ($hoveredRowId = row._id)}> <div class="row" on:mouseenter={() => ($hoveredRowId = row._id)}>
<SheetCell label {rowSelected} {rowHovered} width="40"> <SheetCell
label
rowSelected={rowSelected || containsSelectedRow}
{rowHovered}
width="40"
>
<div <div
on:click={() => selectRow(row._id)} on:click={() => selectRow(row._id)}
class="checkbox" class="checkbox"
@ -108,9 +114,9 @@
</SheetCell> </SheetCell>
{#if $stickyColumn} {#if $stickyColumn}
{@const cellIdx = `${row._id}-${$stickyColumn.name}`} {@const cellIdx = `${row._id}-${stickyColumn.name}`}
<SheetCell <SheetCell
{rowSelected} rowSelected={rowSelected || containsSelectedRow}
{rowHovered} {rowHovered}
rowIdx={idx} rowIdx={idx}
sticky sticky

View File

@ -83,10 +83,10 @@
cursor: default; cursor: default;
} }
.cell.row-selected { .cell.row-selected {
background-color: var(--spectrum-global-color-gray-100); --cell-background: var(--spectrum-global-color-gray-75);
} }
.cell.row-hovered { .cell.row-hovered {
background: var(--cell-background-hover); --cell-background: var(--cell-background-hover);
} }
.cell.center { .cell.center {
justify-content: center; justify-content: center;
@ -94,7 +94,7 @@
/* Reorder styles */ /* Reorder styles */
.cell.reorder-source { .cell.reorder-source {
background: var(--spectrum-global-color-gray-100); --cell-background: var(--spectrum-global-color-gray-100);
} }
.cell.reorder-target:after { .cell.reorder-target:after {
content: " "; content: " ";

View File

@ -1,11 +1,21 @@
import { writable, get } from "svelte/store" import { writable, get, derived } from "svelte/store"
export const createInterfaceStores = context => { export const createInterfaceStores = context => {
const { rows } = context const { rows, rowLookupMap } = context
const selectedCellId = writable(null) const selectedCellId = writable(null)
const selectedRows = writable({}) const selectedRows = writable({})
const hoveredRowId = writable(null) const hoveredRowId = writable(null)
// Derive the row that contains the selected cell.
const selectedCellRow = derived(
[selectedCellId, rowLookupMap, rows],
([$selectedCellId, $rowLookupMap, $rows]) => {
const rowId = $selectedCellId?.split("-")[0]
const index = $rowLookupMap[rowId]
return $rows[index]
}
)
// Ensure we clear invalid rows from state if they disappear // Ensure we clear invalid rows from state if they disappear
rows.subscribe($rows => { rows.subscribe($rows => {
const $selectedCellId = get(selectedCellId) const $selectedCellId = get(selectedCellId)
@ -38,5 +48,5 @@ export const createInterfaceStores = context => {
} }
}) })
return { selectedCellId, selectedRows, hoveredRowId } return { selectedCellId, selectedRows, hoveredRowId, selectedCellRow }
} }

View File

@ -0,0 +1,40 @@
import { writable, get } from "svelte/store"
export const createMenuStores = context => {
const { bounds, selectedCellId, stickyColumn, cellHeight } = context
const menu = writable({
x: 0,
y: 0,
visible: false,
selectedRow: null,
})
const open = (cellId, e) => {
const $bounds = get(bounds)
const $stickyColumn = get(stickyColumn)
e.preventDefault()
selectedCellId.set(cellId)
menu.set({
left: e.clientX - $bounds.left + 44 + ($stickyColumn?.width || 0),
top: e.clientY - $bounds.top + cellHeight + 4,
visible: true,
})
}
const close = () => {
menu.update(state => ({
...state,
visible: false,
}))
}
return {
menu: {
...menu,
actions: {
open,
close,
},
},
}
}

View File

@ -15,6 +15,19 @@ export const createRowsStore = context => {
column: null, column: null,
order: null, order: null,
}) })
const enrichedRows = derived(rows, $rows => {
return $rows.map((row, idx) => ({
...row,
__idx: idx,
}))
})
const rowLookupMap = derived(enrichedRows, $rows => {
let map = {}
for (let i = 0; i < $rows.length; i++) {
map[$rows[i]._id] = i
}
return map
})
// Local cache of row IDs to speed up checking if a row exists // Local cache of row IDs to speed up checking if a row exists
let rowCacheMap = {} let rowCacheMap = {}
@ -84,29 +97,21 @@ export const createRowsStore = context => {
}) })
// Adds a new empty row // Adds a new empty row
const addRow = async () => { const addRow = async (row, idx) => {
try { try {
// Create row // Create row
let newRow = await API.saveRow({ tableId: get(tableId) }) const newRow = await API.saveRow({ ...row, tableId: get(tableId) })
// Use search endpoint to fetch the row again, ensuring relationships are
// properly enriched
const res = await API.searchTable({
tableId: get(tableId),
limit: 1,
query: {
equal: {
_id: newRow._id,
},
},
paginate: false,
})
if (res?.rows?.[0]) {
newRow = res.rows[0]
}
// Update state // Update state
if (idx) {
rowCacheMap[newRow._id] = true
rows.update(state => {
state.splice(idx, 0, newRow)
return state.slice()
})
} else {
handleNewRows([newRow]) handleNewRows([newRow])
}
return newRow return newRow
} catch (error) { } catch (error) {
notifications.error(`Error adding row: ${error?.message}`) notifications.error(`Error adding row: ${error?.message}`)
@ -115,10 +120,6 @@ export const createRowsStore = context => {
// Refreshes a specific row, handling updates, addition or deletion // Refreshes a specific row, handling updates, addition or deletion
const refreshRow = async id => { const refreshRow = async id => {
// Get index of row to check if it exists
const $rows = get(rows)
const index = $rows.findIndex(row => row._id === id)
// Fetch row from the server again // Fetch row from the server again
const res = await API.searchTable({ const res = await API.searchTable({
tableId: get(tableId), tableId: get(tableId),
@ -132,12 +133,16 @@ export const createRowsStore = context => {
}) })
let newRow = res?.rows?.[0] let newRow = res?.rows?.[0]
// Get index of row to check if it exists
const $rows = get(rows)
const index = $rows.findIndex(row => row._id === id)
// Process as either an update, addition or deletion // Process as either an update, addition or deletion
if (newRow) { if (newRow) {
if (index !== -1) { if (index !== -1) {
// An existing row was updated // An existing row was updated
rows.update(state => { rows.update(state => {
state[index] = { ...newRow, __idx: index } state[index] = { ...newRow }
return state return state
}) })
} else { } else {
@ -182,8 +187,6 @@ export const createRowsStore = context => {
} catch (error) { } catch (error) {
notifications.error(`Error saving row: ${error?.message}`) notifications.error(`Error saving row: ${error?.message}`)
} }
return await refreshRow(row._id)
} }
// Deletes an array of rows // Deletes an array of rows
@ -214,15 +217,7 @@ export const createRowsStore = context => {
} }
} }
if (rowsToAppend.length) { if (rowsToAppend.length) {
rows.update($rows => { rows.update(state => [...state, ...rowsToAppend])
return [
...$rows,
...rowsToAppend.map((row, idx) => ({
...row,
__idx: $rows.length + idx,
})),
]
})
} }
} }
@ -233,9 +228,7 @@ export const createRowsStore = context => {
// We deliberately do not remove IDs from the cache map as the data may // We deliberately do not remove IDs from the cache map as the data may
// still exist inside the fetch, but we don't want to add it again // still exist inside the fetch, but we don't want to add it again
rows.update(state => { rows.update(state => {
return state return state.filter(row => !deletedIds.includes(row._id))
.filter(row => !deletedIds.includes(row._id))
.map((row, idx) => ({ ...row, __idx: idx }))
}) })
// If we ended up with no rows, try getting the next page // If we ended up with no rows, try getting the next page
@ -257,6 +250,7 @@ export const createRowsStore = context => {
return { return {
rows: { rows: {
...rows, ...rows,
subscribe: enrichedRows.subscribe,
actions: { actions: {
addRow, addRow,
updateRow, updateRow,
@ -267,6 +261,7 @@ export const createRowsStore = context => {
refreshSchema, refreshSchema,
}, },
}, },
rowLookupMap,
table, table,
schema, schema,
sort, sort,