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>
import { Modal, ModalContent, ActionButton } from "@budibase/bbui"
import {
Modal,
ModalContent,
ActionButton,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte"
const { selectedRows, rows } = getContext("sheet")
@ -19,7 +24,9 @@
// Deletion callback when confirmed
const performDeletion = async () => {
const count = rowsToDelete.length
await rows.actions.deleteRows(rowsToDelete)
notifications.success(`Deleted ${count} row${count === 1 ? "" : "s"}`)
}
</script>
@ -33,7 +40,7 @@
<Modal bind:this={modal}>
<ModalContent
title="Add screens"
title="Delete rows"
confirmText="Continue"
cancelText="Cancel"
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 { createUserStores } from "./stores/users"
import { createResizeStores } from "./stores/resize"
import { createMenuStores } from "./stores/menu"
import DeleteButton from "./DeleteButton.svelte"
import SheetBody from "./SheetBody.svelte"
import ResizeOverlay from "./ResizeOverlay.svelte"
import HeaderRow from "./HeaderRow.svelte"
import ScrollOverlay from "./ScrollOverlay.svelte"
import MenuOverlay from "./MenuOverlay.svelte"
import StickyColumn from "./StickyColumn.svelte"
import UserAvatars from "./UserAvatars.svelte"
@ -60,6 +62,7 @@
context = { ...context, ...createReorderStores(context) }
context = { ...context, ...createInterfaceStores(context) }
context = { ...context, ...createUserStores(context) }
context = { ...context, ...createMenuStores(context) }
// Reference some stores for local use
const { isResizing, isReordering } = context
@ -107,6 +110,7 @@
</div>
<ResizeOverlay />
<ScrollOverlay />
<MenuOverlay />
</div>
</div>
@ -122,7 +126,7 @@
/* Variables */
--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-spacing: 4px;
--cell-font-size: 14px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,21 @@
import { writable, get } from "svelte/store"
import { writable, get, derived } from "svelte/store"
export const createInterfaceStores = context => {
const { rows } = context
const { rows, rowLookupMap } = context
const selectedCellId = writable(null)
const selectedRows = writable({})
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
rows.subscribe($rows => {
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,
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
let rowCacheMap = {}
@ -84,29 +97,21 @@ export const createRowsStore = context => {
})
// Adds a new empty row
const addRow = async () => {
const addRow = async (row, idx) => {
try {
// Create row
let newRow = await API.saveRow({ 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]
}
const newRow = await API.saveRow({ ...row, tableId: get(tableId) })
// Update state
if (idx) {
rowCacheMap[newRow._id] = true
rows.update(state => {
state.splice(idx, 0, newRow)
return state.slice()
})
} else {
handleNewRows([newRow])
}
return newRow
} catch (error) {
notifications.error(`Error adding row: ${error?.message}`)
@ -115,10 +120,6 @@ export const createRowsStore = context => {
// Refreshes a specific row, handling updates, addition or deletion
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
const res = await API.searchTable({
tableId: get(tableId),
@ -132,12 +133,16 @@ export const createRowsStore = context => {
})
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
if (newRow) {
if (index !== -1) {
// An existing row was updated
rows.update(state => {
state[index] = { ...newRow, __idx: index }
state[index] = { ...newRow }
return state
})
} else {
@ -182,8 +187,6 @@ export const createRowsStore = context => {
} catch (error) {
notifications.error(`Error saving row: ${error?.message}`)
}
return await refreshRow(row._id)
}
// Deletes an array of rows
@ -214,15 +217,7 @@ export const createRowsStore = context => {
}
}
if (rowsToAppend.length) {
rows.update($rows => {
return [
...$rows,
...rowsToAppend.map((row, idx) => ({
...row,
__idx: $rows.length + idx,
})),
]
})
rows.update(state => [...state, ...rowsToAppend])
}
}
@ -233,9 +228,7 @@ export const createRowsStore = context => {
// 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
rows.update(state => {
return state
.filter(row => !deletedIds.includes(row._id))
.map((row, idx) => ({ ...row, __idx: idx }))
return state.filter(row => !deletedIds.includes(row._id))
})
// If we ended up with no rows, try getting the next page
@ -257,6 +250,7 @@ export const createRowsStore = context => {
return {
rows: {
...rows,
subscribe: enrichedRows.subscribe,
actions: {
addRow,
updateRow,
@ -267,6 +261,7 @@ export const createRowsStore = context => {
refreshSchema,
},
},
rowLookupMap,
table,
schema,
sort,