Add context menu to sheets with deletion and duplication features
This commit is contained in:
parent
ef54813764
commit
3c71acd68e
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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;
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: " ";
|
||||
|
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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
|
||||
handleNewRows([newRow])
|
||||
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,
|
||||
|
|
Loading…
Reference in New Issue