Merge branch 'master' of github.com:Budibase/budibase into feature/role-multi-inheritance

This commit is contained in:
mike12345567 2024-10-14 12:57:22 +01:00
commit 243391d6cb
23 changed files with 416 additions and 127 deletions

View File

@ -3,26 +3,50 @@ name: deploy-featurebranch
on:
pull_request:
types: [
labeled,
# default types below (https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request)
opened,
synchronize,
reopened,
]
labeled,
# default types below (https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request)
opened,
synchronize,
reopened,
]
jobs:
release:
if: |
(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
steps:
- 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
env:
PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
PAYLOAD_LICENSE_TYPE: "free"
PAYLOAD_LICENSE_TYPE: ${{ env.PAYLOAD_LICENSE_TYPE }}
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-deploy

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.32.15",
"version": "2.32.17",
"npmClient": "yarn",
"packages": [
"packages/*",

View File

@ -273,6 +273,7 @@ class InternalBuilder {
const col = parts.pop()!
const schema = this.table.schema[col]
let identifier = this.quotedIdentifier(field)
if (
schema.type === FieldType.STRING ||
schema.type === FieldType.LONGFORM ||
@ -957,6 +958,13 @@ class InternalBuilder {
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 {
let { sort, resource } = this.query
const primaryKey = this.table.primary
@ -979,13 +987,17 @@ class InternalBuilder {
nulls = value.direction === SortOrder.ASCENDING ? "first" : "last"
}
let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
query = query.orderByRaw(
`${this.convertClobs(composite)} ${direction} nulls ${nulls}`
)
if (this.isAggregateField(key)) {
query = query.orderBy(key, direction, nulls)
} else {
query = query.orderBy(composite, direction, nulls)
let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
query = query.orderByRaw(
`${this.convertClobs(composite)} ${direction} nulls ${nulls}`
)
} else {
query = query.orderBy(composite, direction, nulls)
}
}
}
}

View File

@ -14,7 +14,13 @@
function daysUntilCancel() {
const cancelAt = license?.billing?.subscription?.cancelAt
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>
@ -28,7 +34,7 @@
extraLinkAction={$licensing.goToUpgradePage}
showCloseButton={false}
>
Your free trial will end in {daysUntilCancel()} days.
Your free trial will end {daysUntilCancel()}
</Banner>
</div>
{/if}

View File

@ -4,6 +4,8 @@
import { GridRowHeight, GridColumns } from "constants"
import { memo } from "@budibase/frontend-core"
export let onClick
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
const context = getContext("context")
@ -121,15 +123,19 @@
})
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
bind:this={ref}
class="grid"
class:mobile
class:clickable={!!onClick}
bind:clientWidth={width}
bind:clientHeight={height}
use:styleable={$styles}
data-cols={GridColumns}
data-col-size={colSize}
on:click={onClick}
>
{#if inBuilder}
<div class="underlay">
@ -176,6 +182,9 @@
.placeholder.first-col {
border-left: 1px solid var(--spectrum-global-color-gray-900);
}
.clickable {
cursor: pointer;
}
/* Highlight grid lines when resizing children */
:global(.grid.highlight > .underlay) {

View File

@ -27,9 +27,7 @@
let candidateIndex
let lastSearchId
let searching = false
let container
let anchor
let relationshipFields
$: fieldValue = parseValue(value)
$: oneRowOnly = schema?.relationshipType === "one-to-many"
@ -56,12 +54,6 @@
return acc
}, {})
$: showRelationshipFields =
relationshipFields &&
Object.keys(relationshipFields).length &&
focused &&
!isOpen
const parseValue = value => {
if (Array.isArray(value) && value.every(x => x?._id)) {
return value
@ -242,14 +234,6 @@
return value
}
const displayRelationshipFields = relationship => {
relationshipFields = relationFields[relationship._id]
}
const hideRelationshipFields = () => {
relationshipFields = undefined
}
onMount(() => {
api = {
focus: open,
@ -269,7 +253,7 @@
style="--color:{color};"
bind:this={anchor}
>
<div class="container" bind:this={container}>
<div class="container">
<div
class="values"
class:wrap={editable || contentLines > 1}
@ -281,9 +265,7 @@
<div
class="badge"
class:extra-info={!!relationFields[relationship._id]}
on:mouseover={() => displayRelationshipFields(relationship)}
on:focus={() => {}}
on:mouseleave={() => hideRelationshipFields()}
>
<span>
{readable(
@ -358,21 +340,6 @@
</GridPopover>
{/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>
.wrapper {
flex: 1 1 auto;
@ -539,25 +506,4 @@
.search :global(.spectrum-Form-item) {
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>

View File

@ -125,7 +125,7 @@
subtype: column.subtype,
visible: column.visible,
readonly: column.readonly,
constraints: column.constraints, // This is needed to properly display "users" column
icon: column.icon,
},
}
})

View File

@ -19,6 +19,10 @@ export const getCellID = (rowId, fieldName) => {
}
export const getColumnIcon = column => {
if (column.schema.icon) {
return column.schema.icon
}
if (column.schema.autocolumn) {
return "MagicWand"
}

@ -1 +1 @@
Subproject commit aca9828117bb97f54f40ee359f1a3f6e259174e7
Subproject commit fc4c7f4925139af078480217965c3d6338dc0a7f

View File

@ -14,6 +14,7 @@ import {
InternalTable,
tenancy,
features,
utils,
} from "@budibase/backend-core"
import { quotas } from "@budibase/pro"
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("string column", () => {
beforeAll(async () => {

View File

@ -21,6 +21,7 @@ import {
ViewCalculation,
ViewV2Enriched,
RowExportFormat,
PermissionLevel,
} from "@budibase/types"
import { checkBuilderEndpoint } from "./utilities/TestFunctions"
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", () => {
it("updates a table", async () => {
const table = await config.api.table.save(

View File

@ -3381,6 +3381,124 @@ describe.each([
expect(rows).toHaveLength(1)
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 &&
@ -3428,6 +3546,50 @@ describe.each([
expect(response.rows).toHaveLength(1)
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", () => {

View File

@ -73,8 +73,7 @@ export async function getResourcePerms(
p[level] = { role, type: PermissionSource.BASE }
return p
}, {})
const result = Object.assign(basePermissions, permissions)
return result
return Object.assign(basePermissions, permissions)
}
export async function getDependantResources(

View File

@ -16,7 +16,7 @@ import * as external from "./search/external"
import { ExportRowsParams, ExportRowsResult } from "./search/types"
import { dataFilters } from "@budibase/shared-core"
import sdk from "../../index"
import { searchInputMapping } from "./search/utils"
import { checkFilters, searchInputMapping } from "./search/utils"
import { db, features } from "@budibase/backend-core"
import tracer from "dd-trace"
import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
@ -81,6 +81,10 @@ export async function search(
options.query = {}
}
if (context) {
options.query = await enrichSearchContext(options.query, context)
}
// need to make sure filters in correct shape before checking for view
options = searchInputMapping(table, options)
@ -92,12 +96,15 @@ export async function search(
// Enrich saved query with ephemeral query params.
// 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.
let viewQuery = dataFilters.buildQueryLegacy(view.query) || {}
let viewQuery = await enrichSearchContext(view.query || {}, context)
viewQuery = dataFilters.buildQueryLegacy(viewQuery) || {}
viewQuery = checkFilters(table, viewQuery)
delete viewQuery?.onEmptyFilter
const sqsEnabled = await features.flags.isEnabled("SQS")
const supportsLogicalOperators =
isExternalTableID(view.tableId) || sqsEnabled
if (!supportsLogicalOperators) {
// In the unlikely event that a Grouped Filter is in a non-SQS environment
// It needs to be ignored entirely
@ -113,13 +120,12 @@ export async function search(
?.filter(filter => filter.field)
.map(filter => db.removeKeyNumbering(filter.field)) || []
viewQuery ??= {}
// Carry over filters for unused fields
Object.keys(options.query).forEach(key => {
const operator = key as Exclude<SearchFilterKey, LogicalOperator>
Object.keys(options.query[operator] || {}).forEach(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.fixupFilterArrays(options.query)

View File

@ -418,13 +418,26 @@ export async function search(
if (params.sort) {
const sortField = table.schema[params.sort]
const sortType =
sortField.type === FieldType.NUMBER ? SortType.NUMBER : SortType.STRING
request.sort = {
[mapToUserColumn(sortField.name)]: {
direction: params.sortOrder || SortOrder.ASCENDING,
type: sortType as SortType,
},
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 =
sortField.type === FieldType.NUMBER ? SortType.NUMBER : SortType.STRING
request.sort = {
[mapToUserColumn(sortField.name)]: {
direction: params.sortOrder || SortOrder.ASCENDING,
type: sortType as SortType,
},
}
} else {
throw new Error(`Unable to sort by ${params.sort}`)
}
}

View File

@ -80,35 +80,41 @@ function userColumnMapping(column: string, filters: SearchFilters) {
})
}
export function checkFilters(
table: Table,
filters: SearchFilters
): SearchFilters {
for (let [key, column] of Object.entries(table.schema || {})) {
switch (column.type) {
case FieldType.BB_REFERENCE_SINGLE: {
const subtype = column.subtype
switch (subtype) {
case BBReferenceFieldSubType.USER:
userColumnMapping(key, filters)
break
default:
utils.unreachable(subtype)
}
break
}
case FieldType.BB_REFERENCE: {
userColumnMapping(key, filters)
break
}
}
}
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
function checkFilters(filters: SearchFilters) {
for (let [key, column] of Object.entries(table.schema || {})) {
switch (column.type) {
case FieldType.BB_REFERENCE_SINGLE: {
const subtype = column.subtype
switch (subtype) {
case BBReferenceFieldSubType.USER:
userColumnMapping(key, filters)
break
default:
utils.unreachable(subtype)
}
break
}
case FieldType.BB_REFERENCE: {
userColumnMapping(key, filters)
break
}
}
}
return dataFilters.recurseLogicalOperators(filters, checkFilters)
}
if (options.query) {
options.query = checkFilters(options.query)
options.query = checkFilters(table, options.query)
}
return options
}

View File

@ -1,4 +1,5 @@
import {
BBReferenceFieldSubType,
CalculationType,
canGroupBy,
FeatureFlag,
@ -7,6 +8,7 @@ import {
PermissionLevel,
RelationSchemaField,
RenameColumn,
RequiredKeys,
Table,
TableSchema,
View,
@ -325,13 +327,26 @@ export async function enrichSchema(
const viewFieldSchema = viewFields[relTableFieldName]
const isVisible = !!viewFieldSchema?.visible
const isReadonly = !!viewFieldSchema?.readonly
result[relTableFieldName] = {
...relTableField,
...viewFieldSchema,
name: relTableField.name,
const enrichedFieldSchema: RequiredKeys<ViewV2ColumnEnriched> = {
visible: isVisible,
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
}

View File

@ -355,13 +355,11 @@ describe("table sdk", () => {
visible: true,
columns: {
title: {
name: "title",
type: "string",
visible: true,
readonly: true,
},
age: {
name: "age",
type: "number",
visible: false,
readonly: false,

View File

@ -1,5 +1,5 @@
import { permissions, roles } from "@budibase/backend-core"
import { DocumentType, VirtualDocumentType } from "../db/utils"
import { DocumentType, permissions, roles } from "@budibase/backend-core"
import { VirtualDocumentType } from "../db/utils"
import { getDocumentType, getVirtualDocumentType } from "@budibase/types"
export const CURRENTLY_SUPPORTED_LEVELS: string[] = [
@ -19,6 +19,7 @@ export function getPermissionType(resourceId: string) {
switch (docType) {
case DocumentType.TABLE:
case DocumentType.ROW:
case DocumentType.DATASOURCE_PLUS:
return permissions.PermissionType.TABLE
case DocumentType.AUTOMATION:
return permissions.PermissionType.AUTOMATION

View File

@ -67,7 +67,7 @@ const allowDefaultColumnByType: Record<FieldType, boolean> = {
[FieldType.SIGNATURE_SINGLE]: false,
[FieldType.LINK]: false,
[FieldType.BB_REFERENCE]: false,
[FieldType.BB_REFERENCE_SINGLE]: false,
[FieldType.BB_REFERENCE_SINGLE]: true,
}
export function canBeDisplayColumn(type: FieldType): boolean {

View File

@ -172,7 +172,7 @@ export const processSearchFilters = (
.sort((a, b) => {
return a.localeCompare(b)
})
.filter(key => key in filter)
.filter(key => filter[key])
if (filterPropertyKeys.length == 1) {
const key = filterPropertyKeys[0],

View File

@ -126,6 +126,7 @@ export interface BBReferenceSingleFieldMetadata
extends Omit<BaseFieldSchema, "subtype"> {
type: FieldType.BB_REFERENCE_SINGLE
subtype: Exclude<BBReferenceFieldSubType, BBReferenceFieldSubType.USERS>
default?: string
}
export interface AttachmentFieldMetadata extends BaseFieldSchema {

View File

@ -1,4 +1,10 @@
import { FieldSchema, RelationSchemaField, ViewV2 } from "../documents"
import {
FieldSchema,
FieldSubType,
FieldType,
RelationSchemaField,
ViewV2,
} from "../documents"
export interface ViewV2Enriched extends ViewV2 {
schema?: {
@ -8,4 +14,7 @@ export interface ViewV2Enriched extends ViewV2 {
}
}
export type ViewV2ColumnEnriched = RelationSchemaField & FieldSchema
export interface ViewV2ColumnEnriched extends RelationSchemaField {
type: FieldType
subtype?: FieldSubType
}