diff --git a/.gitignore b/.gitignore index 32d1416f4a..bac643e5df 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/bbui/src/List/ListItem.svelte b/packages/bbui/src/List/ListItem.svelte index 699df2d456..e979b2b684 100644 --- a/packages/bbui/src/List/ListItem.svelte +++ b/packages/bbui/src/List/ListItem.svelte @@ -16,14 +16,17 @@ href={url} class="list-item" class:hoverable={hoverable || url != null} + class:large={!!subtitle} on:click class:selected > -
+
{#if icon === "StatusLight"} {:else if icon} - +
+ +
{/if}
{#if title} @@ -38,7 +41,7 @@ {/if}
-
+
{#if showArrow} @@ -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; } diff --git a/packages/bbui/src/Modal/ModalContent.svelte b/packages/bbui/src/Modal/ModalContent.svelte index 22a14c358f..8a80b2fdb9 100644 --- a/packages/bbui/src/Modal/ModalContent.svelte +++ b/packages/bbui/src/Modal/ModalContent.svelte @@ -147,6 +147,9 @@ .spectrum-Dialog--extraLarge { width: 1000px; } + .spectrum-Dialog--medium { + width: 540px; + } .content-grid { display: grid; diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 1b8014213c..b05d8a2b17 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -578,7 +578,9 @@ break } } - return utils.processSearchFilters(filters) + return Array.isArray(filters) + ? utils.processSearchFilters(filters) + : filters } function saveFilters(key) { diff --git a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte index e616e27467..2071ab8b86 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/TableFilterButton.svelte @@ -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 > diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridSortButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridSortButton.svelte index 5a1f6b221a..4591b22b78 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridSortButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridSortButton.svelte @@ -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) => { diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridViewCalculationButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridViewCalculationButton.svelte new file mode 100644 index 0000000000..72216f3b3b --- /dev/null +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridViewCalculationButton.svelte @@ -0,0 +1,259 @@ + + + + Configure calculations{count ? `: ${count}` : ""} + + + + + {#if calculations.length} +
+ {#each calculations as calc, idx} + {idx === 0 ? "Calculate" : "and"} the + + deleteCalc(idx)} + color="var(--spectrum-global-color-gray-700)" + /> + {/each} + Group by +
+ +
+
+ {/if} +
+ = 5} + > + Add calculation + +
+ +
+
+ + diff --git a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte index b62c8af03d..8a5484e2b2 100644 --- a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte +++ b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte @@ -92,6 +92,7 @@ disabled={error || !name || (rows.length && (!allValid || displayColumn == null))} + size="M" > + {#if calculation} + + {/if} - - - generateButton?.show()} /> + {#if !calculation} + + + generateButton?.show()} /> + {/if} diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/CreateViewButton.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/CreateViewButton.svelte index 8af5007022..ecc85e7622 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/CreateViewButton.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/_components/CreateViewButton.svelte @@ -1,12 +1,14 @@ -
+
{#if title}
@@ -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; diff --git a/packages/builder/src/stores/builder/components.js b/packages/builder/src/stores/builder/components.js index 6c2c438f0c..faa6a086ca 100644 --- a/packages/builder/src/stores/builder/components.js +++ b/packages/builder/src/stores/builder/components.js @@ -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] diff --git a/packages/builder/src/stores/builder/viewsV2.js b/packages/builder/src/stores/builder/viewsV2.js index 3d67686344..9bd32f4a24 100644 --- a/packages/builder/src/stores/builder/viewsV2.js +++ b/packages/builder/src/stores/builder/viewsV2.js @@ -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 diff --git a/packages/frontend-core/src/components/CoreFilterBuilder.svelte b/packages/frontend-core/src/components/CoreFilterBuilder.svelte index 6a5f829ec8..e98a18689b 100644 --- a/packages/frontend-core/src/components/CoreFilterBuilder.svelte +++ b/packages/frontend-core/src/components/CoreFilterBuilder.svelte @@ -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 diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index 6c1c025fcd..93db2b6317 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -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 @@ @@ -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} @@ -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} diff --git a/packages/frontend-core/src/components/grid/lib/renderers.js b/packages/frontend-core/src/components/grid/lib/renderers.js index 70700f9417..a860d01b53 100644 --- a/packages/frontend-core/src/components/grid/lib/renderers.js +++ b/packages/frontend-core/src/components/grid/lib/renderers.js @@ -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] || diff --git a/packages/frontend-core/src/components/grid/stores/columns.js b/packages/frontend-core/src/components/grid/stores/columns.js index f23a17f14c..5f2800ba7a 100644 --- a/packages/frontend-core/src/components/grid/stores/columns.js +++ b/packages/frontend-core/src/components/grid/stores/columns.js @@ -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 } diff --git a/packages/frontend-core/src/components/grid/stores/config.js b/packages/frontend-core/src/components/grid/stores/config.js index fc0435f92d..4a60370690 100644 --- a/packages/frontend-core/src/components/grid/stores/config.js +++ b/packages/frontend-core/src/components/grid/stores/config.js @@ -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 diff --git a/packages/frontend-core/src/components/grid/stores/datasource.js b/packages/frontend-core/src/components/grid/stores/datasource.js index 869cd56730..6aa607f7ed 100644 --- a/packages/frontend-core/src/components/grid/stores/datasource.js +++ b/packages/frontend-core/src/components/grid/stores/datasource.js @@ -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, diff --git a/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js b/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js index f3b8a6e02d..ea558d6236 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/nonPlus.js @@ -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, }) }) diff --git a/packages/frontend-core/src/components/grid/stores/datasources/table.js b/packages/frontend-core/src/components/grid/stores/datasources/table.js index 2ba95e3a74..e415f5914b 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/table.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/table.js @@ -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, }) }) diff --git a/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js b/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js index 56a7479b44..4a4a91658c 100644 --- a/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js +++ b/packages/frontend-core/src/components/grid/stores/datasources/viewV2.js @@ -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 diff --git a/packages/frontend-core/src/components/grid/stores/sort.js b/packages/frontend-core/src/components/grid/stores/sort.js index 336570d012..9ab393b11f 100644 --- a/packages/frontend-core/src/components/grid/stores/sort.js +++ b/packages/frontend-core/src/components/grid/stores/sort.js @@ -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, }) } }) diff --git a/packages/frontend-core/src/fetch/DataFetch.js b/packages/frontend-core/src/fetch/DataFetch.js index a056cdff5d..fb1dbd5885 100644 --- a/packages/frontend-core/src/fetch/DataFetch.js +++ b/packages/frontend-core/src/fetch/DataFetch.js @@ -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, }) diff --git a/packages/frontend-core/src/fetch/TableFetch.js b/packages/frontend-core/src/fetch/TableFetch.js index a13b1bd186..ed17c20c79 100644 --- a/packages/frontend-core/src/fetch/TableFetch.js +++ b/packages/frontend-core/src/fetch/TableFetch.js @@ -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, diff --git a/packages/frontend-core/src/fetch/ViewV2Fetch.js b/packages/frontend-core/src/fetch/ViewV2Fetch.js index 40135746df..6bfef0927f 100644 --- a/packages/frontend-core/src/fetch/ViewV2Fetch.js +++ b/packages/frontend-core/src/fetch/ViewV2Fetch.js @@ -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, } } diff --git a/packages/frontend-core/src/utils/schema.js b/packages/frontend-core/src/utils/schema.js index aac9854e69..e4e26dce39 100644 --- a/packages/frontend-core/src/utils/schema.js +++ b/packages/frontend-core/src/utils/schema.js @@ -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 diff --git a/packages/frontend-core/src/utils/table.js b/packages/frontend-core/src/utils/table.js index 193848ec4d..b7abd572c7 100644 --- a/packages/frontend-core/src/utils/table.js +++ b/packages/frontend-core/src/utils/table.js @@ -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 } diff --git a/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts b/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts index 0979f8bed3..4e9a1e5548 100644 --- a/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts +++ b/packages/server/src/api/routes/tests/queries/generic-sql.spec.ts @@ -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) + }) + }) }) diff --git a/packages/server/src/integrations/mysql.ts b/packages/server/src/integrations/mysql.ts index 8b1ada4184..f78915fb49 100644 --- a/packages/server/src/integrations/mysql.ts +++ b/packages/server/src/integrations/mysql.ts @@ -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() } diff --git a/packages/server/src/utilities/index.ts b/packages/server/src/utilities/index.ts index 129137a72e..331a8e266f 100644 --- a/packages/server/src/utilities/index.ts +++ b/packages/server/src/utilities/index.ts @@ -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) diff --git a/packages/server/src/utilities/tests/utils.spec.ts b/packages/server/src/utilities/tests/utils.spec.ts new file mode 100644 index 0000000000..bd94b5fdd9 --- /dev/null +++ b/packages/server/src/utilities/tests/utils.spec.ts @@ -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) + }) +})