Merge branch 'v3-ui' into feature/automation-branching-ux

This commit is contained in:
Andrew Kingston 2024-10-29 16:59:01 +00:00 committed by GitHub
commit bc406e14db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 706 additions and 1000 deletions

View File

@ -1,20 +1,144 @@
<script> <script>
import { ActionButton, Modal } from "@budibase/bbui" import {
import ExportModal from "../modals/ExportModal.svelte" ActionButton,
Select,
notifications,
Body,
Button,
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { ROW_EXPORT_FORMATS } from "constants/backend"
import DetailPopover from "components/common/DetailPopover.svelte"
export let view export let view
export let filters
export let sorting export let sorting
export let disabled = false export let disabled = false
export let selectedRows export let selectedRows
export let formats export let formats
let modal const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
let popover
let exportFormat
let loading = false
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
const openPopover = () => {
loading = false
popover.show()
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
const exportAllData = async () => {
return await API.exportView({
viewName: view,
format: exportFormat,
})
}
const exportFilteredData = async () => {
let payload = {
tableId: view,
format: exportFormat,
search: {
paginate: false,
},
}
if (selectedRows?.length) {
payload.rows = selectedRows.map(row => row._id)
}
if (sorting) {
payload.search.sort = sorting.sortColumn
payload.search.sortOrder = sorting.sortOrder
}
return await API.exportRows(payload)
}
const exportData = async () => {
try {
loading = true
let data
if (selectedRows?.length || sorting) {
data = await exportFilteredData()
} else {
data = await exportAllData()
}
notifications.success("Export successful")
downloadWithBlob(data, `export.${exportFormat}`)
popover.hide()
} catch (error) {
console.error(error)
notifications.error("Error exporting data")
} finally {
loading = false
}
}
</script> </script>
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}> <DetailPopover title="Export data" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="DataDownload"
quiet
on:click={openPopover}
{disabled}
selected={open}
>
Export Export
</ActionButton> </ActionButton>
<Modal bind:this={modal}> </svelte:fragment>
<ExportModal {view} {filters} {sorting} {selectedRows} {formats} />
</Modal> {#if selectedRows?.length}
<Body size="S">
<span data-testid="exporting-n-rows">
<strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported.`}
</span>
</Body>
{:else}
<Body size="S">
<span data-testid="export-all-rows">
Exporting <strong>all</strong> rows.
</span>
</Body>
{/if}
<span data-testid="format-select">
<Select
label="Format"
bind:value={exportFormat}
{options}
placeholder={null}
getOptionLabel={x => x.name}
getOptionValue={x => x.key}
/>
</span>
<div>
<Button cta disabled={loading} on:click={exportData}>Export</Button>
</div>
</DetailPopover>

View File

@ -1,17 +1,81 @@
<script> <script>
import { ActionButton, Modal } from "@budibase/bbui" import { ActionButton, Button, Body, notifications } from "@budibase/bbui"
import ImportModal from "../modals/ImportModal.svelte" import DetailPopover from "components/common/DetailPopover.svelte"
import ExistingTableDataImport from "components/backend/TableNavigator/ExistingTableDataImport.svelte"
import { createEventDispatcher } from "svelte"
import { API } from "api"
export let tableId export let tableId
export let tableType export let tableType
export let disabled export let disabled
let modal const dispatch = createEventDispatcher()
let popover
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
let loading = false
const openPopover = () => {
rows = []
allValid = false
displayColumn = null
identifierFields = []
loading = false
popover.show()
}
const importData = async () => {
try {
loading = true
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
popover.hide()
} catch (error) {
console.error(error)
notifications.error("Unable to import data")
} finally {
loading = false
}
// Always refresh rows just to be sure
dispatch("importrows")
}
</script> </script>
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}> <DetailPopover title="Import data" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="DataUpload"
quiet
on:click={openPopover}
{disabled}
selected={open}
>
Import Import
</ActionButton> </ActionButton>
<Modal bind:this={modal}> </svelte:fragment>
<ImportModal {tableId} {tableType} on:importrows /> <Body size="S">
</Modal> Import rows to an existing table from a CSV or JSON file. Only columns from
the file which exist in the table will be imported.
</Body>
<ExistingTableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
<div>
<Button cta disabled={loading || !allValid} on:click={importData}>
Import
</Button>
</div>
</DetailPopover>

View File

@ -1,11 +1,12 @@
<script> <script>
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui" import { ActionButton, Button } from "@budibase/bbui"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte" import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding" import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { search } from "@budibase/frontend-core" import { search } from "@budibase/frontend-core"
import { tables } from "stores/builder" import { tables } from "stores/builder"
import DetailPopover from "components/common/DetailPopover.svelte"
export let schema export let schema
export let filters export let filters
@ -14,7 +15,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let drawer let popover
$: localFilters = filters $: localFilters = filters
$: schemaFields = search.getFields( $: schemaFields = search.getFields(
@ -39,39 +40,27 @@
}, },
...getUserBindings(), ...getUserBindings(),
] ]
const openPopover = () => {
localFilters = filters
popover.show()
}
</script> </script>
<ActionButton <DetailPopover bind:this={popover} title="Configure filters" width={800}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="Filter" icon="Filter"
quiet quiet
{disabled} {disabled}
on:click={drawer.show} on:click={openPopover}
selected={filterCount > 0} selected={open || filterCount > 0}
accentColor="#004EA6" accentColor="#004EA6"
>
{filterCount ? `Filter: ${filterCount}` : "Filter"}
</ActionButton>
<Drawer
bind:this={drawer}
title="Filtering"
on:drawerHide
on:drawerShow={() => {
localFilters = filters
}}
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
dispatch("change", localFilters)
drawer.hide()
}}
> >
Save {filterCount ? `Filter: ${filterCount}` : "Filter"}
</Button> </ActionButton>
<DrawerContent slot="body"> </svelte:fragment>
<FilterBuilder <FilterBuilder
filters={localFilters} filters={localFilters}
{schemaFields} {schemaFields}
@ -79,5 +68,16 @@
on:change={e => (localFilters = e.detail)} on:change={e => (localFilters = e.detail)}
{bindings} {bindings}
/> />
</DrawerContent> <div>
</Drawer> <Button
cta
slot="buttons"
on:click={() => {
dispatch("change", localFilters)
popover.hide()
}}
>
Save
</Button>
</div>
</DetailPopover>

View File

@ -210,6 +210,7 @@
anchor={relationshipPanelAnchor} anchor={relationshipPanelAnchor}
align="left" align="left"
> >
<div class="nested">
{#if relationshipPanelColumns.length} {#if relationshipPanelColumns.length}
<div class="relationship-header"> <div class="relationship-header">
{relationshipFieldName} columns {relationshipFieldName} columns
@ -220,6 +221,7 @@
permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]} permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]}
fromRelationshipField={relationshipField} fromRelationshipField={relationshipField}
/> />
</div>
</Popover> </Popover>
{/if} {/if}
@ -230,11 +232,13 @@
} }
.content { .content {
padding: 12px 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
} }
.nested {
padding: 12px;
}
.columns { .columns {
display: grid; display: grid;
align-items: center; align-items: center;
@ -262,6 +266,6 @@
} }
.relationship-header { .relationship-header {
color: var(--spectrum-global-color-gray-600); color: var(--spectrum-global-color-gray-600);
padding: 12px 12px 0 12px; margin-bottom: 12px;
} }
</style> </style>

View File

@ -8,15 +8,15 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import ColumnsSettingContent from "./ColumnsSettingContent.svelte" import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
import { isEnabled } from "helpers/featureFlags" import { isEnabled } from "helpers/featureFlags"
import { FeatureFlag } from "@budibase/types" import { FeatureFlag } from "@budibase/types"
import DetailPopover from "components/common/DetailPopover.svelte"
const { tableColumns, datasource } = getContext("grid") const { tableColumns, datasource } = getContext("grid")
let open = false let popover
let anchor
$: anyRestricted = $tableColumns.filter( $: anyRestricted = $tableColumns.filter(
col => !col.visible || col.readonly col => !col.visible || col.readonly
@ -32,24 +32,23 @@
: [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN] : [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN]
</script> </script>
<div bind:this={anchor}> <DetailPopover bind:this={popover} title="Column settings">
<svelte:fragment slot="anchor" let:open>
<ActionButton <ActionButton
icon="ColumnSettings" icon="ColumnSettings"
quiet quiet
size="M" size="M"
on:click={() => (open = !open)} on:click={popover?.open}
selected={open || anyRestricted} selected={open || anyRestricted}
disabled={!$tableColumns.length} disabled={!$tableColumns.length}
accentColor="#674D00" accentColor="#674D00"
> >
{text} {text}
</ActionButton> </ActionButton>
</div> </svelte:fragment>
<Popover bind:open {anchor} align="left">
<ColumnsSettingContent <ColumnsSettingContent
columns={$tableColumns} columns={$tableColumns}
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)} canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
{permissions} {permissions}
/> />
</Popover> </DetailPopover>

View File

@ -9,8 +9,7 @@
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id })) $: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
</script> </script>
<span data-ignore-click-outside="true"> <ExportButton
<ExportButton
{disabled} {disabled}
view={$datasource.tableId} view={$datasource.tableId}
filters={$filter} filters={$filter}
@ -19,11 +18,4 @@
sortOrder: $sort.order, sortOrder: $sort.order,
}} }}
selectedRows={selectedRowArray} selectedRows={selectedRowArray}
/> />
</span>
<style>
span {
display: contents;
}
</style>

View File

@ -1,6 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Label } from "@budibase/bbui" import { ActionButton, Label } from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
const { const {
Constants, Constants,
@ -32,8 +33,7 @@
}, },
] ]
let open = false let popover
let anchor
// Column width sizes // Column width sizes
$: allSmall = $columns.every(col => col.width === smallColSize) $: allSmall = $columns.every(col => col.width === smallColSize)
@ -66,21 +66,19 @@
} }
</script> </script>
<div bind:this={anchor}> <DetailPopover bind:this={popover} title="Column and row size" width={300}>
<svelte:fragment slot="anchor" let:open>
<ActionButton <ActionButton
icon="MoveUpDown" icon="MoveUpDown"
quiet quiet
size="M" size="M"
on:click={() => (open = !open)} on:click={popover?.open}
selected={open} selected={open}
disabled={!$columns.length} disabled={!$columns.length}
> >
Size Size
</ActionButton> </ActionButton>
</div> </svelte:fragment>
<Popover bind:open {anchor} align="left">
<div class="content">
<div class="size"> <div class="size">
<Label>Row height</Label> <Label>Row height</Label>
<div class="options"> <div class="options">
@ -113,16 +111,9 @@
{/if} {/if}
</div> </div>
</div> </div>
</div> </DetailPopover>
</Popover>
<style> <style>
.content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.size { .size {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,12 +1,12 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Select } from "@budibase/bbui" import { ActionButton, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/frontend-core" import { canBeSortColumn } from "@budibase/frontend-core"
import DetailPopover from "components/common/DetailPopover.svelte"
const { sort, columns } = getContext("grid") const { sort, columns } = getContext("grid")
let open = false let popover
let anchor
$: columnOptions = $columns $: columnOptions = $columns
.filter(col => canBeSortColumn(col.schema)) .filter(col => canBeSortColumn(col.schema))
@ -45,21 +45,19 @@
} }
</script> </script>
<div bind:this={anchor}> <DetailPopover bind:this={popover} title="Sorting" width={300}>
<svelte:fragment slot="anchor" let:open>
<ActionButton <ActionButton
icon="SortOrderDown" icon="SortOrderDown"
quiet quiet
size="M" size="M"
on:click={() => (open = !open)} on:click={popover?.open}
selected={open} selected={open}
disabled={!columnOptions.length} disabled={!columnOptions.length}
> >
Sort Sort
</ActionButton> </ActionButton>
</div> </svelte:fragment>
<Popover bind:open {anchor} align="left">
<div class="content">
<Select <Select
placeholder="Default" placeholder="Default"
value={$sort.column} value={$sort.column}
@ -78,17 +76,4 @@
label="Order" label="Order"
/> />
{/if} {/if}
</div> </DetailPopover>
</Popover>
<style>
.content {
padding: 6px 12px 12px 12px;
display: flex;
align-items: center;
gap: 8px;
}
.content :global(.spectrum-Picker) {
width: 140px;
}
</style>

View File

@ -1,15 +1,15 @@
<script> <script>
import { import {
ActionButton, ActionButton,
Modal,
ModalContent,
Select, Select,
Icon, Icon,
Multiselect, Multiselect,
Button,
} from "@budibase/bbui" } from "@budibase/bbui"
import { CalculationType, canGroupBy, isNumeric } from "@budibase/types" import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte" import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
import DetailPopover from "components/common/DetailPopover.svelte"
const { definition, datasource, rows } = getContext("grid") const { definition, datasource, rows } = getContext("grid")
const calculationTypeOptions = [ const calculationTypeOptions = [
@ -35,19 +35,20 @@
}, },
] ]
let modal let popover
let calculations = [] let calculations = []
let groupBy = [] let groupBy = []
let schema = {} let schema = {}
let loading = false
$: schema = $definition?.schema || {} $: schema = $definition?.schema || {}
$: count = extractCalculations($definition?.schema || {}).length $: count = extractCalculations($definition?.schema || {}).length
$: groupByOptions = getGroupByOptions(schema) $: groupByOptions = getGroupByOptions(schema)
const open = () => { const openPopover = () => {
calculations = extractCalculations(schema) calculations = extractCalculations(schema)
groupBy = calculations.length ? extractGroupBy(schema) : [] groupBy = calculations.length ? extractGroupBy(schema) : []
modal?.show() popover?.show()
} }
const extractCalculations = schema => { const extractCalculations = schema => {
@ -132,6 +133,7 @@
const save = async () => { const save = async () => {
let newSchema = {} let newSchema = {}
loading = true
// Add calculations // Add calculations
for (let calc of calculations) { for (let calc of calculations) {
@ -165,26 +167,27 @@
} }
// Save changes // Save changes
try {
await datasource.actions.saveDefinition({ await datasource.actions.saveDefinition({
...$definition, ...$definition,
primaryDisplay, primaryDisplay,
schema: newSchema, schema: newSchema,
}) })
await rows.actions.refreshData() await rows.actions.refreshData()
} finally {
loading = false
popover.hide()
}
} }
</script> </script>
<ActionButton icon="WebPage" quiet on:click={open}> <DetailPopover bind:this={popover} title="Configure calculations" width={480}>
<svelte:fragment slot="anchor" let:open>
<ActionButton icon="WebPage" quiet on:click={openPopover} selected={open}>
Configure calculations{count ? `: ${count}` : ""} Configure calculations{count ? `: ${count}` : ""}
</ActionButton> </ActionButton>
</svelte:fragment>
<Modal bind:this={modal}>
<ModalContent
title="Calculations"
confirmText="Save"
size="M"
onConfirm={save}
>
{#if calculations.length} {#if calculations.length}
<div class="calculations"> <div class="calculations">
{#each calculations as calc, idx} {#each calculations as calc, idx}
@ -233,8 +236,11 @@
quiet quiet
body="Calculations only work with numeric columns and a maximum of 5 calculations can be added at once." body="Calculations only work with numeric columns and a maximum of 5 calculations can be added at once."
/> />
</ModalContent>
</Modal> <div>
<Button cta on:click={save} disabled={loading}>Save</Button>
</div>
</DetailPopover>
<style> <style>
.calculations { .calculations {

View File

@ -1,224 +0,0 @@
<script>
import {
Select,
ModalContent,
notifications,
Body,
Table,
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { QueryUtils } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { ROW_EXPORT_FORMATS } from "constants/backend"
export let view
export let filters
export let sorting
export let selectedRows = []
export let formats
const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
$: appliedFilters = filters?.filter(filter => !filter.onEmptyFilter)
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
let exportFormat
let filterLookup
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
$: query = QueryUtils.buildQuery(appliedFilters)
$: exportOpDisplay = buildExportOpDisplay(
sorting,
filterDisplay,
appliedFilters
)
filterLookup = utils.filterValueToLabel()
const filterDisplay = () => {
if (!appliedFilters) {
return []
}
return appliedFilters.map(filter => {
let newFieldName = filter.field + ""
const parts = newFieldName.split(":")
parts.shift()
newFieldName = parts.join(":")
return {
Field: newFieldName,
Operation: filterLookup[filter.operator],
"Field Value": filter.value || "",
}
})
}
const buildExportOpDisplay = (sorting, filterDisplay) => {
let filterDisplayConfig = filterDisplay()
if (sorting?.sortColumn) {
filterDisplayConfig = [
...filterDisplayConfig,
{
Field: sorting.sortColumn,
Operation: "Order By",
"Field Value": sorting.sortOrder,
},
]
}
return filterDisplayConfig
}
const displaySchema = {
Field: {
type: "string",
fieldName: "Field",
},
Operation: {
type: "string",
fieldName: "Operation",
},
"Field Value": {
type: "string",
fieldName: "Value",
},
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
async function exportView() {
try {
const data = await API.exportView({
viewName: view,
format: exportFormat,
})
downloadWithBlob(
data,
`export.${exportFormat === "csv" ? "csv" : "json"}`
)
} catch (error) {
notifications.error(`Unable to export ${exportFormat.toUpperCase()} data`)
}
}
async function exportRows() {
if (selectedRows?.length) {
const data = await API.exportRows({
tableId: view,
rows: selectedRows.map(row => row._id),
format: exportFormat,
})
downloadWithBlob(data, `export.${exportFormat}`)
} else if (appliedFilters || sorting) {
let response
try {
response = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
} catch (e) {
console.error("Failed to export", e)
notifications.error("Export Failed")
}
if (response) {
downloadWithBlob(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else {
await exportView()
}
}
</script>
<ModalContent
title="Export Data"
confirmText="Export"
onConfirm={exportRows}
size={appliedFilters?.length || sorting ? "M" : "S"}
>
{#if selectedRows?.length}
<Body size="S">
<span data-testid="exporting-n-rows">
<strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported`}
</span>
</Body>
{:else if appliedFilters?.length || (sorting?.sortOrder && sorting?.sortColumn)}
<Body size="S">
{#if !appliedFilters}
<span data-testid="exporting-rows">
Exporting <strong>all</strong> rows
</span>
{:else}
<span data-testid="filters-applied">Filters applied</span>
{/if}
</Body>
<div class="table-wrap" data-testid="export-config-table">
<Table
schema={displaySchema}
data={exportOpDisplay}
{appliedFilters}
loading={false}
rowCount={appliedFilters?.length + 1}
disableSorting={true}
allowSelectRows={false}
allowEditRows={false}
allowEditColumns={false}
quiet={true}
compact={true}
/>
</div>
{:else}
<Body size="S">
<span data-testid="export-all-rows">
Exporting <strong>all</strong> rows
</span>
</Body>
{/if}
<span data-testid="format-select">
<Select
label="Format"
bind:value={exportFormat}
{options}
placeholder={null}
getOptionLabel={x => x.name}
getOptionValue={x => x.key}
/>
</span>
</ModalContent>
<style>
.table-wrap :global(.wrapper) {
max-width: 400px;
}
</style>

View File

@ -1,243 +0,0 @@
import { it, expect, describe, vi } from "vitest"
import { render, screen } from "@testing-library/svelte"
import "@testing-library/jest-dom"
import ExportModal from "./ExportModal.svelte"
import { utils } from "@budibase/shared-core"
const labelLookup = utils.filterValueToLabel()
const rowText = filter => {
let readableField = filter.field.split(":")[1]
let rowLabel = labelLookup[filter.operator]
let value = Array.isArray(filter.value)
? JSON.stringify(filter.value)
: filter.value
return `${readableField}${rowLabel}${value}`.trim()
}
const defaultFilters = [
{
onEmptyFilter: "all",
},
]
vi.mock("svelte", async () => {
return {
getContext: () => {
return {
hide: vi.fn(),
cancel: vi.fn(),
}
},
createEventDispatcher: vi.fn(),
onDestroy: vi.fn(),
tick: vi.fn(),
}
})
vi.mock("api", async () => {
return {
API: {
exportView: vi.fn(),
exportRows: vi.fn(),
},
}
})
describe("Export Modal", () => {
it("show default messaging with no export config specified", () => {
render(ExportModal, {
props: {},
})
expect(screen.getByTestId("export-all-rows")).toBeVisible()
expect(screen.getByTestId("export-all-rows")).toHaveTextContent(
"Exporting all rows"
)
expect(screen.queryByTestId("export-config-table")).toBe(null)
})
it("indicate that a filter is being applied to the export", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
}
render(ExportModal, {
props: propsCfg,
})
expect(propsCfg.filters[0].field).toBe("1:Cost")
expect(screen.getByTestId("filters-applied")).toBeVisible()
expect(screen.getByTestId("filters-applied").textContent).toBe(
"Filters applied"
)
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
let rowTextContent = rowText(propsCfg.filters[0])
//"CostLess than or equal to100"
expect(rows[0].textContent?.trim()).toEqual(rowTextContent)
})
it("Show only selected row messaging if rows are supplied", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
selectedRows: [
{
_id: "ro_ta_bb_expenses_57d5f6fe1b6640d8bb22b15f5eae62cd",
},
{
_id: "ro_ta_bb_expenses_99ce5760a53a430bab4349cd70335a07",
},
],
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeNull()
expect(screen.queryByTestId("filters-applied")).toBeNull()
expect(screen.queryByTestId("exporting-n-rows")).toBeVisible()
expect(screen.queryByTestId("exporting-n-rows").textContent).toEqual(
"2 rows will be exported"
)
})
it("Show only the configured sort when no filters are specified", () => {
const propsCfg = {
filters: [...defaultFilters],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeVisible()
const ele = screen.queryByTestId("export-config-table")
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
expect(rows[0].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("Display all currently configured filters and applied sort", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
{
id: "2ot-aB0gE",
field: "2:Expense Tags",
operator: "contains",
value: ["Equipment", "Services"],
valueType: "Value",
type: "array",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Payment Due",
sortOrder: "ascending",
},
}
render(ExportModal, {
props: propsCfg,
})
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(3)
let rowTextContent1 = rowText(propsCfg.filters[0])
expect(rows[0].textContent?.trim()).toEqual(rowTextContent1)
let rowTextContent2 = rowText(propsCfg.filters[1])
expect(rows[1].textContent?.trim()).toEqual(rowTextContent2)
expect(rows[2].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("show only the valid, configured download formats", () => {
const propsCfg = {
formats: ["badger", "json"],
}
render(ExportModal, {
props: propsCfg,
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("JSON")
})
it("Load the default format config when no explicit formats are configured", () => {
render(ExportModal, {
props: {},
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("CSV")
})
})

View File

@ -1,61 +0,0 @@
<script>
import {
ModalContent,
Label,
notifications,
Body,
Layout,
} from "@budibase/bbui"
import TableDataImport from "../../TableNavigator/ExistingTableDataImport.svelte"
import { API } from "api"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let tableId
export let tableType
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
async function importData() {
try {
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
} catch (error) {
notifications.error("Unable to import data")
}
// Always refresh rows just to be sure
dispatch("importrows")
}
</script>
<ModalContent
title="Import Data"
confirmText="Import"
onConfirm={importData}
disabled={!allValid}
>
<Body size="S">
Import rows to an existing table from a CSV or JSON file. Only columns from
the file which exist in the table will be imported.
</Body>
<Layout gap="XS" noPadding>
<Label grey extraSmall>CSV or JSON file to import</Label>
<TableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
</Layout>
</ModalContent>

View File

@ -4,7 +4,7 @@
BBReferenceFieldSubType, BBReferenceFieldSubType,
SourceName, SourceName,
} from "@budibase/types" } from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui" import { Select, Toggle, Multiselect, Label, Layout } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend" import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api" import { API } from "api"
import { parseFile } from "./utils" import { parseFile } from "./utils"
@ -140,7 +140,10 @@
} }
</script> </script>
<div class="dropzone"> <Layout gap="S" noPadding>
<Layout noPadding gap="XS">
<Label grey extraSmall>CSV or JSON file to import</Label>
<div class="dropzone">
<input <input
disabled={!schema || loading} disabled={!schema || loading}
id="file-upload" id="file-upload"
@ -159,11 +162,13 @@
Upload Upload
{/if} {/if}
</label> </label>
</div> </div>
{#if fileName && Object.keys(validation).length === 0} </Layout>
<p>No valid fields, try another file</p>
{:else if rows.length > 0 && !error} {#if fileName && Object.keys(validation).length === 0}
<div class="schema-fields"> <div>No valid fields - please try another file.</div>
{:else if fileName && rows.length > 0 && !error}
<div>
{#each Object.keys(validation) as name} {#each Object.keys(validation) as name}
<div class="field"> <div class="field">
<span>{name}</span> <span>{name}</span>
@ -185,7 +190,6 @@
</div> </div>
{/each} {/each}
</div> </div>
<br />
<!-- SQL Server doesn't yet support overwriting rows by existing keys --> <!-- SQL Server doesn't yet support overwriting rows by existing keys -->
{#if datasource?.source !== SourceName.SQL_SERVER} {#if datasource?.source !== SourceName.SQL_SERVER}
<Toggle <Toggle
@ -203,21 +207,24 @@
bind:value={identifierFields} bind:value={identifierFields}
/> />
{:else} {:else}
<p>Rows will be updated based on the table's primary key.</p> <div>Rows will be updated based on the table's primary key.</div>
{/if} {/if}
{/if} {/if}
{#if invalidColumns.length > 0} {#if invalidColumns.length > 0}
<p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM"> <Layout noPadding gap="XS">
The following columns are present in the data you wish to import, but do <div>
not match the schema of this table and will be ignored. The following columns are present in the data you wish to import, but
</p> do not match the schema of this table and will be ignored:
<ul class="ignoredList"> </div>
<div>
{#each invalidColumns as column} {#each invalidColumns as column}
<li>{column}</li> - {column}<br />
{/each} {/each}
</ul> </div>
</Layout>
{/if} {/if}
{/if} {/if}
</Layout>
<style> <style>
.dropzone { .dropzone {
@ -228,11 +235,9 @@
border-radius: 10px; border-radius: 10px;
transition: all 0.3s; transition: all 0.3s;
} }
input { input {
display: none; display: none;
} }
label { label {
font-family: var(--font-sans); font-family: var(--font-sans);
cursor: pointer; cursor: pointer;
@ -240,7 +245,6 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l); padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s; transition: all 0.2s ease 0s;
display: inline-flex; display: inline-flex;
@ -254,20 +258,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
background-color: var(--grey-2); background-color: var(--spectrum-global-color-gray-300);
font-size: var(--font-size-xs); font-size: var(--font-size-s);
line-height: normal; line-height: normal;
border: var(--border-transparent); border: var(--border-transparent);
} }
.uploaded { .uploaded {
color: var(--blue); color: var(--spectrum-global-color-blue-600);
} }
.schema-fields {
margin-top: var(--spacing-xl);
}
.field { .field {
display: grid; display: grid;
grid-template-columns: 2fr 2fr 1fr auto; grid-template-columns: 2fr 2fr 1fr auto;
@ -276,23 +274,14 @@
grid-gap: var(--spacing-m); grid-gap: var(--spacing-m);
font-size: var(--spectrum-global-dimension-font-size-75); font-size: var(--spectrum-global-dimension-font-size-75);
} }
.fieldStatusSuccess { .fieldStatusSuccess {
color: var(--green); color: var(--green);
justify-self: center; justify-self: center;
font-weight: 600; font-weight: 600;
} }
.fieldStatusFailure { .fieldStatusFailure {
color: var(--red); color: var(--red);
justify-self: center; justify-self: center;
font-weight: 600; font-weight: 600;
} }
.ignoredList {
margin: 0;
padding: 0;
list-style: none;
font-size: var(--spectrum-global-dimension-font-size-75);
}
</style> </style>

View File

@ -1,5 +1,5 @@
<script> <script>
import { Select, Icon } from "@budibase/bbui" import { Select, Icon, Layout, Label } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import { utils } from "@budibase/shared-core" import { utils } from "@budibase/shared-core"
import { canBeDisplayColumn } from "@budibase/frontend-core" import { canBeDisplayColumn } from "@budibase/frontend-core"
@ -184,7 +184,12 @@
} }
</script> </script>
<div class="dropzone"> <Layout noPadding gap="S">
<Layout gap="XS" noPadding>
<Label grey extraSmall>
Create a Table from a CSV or JSON file (Optional)
</Label>
<div class="dropzone">
<input <input
bind:this={fileInput} bind:this={fileInput}
disabled={loading} disabled={loading}
@ -202,9 +207,11 @@
Upload Upload
{/if} {/if}
</label> </label>
</div> </div>
{#if rawRows.length > 0 && !error} </Layout>
<div class="schema-fields">
{#if rawRows.length > 0 && !error}
<div>
{#each Object.entries(schema) as [name, column]} {#each Object.entries(schema) as [name, column]}
<div class="field"> <div class="field">
<span>{column.name}</span> <span>{column.name}</span>
@ -239,15 +246,14 @@
</div> </div>
{/each} {/each}
</div> </div>
<div class="display-column">
<Select <Select
label="Display Column" label="Display Column"
bind:value={displayColumn} bind:value={displayColumn}
options={displayColumnOptions} options={displayColumnOptions}
sort sort
/> />
</div> {/if}
{/if} </Layout>
<style> <style>
.dropzone { .dropzone {
@ -269,7 +275,6 @@
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
border-radius: var(--border-radius-s); border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l); padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s; transition: all 0.2s ease 0s;
display: inline-flex; display: inline-flex;
@ -283,20 +288,14 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 100%; width: 100%;
background-color: var(--grey-2); background-color: var(--spectrum-global-color-gray-300);
font-size: var(--font-size-xs); font-size: var(--font-size-s);
line-height: normal; line-height: normal;
border: var(--border-transparent); border: var(--border-transparent);
} }
.uploaded { .uploaded {
color: var(--blue); color: var(--spectrum-global-color-blue-600);
} }
.schema-fields {
margin-top: var(--spacing-xl);
}
.field { .field {
display: grid; display: grid;
grid-template-columns: 2fr 2fr 1fr auto; grid-template-columns: 2fr 2fr 1fr auto;
@ -322,8 +321,4 @@
.fieldStatusFailure :global(.spectrum-Icon) { .fieldStatusFailure :global(.spectrum-Icon) {
width: 12px; width: 12px;
} }
.display-column {
margin-top: var(--spacing-xl);
}
</style> </style>

View File

@ -1,13 +1,7 @@
<script> <script>
import { goto, url } from "@roxi/routify" import { goto, url } from "@roxi/routify"
import { tables, datasources } from "stores/builder" import { tables, datasources } from "stores/builder"
import { import { notifications, Input, ModalContent } from "@budibase/bbui"
notifications,
Input,
Label,
ModalContent,
Layout,
} from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte" import TableDataImport from "../TableDataImport.svelte"
import { import {
BUDIBASE_INTERNAL_DB_ID, BUDIBASE_INTERNAL_DB_ID,
@ -101,11 +95,6 @@
bind:value={name} bind:value={name}
{error} {error}
/> />
<div>
<Layout gap="XS" noPadding>
<Label grey extraSmall
>Create a Table from a CSV or JSON file (Optional)</Label
>
<TableDataImport <TableDataImport
{promptUpload} {promptUpload}
bind:rows bind:rows
@ -113,6 +102,4 @@
bind:allValid bind:allValid
bind:displayColumn bind:displayColumn
/> />
</Layout>
</div>
</ModalContent> </ModalContent>

View File

@ -30,6 +30,7 @@
{showPopover} {showPopover}
on:open on:open
on:close on:close
customZindex={100}
> >
<div class="detail-popover"> <div class="detail-popover">
<div class="detail-popover__header"> <div class="detail-popover__header">

View File

@ -60,10 +60,10 @@
buttonsCollapsed buttonsCollapsed
> >
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
<GridManageAccessButton />
{#if calculation} {#if calculation}
<GridViewCalculationButton /> <GridViewCalculationButton />
{/if} {/if}
<GridManageAccessButton />
<GridFilterButton /> <GridFilterButton />
<GridSortButton /> <GridSortButton />
<GridSizeButton /> <GridSizeButton />

View File

@ -4298,6 +4298,97 @@ describe.each([
}, },
], ],
}, },
{
name: "can handle logical operator any",
insert: [{ string: "bar" }, { string: "foo" }],
query: {
groups: [
{
logicalOperator: UILogicalOperator.ANY,
filters: [
{
operator: BasicOperator.EQUAL,
field: "string",
value: "foo",
},
{
operator: BasicOperator.EQUAL,
field: "string",
value: "bar",
},
],
},
],
},
searchOpts: {
sort: "string",
sortOrder: SortOrder.ASCENDING,
},
expected: [{ string: "bar" }, { string: "foo" }],
},
{
name: "can handle logical operator all",
insert: [
{ string: "bar", number: 1 },
{ string: "foo", number: 2 },
],
query: {
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{
operator: BasicOperator.EQUAL,
field: "string",
value: "foo",
},
{
operator: BasicOperator.EQUAL,
field: "number",
value: 2,
},
],
},
],
},
searchOpts: {
sort: "string",
sortOrder: SortOrder.ASCENDING,
},
expected: [{ string: "foo", number: 2 }],
},
{
name: "overrides allOr with logical operators",
insert: [
{ string: "bar", number: 1 },
{ string: "foo", number: 1 },
],
query: {
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{ operator: "allOr" },
{
operator: BasicOperator.EQUAL,
field: "string",
value: "foo",
},
{
operator: BasicOperator.EQUAL,
field: "number",
value: 1,
},
],
},
],
},
searchOpts: {
sort: "string",
sortOrder: SortOrder.ASCENDING,
},
expected: [{ string: "foo", number: 1 }],
},
] ]
it.each(testCases)( it.each(testCases)(

View File

@ -488,7 +488,13 @@ export function buildQuery(
if (onEmptyFilter) { if (onEmptyFilter) {
query.onEmptyFilter = onEmptyFilter query.onEmptyFilter = onEmptyFilter
} }
const operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
// logicalOperator takes precendence over allOr
let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
if (group.logicalOperator) {
operator = logicalOperatorFromUI(group.logicalOperator)
}
return { return {
[operator]: { conditions: filters.map(buildCondition).filter(f => f) }, [operator]: { conditions: filters.map(buildCondition).filter(f => f) },
} }