Merge remote-tracking branch 'origin/master' into BUDI-9127/allow-selecting-oauth2-configs
This commit is contained in:
commit
f455fb1bee
|
@ -47,6 +47,9 @@ export default [
|
||||||
|
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
allowImportExportEverywhere: true,
|
allowImportExportEverywhere: true,
|
||||||
|
svelteFeatures: {
|
||||||
|
experimentalGenerics: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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 = () => {}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
// Set the tableId based on the selected table
|
if (relationshipTableIdSecondary) {
|
||||||
editableColumn.tableId = relationshipTableIdSecondary
|
// Set the tableId based on the selected table
|
||||||
|
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(
|
||||||
if (!tableAutoColumnsTypes.includes(key)) {
|
(acc: Record<string, { enabled: boolean; name: string }>, key: string) => {
|
||||||
acc[key] = autoColumnInfo[key]
|
if (!tableAutoColumnsTypes.includes(key)) {
|
||||||
}
|
const subtypeKey = key as AutoFieldSubType
|
||||||
return acc
|
if (autoColumnInfo[subtypeKey]) {
|
||||||
}, {})
|
acc[key] = autoColumnInfo[subtypeKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
)
|
||||||
$: availableAutoColumnKeys = availableAutoColumns
|
$: availableAutoColumnKeys = availableAutoColumns
|
||||||
? Object.keys(availableAutoColumns)
|
? Object.keys(availableAutoColumns)
|
||||||
: []
|
: []
|
||||||
|
@ -176,21 +221,26 @@
|
||||||
!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)
|
||||||
fieldId: makeFieldId(t.type, t.subtype),
|
$: orderedAllowedTypes = fixedTypeOrder
|
||||||
...t,
|
.filter(ordered =>
|
||||||
}))
|
allowedTypes.find(allowed => allowed.type === ordered.type)
|
||||||
|
)
|
||||||
|
.map(t => ({
|
||||||
|
fieldId: makeFieldId(t.type, t.subtype),
|
||||||
|
...t,
|
||||||
|
}))
|
||||||
$: defaultValueBindings = [
|
$: defaultValueBindings = [
|
||||||
{
|
{
|
||||||
type: "context",
|
type: "context",
|
||||||
|
@ -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(
|
||||||
let fieldSchema = table?.schema[key]
|
(acc: string[], key: string) => {
|
||||||
if (fieldSchema.autocolumn) {
|
let fieldSchema = table?.schema[key]
|
||||||
acc.push(fieldSchema.subtype)
|
if (fieldSchema?.autocolumn && 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() {
|
||||||
editableColumn.name = originalName
|
if (originalName) {
|
||||||
|
editableColumn.name = originalName
|
||||||
|
}
|
||||||
gridDispatch("close-edit-column")
|
gridDispatch("close-edit-column")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteColumn() {
|
async function deleteColumn() {
|
||||||
try {
|
try {
|
||||||
editableColumn.name = deleteColName
|
if (deleteColName) {
|
||||||
if (editableColumn.name === $tables.selected.primaryDisplay) {
|
editableColumn.name = deleteColName
|
||||||
|
}
|
||||||
|
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
|
||||||
editableColumn.subtype = definition.subtype
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("No valid allowed types found")
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkConstraints(fieldToCheck) {
|
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,26 +769,30 @@
|
||||||
<div class="label-length">
|
<div class="label-length">
|
||||||
<Label size="M">Earliest</Label>
|
<Label size="M">Earliest</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-length">
|
{#if editableColumn.constraints.datetime}
|
||||||
<DatePicker
|
<div class="input-length">
|
||||||
bind:value={editableColumn.constraints.datetime.earliest}
|
<DatePicker
|
||||||
enableTime={!editableColumn.dateOnly}
|
bind:value={editableColumn.constraints.datetime.earliest}
|
||||||
timeOnly={editableColumn.timeOnly}
|
enableTime={!editableColumn.dateOnly}
|
||||||
/>
|
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>
|
||||||
<div class="input-length">
|
{#if editableColumn.constraints.datetime}
|
||||||
<DatePicker
|
<div class="input-length">
|
||||||
bind:value={editableColumn.constraints.datetime.latest}
|
<DatePicker
|
||||||
enableTime={!editableColumn.dateOnly}
|
bind:value={editableColumn.constraints.datetime.latest}
|
||||||
timeOnly={editableColumn.timeOnly}
|
enableTime={!editableColumn.dateOnly}
|
||||||
/>
|
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,25 +822,30 @@
|
||||||
<div class="label-length">
|
<div class="label-length">
|
||||||
<Label size="M">Min Value</Label>
|
<Label size="M">Min Value</Label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-length">
|
{#if editableColumn.constraints.numericality}
|
||||||
<Input
|
<div class="input-length">
|
||||||
type="number"
|
<Input
|
||||||
bind:value={editableColumn.constraints.numericality
|
type="number"
|
||||||
.greaterThanOrEqualTo}
|
bind:value={editableColumn.constraints.numericality
|
||||||
/>
|
.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>
|
||||||
<div class="input-length">
|
{#if editableColumn.constraints.numericality}
|
||||||
<Input
|
<div class="input-length">
|
||||||
type="number"
|
<Input
|
||||||
bind:value={editableColumn.constraints.numericality.lessThanOrEqualTo}
|
type="number"
|
||||||
/>
|
bind:value={editableColumn.constraints.numericality
|
||||||
</div>
|
.lessThanOrEqualTo}
|
||||||
|
/>
|
||||||
|
</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,9 +911,11 @@
|
||||||
title="Formula"
|
title="Formula"
|
||||||
value={editableColumn.formula}
|
value={editableColumn.formula}
|
||||||
on:change={e => {
|
on:change={e => {
|
||||||
editableColumn = {
|
if (editableColumn.type === FieldType.FORMULA) {
|
||||||
...editableColumn,
|
editableColumn = {
|
||||||
formula: e.detail,
|
...editableColumn,
|
||||||
|
formula: e.detail,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
bindings={getBindings({ table })}
|
bindings={getBindings({ table })}
|
||||||
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
})
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -41,17 +41,17 @@
|
||||||
>
|
>
|
||||||
</Layout>
|
</Layout>
|
||||||
<Divider />
|
<Divider />
|
||||||
</Layout>
|
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
data={configs}
|
data={configs}
|
||||||
loading={$oauth2.loading}
|
loading={$oauth2.loading}
|
||||||
{schema}
|
{schema}
|
||||||
{customRenderers}
|
{customRenderers}
|
||||||
allowEditRows={false}
|
allowEditRows={false}
|
||||||
allowEditColumns={false}
|
allowEditColumns={false}
|
||||||
allowClickRows={false}
|
allowClickRows={false}
|
||||||
/>
|
/>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.header {
|
.header {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
import { CreateOAuth2ConfigRequest } from "@budibase/types"
|
import { UpsertOAuth2ConfigRequest } from "@budibase/types"
|
||||||
|
|
||||||
export interface CreateOAuth2Config extends CreateOAuth2ConfigRequest {}
|
export interface CreateOAuth2Config extends UpsertOAuth2ConfigRequest {}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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({
|
operator: BasicOperator.STRING,
|
||||||
// Use a big numeric prefix to avoid clashing with an existing filter
|
value: searchTerm,
|
||||||
field: `999:${primaryDisplay}`,
|
},
|
||||||
operator: BasicOperator.STRING,
|
],
|
||||||
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>
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -397,14 +397,19 @@ export function parseFilter(filter: UISearchFilter) {
|
||||||
|
|
||||||
const update = cloneDeep(filter)
|
const update = cloneDeep(filter)
|
||||||
|
|
||||||
update.groups = update.groups
|
if (update.groups) {
|
||||||
?.map(group => {
|
update.groups = update.groups
|
||||||
group.filters = group.filters?.filter((filter: any) => {
|
.map(group => {
|
||||||
return filter.field && filter.operator
|
if (group.filters) {
|
||||||
|
group.filters = group.filters.filter((filter: any) => {
|
||||||
|
return filter.field && filter.operator
|
||||||
|
})
|
||||||
|
return group.filters?.length ? group : null
|
||||||
|
}
|
||||||
|
return group
|
||||||
})
|
})
|
||||||
return group.filters?.length ? group : null
|
.filter((group): group is SearchFilterGroup => !!group)
|
||||||
})
|
}
|
||||||
.filter((group): group is SearchFilterGroup => !!group)
|
|
||||||
|
|
||||||
return update
|
return update
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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." },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -2195,95 +2195,94 @@ if (descriptions.length) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
isInternal &&
|
describe("attachments and signatures", () => {
|
||||||
describe("attachments and signatures", () => {
|
const coreAttachmentEnrichment = async (
|
||||||
const coreAttachmentEnrichment = async (
|
schema: TableSchema,
|
||||||
schema: TableSchema,
|
field: string,
|
||||||
field: string,
|
attachmentCfg: string | string[]
|
||||||
attachmentCfg: string | string[]
|
) => {
|
||||||
) => {
|
const testTable = await config.api.table.save(
|
||||||
const testTable = await config.api.table.save(
|
defaultTable({
|
||||||
defaultTable({
|
schema,
|
||||||
schema,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
const attachmentToStoreKey = (attachmentId: string) => {
|
|
||||||
return {
|
|
||||||
key: `${config.getAppId()}/attachments/${attachmentId}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const draftRow = {
|
|
||||||
name: "test",
|
|
||||||
description: "test",
|
|
||||||
[field]:
|
|
||||||
typeof attachmentCfg === "string"
|
|
||||||
? attachmentToStoreKey(attachmentCfg)
|
|
||||||
: attachmentCfg.map(attachmentToStoreKey),
|
|
||||||
tableId: testTable._id,
|
|
||||||
}
|
|
||||||
const row = await config.api.row.save(testTable._id!, draftRow)
|
|
||||||
|
|
||||||
await withEnv({ SELF_HOSTED: "true" }, async () => {
|
|
||||||
return context.doInAppContext(config.getAppId(), async () => {
|
|
||||||
const enriched: Row[] = await outputProcessing(testTable, [row])
|
|
||||||
const [targetRow] = enriched
|
|
||||||
const attachmentEntries = Array.isArray(targetRow[field])
|
|
||||||
? targetRow[field]
|
|
||||||
: [targetRow[field]]
|
|
||||||
|
|
||||||
for (const entry of attachmentEntries) {
|
|
||||||
const attachmentId = entry.key.split("/").pop()
|
|
||||||
expect(entry.url.split("?")[0]).toBe(
|
|
||||||
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
const attachmentToStoreKey = (attachmentId: string) => {
|
||||||
|
return {
|
||||||
|
key: `${config.getAppId()}/attachments/${attachmentId}`,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const draftRow = {
|
||||||
|
name: "test",
|
||||||
|
description: "test",
|
||||||
|
[field]:
|
||||||
|
typeof attachmentCfg === "string"
|
||||||
|
? attachmentToStoreKey(attachmentCfg)
|
||||||
|
: attachmentCfg.map(attachmentToStoreKey),
|
||||||
|
tableId: testTable._id,
|
||||||
|
}
|
||||||
|
const row = await config.api.row.save(testTable._id!, draftRow)
|
||||||
|
|
||||||
it("should allow enriching single attachment rows", async () => {
|
await withEnv({ SELF_HOSTED: "true" }, async () => {
|
||||||
await coreAttachmentEnrichment(
|
return context.doInAppContext(config.getAppId(), async () => {
|
||||||
{
|
const enriched: Row[] = await outputProcessing(testTable, [row])
|
||||||
attachment: {
|
const [targetRow] = enriched
|
||||||
type: FieldType.ATTACHMENT_SINGLE,
|
const attachmentEntries = Array.isArray(targetRow[field])
|
||||||
name: "attachment",
|
? targetRow[field]
|
||||||
constraints: { presence: false },
|
: [targetRow[field]]
|
||||||
},
|
|
||||||
},
|
|
||||||
"attachment",
|
|
||||||
`${uuid.v4()}.csv`
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("should allow enriching attachment list rows", async () => {
|
for (const entry of attachmentEntries) {
|
||||||
await coreAttachmentEnrichment(
|
const attachmentId = entry.key.split("/").pop()
|
||||||
{
|
expect(entry.url.split("?")[0]).toBe(
|
||||||
attachments: {
|
`/files/signed/prod-budi-app-assets/${config.getProdAppId()}/attachments/${attachmentId}`
|
||||||
type: FieldType.ATTACHMENTS,
|
)
|
||||||
name: "attachments",
|
}
|
||||||
constraints: { type: "array", presence: false },
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
"attachments",
|
|
||||||
[`${uuid.v4()}.csv`]
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
it("should allow enriching signature rows", async () => {
|
it("should allow enriching single attachment rows", async () => {
|
||||||
await coreAttachmentEnrichment(
|
await coreAttachmentEnrichment(
|
||||||
{
|
{
|
||||||
signature: {
|
attachment: {
|
||||||
type: FieldType.SIGNATURE_SINGLE,
|
type: FieldType.ATTACHMENT_SINGLE,
|
||||||
name: "signature",
|
name: "attachment",
|
||||||
constraints: { presence: false },
|
constraints: { presence: false },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"signature",
|
},
|
||||||
`${uuid.v4()}.png`
|
"attachment",
|
||||||
)
|
`${uuid.v4()}.csv`
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("should allow enriching attachment list rows", async () => {
|
||||||
|
await coreAttachmentEnrichment(
|
||||||
|
{
|
||||||
|
attachments: {
|
||||||
|
type: FieldType.ATTACHMENTS,
|
||||||
|
name: "attachments",
|
||||||
|
constraints: { type: "array", presence: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"attachments",
|
||||||
|
[`${uuid.v4()}.csv`]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("should allow enriching signature rows", async () => {
|
||||||
|
await coreAttachmentEnrichment(
|
||||||
|
{
|
||||||
|
signature: {
|
||||||
|
type: FieldType.SIGNATURE_SINGLE,
|
||||||
|
name: "signature",
|
||||||
|
constraints: { presence: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"signature",
|
||||||
|
`${uuid.v4()}.png`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("exportRows", () => {
|
describe("exportRows", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
table = await config.api.table.save(defaultTable())
|
table = await config.api.table.save(defaultTable())
|
||||||
|
|
|
@ -381,32 +381,37 @@ 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) {
|
return { Authorization: await sdk.oauth2.generateToken(authConfigId) }
|
||||||
headers.Authorization = await sdk.oauth2.generateToken(authConfigId)
|
}
|
||||||
} else if (this.config.authConfigs) {
|
|
||||||
const authConfig = this.config.authConfigs.filter(
|
if (!this.config.authConfigs) {
|
||||||
c => c._id === authConfigId
|
return {}
|
||||||
)[0]
|
}
|
||||||
// check the config still exists before proceeding
|
|
||||||
// if not - do nothing
|
let headers: any = {}
|
||||||
if (authConfig) {
|
const authConfig = this.config.authConfigs.filter(
|
||||||
const { type, config } = authConfig
|
c => c._id === authConfigId
|
||||||
switch (type) {
|
)[0]
|
||||||
case RestAuthType.BASIC:
|
// check the config still exists before proceeding
|
||||||
headers.Authorization = `Basic ${Buffer.from(
|
// if not - do nothing
|
||||||
`${config.username}:${config.password}`
|
if (authConfig) {
|
||||||
).toString("base64")}`
|
const { type, config } = authConfig
|
||||||
break
|
switch (type) {
|
||||||
case RestAuthType.BEARER:
|
case RestAuthType.BASIC:
|
||||||
headers.Authorization = `Bearer ${config.token}`
|
headers.Authorization = `Basic ${Buffer.from(
|
||||||
break
|
`${config.username}:${config.password}`
|
||||||
default:
|
).toString("base64")}`
|
||||||
throw utils.unreachable(type)
|
break
|
||||||
}
|
case RestAuthType.BEARER:
|
||||||
}
|
headers.Authorization = `Bearer ${config.token}`
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
throw utils.unreachable(type)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue