Merge branch 'fix/relationship-picker-filtering' of github.com:Budibase/budibase into fix/relationship-picker-filtering
This commit is contained in:
commit
e8017e8fab
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.13.14",
|
||||
"version": "2.13.15",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
|
|
|
@ -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)
|
||||
if (
|
||||
isJS ||
|
||||
shouldReplaceBinding(newBoundValue, from, convertTo, binding)
|
||||
) {
|
||||
let idx
|
||||
do {
|
||||
// see if any instances of this binding exist in the search string
|
||||
|
|
|
@ -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 }}.`)
|
||||
})
|
||||
})
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { goto, isActive, params } from "@roxi/routify"
|
||||
import { Layout } from "@budibase/bbui"
|
||||
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
|
||||
import {
|
||||
database,
|
||||
|
@ -21,8 +22,11 @@
|
|||
import IntegrationIcon from "./IntegrationIcon.svelte"
|
||||
import { TableNames } from "constants"
|
||||
import { userSelectedResourceMap } from "builderStore"
|
||||
import { enrichDatasources } from "./datasourceUtils"
|
||||
import { onMount } from "svelte"
|
||||
|
||||
let openDataSources = []
|
||||
export let searchTerm
|
||||
let toggledDatasources = {}
|
||||
|
||||
$: enrichedDataSources = enrichDatasources(
|
||||
$datasources,
|
||||
|
@ -32,52 +36,9 @@
|
|||
$queries,
|
||||
$views,
|
||||
$viewsV2,
|
||||
openDataSources
|
||||
toggledDatasources,
|
||||
searchTerm
|
||||
)
|
||||
$: openDataSource = enrichedDataSources.find(x => x.open)
|
||||
$: {
|
||||
// Ensure the open datasource is always actually open
|
||||
if (openDataSource) {
|
||||
openNode(openDataSource)
|
||||
}
|
||||
}
|
||||
|
||||
const enrichDatasources = (
|
||||
datasources,
|
||||
params,
|
||||
isActive,
|
||||
tables,
|
||||
queries,
|
||||
views,
|
||||
viewsV2,
|
||||
openDataSources
|
||||
) => {
|
||||
if (!datasources?.list?.length) {
|
||||
return []
|
||||
}
|
||||
return datasources.list.map(datasource => {
|
||||
const selected =
|
||||
isActive("./datasource") &&
|
||||
datasources.selectedDatasourceId === datasource._id
|
||||
const open = openDataSources.includes(datasource._id)
|
||||
const containsSelected = containsActiveEntity(
|
||||
datasource,
|
||||
params,
|
||||
isActive,
|
||||
tables,
|
||||
queries,
|
||||
views,
|
||||
viewsV2
|
||||
)
|
||||
const onlySource = datasources.list.length === 1
|
||||
return {
|
||||
...datasource,
|
||||
selected,
|
||||
containsSelected,
|
||||
open: selected || open || containsSelected || onlySource,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function selectDatasource(datasource) {
|
||||
openNode(datasource)
|
||||
|
@ -91,102 +52,42 @@
|
|||
}
|
||||
}
|
||||
|
||||
function closeNode(datasource) {
|
||||
openDataSources = openDataSources.filter(id => datasource._id !== id)
|
||||
}
|
||||
|
||||
function openNode(datasource) {
|
||||
if (!openDataSources.includes(datasource._id)) {
|
||||
openDataSources = [...openDataSources, datasource._id]
|
||||
}
|
||||
toggledDatasources[datasource._id] = true
|
||||
}
|
||||
|
||||
function toggleNode(datasource) {
|
||||
const isOpen = openDataSources.includes(datasource._id)
|
||||
if (isOpen) {
|
||||
closeNode(datasource)
|
||||
} else {
|
||||
openNode(datasource)
|
||||
}
|
||||
toggledDatasources[datasource._id] = !datasource.open
|
||||
}
|
||||
|
||||
const containsActiveEntity = (
|
||||
datasource,
|
||||
params,
|
||||
isActive,
|
||||
tables,
|
||||
queries,
|
||||
views,
|
||||
viewsV2
|
||||
) => {
|
||||
// Check for being on a datasource page
|
||||
if (params.datasourceId === datasource._id) {
|
||||
return true
|
||||
}
|
||||
const appUsersTableName = "App users"
|
||||
$: showAppUsersTable =
|
||||
!searchTerm ||
|
||||
appUsersTableName.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1
|
||||
|
||||
// Check for hardcoded datasource edge cases
|
||||
if (
|
||||
isActive("./datasource/bb_internal") &&
|
||||
datasource._id === "bb_internal"
|
||||
) {
|
||||
return true
|
||||
onMount(() => {
|
||||
if ($tables.selected) {
|
||||
toggledDatasources[$tables.selected.sourceId] = 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
|
||||
}
|
||||
$: showNoResults =
|
||||
searchTerm && !showAppUsersTable && !enrichedDataSources.find(ds => ds.show)
|
||||
</script>
|
||||
|
||||
{#if $database?._id}
|
||||
<div class="hierarchy-items-container">
|
||||
{#if showAppUsersTable}
|
||||
<NavItem
|
||||
icon="UserGroup"
|
||||
text="App users"
|
||||
text={appUsersTableName}
|
||||
selected={$isActive("./table/:tableId") &&
|
||||
$tables.selected?._id === TableNames.USERS}
|
||||
on:click={() => selectTable(TableNames.USERS)}
|
||||
selectedBy={$userSelectedResourceMap[TableNames.USERS]}
|
||||
/>
|
||||
{#each enrichedDataSources as datasource}
|
||||
{/if}
|
||||
{#each enrichedDataSources.filter(ds => ds.show) as datasource}
|
||||
<NavItem
|
||||
border
|
||||
text={datasource.name}
|
||||
|
@ -210,8 +111,8 @@
|
|||
</NavItem>
|
||||
|
||||
{#if datasource.open}
|
||||
<TableNavigator sourceId={datasource._id} {selectTable} />
|
||||
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query}
|
||||
<TableNavigator tables={datasource.tables} {selectTable} />
|
||||
{#each datasource.queries as query}
|
||||
<NavItem
|
||||
indentLevel={1}
|
||||
icon="SQLQuery"
|
||||
|
@ -228,6 +129,13 @@
|
|||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if showNoResults}
|
||||
<Layout paddingY="none" paddingX="L">
|
||||
<div class="no-results">
|
||||
There aren't any datasources matching that name
|
||||
</div>
|
||||
</Layout>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
@ -240,4 +148,8 @@
|
|||
place-items: center;
|
||||
flex: 0 0 24px;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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: [],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,5 +1,10 @@
|
|||
<script>
|
||||
import { tables, views, viewsV2, database } from "stores/backend"
|
||||
import {
|
||||
tables as tablesStore,
|
||||
views,
|
||||
viewsV2,
|
||||
database,
|
||||
} from "stores/backend"
|
||||
import { TableNames } from "constants"
|
||||
import EditTablePopover from "./popovers/EditTablePopover.svelte"
|
||||
import EditViewPopover from "./popovers/EditViewPopover.svelte"
|
||||
|
@ -7,14 +12,10 @@
|
|||
import { goto, isActive } from "@roxi/routify"
|
||||
import { userSelectedResourceMap } from "builderStore"
|
||||
|
||||
export let sourceId
|
||||
export let tables
|
||||
export let selectTable
|
||||
|
||||
$: sortedTables = $tables.list
|
||||
.filter(
|
||||
table => table.sourceId === sourceId && table._id !== TableNames.USERS
|
||||
)
|
||||
.sort(alphabetical)
|
||||
$: sortedTables = tables.sort(alphabetical)
|
||||
|
||||
const alphabetical = (a, b) => {
|
||||
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
|
||||
|
@ -37,7 +38,7 @@
|
|||
icon={table._id === TableNames.USERS ? "UserGroup" : "Table"}
|
||||
text={table.name}
|
||||
selected={$isActive("./table/:tableId") &&
|
||||
$tables.selected?._id === table._id}
|
||||
$tablesStore.selected?._id === table._id}
|
||||
on:click={() => selectTable(table._id)}
|
||||
selectedBy={$userSelectedResourceMap[table._id]}
|
||||
>
|
||||
|
|
|
@ -0,0 +1,149 @@
|
|||
<script>
|
||||
import { tick } from "svelte"
|
||||
import { Icon, Body } from "@budibase/bbui"
|
||||
import { keyUtils } from "helpers/keyUtils"
|
||||
|
||||
export let title
|
||||
export let placeholder
|
||||
export let value
|
||||
export let onAdd
|
||||
export let search
|
||||
|
||||
let searchInput
|
||||
|
||||
const openSearch = async () => {
|
||||
search = true
|
||||
await tick()
|
||||
searchInput.focus()
|
||||
}
|
||||
|
||||
const closeSearch = async () => {
|
||||
search = false
|
||||
value = ""
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (e.key === "Escape") {
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddButton = () => {
|
||||
if (search) {
|
||||
closeSearch()
|
||||
} else {
|
||||
onAdd()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
|
||||
<div class="header" class:search>
|
||||
<input
|
||||
readonly={!search}
|
||||
bind:value
|
||||
bind:this={searchInput}
|
||||
class="searchBox"
|
||||
class:hide={!search}
|
||||
{placeholder}
|
||||
/>
|
||||
|
||||
<div class="title" class:hide={search}>
|
||||
<Body size="S">{title}</Body>
|
||||
</div>
|
||||
<div
|
||||
on:click={openSearch}
|
||||
on:keydown={keyUtils.handleEnter(openSearch)}
|
||||
class="searchButton"
|
||||
class:hide={search}
|
||||
>
|
||||
<Icon size="S" name="Search" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
on:click={handleAddButton}
|
||||
on:keydown={keyUtils.handleEnter(handleAddButton)}
|
||||
class="addButton"
|
||||
class:rotate={search}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.search {
|
||||
transition: height 300ms ease-out;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
flex-direction: row;
|
||||
position: relative;
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-bottom 130ms ease-out;
|
||||
gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--ink);
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
font-size: var(--spectrum-alias-font-size-default);
|
||||
display: flex;
|
||||
}
|
||||
.searchBox:focus {
|
||||
outline: none;
|
||||
}
|
||||
.searchBox::placeholder {
|
||||
color: var(--spectrum-global-color-gray-600);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
opacity: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.searchButton {
|
||||
color: var(--grey-7);
|
||||
cursor: pointer;
|
||||
opacity: 1;
|
||||
}
|
||||
.searchButton:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.hide {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.addButton {
|
||||
display: flex;
|
||||
transition: transform 300ms ease-out;
|
||||
color: var(--grey-7);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addButton:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.rotate {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
|
@ -189,6 +189,7 @@
|
|||
flex: 0 0 20px;
|
||||
pointer-events: all;
|
||||
order: 0;
|
||||
transition: transform 100ms linear;
|
||||
}
|
||||
.icon.arrow.absolute {
|
||||
position: absolute;
|
||||
|
|
|
@ -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
|
||||
>
|
||||
<div class="header" class:custom={customHeaderContent}>
|
||||
<div
|
||||
class="header"
|
||||
class:custom={customHeaderContent}
|
||||
class:borderBottom={borderBottomHeader}
|
||||
>
|
||||
{#if showBackButton}
|
||||
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
|
||||
{/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;
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
function handleEnter(fnc) {
|
||||
return e => e.key === "Enter" && fnc()
|
||||
}
|
||||
|
||||
export const keyUtils = {
|
||||
handleEnter,
|
||||
}
|
|
@ -1,9 +1,12 @@
|
|||
<script>
|
||||
import { Button, Layout } from "@budibase/bbui"
|
||||
import { Layout } from "@budibase/bbui"
|
||||
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
|
||||
import Panel from "components/design/Panel.svelte"
|
||||
import { isActive, redirect, goto, params } from "@roxi/routify"
|
||||
import { datasources } from "stores/backend"
|
||||
import NavHeader from "components/common/NavHeader.svelte"
|
||||
|
||||
let searchValue
|
||||
|
||||
$: {
|
||||
// If we ever don't have any data other than the users table, prompt the
|
||||
|
@ -18,10 +21,17 @@
|
|||
<!-- routify:options index=1 -->
|
||||
<div class="data">
|
||||
{#if !$isActive("./new")}
|
||||
<Panel title="Sources" borderRight>
|
||||
<Layout paddingX="L" paddingY="XL" gap="S">
|
||||
<Button cta on:click={() => $goto("./new")}>Add source</Button>
|
||||
<DatasourceNavigator />
|
||||
<Panel borderRight borderBottomHeader={false}>
|
||||
<span class="panel-title-content" slot="panel-title-content">
|
||||
<NavHeader
|
||||
title="Sources"
|
||||
placeholder="Search for sources"
|
||||
bind:value={searchValue}
|
||||
onAdd={() => $goto("./new")}
|
||||
/>
|
||||
</span>
|
||||
<Layout paddingX="L" paddingY="none" gap="S">
|
||||
<DatasourceNavigator searchTerm={searchValue} />
|
||||
</Layout>
|
||||
</Panel>
|
||||
{/if}
|
||||
|
@ -51,4 +61,8 @@
|
|||
flex: 1 1 auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.panel-title-content {
|
||||
display: contents;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { Icon, Layout, Body } from "@budibase/bbui"
|
||||
import { Layout } from "@budibase/bbui"
|
||||
import {
|
||||
store,
|
||||
sortedScreens,
|
||||
|
@ -9,13 +9,14 @@
|
|||
import NavItem from "components/common/NavItem.svelte"
|
||||
import RoleIndicator from "./RoleIndicator.svelte"
|
||||
import DropdownMenu from "./DropdownMenu.svelte"
|
||||
import { onMount, tick } from "svelte"
|
||||
import { onMount } from "svelte"
|
||||
import { goto } from "@roxi/routify"
|
||||
import NavHeader from "components/common/NavHeader.svelte"
|
||||
|
||||
let search = false
|
||||
let resizing = false
|
||||
let searchValue = ""
|
||||
let searchInput
|
||||
|
||||
let container
|
||||
let screensContainer
|
||||
let scrolling = false
|
||||
|
@ -26,10 +27,9 @@
|
|||
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
$: search ? openSearch() : closeSearch()
|
||||
|
||||
const openSearch = async () => {
|
||||
search = true
|
||||
await tick()
|
||||
searchInput.focus()
|
||||
screensContainer.scroll({ top: 0, behavior: "smooth" })
|
||||
previousHeight = $screensHeight
|
||||
$screensHeight = "calc(100% + 1px)"
|
||||
|
@ -42,8 +42,6 @@
|
|||
previousHeight = null
|
||||
await sleep(300)
|
||||
}
|
||||
search = false
|
||||
searchValue = ""
|
||||
}
|
||||
|
||||
const getFilteredScreens = (screens, search) => {
|
||||
|
@ -52,20 +50,6 @@
|
|||
})
|
||||
}
|
||||
|
||||
const handleAddButton = () => {
|
||||
if (search) {
|
||||
closeSearch()
|
||||
} else {
|
||||
$goto("../new")
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = e => {
|
||||
if (e.key === "Escape") {
|
||||
closeSearch()
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = e => {
|
||||
scrolling = e.target.scrollTop !== 0
|
||||
}
|
||||
|
@ -105,7 +89,7 @@
|
|||
})
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
<svelte:window />
|
||||
<div
|
||||
class="screens"
|
||||
class:search
|
||||
|
@ -114,26 +98,13 @@
|
|||
bind:this={container}
|
||||
>
|
||||
<div class="header" class:scrolling>
|
||||
<input
|
||||
readonly={!search}
|
||||
bind:value={searchValue}
|
||||
bind:this={searchInput}
|
||||
class="input"
|
||||
<NavHeader
|
||||
title="Screens"
|
||||
placeholder="Search for screens"
|
||||
bind:value={searchValue}
|
||||
bind:search
|
||||
onAdd={() => $goto("../new")}
|
||||
/>
|
||||
<div class="title" class:hide={search}>
|
||||
<Body size="S">Screens</Body>
|
||||
</div>
|
||||
<div on:click={openSearch} class="searchButton" class:hide={search}>
|
||||
<Icon size="S" name="Search" />
|
||||
</div>
|
||||
<div
|
||||
on:click={handleAddButton}
|
||||
class="addButton"
|
||||
class:closeButton={search}
|
||||
>
|
||||
<Icon name="Add" />
|
||||
</div>
|
||||
</div>
|
||||
<div on:scroll={handleScroll} bind:this={screensContainer} class="content">
|
||||
{#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;
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
<script>
|
||||
import { Icon, Body } from "@budibase/bbui"
|
||||
import { apps, sideBarCollapsed } from "stores/portal"
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import { tick } from "svelte"
|
||||
import NavItem from "components/common/NavItem.svelte"
|
||||
import NavHeader from "components/common/NavHeader.svelte"
|
||||
|
||||
let searchInput
|
||||
let searchString
|
||||
let searching = false
|
||||
|
||||
$: filteredApps = $apps
|
||||
.filter(app => {
|
||||
|
@ -21,39 +18,16 @@
|
|||
const lowerB = b.name.toLowerCase()
|
||||
return lowerA > lowerB ? 1 : -1
|
||||
})
|
||||
|
||||
const startSearching = async () => {
|
||||
searching = true
|
||||
searchString = ""
|
||||
await tick()
|
||||
searchInput.focus()
|
||||
}
|
||||
|
||||
const stopSearching = () => {
|
||||
searching = false
|
||||
searchString = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="side-bar" class:collapsed={$sideBarCollapsed}>
|
||||
<div class="side-bar-controls">
|
||||
{#if searching}
|
||||
<input
|
||||
bind:this={searchInput}
|
||||
bind:value={searchString}
|
||||
<NavHeader
|
||||
title="Apps"
|
||||
placeholder="Search for apps"
|
||||
bind:value={searchString}
|
||||
onAdd={() => $goto("./create")}
|
||||
/>
|
||||
{:else}
|
||||
<Body size="S">Apps</Body>
|
||||
<Icon name="Search" size="S" hoverable on:click={startSearching} />
|
||||
{/if}
|
||||
<div class="rotational" class:rotated={searching}>
|
||||
<Icon
|
||||
name="Add"
|
||||
hoverable
|
||||
on:click={searching ? stopSearching : () => $goto("./create")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="side-bar-nav">
|
||||
<NavItem
|
||||
|
@ -103,44 +77,13 @@
|
|||
gap: var(--spacing-l);
|
||||
padding: 0 var(--spacing-l);
|
||||
}
|
||||
.side-bar-controls :global(.spectrum-Body),
|
||||
.side-bar-controls input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.side-bar-controls :global(.spectrum-Icon) {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
}
|
||||
|
||||
input {
|
||||
outline: none;
|
||||
border: none;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
transition: border 130ms ease-out;
|
||||
font-family: var(--font-sans);
|
||||
background: inherit;
|
||||
}
|
||||
input::placeholder {
|
||||
color: var(--spectrum-global-color-gray-700);
|
||||
transition: color 130ms ease-out;
|
||||
}
|
||||
input:hover::placeholder {
|
||||
color: var(--spectrum-global-color-gray-800);
|
||||
}
|
||||
|
||||
.side-bar-nav {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
div.rotational {
|
||||
transition: transform 130ms ease-out;
|
||||
}
|
||||
div.rotational.rotated {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -426,7 +426,7 @@ describe.each([
|
|||
const saved = (await loadRow(id, table._id!)).body
|
||||
|
||||
expect(saved.stringUndefined).toBe(undefined)
|
||||
expect(saved.stringNull).toBe("")
|
||||
expect(saved.stringNull).toBe(null)
|
||||
expect(saved.stringString).toBe("i am a string")
|
||||
expect(saved.numberEmptyString).toBe(null)
|
||||
expect(saved.numberNull).toBe(null)
|
||||
|
|
|
@ -46,23 +46,23 @@ export const TYPE_TRANSFORM_MAP: any = {
|
|||
parse: parseArrayString,
|
||||
},
|
||||
[FieldTypes.STRING]: {
|
||||
"": "",
|
||||
[null]: "",
|
||||
"": null,
|
||||
[null]: null,
|
||||
[undefined]: undefined,
|
||||
},
|
||||
[FieldTypes.BARCODEQR]: {
|
||||
"": "",
|
||||
[null]: "",
|
||||
"": null,
|
||||
[null]: null,
|
||||
[undefined]: undefined,
|
||||
},
|
||||
[FieldTypes.FORMULA]: {
|
||||
"": "",
|
||||
[null]: "",
|
||||
"": null,
|
||||
[null]: null,
|
||||
[undefined]: undefined,
|
||||
},
|
||||
[FieldTypes.LONGFORM]: {
|
||||
"": "",
|
||||
[null]: "",
|
||||
"": null,
|
||||
[null]: null,
|
||||
[undefined]: undefined,
|
||||
},
|
||||
[FieldTypes.NUMBER]: {
|
||||
|
@ -71,6 +71,11 @@ export const TYPE_TRANSFORM_MAP: any = {
|
|||
[undefined]: undefined,
|
||||
parse: n => parseFloat(n),
|
||||
},
|
||||
[FieldTypes.BIGINT]: {
|
||||
"": null,
|
||||
[null]: null,
|
||||
[undefined]: undefined,
|
||||
},
|
||||
[FieldTypes.DATETIME]: {
|
||||
"": null,
|
||||
[undefined]: undefined,
|
||||
|
|
Loading…
Reference in New Issue