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 @@ + + + +
+ + +
+ {title} +
+
+ +
+ +
+ +
+
+ + 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 > -
+
{#if showBackButton} {/if} @@ -94,9 +99,11 @@ justify-content: space-between; align-items: center; padding: 0 var(--spacing-l); - border-bottom: var(--border-light); gap: var(--spacing-m); } + .header.borderBottom { + border-bottom: var(--border-light); + } .title { flex: 1 1 auto; width: 0; diff --git a/packages/builder/src/helpers/keyUtils.js b/packages/builder/src/helpers/keyUtils.js new file mode 100644 index 0000000000..8d6dfb06dc --- /dev/null +++ b/packages/builder/src/helpers/keyUtils.js @@ -0,0 +1,7 @@ +function handleEnter(fnc) { + return e => e.key === "Enter" && fnc() +} + +export const keyUtils = { + handleEnter, +} diff --git a/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte b/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte index d89f09fc08..7dea0078fd 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/_layout.svelte @@ -1,9 +1,12 @@ - +
- $goto("../new")} /> -
- Screens -
-
- -
-
- -
{#if filteredScreens?.length} @@ -177,9 +148,9 @@ min-height: 147px; max-height: calc(100% - 147px); position: relative; + transition: height 300ms ease-out; } .screens.search { - transition: height 300ms ease-out; max-height: none; } .screens.resizing { @@ -202,37 +173,6 @@ border-bottom: var(--border-light); } - .input { - font-family: var(--font-sans); - position: absolute; - color: var(--ink); - background-color: transparent; - border: none; - font-size: var(--spectrum-alias-font-size-default); - width: 260px; - box-sizing: border-box; - display: none; - } - .input:focus { - outline: none; - } - .input::placeholder { - color: var(--spectrum-global-color-gray-600); - } - .screens.search input { - display: block; - } - - .title { - display: flex; - align-items: center; - height: 100%; - box-sizing: border-box; - flex: 1; - opacity: 1; - z-index: 1; - } - .content { overflow: auto; flex-grow: 1; @@ -245,34 +185,6 @@ padding-right: 8px !important; } - .searchButton { - color: var(--grey-7); - cursor: pointer; - margin-right: 10px; - opacity: 1; - } - .searchButton:hover { - color: var(--ink); - } - - .hide { - opacity: 0; - pointer-events: none; - } - - .addButton { - color: var(--grey-7); - cursor: pointer; - transition: transform 300ms ease-out; - } - .addButton:hover { - color: var(--ink); - } - - .closeButton { - transform: rotate(45deg); - } - .icon { margin-left: 4px; margin-right: 4px; diff --git a/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte b/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte index 7989c5f1a8..3ece483425 100644 --- a/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte +++ b/packages/builder/src/pages/builder/portal/apps/_components/PortalSideBar.svelte @@ -1,13 +1,10 @@