Merge remote-tracking branch 'origin/v3-ui' into feature/automation-branching-ux

This commit is contained in:
Dean 2024-10-24 12:15:56 +01:00
commit 6a6ae2c4e7
33 changed files with 655 additions and 175 deletions

3
.gitignore vendored
View File

@ -4,11 +4,10 @@ packages/server/runtime_apps/
.idea/
bb-airgapped.tar.gz
*.iml
packages/server/build/oldClientVersions/**/*
packages/builder/src/components/deploy/clientVersions.json
packages/server/src/integrations/tests/utils/*.lock
packages/builder/vite.config.mjs.timestamp*
# Logs
logs

View File

@ -16,14 +16,17 @@
href={url}
class="list-item"
class:hoverable={hoverable || url != null}
class:large={!!subtitle}
on:click
class:selected
>
<div class="left">
<div class="list-item__left">
{#if icon === "StatusLight"}
<StatusLight square size="L" color={iconColor} />
{:else if icon}
<Icon name={icon} color={iconColor} />
<div class="list-item__icon">
<Icon name={icon} color={iconColor} size={subtitle ? "XL" : "M"} />
</div>
{/if}
<div class="list-item__text">
{#if title}
@ -38,7 +41,7 @@
{/if}
</div>
</div>
<div class="right">
<div class="list-item__right">
<slot name="right" />
{#if showArrow}
<Icon name="ChevronRight" />
@ -54,9 +57,12 @@
flex-direction: row;
justify-content: space-between;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background 130ms ease-out;
transition: background 130ms ease-out, border-color 130ms ease-out;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-800);
cursor: pointer;
position: relative;
box-sizing: border-box;
}
.list-item:not(:first-child) {
border-top: none;
@ -74,27 +80,72 @@
}
.hoverable:not(.selected):hover {
background: var(--spectrum-global-color-gray-200);
border-color: var(--spectrum-global-color-gray-400);
}
.selected {
background: var(--spectrum-global-color-blue-100);
}
.left,
.right {
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
.list-item.selected {
background-color: var(--spectrum-global-color-blue-100);
border-color: var(--spectrum-global-color-blue-100);
}
.list-item.selected:after {
content: "";
position: absolute;
height: 100%;
width: 100%;
border: 1px solid var(--spectrum-global-color-blue-400);
pointer-events: none;
top: 0;
left: 0;
border-radius: 4px;
box-sizing: border-box;
z-index: 1;
opacity: 0.5;
}
/* Large icons */
.list-item.large .list-item__icon {
background-color: var(--spectrum-global-color-gray-200);
padding: 4px;
border-radius: 4px;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background-color 130ms ease-out, border-color 130ms ease-out,
color 130ms ease-out;
}
.list-item.large.hoverable:not(.selected):hover .list-item__icon {
background-color: var(--spectrum-global-color-gray-300);
}
.list-item.large.selected .list-item__icon {
background-color: var(--spectrum-global-color-blue-400);
color: white;
border-color: var(--spectrum-global-color-blue-100);
}
/* Internal layout */
.list-item__left,
.list-item__right {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-m);
}
.left {
.list-item.large .list-item__left,
.list-item.large .list-item__right {
gap: var(--spacing-m);
}
.list-item__left {
width: 0;
flex: 1 1 auto;
}
.right {
.list-item__right {
flex: 0 0 auto;
color: var(--spectrum-global-color-gray-600);
}
/* Text */
.list-item__text {
flex: 1 1 auto;
width: 0;
@ -106,6 +157,7 @@
text-overflow: ellipsis;
}
.list-item__subtitle {
color: var(--spectrum-global-color-gray-600);
color: var(--spectrum-global-color-gray-700);
font-size: 12px;
}
</style>

View File

@ -147,6 +147,9 @@
.spectrum-Dialog--extraLarge {
width: 1000px;
}
.spectrum-Dialog--medium {
width: 540px;
}
.content-grid {
display: grid;

View File

@ -578,7 +578,9 @@
break
}
}
return utils.processSearchFilters(filters)
return Array.isArray(filters)
? utils.processSearchFilters(filters)
: filters
}
function saveFilters(key) {

View File

@ -5,7 +5,6 @@
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
import { search } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { tables } from "stores/builder"
export let schema
@ -17,19 +16,16 @@
let drawer
$: localFilters = utils.processSearchFilters(filters)
$: localFilters = filters
$: schemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{ allowLinks: true }
)
$: filterCount =
localFilters?.groups?.reduce((acc, group) => {
return (acc += group.filters.filter(filter => filter.field).length)
}, 0) || 0
$: bindings = [
{
type: "context",
@ -61,7 +57,7 @@
title="Filtering"
on:drawerHide
on:drawerShow={() => {
localFilters = utils.processSearchFilters(filters)
localFilters = filters
}}
forceModal
>

View File

@ -9,13 +9,11 @@
let anchor
$: columnOptions = $columns
.filter(col => canBeSortColumn(col.schema))
.map(col => ({
label: col.label || col.name,
value: col.name,
type: col.schema?.type,
related: col.related,
}))
.filter(col => canBeSortColumn(col))
$: orderOptions = getOrderOptions($sort.column, columnOptions)
const getOrderOptions = (column, columnOptions) => {

View File

@ -0,0 +1,259 @@
<script>
import {
ActionButton,
Modal,
ModalContent,
Select,
Icon,
Multiselect,
} from "@budibase/bbui"
import { CalculationType, canGroupBy, FieldType } from "@budibase/types"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte"
const { definition, datasource, rows } = getContext("grid")
const calculationTypeOptions = [
{
label: "Average",
value: CalculationType.AVG,
},
{
label: "Sum",
value: CalculationType.SUM,
},
{
label: "Minimum",
value: CalculationType.MIN,
},
{
label: "Maximum",
value: CalculationType.MAX,
},
{
label: "Count",
value: CalculationType.COUNT,
},
]
let modal
let calculations = []
let groupBy = []
let schema = {}
$: schema = $definition?.schema || {}
$: count = extractCalculations($definition?.schema || {}).length
$: groupByOptions = getGroupByOptions(schema)
const open = () => {
calculations = extractCalculations(schema)
groupBy = calculations.length ? extractGroupBy(schema) : []
modal?.show()
}
const extractCalculations = schema => {
if (!schema) {
return []
}
return Object.keys(schema)
.filter(field => {
return schema[field].calculationType != null
})
.map(field => ({
type: schema[field].calculationType,
field: schema[field].field,
}))
}
const extractGroupBy = schema => {
if (!schema) {
return []
}
return Object.keys(schema).filter(field => {
return schema[field].calculationType == null && schema[field].visible
})
}
// Gets the available types for a given calculation
const getTypeOptions = (self, calculations) => {
return calculationTypeOptions.filter(option => {
return !calculations.some(
calc =>
calc !== self &&
calc.field === self.field &&
calc.type === option.value
)
})
}
// Gets the available fields for a given calculation
const getFieldOptions = (self, calculations, schema) => {
return Object.entries(schema)
.filter(([field, fieldSchema]) => {
// Only allow numeric fields that are not calculations themselves
if (
fieldSchema.calculationType ||
fieldSchema.type !== FieldType.NUMBER
) {
return false
}
// Don't allow duplicates
return !calculations.some(calc => {
return (
calc !== self && calc.type === self.type && calc.field === field
)
})
})
.map(([field]) => field)
}
// Gets the available fields to group by
const getGroupByOptions = schema => {
return Object.entries(schema)
.filter(([_, fieldSchema]) => {
// Don't allow grouping by calculations
if (fieldSchema.calculationType) {
return false
}
// Don't allow complex types
return canGroupBy(fieldSchema.type)
})
.map(([field]) => field)
}
const addCalc = () => {
calculations = [...calculations, { type: CalculationType.AVG }]
}
const deleteCalc = idx => {
calculations = calculations.toSpliced(idx, 1)
// Remove any grouping if clearing the last calculation
if (!calculations.length) {
groupBy = []
}
}
const save = async () => {
let newSchema = {}
// Add calculations
for (let calc of calculations) {
if (!calc.type || !calc.field) {
continue
}
const typeOption = calculationTypeOptions.find(x => x.value === calc.type)
const name = `${typeOption.label} ${calc.field}`
newSchema[name] = {
calculationType: calc.type,
field: calc.field,
visible: true,
}
}
// Add other fields
for (let field of Object.keys(schema)) {
if (schema[field].calculationType) {
continue
}
newSchema[field] = {
...schema[field],
visible: groupBy.includes(field),
}
}
// Ensure primary display is valid
let primaryDisplay = $definition.primaryDisplay
if (!primaryDisplay || !newSchema[primaryDisplay]?.visible) {
primaryDisplay = groupBy[0]
}
// Save changes
await datasource.actions.saveDefinition({
...$definition,
primaryDisplay,
schema: newSchema,
})
await rows.actions.refreshData()
}
</script>
<ActionButton icon="WebPage" quiet on:click={open}>
Configure calculations{count ? `: ${count}` : ""}
</ActionButton>
<Modal bind:this={modal}>
<ModalContent
title="Calculations"
confirmText="Save"
size="M"
onConfirm={save}
>
{#if calculations.length}
<div class="calculations">
{#each calculations as calc, idx}
<span>{idx === 0 ? "Calculate" : "and"} the</span>
<Select
options={getTypeOptions(calc, calculations)}
bind:value={calc.type}
placeholder={false}
/>
<span>of</span>
<Select
options={getFieldOptions(calc, calculations, schema)}
bind:value={calc.field}
placeholder="Column"
/>
<Icon
hoverable
name="Delete"
size="S"
on:click={() => deleteCalc(idx)}
color="var(--spectrum-global-color-gray-700)"
/>
{/each}
<span>Group by</span>
<div class="group-by">
<Multiselect
options={groupByOptions}
bind:value={groupBy}
placeholder="None"
/>
</div>
</div>
{/if}
<div class="buttons">
<ActionButton
quiet
icon="Add"
on:click={addCalc}
disabled={calculations.length >= 5}
>
Add calculation
</ActionButton>
</div>
<InfoDisplay
icon="Help"
quiet
body="Calculations only work with numeric columns and a maximum of 5 calculations can be added at once."
/>
</ModalContent>
</Modal>
<style>
.calculations {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto;
align-items: center;
column-gap: var(--spacing-m);
row-gap: var(--spacing-m);
}
.buttons {
display: flex;
flex-direction: row;
}
.group-by {
grid-column: 2 / 5;
}
span {
}
</style>

View File

@ -92,6 +92,7 @@
disabled={error ||
!name ||
(rows.length && (!allValid || displayColumn == null))}
size="M"
>
<Input
thin

View File

@ -4,6 +4,7 @@
export let title
export let align = "left"
export let showPopover
export let width
let popover
let anchor
@ -22,8 +23,8 @@
<Popover
bind:this={popover}
bind:open
minWidth={400}
maxWidth={400}
minWidth={width || 400}
maxWidth={width || 400}
{anchor}
{align}
{showPopover}

View File

@ -13,14 +13,18 @@
import GridGenerateButton from "components/backend/DataTable/buttons/grid/GridGenerateButton.svelte"
import GridScreensButton from "components/backend/DataTable/buttons/grid/GridScreensButton.svelte"
import GridRowActionsButton from "components/backend/DataTable/buttons/grid/GridRowActionsButton.svelte"
import GridViewCalculationButton from "components/backend/DataTable/buttons/grid/GridViewCalculationButton.svelte"
import { ViewV2Type } from "@budibase/types"
let generateButton
$: id = $viewsV2.selected?.id
$: view = $viewsV2.selected
$: calculation = view?.type === ViewV2Type.CALCULATION
$: id = view?.id
$: datasource = {
type: "viewV2",
id,
tableId: $viewsV2.selected?.tableId,
tableId: view?.tableId,
}
$: buttons = makeRowActionButtons($rowActions[id])
$: rowActions.refreshRowActions(id)
@ -56,13 +60,18 @@
buttonsCollapsed
>
<svelte:fragment slot="controls">
{#if calculation}
<GridViewCalculationButton />
{/if}
<GridManageAccessButton />
<GridFilterButton />
<GridSortButton />
<GridSizeButton />
<GridColumnsSettingButton />
<GridRowActionsButton />
<GridScreensButton on:generate={() => generateButton?.show()} />
{#if !calculation}
<GridColumnsSettingButton />
<GridRowActionsButton />
<GridScreensButton on:generate={() => generateButton?.show()} />
{/if}
<GridGenerateButton bind:this={generateButton} />
</svelte:fragment>
<GridCreateEditRowModal />

View File

@ -1,12 +1,14 @@
<script>
import DetailPopover from "components/common/DetailPopover.svelte"
import { Input, notifications, Button, Icon } from "@budibase/bbui"
import { Input, notifications, Button, Icon, ListItem } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { viewsV2 } from "stores/builder"
import { ViewV2Type } from "@budibase/types"
export let table
export let firstView = false
let calculation = false
let name
let popover
@ -37,8 +39,9 @@
const newView = await viewsV2.create({
name: trimmedName,
tableId: table._id,
schema: enrichSchema(table.schema),
primaryDisplay: table.primaryDisplay,
schema: calculation ? {} : enrichSchema(table.schema),
primaryDisplay: calculation ? undefined : table.primaryDisplay,
type: calculation ? ViewV2Type.CALCULATION : undefined,
})
notifications.success(`View ${name} created`)
$goto(`./${newView.id}`)
@ -52,6 +55,7 @@
title="Create view"
bind:this={popover}
on:open={() => (name = null)}
width={540}
>
<svelte:fragment slot="anchor" let:open>
{#if firstView}
@ -66,6 +70,28 @@
</div>
{/if}
</svelte:fragment>
<div class="options">
<div>
<ListItem
title="Table"
subtitle="Create a subset of your data"
hoverable
on:click={() => (calculation = false)}
selected={!calculation}
icon="Rail"
/>
</div>
<div>
<ListItem
title="Calculation"
subtitle="Calculate groups of rows"
hoverable
on:click={() => (calculation = true)}
selected={calculation}
icon="123"
/>
</div>
</div>
<Input
label="Name"
thin
@ -81,6 +107,11 @@
</DetailPopover>
<style>
.options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-m);
}
.icon {
height: 32px;
padding: 0 8px;

View File

@ -4,11 +4,12 @@
export let title
export let body
export let icon = "HelpOutline"
export let quiet = false
export let warning = false
export let error = false
</script>
<div class="info" class:noTitle={!title} class:warning class:error>
<div class="info" class:noTitle={!title} class:warning class:error class:quiet>
{#if title}
<div class="title">
<Icon name={icon} />
@ -58,7 +59,22 @@
.icon {
color: var(--spectrum-global-color-gray-600);
}
.info {
background-color: var(--background-alt);
padding: var(--spacing-m) var(--spacing-l) var(--spacing-m) var(--spacing-l);
border-radius: var(--border-radius-s);
font-size: 13px;
}
.quiet {
background: none;
color: var(--spectrum-global-color-gray-700);
padding: 0;
}
.noTitle {
display: flex;
align-items: center;
gap: var(--spacing-l);
}
.info :global(a) {
color: inherit;
transition: color 130ms ease-out;

View File

@ -207,7 +207,6 @@ export class ComponentStore extends BudiStore {
)
for (let setting of filterableTypes || []) {
const isLegacy = Array.isArray(enrichedComponent[setting.key])
if (isLegacy) {
const processedSetting = utils.processSearchFilters(
enrichedComponent[setting.key]

View File

@ -1,30 +1,6 @@
import { writable, derived, get } from "svelte/store"
import { tables } from "./tables"
import { API } from "api"
import { dataFilters } from "@budibase/shared-core"
function convertToSearchFilters(view) {
// convert from UISearchFilter type
if (view?.query) {
return {
...view,
queryUI: view.query,
query: dataFilters.buildQuery(view.query),
}
}
return view
}
function convertToUISearchFilter(view) {
if (view?.queryUI) {
return {
...view,
query: view.queryUI,
queryUI: undefined,
}
}
return view
}
export function createViewsV2Store() {
const store = writable({
@ -36,7 +12,7 @@ export function createViewsV2Store() {
const views = Object.values(table?.views || {}).filter(view => {
return view.version === 2
})
list = list.concat(views.map(view => convertToUISearchFilter(view)))
list = list.concat(views)
})
return {
...$store,
@ -58,7 +34,6 @@ export function createViewsV2Store() {
}
const create = async view => {
view = convertToSearchFilters(view)
const savedViewResponse = await API.viewV2.create(view)
const savedView = savedViewResponse.data
replaceView(savedView.id, savedView)
@ -66,7 +41,6 @@ export function createViewsV2Store() {
}
const save = async view => {
view = convertToSearchFilters(view)
const res = await API.viewV2.update(view)
const savedView = res?.data
replaceView(view.id, savedView)
@ -77,7 +51,6 @@ export function createViewsV2Store() {
if (!viewId) {
return
}
view = convertToUISearchFilter(view)
const existingView = get(derivedStore).list.find(view => view.id === viewId)
const tableIndex = get(tables).list.findIndex(table => {
return table._id === view?.tableId || table._id === existingView?.tableId

View File

@ -17,6 +17,7 @@
import { getContext, createEventDispatcher } from "svelte"
import FilterField from "./FilterField.svelte"
import ConditionField from "./ConditionField.svelte"
import { utils } from "@budibase/shared-core"
const dispatch = createEventDispatcher()
const {
@ -42,8 +43,7 @@
export let toReadable
export let toRuntime
$: editableFilters = filters ? Helpers.cloneDeep(filters) : null
$: editableFilters = migrateFilters(filters)
$: {
if (
tables.find(
@ -57,6 +57,16 @@
}
}
// We still may need to migrate this even though the backend does it automatically now
// for query definitions. This is because we might be editing saved filter definitions
// from old screens, which will still be of type LegacyFilter[].
const migrateFilters = filters => {
if (Array.isArray(filters)) {
return utils.processSearchFilters(filters)
}
return Helpers.cloneDeep(filters)
}
const filterOperatorOptions = Object.values(FilterOperator).map(entry => {
return { value: entry, label: Helpers.capitalise(entry) }
})
@ -72,10 +82,12 @@
const context = getContext("context")
$: fieldOptions = (schemaFields || []).map(field => ({
label: field.displayName || field.name,
value: field.name,
}))
$: fieldOptions = (schemaFields || [])
.filter(field => !field.calculationType)
.map(field => ({
label: field.displayName || field.name,
value: field.name,
}))
const onFieldChange = filter => {
const previousType = filter.type

View File

@ -6,7 +6,7 @@
import { getColumnIcon } from "../../../utils/schema"
import MigrationModal from "../controls/MigrationModal.svelte"
import { debounce } from "../../../utils/utils"
import { FieldType, FormulaType } from "@budibase/types"
import { FieldType, FormulaType, SortOrder } from "@budibase/types"
import { TableNames } from "../../../constants"
import GridPopover from "../overlays/GridPopover.svelte"
@ -52,7 +52,7 @@
$: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $scrollableColumns.length - 1
$: sortingLabels = getSortingLabels(column.schema?.type)
$: sortingLabels = getSortingLabels(column)
$: searchable = isColumnSearchable(column)
$: resetSearchValue(column.name)
$: searching = searchValue != null
@ -66,8 +66,14 @@
editIsOpen = false
}
const getSortingLabels = type => {
switch (type) {
const getSortingLabels = column => {
if (column.calculationType) {
return {
ascending: "low-high",
descending: "high-low",
}
}
switch (column?.schema?.type) {
case FieldType.NUMBER:
case FieldType.BIGINT:
return {
@ -137,7 +143,7 @@
const sortAscending = () => {
sort.set({
column: column.name,
order: "ascending",
order: SortOrder.ASCENDING,
})
open = false
}
@ -145,7 +151,7 @@
const sortDescending = () => {
sort.set({
column: column.name,
order: "descending",
order: SortOrder.DESCENDING,
})
open = false
}
@ -318,7 +324,7 @@
<Icon
hoverable
size="S"
name={$sort.order === "descending"
name={$sort.order === SortOrder.DESCENDING
? "SortOrderDown"
: "SortOrderUp"}
/>
@ -366,7 +372,8 @@
icon="SortOrderUp"
on:click={sortAscending}
disabled={!canBeSortColumn(column.schema) ||
(column.name === $sort.column && $sort.order === "ascending")}
(column.name === $sort.column &&
$sort.order === SortOrder.ASCENDING)}
>
Sort {sortingLabels.ascending}
</MenuItem>
@ -374,7 +381,8 @@
icon="SortOrderDown"
on:click={sortDescending}
disabled={!canBeSortColumn(column.schema) ||
(column.name === $sort.column && $sort.order === "descending")}
(column.name === $sort.column &&
$sort.order === SortOrder.DESCENDING)}
>
Sort {sortingLabels.descending}
</MenuItem>

View File

@ -41,6 +41,9 @@ const TypeComponentMap = {
role: RoleCell,
}
export const getCellRenderer = column => {
if (column.calculationType) {
return NumberCell
}
return (
TypeComponentMap[column?.schema?.cellRenderType] ||
TypeComponentMap[column?.schema?.type] ||

View File

@ -161,10 +161,10 @@ export const initialise = context => {
order: fieldSchema.order ?? oldColumn?.order,
conditions: fieldSchema.conditions,
related: fieldSchema.related,
calculationType: fieldSchema.calculationType,
}
// Override a few properties for primary display
if (field === primaryDisplay) {
column.visible = true
column.order = 0
column.primaryDisplay = true
}

View File

@ -1,5 +1,6 @@
import { derivedMemo } from "../../../utils"
import { derived } from "svelte/store"
import { ViewV2Type } from "@budibase/types"
export const createStores = context => {
const { props } = context
@ -30,18 +31,26 @@ export const createStores = context => {
}
export const deriveStores = context => {
const { props, hasNonAutoColumn } = context
const { props, definition, hasNonAutoColumn } = context
// Derive features
const config = derived(
[props, hasNonAutoColumn],
([$props, $hasNonAutoColumn]) => {
[props, definition, hasNonAutoColumn],
([$props, $definition, $hasNonAutoColumn]) => {
let config = { ...$props }
const type = $props.datasource?.type
// Disable some features if we're editing a view
if (type === "viewV2") {
config.canEditColumns = false
// Disable features for calculation views
if ($definition?.type === ViewV2Type.CALCULATION) {
config.canAddRows = false
config.canEditRows = false
config.canDeleteRows = false
config.canExpandRows = false
}
}
// Disable adding rows if we don't have any valid columns

View File

@ -2,6 +2,7 @@ import { derived, get } from "svelte/store"
import { getDatasourceDefinition, getDatasourceSchema } from "../../../fetch"
import { enrichSchemaWithRelColumns, memo } from "../../../utils"
import { cloneDeep } from "lodash"
import { ViewV2Type } from "@budibase/types"
export const createStores = () => {
const definition = memo(null)
@ -81,13 +82,20 @@ export const deriveStores = context => {
}
)
const hasBudibaseIdentifiers = derived(datasource, $datasource => {
let type = $datasource?.type
if (type === "provider") {
type = $datasource.value?.datasource?.type
const hasBudibaseIdentifiers = derived(
[datasource, definition],
([$datasource, $definition]) => {
let type = $datasource?.type
if (type === "provider") {
type = $datasource.value?.datasource?.type
}
// Handle calculation views
if (type === "viewV2" && $definition?.type === ViewV2Type.CALCULATION) {
return false
}
return ["table", "viewV2", "link"].includes(type)
}
return ["table", "viewV2", "link"].includes(type)
})
)
return {
schema,

View File

@ -1,3 +1,4 @@
import { SortOrder } from "@budibase/types"
import { get } from "svelte/store"
export const createActions = context => {
@ -84,7 +85,7 @@ export const initialise = context => {
inlineFilters.set([])
sort.set({
column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending",
order: get(initialSortOrder) || SortOrder.ASCENDING,
})
// Update fetch when filter changes
@ -110,7 +111,7 @@ export const initialise = context => {
return
}
$fetch.update({
sortOrder: $sort.order || "ascending",
sortOrder: $sort.order || SortOrder.ASCENDING,
sortColumn: $sort.column,
})
})

View File

@ -1,3 +1,4 @@
import { SortOrder } from "@budibase/types"
import { get } from "svelte/store"
const SuppressErrors = true
@ -93,7 +94,7 @@ export const initialise = context => {
inlineFilters.set([])
sort.set({
column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending",
order: get(initialSortOrder) || SortOrder.ASCENDING,
})
// Update fetch when filter changes
@ -119,7 +120,7 @@ export const initialise = context => {
return
}
$fetch.update({
sortOrder: $sort.order || "ascending",
sortOrder: $sort.order || SortOrder.ASCENDING,
sortColumn: $sort.column,
})
})

View File

@ -1,17 +1,5 @@
import { get } from "svelte/store"
import { dataFilters } from "@budibase/shared-core"
function convertToSearchFilters(view) {
// convert from SearchFilterGroup type
if (view?.query) {
return {
...view,
queryUI: view.query,
query: dataFilters.buildQuery(view.query),
}
}
return view
}
import { SortOrder } from "@budibase/types"
const SuppressErrors = true
@ -19,7 +7,7 @@ export const createActions = context => {
const { API, datasource, columns } = context
const saveDefinition = async newDefinition => {
await API.viewV2.update(convertToSearchFilters(newDefinition))
await API.viewV2.update(newDefinition)
}
const saveRow = async row => {
@ -117,7 +105,7 @@ export const initialise = context => {
inlineFilters.set([])
sort.set({
column: get(initialSortColumn),
order: get(initialSortOrder) || "ascending",
order: get(initialSortOrder) || SortOrder.ASCENDING,
})
// Keep sort and filter state in line with the view definition when in builder
@ -133,12 +121,12 @@ export const initialise = context => {
if (!get(initialSortColumn)) {
sort.set({
column: $definition.sort?.field,
order: $definition.sort?.order || "ascending",
order: $definition.sort?.order || SortOrder.ASCENDING,
})
}
// Only override filter state if we don't have an initial filter
if (!get(initialFilter)) {
filter.set($definition.queryUI || $definition.query)
filter.set($definition.queryUI)
}
})
)
@ -166,7 +154,7 @@ export const initialise = context => {
...$view,
sort: {
field: $sort.column,
order: $sort.order || "ascending",
order: $sort.order || SortOrder.ASCENDING,
},
})
}
@ -197,7 +185,7 @@ export const initialise = context => {
if (JSON.stringify($filter) !== JSON.stringify($view.queryUI)) {
await datasource.actions.saveDefinition({
...$view,
query: $filter,
queryUI: $filter,
})
// Refresh data since view definition changed

View File

@ -1,5 +1,6 @@
import { derived, get } from "svelte/store"
import { memo } from "../../../utils"
import { SortOrder } from "@budibase/types"
export const createStores = context => {
const { props } = context
@ -8,7 +9,7 @@ export const createStores = context => {
// Initialise to default props
const sort = memo({
column: $props.initialSortColumn,
order: $props.initialSortOrder || "ascending",
order: $props.initialSortOrder || SortOrder.ASCENDING,
})
return {
@ -24,7 +25,10 @@ export const initialise = context => {
sort.update(state => ({ ...state, column: newSortColumn }))
})
initialSortOrder.subscribe(newSortOrder => {
sort.update(state => ({ ...state, order: newSortOrder || "ascending" }))
sort.update(state => ({
...state,
order: newSortOrder || SortOrder.ASCENDING,
}))
})
// Derive if the current sort column exists in the schema
@ -40,7 +44,7 @@ export const initialise = context => {
if (!exists) {
sort.set({
column: null,
order: "ascending",
order: SortOrder.ASCENDING,
})
}
})

View File

@ -2,6 +2,7 @@ import { writable, derived, get } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { QueryUtils } from "../utils"
import { convertJSONSchemaToTableSchema } from "../utils/json"
import { FieldType, SortOrder, SortType } from "@budibase/types"
const { buildQuery, limit: queryLimit, runQuery, sort } = QueryUtils
@ -37,7 +38,7 @@ export default class DataFetch {
// Sorting config
sortColumn: null,
sortOrder: "ascending",
sortOrder: SortOrder.ASCENDING,
sortType: null,
// Pagination config
@ -162,17 +163,22 @@ export default class DataFetch {
// If we don't have a sort column specified then just ensure we don't set
// any sorting params
if (!this.options.sortColumn) {
this.options.sortOrder = "ascending"
this.options.sortOrder = SortOrder.ASCENDING
this.options.sortType = null
} else {
// Otherwise determine what sort type to use base on sort column
const type = schema?.[this.options.sortColumn]?.type
this.options.sortType =
type === "number" || type === "bigint" ? "number" : "string"
this.options.sortType = SortType.STRING
const fieldSchema = schema?.[this.options.sortColumn]
if (
fieldSchema?.type === FieldType.NUMBER ||
fieldSchema?.type === FieldType.BIGINT ||
fieldSchema?.calculationType
) {
this.options.sortType = SortType.NUMBER
}
// If no sort order, default to ascending
if (!this.options.sortOrder) {
this.options.sortOrder = "ascending"
this.options.sortOrder = SortOrder.ASCENDING
}
}
@ -310,7 +316,7 @@ export default class DataFetch {
let jsonAdditions = {}
Object.keys(schema).forEach(fieldKey => {
const fieldSchema = schema[fieldKey]
if (fieldSchema?.type === "json") {
if (fieldSchema?.type === FieldType.JSON) {
const jsonSchema = convertJSONSchemaToTableSchema(fieldSchema, {
squashObjects: true,
})

View File

@ -1,5 +1,6 @@
import { get } from "svelte/store"
import DataFetch from "./DataFetch.js"
import { SortOrder } from "@budibase/types"
export default class TableFetch extends DataFetch {
determineFeatureFlags() {
@ -23,7 +24,7 @@ export default class TableFetch extends DataFetch {
query,
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortOrder: sortOrder?.toLowerCase() ?? SortOrder.ASCENDING,
sortType,
paginate,
bookmark: cursor,

View File

@ -1,3 +1,4 @@
import { ViewV2Type } from "@budibase/types"
import DataFetch from "./DataFetch.js"
import { get } from "svelte/store"
@ -39,6 +40,19 @@ export default class ViewV2Fetch extends DataFetch {
this.options
const { cursor, query, definition } = get(this.store)
// If this is a calculation view and we have no calculations, return nothing
if (
definition.type === ViewV2Type.CALCULATION &&
!Object.values(definition.schema || {}).some(x => x.calculationType)
) {
return {
rows: [],
hasNextPage: false,
cursor: null,
error: null,
}
}
// If sort/filter params are not defined, update options to store the
// params built in to this view. This ensures that we can accurately
// compare old and new params and skip a redundant API call.
@ -67,6 +81,7 @@ export default class ViewV2Fetch extends DataFetch {
return {
rows: [],
hasNextPage: false,
cursor: null,
error,
}
}

View File

@ -5,15 +5,15 @@ export const getColumnIcon = column => {
if (column.schema.icon) {
return column.schema.icon
}
if (column.calculationType) {
return "Calculator"
}
if (column.schema.autocolumn) {
return "MagicWand"
}
if (helpers.schema.isDeprecatedSingleUserColumn(column.schema)) {
return "User"
}
const { type, subtype } = column.schema
const result =
typeof TypeIconMap[type] === "object" && subtype

View File

@ -4,24 +4,24 @@ export function canBeDisplayColumn(column) {
if (!sharedCore.canBeDisplayColumn(column.type)) {
return false
}
// If it's a related column (only available in the frontend), don't allow using it as display column
if (column.related) {
// If it's a related column (only available in the frontend), don't allow using it as display column
return false
}
return true
}
export function canBeSortColumn(column) {
// Allow sorting by calculation columns
if (column.calculationType) {
return true
}
if (!sharedCore.canBeSortColumn(column.type)) {
return false
}
// If it's a related column (only available in the frontend), don't allow using it as display column
if (column.related) {
// If it's a related column (only available in the frontend), don't allow using it as display column
return false
}
return true
}

View File

@ -29,6 +29,7 @@ describe.each(
const isOracle = dbName === DatabaseName.ORACLE
const isMsSQL = dbName === DatabaseName.SQL_SERVER
const isPostgres = dbName === DatabaseName.POSTGRES
const mainTableName = "test_table"
let rawDatasource: Datasource
let datasource: Datasource
@ -71,15 +72,15 @@ describe.each(
client = await knexClient(rawDatasource)
await client.schema.dropTableIfExists("test_table")
await client.schema.createTable("test_table", table => {
await client.schema.dropTableIfExists(mainTableName)
await client.schema.createTable(mainTableName, table => {
table.increments("id").primary()
table.string("name")
table.timestamp("birthday")
table.integer("number")
})
await client("test_table").insert([
await client(mainTableName).insert([
{ name: "one" },
{ name: "two" },
{ name: "three" },
@ -105,7 +106,7 @@ describe.each(
const query = await createQuery({
name: "New Query",
fields: {
sql: client("test_table").select("*").toString(),
sql: client(mainTableName).select("*").toString(),
},
})
@ -114,7 +115,7 @@ describe.each(
name: "New Query",
parameters: [],
fields: {
sql: client("test_table").select("*").toString(),
sql: client(mainTableName).select("*").toString(),
},
schema: {},
queryVerb: "read",
@ -133,7 +134,7 @@ describe.each(
it("should be able to update a query", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").select("*").toString(),
sql: client(mainTableName).select("*").toString(),
},
})
@ -143,7 +144,7 @@ describe.each(
...query,
name: "Updated Query",
fields: {
sql: client("test_table").where({ id: 1 }).toString(),
sql: client(mainTableName).where({ id: 1 }).toString(),
},
})
@ -152,7 +153,7 @@ describe.each(
name: "Updated Query",
parameters: [],
fields: {
sql: client("test_table").where({ id: 1 }).toString(),
sql: client(mainTableName).where({ id: 1 }).toString(),
},
schema: {},
queryVerb: "read",
@ -169,7 +170,7 @@ describe.each(
it("should be able to delete a query", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").select("*").toString(),
sql: client(mainTableName).select("*").toString(),
},
})
@ -188,7 +189,7 @@ describe.each(
it("should be able to list queries", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").select("*").toString(),
sql: client(mainTableName).select("*").toString(),
},
})
@ -199,7 +200,7 @@ describe.each(
it("should strip sensitive fields for prod apps", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").select("*").toString(),
sql: client(mainTableName).select("*").toString(),
},
})
@ -217,7 +218,7 @@ describe.each(
const jsonStatement = `COALESCE(json_build_object('name', name),'{"name":{}}'::json)`
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.select([
"*",
client.raw(
@ -245,7 +246,7 @@ describe.each(
datasourceId: datasource._id!,
queryVerb: "read",
fields: {
sql: client("test_table").where({ id: 1 }).toString(),
sql: client(mainTableName).where({ id: 1 }).toString(),
},
parameters: [],
transformer: "return data",
@ -391,7 +392,7 @@ describe.each(
it("should work with dynamic variables", async () => {
const basedOnQuery = await createQuery({
fields: {
sql: client("test_table").select("name").where({ id: 1 }).toString(),
sql: client(mainTableName).select("name").where({ id: 1 }).toString(),
},
})
@ -440,7 +441,7 @@ describe.each(
it("should handle the dynamic base query being deleted", async () => {
const basedOnQuery = await createQuery({
fields: {
sql: client("test_table").select("name").where({ id: 1 }).toString(),
sql: client(mainTableName).select("name").where({ id: 1 }).toString(),
},
})
@ -494,7 +495,7 @@ describe.each(
it("should be able to insert with bindings", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").insert({ name: "{{ foo }}" }).toString(),
sql: client(mainTableName).insert({ name: "{{ foo }}" }).toString(),
},
parameters: [
{
@ -517,7 +518,7 @@ describe.each(
},
])
const rows = await client("test_table").where({ name: "baz" }).select()
const rows = await client(mainTableName).where({ name: "baz" }).select()
expect(rows).toHaveLength(1)
for (const row of rows) {
expect(row).toMatchObject({ name: "baz" })
@ -527,7 +528,7 @@ describe.each(
it("should not allow handlebars as parameters", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").insert({ name: "{{ foo }}" }).toString(),
sql: client(mainTableName).insert({ name: "{{ foo }}" }).toString(),
},
parameters: [
{
@ -563,7 +564,7 @@ describe.each(
const date = new Date(datetimeStr)
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.insert({
name: "foo",
birthday: client.raw("{{ birthday }}"),
@ -585,7 +586,7 @@ describe.each(
expect(result.data).toEqual([{ created: true }])
const rows = await client("test_table")
const rows = await client(mainTableName)
.where({ birthday: datetimeStr })
.select()
expect(rows).toHaveLength(1)
@ -601,7 +602,7 @@ describe.each(
async notDateStr => {
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.insert({ name: client.raw("{{ name }}") })
.toString(),
},
@ -622,7 +623,7 @@ describe.each(
expect(result.data).toEqual([{ created: true }])
const rows = await client("test_table")
const rows = await client(mainTableName)
.where({ name: notDateStr })
.select()
expect(rows).toHaveLength(1)
@ -634,7 +635,7 @@ describe.each(
it("should execute a query", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").select("*").orderBy("id").toString(),
sql: client(mainTableName).select("*").orderBy("id").toString(),
},
})
@ -677,7 +678,7 @@ describe.each(
it("should be able to transform a query", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").where({ id: 1 }).select("*").toString(),
sql: client(mainTableName).where({ id: 1 }).select("*").toString(),
},
transformer: `
data[0].id = data[0].id + 1;
@ -700,7 +701,7 @@ describe.each(
it("should coerce numeric bindings", async () => {
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.where({ id: client.raw("{{ id }}") })
.select("*")
.toString(),
@ -734,7 +735,7 @@ describe.each(
it("should be able to update rows", async () => {
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.update({ name: client.raw("{{ name }}") })
.where({ id: client.raw("{{ id }}") })
.toString(),
@ -759,7 +760,7 @@ describe.each(
},
})
const rows = await client("test_table").where({ id: 1 }).select()
const rows = await client(mainTableName).where({ id: 1 }).select()
expect(rows).toEqual([
{ id: 1, name: "foo", birthday: null, number: null },
])
@ -768,7 +769,7 @@ describe.each(
it("should be able to execute an update that updates no rows", async () => {
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.update({ name: "updated" })
.where({ id: 100 })
.toString(),
@ -778,7 +779,7 @@ describe.each(
await config.api.query.execute(query._id!)
const rows = await client("test_table").select()
const rows = await client(mainTableName).select()
for (const row of rows) {
expect(row.name).not.toEqual("updated")
}
@ -787,14 +788,14 @@ describe.each(
it("should be able to execute a delete that deletes no rows", async () => {
const query = await createQuery({
fields: {
sql: client("test_table").where({ id: 100 }).delete().toString(),
sql: client(mainTableName).where({ id: 100 }).delete().toString(),
},
queryVerb: "delete",
})
await config.api.query.execute(query._id!)
const rows = await client("test_table").select()
const rows = await client(mainTableName).select()
expect(rows).toHaveLength(5)
})
})
@ -803,7 +804,7 @@ describe.each(
it("should be able to delete rows", async () => {
const query = await createQuery({
fields: {
sql: client("test_table")
sql: client(mainTableName)
.where({ id: client.raw("{{ id }}") })
.delete()
.toString(),
@ -823,7 +824,7 @@ describe.each(
},
})
const rows = await client("test_table").where({ id: 1 }).select()
const rows = await client(mainTableName).where({ id: 1 }).select()
expect(rows).toHaveLength(0)
})
})
@ -831,7 +832,7 @@ describe.each(
describe("query through datasource", () => {
it("should be able to query the datasource", async () => {
const entityId = "test_table"
const entityId = mainTableName
await config.api.datasource.update({
...datasource,
entities: {
@ -876,7 +877,7 @@ describe.each(
beforeAll(async () => {
queryParams = {
fields: {
sql: client("test_table")
sql: client(mainTableName)
.insert({
name: client.raw("{{ bindingName }}"),
number: client.raw("{{ bindingNumber }}"),
@ -929,4 +930,34 @@ describe.each(
})
})
})
describe("edge cases", () => {
it("should find rows with a binding containing a slash", async () => {
const slashValue = "1/10"
await client(mainTableName).insert([{ name: slashValue }])
const query = await createQuery({
fields: {
sql: client(mainTableName)
.select("*")
.where("name", "=", client.raw("{{ bindingName }}"))
.toString(),
},
parameters: [
{
name: "bindingName",
default: "",
},
],
queryVerb: "read",
})
const results = await config.api.query.execute(query._id!, {
parameters: {
bindingName: slashValue,
},
})
expect(results).toBeDefined()
expect(results.data.length).toEqual(1)
})
})
})

View File

@ -24,8 +24,7 @@ import {
checkExternalTables,
HOST_ADDRESS,
} from "./utils"
import dayjs from "dayjs"
import { NUMBER_REGEX } from "../utilities"
import { isDate, NUMBER_REGEX } from "../utilities"
import { MySQLColumn } from "./base/types"
import { getReadableErrorMessage } from "./base/errorMapping"
import { sql } from "@budibase/backend-core"
@ -129,11 +128,7 @@ export function bindingTypeCoerce(bindings: SqlQueryBinding) {
}
// if not a number, see if it is a date - important to do in this order as any
// integer will be considered a valid date
else if (
/^\d/.test(binding) &&
dayjs(binding).isValid() &&
!binding.includes(",")
) {
else if (isDate(binding)) {
let value: any
value = new Date(binding)
if (isNaN(value)) {
@ -439,8 +434,7 @@ class MySQLIntegration extends Sql implements DatasourcePlus {
dumpContent.push(createTableStatement)
}
const schema = dumpContent.join("\n")
return schema
return dumpContent.join("\n")
} finally {
this.disconnect()
}

View File

@ -2,6 +2,10 @@ import env from "../environment"
import { context } from "@budibase/backend-core"
import { generateMetadataID } from "../db/utils"
import { Document } from "@budibase/types"
import dayjs from "dayjs"
import customParseFormat from "dayjs/plugin/customParseFormat"
dayjs.extend(customParseFormat)
export function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
@ -10,6 +14,28 @@ export function wait(ms: number) {
export const isDev = env.isDev
export const NUMBER_REGEX = /^[+-]?([0-9]*[.])?[0-9]+$/g
const ACCEPTED_DATE_FORMATS = [
"MM/DD/YYYY",
"MM/DD/YY",
"DD/MM/YYYY",
"DD/MM/YY",
"YYYY/MM/DD",
"YYYY-MM-DD",
"YYYY-MM-DDTHH:mm",
"YYYY-MM-DDTHH:mm:ss",
"YYYY-MM-DDTHH:mm:ss[Z]",
"YYYY-MM-DDTHH:mm:ss.SSS[Z]",
]
export function isDate(str: string) {
// checks for xx/xx/xx or ISO date timestamp formats
for (const format of ACCEPTED_DATE_FORMATS) {
if (dayjs(str, format, true).isValid()) {
return true
}
}
return false
}
export function removeFromArray(array: any[], element: any) {
const index = array.indexOf(element)

View File

@ -0,0 +1,34 @@
import { isDate } from "../"
describe("isDate", () => {
it("should handle DD/MM/YYYY", () => {
expect(isDate("01/01/2001")).toEqual(true)
})
it("should handle DD/MM/YY", () => {
expect(isDate("01/01/01")).toEqual(true)
})
it("should handle ISO format YYYY-MM-DD", () => {
expect(isDate("2001-01-01")).toEqual(true)
})
it("should handle ISO format with time (YYYY-MM-DDTHH:MM)", () => {
expect(isDate("2001-01-01T12:30")).toEqual(true)
})
it("should handle ISO format with full timestamp (YYYY-MM-DDTHH:MM:SS)", () => {
expect(isDate("2001-01-01T12:30:45")).toEqual(true)
})
it("should handle complete ISO format", () => {
expect(isDate("2001-01-01T12:30:00.000Z")).toEqual(true)
})
it("should return false for invalid formats", () => {
expect(isDate("")).toEqual(false)
expect(isDate("1/10")).toEqual(false)
expect(isDate("random string")).toEqual(false)
expect(isDate("123456")).toEqual(false)
})
})