Merge branch 'fix/relationship-picker-filtering' of github.com:Budibase/budibase into fix/relationship-picker-filtering

This commit is contained in:
Andrew Kingston 2023-11-28 09:41:58 +00:00
commit e8017e8fab
16 changed files with 768 additions and 327 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "2.13.14", "version": "2.13.15",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -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, * When converting from readable to runtime it can sometimes add too many square brackets,
* this makes sure that doesn't happen. * this makes sure that doesn't happen.
*/ */
const shouldReplaceBinding = (currentValue, convertFrom, convertTo) => { const shouldReplaceBinding = (currentValue, from, convertTo, binding) => {
if (!currentValue?.includes(convertFrom)) { if (!currentValue?.includes(from)) {
return false return false
} }
if (convertTo === "readableBinding") { 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 // remove all the spaces, if the input is surrounded by spaces e.g. [ Auto ID ] then
// this makes sure it is detected // this makes sure it is detected
const noSpaces = currentValue.replace(/\s+/g, "") const noSpaces = currentValue.replace(/\s+/g, "")
const fromNoSpaces = convertFrom.replace(/\s+/g, "") const fromNoSpaces = from.replace(/\s+/g, "")
const invalids = [ const invalids = [
`[${fromNoSpaces}]`, `[${fromNoSpaces}]`,
`"${fromNoSpaces}"`, `"${fromNoSpaces}"`,
@ -1152,8 +1153,11 @@ const bindingReplacement = (
// in the search, working from longest to shortest so always use best match first // in the search, working from longest to shortest so always use best match first
let searchString = newBoundValue let searchString = newBoundValue
for (let from of convertFromProps) { 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 let idx
do { do {
// see if any instances of this binding exist in the search string // see if any instances of this binding exist in the search string

View File

@ -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 }}.`)
})
})

View File

@ -1,5 +1,6 @@
<script> <script>
import { goto, isActive, params } from "@roxi/routify" import { goto, isActive, params } from "@roxi/routify"
import { Layout } from "@budibase/bbui"
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend" import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
import { import {
database, database,
@ -21,8 +22,11 @@
import IntegrationIcon from "./IntegrationIcon.svelte" import IntegrationIcon from "./IntegrationIcon.svelte"
import { TableNames } from "constants" import { TableNames } from "constants"
import { userSelectedResourceMap } from "builderStore" import { userSelectedResourceMap } from "builderStore"
import { enrichDatasources } from "./datasourceUtils"
import { onMount } from "svelte"
let openDataSources = [] export let searchTerm
let toggledDatasources = {}
$: enrichedDataSources = enrichDatasources( $: enrichedDataSources = enrichDatasources(
$datasources, $datasources,
@ -32,52 +36,9 @@
$queries, $queries,
$views, $views,
$viewsV2, $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) { function selectDatasource(datasource) {
openNode(datasource) openNode(datasource)
@ -91,102 +52,42 @@
} }
} }
function closeNode(datasource) {
openDataSources = openDataSources.filter(id => datasource._id !== id)
}
function openNode(datasource) { function openNode(datasource) {
if (!openDataSources.includes(datasource._id)) { toggledDatasources[datasource._id] = true
openDataSources = [...openDataSources, datasource._id]
}
} }
function toggleNode(datasource) { function toggleNode(datasource) {
const isOpen = openDataSources.includes(datasource._id) toggledDatasources[datasource._id] = !datasource.open
if (isOpen) {
closeNode(datasource)
} else {
openNode(datasource)
}
} }
const containsActiveEntity = ( const appUsersTableName = "App users"
datasource, $: showAppUsersTable =
params, !searchTerm ||
isActive, appUsersTableName.toLowerCase().indexOf(searchTerm.toLowerCase()) > -1
tables,
queries,
views,
viewsV2
) => {
// Check for being on a datasource page
if (params.datasourceId === datasource._id) {
return true
}
// Check for hardcoded datasource edge cases onMount(() => {
if ( if ($tables.selected) {
isActive("./datasource/bb_internal") && toggledDatasources[$tables.selected.sourceId] = true
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 $: showNoResults =
if (params.queryId) { searchTerm && !showAppUsersTable && !enrichedDataSources.find(ds => ds.show)
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
}
</script> </script>
{#if $database?._id} {#if $database?._id}
<div class="hierarchy-items-container"> <div class="hierarchy-items-container">
<NavItem {#if showAppUsersTable}
icon="UserGroup" <NavItem
text="App users" icon="UserGroup"
selected={$isActive("./table/:tableId") && text={appUsersTableName}
$tables.selected?._id === TableNames.USERS} selected={$isActive("./table/:tableId") &&
on:click={() => selectTable(TableNames.USERS)} $tables.selected?._id === TableNames.USERS}
selectedBy={$userSelectedResourceMap[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 <NavItem
border border
text={datasource.name} text={datasource.name}
@ -210,8 +111,8 @@
</NavItem> </NavItem>
{#if datasource.open} {#if datasource.open}
<TableNavigator sourceId={datasource._id} {selectTable} /> <TableNavigator tables={datasource.tables} {selectTable} />
{#each $queries.list.filter(query => query.datasourceId === datasource._id) as query} {#each datasource.queries as query}
<NavItem <NavItem
indentLevel={1} indentLevel={1}
icon="SQLQuery" icon="SQLQuery"
@ -228,6 +129,13 @@
{/each} {/each}
{/if} {/if}
{/each} {/each}
{#if showNoResults}
<Layout paddingY="none" paddingX="L">
<div class="no-results">
There aren't any datasources matching that name
</div>
</Layout>
{/if}
</div> </div>
{/if} {/if}
@ -240,4 +148,8 @@
place-items: center; place-items: center;
flex: 0 0 24px; flex: 0 0 24px;
} }
.no-results {
color: var(--spectrum-global-color-gray-600);
}
</style> </style>

View File

@ -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,
}
})
}

View File

@ -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: [],
}),
])
})
})
})
})

View File

@ -1,5 +1,10 @@
<script> <script>
import { tables, views, viewsV2, database } from "stores/backend" import {
tables as tablesStore,
views,
viewsV2,
database,
} from "stores/backend"
import { TableNames } from "constants" import { TableNames } from "constants"
import EditTablePopover from "./popovers/EditTablePopover.svelte" import EditTablePopover from "./popovers/EditTablePopover.svelte"
import EditViewPopover from "./popovers/EditViewPopover.svelte" import EditViewPopover from "./popovers/EditViewPopover.svelte"
@ -7,14 +12,10 @@
import { goto, isActive } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { userSelectedResourceMap } from "builderStore" import { userSelectedResourceMap } from "builderStore"
export let sourceId export let tables
export let selectTable export let selectTable
$: sortedTables = $tables.list $: sortedTables = tables.sort(alphabetical)
.filter(
table => table.sourceId === sourceId && table._id !== TableNames.USERS
)
.sort(alphabetical)
const alphabetical = (a, b) => { const alphabetical = (a, b) => {
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1 return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
@ -37,7 +38,7 @@
icon={table._id === TableNames.USERS ? "UserGroup" : "Table"} icon={table._id === TableNames.USERS ? "UserGroup" : "Table"}
text={table.name} text={table.name}
selected={$isActive("./table/:tableId") && selected={$isActive("./table/:tableId") &&
$tables.selected?._id === table._id} $tablesStore.selected?._id === table._id}
on:click={() => selectTable(table._id)} on:click={() => selectTable(table._id)}
selectedBy={$userSelectedResourceMap[table._id]} selectedBy={$userSelectedResourceMap[table._id]}
> >

View File

@ -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>

View File

@ -189,6 +189,7 @@
flex: 0 0 20px; flex: 0 0 20px;
pointer-events: all; pointer-events: all;
order: 0; order: 0;
transition: transform 100ms linear;
} }
.icon.arrow.absolute { .icon.arrow.absolute {
position: absolute; position: absolute;

View File

@ -11,6 +11,7 @@
export let onClickCloseButton export let onClickCloseButton
export let borderLeft = false export let borderLeft = false
export let borderRight = false export let borderRight = false
export let borderBottomHeader = true
export let wide = false export let wide = false
export let extraWide = false export let extraWide = false
export let closeButtonIcon = "Close" export let closeButtonIcon = "Close"
@ -26,7 +27,11 @@
class:borderLeft class:borderLeft
class:borderRight class:borderRight
> >
<div class="header" class:custom={customHeaderContent}> <div
class="header"
class:custom={customHeaderContent}
class:borderBottom={borderBottomHeader}
>
{#if showBackButton} {#if showBackButton}
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} /> <Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
{/if} {/if}
@ -94,9 +99,11 @@
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 var(--spacing-l); padding: 0 var(--spacing-l);
border-bottom: var(--border-light);
gap: var(--spacing-m); gap: var(--spacing-m);
} }
.header.borderBottom {
border-bottom: var(--border-light);
}
.title { .title {
flex: 1 1 auto; flex: 1 1 auto;
width: 0; width: 0;

View File

@ -0,0 +1,7 @@
function handleEnter(fnc) {
return e => e.key === "Enter" && fnc()
}
export const keyUtils = {
handleEnter,
}

View File

@ -1,9 +1,12 @@
<script> <script>
import { Button, Layout } from "@budibase/bbui" import { Layout } from "@budibase/bbui"
import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte" import DatasourceNavigator from "components/backend/DatasourceNavigator/DatasourceNavigator.svelte"
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { isActive, redirect, goto, params } from "@roxi/routify" import { isActive, redirect, goto, params } from "@roxi/routify"
import { datasources } from "stores/backend" 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 // If we ever don't have any data other than the users table, prompt the
@ -18,10 +21,17 @@
<!-- routify:options index=1 --> <!-- routify:options index=1 -->
<div class="data"> <div class="data">
{#if !$isActive("./new")} {#if !$isActive("./new")}
<Panel title="Sources" borderRight> <Panel borderRight borderBottomHeader={false}>
<Layout paddingX="L" paddingY="XL" gap="S"> <span class="panel-title-content" slot="panel-title-content">
<Button cta on:click={() => $goto("./new")}>Add source</Button> <NavHeader
<DatasourceNavigator /> title="Sources"
placeholder="Search for sources"
bind:value={searchValue}
onAdd={() => $goto("./new")}
/>
</span>
<Layout paddingX="L" paddingY="none" gap="S">
<DatasourceNavigator searchTerm={searchValue} />
</Layout> </Layout>
</Panel> </Panel>
{/if} {/if}
@ -51,4 +61,8 @@
flex: 1 1 auto; flex: 1 1 auto;
z-index: 1; z-index: 1;
} }
.panel-title-content {
display: contents;
}
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { Icon, Layout, Body } from "@budibase/bbui" import { Layout } from "@budibase/bbui"
import { import {
store, store,
sortedScreens, sortedScreens,
@ -9,13 +9,14 @@
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import RoleIndicator from "./RoleIndicator.svelte" import RoleIndicator from "./RoleIndicator.svelte"
import DropdownMenu from "./DropdownMenu.svelte" import DropdownMenu from "./DropdownMenu.svelte"
import { onMount, tick } from "svelte" import { onMount } from "svelte"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import NavHeader from "components/common/NavHeader.svelte"
let search = false let search = false
let resizing = false let resizing = false
let searchValue = "" let searchValue = ""
let searchInput
let container let container
let screensContainer let screensContainer
let scrolling = false let scrolling = false
@ -26,10 +27,9 @@
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
$: search ? openSearch() : closeSearch()
const openSearch = async () => { const openSearch = async () => {
search = true
await tick()
searchInput.focus()
screensContainer.scroll({ top: 0, behavior: "smooth" }) screensContainer.scroll({ top: 0, behavior: "smooth" })
previousHeight = $screensHeight previousHeight = $screensHeight
$screensHeight = "calc(100% + 1px)" $screensHeight = "calc(100% + 1px)"
@ -42,8 +42,6 @@
previousHeight = null previousHeight = null
await sleep(300) await sleep(300)
} }
search = false
searchValue = ""
} }
const getFilteredScreens = (screens, search) => { 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 => { const handleScroll = e => {
scrolling = e.target.scrollTop !== 0 scrolling = e.target.scrollTop !== 0
} }
@ -105,7 +89,7 @@
}) })
</script> </script>
<svelte:window on:keydown={onKeyDown} /> <svelte:window />
<div <div
class="screens" class="screens"
class:search class:search
@ -114,26 +98,13 @@
bind:this={container} bind:this={container}
> >
<div class="header" class:scrolling> <div class="header" class:scrolling>
<input <NavHeader
readonly={!search} title="Screens"
bind:value={searchValue}
bind:this={searchInput}
class="input"
placeholder="Search for 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>
<div on:scroll={handleScroll} bind:this={screensContainer} class="content"> <div on:scroll={handleScroll} bind:this={screensContainer} class="content">
{#if filteredScreens?.length} {#if filteredScreens?.length}
@ -177,9 +148,9 @@
min-height: 147px; min-height: 147px;
max-height: calc(100% - 147px); max-height: calc(100% - 147px);
position: relative; position: relative;
transition: height 300ms ease-out;
} }
.screens.search { .screens.search {
transition: height 300ms ease-out;
max-height: none; max-height: none;
} }
.screens.resizing { .screens.resizing {
@ -202,37 +173,6 @@
border-bottom: var(--border-light); 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 { .content {
overflow: auto; overflow: auto;
flex-grow: 1; flex-grow: 1;
@ -245,34 +185,6 @@
padding-right: 8px !important; 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 { .icon {
margin-left: 4px; margin-left: 4px;
margin-right: 4px; margin-right: 4px;

View File

@ -1,13 +1,10 @@
<script> <script>
import { Icon, Body } from "@budibase/bbui"
import { apps, sideBarCollapsed } from "stores/portal" import { apps, sideBarCollapsed } from "stores/portal"
import { params, goto } from "@roxi/routify" import { params, goto } from "@roxi/routify"
import { tick } from "svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
import NavHeader from "components/common/NavHeader.svelte"
let searchInput
let searchString let searchString
let searching = false
$: filteredApps = $apps $: filteredApps = $apps
.filter(app => { .filter(app => {
@ -21,39 +18,16 @@
const lowerB = b.name.toLowerCase() const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1 return lowerA > lowerB ? 1 : -1
}) })
const startSearching = async () => {
searching = true
searchString = ""
await tick()
searchInput.focus()
}
const stopSearching = () => {
searching = false
searchString = ""
}
</script> </script>
<div class="side-bar" class:collapsed={$sideBarCollapsed}> <div class="side-bar" class:collapsed={$sideBarCollapsed}>
<div class="side-bar-controls"> <div class="side-bar-controls">
{#if searching} <NavHeader
<input title="Apps"
bind:this={searchInput} placeholder="Search for apps"
bind:value={searchString} bind:value={searchString}
placeholder="Search for apps" 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>
<div class="side-bar-nav"> <div class="side-bar-nav">
<NavItem <NavItem
@ -103,44 +77,13 @@
gap: var(--spacing-l); gap: var(--spacing-l);
padding: 0 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) { .side-bar-controls :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-700); 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 { .side-bar-nav {
flex: 1 1 auto; flex: 1 1 auto;
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
} }
div.rotational {
transition: transform 130ms ease-out;
}
div.rotational.rotated {
transform: rotate(45deg);
}
</style> </style>

View File

@ -426,7 +426,7 @@ describe.each([
const saved = (await loadRow(id, table._id!)).body const saved = (await loadRow(id, table._id!)).body
expect(saved.stringUndefined).toBe(undefined) expect(saved.stringUndefined).toBe(undefined)
expect(saved.stringNull).toBe("") expect(saved.stringNull).toBe(null)
expect(saved.stringString).toBe("i am a string") expect(saved.stringString).toBe("i am a string")
expect(saved.numberEmptyString).toBe(null) expect(saved.numberEmptyString).toBe(null)
expect(saved.numberNull).toBe(null) expect(saved.numberNull).toBe(null)

View File

@ -46,23 +46,23 @@ export const TYPE_TRANSFORM_MAP: any = {
parse: parseArrayString, parse: parseArrayString,
}, },
[FieldTypes.STRING]: { [FieldTypes.STRING]: {
"": "", "": null,
[null]: "", [null]: null,
[undefined]: undefined, [undefined]: undefined,
}, },
[FieldTypes.BARCODEQR]: { [FieldTypes.BARCODEQR]: {
"": "", "": null,
[null]: "", [null]: null,
[undefined]: undefined, [undefined]: undefined,
}, },
[FieldTypes.FORMULA]: { [FieldTypes.FORMULA]: {
"": "", "": null,
[null]: "", [null]: null,
[undefined]: undefined, [undefined]: undefined,
}, },
[FieldTypes.LONGFORM]: { [FieldTypes.LONGFORM]: {
"": "", "": null,
[null]: "", [null]: null,
[undefined]: undefined, [undefined]: undefined,
}, },
[FieldTypes.NUMBER]: { [FieldTypes.NUMBER]: {
@ -71,6 +71,11 @@ export const TYPE_TRANSFORM_MAP: any = {
[undefined]: undefined, [undefined]: undefined,
parse: n => parseFloat(n), parse: n => parseFloat(n),
}, },
[FieldTypes.BIGINT]: {
"": null,
[null]: null,
[undefined]: undefined,
},
[FieldTypes.DATETIME]: { [FieldTypes.DATETIME]: {
"": null, "": null,
[undefined]: undefined, [undefined]: undefined,