521 lines
13 KiB
Svelte
521 lines
13 KiB
Svelte
<script>
|
|
import { getContext } from "svelte"
|
|
import { fetchData, LuceneUtils } from "@budibase/frontend-core"
|
|
import { Icon, ActionButton } from "@budibase/bbui"
|
|
import TextCell from "./TextCell.svelte"
|
|
import OptionsCell from "./OptionsCell.svelte"
|
|
import DateCell from "./DateCell.svelte"
|
|
import MultiSelectCell from "./MultiSelectCell.svelte"
|
|
|
|
export let table
|
|
export let filter
|
|
export let sortColumn
|
|
export let sortOrder
|
|
|
|
const { styleable, API, confirmationStore, notificationStore } =
|
|
getContext("sdk")
|
|
const component = getContext("component")
|
|
const limit = 100
|
|
const defaultWidth = 160
|
|
const minWidth = 100
|
|
|
|
let widths
|
|
let hoveredRow
|
|
let selectedCell
|
|
let selectedRows = {}
|
|
let horizontallyScrolled = false
|
|
let changeCache = {}
|
|
let newRows = []
|
|
|
|
$: query = LuceneUtils.buildLuceneQuery(filter)
|
|
$: fetch = createFetch(table)
|
|
$: fetch.update({
|
|
sortColumn,
|
|
sortOrder,
|
|
query,
|
|
limit,
|
|
})
|
|
$: fields = Object.keys($fetch.schema || {})
|
|
$: initWidths(fields)
|
|
$: gridStyles = getGridStyles(widths)
|
|
$: schema = $fetch.schema
|
|
$: rowCount = $fetch.rows?.length || 0
|
|
$: selectedRowCount = Object.values(selectedRows).filter(x => !!x).length
|
|
$: rows = getSortedRows($fetch.rows, newRows)
|
|
|
|
const createFetch = datasource => {
|
|
return fetchData({
|
|
API,
|
|
datasource,
|
|
options: {
|
|
sortColumn,
|
|
sortOrder,
|
|
query,
|
|
limit,
|
|
paginate: true,
|
|
},
|
|
})
|
|
}
|
|
|
|
const initWidths = fields => {
|
|
widths = fields.map(() => defaultWidth)
|
|
}
|
|
|
|
const getGridStyles = widths => {
|
|
if (!widths?.length) {
|
|
return "--grid: 1fr;"
|
|
}
|
|
return `--grid: 40px ${widths.map(x => `${x}px`).join(" ")} 180px;`
|
|
}
|
|
|
|
const handleScroll = e => {
|
|
const nextHorizontallyScrolled = e.target.scrollLeft > 0
|
|
if (nextHorizontallyScrolled !== horizontallyScrolled) {
|
|
horizontallyScrolled = nextHorizontallyScrolled
|
|
}
|
|
}
|
|
|
|
const getCellForField = field => {
|
|
const type = schema?.[field]?.type
|
|
if (type === "options") {
|
|
return OptionsCell
|
|
} else if (type === "datetime") {
|
|
return DateCell
|
|
} else if (type === "array") {
|
|
return MultiSelectCell
|
|
}
|
|
return TextCell
|
|
}
|
|
|
|
const getIconForField = field => {
|
|
const type = schema?.[field]?.type
|
|
if (type === "options") {
|
|
return "ChevronDown"
|
|
} else if (type === "datetime") {
|
|
return "Date"
|
|
}
|
|
return "Text"
|
|
}
|
|
|
|
const selectRow = id => {
|
|
selectedRows[id] = !selectedRows[id]
|
|
}
|
|
|
|
const selectAll = () => {
|
|
const allSelected = selectedRowCount === rowCount
|
|
if (allSelected) {
|
|
selectedRows = {}
|
|
} else {
|
|
rows.forEach(row => {
|
|
selectedRows[row._id] = true
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleChange = async (rowId, field, value) => {
|
|
let row = rows.find(x => x._id === rowId)
|
|
if (!row) {
|
|
return
|
|
}
|
|
if (row[field] === value) {
|
|
return
|
|
}
|
|
changeCache[rowId] = { [field]: value }
|
|
await API.saveRow({
|
|
...row,
|
|
...changeCache[rowId],
|
|
})
|
|
await fetch.refresh()
|
|
delete changeCache[rowId]
|
|
}
|
|
|
|
const deleteRows = () => {
|
|
// Fetch full row objects to be deleted
|
|
const rowsToDelete = Object.entries(selectedRows)
|
|
.map(entry => {
|
|
if (entry[1] === true) {
|
|
return rows.find(x => x._id === entry[0])
|
|
} else {
|
|
return null
|
|
}
|
|
})
|
|
.filter(x => x != null)
|
|
|
|
// Deletion callback when confirmed
|
|
const performDeletion = async () => {
|
|
await API.deleteRows({
|
|
tableId: table.tableId,
|
|
rows: rowsToDelete,
|
|
})
|
|
await fetch.refresh()
|
|
// notificationStore.actions.success(
|
|
// `${selectedRowCount} row${
|
|
// selectedRowCount === 1 ? "" : "s"
|
|
// } deleted successfully`
|
|
// )
|
|
|
|
// Refresh state
|
|
selectedCell = null
|
|
hoveredRow = null
|
|
selectedRows = {}
|
|
}
|
|
|
|
// Show confirmation
|
|
confirmationStore.actions.showConfirmation(
|
|
"Delete rows",
|
|
`Are you sure you want to delete ${selectedRowCount} row${
|
|
selectedRowCount === 1 ? "" : "s"
|
|
}?`,
|
|
performDeletion
|
|
)
|
|
}
|
|
|
|
const addRow = async field => {
|
|
const res = await API.saveRow({ tableId: table.tableId })
|
|
selectedCell = `${res._id}-${field}`
|
|
newRows.push(res._id)
|
|
await fetch.refresh()
|
|
}
|
|
|
|
const getSortedRows = (rows, newRows) => {
|
|
let sortedRows = rows.slice()
|
|
sortedRows.sort((a, b) => {
|
|
const aIndex = newRows.indexOf(a._id)
|
|
const bIndex = newRows.indexOf(b._id)
|
|
return aIndex < bIndex ? -1 : 1
|
|
})
|
|
return sortedRows
|
|
}
|
|
|
|
let resizeInitialX
|
|
let resizeInitialWidth
|
|
let resizeFieldIndex
|
|
|
|
const startResizing = (fieldIdx, e) => {
|
|
resizeInitialX = e.clientX
|
|
resizeInitialWidth = widths[fieldIdx]
|
|
resizeFieldIndex = fieldIdx
|
|
document.addEventListener("mousemove", onResize)
|
|
document.addEventListener("mouseup", stopResizing)
|
|
}
|
|
|
|
const onResize = e => {
|
|
const dx = e.clientX - resizeInitialX
|
|
widths[resizeFieldIndex] = Math.max(minWidth, resizeInitialWidth + dx)
|
|
}
|
|
|
|
const stopResizing = () => {
|
|
document.removeEventListener("mousemove", onResize)
|
|
document.removeEventListener("mouseup", stopResizing)
|
|
}
|
|
</script>
|
|
|
|
<div use:styleable={$component.styles}>
|
|
<div class="wrapper">
|
|
<div class="controls">
|
|
<div class="buttons">
|
|
<ActionButton icon="Filter" size="S">Filter</ActionButton>
|
|
<ActionButton icon="Group" size="S">Group</ActionButton>
|
|
<ActionButton icon="SortOrderDown" size="S">Sort</ActionButton>
|
|
<ActionButton icon="VisibilityOff" size="S">Hide fields</ActionButton>
|
|
</div>
|
|
<div class="title">Sales Records</div>
|
|
<div class="delete">
|
|
{#if selectedRowCount}
|
|
<ActionButton icon="Delete" size="S" on:click={deleteRows}>
|
|
Delete {selectedRowCount} row{selectedRowCount === 1 ? "" : "s"}
|
|
</ActionButton>
|
|
{:else}
|
|
{rowCount} row{rowCount === 1 ? "" : "s"}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="spreadsheet"
|
|
on:scroll={handleScroll}
|
|
style={gridStyles}
|
|
on:click|self={() => (selectedCell = null)}
|
|
>
|
|
<!-- Field headers -->
|
|
<div class="header cell label" on:click={selectAll}>
|
|
<input
|
|
type="checkbox"
|
|
checked={rowCount && selectedRowCount === rowCount}
|
|
/>
|
|
</div>
|
|
{#each fields as field, fieldIdx}
|
|
<div
|
|
class="header cell"
|
|
class:sticky={fieldIdx === 0}
|
|
class:shadow={horizontallyScrolled}
|
|
>
|
|
<Icon
|
|
size="S"
|
|
name={getIconForField(field)}
|
|
color="var(--spectrum-global-color-gray-600)"
|
|
/>
|
|
<span>
|
|
{field}
|
|
</span>
|
|
<div class="slider" on:mousedown={e => startResizing(fieldIdx, e)} />
|
|
</div>
|
|
{/each}
|
|
<!-- Horizontal spacer -->
|
|
<div />
|
|
|
|
<!-- All real rows -->
|
|
{#each rows as row, rowIdx (row._id)}
|
|
{@const rowSelected = !!selectedRows[row._id]}
|
|
{@const rowHovered = hoveredRow === row._id}
|
|
{@const data = { ...row, ...changeCache[row._id] }}
|
|
<div
|
|
class="cell label"
|
|
class:row-selected={rowSelected}
|
|
class:hovered={rowHovered}
|
|
on:focus
|
|
on:mouseover={() => (hoveredRow = row._id)}
|
|
on:click={() => selectRow(row._id)}
|
|
>
|
|
{#if rowSelected || rowHovered}
|
|
<input type="checkbox" checked={rowSelected} />
|
|
{:else}
|
|
<span>
|
|
{rowIdx + 1}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{#each fields as field, fieldIdx}
|
|
{@const cellIdx = `${row._id}-${field}`}
|
|
<div
|
|
class="cell"
|
|
class:row-selected={rowSelected}
|
|
class:sticky={fieldIdx === 0}
|
|
class:hovered={rowHovered}
|
|
class:selected={selectedCell === cellIdx}
|
|
class:shadow={horizontallyScrolled}
|
|
on:focus
|
|
on:mouseover={() => (hoveredRow = row._id)}
|
|
on:click={() => (selectedCell = cellIdx)}
|
|
>
|
|
<svelte:component
|
|
this={getCellForField(field)}
|
|
value={data[field]}
|
|
schema={schema[field]}
|
|
selected={selectedCell === cellIdx}
|
|
onChange={val => handleChange(row._id, field, val)}
|
|
/>
|
|
</div>
|
|
{/each}
|
|
<!-- Horizontal spacer -->
|
|
<div />
|
|
{/each}
|
|
|
|
<!-- New row placeholder -->
|
|
<div
|
|
class="cell label new"
|
|
on:click={addRow}
|
|
on:focus
|
|
on:mouseover={() => (hoveredRow = "new")}
|
|
class:hovered={hoveredRow === "new"}
|
|
>
|
|
<Icon hoverable name="Add" size="S" />
|
|
</div>
|
|
{#each fields as field, fieldIdx}
|
|
<div
|
|
class="cell new"
|
|
class:sticky={fieldIdx === 0}
|
|
class:shadow={horizontallyScrolled}
|
|
class:hovered={hoveredRow === "new"}
|
|
on:click={() => addRow(field)}
|
|
on:focus
|
|
on:mouseover={() => (hoveredRow = "new")}
|
|
/>
|
|
{/each}
|
|
<!-- Horizontal spacer -->
|
|
<div />
|
|
|
|
<!-- Vertical spacer -->
|
|
<div class="vertical-spacer" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: flex-start;
|
|
align-items: stretch;
|
|
border: 1px solid var(--spectrum-global-color-gray-400);
|
|
border-radius: 4px;
|
|
}
|
|
.spreadsheet {
|
|
display: grid;
|
|
grid-template-columns: var(--grid);
|
|
justify-content: flex-start;
|
|
align-items: stretch;
|
|
overflow: auto;
|
|
max-height: 600px;
|
|
position: relative;
|
|
cursor: default;
|
|
}
|
|
.vertical-spacer {
|
|
grid-column: 1/-1;
|
|
height: 180px;
|
|
}
|
|
|
|
.wrapper ::-webkit-scrollbar-track {
|
|
background: var(--spectrum-global-color-gray-50);
|
|
}
|
|
|
|
.controls {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto 1fr;
|
|
align-items: center;
|
|
height: 36px;
|
|
padding: 0 12px;
|
|
background: var(--spectrum-global-color-gray-200);
|
|
gap: 8px;
|
|
border-bottom: 1px solid var(--spectrum-global-color-gray-400);
|
|
}
|
|
.title {
|
|
font-weight: 600;
|
|
}
|
|
.buttons {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
.delete {
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
color: var(--spectrum-global-color-gray-700);
|
|
}
|
|
.delete :global(.spectrum-ActionButton) {
|
|
color: var(--spectrum-global-color-red-600);
|
|
}
|
|
.delete :global(.spectrum-Icon) {
|
|
fill: var(--spectrum-global-color-red-600);
|
|
}
|
|
|
|
.cell {
|
|
height: 32px;
|
|
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
|
|
border-right: 1px solid var(--spectrum-global-color-gray-300);
|
|
display: flex;
|
|
flex-direction: row;
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
color: var(--spectrum-global-color-gray-900);
|
|
font-size: 14px;
|
|
gap: 4px;
|
|
background: var(--spectrum-global-color-gray-50);
|
|
position: relative;
|
|
}
|
|
.cell.hovered {
|
|
background: var(--spectrum-global-color-gray-100);
|
|
}
|
|
.cell.selected {
|
|
box-shadow: inset 0 0 0 2px var(--spectrum-global-color-blue-400);
|
|
z-index: 1;
|
|
}
|
|
.cell:not(.selected) {
|
|
user-select: none;
|
|
}
|
|
.cell:hover {
|
|
cursor: default;
|
|
}
|
|
.cell.sticky {
|
|
position: sticky;
|
|
left: 40px;
|
|
z-index: 2;
|
|
}
|
|
.cell.sticky.selected {
|
|
z-index: 3;
|
|
}
|
|
.cell.row-selected {
|
|
background-color: rgb(224, 242, 255);
|
|
}
|
|
.cell.new:hover {
|
|
cursor: pointer;
|
|
}
|
|
|
|
.header {
|
|
background: var(--spectrum-global-color-gray-200);
|
|
position: sticky;
|
|
top: 0;
|
|
padding: 0 8px;
|
|
z-index: 3;
|
|
border-color: var(--spectrum-global-color-gray-400);
|
|
}
|
|
.header span {
|
|
flex: 1 1 auto;
|
|
width: 0;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
overflow: hidden;
|
|
}
|
|
.header.sticky {
|
|
z-index: 4;
|
|
}
|
|
|
|
/* Column resizing */
|
|
.slider {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
width: 16px;
|
|
height: 100%;
|
|
}
|
|
.slider:after {
|
|
opacity: 0;
|
|
content: " ";
|
|
position: absolute;
|
|
width: 4px;
|
|
right: 0;
|
|
top: 0;
|
|
height: 100%;
|
|
background: var(--spectrum-global-color-gray-600);
|
|
transition: opacity 130ms ease-out;
|
|
}
|
|
.slider:hover {
|
|
cursor: col-resize;
|
|
}
|
|
.slider:hover:after {
|
|
opacity: 1;
|
|
}
|
|
|
|
.sticky.shadow:after {
|
|
content: " ";
|
|
position: absolute;
|
|
width: 10px;
|
|
left: 100%;
|
|
height: 100%;
|
|
background: linear-gradient(to right, rgba(0, 0, 0, 0.08), transparent);
|
|
}
|
|
|
|
.label {
|
|
padding: 0 12px;
|
|
border-right: none;
|
|
position: sticky;
|
|
left: 0;
|
|
z-index: 2;
|
|
}
|
|
.label.header {
|
|
z-index: 4;
|
|
}
|
|
.label span {
|
|
min-width: 14px;
|
|
text-align: center;
|
|
color: var(--spectrum-global-color-gray-500);
|
|
}
|
|
|
|
input[type="checkbox"] {
|
|
margin: 0;
|
|
}
|
|
</style>
|