diff --git a/lerna.json b/lerna.json
index c2d038db02..faba64ce90 100644
--- a/lerna.json
+++ b/lerna.json
@@ -1,5 +1,5 @@
{
- "version": "2.13.14",
+ "version": "2.13.15",
"npmClient": "yarn",
"packages": [
"packages/*"
diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js
index 246590a22e..85ac822006 100644
--- a/packages/builder/src/builderStore/dataBinding.js
+++ b/packages/builder/src/builderStore/dataBinding.js
@@ -1090,17 +1090,18 @@ export const removeBindings = (obj, replacement = "Invalid binding") => {
* When converting from readable to runtime it can sometimes add too many square brackets,
* this makes sure that doesn't happen.
*/
-const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => {
- if (!currentValue?.includes(convertFrom)) {
+const shouldReplaceBinding = (currentValue, from, convertTo, binding) => {
+ if (!currentValue?.includes(from)) {
return false
}
if (convertTo === "readableBinding") {
- return true
+ // Dont replace if the value already matches the readable binding
+ return currentValue.indexOf(binding.readableBinding) === -1
}
// remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected
const noSpaces = currentValue.replace(/\s+/g, "")
- const fromNoSpaces = convertFrom.replace(/\s+/g, "")
+ const fromNoSpaces = from.replace(/\s+/g, "")
const invalids = [
`[${fromNoSpaces}]`,
`"${fromNoSpaces}"`,
@@ -1152,8 +1153,11 @@ const bindingReplacement = (
// in the search, working from longest to shortest so always use best match first
let searchString = newBoundValue
for (let from of convertFromProps) {
- if (isJS || shouldReplaceBinding(newBoundValue, from, convertTo)) {
- const binding = bindableProperties.find(el => el[convertFrom] === from)
+ const binding = bindableProperties.find(el => el[convertFrom] === from)
+ if (
+ isJS ||
+ shouldReplaceBinding(newBoundValue, from, convertTo, binding)
+ ) {
let idx
do {
// see if any instances of this binding exist in the search string
diff --git a/packages/builder/src/builderStore/tests/dataBinding.test.js b/packages/builder/src/builderStore/tests/dataBinding.test.js
new file mode 100644
index 0000000000..47f6564749
--- /dev/null
+++ b/packages/builder/src/builderStore/tests/dataBinding.test.js
@@ -0,0 +1,86 @@
+import { expect, describe, it, vi } from "vitest"
+import {
+ runtimeToReadableBinding,
+ readableToRuntimeBinding,
+} from "../dataBinding"
+
+vi.mock("@budibase/frontend-core")
+vi.mock("builderStore/componentUtils")
+vi.mock("builderStore/store")
+vi.mock("builderStore/store/theme")
+vi.mock("builderStore/store/temporal")
+
+describe("runtimeToReadableBinding", () => {
+ const bindableProperties = [
+ {
+ category: "Current User",
+ icon: "User",
+ providerId: "user",
+ readableBinding: "Current User.firstName",
+ runtimeBinding: "[user].[firstName]",
+ type: "context",
+ },
+ {
+ category: "Bindings",
+ icon: "Brackets",
+ readableBinding: "Binding.count",
+ runtimeBinding: "count",
+ type: "context",
+ },
+ ]
+ it("should convert a runtime binding to a readable one", () => {
+ const textWithBindings = `Hello {{ [user].[firstName] }}! The count is {{ count }}.`
+ expect(
+ runtimeToReadableBinding(
+ bindableProperties,
+ textWithBindings,
+ "readableBinding"
+ )
+ ).toEqual(
+ `Hello {{ Current User.firstName }}! The count is {{ Binding.count }}.`
+ )
+ })
+
+ it("should not convert to readable binding if it is already readable", () => {
+ const textWithBindings = `Hello {{ [user].[firstName] }}! The count is {{ Binding.count }}.`
+ expect(
+ runtimeToReadableBinding(
+ bindableProperties,
+ textWithBindings,
+ "readableBinding"
+ )
+ ).toEqual(
+ `Hello {{ Current User.firstName }}! The count is {{ Binding.count }}.`
+ )
+ })
+})
+
+describe("readableToRuntimeBinding", () => {
+ const bindableProperties = [
+ {
+ category: "Current User",
+ icon: "User",
+ providerId: "user",
+ readableBinding: "Current User.firstName",
+ runtimeBinding: "[user].[firstName]",
+ type: "context",
+ },
+ {
+ category: "Bindings",
+ icon: "Brackets",
+ readableBinding: "Binding.count",
+ runtimeBinding: "count",
+ type: "context",
+ },
+ ]
+ it("should convert a readable binding to a runtime one", () => {
+ const textWithBindings = `Hello {{ Current User.firstName }}! The count is {{ Binding.count }}.`
+ expect(
+ readableToRuntimeBinding(
+ bindableProperties,
+ textWithBindings,
+ "runtimeBinding"
+ )
+ ).toEqual(`Hello {{ [user].[firstName] }}! The count is {{ count }}.`)
+ })
+})
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte
index 0cc61c69e6..23697bf2c7 100644
--- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte
+++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte
@@ -1,5 +1,6 @@
{#if $database?._id}
-
selectTable(TableNames.USERS)}
- selectedBy={$userSelectedResourceMap[TableNames.USERS]}
- />
- {#each enrichedDataSources as datasource}
+ {#if showAppUsersTable}
+ selectTable(TableNames.USERS)}
+ selectedBy={$userSelectedResourceMap[TableNames.USERS]}
+ />
+ {/if}
+ {#each enrichedDataSources.filter(ds => ds.show) as datasource}
{#if datasource.open}
-
- {#each $queries.list.filter(query => query.datasourceId === datasource._id) as query}
+
+ {#each datasource.queries as query}
+
+ There aren't any datasources matching that name
+
+
+ {/if}
{/if}
@@ -240,4 +148,8 @@
place-items: center;
flex: 0 0 24px;
}
+
+ .no-results {
+ color: var(--spectrum-global-color-gray-600);
+ }
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/datasourceUtils.js b/packages/builder/src/components/backend/DatasourceNavigator/datasourceUtils.js
new file mode 100644
index 0000000000..bc7fa53b49
--- /dev/null
+++ b/packages/builder/src/components/backend/DatasourceNavigator/datasourceUtils.js
@@ -0,0 +1,181 @@
+import { TableNames } from "constants"
+
+const showDatasourceOpen = ({
+ selected,
+ containsSelected,
+ dsToggledStatus,
+ searchTerm,
+ onlyOneSource,
+}) => {
+ // We want to display all the ds expanded while filtering ds
+ if (searchTerm) {
+ return true
+ }
+
+ // If the toggle status has been a value
+ if (dsToggledStatus !== undefined) {
+ return dsToggledStatus
+ }
+
+ if (onlyOneSource) {
+ return true
+ }
+
+ return selected || containsSelected
+}
+
+const containsActiveEntity = (
+ datasource,
+ params,
+ isActive,
+ tables,
+ queries,
+ views,
+ viewsV2
+) => {
+ // Check for being on a datasource page
+ if (params.datasourceId === datasource._id) {
+ return true
+ }
+
+ // Check for hardcoded datasource edge cases
+ if (
+ isActive("./datasource/bb_internal") &&
+ datasource._id === "bb_internal"
+ ) {
+ return true
+ }
+ if (
+ isActive("./datasource/datasource_internal_bb_default") &&
+ datasource._id === "datasource_internal_bb_default"
+ ) {
+ return true
+ }
+
+ // Check for a matching query
+ if (params.queryId) {
+ const query = queries.list?.find(q => q._id === params.queryId)
+ return datasource._id === query?.datasourceId
+ }
+
+ // If there are no entities it can't contain anything
+ if (!datasource.entities) {
+ return false
+ }
+
+ // Get a list of table options
+ let options = datasource.entities
+ if (!Array.isArray(options)) {
+ options = Object.values(options)
+ }
+
+ // Check for a matching table
+ if (params.tableId) {
+ const selectedTable = tables.selected?._id
+ return options.find(x => x._id === selectedTable) != null
+ }
+
+ // Check for a matching view
+ const selectedView = views.selected?.name
+ const viewTable = options.find(table => {
+ return table.views?.[selectedView] != null
+ })
+ if (viewTable) {
+ return true
+ }
+
+ // Check for a matching viewV2
+ const viewV2Table = options.find(x => x._id === viewsV2.selected?.tableId)
+ return viewV2Table != null
+}
+
+export const enrichDatasources = (
+ datasources,
+ params,
+ isActive,
+ tables,
+ queries,
+ views,
+ viewsV2,
+ toggledDatasources,
+ searchTerm
+) => {
+ if (!datasources?.list?.length) {
+ return []
+ }
+
+ const onlySource = datasources.list.length === 1
+ return datasources.list.map(datasource => {
+ const selected =
+ isActive("./datasource") &&
+ datasources.selectedDatasourceId === datasource._id
+ const containsSelected = containsActiveEntity(
+ datasource,
+ params,
+ isActive,
+ tables,
+ queries,
+ views,
+ viewsV2
+ )
+
+ const dsTables = tables.list.filter(
+ table =>
+ table.sourceId === datasource._id && table._id !== TableNames.USERS
+ )
+ const dsQueries = queries.list.filter(
+ query => query.datasourceId === datasource._id
+ )
+
+ const open = showDatasourceOpen({
+ selected,
+ containsSelected,
+ dsToggledStatus: toggledDatasources[datasource._id],
+ searchTerm,
+ onlyOneSource: onlySource,
+ })
+
+ const visibleDsQueries = dsQueries.filter(
+ q =>
+ !searchTerm ||
+ q.name?.toLowerCase()?.indexOf(searchTerm.toLowerCase()) > -1
+ )
+
+ const visibleDsTables = dsTables
+ .map(t => ({
+ ...t,
+ views: !searchTerm
+ ? t.views
+ : Object.keys(t.views || {})
+ .filter(
+ viewName =>
+ viewName.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1
+ )
+ .reduce(
+ (acc, viewName) => ({ ...acc, [viewName]: t.views[viewName] }),
+ {}
+ ),
+ }))
+ .filter(
+ table =>
+ !searchTerm ||
+ table.name?.toLowerCase()?.indexOf(searchTerm.toLowerCase()) > -1 ||
+ Object.keys(table.views).length
+ )
+
+ const show = !!(
+ !searchTerm ||
+ visibleDsQueries.length ||
+ visibleDsTables.length
+ )
+ return {
+ ...datasource,
+ selected,
+ containsSelected,
+ open,
+ queries: visibleDsQueries,
+ tables: visibleDsTables,
+ show,
+ }
+ })
+}
diff --git a/packages/builder/src/components/backend/DatasourceNavigator/tests/datasourceUtils.spec.js b/packages/builder/src/components/backend/DatasourceNavigator/tests/datasourceUtils.spec.js
new file mode 100644
index 0000000000..f71c647490
--- /dev/null
+++ b/packages/builder/src/components/backend/DatasourceNavigator/tests/datasourceUtils.spec.js
@@ -0,0 +1,219 @@
+import { enrichDatasources } from "../datasourceUtils"
+
+describe("datasourceUtils", () => {
+ describe("enrichDatasources", () => {
+ it.each([
+ ["undefined", undefined],
+ ["undefined list", {}],
+ ["empty list", { list: [] }],
+ ])("%s datasources will return an empty list", datasources => {
+ const result = enrichDatasources(datasources)
+
+ expect(result).toEqual([])
+ })
+
+ describe("filtering", () => {
+ const internalTables = {
+ _id: "datasource_internal_bb_default",
+ name: "Sample Data",
+ }
+
+ const pgDatasource = {
+ _id: "pg_ds",
+ name: "PostgreSQL local",
+ }
+
+ const mysqlDatasource = {
+ _id: "mysql_ds",
+ name: "My SQL local",
+ }
+
+ const tables = [
+ ...[
+ {
+ _id: "ta_bb_employee",
+ name: "Employees",
+ },
+ {
+ _id: "ta_bb_expenses",
+ name: "Expenses",
+ },
+ {
+ _id: "ta_bb_expenses_2",
+ name: "Expenses 2",
+ },
+ {
+ _id: "ta_bb_inventory",
+ name: "Inventory",
+ },
+ {
+ _id: "ta_bb_jobs",
+ name: "Jobs",
+ },
+ ].map(t => ({
+ ...t,
+ sourceId: internalTables._id,
+ })),
+ ...[
+ {
+ _id: "pg_ds-external_inventory",
+ name: "External Inventory",
+ views: {
+ "External Inventory first view": {
+ name: "External Inventory first view",
+ id: "pg_ds_view_1",
+ },
+ "External Inventory second view": {
+ name: "External Inventory second view",
+ id: "pg_ds_view_2",
+ },
+ },
+ },
+ {
+ _id: "pg_ds-another_table",
+ name: "Another table",
+ views: {
+ view1: {
+ id: "pg_ds-another_table-view1",
+ name: "view1",
+ },
+ ["View 2"]: {
+ id: "pg_ds-another_table-view2",
+ name: "View 2",
+ },
+ },
+ },
+ {
+ _id: "pg_ds_table2",
+ name: "table2",
+ views: {
+ "new 2": {
+ name: "new 2",
+ id: "pg_ds_table2_new_2",
+ },
+ new: {
+ name: "new",
+ id: "pg_ds_table2_new_",
+ },
+ },
+ },
+ ].map(t => ({
+ ...t,
+ sourceId: pgDatasource._id,
+ })),
+ ...[
+ {
+ _id: "mysql_ds-mysql_table",
+ name: "MySQL table",
+ },
+ ].map(t => ({
+ ...t,
+ sourceId: mysqlDatasource._id,
+ })),
+ ]
+
+ const datasources = {
+ list: [internalTables, pgDatasource, mysqlDatasource],
+ }
+ const isActive = vi.fn().mockReturnValue(true)
+
+ it("without a search term, all datasources are returned", () => {
+ const searchTerm = ""
+
+ const result = enrichDatasources(
+ datasources,
+ {},
+ isActive,
+ { list: [] },
+ { list: [] },
+ { list: [] },
+ { list: [] },
+ {},
+ searchTerm
+ )
+
+ expect(result).toEqual(
+ datasources.list.map(d =>
+ expect.objectContaining({
+ _id: d._id,
+ show: true,
+ })
+ )
+ )
+ })
+
+ it("given a valid search term, all tables are correctly filtered", () => {
+ const searchTerm = "ex"
+
+ const result = enrichDatasources(
+ datasources,
+ {},
+ isActive,
+ { list: tables },
+ { list: [] },
+ { list: [] },
+ { list: [] },
+ {},
+ searchTerm
+ )
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ _id: internalTables._id,
+ show: true,
+ tables: [
+ expect.objectContaining({ _id: "ta_bb_expenses" }),
+ expect.objectContaining({ _id: "ta_bb_expenses_2" }),
+ ],
+ }),
+ expect.objectContaining({
+ _id: pgDatasource._id,
+ show: true,
+ tables: [
+ expect.objectContaining({ _id: "pg_ds-external_inventory" }),
+ ],
+ }),
+ expect.objectContaining({
+ _id: mysqlDatasource._id,
+ show: false,
+ tables: [],
+ }),
+ ])
+ })
+
+ it("given a non matching search term, all entities are empty", () => {
+ const searchTerm = "non matching"
+
+ const result = enrichDatasources(
+ datasources,
+ {},
+ isActive,
+ { list: tables },
+ { list: [] },
+ { list: [] },
+ { list: [] },
+ {},
+ searchTerm
+ )
+
+ expect(result).toEqual([
+ expect.objectContaining({
+ _id: internalTables._id,
+ show: false,
+ tables: [],
+ }),
+ expect.objectContaining({
+ _id: pgDatasource._id,
+ show: false,
+ tables: [],
+ }),
+ expect.objectContaining({
+ _id: mysqlDatasource._id,
+ show: false,
+ tables: [],
+ }),
+ ])
+ })
+ })
+ })
+})
diff --git a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte
index 712d74889c..33bcb56c98 100644
--- a/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte
+++ b/packages/builder/src/components/backend/TableNavigator/TableNavigator.svelte
@@ -1,5 +1,10 @@
+
+
+
+
+
+
diff --git a/packages/builder/src/components/common/NavItem.svelte b/packages/builder/src/components/common/NavItem.svelte
index 2c8a862535..1c9267ca18 100644
--- a/packages/builder/src/components/common/NavItem.svelte
+++ b/packages/builder/src/components/common/NavItem.svelte
@@ -189,6 +189,7 @@
flex: 0 0 20px;
pointer-events: all;
order: 0;
+ transition: transform 100ms linear;
}
.icon.arrow.absolute {
position: absolute;
diff --git a/packages/builder/src/components/design/Panel.svelte b/packages/builder/src/components/design/Panel.svelte
index 3d5938c174..c0b752d013 100644
--- a/packages/builder/src/components/design/Panel.svelte
+++ b/packages/builder/src/components/design/Panel.svelte
@@ -11,6 +11,7 @@
export let onClickCloseButton
export let borderLeft = false
export let borderRight = false
+ export let borderBottomHeader = true
export let wide = false
export let extraWide = false
export let closeButtonIcon = "Close"
@@ -26,7 +27,11 @@
class:borderLeft
class:borderRight
>
-