Merge branch 'master' of github.com:Budibase/budibase into feature/role-multi-inheritance
This commit is contained in:
commit
243391d6cb
|
@ -14,15 +14,39 @@ jobs:
|
||||||
release:
|
release:
|
||||||
if: |
|
if: |
|
||||||
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') &&
|
(github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase') &&
|
||||||
contains(github.event.pull_request.labels.*.name, 'feature-branch')
|
(
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'feature-branch') ||
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'feature-branch-pro') ||
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'feature-branch-team') ||
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'feature-branch-business') ||
|
||||||
|
contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise')
|
||||||
|
)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set PAYLOAD_LICENSE_TYPE
|
||||||
|
id: set_license_type
|
||||||
|
run: |
|
||||||
|
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch') }}" == "true" ]]; then
|
||||||
|
echo "PAYLOAD_LICENSE_TYPE=free" >> $GITHUB_ENV
|
||||||
|
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-pro') }}" == "true" ]]; then
|
||||||
|
echo "PAYLOAD_LICENSE_TYPE=pro" >> $GITHUB_ENV
|
||||||
|
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-team') }}" == "true" ]]; then
|
||||||
|
echo "PAYLOAD_LICENSE_TYPE=team" >> $GITHUB_ENV
|
||||||
|
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-business') }}" == "true" ]]; then
|
||||||
|
echo "PAYLOAD_LICENSE_TYPE=business" >> $GITHUB_ENV
|
||||||
|
elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'feature-branch-enterprise') }}" == "true" ]]; then
|
||||||
|
echo "PAYLOAD_LICENSE_TYPE=enterprise" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "PAYLOAD_LICENSE_TYPE=free" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
- uses: passeidireto/trigger-external-workflow-action@main
|
- uses: passeidireto/trigger-external-workflow-action@main
|
||||||
env:
|
env:
|
||||||
PAYLOAD_BRANCH: ${{ github.head_ref }}
|
PAYLOAD_BRANCH: ${{ github.head_ref }}
|
||||||
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
|
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
PAYLOAD_LICENSE_TYPE: "free"
|
PAYLOAD_LICENSE_TYPE: ${{ env.PAYLOAD_LICENSE_TYPE }}
|
||||||
with:
|
with:
|
||||||
repository: budibase/budibase-deploys
|
repository: budibase/budibase-deploys
|
||||||
event: featurebranch-qa-deploy
|
event: featurebranch-qa-deploy
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||||
"version": "2.32.15",
|
"version": "2.32.17",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -273,6 +273,7 @@ class InternalBuilder {
|
||||||
const col = parts.pop()!
|
const col = parts.pop()!
|
||||||
const schema = this.table.schema[col]
|
const schema = this.table.schema[col]
|
||||||
let identifier = this.quotedIdentifier(field)
|
let identifier = this.quotedIdentifier(field)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
schema.type === FieldType.STRING ||
|
schema.type === FieldType.STRING ||
|
||||||
schema.type === FieldType.LONGFORM ||
|
schema.type === FieldType.LONGFORM ||
|
||||||
|
@ -957,6 +958,13 @@ class InternalBuilder {
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isAggregateField(field: string): boolean {
|
||||||
|
const found = this.query.resource?.aggregations?.find(
|
||||||
|
aggregation => aggregation.name === field
|
||||||
|
)
|
||||||
|
return !!found
|
||||||
|
}
|
||||||
|
|
||||||
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
|
addSorting(query: Knex.QueryBuilder): Knex.QueryBuilder {
|
||||||
let { sort, resource } = this.query
|
let { sort, resource } = this.query
|
||||||
const primaryKey = this.table.primary
|
const primaryKey = this.table.primary
|
||||||
|
@ -979,6 +987,9 @@ class InternalBuilder {
|
||||||
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
|
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isAggregateField(key)) {
|
||||||
|
query = query.orderBy(key, direction, nulls)
|
||||||
|
} else {
|
||||||
let composite = `${aliased}.${key}`
|
let composite = `${aliased}.${key}`
|
||||||
if (this.client === SqlClient.ORACLE) {
|
if (this.client === SqlClient.ORACLE) {
|
||||||
query = query.orderByRaw(
|
query = query.orderByRaw(
|
||||||
|
@ -989,6 +1000,7 @@ class InternalBuilder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// add sorting by the primary key if the result isn't already sorted by it,
|
// add sorting by the primary key if the result isn't already sorted by it,
|
||||||
// to make sure result is deterministic
|
// to make sure result is deterministic
|
||||||
|
|
|
@ -14,7 +14,13 @@
|
||||||
function daysUntilCancel() {
|
function daysUntilCancel() {
|
||||||
const cancelAt = license?.billing?.subscription?.cancelAt
|
const cancelAt = license?.billing?.subscription?.cancelAt
|
||||||
const diffTime = Math.abs(cancelAt - new Date().getTime()) / 1000
|
const diffTime = Math.abs(cancelAt - new Date().getTime()) / 1000
|
||||||
return Math.floor(diffTime / oneDayInSeconds)
|
const days = Math.floor(diffTime / oneDayInSeconds)
|
||||||
|
if (days === 1) {
|
||||||
|
return "tomorrow."
|
||||||
|
} else if (days === 0) {
|
||||||
|
return "today."
|
||||||
|
}
|
||||||
|
return `in ${days} days.`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -28,7 +34,7 @@
|
||||||
extraLinkAction={$licensing.goToUpgradePage}
|
extraLinkAction={$licensing.goToUpgradePage}
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
>
|
>
|
||||||
Your free trial will end in {daysUntilCancel()} days.
|
Your free trial will end {daysUntilCancel()}
|
||||||
</Banner>
|
</Banner>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
@ -4,6 +4,8 @@
|
||||||
import { GridRowHeight, GridColumns } from "constants"
|
import { GridRowHeight, GridColumns } from "constants"
|
||||||
import { memo } from "@budibase/frontend-core"
|
import { memo } from "@budibase/frontend-core"
|
||||||
|
|
||||||
|
export let onClick
|
||||||
|
|
||||||
const component = getContext("component")
|
const component = getContext("component")
|
||||||
const { styleable, builderStore } = getContext("sdk")
|
const { styleable, builderStore } = getContext("sdk")
|
||||||
const context = getContext("context")
|
const context = getContext("context")
|
||||||
|
@ -121,15 +123,19 @@
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<div
|
<div
|
||||||
bind:this={ref}
|
bind:this={ref}
|
||||||
class="grid"
|
class="grid"
|
||||||
class:mobile
|
class:mobile
|
||||||
|
class:clickable={!!onClick}
|
||||||
bind:clientWidth={width}
|
bind:clientWidth={width}
|
||||||
bind:clientHeight={height}
|
bind:clientHeight={height}
|
||||||
use:styleable={$styles}
|
use:styleable={$styles}
|
||||||
data-cols={GridColumns}
|
data-cols={GridColumns}
|
||||||
data-col-size={colSize}
|
data-col-size={colSize}
|
||||||
|
on:click={onClick}
|
||||||
>
|
>
|
||||||
{#if inBuilder}
|
{#if inBuilder}
|
||||||
<div class="underlay">
|
<div class="underlay">
|
||||||
|
@ -176,6 +182,9 @@
|
||||||
.placeholder.first-col {
|
.placeholder.first-col {
|
||||||
border-left: 1px solid var(--spectrum-global-color-gray-900);
|
border-left: 1px solid var(--spectrum-global-color-gray-900);
|
||||||
}
|
}
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* Highlight grid lines when resizing children */
|
/* Highlight grid lines when resizing children */
|
||||||
:global(.grid.highlight > .underlay) {
|
:global(.grid.highlight > .underlay) {
|
||||||
|
|
|
@ -27,9 +27,7 @@
|
||||||
let candidateIndex
|
let candidateIndex
|
||||||
let lastSearchId
|
let lastSearchId
|
||||||
let searching = false
|
let searching = false
|
||||||
let container
|
|
||||||
let anchor
|
let anchor
|
||||||
let relationshipFields
|
|
||||||
|
|
||||||
$: fieldValue = parseValue(value)
|
$: fieldValue = parseValue(value)
|
||||||
$: oneRowOnly = schema?.relationshipType === "one-to-many"
|
$: oneRowOnly = schema?.relationshipType === "one-to-many"
|
||||||
|
@ -56,12 +54,6 @@
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
|
|
||||||
$: showRelationshipFields =
|
|
||||||
relationshipFields &&
|
|
||||||
Object.keys(relationshipFields).length &&
|
|
||||||
focused &&
|
|
||||||
!isOpen
|
|
||||||
|
|
||||||
const parseValue = value => {
|
const parseValue = value => {
|
||||||
if (Array.isArray(value) && value.every(x => x?._id)) {
|
if (Array.isArray(value) && value.every(x => x?._id)) {
|
||||||
return value
|
return value
|
||||||
|
@ -242,14 +234,6 @@
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayRelationshipFields = relationship => {
|
|
||||||
relationshipFields = relationFields[relationship._id]
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideRelationshipFields = () => {
|
|
||||||
relationshipFields = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
api = {
|
api = {
|
||||||
focus: open,
|
focus: open,
|
||||||
|
@ -269,7 +253,7 @@
|
||||||
style="--color:{color};"
|
style="--color:{color};"
|
||||||
bind:this={anchor}
|
bind:this={anchor}
|
||||||
>
|
>
|
||||||
<div class="container" bind:this={container}>
|
<div class="container">
|
||||||
<div
|
<div
|
||||||
class="values"
|
class="values"
|
||||||
class:wrap={editable || contentLines > 1}
|
class:wrap={editable || contentLines > 1}
|
||||||
|
@ -281,9 +265,7 @@
|
||||||
<div
|
<div
|
||||||
class="badge"
|
class="badge"
|
||||||
class:extra-info={!!relationFields[relationship._id]}
|
class:extra-info={!!relationFields[relationship._id]}
|
||||||
on:mouseover={() => displayRelationshipFields(relationship)}
|
|
||||||
on:focus={() => {}}
|
on:focus={() => {}}
|
||||||
on:mouseleave={() => hideRelationshipFields()}
|
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{readable(
|
{readable(
|
||||||
|
@ -358,21 +340,6 @@
|
||||||
</GridPopover>
|
</GridPopover>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if showRelationshipFields}
|
|
||||||
<GridPopover {anchor} minWidth={300} maxWidth={400}>
|
|
||||||
<div class="relationship-fields">
|
|
||||||
{#each Object.entries(relationshipFields) as [fieldName, fieldValue]}
|
|
||||||
<div class="relationship-field-name">
|
|
||||||
{fieldName}
|
|
||||||
</div>
|
|
||||||
<div class="relationship-field-value">
|
|
||||||
{fieldValue}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</GridPopover>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.wrapper {
|
.wrapper {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
@ -539,25 +506,4 @@
|
||||||
.search :global(.spectrum-Form-item) {
|
.search :global(.spectrum-Form-item) {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.relationship-fields {
|
|
||||||
margin: var(--spacing-m) var(--spacing-l);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(auto, 50%) auto;
|
|
||||||
grid-row-gap: var(--spacing-m);
|
|
||||||
grid-column-gap: var(--spacing-m);
|
|
||||||
}
|
|
||||||
|
|
||||||
.relationship-field-name {
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--spectrum-global-color-gray-600);
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
.relationship-field-value {
|
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
line-clamp: 3;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -125,7 +125,7 @@
|
||||||
subtype: column.subtype,
|
subtype: column.subtype,
|
||||||
visible: column.visible,
|
visible: column.visible,
|
||||||
readonly: column.readonly,
|
readonly: column.readonly,
|
||||||
constraints: column.constraints, // This is needed to properly display "users" column
|
icon: column.icon,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -19,6 +19,10 @@ export const getCellID = (rowId, fieldName) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getColumnIcon = column => {
|
export const getColumnIcon = column => {
|
||||||
|
if (column.schema.icon) {
|
||||||
|
return column.schema.icon
|
||||||
|
}
|
||||||
|
|
||||||
if (column.schema.autocolumn) {
|
if (column.schema.autocolumn) {
|
||||||
return "MagicWand"
|
return "MagicWand"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
Subproject commit aca9828117bb97f54f40ee359f1a3f6e259174e7
|
Subproject commit fc4c7f4925139af078480217965c3d6338dc0a7f
|
|
@ -14,6 +14,7 @@ import {
|
||||||
InternalTable,
|
InternalTable,
|
||||||
tenancy,
|
tenancy,
|
||||||
features,
|
features,
|
||||||
|
utils,
|
||||||
} from "@budibase/backend-core"
|
} from "@budibase/backend-core"
|
||||||
import { quotas } from "@budibase/pro"
|
import { quotas } from "@budibase/pro"
|
||||||
import {
|
import {
|
||||||
|
@ -757,6 +758,37 @@ describe.each([
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("user column", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
user: {
|
||||||
|
name: "user",
|
||||||
|
type: FieldType.BB_REFERENCE_SINGLE,
|
||||||
|
subtype: BBReferenceFieldSubType.USER,
|
||||||
|
default: "{{ [Current User]._id }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("creates a new row with a default value successfully", async () => {
|
||||||
|
const row = await config.api.row.save(table._id!, {})
|
||||||
|
expect(row.user._id).toEqual(config.getUser()._id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not use default value if value specified", async () => {
|
||||||
|
const id = `us_${utils.newid()}`
|
||||||
|
await config.createUser({ _id: id })
|
||||||
|
const row = await config.api.row.save(table._id!, {
|
||||||
|
user: id,
|
||||||
|
})
|
||||||
|
expect(row.user._id).toEqual(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("bindings", () => {
|
describe("bindings", () => {
|
||||||
describe("string column", () => {
|
describe("string column", () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
|
|
@ -21,6 +21,7 @@ import {
|
||||||
ViewCalculation,
|
ViewCalculation,
|
||||||
ViewV2Enriched,
|
ViewV2Enriched,
|
||||||
RowExportFormat,
|
RowExportFormat,
|
||||||
|
PermissionLevel,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
|
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
|
||||||
import * as setup from "./utilities"
|
import * as setup from "./utilities"
|
||||||
|
@ -191,6 +192,55 @@ describe.each([
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("permissions", () => {
|
||||||
|
it("get the base permissions for the table", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
tableForDatasource(datasource, {
|
||||||
|
schema: {
|
||||||
|
name: {
|
||||||
|
type: FieldType.STRING,
|
||||||
|
name: "name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// get the explicit permissions
|
||||||
|
const { permissions } = await config.api.permission.get(table._id!, {
|
||||||
|
status: 200,
|
||||||
|
})
|
||||||
|
const explicitPermissions = {
|
||||||
|
role: "ADMIN",
|
||||||
|
permissionType: "EXPLICIT",
|
||||||
|
}
|
||||||
|
expect(permissions.write).toEqual(explicitPermissions)
|
||||||
|
expect(permissions.read).toEqual(explicitPermissions)
|
||||||
|
|
||||||
|
// revoke the explicit permissions
|
||||||
|
for (let level of [PermissionLevel.WRITE, PermissionLevel.READ]) {
|
||||||
|
await config.api.permission.revoke(
|
||||||
|
{
|
||||||
|
roleId: permissions[level].role,
|
||||||
|
resourceId: table._id!,
|
||||||
|
level,
|
||||||
|
},
|
||||||
|
{ status: 200 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// check base permissions
|
||||||
|
const { permissions: basePermissions } = await config.api.permission.get(
|
||||||
|
table._id!,
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const basePerms = { role: "BASIC", permissionType: "BASE" }
|
||||||
|
expect(basePermissions.write).toEqual(basePerms)
|
||||||
|
expect(basePermissions.read).toEqual(basePerms)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
it("updates a table", async () => {
|
it("updates a table", async () => {
|
||||||
const table = await config.api.table.save(
|
const table = await config.api.table.save(
|
||||||
|
|
|
@ -3381,6 +3381,124 @@ describe.each([
|
||||||
expect(rows).toHaveLength(1)
|
expect(rows).toHaveLength(1)
|
||||||
expect(rows[0].sum).toEqual(3)
|
expect(rows[0].sum).toEqual(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should be able to sort by group by field", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
quantity: {
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
name: "quantity",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
name: "price",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
type: ViewV2Type.CALCULATION,
|
||||||
|
schema: {
|
||||||
|
quantity: { visible: true },
|
||||||
|
sum: {
|
||||||
|
visible: true,
|
||||||
|
calculationType: CalculationType.SUM,
|
||||||
|
field: "price",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await config.api.row.bulkImport(table._id!, {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 2,
|
||||||
|
price: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const { rows } = await config.api.viewV2.search(view.id, {
|
||||||
|
query: {},
|
||||||
|
sort: "quantity",
|
||||||
|
sortOrder: SortOrder.DESCENDING,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
expect.objectContaining({ quantity: 2, sum: 10 }),
|
||||||
|
expect.objectContaining({ quantity: 1, sum: 3 }),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should be able to sort by a calculation", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
quantity: {
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
name: "quantity",
|
||||||
|
},
|
||||||
|
price: {
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
name: "price",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await config.api.row.bulkImport(table._id!, {
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 1,
|
||||||
|
price: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
quantity: 2,
|
||||||
|
price: 10,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
type: ViewV2Type.CALCULATION,
|
||||||
|
schema: {
|
||||||
|
quantity: { visible: true },
|
||||||
|
sum: {
|
||||||
|
visible: true,
|
||||||
|
calculationType: CalculationType.SUM,
|
||||||
|
field: "price",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { rows } = await config.api.viewV2.search(view.id, {
|
||||||
|
query: {},
|
||||||
|
sort: "sum",
|
||||||
|
sortOrder: SortOrder.DESCENDING,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toEqual([
|
||||||
|
expect.objectContaining({ quantity: 2, sum: 10 }),
|
||||||
|
expect.objectContaining({ quantity: 1, sum: 3 }),
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
!isLucene &&
|
!isLucene &&
|
||||||
|
@ -3428,6 +3546,50 @@ describe.each([
|
||||||
expect(response.rows).toHaveLength(1)
|
expect(response.rows).toHaveLength(1)
|
||||||
expect(response.rows[0].sum).toEqual(61)
|
expect(response.rows[0].sum).toEqual(61)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should be able to filter on a single user field in both the view query and search query", async () => {
|
||||||
|
const table = await config.api.table.save(
|
||||||
|
saveTableRequest({
|
||||||
|
schema: {
|
||||||
|
user: {
|
||||||
|
name: "user",
|
||||||
|
type: FieldType.BB_REFERENCE_SINGLE,
|
||||||
|
subtype: BBReferenceFieldSubType.USER,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await config.api.row.save(table._id!, {
|
||||||
|
user: config.getUser()._id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = await config.api.viewV2.create({
|
||||||
|
tableId: table._id!,
|
||||||
|
name: generator.guid(),
|
||||||
|
query: {
|
||||||
|
equal: {
|
||||||
|
user: "{{ [user].[_id] }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
user: {
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { rows } = await config.api.viewV2.search(view.id, {
|
||||||
|
query: {
|
||||||
|
equal: {
|
||||||
|
user: "{{ [user].[_id] }}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(1)
|
||||||
|
expect(rows[0].user._id).toEqual(config.getUser()._id)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("permissions", () => {
|
describe("permissions", () => {
|
||||||
|
|
|
@ -73,8 +73,7 @@ export async function getResourcePerms(
|
||||||
p[level] = { role, type: PermissionSource.BASE }
|
p[level] = { role, type: PermissionSource.BASE }
|
||||||
return p
|
return p
|
||||||
}, {})
|
}, {})
|
||||||
const result = Object.assign(basePermissions, permissions)
|
return Object.assign(basePermissions, permissions)
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDependantResources(
|
export async function getDependantResources(
|
||||||
|
|
|
@ -16,7 +16,7 @@ import * as external from "./search/external"
|
||||||
import { ExportRowsParams, ExportRowsResult } from "./search/types"
|
import { ExportRowsParams, ExportRowsResult } from "./search/types"
|
||||||
import { dataFilters } from "@budibase/shared-core"
|
import { dataFilters } from "@budibase/shared-core"
|
||||||
import sdk from "../../index"
|
import sdk from "../../index"
|
||||||
import { searchInputMapping } from "./search/utils"
|
import { checkFilters, searchInputMapping } from "./search/utils"
|
||||||
import { db, features } from "@budibase/backend-core"
|
import { db, features } from "@budibase/backend-core"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
|
import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
|
||||||
|
@ -81,6 +81,10 @@ export async function search(
|
||||||
options.query = {}
|
options.query = {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context) {
|
||||||
|
options.query = await enrichSearchContext(options.query, context)
|
||||||
|
}
|
||||||
|
|
||||||
// need to make sure filters in correct shape before checking for view
|
// need to make sure filters in correct shape before checking for view
|
||||||
options = searchInputMapping(table, options)
|
options = searchInputMapping(table, options)
|
||||||
|
|
||||||
|
@ -92,12 +96,15 @@ export async function search(
|
||||||
// Enrich saved query with ephemeral query params.
|
// Enrich saved query with ephemeral query params.
|
||||||
// We prevent searching on any fields that are saved as part of the query, as
|
// We prevent searching on any fields that are saved as part of the query, as
|
||||||
// that could let users find rows they should not be allowed to access.
|
// that could let users find rows they should not be allowed to access.
|
||||||
let viewQuery = dataFilters.buildQueryLegacy(view.query) || {}
|
let viewQuery = await enrichSearchContext(view.query || {}, context)
|
||||||
|
viewQuery = dataFilters.buildQueryLegacy(viewQuery) || {}
|
||||||
|
viewQuery = checkFilters(table, viewQuery)
|
||||||
delete viewQuery?.onEmptyFilter
|
delete viewQuery?.onEmptyFilter
|
||||||
|
|
||||||
const sqsEnabled = await features.flags.isEnabled("SQS")
|
const sqsEnabled = await features.flags.isEnabled("SQS")
|
||||||
const supportsLogicalOperators =
|
const supportsLogicalOperators =
|
||||||
isExternalTableID(view.tableId) || sqsEnabled
|
isExternalTableID(view.tableId) || sqsEnabled
|
||||||
|
|
||||||
if (!supportsLogicalOperators) {
|
if (!supportsLogicalOperators) {
|
||||||
// In the unlikely event that a Grouped Filter is in a non-SQS environment
|
// In the unlikely event that a Grouped Filter is in a non-SQS environment
|
||||||
// It needs to be ignored entirely
|
// It needs to be ignored entirely
|
||||||
|
@ -113,13 +120,12 @@ export async function search(
|
||||||
?.filter(filter => filter.field)
|
?.filter(filter => filter.field)
|
||||||
.map(filter => db.removeKeyNumbering(filter.field)) || []
|
.map(filter => db.removeKeyNumbering(filter.field)) || []
|
||||||
|
|
||||||
viewQuery ??= {}
|
|
||||||
// Carry over filters for unused fields
|
// Carry over filters for unused fields
|
||||||
Object.keys(options.query).forEach(key => {
|
Object.keys(options.query).forEach(key => {
|
||||||
const operator = key as Exclude<SearchFilterKey, LogicalOperator>
|
const operator = key as Exclude<SearchFilterKey, LogicalOperator>
|
||||||
Object.keys(options.query[operator] || {}).forEach(field => {
|
Object.keys(options.query[operator] || {}).forEach(field => {
|
||||||
if (!existingFields.includes(db.removeKeyNumbering(field))) {
|
if (!existingFields.includes(db.removeKeyNumbering(field))) {
|
||||||
viewQuery![operator]![field] = options.query[operator]![field]
|
viewQuery[operator]![field] = options.query[operator]![field]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -137,10 +143,6 @@ export async function search(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context) {
|
|
||||||
options.query = await enrichSearchContext(options.query, context)
|
|
||||||
}
|
|
||||||
|
|
||||||
options.query = dataFilters.cleanupQuery(options.query)
|
options.query = dataFilters.cleanupQuery(options.query)
|
||||||
options.query = dataFilters.fixupFilterArrays(options.query)
|
options.query = dataFilters.fixupFilterArrays(options.query)
|
||||||
|
|
||||||
|
|
|
@ -418,6 +418,16 @@ export async function search(
|
||||||
|
|
||||||
if (params.sort) {
|
if (params.sort) {
|
||||||
const sortField = table.schema[params.sort]
|
const sortField = table.schema[params.sort]
|
||||||
|
const isAggregateField = aggregations.some(agg => agg.name === params.sort)
|
||||||
|
|
||||||
|
if (isAggregateField) {
|
||||||
|
request.sort = {
|
||||||
|
[params.sort]: {
|
||||||
|
direction: params.sortOrder || SortOrder.ASCENDING,
|
||||||
|
type: SortType.NUMBER,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if (sortField) {
|
||||||
const sortType =
|
const sortType =
|
||||||
sortField.type === FieldType.NUMBER ? SortType.NUMBER : SortType.STRING
|
sortField.type === FieldType.NUMBER ? SortType.NUMBER : SortType.STRING
|
||||||
request.sort = {
|
request.sort = {
|
||||||
|
@ -426,6 +436,9 @@ export async function search(
|
||||||
type: sortType as SortType,
|
type: sortType as SortType,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unable to sort by ${params.sort}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.bookmark && typeof params.bookmark !== "number") {
|
if (params.bookmark && typeof params.bookmark !== "number") {
|
||||||
|
|
|
@ -80,11 +80,10 @@ function userColumnMapping(column: string, filters: SearchFilters) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// maps through the search parameters to check if any of the inputs are invalid
|
export function checkFilters(
|
||||||
// based on the table schema, converts them to something that is valid.
|
table: Table,
|
||||||
export function searchInputMapping(table: Table, options: RowSearchParams) {
|
filters: SearchFilters
|
||||||
// need an internal function to loop over filters, because this takes the full options
|
): SearchFilters {
|
||||||
function checkFilters(filters: SearchFilters) {
|
|
||||||
for (let [key, column] of Object.entries(table.schema || {})) {
|
for (let [key, column] of Object.entries(table.schema || {})) {
|
||||||
switch (column.type) {
|
switch (column.type) {
|
||||||
case FieldType.BB_REFERENCE_SINGLE: {
|
case FieldType.BB_REFERENCE_SINGLE: {
|
||||||
|
@ -105,10 +104,17 @@ export function searchInputMapping(table: Table, options: RowSearchParams) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dataFilters.recurseLogicalOperators(filters, checkFilters)
|
return dataFilters.recurseLogicalOperators(filters, filters =>
|
||||||
|
checkFilters(table, filters)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maps through the search parameters to check if any of the inputs are invalid
|
||||||
|
// based on the table schema, converts them to something that is valid.
|
||||||
|
export function searchInputMapping(table: Table, options: RowSearchParams) {
|
||||||
|
// need an internal function to loop over filters, because this takes the full options
|
||||||
if (options.query) {
|
if (options.query) {
|
||||||
options.query = checkFilters(options.query)
|
options.query = checkFilters(table, options.query)
|
||||||
}
|
}
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
BBReferenceFieldSubType,
|
||||||
CalculationType,
|
CalculationType,
|
||||||
canGroupBy,
|
canGroupBy,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
|
@ -7,6 +8,7 @@ import {
|
||||||
PermissionLevel,
|
PermissionLevel,
|
||||||
RelationSchemaField,
|
RelationSchemaField,
|
||||||
RenameColumn,
|
RenameColumn,
|
||||||
|
RequiredKeys,
|
||||||
Table,
|
Table,
|
||||||
TableSchema,
|
TableSchema,
|
||||||
View,
|
View,
|
||||||
|
@ -325,13 +327,26 @@ export async function enrichSchema(
|
||||||
const viewFieldSchema = viewFields[relTableFieldName]
|
const viewFieldSchema = viewFields[relTableFieldName]
|
||||||
const isVisible = !!viewFieldSchema?.visible
|
const isVisible = !!viewFieldSchema?.visible
|
||||||
const isReadonly = !!viewFieldSchema?.readonly
|
const isReadonly = !!viewFieldSchema?.readonly
|
||||||
result[relTableFieldName] = {
|
const enrichedFieldSchema: RequiredKeys<ViewV2ColumnEnriched> = {
|
||||||
...relTableField,
|
|
||||||
...viewFieldSchema,
|
|
||||||
name: relTableField.name,
|
|
||||||
visible: isVisible,
|
visible: isVisible,
|
||||||
readonly: isReadonly,
|
readonly: isReadonly,
|
||||||
|
order: viewFieldSchema?.order,
|
||||||
|
width: viewFieldSchema?.width,
|
||||||
|
|
||||||
|
icon: relTableField.icon,
|
||||||
|
type: relTableField.type,
|
||||||
|
subtype: relTableField.subtype,
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!enrichedFieldSchema.icon &&
|
||||||
|
relTableField.type === FieldType.BB_REFERENCE &&
|
||||||
|
relTableField.subtype === BBReferenceFieldSubType.USER &&
|
||||||
|
!helpers.schema.isDeprecatedSingleUserColumn(relTableField)
|
||||||
|
) {
|
||||||
|
// Forcing the icon, otherwise we would need to pass the constraints to show the proper icon
|
||||||
|
enrichedFieldSchema.icon = "UserGroup"
|
||||||
|
}
|
||||||
|
result[relTableFieldName] = enrichedFieldSchema
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -355,13 +355,11 @@ describe("table sdk", () => {
|
||||||
visible: true,
|
visible: true,
|
||||||
columns: {
|
columns: {
|
||||||
title: {
|
title: {
|
||||||
name: "title",
|
|
||||||
type: "string",
|
type: "string",
|
||||||
visible: true,
|
visible: true,
|
||||||
readonly: true,
|
readonly: true,
|
||||||
},
|
},
|
||||||
age: {
|
age: {
|
||||||
name: "age",
|
|
||||||
type: "number",
|
type: "number",
|
||||||
visible: false,
|
visible: false,
|
||||||
readonly: false,
|
readonly: false,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { permissions, roles } from "@budibase/backend-core"
|
import { DocumentType, permissions, roles } from "@budibase/backend-core"
|
||||||
import { DocumentType, VirtualDocumentType } from "../db/utils"
|
import { VirtualDocumentType } from "../db/utils"
|
||||||
import { getDocumentType, getVirtualDocumentType } from "@budibase/types"
|
import { getDocumentType, getVirtualDocumentType } from "@budibase/types"
|
||||||
|
|
||||||
export const CURRENTLY_SUPPORTED_LEVELS: string[] = [
|
export const CURRENTLY_SUPPORTED_LEVELS: string[] = [
|
||||||
|
@ -19,6 +19,7 @@ export function getPermissionType(resourceId: string) {
|
||||||
switch (docType) {
|
switch (docType) {
|
||||||
case DocumentType.TABLE:
|
case DocumentType.TABLE:
|
||||||
case DocumentType.ROW:
|
case DocumentType.ROW:
|
||||||
|
case DocumentType.DATASOURCE_PLUS:
|
||||||
return permissions.PermissionType.TABLE
|
return permissions.PermissionType.TABLE
|
||||||
case DocumentType.AUTOMATION:
|
case DocumentType.AUTOMATION:
|
||||||
return permissions.PermissionType.AUTOMATION
|
return permissions.PermissionType.AUTOMATION
|
||||||
|
|
|
@ -67,7 +67,7 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
|
||||||
[FieldType.SIGNATURE_SINGLE]: false,
|
[FieldType.SIGNATURE_SINGLE]: false,
|
||||||
[FieldType.LINK]: false,
|
[FieldType.LINK]: false,
|
||||||
[FieldType.BB_REFERENCE]: false,
|
[FieldType.BB_REFERENCE]: false,
|
||||||
[FieldType.BB_REFERENCE_SINGLE]: false,
|
[FieldType.BB_REFERENCE_SINGLE]: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canBeDisplayColumn(type: FieldType): boolean {
|
export function canBeDisplayColumn(type: FieldType): boolean {
|
||||||
|
|
|
@ -172,7 +172,7 @@ export const processSearchFilters = (
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return a.localeCompare(b)
|
return a.localeCompare(b)
|
||||||
})
|
})
|
||||||
.filter(key => key in filter)
|
.filter(key => filter[key])
|
||||||
|
|
||||||
if (filterPropertyKeys.length == 1) {
|
if (filterPropertyKeys.length == 1) {
|
||||||
const key = filterPropertyKeys[0],
|
const key = filterPropertyKeys[0],
|
||||||
|
|
|
@ -126,6 +126,7 @@ export interface BBReferenceSingleFieldMetadata
|
||||||
extends Omit<BaseFieldSchema, "subtype"> {
|
extends Omit<BaseFieldSchema, "subtype"> {
|
||||||
type: FieldType.BB_REFERENCE_SINGLE
|
type: FieldType.BB_REFERENCE_SINGLE
|
||||||
subtype: Exclude<BBReferenceFieldSubType, BBReferenceFieldSubType.USERS>
|
subtype: Exclude<BBReferenceFieldSubType, BBReferenceFieldSubType.USERS>
|
||||||
|
default?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentFieldMetadata extends BaseFieldSchema {
|
export interface AttachmentFieldMetadata extends BaseFieldSchema {
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents"
|
import {
|
||||||
|
FieldSchema,
|
||||||
|
FieldSubType,
|
||||||
|
FieldType,
|
||||||
|
RelationSchemaField,
|
||||||
|
ViewV2,
|
||||||
|
} from "../documents"
|
||||||
|
|
||||||
export interface ViewV2Enriched extends ViewV2 {
|
export interface ViewV2Enriched extends ViewV2 {
|
||||||
schema?: {
|
schema?: {
|
||||||
|
@ -8,4 +14,7 @@ export interface ViewV2Enriched extends ViewV2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ViewV2ColumnEnriched = RelationSchemaField & FieldSchema
|
export interface ViewV2ColumnEnriched extends RelationSchemaField {
|
||||||
|
type: FieldType
|
||||||
|
subtype?: FieldSubType
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue