1
0
Fork 0
mirror of synced 2024-09-20 11:27:56 +12:00

Merge branch 'master' of github.com:Budibase/budibase into table-improvements-2

This commit is contained in:
Andrew Kingston 2024-06-27 16:18:00 +01:00
commit b58519d562
No known key found for this signature in database
14 changed files with 280 additions and 170 deletions

View file

@ -1,14 +1,5 @@
export const CONSTANT_INTERNAL_ROW_COLS = [ export {
"_id", CONSTANT_INTERNAL_ROW_COLS,
"_rev", CONSTANT_EXTERNAL_ROW_COLS,
"type", isInternalColumnName,
"createdAt", } from "@budibase/shared-core"
"updatedAt",
"tableId",
] as const
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const
export function isInternalColumnName(name: string): boolean {
return (CONSTANT_INTERNAL_ROW_COLS as readonly string[]).includes(name)
}

View file

@ -17,6 +17,8 @@
SWITCHABLE_TYPES, SWITCHABLE_TYPES,
ValidColumnNameRegex, ValidColumnNameRegex,
helpers, helpers,
CONSTANT_INTERNAL_ROW_COLS,
CONSTANT_EXTERNAL_ROW_COLS,
} from "@budibase/shared-core" } from "@budibase/shared-core"
import { createEventDispatcher, getContext, onMount } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -52,7 +54,6 @@
const DATE_TYPE = FieldType.DATETIME const DATE_TYPE = FieldType.DATETIME
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
const { dispatch: gridDispatch, rows } = getContext("grid") const { dispatch: gridDispatch, rows } = getContext("grid")
export let field export let field
@ -487,20 +488,27 @@
}) })
} }
const newError = {} const newError = {}
const prohibited = externalTable
? CONSTANT_EXTERNAL_ROW_COLS
: CONSTANT_INTERNAL_ROW_COLS
if (!externalTable && fieldInfo.name?.startsWith("_")) { if (!externalTable && fieldInfo.name?.startsWith("_")) {
newError.name = `Column name cannot start with an underscore.` newError.name = `Column name cannot start with an underscore.`
} else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) { } else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) {
newError.name = `Illegal character; must be alpha-numeric.` newError.name = `Illegal character; must be alpha-numeric.`
} else if (PROHIBITED_COLUMN_NAMES.some(name => fieldInfo.name === name)) { } else if (
newError.name = `${PROHIBITED_COLUMN_NAMES.join( prohibited.some(
name => fieldInfo?.name?.toLowerCase() === name.toLowerCase()
)
) {
newError.name = `${prohibited.join(
", " ", "
)} are not allowed as column names` )} are not allowed as column names - case insensitive.`
} else if (inUse($tables.selected, fieldInfo.name, originalName)) { } else if (inUse($tables.selected, fieldInfo.name, originalName)) {
newError.name = `Column name already in use.` newError.name = `Column name already in use.`
} }
if (fieldInfo.type === FieldType.AUTO && !fieldInfo.subtype) { if (fieldInfo.type === FieldType.AUTO && !fieldInfo.subtype) {
newError.subtype = `Auto Column requires a type` newError.subtype = `Auto Column requires a type.`
} }
if (fieldInfo.fieldName && fieldInfo.tableId) { if (fieldInfo.fieldName && fieldInfo.tableId) {

View file

@ -3,6 +3,7 @@
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
import GridCell from "../cells/GridCell.svelte" import GridCell from "../cells/GridCell.svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte" import GridScrollWrapper from "./GridScrollWrapper.svelte"
import { BlankRowID } from "../lib/constants"
const { const {
renderedRows, renderedRows,
@ -17,6 +18,7 @@
isDragging, isDragging,
buttonColumnWidth, buttonColumnWidth,
showVScrollbar, showVScrollbar,
dispatch,
} = getContext("grid") } = getContext("grid")
let container let container
@ -91,6 +93,17 @@
</GridCell> </GridCell>
</div> </div>
{/each} {/each}
<div
class="row blank"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
>
<GridCell
width={$buttonColumnWidth}
highlighted={$hoveredRowId === BlankRowID}
on:click={() => dispatch("add-row-inline")}
/>
</div>
</GridScrollWrapper> </GridScrollWrapper>
</div> </div>
</div> </div>
@ -131,8 +144,11 @@
align-items: center; align-items: center;
gap: 4px; gap: 4px;
} }
.blank :global(.cell:hover) {
cursor: pointer;
}
/* Add left cell border */ /* Add left cell border to all cells */
.button-column :global(.cell) { .button-column :global(.cell) {
border-left: var(--cell-border); border-left: var(--cell-border);
} }

View file

@ -28,7 +28,7 @@
MaxCellRenderOverflow, MaxCellRenderOverflow,
GutterWidth, GutterWidth,
DefaultRowHeight, DefaultRowHeight,
Padding, VPadding,
SmallRowHeight, SmallRowHeight,
ControlsHeight, ControlsHeight,
ScrollBarSize, ScrollBarSize,
@ -119,7 +119,7 @@
// Derive min height and make available in context // Derive min height and make available in context
const minHeight = derived(rowHeight, $height => { const minHeight = derived(rowHeight, $height => {
const heightForControls = showControls ? ControlsHeight : 0 const heightForControls = showControls ? ControlsHeight : 0
return Padding + SmallRowHeight + $height + heightForControls return VPadding + SmallRowHeight + $height + heightForControls
}) })
context = { ...context, minHeight } context = { ...context, minHeight }
@ -356,8 +356,13 @@
transition: none; transition: none;
} }
/* Overrides */ /* Overrides for quiet */
.grid.quiet :global(.grid-data-content .row > .cell:not(:last-child)) { .grid.quiet :global(.grid-data-content .row > .cell:not(:last-child)),
.grid.quiet :global(.sticky-column .row > .cell),
.grid.quiet :global(.new-row .row > .cell:not(:last-child)) {
border-right: none; border-right: none;
} }
.grid.quiet :global(.sticky-column:before) {
display: none;
}
</style> </style>

View file

@ -2,6 +2,7 @@
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte" import GridScrollWrapper from "./GridScrollWrapper.svelte"
import GridRow from "./GridRow.svelte" import GridRow from "./GridRow.svelte"
import GridCell from "../cells/GridCell.svelte"
import { BlankRowID } from "../lib/constants" import { BlankRowID } from "../lib/constants"
import ButtonColumn from "./ButtonColumn.svelte" import ButtonColumn from "./ButtonColumn.svelte"
@ -46,7 +47,6 @@
</script> </script>
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div bind:this={body} class="grid-body"> <div bind:this={body} class="grid-body">
<GridScrollWrapper scrollHorizontally scrollVertically attachHandlers> <GridScrollWrapper scrollHorizontally scrollVertically attachHandlers>
{#each $renderedRows as row, idx} {#each $renderedRows as row, idx}
@ -54,13 +54,16 @@
{/each} {/each}
{#if $config.canAddRows} {#if $config.canAddRows}
<div <div
class="blank" class="row blank"
class:highlighted={$hoveredRowId === BlankRowID}
style="width:{columnsWidth}px"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")} >
/> <GridCell
width={columnsWidth}
highlighted={$hoveredRowId === BlankRowID}
on:click={() => dispatch("add-row-inline")}
/>
</div>
{/if} {/if}
</GridScrollWrapper> </GridScrollWrapper>
{#if $props.buttons?.length} {#if $props.buttons?.length}
@ -76,15 +79,13 @@
overflow: hidden; overflow: hidden;
flex: 1 1 auto; flex: 1 1 auto;
} }
.blank { .row {
height: var(--row-height); display: flex;
background: var(--cell-background); flex-direction: row;
border-bottom: var(--cell-border); justify-content: flex-start;
border-right: var(--cell-border); align-items: stretch;
position: absolute;
} }
.blank.highlighted { .blank :global(.cell:hover) {
background: var(--cell-background-hover);
cursor: pointer; cursor: pointer;
} }
</style> </style>

View file

@ -32,6 +32,7 @@
inlineFilters, inlineFilters,
columnRenderMap, columnRenderMap,
visibleColumns, visibleColumns,
scrollTop,
} = getContext("grid") } = getContext("grid")
let visible = false let visible = false
@ -44,6 +45,21 @@
$: $datasource, (visible = false) $: $datasource, (visible = false)
$: selectedRowCount = Object.values($selectedRows).length $: selectedRowCount = Object.values($selectedRows).length
$: hasNoRows = !$rows.length $: hasNoRows = !$rows.length
$: renderedRowCount = $renderedRows.length
$: offset = getOffset($hasNextPage, renderedRowCount, $rowHeight, $scrollTop)
const getOffset = (hasNextPage, rowCount, rowHeight, scrollTop) => {
// If we have a next page of data then we aren't truly at the bottom, so we
// render the add row component at the top
if (hasNextPage) {
return 0
}
offset = rowCount * rowHeight - (scrollTop % rowHeight)
if (rowCount !== 0) {
offset -= 1
}
return offset
}
const addRow = async () => { const addRow = async () => {
// Blur the active cell and tick to let final value updates propagate // Blur the active cell and tick to let final value updates propagate
@ -89,12 +105,6 @@
return return
} }
// If we have a next page of data then we aren't truly at the bottom, so we
// render the add row component at the top
if ($hasNextPage) {
offset = 0
}
// If we don't have a next page then we're at the bottom and can scroll to // If we don't have a next page then we're at the bottom and can scroll to
// the max available offset // the max available offset
else { else {
@ -102,10 +112,6 @@
...state, ...state,
top: $maxScrollTop, top: $maxScrollTop,
})) }))
offset = $renderedRows.length * $rowHeight - ($maxScrollTop % $rowHeight)
if ($renderedRows.length !== 0) {
offset -= 1
}
} }
// Update state and select initial cell // Update state and select initial cell
@ -175,39 +181,41 @@
<!-- Only show new row functionality if we have any columns --> <!-- Only show new row functionality if we have any columns -->
{#if visible} {#if visible}
<div <div
class="container" class="new-row"
class:floating={offset > 0} class:floating={offset > 0}
style="--offset:{offset}px; --sticky-width:{width}px;" style="--offset:{offset}px; --sticky-width:{width}px;"
> >
<div class="underlay sticky" transition:fade|local={{ duration: 130 }} /> <div class="underlay sticky" transition:fade|local={{ duration: 130 }} />
<div class="underlay" transition:fade|local={{ duration: 130 }} /> <div class="underlay" transition:fade|local={{ duration: 130 }} />
<div class="sticky-column" transition:fade|local={{ duration: 130 }}> <div class="sticky-column" transition:fade|local={{ duration: 130 }}>
<GutterCell expandable on:expand={addViaModal} rowHovered> <div class="row">
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" /> <GutterCell expandable on:expand={addViaModal} rowHovered>
{#if isAdding} <Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
<div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if}
</GutterCell>
{#if $displayColumn}
{@const cellId = getCellID(NewRowID, $displayColumn.name)}
<DataCell
{cellId}
rowFocused
column={$displayColumn}
row={newRow}
focused={$focusedCellId === cellId}
width={$displayColumn.width}
{updateValue}
topRow={offset === 0}
>
{#if $displayColumn?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div>
{/if}
{#if isAdding} {#if isAdding}
<div in:fade={{ duration: 130 }} class="loading-overlay" /> <div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if} {/if}
</DataCell> </GutterCell>
{/if} {#if $displayColumn}
{@const cellId = getCellID(NewRowID, $displayColumn.name)}
<DataCell
{cellId}
rowFocused
column={$displayColumn}
row={newRow}
focused={$focusedCellId === cellId}
width={$displayColumn.width}
{updateValue}
topRow={offset === 0}
>
{#if $displayColumn?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div>
{/if}
{#if isAdding}
<div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if}
</DataCell>
{/if}
</div>
</div> </div>
<div class="normal-columns" transition:fade|local={{ duration: 130 }}> <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally attachHandlers> <GridScrollWrapper scrollHorizontally attachHandlers>
@ -274,7 +282,7 @@
margin-left: -6px; margin-left: -6px;
} }
.container { .new-row {
position: absolute; position: absolute;
top: var(--default-row-height); top: var(--default-row-height);
left: 0; left: 0;
@ -284,10 +292,10 @@
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
} }
.container :global(.cell) { .new-row :global(.cell) {
--cell-background: var(--spectrum-global-color-gray-75) !important; --cell-background: var(--spectrum-global-color-gray-75) !important;
} }
.container.floating :global(.cell) { .new-row.floating :global(.cell) {
height: calc(var(--row-height) + 1px); height: calc(var(--row-height) + 1px);
border-top: var(--cell-border); border-top: var(--cell-border);
} }
@ -316,8 +324,10 @@
pointer-events: all; pointer-events: all;
z-index: 3; z-index: 3;
position: absolute; position: absolute;
top: calc(var(--row-height) + var(--offset) + 24px); top: calc(
left: 18px; var(--row-height) + var(--offset) + var(--default-row-height) / 2
);
left: calc(var(--default-row-height) / 2);
} }
.button-with-keys { .button-with-keys {
display: flex; display: flex;

View file

@ -69,66 +69,62 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="content"> <GridScrollWrapper scrollVertically attachHandlers>
<GridScrollWrapper scrollVertically attachHandlers> {#each $renderedRows as row, idx}
{#each $renderedRows as row, idx} {@const rowSelected = !!$selectedRows[row._id]}
{@const rowSelected = !!$selectedRows[row._id]} {@const rowHovered =
{@const rowHovered = $hoveredRowId === row._id &&
$hoveredRowId === row._id && (!$selectedCellCount || !$isSelectingCells)}
(!$selectedCellCount || !$isSelectingCells)} {@const rowFocused = $focusedRow?._id === row._id}
{@const rowFocused = $focusedRow?._id === row._id} {@const cellId = getCellID(row._id, $displayColumn?.name)}
{@const cellId = getCellID(row._id, $displayColumn?.name)} <div
<div class="row"
class="row" on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)}
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = row._id)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))} >
> <GutterCell {row} {rowFocused} {rowHovered} {rowSelected} />
<GutterCell {row} {rowFocused} {rowHovered} {rowSelected} /> {#if $displayColumn}
{#if $displayColumn} <DataCell
<DataCell {row}
{row} {cellId}
{cellId} {rowFocused}
{rowFocused} {rowSelected}
{rowSelected} cellSelected={$selectedCellMap[cellId]}
cellSelected={$selectedCellMap[cellId]} highlighted={rowHovered || rowFocused}
highlighted={rowHovered || rowFocused} rowIdx={row.__idx}
rowIdx={row.__idx} topRow={idx === 0}
topRow={idx === 0} focused={$focusedCellId === cellId}
focused={$focusedCellId === cellId} selectedUser={$userCellMap[cellId]}
selectedUser={$userCellMap[cellId]} width={$displayColumn.width}
width={$displayColumn.width} column={$displayColumn}
column={$displayColumn} contentLines={$contentLines}
contentLines={$contentLines} isSelectingCells={$isSelectingCells}
isSelectingCells={$isSelectingCells} />
/> {/if}
{/if} </div>
</div> {/each}
{/each} {#if $config.canAddRows}
{#if $config.canAddRows} <div
<div class="row blank"
class="row new" on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
on:mouseenter={$isDragging on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
? null on:click={() => dispatch("add-row-inline")}
: () => ($hoveredRowId = BlankRowID)} >
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} <GutterCell rowHovered={$hoveredRowId === BlankRowID}>
on:click={() => dispatch("add-row-inline")} <Icon name="Add" color="var(--spectrum-global-color-gray-500)" />
> </GutterCell>
<GutterCell rowHovered={$hoveredRowId === BlankRowID}> {#if $displayColumn}
<Icon name="Add" color="var(--spectrum-global-color-gray-500)" /> <GridCell
</GutterCell> width={$displayColumn.width}
{#if $displayColumn} highlighted={$hoveredRowId === BlankRowID}
<GridCell >
width={$displayColumn.width} <KeyboardShortcut padded keybind="Ctrl+Enter" />
highlighted={$hoveredRowId === BlankRowID} </GridCell>
> {/if}
<KeyboardShortcut padded keybind="Ctrl+Enter" /> </div>
</GridCell> {/if}
{/if} </GridScrollWrapper>
</div>
{/if}
</GridScrollWrapper>
</div>
</div> </div>
<style> <style>
@ -181,11 +177,7 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
} }
.content { .blank :global(.cell:hover) {
position: relative;
flex: 1 1 auto;
}
.row.new :global(*:hover) {
cursor: pointer; cursor: pointer;
} }
</style> </style>

View file

@ -1,12 +1,13 @@
export const Padding = 100
export const ScrollBarSize = 8
export const GutterWidth = 72
export const DefaultColumnWidth = 200
export const MinColumnWidth = 80
export const SmallRowHeight = 36 export const SmallRowHeight = 36
export const MediumRowHeight = 64 export const MediumRowHeight = 64
export const LargeRowHeight = 92 export const LargeRowHeight = 92
export const DefaultRowHeight = SmallRowHeight export const DefaultRowHeight = SmallRowHeight
export const VPadding = SmallRowHeight * 2
export const HPadding = 40
export const ScrollBarSize = 8
export const GutterWidth = 72
export const DefaultColumnWidth = 200
export const MinColumnWidth = 80
export const NewRowID = "new" export const NewRowID = "new"
export const BlankRowID = "blank" export const BlankRowID = "blank"
export const RowPageSize = 100 export const RowPageSize = 100

View file

@ -1,6 +1,12 @@
import { writable, derived, get } from "svelte/store" import { writable, derived, get } from "svelte/store"
import { tick } from "svelte" import { tick } from "svelte"
import { Padding, GutterWidth, FocusedCellMinOffset } from "../lib/constants" import {
GutterWidth,
FocusedCellMinOffset,
ScrollBarSize,
HPadding,
VPadding,
} from "../lib/constants"
import { parseCellID } from "../lib/utils" import { parseCellID } from "../lib/utils"
export const createStores = () => { export const createStores = () => {
@ -36,26 +42,15 @@ export const deriveStores = context => {
return ($displayColumn?.width || 0) + GutterWidth return ($displayColumn?.width || 0) + GutterWidth
}) })
// Derive vertical limits
const contentHeight = derived(
[rows, rowHeight],
([$rows, $rowHeight]) => ($rows.length + 1) * $rowHeight + Padding
)
const maxScrollTop = derived(
[height, contentHeight],
([$height, $contentHeight]) => Math.max($contentHeight - $height, 0)
)
// Derive horizontal limits // Derive horizontal limits
const contentWidth = derived( const contentWidth = derived(
[visibleColumns, buttonColumnWidth], [visibleColumns, buttonColumnWidth],
([$visibleColumns, $buttonColumnWidth]) => { ([$visibleColumns, $buttonColumnWidth]) => {
const space = Math.max(Padding, $buttonColumnWidth - 1) let width = GutterWidth + $buttonColumnWidth
let width = GutterWidth + space
$visibleColumns.forEach(col => { $visibleColumns.forEach(col => {
width += col.width width += col.width
}) })
return width return width + HPadding
} }
) )
const screenWidth = derived( const screenWidth = derived(
@ -70,14 +65,6 @@ export const deriveStores = context => {
return Math.max($contentWidth - $screenWidth, 0) return Math.max($contentWidth - $screenWidth, 0)
} }
) )
// Derive whether to show scrollbars or not
const showVScrollbar = derived(
[contentHeight, height],
([$contentHeight, $height]) => {
return $contentHeight > $height
}
)
const showHScrollbar = derived( const showHScrollbar = derived(
[contentWidth, screenWidth], [contentWidth, screenWidth],
([$contentWidth, $screenWidth]) => { ([$contentWidth, $screenWidth]) => {
@ -85,6 +72,28 @@ export const deriveStores = context => {
} }
) )
// Derive vertical limits
const contentHeight = derived(
[rows, rowHeight, showHScrollbar],
([$rows, $rowHeight, $showHScrollbar]) => {
let height = ($rows.length + 1) * $rowHeight + VPadding
if ($showHScrollbar) {
height += ScrollBarSize * 2
}
return height
}
)
const maxScrollTop = derived(
[height, contentHeight],
([$height, $contentHeight]) => Math.max($contentHeight - $height, 0)
)
const showVScrollbar = derived(
[contentHeight, height],
([$contentHeight, $height]) => {
return $contentHeight > $height
}
)
return { return {
stickyWidth, stickyWidth,
contentHeight, contentHeight,

View file

@ -276,6 +276,31 @@ describe.each([
}) })
}) })
isInternal &&
it("shouldn't allow duplicate column names", async () => {
const saveTableRequest: SaveTableRequest = {
...basicTable(),
}
saveTableRequest.schema["Type"] = {
type: FieldType.STRING,
name: "Type",
}
// allow the "Type" column - internal columns aren't case sensitive
await config.api.table.save(saveTableRequest, {
status: 200,
})
saveTableRequest.schema.foo = { type: FieldType.STRING, name: "foo" }
saveTableRequest.schema.FOO = { type: FieldType.STRING, name: "FOO" }
await config.api.table.save(saveTableRequest, {
status: 400,
body: {
message:
'Column(s) "foo" are duplicated - check for other columns with these name (case in-sensitive)',
},
})
})
it("should add a new column for an internal DB table", async () => { it("should add a new column for an internal DB table", async () => {
const saveTableRequest: SaveTableRequest = { const saveTableRequest: SaveTableRequest = {
...basicTable(), ...basicTable(),

View file

@ -17,6 +17,7 @@ import { cloneDeep } from "lodash/fp"
import isEqual from "lodash/isEqual" import isEqual from "lodash/isEqual"
import { runStaticFormulaChecks } from "../../../../api/controllers/table/bulkFormula" import { runStaticFormulaChecks } from "../../../../api/controllers/table/bulkFormula"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { findDuplicateInternalColumns } from "@budibase/shared-core"
import { getTable } from "../getters" import { getTable } from "../getters"
import { checkAutoColumns } from "./utils" import { checkAutoColumns } from "./utils"
import * as viewsSdk from "../../views" import * as viewsSdk from "../../views"
@ -44,6 +45,17 @@ export async function save(
if (hasTypeChanged(table, oldTable)) { if (hasTypeChanged(table, oldTable)) {
throw new Error("A column type has changed.") throw new Error("A column type has changed.")
} }
// check for case sensitivity - we don't want to allow duplicated columns
const duplicateColumn = findDuplicateInternalColumns(table)
if (duplicateColumn.length) {
throw new Error(
`Column(s) "${duplicateColumn.join(
", "
)}" are duplicated - check for other columns with these name (case in-sensitive)`
)
}
// check that subtypes have been maintained // check that subtypes have been maintained
table = checkAutoColumns(table, oldTable) table = checkAutoColumns(table, oldTable)

View file

@ -1,5 +1,6 @@
export * from "./api" export * from "./api"
export * from "./fields" export * from "./fields"
export * from "./rows"
export const OperatorOptions = { export const OperatorOptions = {
Equals: { Equals: {

View file

@ -0,0 +1,14 @@
export const CONSTANT_INTERNAL_ROW_COLS = [
"_id",
"_rev",
"type",
"createdAt",
"updatedAt",
"tableId",
] as const
export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const
export function isInternalColumnName(name: string): boolean {
return (CONSTANT_INTERNAL_ROW_COLS as readonly string[]).includes(name)
}

View file

@ -1,4 +1,5 @@
import { FieldType } from "@budibase/types" import { FieldType, Table } from "@budibase/types"
import { CONSTANT_INTERNAL_ROW_COLS } from "./constants"
const allowDisplayColumnByType: Record<FieldType, boolean> = { const allowDisplayColumnByType: Record<FieldType, boolean> = {
[FieldType.STRING]: true, [FieldType.STRING]: true,
@ -51,3 +52,27 @@ export function canBeDisplayColumn(type: FieldType): boolean {
export function canBeSortColumn(type: FieldType): boolean { export function canBeSortColumn(type: FieldType): boolean {
return !!allowSortColumnByType[type] return !!allowSortColumnByType[type]
} }
export function findDuplicateInternalColumns(table: Table): string[] {
// maintains the case of keys
const casedKeys = Object.keys(table.schema)
// get the column names
const uncasedKeys = casedKeys.map(colName => colName.toLowerCase())
// there are duplicates
const set = new Set(uncasedKeys)
let duplicates: string[] = []
if (set.size !== uncasedKeys.length) {
for (let key of set.keys()) {
const count = uncasedKeys.filter(name => name === key).length
if (count > 1) {
duplicates.push(key)
}
}
}
for (let internalColumn of CONSTANT_INTERNAL_ROW_COLS) {
if (casedKeys.find(key => key === internalColumn)) {
duplicates.push(internalColumn)
}
}
return duplicates
}