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",
"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,
* 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

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

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>
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]}
>

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;
pointer-events: all;
order: 0;
transition: transform 100ms linear;
}
.icon.arrow.absolute {
position: absolute;

View File

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

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

View File

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

View File

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

View File

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

View File

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