Merge pull request #4638 from Budibase/feature/table-row-selection

Allow selection of rows from table component
This commit is contained in:
Peter Clement 2022-03-01 12:03:24 +00:00 committed by GitHub
commit ffe35bc5ec
16 changed files with 257 additions and 80 deletions

View File

@ -47,7 +47,9 @@
<use xlink:href="#spectrum-css-icon-Dash100" />
</svg>
</span>
<span class="spectrum-Checkbox-label">{text || ""}</span>
{#if text}
<span class="spectrum-Checkbox-label">{text}</span>
{/if}
</label>
<style>

View File

@ -8,9 +8,21 @@
export let allowEditRows = false
</script>
{#if allowSelectRows}
<Checkbox value={selected} />
{/if}
{#if allowEditRows}
<ActionButton size="S" on:click={onEdit}>Edit</ActionButton>
{/if}
<div>
{#if allowSelectRows}
<Checkbox value={selected} />
{/if}
{#if allowEditRows}
<ActionButton size="S" on:click={onEdit}>Edit</ActionButton>
{/if}
</div>
<style>
div {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
}
</style>

View File

@ -5,6 +5,7 @@
import SelectEditRenderer from "./SelectEditRenderer.svelte"
import { cloneDeep, deepGet } from "../helpers"
import ProgressCircle from "../ProgressCircle/ProgressCircle.svelte"
import Checkbox from "../Form/Checkbox.svelte"
/**
* The expected schema is our normal couch schemas for our tables.
@ -31,7 +32,6 @@
export let allowEditRows = true
export let allowEditColumns = true
export let selectedRows = []
export let editColumnTitle = "Edit"
export let customRenderers = []
export let disableSorting = false
export let autoSortColumns = true
@ -50,6 +50,8 @@
// Table state
let height = 0
let loaded = false
let checkboxStatus = false
$: schema = fixSchema(schema)
$: if (!loading) loaded = true
$: fields = getFields(schema, showAutoColumns, autoSortColumns)
@ -67,6 +69,16 @@
$: showEditColumn = allowEditRows || allowSelectRows
$: cellStyles = computeCellStyles(schema)
// Deselect the "select all" checkbox when the user navigates to a new page
$: {
let checkRowCount = rows.filter(o1 =>
selectedRows.some(o2 => o1._id === o2._id)
)
if (checkRowCount.length === 0) {
checkboxStatus = false
}
}
const fixSchema = schema => {
let fixedSchema = {}
Object.entries(schema || {}).forEach(([fieldName, fieldSchema]) => {
@ -197,13 +209,32 @@
if (!allowSelectRows) {
return
}
if (selectedRows.includes(row)) {
selectedRows = selectedRows.filter(selectedRow => selectedRow !== row)
if (selectedRows.some(selectedRow => selectedRow._id === row._id)) {
selectedRows = selectedRows.filter(
selectedRow => selectedRow._id !== row._id
)
} else {
selectedRows = [...selectedRows, row]
}
}
const toggleSelectAll = e => {
const select = !!e.detail
if (select) {
// Add any rows which are not already in selected rows
rows.forEach(row => {
if (selectedRows.findIndex(x => x._id === row._id) === -1) {
selectedRows.push(row)
}
})
} else {
// Remove any rows from selected rows that are in the current data set
selectedRows = selectedRows.filter(el =>
rows.every(f => f._id !== el._id)
)
}
}
const computeCellStyles = schema => {
let styles = {}
Object.keys(schema || {}).forEach(field => {
@ -244,7 +275,14 @@
<div
class="spectrum-Table-headCell spectrum-Table-headCell--divider spectrum-Table-headCell--edit"
>
{editColumnTitle || ""}
{#if allowSelectRows}
<Checkbox
bind:value={checkboxStatus}
on:change={toggleSelectAll}
/>
{:else}
Edit
{/if}
</div>
{/if}
{#each fields as field}
@ -302,11 +340,16 @@
{#if showEditColumn}
<div
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
on:click={e => {
toggleSelectRow(row)
e.stopPropagation()
}}
>
<SelectEditRenderer
data={row}
selected={selectedRows.includes(row)}
onToggleSelection={() => toggleSelectRow(row)}
selected={selectedRows.findIndex(
selectedRow => selectedRow._id === row._id
) !== -1}
onEdit={e => editRow(e, row)}
{allowSelectRows}
{allowEditRows}

View File

@ -53,10 +53,10 @@
to-gfm-code-block "^0.1.1"
year "^0.2.1"
"@budibase/string-templates@^1.0.66-alpha.0":
version "1.0.72"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-1.0.72.tgz#acc154e402cce98ea30eedde9c6124183ee9b37c"
integrity sha512-w715TjgO6NUHkZNqoOEo8lAKJ/PQ4b00ATWSX5VB523SAu7y/uOiqKqV1E3fgwxq1o8L+Ff7rn9FTkiYtjkV/g==
"@budibase/string-templates@^1.0.72-alpha.0":
version "1.0.75"
resolved "https://registry.yarnpkg.com/@budibase/string-templates/-/string-templates-1.0.75.tgz#5b4061f1a626160ec092f32f036541376298100c"
integrity sha512-hPgr6n5cpSCGFEha5DS/P+rtRXOLc72M6y4J/scl59JvUi/ZUJkjRgJdpQPdBLu04CNKp89V59+rAqAuDjOC0g==
dependencies:
"@budibase/handlebars-helpers" "^0.11.7"
dayjs "^1.10.4"

View File

@ -27,10 +27,13 @@ filterTests(["smoke", "all"], () => {
it("updates a column on the table", () => {
cy.get(".title").click()
cy.get(".spectrum-Table-editIcon > use").click()
cy.get("input").eq(1).type("updated", { force: true })
cy.get(".modal-inner-wrapper").within(() => {
cy.get("input").eq(0).type("updated", { force: true })
// Unset table display column
cy.get(".spectrum-Switch-input").eq(1).click()
cy.contains("Save Column").click()
})
cy.contains("nameupdated ").should("contain", "nameupdated")
})

View File

@ -172,17 +172,19 @@ Cypress.Commands.add("addRow", values => {
Cypress.Commands.add("addRowMultiValue", values => {
cy.contains("Create row").click()
cy.get(".spectrum-Form-itemField")
.click()
.then(() => {
cy.get(".spectrum-Popover").within(() => {
for (let i = 0; i < values.length; i++) {
cy.get(".spectrum-Menu-item").eq(i).click()
}
cy.get(".spectrum-Modal").within(() => {
cy.get(".spectrum-Form-itemField")
.click()
.then(() => {
cy.get(".spectrum-Popover").within(() => {
for (let i = 0; i < values.length; i++) {
cy.get(".spectrum-Menu-item").eq(i).click()
}
})
cy.get(".spectrum-Dialog-grid").click("top")
cy.get(".spectrum-ButtonGroup").contains("Create").click()
})
cy.get(".spectrum-Dialog-grid").click("top")
cy.get(".spectrum-ButtonGroup").contains("Create").click()
})
})
})
Cypress.Commands.add("createUser", email => {

View File

@ -32,12 +32,14 @@ export const getBindableProperties = (asset, componentId) => {
const urlBindings = getUrlBindings(asset)
const deviceBindings = getDeviceBindings()
const stateBindings = getStateBindings()
const selectedRowsBindings = getSelectedRowsBindings(asset)
return [
...contextBindings,
...urlBindings,
...stateBindings,
...userBindings,
...deviceBindings,
...selectedRowsBindings,
]
}
@ -315,6 +317,40 @@ const getDeviceBindings = () => {
return bindings
}
/**
* Gets all selected rows bindings for tables in the current asset.
*/
const getSelectedRowsBindings = asset => {
let bindings = []
if (get(store).clientFeatures?.rowSelection) {
// Add bindings for table components
let tables = findAllMatchingComponents(asset?.props, component =>
component._component.endsWith("table")
)
const safeState = makePropSafe("rowSelection")
bindings = bindings.concat(
tables.map(table => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(table._id)}`,
readableBinding: `${table._instanceName}.Selected rows`,
}))
)
// Add bindings for table blocks
let tableBlocks = findAllMatchingComponents(asset?.props, component =>
component._component.endsWith("tableblock")
)
bindings = bindings.concat(
tableBlocks.map(block => ({
type: "context",
runtimeBinding: `${safeState}.${makePropSafe(block._id + "-table")}`,
readableBinding: `${block._instanceName}.Selected rows`,
}))
)
}
return bindings
}
/**
* Gets all state bindings that are globally available.
*/
@ -597,14 +633,9 @@ const buildFormSchema = component => {
* in the app.
*/
export const getAllStateVariables = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(store).layouts || [])
allAssets = allAssets.concat(get(store).screens || [])
// Find all button action settings in all components
let eventSettings = []
allAssets.forEach(asset => {
getAllAssets().forEach(asset => {
findAllMatchingComponents(asset.props, component => {
const settings = getComponentSettings(component._component)
settings
@ -635,6 +666,15 @@ export const getAllStateVariables = () => {
return Array.from(bindingSet)
}
export const getAllAssets = () => {
// Get all component containing assets
let allAssets = []
allAssets = allAssets.concat(get(store).layouts || [])
allAssets = allAssets.concat(get(store).screens || [])
return allAssets
}
/**
* Recurses the input object to remove any instances of bindings.
*/

View File

@ -41,6 +41,7 @@ const INITIAL_FRONTEND_STATE = {
intelligentLoading: false,
deviceAwareness: false,
state: false,
rowSelection: false,
customThemes: false,
devicePreview: false,
messagePassing: false,

View File

@ -6,7 +6,8 @@
"state": true,
"customThemes": true,
"devicePreview": true,
"messagePassing": true
"messagePassing": true,
"rowSelection": true
},
"layout": {
"name": "Layout",
@ -2714,6 +2715,13 @@
"key": "showAutoColumns",
"defaultValue": false
},
{
"type": "boolean",
"label": "Allow row selection",
"key": "allowSelectRows",
"defaultValue": false
},
{
"type": "boolean",
"label": "Link table rows",
@ -2973,6 +2981,11 @@
"label": "Show auto columns",
"key": "showAutoColumns"
},
{
"type": "boolean",
"label": "Allow row selection",
"key": "allowSelectRows"
},
{
"type": "boolean",
"label": "Link table rows",

View File

@ -21,6 +21,7 @@
import UserBindingsProvider from "components/context/UserBindingsProvider.svelte"
import DeviceBindingsProvider from "components/context/DeviceBindingsProvider.svelte"
import StateBindingsProvider from "components/context/StateBindingsProvider.svelte"
import RowSelectionProvider from "components/context/RowSelectionProvider.svelte"
import SettingsBar from "components/preview/SettingsBar.svelte"
import SelectionIndicator from "components/preview/SelectionIndicator.svelte"
import HoverIndicator from "components/preview/HoverIndicator.svelte"
@ -90,59 +91,61 @@
<UserBindingsProvider>
<DeviceBindingsProvider>
<StateBindingsProvider>
<!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder}
<SettingsBar />
{/if}
{/key}
<RowSelectionProvider>
<!-- Settings bar can be rendered outside of device preview -->
<!-- Key block needs to be outside the if statement or it breaks -->
{#key $builderStore.selectedComponentId}
{#if $builderStore.inBuilder}
<SettingsBar />
{/if}
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice === "tablet"}
class:mobile-preview={$builderStore.previewDevice === "mobile"}
>
<!-- Actual app -->
<div id="app-root">
<CustomThemeWrapper>
{#key `${$screenStore.activeLayout._id}-${$builderStore.previewType}`}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!-- Clip boundary for selection indicators -->
<div
id="clip-root"
class:preview={$builderStore.inBuilder}
class:tablet-preview={$builderStore.previewDevice === "tablet"}
class:mobile-preview={$builderStore.previewDevice === "mobile"}
>
<!-- Actual app -->
<div id="app-root">
<CustomThemeWrapper>
{#key `${$screenStore.activeLayout._id}-${$builderStore.previewType}`}
<Component
isLayout
instance={$screenStore.activeLayout.props}
/>
{/key}
<!--
<!--
Flatpickr needs to be inside the theme wrapper.
It also needs its own container because otherwise it hijacks
key events on the whole page. It is painful to work with.
-->
<div id="flatpickr-root" />
<div id="flatpickr-root" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
</div>
<!-- Layers on top of app -->
<NotificationDisplay />
<ConfirmationDisplay />
<PeekScreenDisplay />
</CustomThemeWrapper>
</div>
<!-- Selection indicators should be bounded by device -->
<!--
<!-- Selection indicators should be bounded by device -->
<!--
We don't want to key these by componentID as they control their own
re-mounting to avoid flashes.
-->
{#if $builderStore.inBuilder}
<SelectionIndicator />
<HoverIndicator />
<DNDHandler />
{/if}
</div>
{#if $builderStore.inBuilder}
<SelectionIndicator />
<HoverIndicator />
<DNDHandler />
{/if}
</div>
</RowSelectionProvider>
</StateBindingsProvider>
</DeviceBindingsProvider>
</UserBindingsProvider>

View File

@ -18,6 +18,7 @@
export let quiet
export let compact
export let size
export let allowSelectRows
export let linkRows
export let linkURL
export let linkColumn
@ -157,6 +158,7 @@
>
<BlockComponent
type="table"
context="table"
props={{
dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
columns: tableColumns,
@ -164,6 +166,7 @@
rowCount,
quiet,
compact,
allowSelectRows,
size,
linkRows,
linkURL,

View File

@ -3,6 +3,7 @@
import { Table } from "@budibase/bbui"
import SlotRenderer from "./SlotRenderer.svelte"
import { UnsortableTypes } from "../../../constants"
import { onDestroy } from "svelte"
export let dataProvider
export let columns
@ -14,10 +15,12 @@
export let linkURL
export let linkColumn
export let linkPeek
export let allowSelectRows
export let compact
const component = getContext("component")
const { styleable, getAction, ActionTypes, routeStore } = getContext("sdk")
const { styleable, getAction, ActionTypes, routeStore, rowSelectionStore } =
getContext("sdk")
const customColumnKey = `custom-${Math.random()}`
const customRenderers = [
{
@ -25,7 +28,7 @@
component: SlotRenderer,
},
]
let selectedRows = []
$: hasChildren = $component.children
$: loading = dataProvider?.loading ?? false
$: data = dataProvider?.rows || []
@ -36,6 +39,12 @@
dataProvider?.id,
ActionTypes.SetDataProviderSorting
)
$: {
rowSelectionStore.actions.updateSelection(
$component.id,
selectedRows.map(row => row._id)
)
}
const getFields = (schema, customColumns, showAutoColumns) => {
// Check for an invalid column selection
@ -117,6 +126,10 @@
const split = linkURL.split("/:")
routeStore.actions.navigate(`${split[0]}/${id}`, linkPeek)
}
onDestroy(() => {
rowSelectionStore.actions.updateSelection($component.id, [])
})
</script>
<div use:styleable={$component.styles} class={size}>
@ -128,7 +141,8 @@
{quiet}
{compact}
{customRenderers}
allowSelectRows={false}
allowSelectRows={!!allowSelectRows}
bind:selectedRows
allowEditRows={false}
allowEditColumns={false}
showAutoColumns={true}
@ -139,10 +153,19 @@
>
<slot />
</Table>
{#if allowSelectRows && selectedRows.length}
<div class="row-count">
{selectedRows.length} row{selectedRows.length === 1 ? "" : "s"} selected
</div>
{/if}
</div>
<style>
div {
background-color: var(--spectrum-alias-background-color-secondary);
}
.row-count {
margin-top: var(--spacing-l);
}
</style>

View File

@ -0,0 +1,8 @@
<script>
import Provider from "./Provider.svelte"
import { rowSelectionStore } from "stores"
</script>
<Provider key="rowSelection" data={$rowSelectionStore}>
<slot />
</Provider>

View File

@ -6,6 +6,7 @@ import {
screenStore,
builderStore,
uploadStore,
rowSelectionStore,
} from "stores"
import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable"
@ -19,6 +20,7 @@ export default {
authStore,
notificationStore,
routeStore,
rowSelectionStore,
screenStore,
builderStore,
uploadStore,

View File

@ -10,7 +10,7 @@ export { peekStore } from "./peek"
export { stateStore } from "./state"
export { themeStore } from "./theme"
export { uploadStore } from "./uploads.js"
export { rowSelectionStore } from "./rowSelection.js"
// Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context"

View File

@ -0,0 +1,22 @@
import { writable } from "svelte/store"
const createRowSelectionStore = () => {
const store = writable({})
function updateSelection(componentId, selectedRows) {
store.update(state => {
state[componentId] = [...selectedRows]
return state
})
}
return {
subscribe: store.subscribe,
set: store.set,
actions: {
updateSelection,
},
}
}
export const rowSelectionStore = createRowSelectionStore()