Grid columns UI (#12209)

* grid columns ui

* linting

* remove comment

* remove property thats not used

* remove cli pacakge change

* PR Feedback

* fixes

* fixes

* wip

* wip

* wip

* wip

* wip

* wip

* about to implement

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* tests

* linting

* remove drag handle file

* fix icons

* remove field config changes

* wip

* wip

* wip

* remove logs

* wip

* linting

* pr feedback

* linting
This commit is contained in:
Gerard Burns 2023-11-24 11:02:57 +00:00 committed by GitHub
parent 176f7fe404
commit 0d0db98484
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 865 additions and 32 deletions

View File

@ -20,7 +20,7 @@ import ValidationEditor from "./controls/ValidationEditor/ValidationEditor.svelt
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ColumnEditor from "./controls/ColumnEditor/ColumnEditor.svelte"
import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
import GridColumnEditor from "./controls/GridColumnConfiguration/GridColumnConfiguration.svelte"
import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
@ -28,6 +28,7 @@ import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte
const componentMap = {
text: DrawerBindableInput,
plainText: Input,
select: Select,
radio: RadioGroup,
dataSource: DataSourceSelect,

View File

@ -1,10 +0,0 @@
<script>
import ColumnEditor from "./ColumnEditor.svelte"
</script>
<ColumnEditor
{...$$props}
on:change
allowCellEditing={false}
allowReorder={false}
/>

View File

@ -0,0 +1,91 @@
<script>
import EditComponentPopover from "../EditComponentPopover.svelte"
import { FieldTypeToComponentMap } from "../FieldConfiguration/utils"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { store } from "builderStore"
export let item
export let anchor
const dispatch = createEventDispatcher()
const onToggle = item => {
return e => {
item.active = e.detail
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
const getIcon = () => {
const component = `@budibase/standard-components/${
FieldTypeToComponentMap[item.columnType]
}`
return store.actions.components.getDefinition(component).icon
}
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditComponentPopover
{anchor}
componentInstance={item}
{parseSettings}
on:change
>
<div slot="header" class="type-icon">
<Icon name={getIcon()} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{item.label || item.field}</div>
</div>
<div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View File

@ -0,0 +1,107 @@
<script>
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import DraggableList from "../DraggableList/DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import FieldSetting from "./FieldSetting.svelte"
import PrimaryColumnFieldSetting from "./PrimaryColumnFieldSetting.svelte"
import getColumns from "./getColumns.js"
export let value
export let componentInstance
const dispatch = createEventDispatcher()
let primaryDisplayColumnAnchor
const handleChange = newValues => {
dispatch("change", newValues)
}
const getSchema = (asset, datasource) => {
const schema = getSchemaForDatasource(asset, datasource).schema
// Don't show ID and rev in tables
if (schema) {
delete schema._id
delete schema._rev
}
return schema
}
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: primaryDisplayColumnName = getSchemaForDatasource(
$currentAsset,
datasource
)?.table?.primaryDisplay
$: schema = getSchema(currentAsset, datasource)
$: columns = getColumns({
columns: value,
schema,
primaryDisplayColumnName,
onChange: handleChange,
createComponent: store.actions.components.createInstance,
})
</script>
{#if columns.primary}
<div class="sticky-item">
<div bind:this={primaryDisplayColumnAnchor} class="sticky-item-inner">
<div class="right-content">
<PrimaryColumnFieldSetting
anchor={primaryDisplayColumnAnchor}
item={columns.primary}
on:change={e => columns.update(e.detail)}
/>
</div>
</div>
</div>
{/if}
<DraggableList
on:change={e => columns.updateSortable(e.detail)}
on:itemChange={e => columns.update(e.detail)}
items={columns.sortable}
listItemKey={"_id"}
listType={FieldSetting}
/>
<style>
.right-content {
flex: 1;
min-width: 0;
margin-left: 17.5px;
}
.sticky-item {
list-style-type: none;
margin: 0;
padding: 0;
width: 100%;
border-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
margin-bottom: 10px;
}
.sticky-item-inner {
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
align-items: center;
border-bottom: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
box-sizing: border-box;
border-radius: 4px;
border-bottom: 0;
}
</style>

View File

@ -0,0 +1,100 @@
<script>
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui"
import { setContext } from "svelte"
import { writable } from "svelte/store"
import { FieldTypeToComponentMap } from "../FieldConfiguration/utils"
import { store } from "builderStore"
export let item
export let anchor
let draggableStore = writable({
selected: null,
actions: {
select: id => {
draggableStore.update(state => ({
...state,
selected: id,
}))
},
},
})
setContext("draggable", draggableStore)
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
const getIcon = item => {
const component = `@budibase/standard-components/${
FieldTypeToComponentMap[item.columnType]
}`
return store.actions.components.getDefinition(component)?.icon
}
$: icon = getIcon(item)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditComponentPopover
{anchor}
componentInstance={item}
{parseSettings}
on:change
>
<div slot="header" class="type-icon">
<Icon name={icon} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{item.label || item.field}</div>
</div>
<div title="The display column is always shown first" class="list-item-right">
<Icon name={"Info"} />
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(svg) {
color: var(--grey-5);
padding: 7px 5px 7px 0;
}
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View File

@ -0,0 +1,129 @@
const modernize = columns => {
if (!columns) {
return []
}
// If the first element has no active key then it's safe to assume all elements are in the old format
if (columns?.[0] && columns[0].active === undefined) {
return columns.map(column => ({
label: column.displayName,
field: column.name,
active: true,
}))
}
return columns
}
const removeInvalidAddMissing = (
columns = [],
defaultColumns,
primaryDisplayColumnName
) => {
const defaultColumnNames = defaultColumns.map(column => column.field)
const columnNames = columns.map(column => column.field)
const validColumns = columns.filter(column =>
defaultColumnNames.includes(column.field)
)
let missingColumns = defaultColumns.filter(
defaultColumn => !columnNames.includes(defaultColumn.field)
)
// If the user already has fields selected, any appended missing fields should be disabled by default
if (validColumns.length) {
missingColumns = missingColumns.map(field => ({ ...field, active: false }))
}
const combinedColumns = [...validColumns, ...missingColumns]
// Ensure the primary display column is always visible
const primaryDisplayIndex = combinedColumns.findIndex(
column => column.field === primaryDisplayColumnName
)
if (primaryDisplayIndex > -1) {
combinedColumns[primaryDisplayIndex].active = true
}
return combinedColumns
}
const getDefault = (schema = {}) => {
const defaultValues = Object.values(schema)
.filter(column => !column.nestedJSON)
.map(column => ({
label: column.name,
field: column.name,
active: column.visible ?? true,
order: column.visible ? column.order ?? -1 : Number.MAX_SAFE_INTEGER,
}))
defaultValues.sort((a, b) => a.order - b.order)
return defaultValues
}
const toGridFormat = draggableListColumns => {
return draggableListColumns.map(entry => ({
label: entry.label,
field: entry.field,
active: entry.active,
}))
}
const toDraggableListFormat = (gridFormatColumns, createComponent, schema) => {
return gridFormatColumns.map(column => {
return createComponent(
"@budibase/standard-components/labelfield",
{
_instanceName: column.field,
active: column.active,
field: column.field,
label: column.label,
columnType: schema[column.field].type,
},
{}
)
})
}
const getColumns = ({
columns,
schema,
primaryDisplayColumnName,
onChange,
createComponent,
}) => {
const validatedColumns = removeInvalidAddMissing(
modernize(columns),
getDefault(schema),
primaryDisplayColumnName
)
const draggableList = toDraggableListFormat(
validatedColumns,
createComponent,
schema
)
const primary = draggableList.find(
entry => entry.field === primaryDisplayColumnName
)
const sortable = draggableList.filter(
entry => entry.field !== primaryDisplayColumnName
)
return {
primary,
sortable,
updateSortable: newDraggableList => {
onChange(toGridFormat(newDraggableList.concat(primary)))
},
update: newEntry => {
const newDraggableList = draggableList.map(entry => {
return newEntry.field === entry.field ? newEntry : entry
})
onChange(toGridFormat(newDraggableList))
},
}
}
export default getColumns

View File

@ -0,0 +1,374 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import getColumns from "./getColumns"
describe("getColumns", () => {
beforeEach(ctx => {
ctx.schema = {
one: { name: "one", visible: false, order: 0, type: "foo" },
two: { name: "two", visible: true, order: 1, type: "foo" },
three: { name: "three", visible: true, order: 2, type: "foo" },
four: { name: "four", visible: false, order: 3, type: "foo" },
five: {
name: "excluded",
visible: true,
order: 4,
type: "foo",
nestedJSON: true,
},
}
ctx.primaryDisplayColumnName = "four"
ctx.onChange = vi.fn()
ctx.createComponent = (componentName, props) => {
return { componentName, ...props }
}
})
describe("nested json fields", () => {
beforeEach(ctx => {
ctx.columns = getColumns({
columns: null,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("does not return nested json fields, as the grid cannot display them", ctx => {
expect(ctx.columns.sortable).not.toContainEqual({
name: "excluded",
visible: true,
order: 4,
type: "foo",
nestedJSON: true,
})
})
})
describe("using the old grid column format", () => {
beforeEach(ctx => {
const oldGridFormatColumns = [
{ displayName: "three label", name: "three" },
{ displayName: "two label", name: "two" },
]
ctx.columns = getColumns({
columns: oldGridFormatColumns,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns the selected and unselected fields in the modern format, respecting the original order", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three label",
},
{
_instanceName: "two",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two label",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
})
describe("default columns", () => {
beforeEach(ctx => {
ctx.columns = getColumns({
columns: undefined,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns all columns, with non-hidden columns automatically selected", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "two",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
it("Unselected columns should be placed at the end", ctx => {
expect(ctx.columns.sortable[2].field).toEqual("one")
})
})
describe("missing columns", () => {
beforeEach(ctx => {
const gridFormatColumns = [
{ label: "three label", field: "three", active: true },
]
ctx.columns = getColumns({
columns: gridFormatColumns,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns all columns, including those missing from the initial data", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three label",
},
{
_instanceName: "two",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
})
describe("invalid columns", () => {
beforeEach(ctx => {
const gridFormatColumns = [
{ label: "three label", field: "three", active: true },
{ label: "some nonsense", field: "some nonsense", active: true },
]
ctx.columns = getColumns({
columns: gridFormatColumns,
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
})
it("returns all valid columns, excluding those that aren't valid for the schema", ctx => {
expect(ctx.columns.sortable).toEqual([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three label",
},
{
_instanceName: "two",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
{
_instanceName: "one",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
])
expect(ctx.columns.primary).toEqual({
_instanceName: "four",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "four",
label: "four",
})
})
})
describe("methods", () => {
beforeEach(ctx => {
const { update, updateSortable } = getColumns({
columns: [],
schema: ctx.schema,
primaryDisplayColumnName: ctx.primaryDisplayColumnName,
onChange: ctx.onChange,
createComponent: ctx.createComponent,
})
ctx.update = update
ctx.updateSortable = updateSortable
})
describe("update", () => {
beforeEach(ctx => {
ctx.update({
field: "one",
label: "a new label",
active: true,
})
})
it("calls the callback with the updated columns", ctx => {
expect(ctx.onChange).toHaveBeenCalledTimes(1)
expect(ctx.onChange).toHaveBeenCalledWith([
{
field: "two",
label: "two",
active: true,
},
{
field: "three",
label: "three",
active: true,
},
{
field: "one",
label: "a new label",
active: true,
},
{
field: "four",
label: "four",
active: true,
},
])
})
})
describe("updateSortable", () => {
beforeEach(ctx => {
ctx.updateSortable([
{
_instanceName: "three",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "three",
label: "three",
},
{
_instanceName: "one",
active: true,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "one",
label: "one",
},
{
_instanceName: "two",
active: false,
columnType: "foo",
componentName: "@budibase/standard-components/labelfield",
field: "two",
label: "two",
},
])
})
it("calls the callback with the updated columns", ctx => {
expect(ctx.onChange).toHaveBeenCalledTimes(1)
expect(ctx.onChange).toHaveBeenCalledWith([
{
field: "three",
label: "three",
active: true,
},
{
field: "one",
label: "one",
active: true,
},
{
field: "two",
label: "two",
active: false,
},
{
field: "four",
label: "four",
active: true,
},
])
})
})
})
})

View File

@ -2698,6 +2698,22 @@
}
]
},
"labelfield": {
"name": "Text Field",
"icon": "Text",
"editable": true,
"size": {
"width": 400,
"height": 32
},
"settings": [
{
"type": "plainText",
"label": "Label",
"key": "label"
}
]
},
"stringfield": {
"name": "Text Field",
"icon": "Text",
@ -6308,19 +6324,6 @@
"key": "table",
"required": true
},
{
"type": "columns/grid",
"label": "Columns",
"key": "columns",
"dependsOn": [
"table",
{
"setting": "table.type",
"value": "custom",
"invert": true
}
]
},
{
"type": "filter",
"label": "Filtering",
@ -6417,6 +6420,18 @@
"key": "stripeRows",
"defaultValue": false
},
{
"section": true,
"name": "Columns",
"settings": [
{
"type": "columns/grid",
"key": "columns",
"nested": true,
"resetOn": "table"
}
]
},
{
"section": true,
"name": "Buttons",

View File

@ -19,6 +19,22 @@
export let onRowClick = null
export let buttons = null
// parses columns to fix older formats
const getParsedColumns = columns => {
// If the first element has an active key all elements should be in the new format
if (columns?.length && columns[0]?.active !== undefined) {
return columns
}
return columns?.map(column => ({
label: column.displayName || column.name,
field: column.name,
active: true,
}))
}
$: parsedColumns = getParsedColumns(columns)
const context = getContext("context")
const component = getContext("component")
const {
@ -33,16 +49,17 @@
let grid
$: columnWhitelist = columns?.map(col => col.name)
$: schemaOverrides = getSchemaOverrides(columns)
$: columnWhitelist = parsedColumns
?.filter(col => col.active)
?.map(col => col.field)
$: schemaOverrides = getSchemaOverrides(parsedColumns)
$: enrichedButtons = enrichButtons(buttons)
const getSchemaOverrides = columns => {
let overrides = {}
columns?.forEach(column => {
overrides[column.name] = {
displayName: column.displayName || column.name,
visible: true,
overrides[column.field] = {
displayName: column.label,
}
})
return overrides

View File

@ -55,11 +55,20 @@ export const deriveStores = context => {
// Apply whitelist if specified
if ($columnWhitelist?.length) {
Object.keys(enrichedSchema).forEach(key => {
if (!$columnWhitelist.includes(key)) {
delete enrichedSchema[key]
const sortedColumns = {}
$columnWhitelist.forEach((columnKey, idx) => {
const enrichedColumn = enrichedSchema[columnKey]
if (enrichedColumn) {
sortedColumns[columnKey] = {
...enrichedColumn,
order: idx,
visible: true,
}
}
})
return sortedColumns
}
return enrichedSchema