Merge remote-tracking branch 'origin/master' into BUDI-9127/allow-selecting-oauth2-configs

This commit is contained in:
Adria Navarro 2025-03-19 09:14:25 +01:00
commit f455fb1bee
42 changed files with 1259 additions and 492 deletions

View File

@ -47,6 +47,9 @@ export default [
parserOptions: { parserOptions: {
allowImportExportEverywhere: true, allowImportExportEverywhere: true,
svelteFeatures: {
experimentalGenerics: true,
},
}, },
}, },

View File

@ -141,19 +141,23 @@ function generateSchema(
.references(`${tableName}.${relatedPrimary}`) .references(`${tableName}.${relatedPrimary}`)
} }
break break
case FieldType.SIGNATURE_SINGLE:
case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE:
// single attachments are stored as an object, multi attachments
// are stored as an array
schema.json(key)
break
case FieldType.FORMULA: case FieldType.FORMULA:
// This is allowed, but nothing to do on the external datasource // This is allowed, but nothing to do on the external datasource
break break
case FieldType.AI: case FieldType.AI:
// This is allowed, but nothing to do on the external datasource // This is allowed, but nothing to do on the external datasource
break break
case FieldType.ATTACHMENTS:
case FieldType.ATTACHMENT_SINGLE:
case FieldType.SIGNATURE_SINGLE:
case FieldType.AUTO: case FieldType.AUTO:
case FieldType.JSON: case FieldType.JSON:
case FieldType.INTERNAL: case FieldType.INTERNAL:
throw `${column.type} is not a valid SQL type` throw new Error(`${column.type} is not a valid SQL type`)
default: default:
utils.unreachable(columnType) utils.unreachable(columnType)

View File

@ -20,7 +20,7 @@
export let searchTerm: string | null = null export let searchTerm: string | null = null
export let customPopoverHeight: string | undefined = undefined export let customPopoverHeight: string | undefined = undefined
export let open: boolean = false export let open: boolean = false
export let loading: boolean export let loading: boolean = false
export let onOptionMouseenter = () => {} export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {} export let onOptionMouseleave = () => {}

View File

@ -3,7 +3,7 @@
import DatePicker from "./Core/DatePicker/DatePicker.svelte" import DatePicker from "./Core/DatePicker/DatePicker.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = null export let value = undefined
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let disabled = false export let disabled = false

View File

@ -1,29 +1,31 @@
<script> <script lang="ts" generics="Option">
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import Multiselect from "./Core/Multiselect.svelte" import Multiselect from "./Core/Multiselect.svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
export let value = [] export let value: string[] | string = []
export let label = null export let label: string | undefined = undefined
export let disabled = false export let disabled = false
export let readonly = false export let readonly = false
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error: string | undefined = undefined
export let placeholder = null export let placeholder: string | undefined = undefined
export let options = [] export let options: Option[] = []
export let getOptionLabel = option => option export let getOptionLabel = (option: Option) => option
export let getOptionValue = option => option export let getOptionValue = (option: Option) => option
export let sort = false export let sort = false
export let autoWidth = false export let autoWidth = false
export let autocomplete = false export let autocomplete = false
export let searchTerm = null export let searchTerm: string | undefined = undefined
export let customPopoverHeight export let customPopoverHeight: string | undefined = undefined
export let helpText = null export let helpText: string | undefined = undefined
export let onOptionMouseenter = () => {} export let onOptionMouseenter = () => {}
export let onOptionMouseleave = () => {} export let onOptionMouseleave = () => {}
$: arrayValue = value && !Array.isArray(value) ? [value] : (value as string[])
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = (e: any) => {
value = e.detail value = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
@ -31,10 +33,9 @@
<Field {helpText} {label} {labelPosition} {error}> <Field {helpText} {label} {labelPosition} {error}>
<Multiselect <Multiselect
{error}
{disabled} {disabled}
{readonly} {readonly}
{value} bind:value={arrayValue}
{options} {options}
{placeholder} {placeholder}
{sort} {sort}

View File

@ -3,10 +3,10 @@
import Switch from "./Core/Switch.svelte" import Switch from "./Core/Switch.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let value = null export let value = undefined
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let text = null export let text = undefined
export let disabled = false export let disabled = false
export let error = null export let error = null
export let helpText = null export let helpText = null

View File

@ -89,17 +89,18 @@
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */ /* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
.list-item.selected { .list-item.selected {
background-color: var(--spectrum-global-color-blue-100); background-color: var(--spectrum-global-color-blue-100);
border: 1px solid var(--spectrum-global-color-blue-400); border: none;
} }
.list-item.selected:after { .list-item.selected:after {
content: ""; content: "";
position: absolute; position: absolute;
height: 100%; height: 100%;
width: 100%; width: 100%;
border: 1px solid var(--spectrum-global-color-blue-400);
pointer-events: none; pointer-events: none;
top: 0; top: 0;
left: 0; left: 0;
border-radius: 4px; border-radius: inherit;
box-sizing: border-box; box-sizing: border-box;
z-index: 1; z-index: 1;
opacity: 0.5; opacity: 0.5;

View File

@ -1,40 +1,42 @@
<script> <script lang="ts">
import { import {
Input,
Button,
Label,
Select,
Multiselect,
Toggle,
Icon,
DatePicker,
Modal,
notifications,
Layout,
AbsTooltip, AbsTooltip,
Button,
DatePicker,
Icon,
Input,
Label,
Layout,
Modal,
Multiselect,
notifications,
ProgressCircle, ProgressCircle,
Select,
Toggle,
TooltipPosition,
TooltipType,
} from "@budibase/bbui" } from "@budibase/bbui"
import { import {
canHaveDefaultColumn,
helpers,
PROTECTED_EXTERNAL_COLUMNS,
PROTECTED_INTERNAL_COLUMNS,
SWITCHABLE_TYPES, SWITCHABLE_TYPES,
ValidColumnNameRegex, ValidColumnNameRegex,
helpers,
PROTECTED_INTERNAL_COLUMNS,
PROTECTED_EXTERNAL_COLUMNS,
canHaveDefaultColumn,
} from "@budibase/shared-core" } from "@budibase/shared-core"
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { createEventDispatcher, getContext, onMount } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "@/stores/builder" import { datasources, tables } from "@/stores/builder"
import { licensing } from "@/stores/portal" import { licensing } from "@/stores/portal"
import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants" import { TableNames, UNEDITABLE_USER_FIELDS } from "@/constants"
import { import {
FIELDS,
RelationshipType,
PrettyRelationshipDefinitions,
DB_TYPE_EXTERNAL, DB_TYPE_EXTERNAL,
FIELDS,
PrettyRelationshipDefinitions,
RelationshipType,
} from "@/constants/backend" } from "@/constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "@/helpers/utils" import { buildAutoColumn, getAutoColumnInformation } from "@/helpers/utils"
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte" import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
import AIFieldConfiguration from "@/components/common/AIFieldConfiguration.svelte" import AIFieldConfiguration from "@/components/common/AIFieldConfiguration.svelte"
import ModalBindableInput from "@/components/common/bindings/ModalBindableInput.svelte" import ModalBindableInput from "@/components/common/bindings/ModalBindableInput.svelte"
@ -43,42 +45,52 @@
import { import {
BBReferenceFieldSubType, BBReferenceFieldSubType,
FieldType, FieldType,
FormulaType,
SourceName, SourceName,
} from "@budibase/types" } from "@budibase/types"
import RelationshipSelector from "@/components/common/RelationshipSelector.svelte" import RelationshipSelector from "@/components/common/RelationshipSelector.svelte"
import { RowUtils, canBeDisplayColumn } from "@budibase/frontend-core" import { canBeDisplayColumn, RowUtils } from "@budibase/frontend-core"
import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte" import ServerBindingPanel from "@/components/common/bindings/ServerBindingPanel.svelte"
import OptionsEditor from "./OptionsEditor.svelte" import OptionsEditor from "./OptionsEditor.svelte"
import { getUserBindings } from "@/dataBinding" import { getUserBindings } from "@/dataBinding"
import type {
Table,
Datasource,
FieldSchema,
UIField,
AutoFieldSubType,
FormulaResponseType,
FieldSchemaConfig,
} from "@budibase/types"
export let field export let field: FieldSchema
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const { dispatch: gridDispatch, rows } = getContext("grid") const { dispatch: gridDispatch, rows } = getContext("grid") as any
const SafeID = `${makePropSafe("user")}.${makePropSafe("_id")}` const SafeID = `${makePropSafe("user")}.${makePropSafe("_id")}`
const SingleUserDefault = `{{ ${SafeID} }}` const SingleUserDefault = `{{ ${SafeID} }}`
const MultiUserDefault = `{{ js "${btoa(`return [$("${SafeID}")]`)}" }}` const MultiUserDefault = `{{ js "${btoa(`return [$("${SafeID}")]`)}" }}`
let mounted = false let mounted = false
let originalName let originalName: string | undefined
let linkEditDisabled let linkEditDisabled: boolean = false
let primaryDisplay let hasPrimaryDisplay: boolean
let indexes = [...($tables.selected.indexes || [])] let isCreating: boolean | undefined
let isCreating = undefined
let relationshipPart1 = PrettyRelationshipDefinitions.MANY let relationshipPart1 = PrettyRelationshipDefinitions.MANY
let relationshipPart2 = PrettyRelationshipDefinitions.ONE let relationshipPart2 = PrettyRelationshipDefinitions.ONE
let relationshipTableIdPrimary = null let relationshipTableIdPrimary: string | undefined
let relationshipTableIdSecondary = null let relationshipTableIdSecondary: string | undefined
let table = $tables.selected let table: Table | undefined = $tables.selected
let confirmDeleteDialog let confirmDeleteDialog: any
let savingColumn let savingColumn: boolean
let deleteColName let deleteColName: string | undefined
let jsonSchemaModal let jsonSchemaModal: any
let editableColumn = { let editableColumn: FieldSchemaConfig = {
type: FIELDS.STRING.type, type: FieldType.STRING,
constraints: FIELDS.STRING.constraints, constraints: FIELDS.STRING.constraints as any,
name: "",
// Initial value for column name in other table for linked records // Initial value for column name in other table for linked records
fieldName: $tables.selected.name, fieldName: $tables.selected?.name || "",
} }
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
@ -99,17 +111,42 @@
const autoColumnInfo = getAutoColumnInformation() const autoColumnInfo = getAutoColumnInformation()
let optionsValid = true let optionsValid = true
// a fixed order for the types to stop them moving around
// we've never really guaranteed an order to these, which means that
// they can move around very easily
const fixedTypeOrder = [
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
FIELDS.AI,
FIELDS.LONGFORM,
FIELDS.USER,
FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.FORMULA,
FIELDS.JSON,
FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT,
FIELDS.AUTO,
]
$: rowGoldenSample = RowUtils.generateGoldenSample($rows) $: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: aiEnabled = $: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAiEnabled $licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: if (primaryDisplay) { $: if (hasPrimaryDisplay && editableColumn.constraints) {
editableColumn.constraints.presence = { allowEmpty: false } editableColumn.constraints.presence = { allowEmpty: false }
} }
$: { $: {
// this parses any changes the user has made when creating a new internal relationship // this parses any changes the user has made when creating a new internal relationship
// into what we expect the schema to look like // into what we expect the schema to look like
if (editableColumn.type === FieldType.LINK) { if (editableColumn.type === FieldType.LINK) {
relationshipTableIdPrimary = table._id relationshipTableIdPrimary = table?._id
if (relationshipPart1 === PrettyRelationshipDefinitions.ONE) { if (relationshipPart1 === PrettyRelationshipDefinitions.ONE) {
relationshipOpts2 = relationshipOpts2.filter( relationshipOpts2 = relationshipOpts2.filter(
opt => opt !== PrettyRelationshipDefinitions.ONE opt => opt !== PrettyRelationshipDefinitions.ONE
@ -129,36 +166,44 @@
editableColumn.relationshipType = Object.entries(relationshipMap).find( editableColumn.relationshipType = Object.entries(relationshipMap).find(
([_, parts]) => ([_, parts]) =>
parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2 parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2
)?.[0] )?.[0] as RelationshipType
if (relationshipTableIdSecondary) {
// Set the tableId based on the selected table // Set the tableId based on the selected table
editableColumn.tableId = relationshipTableIdSecondary editableColumn.tableId = relationshipTableIdSecondary
} }
} }
}
$: initialiseField(field, savingColumn) $: initialiseField(field, savingColumn)
$: checkConstraints(editableColumn) $: checkConstraints(editableColumn)
$: required = $: required =
primaryDisplay || hasPrimaryDisplay ||
editableColumn?.constraints?.presence === true || editableColumn?.constraints?.presence === true ||
editableColumn?.constraints?.presence?.allowEmpty === false (editableColumn?.constraints?.presence as any)?.allowEmpty === false
$: uneditable = $: uneditable =
$tables.selected?._id === TableNames.USERS && $tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(editableColumn.name) UNEDITABLE_USER_FIELDS.includes(editableColumn.name || "")
$: invalid = $: invalid =
!editableColumn?.name || !editableColumn?.name ||
(editableColumn?.type === FieldType.LINK && !editableColumn?.tableId) || (editableColumn?.type === FieldType.LINK && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0 || Object.keys(errors || {}).length !== 0 ||
!optionsValid !optionsValid
$: errors = checkErrors(editableColumn) $: errors = checkErrors(editableColumn)
$: datasource = $datasources.list.find( $: datasource = $datasources.list.find(
source => source._id === table?.sourceId source => source._id === table?.sourceId
) ) as Datasource | undefined
$: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected) $: tableAutoColumnsTypes = getTableAutoColumnTypes($tables?.selected)
$: availableAutoColumns = Object.keys(autoColumnInfo).reduce((acc, key) => { $: availableAutoColumns = Object.keys(autoColumnInfo).reduce(
(acc: Record<string, { enabled: boolean; name: string }>, key: string) => {
if (!tableAutoColumnsTypes.includes(key)) { if (!tableAutoColumnsTypes.includes(key)) {
acc[key] = autoColumnInfo[key] const subtypeKey = key as AutoFieldSubType
if (autoColumnInfo[subtypeKey]) {
acc[key] = autoColumnInfo[subtypeKey]
}
} }
return acc return acc
}, {}) },
{}
)
$: availableAutoColumnKeys = availableAutoColumns $: availableAutoColumnKeys = availableAutoColumns
? Object.keys(availableAutoColumns) ? Object.keys(availableAutoColumns)
: [] : []
@ -176,18 +221,23 @@
!editableColumn.autocolumn !editableColumn.autocolumn
$: hasDefault = $: hasDefault =
editableColumn?.default != null && editableColumn?.default !== "" editableColumn?.default != null && editableColumn?.default !== ""
$: externalTable = table.sourceType === DB_TYPE_EXTERNAL $: isExternalTable = table?.sourceType === DB_TYPE_EXTERNAL
// in the case of internal tables the sourceId will just be undefined // in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter( $: tableOptions = $tables.list.filter(
opt => opt =>
opt.sourceType === table.sourceType && table.sourceId === opt.sourceId opt.sourceType === table?.sourceType && table.sourceId === opt.sourceId
) )
$: typeEnabled = $: typeEnabled =
!originalName || !originalName ||
(originalName && (originalName &&
SWITCHABLE_TYPES[field.type] && SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn) !editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({ $: allowedTypes = getAllowedTypes(datasource, table)
$: orderedAllowedTypes = fixedTypeOrder
.filter(ordered =>
allowedTypes.find(allowed => allowed.type === ordered.type)
)
.map(t => ({
fieldId: makeFieldId(t.type, t.subtype), fieldId: makeFieldId(t.type, t.subtype),
...t, ...t,
})) }))
@ -210,7 +260,23 @@
editableColumn.default editableColumn.default
) )
const fieldDefinitions = Object.values(FIELDS).reduce( const allTableFields = [
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
FIELDS.LONGFORM,
FIELDS.FORMULA,
FIELDS.BARCODEQR,
FIELDS.BIGINT,
]
const fieldDefinitions: Record<string, UIField> = Object.values(
FIELDS
).reduce(
// Storing the fields by complex field id // Storing the fields by complex field id
(acc, field) => ({ (acc, field) => ({
...acc, ...acc,
@ -219,7 +285,7 @@
{} {}
) )
function makeFieldId(type, subtype, autocolumn) { function makeFieldId(type: string, subtype?: string, autocolumn?: boolean) {
// don't make field IDs for auto types // don't make field IDs for auto types
if (type === FieldType.AUTO || autocolumn) { if (type === FieldType.AUTO || autocolumn) {
return type.toUpperCase() return type.toUpperCase()
@ -233,23 +299,29 @@
} }
} }
const initialiseField = (field, savingColumn) => { const initialiseField = (
field: FieldSchema | undefined,
savingColumn: boolean
) => {
isCreating = !field isCreating = !field
if (field && !savingColumn) { if (field && !savingColumn) {
editableColumn = cloneDeep(field) editableColumn = cloneDeep(field) as FieldSchemaConfig
originalName = editableColumn.name ? editableColumn.name + "" : null originalName = editableColumn.name ? editableColumn.name + "" : undefined
linkEditDisabled = originalName != null linkEditDisabled = originalName != null
primaryDisplay = hasPrimaryDisplay =
$tables.selected.primaryDisplay == null || $tables.selected?.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name $tables.selected?.primaryDisplay === editableColumn.name
// Here we are setting the relationship values based on the editableColumn // Here we are setting the relationship values based on the editableColumn
// This part of the code is used when viewing an existing field hence the check // This part of the code is used when viewing an existing field hence the check
// for the tableId // for the tableId
if (editableColumn.type === FieldType.LINK && editableColumn.tableId) { if (editableColumn.type === FieldType.LINK && editableColumn.tableId) {
relationshipTableIdPrimary = table._id relationshipTableIdPrimary = table?._id
relationshipTableIdSecondary = editableColumn.tableId relationshipTableIdSecondary = editableColumn.tableId
if (editableColumn.relationshipType in relationshipMap) { if (
editableColumn.relationshipType &&
editableColumn.relationshipType in relationshipMap
) {
const { part1, part2 } = const { part1, part2 } =
relationshipMap[editableColumn.relationshipType] relationshipMap[editableColumn.relationshipType]
relationshipPart1 = part1 relationshipPart1 = part1
@ -267,18 +339,21 @@
} }
} }
const getTableAutoColumnTypes = table => { const getTableAutoColumnTypes = (table: Table | undefined) => {
return Object.keys(table?.schema).reduce((acc, key) => { return Object.keys(table?.schema || {}).reduce(
(acc: string[], key: string) => {
let fieldSchema = table?.schema[key] let fieldSchema = table?.schema[key]
if (fieldSchema.autocolumn) { if (fieldSchema?.autocolumn && fieldSchema?.subtype) {
acc.push(fieldSchema.subtype) acc.push(fieldSchema.subtype)
} }
return acc return acc
}, []) },
[]
)
} }
async function saveColumn() { async function saveColumn() {
if (errors?.length) { if (Object.keys(errors || {}).length) {
return return
} }
@ -287,14 +362,18 @@
delete saveColumn.fieldId delete saveColumn.fieldId
if (saveColumn.type === FieldType.AUTO) { if (
$tables.selected &&
saveColumn.name &&
saveColumn.type === FieldType.AUTO
) {
saveColumn = buildAutoColumn( saveColumn = buildAutoColumn(
$tables.selected.name, $tables.selected.name,
saveColumn.name, saveColumn.name,
saveColumn.subtype saveColumn.subtype as AutoFieldSubType
) ) as FieldSchemaConfig
} }
if (saveColumn.type !== FieldType.LINK) { if ("fieldName" in saveColumn && saveColumn.type !== FieldType.LINK) {
delete saveColumn.fieldName delete saveColumn.fieldName
} }
@ -304,22 +383,21 @@
} }
// Ensure primary display columns are always required and don't have default values // Ensure primary display columns are always required and don't have default values
if (primaryDisplay) { if (hasPrimaryDisplay) {
saveColumn.constraints.presence = { allowEmpty: false } saveColumn.constraints!.presence = { allowEmpty: false }
delete saveColumn.default delete saveColumn.default
} }
// Ensure the field is not required if we have a default value // Ensure the field is not required if we have a default value
if (saveColumn.default) { if (saveColumn.default) {
saveColumn.constraints.presence = false saveColumn.constraints!.presence = false
} }
try { try {
await tables.saveField({ await tables.saveField({
originalName, originalName,
field: saveColumn, field: saveColumn as FieldSchema,
primaryDisplay, hasPrimaryDisplay,
indexes,
}) })
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column") gridDispatch("close-edit-column")
@ -329,7 +407,7 @@
} else { } else {
notifications.success("Column created successfully") notifications.success("Column created successfully")
} }
} catch (err) { } catch (err: any) {
notifications.error(`Error saving column: ${err.message}`) notifications.error(`Error saving column: ${err.message}`)
} finally { } finally {
savingColumn = false savingColumn = false
@ -337,65 +415,83 @@
} }
function cancelEdit() { function cancelEdit() {
if (originalName) {
editableColumn.name = originalName editableColumn.name = originalName
}
gridDispatch("close-edit-column") gridDispatch("close-edit-column")
} }
async function deleteColumn() { async function deleteColumn() {
try { try {
if (deleteColName) {
editableColumn.name = deleteColName editableColumn.name = deleteColName
if (editableColumn.name === $tables.selected.primaryDisplay) { }
if (editableColumn.name === $tables.selected?.primaryDisplay) {
notifications.error("You cannot delete the display column") notifications.error("You cannot delete the display column")
} else { } else {
await tables.deleteField(editableColumn) await tables.deleteField({ name: editableColumn.name! })
notifications.success(`Column ${editableColumn.name} deleted`) notifications.success(`Column ${editableColumn.name} deleted`)
confirmDeleteDialog.hide() confirmDeleteDialog.hide()
dispatch("updatecolumns") dispatch("updatecolumns")
gridDispatch("close-edit-column") gridDispatch("close-edit-column")
} }
} catch (error) { } catch (error: any) {
notifications.error(`Error deleting column: ${error.message}`) notifications.error(`Error deleting column: ${error.message}`)
} }
} }
function onHandleTypeChange(event) { function onHandleTypeChange(event: any) {
handleTypeChange(event.detail) handleTypeChange(event.detail)
} }
function handleTypeChange(type) { function handleTypeChange(type?: string) {
// remove any extra fields that may not be related to this type // remove any extra fields that may not be related to this type
delete editableColumn.autocolumn const columnsToClear = [
delete editableColumn.subtype "autocolumn",
delete editableColumn.tableId "subtype",
delete editableColumn.relationshipType "tableId",
delete editableColumn.formulaType "relationshipType",
delete editableColumn.constraints "formulaType",
delete editableColumn.responseType "responseType",
]
for (let column of columnsToClear) {
if (column in editableColumn) {
delete editableColumn[column as keyof FieldSchema]
}
}
editableColumn.constraints = {}
// Add in defaults and initial definition // Add in defaults and initial definition
const definition = fieldDefinitions[type?.toUpperCase()] const definition = fieldDefinitions[type?.toUpperCase() || ""]
if (definition?.constraints) { if (definition?.constraints) {
editableColumn.constraints = cloneDeep(definition.constraints) editableColumn.constraints = cloneDeep(definition.constraints)
} }
editableColumn.type = definition.type editableColumn.type = definition.type
if (definition.subtype) {
// @ts-expect-error the setting of sub-type here doesn't fit our definition with
// FieldSchema, there is no type checking, it simply sets it if it is provided
editableColumn.subtype = definition.subtype editableColumn.subtype = definition.subtype
}
// Default relationships many to many // Default relationships many to many
if (editableColumn.type === FieldType.LINK) { if (editableColumn.type === FieldType.LINK) {
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} else if (editableColumn.type === FieldType.FORMULA) { } else if (editableColumn.type === FieldType.FORMULA) {
editableColumn.formulaType = "dynamic" editableColumn.formulaType = FormulaType.DYNAMIC
editableColumn.responseType = field?.responseType || FIELDS.STRING.type editableColumn.responseType =
field && "responseType" in field
? field.responseType
: (FIELDS.STRING.type as FormulaResponseType)
} }
} }
function setRequired(req) { function setRequired(req: boolean) {
editableColumn.constraints.presence = req ? { allowEmpty: false } : false editableColumn.constraints!.presence = req ? { allowEmpty: false } : false
required = req required = req
} }
function onChangeRequired(e) { function onChangeRequired(e: any) {
setRequired(e.detail) setRequired(e.detail)
} }
@ -412,10 +508,21 @@
deleteColName = "" deleteColName = ""
} }
function getAllowedTypes(datasource) { function getAllowedTypes(
datasource: Datasource | undefined,
table: Table | undefined
): UIField[] {
const isSqlTable = table?.sql
const isGoogleSheet =
table?.sourceType === DB_TYPE_EXTERNAL &&
datasource?.source === SourceName.GOOGLE_SHEETS
if (originalName) { if (originalName) {
let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type] let possibleTypes = SWITCHABLE_TYPES[field.type] || [editableColumn.type]
if (helpers.schema.isDeprecatedSingleUserColumn(editableColumn)) { if (
helpers.schema.isDeprecatedSingleUserColumn(
editableColumn as FieldSchema
)
) {
// This will handle old single users columns // This will handle old single users columns
return [ return [
{ {
@ -442,60 +549,43 @@
.map(([_, fieldDefinition]) => fieldDefinition) .map(([_, fieldDefinition]) => fieldDefinition)
} }
if (!externalTable) { if (!isExternalTable) {
return [ const fields = [
FIELDS.STRING, ...allTableFields,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
...(aiEnabled ? [FIELDS.AI] : []),
FIELDS.LONGFORM,
FIELDS.USER, FIELDS.USER,
FIELDS.USERS, FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE, FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS, FIELDS.ATTACHMENTS,
FIELDS.FORMULA,
FIELDS.JSON,
FIELDS.BARCODEQR,
FIELDS.SIGNATURE_SINGLE, FIELDS.SIGNATURE_SINGLE,
FIELDS.BIGINT, FIELDS.JSON,
FIELDS.AUTO, FIELDS.AUTO,
] ]
} else { if (aiEnabled) {
let fields = [ fields.push(FIELDS.AI)
FIELDS.STRING,
FIELDS.NUMBER,
FIELDS.OPTIONS,
FIELDS.ARRAY,
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
FIELDS.LONGFORM,
FIELDS.USER,
FIELDS.USERS,
FIELDS.FORMULA,
FIELDS.BARCODEQR,
FIELDS.BIGINT,
]
// Filter out multiple users for google sheets
if (datasource?.source === SourceName.GOOGLE_SHEETS) {
fields = fields.filter(x => x !== FIELDS.USERS)
} }
// Filter out SQL-specific types for non-SQL datasources
if (!table.sql) {
fields = fields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
}
return fields return fields
} }
if (isExternalTable && isSqlTable) {
return [
...allTableFields,
FIELDS.USER,
FIELDS.USERS,
FIELDS.ATTACHMENT_SINGLE,
FIELDS.ATTACHMENTS,
FIELDS.SIGNATURE_SINGLE,
]
} else if (isExternalTable && isGoogleSheet) {
// google-sheets supports minimum set (no attachments or user references)
return allTableFields
} else if (isExternalTable && !isSqlTable) {
// filter out SQL-specific types for non-SQL datasources
return allTableFields.filter(x => x !== FIELDS.LINK && x !== FIELDS.ARRAY)
} }
function checkConstraints(fieldToCheck) { throw new Error("No valid allowed types found")
}
function checkConstraints(fieldToCheck: FieldSchema) {
if (!fieldToCheck) { if (!fieldToCheck) {
return return
} }
@ -526,11 +616,11 @@
} }
} }
function checkErrors(fieldInfo) { function checkErrors(fieldInfo: FieldSchema) {
if (!editableColumn) { if (!editableColumn) {
return {} return
} }
function inUse(tbl, column, ogName = null) { function inUse(tbl?: Table, column?: string, ogName?: string) {
const parsedColumn = column ? column.toLowerCase().trim() : column const parsedColumn = column ? column.toLowerCase().trim() : column
return Object.keys(tbl?.schema || {}).some(key => { return Object.keys(tbl?.schema || {}).some(key => {
@ -538,11 +628,12 @@
return lowerKey !== ogName?.toLowerCase() && lowerKey === parsedColumn return lowerKey !== ogName?.toLowerCase() && lowerKey === parsedColumn
}) })
} }
const newError = {} const newError: { name?: string; subtype?: string; relatedName?: string } =
const prohibited = externalTable {}
const prohibited = isExternalTable
? PROTECTED_EXTERNAL_COLUMNS ? PROTECTED_EXTERNAL_COLUMNS
: PROTECTED_INTERNAL_COLUMNS : PROTECTED_INTERNAL_COLUMNS
if (!externalTable && fieldInfo.name?.startsWith("_")) { if (!isExternalTable && fieldInfo.name?.startsWith("_")) {
newError.name = `Column name cannot start with an underscore.` newError.name = `Column name cannot start with an underscore.`
} else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) { } else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) {
newError.name = `Illegal character; must be alpha-numeric.` newError.name = `Illegal character; must be alpha-numeric.`
@ -558,31 +649,52 @@
newError.subtype = `Auto Column requires a type.` newError.subtype = `Auto Column requires a type.`
} }
if (fieldInfo.fieldName && fieldInfo.tableId) { if (
fieldInfo.type === FieldType.LINK &&
fieldInfo.fieldName &&
fieldInfo.tableId
) {
const relatedTable = $tables.list.find( const relatedTable = $tables.list.find(
tbl => tbl._id === fieldInfo.tableId tbl => tbl._id === fieldInfo.tableId
) )
if (inUse(relatedTable, fieldInfo.fieldName) && !originalName) { if (inUse(relatedTable, fieldInfo.fieldName) && !originalName) {
newError.relatedName = `Column name already in use in table ${relatedTable.name}` newError.relatedName = `Column name already in use in table ${relatedTable?.name}`
} }
} }
return newError return newError
} }
const sanitiseDefaultValue = (type, options, defaultValue) => { const sanitiseDefaultValue = (
type: FieldType,
options: string[],
defaultValue?: string[] | string
) => {
if (!defaultValue?.length) { if (!defaultValue?.length) {
return return
} }
// Delete default value for options fields if the option is no longer available // Delete default value for options fields if the option is no longer available
if (type === FieldType.OPTIONS && !options.includes(defaultValue)) { if (
type === FieldType.OPTIONS &&
typeof defaultValue === "string" &&
!options.includes(defaultValue)
) {
delete editableColumn.default delete editableColumn.default
} }
// Filter array default values to only valid options // Filter array default values to only valid options
if (type === FieldType.ARRAY) { if (type === FieldType.ARRAY && Array.isArray(defaultValue)) {
editableColumn.default = defaultValue.filter(x => options.includes(x)) editableColumn.default = defaultValue.filter(x => options.includes(x))
} }
} }
function handleNameInput(evt: any) {
if (
!uneditable &&
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
) {
editableColumn.name = evt.target.value
}
}
onMount(() => { onMount(() => {
mounted = true mounted = true
}) })
@ -593,25 +705,18 @@
<Input <Input
value={editableColumn.name} value={editableColumn.name}
autofocus autofocus
on:input={e => { on:input={handleNameInput}
if (
!uneditable &&
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
) {
editableColumn.name = e.target.value
}
}}
disabled={uneditable || disabled={uneditable ||
(linkEditDisabled && editableColumn.type === FieldType.LINK)} (linkEditDisabled && editableColumn.type === FieldType.LINK)}
error={errors?.name} error={errors?.name}
/> />
{/if} {/if}
<Select <Select
placeholder={null} placeholder={undefined}
disabled={!typeEnabled} disabled={!typeEnabled}
bind:value={editableColumn.fieldId} bind:value={editableColumn.fieldId}
on:change={onHandleTypeChange} on:change={onHandleTypeChange}
options={allowedTypes} options={orderedAllowedTypes}
getOptionLabel={field => field.name} getOptionLabel={field => field.name}
getOptionValue={field => field.fieldId} getOptionValue={field => field.fieldId}
getOptionIcon={field => field.icon} getOptionIcon={field => field.icon}
@ -623,7 +728,7 @@
}} }}
/> />
{#if editableColumn.type === FieldType.STRING} {#if editableColumn.type === FieldType.STRING && editableColumn.constraints.length}
<Input <Input
type="number" type="number"
label="Max Length" label="Max Length"
@ -640,8 +745,8 @@
<div class="tooltip-alignment"> <div class="tooltip-alignment">
<Label size="M">Formatting</Label> <Label size="M">Formatting</Label>
<AbsTooltip <AbsTooltip
position="top" position={TooltipPosition.Top}
type="info" type={TooltipType.Info}
text={"Rich text includes support for images, link"} text={"Rich text includes support for images, link"}
> >
<Icon size="XS" name="InfoOutline" /> <Icon size="XS" name="InfoOutline" />
@ -664,6 +769,7 @@
<div class="label-length"> <div class="label-length">
<Label size="M">Earliest</Label> <Label size="M">Earliest</Label>
</div> </div>
{#if editableColumn.constraints.datetime}
<div class="input-length"> <div class="input-length">
<DatePicker <DatePicker
bind:value={editableColumn.constraints.datetime.earliest} bind:value={editableColumn.constraints.datetime.earliest}
@ -671,12 +777,14 @@
timeOnly={editableColumn.timeOnly} timeOnly={editableColumn.timeOnly}
/> />
</div> </div>
{/if}
</div> </div>
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">
<Label size="M">Latest</Label> <Label size="M">Latest</Label>
</div> </div>
{#if editableColumn.constraints.datetime}
<div class="input-length"> <div class="input-length">
<DatePicker <DatePicker
bind:value={editableColumn.constraints.datetime.latest} bind:value={editableColumn.constraints.datetime.latest}
@ -684,6 +792,7 @@
timeOnly={editableColumn.timeOnly} timeOnly={editableColumn.timeOnly}
/> />
</div> </div>
{/if}
</div> </div>
{#if !editableColumn.timeOnly} {#if !editableColumn.timeOnly}
{#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly} {#if datasource?.source !== SourceName.ORACLE && datasource?.source !== SourceName.SQL_SERVER && !editableColumn.dateOnly}
@ -691,10 +800,10 @@
<div class="row"> <div class="row">
<Label>Time zones</Label> <Label>Time zones</Label>
<AbsTooltip <AbsTooltip
position="top" position={TooltipPosition.Top}
type="info" type={TooltipType.Info}
text={isCreating text={isCreating
? null ? undefined
: "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"} : "We recommend not changing how timezones are handled for existing columns, as existing data will not be updated"}
> >
<Icon size="XS" name="InfoOutline" /> <Icon size="XS" name="InfoOutline" />
@ -713,6 +822,7 @@
<div class="label-length"> <div class="label-length">
<Label size="M">Min Value</Label> <Label size="M">Min Value</Label>
</div> </div>
{#if editableColumn.constraints.numericality}
<div class="input-length"> <div class="input-length">
<Input <Input
type="number" type="number"
@ -720,18 +830,22 @@
.greaterThanOrEqualTo} .greaterThanOrEqualTo}
/> />
</div> </div>
{/if}
</div> </div>
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">
<Label size="M">Max Value</Label> <Label size="M">Max Value</Label>
</div> </div>
{#if editableColumn.constraints.numericality}
<div class="input-length"> <div class="input-length">
<Input <Input
type="number" type="number"
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo} bind:value={editableColumn.constraints.numericality
.lessThanOrEqualTo}
/> />
</div> </div>
{/if}
</div> </div>
{:else if editableColumn.type === FieldType.LINK && !editableColumn.autocolumn} {:else if editableColumn.type === FieldType.LINK && !editableColumn.autocolumn}
<RelationshipSelector <RelationshipSelector
@ -747,7 +861,7 @@
{errors} {errors}
/> />
{:else if editableColumn.type === FieldType.FORMULA} {:else if editableColumn.type === FieldType.FORMULA}
{#if !externalTable} {#if !isExternalTable}
<div class="split-label"> <div class="split-label">
<div class="label-length"> <div class="label-length">
<Label size="M">Formula Type</Label> <Label size="M">Formula Type</Label>
@ -797,10 +911,12 @@
title="Formula" title="Formula"
value={editableColumn.formula} value={editableColumn.formula}
on:change={e => { on:change={e => {
if (editableColumn.type === FieldType.FORMULA) {
editableColumn = { editableColumn = {
...editableColumn, ...editableColumn,
formula: e.detail, formula: e.detail,
} }
}
}} }}
bindings={getBindings({ table })} bindings={getBindings({ table })}
allowJS allowJS
@ -808,7 +924,7 @@
/> />
</div> </div>
</div> </div>
{:else if editableColumn.type === FieldType.AI} {:else if editableColumn.type === FieldType.AI && table}
<AIFieldConfiguration <AIFieldConfiguration
aiField={editableColumn} aiField={editableColumn}
context={rowGoldenSample} context={rowGoldenSample}
@ -816,9 +932,7 @@
schema={table.schema} schema={table.schema}
/> />
{:else if editableColumn.type === FieldType.JSON} {:else if editableColumn.type === FieldType.JSON}
<Button primary text on:click={openJsonSchemaEditor}> <Button primary on:click={openJsonSchemaEditor}>Open schema editor</Button>
Open schema editor
</Button>
{/if} {/if}
{#if editableColumn.type === FieldType.AUTO || editableColumn.autocolumn} {#if editableColumn.type === FieldType.AUTO || editableColumn.autocolumn}
<Select <Select
@ -839,8 +953,7 @@
<Toggle <Toggle
value={required} value={required}
on:change={onChangeRequired} on:change={onChangeRequired}
disabled={primaryDisplay || hasDefault} disabled={hasPrimaryDisplay || hasDefault}
thin
text="Required" text="Required"
/> />
{/if} {/if}
@ -895,7 +1008,7 @@
<div class="action-buttons"> <div class="action-buttons">
{#if !uneditable && originalName != null} {#if !uneditable && originalName != null}
<Button quiet warning text on:click={confirmDelete}>Delete</Button> <Button quiet warning on:click={confirmDelete}>Delete</Button>
{/if} {/if}
<Button secondary newStyles on:click={cancelEdit}>Cancel</Button> <Button secondary newStyles on:click={cancelEdit}>Cancel</Button>
<Button <Button

View File

@ -1,16 +1,18 @@
<script> <script lang="ts">
import { Modal, ModalContent, Body } from "@budibase/bbui" import { Modal, ModalContent, Body } from "@budibase/bbui"
export let title = "" export let title: string = ""
export let body = "" export let body: string = ""
export let okText = "Confirm" export let okText: string = "Confirm"
export let cancelText = "Cancel" export let cancelText: string = "Cancel"
export let onOk = undefined export let size: "S" | "M" | "L" | "XL" | undefined = undefined
export let onCancel = undefined export let onOk: (() => void) | undefined = undefined
export let warning = true export let onCancel: (() => void) | undefined = undefined
export let disabled = false export let onClose: (() => void) | undefined = undefined
export let warning: boolean = true
export let disabled: boolean = false
let modal let modal: Modal
export const show = () => { export const show = () => {
modal.show() modal.show()
@ -20,14 +22,16 @@
} }
</script> </script>
<Modal bind:this={modal} on:hide={onCancel}> <Modal bind:this={modal} on:hide={onClose ?? onCancel}>
<ModalContent <ModalContent
onConfirm={onOk} onConfirm={onOk}
{onCancel}
{title} {title}
confirmText={okText} confirmText={okText}
{cancelText} {cancelText}
{warning} {warning}
{disabled} {disabled}
{size}
> >
<Body size="S"> <Body size="S">
{body} {body}

View File

@ -11,8 +11,8 @@
export let errors export let errors
export let relationshipOpts1 export let relationshipOpts1
export let relationshipOpts2 export let relationshipOpts2
export let primaryTableChanged export let primaryTableChanged = undefined
export let secondaryTableChanged export let secondaryTableChanged = undefined
export let primaryDisabled = true export let primaryDisabled = true
</script> </script>

View File

@ -1,5 +1,5 @@
<script> <script>
import { goto, params } from "@roxi/routify" import { beforeUrlChange, goto, params } from "@roxi/routify"
import { datasources, flags, integrations, queries } from "@/stores/builder" import { datasources, flags, integrations, queries } from "@/stores/builder"
import { environment } from "@/stores/portal" import { environment } from "@/stores/portal"
import { import {
@ -25,7 +25,7 @@
EditorModes, EditorModes,
} from "@/components/common/CodeMirrorEditor.svelte" } from "@/components/common/CodeMirrorEditor.svelte"
import RestBodyInput from "./RestBodyInput.svelte" import RestBodyInput from "./RestBodyInput.svelte"
import { capitalise } from "@/helpers" import { capitalise, confirm } from "@/helpers"
import { onMount } from "svelte" import { onMount } from "svelte"
import restUtils from "@/helpers/data/utils" import restUtils from "@/helpers/data/utils"
import { import {
@ -64,6 +64,7 @@
let nestedSchemaFields = {} let nestedSchemaFields = {}
let saving let saving
let queryNameLabel let queryNameLabel
let mounted = false
$: staticVariables = datasource?.config?.staticVariables || {} $: staticVariables = datasource?.config?.staticVariables || {}
@ -105,8 +106,10 @@
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs) $: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
$: originalQuery = originalQuery ?? cloneDeep(query)
$: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings) $: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings)
$: originalQuery = mounted
? originalQuery ?? cloneDeep(builtQuery)
: undefined
$: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery) $: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery)
function getSelectedQuery() { function getSelectedQuery() {
@ -209,11 +212,14 @@
originalQuery = null originalQuery = null
queryNameLabel.disableEditingState() queryNameLabel.disableEditingState()
return { ok: true }
} catch (err) { } catch (err) {
notifications.error(`Error saving query`) notifications.error(`Error saving query`)
} finally { } finally {
saving = false saving = false
} }
return { ok: false }
} }
const validateQuery = async () => { const validateQuery = async () => {
@ -475,6 +481,38 @@
staticVariables, staticVariables,
restBindings restBindings
) )
mounted = true
})
$beforeUrlChange(async () => {
if (!isModified) {
return true
}
return await confirm({
title: "Some updates are not saved",
body: "Some of your changes are not yet saved. Do you want to save them before leaving?",
okText: "Save and continue",
cancelText: "Discard and continue",
size: "M",
onConfirm: async () => {
const saveResult = await saveQuery()
if (!saveResult.ok) {
// We can't leave as the query was not properly saved
return false
}
return true
},
onCancel: () => {
// Leave without saving anything
return true
},
onClose: () => {
return false
},
})
}) })
</script> </script>

View File

@ -76,7 +76,7 @@
</div> </div>
<Body size="S" color="var(--spectrum-global-color-gray-700)"> <Body size="S" color="var(--spectrum-global-color-gray-700)">
Basic (Username & Password Authentication) Basic & Bearer Authentication
</Body> </Body>
{#if authConfigs.length} {#if authConfigs.length}
@ -92,7 +92,7 @@
{/if} {/if}
<div> <div>
<Button secondary icon="Add" on:click={addBasicConfiguration} <Button secondary icon="Add" on:click={addBasicConfiguration}
>Add Basic</Button >Add config</Button
> >
</div> </div>

View File

@ -6,6 +6,7 @@ import {
Hosting, Hosting,
} from "@budibase/types" } from "@budibase/types"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import { UIField } from "@budibase/types"
const { TypeIconMap } = Constants const { TypeIconMap } = Constants
@ -27,7 +28,7 @@ export const AUTO_COLUMN_DISPLAY_NAMES: Record<
UPDATED_AT: "Updated At", UPDATED_AT: "Updated At",
} }
export const FIELDS = { export const FIELDS: Record<string, UIField> = {
STRING: { STRING: {
name: "Text", name: "Text",
type: FieldType.STRING, type: FieldType.STRING,

View File

@ -0,0 +1,41 @@
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
export enum ConfirmOutput {}
export async function confirm(props: {
title: string
body?: string
okText?: string
cancelText?: string
size?: "S" | "M" | "L" | "XL"
onConfirm?: () => void
onCancel?: () => void
onClose?: () => void
}) {
return await new Promise(resolve => {
const dialog = new ConfirmDialog({
target: document.body,
props: {
title: props.title,
body: props.body,
okText: props.okText,
cancelText: props.cancelText,
size: props.size,
warning: false,
onOk: () => {
dialog.$destroy()
resolve(props.onConfirm?.() || true)
},
onCancel: () => {
dialog.$destroy()
resolve(props.onCancel?.() || false)
},
onClose: () => {
dialog.$destroy()
resolve(props.onClose?.() || false)
},
},
})
dialog.show()
})
}

View File

@ -11,3 +11,4 @@ export {
} from "./helpers" } from "./helpers"
export * as featureFlag from "./featureFlags" export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings" export * as bindings from "./bindings"
export * from "./confirm"

View File

@ -7,22 +7,79 @@
Divider, Divider,
Heading, Heading,
Input, Input,
keepOpen,
Link, Link,
Modal, Modal,
ModalContent, ModalContent,
notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import type { ZodType } from "zod"
import { z } from "zod"
let modal: Modal let modal: Modal
function openModal() { function openModal() {
config = {}
errors = {}
hasBeenSubmitted = false
modal.show() modal.show()
} }
let config: Partial<CreateOAuth2Config> = {} let config: Partial<CreateOAuth2Config> = {}
let errors: Record<string, string> = {}
let hasBeenSubmitted = false
const requiredString = (errorMessage: string) =>
z.string({ required_error: errorMessage }).trim().min(1, errorMessage)
const validateConfig = (config: Partial<CreateOAuth2Config>) => {
const validator = z.object({
name: requiredString("Name is required.").refine(
val =>
!$oauth2.configs
.map(c => c.name.toLowerCase())
.includes(val.toLowerCase()),
{
message: "This name is already taken.",
}
),
url: requiredString("Url is required.").url(),
clientId: requiredString("Client ID is required."),
clientSecret: requiredString("Client secret is required."),
}) satisfies ZodType<CreateOAuth2Config>
const validationResult = validator.safeParse(config)
errors = {}
if (!validationResult.success) {
errors = Object.entries(
validationResult.error.formErrors.fieldErrors
).reduce<Record<string, string>>((acc, [field, errors]) => {
if (errors[0]) {
acc[field] = errors[0]
}
return acc
}, {})
}
return validationResult
}
$: saveOAuth2Config = async () => { $: saveOAuth2Config = async () => {
await oauth2.create(config as any) // TODO hasBeenSubmitted = true
const validationResult = validateConfig(config)
if (validationResult.error) {
return keepOpen
} }
try {
await oauth2.create(validationResult.data)
} catch (e: any) {
notifications.error(e.message)
return keepOpen
}
}
$: hasBeenSubmitted && validateConfig(config)
</script> </script>
<Button cta size="M" on:click={openModal}>Add OAuth2</Button> <Button cta size="M" on:click={openModal}>Add OAuth2</Button>
@ -34,11 +91,11 @@
machine) grant type. machine) grant type.
</Body> </Body>
<Divider noGrid noMargin /> <Divider noGrid noMargin />
<Input label="Name*" placeholder="Type here..." bind:value={config.name} /> <Input error={errors.name} />
<Input <Input
label="Service URL*" label="Service URL*"
placeholder="E.g. www.google.com" placeholder="E.g. www.google.com"
bind:value={config.url} error={errors.url}
/> />
<div class="field-info"> <div class="field-info">
<Body size="XS" color="var(--spectrum-global-color-gray-700)"> <Body size="XS" color="var(--spectrum-global-color-gray-700)">
@ -50,11 +107,14 @@
label="Client ID*" label="Client ID*"
placeholder="Type here..." placeholder="Type here..."
bind:value={config.clientId} bind:value={config.clientId}
error={errors.clientId}
/> />
<Input <Input
type="password"
label="Client secret*" label="Client secret*"
placeholder="Type here..." placeholder="Type here..."
bind:value={config.clientSecret} bind:value={config.clientSecret}
error={errors.clientSecret}
/> />
<Body size="S" <Body size="S"
>To learn how to configure OAuth2, our documentation <Link >To learn how to configure OAuth2, our documentation <Link

View File

@ -41,7 +41,6 @@
> >
</Layout> </Layout>
<Divider /> <Divider />
</Layout>
<Table <Table
data={configs} data={configs}
@ -52,6 +51,7 @@
allowEditColumns={false} allowEditColumns={false}
allowClickRows={false} allowClickRows={false}
/> />
</Layout>
<style> <style>
.header { .header {

View File

@ -79,6 +79,7 @@
<Heading size="M">Reset your password</Heading> <Heading size="M">Reset your password</Heading>
<Body size="M">Must contain at least 12 characters</Body> <Body size="M">Must contain at least 12 characters</Body>
<PasswordRepeatInput <PasswordRepeatInput
bind:passwordForm={form}
bind:password bind:password
bind:error={passwordError} bind:error={passwordError}
minLength={$admin.passwordMinLength || 12} minLength={$admin.passwordMinLength || 12}

View File

@ -148,13 +148,11 @@ export class TableStore extends DerivedBudiStore<
async saveField({ async saveField({
originalName, originalName,
field, field,
primaryDisplay = false, hasPrimaryDisplay = false,
indexes,
}: { }: {
originalName: string originalName?: string
field: FieldSchema field: FieldSchema
primaryDisplay: boolean hasPrimaryDisplay: boolean
indexes: Record<string, any>
}) { }) {
const draft: SaveTableRequest = cloneDeep(get(this.derivedStore).selected!) const draft: SaveTableRequest = cloneDeep(get(this.derivedStore).selected!)
@ -169,7 +167,7 @@ export class TableStore extends DerivedBudiStore<
} }
// Optionally set display column // Optionally set display column
if (primaryDisplay) { if (hasPrimaryDisplay) {
draft.primaryDisplay = field.name draft.primaryDisplay = field.name
} else if (draft.primaryDisplay === originalName) { } else if (draft.primaryDisplay === originalName) {
const fields = Object.keys(draft.schema) const fields = Object.keys(draft.schema)
@ -178,9 +176,6 @@ export class TableStore extends DerivedBudiStore<
name => name !== originalName || name !== field.name name => name !== originalName || name !== field.name
)[0] )[0]
} }
if (indexes) {
draft.indexes = indexes
}
draft.schema = { draft.schema = {
...draft.schema, ...draft.schema,
[field.name]: cloneDeep(field), [field.name]: cloneDeep(field),

View File

@ -1,3 +1,3 @@
import { CreateOAuth2ConfigRequest } from "@budibase/types" import { UpsertOAuth2ConfigRequest } from "@budibase/types"
export interface CreateOAuth2Config extends CreateOAuth2ConfigRequest {} export interface CreateOAuth2Config extends UpsertOAuth2ConfigRequest {}

View File

@ -41,4 +41,15 @@
div :global(img) { div :global(img) {
max-width: 100%; max-width: 100%;
} }
div :global(.editor-preview-full) {
height: auto;
}
div :global(h1),
div :global(h2),
div :global(h3),
div :global(h4),
div :global(h5),
div :global(h6) {
font-weight: 600;
}
</style> </style>

View File

@ -1,17 +1,26 @@
<script lang="ts"> <script context="module" lang="ts">
type BasicRelatedRow = { _id: string; primaryDisplay: string }
type OptionsMap = Record<string, BasicRelatedRow>
</script>
<script lang="ts" generics="ValueType extends string | string[]">
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { BasicOperator, FieldType, InternalTable } from "@budibase/types" import {
BasicOperator,
EmptyFilterOption,
FieldType,
InternalTable,
UILogicalOperator,
type LegacyFilter,
type SearchFilterGroup,
type UISearchFilter,
} from "@budibase/types"
import { fetchData, Utils } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import type { import type { RelationshipFieldMetadata, Row } from "@budibase/types"
SearchFilter,
RelationshipFieldMetadata,
Row,
} from "@budibase/types"
import type { FieldApi, FieldState, FieldValidation } from "@/types" import type { FieldApi, FieldState, FieldValidation } from "@/types"
import { utils } from "@budibase/shared-core"
type ValueType = string | string[]
export let field: string | undefined = undefined export let field: string | undefined = undefined
export let label: string | undefined = undefined export let label: string | undefined = undefined
@ -22,7 +31,7 @@
export let autocomplete: boolean = true export let autocomplete: boolean = true
export let defaultValue: ValueType | undefined = undefined export let defaultValue: ValueType | undefined = undefined
export let onChange: (_props: { value: ValueType }) => void export let onChange: (_props: { value: ValueType }) => void
export let filter: SearchFilter[] export let filter: UISearchFilter | LegacyFilter[] | undefined = undefined
export let datasourceType: "table" | "user" = "table" export let datasourceType: "table" | "user" = "table"
export let primaryDisplay: string | undefined = undefined export let primaryDisplay: string | undefined = undefined
export let span: number | undefined = undefined export let span: number | undefined = undefined
@ -32,14 +41,10 @@
| FieldType.BB_REFERENCE | FieldType.BB_REFERENCE
| FieldType.BB_REFERENCE_SINGLE = FieldType.LINK | FieldType.BB_REFERENCE_SINGLE = FieldType.LINK
type BasicRelatedRow = { _id: string; primaryDisplay: string }
type OptionsMap = Record<string, BasicRelatedRow>
const { API } = getContext("sdk") const { API } = getContext("sdk")
// Field state // Field state
let fieldState: FieldState<string | string[]> | undefined let fieldState: FieldState<string | string[]> | undefined
let fieldApi: FieldApi let fieldApi: FieldApi
let fieldSchema: RelationshipFieldMetadata | undefined let fieldSchema: RelationshipFieldMetadata | undefined
@ -52,20 +57,29 @@
let optionsMap: OptionsMap = {} let optionsMap: OptionsMap = {}
let loadingMissingOptions: boolean = false let loadingMissingOptions: boolean = false
// Reset the available options when our base filter changes
$: filter, (optionsMap = {})
// Determine if we can select multiple rows or not // Determine if we can select multiple rows or not
$: multiselect = $: multiselect =
[FieldType.LINK, FieldType.BB_REFERENCE].includes(type) && [FieldType.LINK, FieldType.BB_REFERENCE].includes(type) &&
fieldSchema?.relationshipType !== "one-to-many" fieldSchema?.relationshipType !== "one-to-many"
// Get the proper string representation of the value // Get the proper string representation of the value
$: realValue = fieldState?.value $: realValue = fieldState?.value as ValueType
$: selectedValue = parseSelectedValue(realValue, multiselect) $: selectedValue = parseSelectedValue(realValue, multiselect)
$: selectedIDs = getSelectedIDs(selectedValue) $: selectedIDs = getSelectedIDs(selectedValue)
// If writable, we use a fetch to load options // If writable, we use a fetch to load options
$: linkedTableId = fieldSchema?.tableId $: linkedTableId = fieldSchema?.tableId
$: writable = !disabled && !readonly $: writable = !disabled && !readonly
$: fetch = createFetch(writable, datasourceType, filter, linkedTableId) $: migratedFilter = migrateFilter(filter)
$: fetch = createFetch(
writable,
datasourceType,
migratedFilter,
linkedTableId
)
// Attempt to determine the primary display field to use // Attempt to determine the primary display field to use
$: tableDefinition = $fetch?.definition $: tableDefinition = $fetch?.definition
@ -90,8 +104,8 @@
// Ensure backwards compatibility // Ensure backwards compatibility
$: enrichedDefaultValue = enrichDefaultValue(defaultValue) $: enrichedDefaultValue = enrichDefaultValue(defaultValue)
$: emptyValue = multiselect ? [] : undefined
// We need to cast value to pass it down, as those components aren't typed // We need to cast value to pass it down, as those components aren't typed
$: emptyValue = multiselect ? [] : undefined
$: displayValue = (missingIDs.length ? emptyValue : selectedValue) as any $: displayValue = (missingIDs.length ? emptyValue : selectedValue) as any
// Ensures that we flatten any objects so that only the IDs of the selected // Ensures that we flatten any objects so that only the IDs of the selected
@ -107,7 +121,7 @@
const createFetch = ( const createFetch = (
writable: boolean, writable: boolean,
dsType: typeof datasourceType, dsType: typeof datasourceType,
filter: SearchFilter[], filter: UISearchFilter | undefined,
linkedTableId?: string linkedTableId?: string
) => { ) => {
const datasource = const datasource =
@ -176,9 +190,16 @@
option: string | BasicRelatedRow | Row, option: string | BasicRelatedRow | Row,
primaryDisplay?: string primaryDisplay?: string
): BasicRelatedRow | null => { ): BasicRelatedRow | null => {
// For plain strings, check if we already have this option available
if (typeof option === "string" && optionsMap[option]) {
return optionsMap[option]
}
// Otherwise ensure we have a valid option object
if (!option || typeof option !== "object" || !option?._id) { if (!option || typeof option !== "object" || !option?._id) {
return null return null
} }
// If this is a basic related row shape (_id and PD only) then just use // If this is a basic related row shape (_id and PD only) then just use
// that // that
if (Object.keys(option).length === 2 && "primaryDisplay" in option) { if (Object.keys(option).length === 2 && "primaryDisplay" in option) {
@ -300,24 +321,54 @@
return val.includes(",") ? val.split(",") : val return val.includes(",") ? val.split(",") : val
} }
// We may need to migrate the filter structure, in the case of this being
// an old app with LegacyFilter[] saved
const migrateFilter = (
filter: UISearchFilter | LegacyFilter[] | undefined
): UISearchFilter | undefined => {
if (Array.isArray(filter)) {
return utils.processSearchFilters(filter)
}
return filter
}
// Searches for new options matching the given term // Searches for new options matching the given term
async function searchOptions(searchTerm: string, primaryDisplay?: string) { async function searchOptions(searchTerm: string, primaryDisplay?: string) {
if (!primaryDisplay) { if (!primaryDisplay) {
return return
} }
let newFilter: UISearchFilter | undefined = undefined
// Ensure we match all filters, rather than any let searchFilter: SearchFilterGroup = {
let newFilter = filter logicalOperator: UILogicalOperator.ALL,
if (searchTerm) { filters: [
// @ts-expect-error this doesn't fit types, but don't want to change it yet {
newFilter = (newFilter || []).filter(x => x.operator !== "allOr") field: primaryDisplay,
newFilter.push({
// Use a big numeric prefix to avoid clashing with an existing filter
field: `999:${primaryDisplay}`,
operator: BasicOperator.STRING, operator: BasicOperator.STRING,
value: searchTerm, value: searchTerm,
}) },
],
} }
// Determine the new filter to apply to the fetch
if (searchTerm && migratedFilter) {
// If we have both a search term and existing filter, filter by both
newFilter = {
logicalOperator: UILogicalOperator.ALL,
groups: [searchFilter, migratedFilter],
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
} else if (searchTerm) {
// If we just have a search term them use that
newFilter = {
logicalOperator: UILogicalOperator.ALL,
groups: [searchFilter],
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
}
} else {
// Otherwise use the supplied filter untouched
newFilter = migratedFilter
}
await fetch?.update({ await fetch?.update({
filter: newFilter, filter: newFilter,
}) })
@ -389,7 +440,6 @@
bind:searchTerm bind:searchTerm
bind:open bind:open
on:change={handleChange} on:change={handleChange}
on:loadMore={() => fetch?.nextPage()}
/> />
{/if} {/if}
</Field> </Field>

View File

@ -1,16 +1,16 @@
import { import {
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
CreateOAuth2ConfigResponse,
OAuth2ConfigResponse, OAuth2ConfigResponse,
CreateOAuth2ConfigRequest, UpsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse,
} from "@budibase/types" } from "@budibase/types"
import { BaseAPIClient } from "./types" import { BaseAPIClient } from "./types"
export interface OAuth2Endpoints { export interface OAuth2Endpoints {
fetch: () => Promise<OAuth2ConfigResponse[]> fetch: () => Promise<OAuth2ConfigResponse[]>
create: ( create: (
config: CreateOAuth2ConfigRequest config: UpsertOAuth2ConfigRequest
) => Promise<CreateOAuth2ConfigResponse> ) => Promise<UpsertOAuth2ConfigResponse>
} }
export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
@ -33,8 +33,8 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
*/ */
create: async config => { create: async config => {
return await API.post< return await API.post<
CreateOAuth2ConfigRequest, UpsertOAuth2ConfigRequest,
CreateOAuth2ConfigResponse UpsertOAuth2ConfigResponse
>({ >({
url: `/api/oauth2`, url: `/api/oauth2`,
body: { body: {

View File

@ -1,13 +1,14 @@
<script> <script lang="ts">
import { FancyForm, FancyInput } from "@budibase/bbui" import { FancyForm, FancyInput } from "@budibase/bbui"
import { createValidationStore, requiredValidator } from "../utils/validation" import { createValidationStore, requiredValidator } from "../utils/validation"
export let password export let passwordForm: FancyForm | undefined = undefined
export let error export let password: string
export let error: string
export let minLength = "12" export let minLength = "12"
const validatePassword = value => { const validatePassword = (value: string | undefined) => {
if (!value || value.length < minLength) { if (!value || value.length < parseInt(minLength)) {
return `Please enter at least ${minLength} characters. We recommend using machine generated or random passwords.` return `Please enter at least ${minLength} characters. We recommend using machine generated or random passwords.`
} }
return null return null
@ -35,7 +36,7 @@
firstPasswordError firstPasswordError
</script> </script>
<FancyForm> <FancyForm bind:this={passwordForm}>
<FancyInput <FancyInput
label="Password" label="Password"
type="password" type="password"

View File

@ -465,7 +465,7 @@
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
font-weight: bold; font-weight: 600;
} }
.header-cell.searching .name { .header-cell.searching .name {
opacity: 0; opacity: 0;

View File

@ -219,7 +219,7 @@
--grid-background-alt: var(--spectrum-global-color-gray-100); --grid-background-alt: var(--spectrum-global-color-gray-100);
--header-cell-background: var( --header-cell-background: var(
--custom-header-cell-background, --custom-header-cell-background,
var(--grid-background-alt) var(--spectrum-global-color-gray-100)
); );
--cell-background: var(--grid-background); --cell-background: var(--grid-background);
--cell-background-hover: var(--grid-background-alt); --cell-background-hover: var(--grid-background-alt);

View File

@ -397,14 +397,19 @@ export function parseFilter(filter: UISearchFilter) {
const update = cloneDeep(filter) const update = cloneDeep(filter)
if (update.groups) {
update.groups = update.groups update.groups = update.groups
?.map(group => { .map(group => {
group.filters = group.filters?.filter((filter: any) => { if (group.filters) {
group.filters = group.filters.filter((filter: any) => {
return filter.field && filter.operator return filter.field && filter.operator
}) })
return group.filters?.length ? group : null return group.filters?.length ? group : null
}
return group
}) })
.filter((group): group is SearchFilterGroup => !!group) .filter((group): group is SearchFilterGroup => !!group)
}
return update return update
} }

View File

@ -358,8 +358,8 @@ async function performAppCreate(
}, },
theme: DefaultAppTheme, theme: DefaultAppTheme,
customTheme: { customTheme: {
primaryColor: "var(--spectrum-global-color-static-blue-1200)", primaryColor: "var(--spectrum-global-color-blue-700)",
primaryColorHover: "var(--spectrum-global-color-static-blue-800)", primaryColorHover: "var(--spectrum-global-color-blue-600)",
buttonBorderRadius: "16px", buttonBorderRadius: "16px",
}, },
features: { features: {

View File

@ -1,6 +1,6 @@
import { import {
CreateOAuth2ConfigRequest, UpsertOAuth2ConfigRequest,
CreateOAuth2ConfigResponse, UpsertOAuth2ConfigResponse,
Ctx, Ctx,
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
OAuth2Config, OAuth2Config,
@ -22,7 +22,7 @@ export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
} }
export async function create( export async function create(
ctx: Ctx<CreateOAuth2ConfigRequest, CreateOAuth2ConfigResponse> ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>
) { ) {
const { body } = ctx.request const { body } = ctx.request
const newConfig: RequiredKeys<Omit<OAuth2Config, "id">> = { const newConfig: RequiredKeys<Omit<OAuth2Config, "id">> = {
@ -36,3 +36,28 @@ export async function create(
ctx.status = 201 ctx.status = 201
ctx.body = { config } ctx.body = { config }
} }
export async function edit(
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>
) {
const { body } = ctx.request
const toUpdate: RequiredKeys<OAuth2Config> = {
id: ctx.params.id,
name: body.name,
url: body.url,
clientId: ctx.clientId,
clientSecret: ctx.clientSecret,
}
const config = await sdk.oauth2.update(toUpdate)
ctx.body = { config }
}
export async function remove(
ctx: Ctx<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>
) {
const configToRemove = ctx.params.id
await sdk.oauth2.remove(configToRemove)
ctx.status = 204
}

View File

@ -1,8 +1,22 @@
import Router from "@koa/router" import Router from "@koa/router"
import { PermissionType } from "@budibase/types" import { PermissionType } from "@budibase/types"
import { middleware } from "@budibase/backend-core"
import authorized from "../../middleware/authorized" import authorized from "../../middleware/authorized"
import * as controller from "../controllers/oauth2" import * as controller from "../controllers/oauth2"
import Joi from "joi"
function oAuth2ConfigValidator() {
return middleware.joiValidator.body(
Joi.object({
name: Joi.string().required(),
url: Joi.string().required(),
clientId: Joi.string().required(),
clientSecret: Joi.string().required(),
}),
{ allowUnknown: false }
)
}
const router: Router = new Router() const router: Router = new Router()
@ -10,7 +24,19 @@ router.get("/api/oauth2", authorized(PermissionType.BUILDER), controller.fetch)
router.post( router.post(
"/api/oauth2", "/api/oauth2",
authorized(PermissionType.BUILDER), authorized(PermissionType.BUILDER),
oAuth2ConfigValidator(),
controller.create controller.create
) )
router.put(
"/api/oauth2/:id",
authorized(PermissionType.BUILDER),
oAuth2ConfigValidator(),
controller.edit
)
router.delete(
"/api/oauth2/:id",
authorized(PermissionType.BUILDER),
controller.remove
)
export default router export default router

View File

@ -1,11 +1,16 @@
import { CreateOAuth2ConfigRequest, VirtualDocumentType } from "@budibase/types" import {
OAuth2Config,
UpsertOAuth2ConfigRequest,
VirtualDocumentType,
} from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import _ from "lodash/fp"
describe("/oauth2", () => { describe("/oauth2", () => {
let config = setup.getConfig() let config = setup.getConfig()
function makeOAuth2Config(): CreateOAuth2ConfigRequest { function makeOAuth2Config(): UpsertOAuth2ConfigRequest {
return { return {
name: generator.guid(), name: generator.guid(),
url: generator.url(), url: generator.url(),
@ -19,7 +24,7 @@ describe("/oauth2", () => {
beforeEach(async () => await config.newTenant()) beforeEach(async () => await config.newTenant())
const expectOAuth2ConfigId = expect.stringMatching( const expectOAuth2ConfigId = expect.stringMatching(
`^${VirtualDocumentType.ROW_ACTION}_.+$` `^${VirtualDocumentType.OAUTH2_CONFIG}_.+$`
) )
describe("fetch", () => { describe("fetch", () => {
@ -92,4 +97,96 @@ describe("/oauth2", () => {
]) ])
}) })
}) })
describe("update", () => {
let existingConfigs: OAuth2Config[] = []
beforeEach(async () => {
existingConfigs = []
for (let i = 0; i < 10; i++) {
const oauth2Config = makeOAuth2Config()
const result = await config.api.oauth2.create(oauth2Config)
existingConfigs.push({ ...oauth2Config, id: result.config.id })
}
})
it("can update an existing configuration", async () => {
const { id: configId, ...configData } = _.sample(existingConfigs)!
await config.api.oauth2.update(configId, {
...configData,
name: "updated name",
})
const response = await config.api.oauth2.fetch()
expect(response.configs).toHaveLength(existingConfigs.length)
expect(response.configs).toEqual(
expect.arrayContaining([
{
id: configId,
name: "updated name",
url: configData.url,
},
])
)
})
it("throw if config not found", async () => {
await config.api.oauth2.update("unexisting", makeOAuth2Config(), {
status: 404,
body: { message: "OAuth2 config with id 'unexisting' not found." },
})
})
it("throws if trying to use an existing name", async () => {
const [config1, config2] = _.sampleSize(2, existingConfigs)
const { id: configId, ...configData } = config1
await config.api.oauth2.update(
configId,
{
...configData,
name: config2.name,
},
{
status: 400,
body: {
message: `OAuth2 config with name '${config2.name}' is already taken.`,
},
}
)
})
})
describe("delete", () => {
let existingConfigs: OAuth2Config[] = []
beforeEach(async () => {
existingConfigs = []
for (let i = 0; i < 5; i++) {
const oauth2Config = makeOAuth2Config()
const result = await config.api.oauth2.create(oauth2Config)
existingConfigs.push({ ...oauth2Config, id: result.config.id })
}
})
it("can delete an existing configuration", async () => {
const { id: configId } = _.sample(existingConfigs)!
await config.api.oauth2.delete(configId, { status: 204 })
const response = await config.api.oauth2.fetch()
expect(response.configs).toHaveLength(existingConfigs.length - 1)
expect(response.configs.find(c => c.id === configId)).toBeUndefined()
})
it("throw if config not found", async () => {
await config.api.oauth2.delete("unexisting", {
status: 404,
body: { message: "OAuth2 config with id 'unexisting' not found." },
})
})
})
}) })

View File

@ -2195,7 +2195,6 @@ if (descriptions.length) {
}) })
}) })
isInternal &&
describe("attachments and signatures", () => { describe("attachments and signatures", () => {
const coreAttachmentEnrichment = async ( const coreAttachmentEnrichment = async (
schema: TableSchema, schema: TableSchema,

View File

@ -381,12 +381,19 @@ export class RestIntegration implements IntegrationBase {
authConfigId?: string, authConfigId?: string,
authConfigType?: RestAuthType authConfigType?: RestAuthType
): Promise<{ [key: string]: any }> { ): Promise<{ [key: string]: any }> {
let headers: any = {} if (!authConfigId) {
return {}
}
if (authConfigId) {
if (authConfigType === RestAuthType.OAUTH2) { if (authConfigType === RestAuthType.OAUTH2) {
headers.Authorization = await sdk.oauth2.generateToken(authConfigId) return { Authorization: await sdk.oauth2.generateToken(authConfigId) }
} else if (this.config.authConfigs) { }
if (!this.config.authConfigs) {
return {}
}
let headers: any = {}
const authConfig = this.config.authConfigs.filter( const authConfig = this.config.authConfigs.filter(
c => c._id === authConfigId c => c._id === authConfigId
)[0] )[0]
@ -407,8 +414,6 @@ export class RestIntegration implements IntegrationBase {
throw utils.unreachable(type) throw utils.unreachable(type)
} }
} }
}
}
return headers return headers
} }

View File

@ -34,7 +34,7 @@ export async function create(
throw new HTTPError("Name already used", 400) throw new HTTPError("Name already used", 400)
} }
const id = `${VirtualDocumentType.ROW_ACTION}${SEPARATOR}${utils.newid()}` const id = `${VirtualDocumentType.OAUTH2_CONFIG}${SEPARATOR}${utils.newid()}`
doc.configs[id] = { doc.configs[id] = {
id, id,
...config, ...config,
@ -48,3 +48,49 @@ export async function get(id: string): Promise<OAuth2Config | undefined> {
const doc = await getDocument() const doc = await getDocument()
return doc?.configs?.[id] return doc?.configs?.[id]
} }
export async function update(config: OAuth2Config): Promise<OAuth2Config> {
const db = context.getAppDB()
const doc: OAuth2Configs = (await getDocument(db)) ?? {
_id: DocumentType.OAUTH2_CONFIG,
configs: {},
}
if (!doc.configs[config.id]) {
throw new HTTPError(`OAuth2 config with id '${config.id}' not found.`, 404)
}
if (
Object.values(doc.configs).find(
c => c.name === config.name && c.id !== config.id
)
) {
throw new HTTPError(
`OAuth2 config with name '${config.name}' is already taken.`,
400
)
}
doc.configs[config.id] = {
...config,
}
await db.put(doc)
return doc.configs[config.id]
}
export async function remove(configId: string): Promise<void> {
const db = context.getAppDB()
const doc: OAuth2Configs = (await getDocument(db)) ?? {
_id: DocumentType.OAUTH2_CONFIG,
configs: {},
}
if (!doc.configs[configId]) {
throw new HTTPError(`OAuth2 config with id '${configId}' not found.`, 404)
}
delete doc.configs[configId]
await db.put(doc)
}

View File

@ -1,6 +1,6 @@
import { import {
CreateOAuth2ConfigRequest, UpsertOAuth2ConfigRequest,
CreateOAuth2ConfigResponse, UpsertOAuth2ConfigResponse,
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
} from "@budibase/types" } from "@budibase/types"
import { Expectations, TestAPI } from "./base" import { Expectations, TestAPI } from "./base"
@ -13,10 +13,10 @@ export class OAuth2API extends TestAPI {
} }
create = async ( create = async (
body: CreateOAuth2ConfigRequest, body: UpsertOAuth2ConfigRequest,
expectations?: Expectations expectations?: Expectations
) => { ) => {
return await this._post<CreateOAuth2ConfigResponse>("/api/oauth2", { return await this._post<UpsertOAuth2ConfigResponse>("/api/oauth2", {
body, body,
expectations: { expectations: {
status: expectations?.status ?? 201, status: expectations?.status ?? 201,
@ -24,4 +24,21 @@ export class OAuth2API extends TestAPI {
}, },
}) })
} }
update = async (
id: string,
body: UpsertOAuth2ConfigRequest,
expectations?: Expectations
) => {
return await this._put<UpsertOAuth2ConfigResponse>(`/api/oauth2/${id}`, {
body,
expectations,
})
}
delete = async (id: string, expectations?: Expectations) => {
return await this._delete<void>(`/api/oauth2/${id}`, {
expectations,
})
}
} }

View File

@ -1,30 +1,30 @@
import { import {
Datasource, ArrayOperator,
BasicOperator,
BBReferenceFieldSubType, BBReferenceFieldSubType,
Datasource,
EmptyFilterOption,
FieldConstraints,
FieldType, FieldType,
FormulaType, FormulaType,
isArraySearchOperator,
isBasicSearchOperator,
isLogicalSearchOperator,
isRangeSearchOperator,
LegacyFilter, LegacyFilter,
LogicalOperator,
RangeOperator,
RowSearchParams,
SearchFilter,
SearchFilterOperator,
SearchFilters, SearchFilters,
SearchQueryFields, SearchQueryFields,
ArrayOperator,
SearchFilterOperator,
SortType,
FieldConstraints,
SortOrder,
RowSearchParams,
EmptyFilterOption,
SearchResponse, SearchResponse,
SortOrder,
SortType,
Table, Table,
BasicOperator,
RangeOperator,
LogicalOperator,
isLogicalSearchOperator,
UISearchFilter,
UILogicalOperator, UILogicalOperator,
isBasicSearchOperator, UISearchFilter,
isArraySearchOperator,
isRangeSearchOperator,
SearchFilter,
} from "@budibase/types" } from "@budibase/types"
import dayjs from "dayjs" import dayjs from "dayjs"
import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants" import { OperatorOptions, SqlNumberTypeRangeMap } from "./constants"
@ -444,6 +444,7 @@ export function buildQuery(
return {} return {}
} }
// Migrate legacy filters if required
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
filter = processSearchFilters(filter) filter = processSearchFilters(filter)
if (!filter) { if (!filter) {
@ -451,10 +452,7 @@ export function buildQuery(
} }
} }
const operator = logicalOperatorFromUI( // Determine top level empty filter behaviour
filter.logicalOperator || UILogicalOperator.ALL
)
const query: SearchFilters = {} const query: SearchFilters = {}
if (filter.onEmptyFilter) { if (filter.onEmptyFilter) {
query.onEmptyFilter = filter.onEmptyFilter query.onEmptyFilter = filter.onEmptyFilter
@ -462,8 +460,24 @@ export function buildQuery(
query.onEmptyFilter = EmptyFilterOption.RETURN_ALL query.onEmptyFilter = EmptyFilterOption.RETURN_ALL
} }
// Default to matching all groups/filters
const operator = logicalOperatorFromUI(
filter.logicalOperator || UILogicalOperator.ALL
)
query[operator] = { query[operator] = {
conditions: (filter.groups || []).map(group => { conditions: (filter.groups || []).map(group => {
// Check if we contain more groups
if (group.groups) {
const searchFilter = buildQuery(group)
// We don't define this properly in the types, but certain fields should
// not be present in these nested search filters
delete searchFilter.onEmptyFilter
return searchFilter
}
// Otherwise handle filters
const { allOr, onEmptyFilter, filters } = splitFiltersArray( const { allOr, onEmptyFilter, filters } = splitFiltersArray(
group.filters || [] group.filters || []
) )
@ -471,7 +485,7 @@ export function buildQuery(
query.onEmptyFilter = onEmptyFilter query.onEmptyFilter = onEmptyFilter
} }
// logicalOperator takes precendence over allOr // logicalOperator takes precedence over allOr
let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND let operator = allOr ? LogicalOperator.OR : LogicalOperator.AND
if (group.logicalOperator) { if (group.logicalOperator) {
operator = logicalOperatorFromUI(group.logicalOperator) operator = logicalOperatorFromUI(group.logicalOperator)

View File

@ -0,0 +1,156 @@
import { buildQuery } from "../filters"
import {
BasicOperator,
EmptyFilterOption,
FieldType,
UILogicalOperator,
UISearchFilter,
} from "@budibase/types"
describe("filter to query conversion", () => {
it("handles a filter with 1 group", () => {
const filter: UISearchFilter = {
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{
field: "city",
operator: BasicOperator.STRING,
value: "lon",
},
],
},
],
}
const query = buildQuery(filter)
expect(query).toEqual({
onEmptyFilter: "none",
$and: {
conditions: [
{
$and: {
conditions: [
{
string: {
city: "lon",
},
},
],
},
},
],
},
})
})
it("handles an empty filter", () => {
const filter = undefined
const query = buildQuery(filter)
expect(query).toEqual({})
})
it("handles legacy filters", () => {
const filter = [
{
field: "city",
operator: BasicOperator.STRING,
value: "lon",
},
]
const query = buildQuery(filter)
expect(query).toEqual({
onEmptyFilter: "all",
$and: {
conditions: [
{
$and: {
conditions: [
{
string: {
city: "lon",
},
},
],
},
},
],
},
})
})
it("handles nested groups", () => {
const filter: UISearchFilter = {
logicalOperator: UILogicalOperator.ALL,
onEmptyFilter: EmptyFilterOption.RETURN_NONE,
groups: [
{
logicalOperator: UILogicalOperator.ALL,
filters: [
{
field: "city",
operator: BasicOperator.STRING,
value: "lon",
},
],
},
{
logicalOperator: UILogicalOperator.ALL,
groups: [
{
logicalOperator: UILogicalOperator.ANY,
filters: [
{
valueType: "Binding",
field: "country.country_name",
type: FieldType.STRING,
operator: BasicOperator.EQUAL,
noValue: false,
value: "England",
},
],
},
],
},
],
}
const query = buildQuery(filter)
expect(query).toEqual({
onEmptyFilter: "none",
$and: {
conditions: [
{
$and: {
conditions: [
{
string: {
city: "lon",
},
},
],
},
},
{
$and: {
conditions: [
{
$or: {
conditions: [
{
equal: {
"country.country_name": "England",
},
},
],
},
},
],
},
},
],
},
})
})
})

View File

@ -7,13 +7,13 @@ export interface FetchOAuth2ConfigsResponse {
configs: OAuth2ConfigResponse[] configs: OAuth2ConfigResponse[]
} }
export interface CreateOAuth2ConfigRequest { export interface UpsertOAuth2ConfigRequest {
name: string name: string
url: string url: string
clientId: string clientId: string
clientSecret: string clientSecret: string
} }
export interface CreateOAuth2ConfigResponse { export interface UpsertOAuth2ConfigResponse {
config: OAuth2ConfigResponse config: OAuth2ConfigResponse
} }

View File

@ -38,11 +38,19 @@ export type SearchFilter = {
// involved. We convert this to a SearchFilters before use with the search SDK. // involved. We convert this to a SearchFilters before use with the search SDK.
export type LegacyFilter = AllOr | OnEmptyFilter | SearchFilter export type LegacyFilter = AllOr | OnEmptyFilter | SearchFilter
// A search filter group should either contain groups or filters, but not both
export type SearchFilterGroup = { export type SearchFilterGroup = {
logicalOperator?: UILogicalOperator logicalOperator?: UILogicalOperator
groups?: SearchFilterGroup[] } & (
filters?: LegacyFilter[] | {
groups?: (SearchFilterGroup | UISearchFilter)[]
filters?: never
} }
| {
filters?: LegacyFilter[]
groups?: never
}
)
// As of v3, this is the format that the frontend always sends when search // As of v3, this is the format that the frontend always sends when search
// filters are involved. We convert this to SearchFilters before use with the // filters are involved. We convert this to SearchFilters before use with the

View File

@ -156,8 +156,8 @@ export interface FieldConstraints {
message?: string message?: string
} }
numericality?: { numericality?: {
greaterThanOrEqualTo: string | null greaterThanOrEqualTo?: string | null
lessThanOrEqualTo: string | null lessThanOrEqualTo?: string | null
} }
presence?: presence?:
| boolean | boolean
@ -165,8 +165,8 @@ export interface FieldConstraints {
allowEmpty?: boolean allowEmpty?: boolean
} }
datetime?: { datetime?: {
latest: string latest?: string
earliest: string earliest?: string
} }
} }
@ -197,7 +197,7 @@ export interface BigIntFieldMetadata extends BaseFieldSchema {
default?: string default?: string
} }
interface BaseFieldSchema extends UIFieldMetadata { export interface BaseFieldSchema extends UIFieldMetadata {
type: FieldType type: FieldType
name: string name: string
sortable?: boolean sortable?: boolean

View File

@ -0,0 +1,43 @@
import {
FieldType,
FieldConstraints,
type FieldSchema,
type FormulaResponseType,
} from "../"
export interface UIField {
name: string
type: FieldType
subtype?: string
icon: string
constraints?: {
type?: string
presence?: boolean
length?: any
inclusion?: string[]
numericality?: {
greaterThanOrEqualTo?: string
lessThanOrEqualTo?: string
}
datetime?: {
latest?: string
earliest?: string
}
}
}
// an empty/partial field schema which is used when building new columns in the UI
// the current construction process of a column means that it is never certain what
// this object contains, or what type it is currently set to, meaning that our
// strict FieldSchema isn't really usable here, the strict fieldSchema only occurs
// when the table is saved, but in the UI in can be in a real mix of states
export type FieldSchemaConfig = FieldSchema & {
constraints: FieldConstraints
fieldName?: string
responseType?: FormulaResponseType
default?: any
fieldId?: string
optionColors?: string[]
schema?: any
json?: string
}

View File

@ -5,3 +5,4 @@ export * from "./dataFetch"
export * from "./datasource" export * from "./datasource"
export * from "./common" export * from "./common"
export * from "./BudibaseApp" export * from "./BudibaseApp"
export * from "./fields"