1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-05-12 12:21:50 +00:00

Enable linking to multiple link to table entries in the form view

This commit is contained in:
Bram Wiepjes 2023-10-23 12:52:24 +00:00
parent 587efa2b3a
commit 94af7f96f7
19 changed files with 364 additions and 52 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database/api/views/form
changelog/entries/unreleased/feature
web-frontend

View file

@ -49,6 +49,7 @@ class FormViewFieldOptionsSerializer(serializers.ModelSerializer):
"order",
"conditions",
"condition_groups",
"field_component",
)
@ -89,6 +90,7 @@ class PublicFormViewFieldOptionsSerializer(FieldSerializer):
"conditions",
"condition_groups",
"groups",
"field_component",
)
# @TODO show correct API docs discriminated by field type.

View file

@ -0,0 +1,21 @@
# Generated by Django 3.2.21 on 2023-10-23 11:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("database", "0132_add_filter_group"),
]
operations = [
migrations.AddField(
model_name="formviewfieldoptions",
name="field_component",
field=models.CharField(
default="default",
help_text="Indicates which field input component is used in the form. The value is only used in the frontend, and can differ per field.",
max_length=32,
),
),
]

View file

@ -47,6 +47,9 @@ OWNERSHIP_TYPE_COLLABORATIVE = "collaborative"
DEFAULT_OWNERSHIP_TYPE = OWNERSHIP_TYPE_COLLABORATIVE
VIEW_OWNERSHIP_TYPES = [OWNERSHIP_TYPE_COLLABORATIVE]
# Must be the same as `modules/database/constants.js`.
DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY = "default"
def get_default_view_content_type():
return ContentType.objects.get_for_model(View)
@ -770,6 +773,12 @@ class FormViewFieldOptions(HierarchicalModelMixin, models.Model):
help_text="Indicates whether all (AND) or any (OR) of the conditions should "
"match before shown.",
)
field_component = models.CharField(
max_length=32,
default=DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY,
help_text="Indicates which field input component is used in the form. The "
"value is only used in the frontend, and can differ per field.",
)
# The default value is the maximum value of the small integer field because a newly
# created field must always be last.
order = models.SmallIntegerField(

View file

@ -543,6 +543,7 @@ class FormViewType(ViewType):
"show_when_matching_conditions",
"condition_type",
"order",
"field_component",
]
serializer_field_names = [
"title",
@ -978,6 +979,7 @@ class FormViewType(ViewType):
}
for condition in field_option.conditions.all()
],
"field_component": field_option.field_component,
}
)

View file

@ -325,6 +325,7 @@ def test_meta_submit_form_view(api_client, data_fixture):
"condition_groups": [],
"show_when_matching_conditions": False,
"field": {"id": text_field.id, "type": "text", "text_default": ""},
"field_component": "default",
}
assert response_json["fields"][1] == {
"name": number_field.name,
@ -341,6 +342,7 @@ def test_meta_submit_form_view(api_client, data_fixture):
"number_decimal_places": 0,
"number_negative": False,
},
"field_component": "default",
}
@ -871,6 +873,7 @@ def test_get_form_view_field_options(
"group": condition_group_1.id,
}
],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -882,6 +885,7 @@ def test_get_form_view_field_options(
"condition_type": "AND",
"conditions": [],
"condition_groups": [],
"field_component": "default",
},
}
}
@ -940,6 +944,7 @@ def test_patch_form_view_field_options_conditions_create(
"group": None,
}
],
"field_component": "test",
}
}
},
@ -970,6 +975,7 @@ def test_patch_form_view_field_options_conditions_create(
"group": None,
}
],
"field_component": "test",
},
str(text_field_2.id): {
"name": "",
@ -981,6 +987,7 @@ def test_patch_form_view_field_options_conditions_create(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}
@ -1080,6 +1087,7 @@ def test_patch_form_view_field_options_condition_groups_create(
"group": conditions[1]["group_id"],
},
],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -1091,6 +1099,7 @@ def test_patch_form_view_field_options_condition_groups_create(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}
@ -1159,6 +1168,7 @@ def test_patch_form_view_field_options_conditions_update(
"group": None,
}
],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -1170,6 +1180,7 @@ def test_patch_form_view_field_options_conditions_update(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_3.id): {
"name": "",
@ -1181,6 +1192,7 @@ def test_patch_form_view_field_options_conditions_update(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}
@ -1291,6 +1303,7 @@ def test_patch_form_view_field_options_condition_groups_update(
"group": condition_group_1.id,
}
],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -1302,6 +1315,7 @@ def test_patch_form_view_field_options_condition_groups_update(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_3.id): {
"name": "",
@ -1313,6 +1327,7 @@ def test_patch_form_view_field_options_condition_groups_update(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}
@ -1386,6 +1401,7 @@ def test_patch_form_view_field_options_conditions_update_position(
"order": 1,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_3.id): {
"name": "",
@ -1405,6 +1421,7 @@ def test_patch_form_view_field_options_conditions_update_position(
"group": None,
}
],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -1416,6 +1433,7 @@ def test_patch_form_view_field_options_conditions_update_position(
"order": 3,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}
@ -1467,6 +1485,7 @@ def test_patch_form_view_field_options_conditions_delete(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -1478,6 +1497,7 @@ def test_patch_form_view_field_options_conditions_delete(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_3.id): {
"name": "",
@ -1489,6 +1509,7 @@ def test_patch_form_view_field_options_conditions_delete(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}
@ -1545,6 +1566,7 @@ def test_patch_form_view_field_options_condition_groups_delete(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_2.id): {
"name": "",
@ -1556,6 +1578,7 @@ def test_patch_form_view_field_options_condition_groups_delete(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
str(text_field_3.id): {
"name": "",
@ -1567,6 +1590,7 @@ def test_patch_form_view_field_options_condition_groups_delete(
"order": 32767,
"condition_groups": [],
"conditions": [],
"field_component": "default",
},
}
}

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Enable linking to multiple link to table entries in form view",
"issue_number": 810,
"bullet_points": [],
"created_at": "2023-10-22"
}

View file

@ -100,7 +100,10 @@
"count": "Count",
"rollup": "Rollup",
"lookup": "Lookup",
"multipleCollaborators": "Collaborators"
"multipleCollaborators": "Collaborators",
"defaultFormViewComponent": "Default",
"linkRowSingle": "Single",
"linkRowMultiple": "Multiple"
},
"fieldErrors": {
"invalidNumber": "Invalid number",

View file

@ -6,15 +6,11 @@
justify-content: right;
}
&.control--horizontal {
&.control--horizontal,
&.control--horizontal-variable {
display: flex;
flex-wrap: wrap;
align-items: center;
& .control__elements {
flex-basis: 70%;
margin-top: 0;
}
}
}
@ -50,6 +46,12 @@
font-weight: 500;
margin: 0;
}
.control--horizontal-variable & {
flex: auto 0 0;
font-weight: 500;
margin: 0;
}
}
.control__label-icon {
@ -62,6 +64,18 @@
position: relative;
}
.control__elements {
.control--horizontal & {
flex-basis: 70%;
margin-top: 0;
}
.control--horizontal-variable & {
margin-top: 0;
margin-left: 16px;
}
}
.control__elements--flex {
display: flex;
align-items: center;

View file

@ -160,6 +160,17 @@
}
}
.flex {
width: 100%;
display: flex;
gap: var(--gap, 10px);
}
.flex-100 {
width: 100%;
flex-basis: 100%;
}
@keyframes spin {
0% {
transform: rotate(0);

View file

@ -4,6 +4,7 @@
class="control"
:class="{
'control--horizontal': horizontal,
'control--horizontal-variable': horizontalVariable,
}"
>
<label
@ -121,6 +122,11 @@ export default {
required: false,
default: false,
},
horizontalVariable: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Boolean,
required: false,

View file

@ -121,6 +121,14 @@
large
icon-left="iconoir-search"
/>
<FormInput
v-model="input"
horizontal-variable
label="Horizontal Large icon field left"
placeholder="Enter something here"
large
icon-left="iconoir-search"
/>
<div class="control">
<label class="control__label">Checkbox field</label>
<div class="control__elements">

View file

@ -8,7 +8,7 @@
<!-- prettier-ignore -->
<div v-if="field.description" class="form-view__field-description whitespace-pre-wrap">{{ field.description }}</div>
<component
:is="getFieldComponent()"
:is="selectedFieldComponent.component"
:key="field.field.id"
ref="field"
:workspace-id="0"
@ -18,7 +18,7 @@
:read-only="false"
:required="field.required"
:touched="field._.touched"
v-bind="getFieldComponentProperties()"
v-bind="selectedFieldComponent.properties"
@update="$emit('input', $event)"
@touched="field._.touched = true"
/>
@ -29,6 +29,7 @@
<script>
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
import { DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY } from '@baserow/modules/database/constants'
export default {
name: 'FormPageField',
@ -47,17 +48,20 @@ export default {
required: true,
},
},
computed: {
selectedFieldComponent() {
const components = this.$registry
.get('field', this.field.field.type)
.getFormViewFieldComponents(this.field.field, this)
return Object.prototype.hasOwnProperty.call(
components,
this.field.field_component
)
? components[this.field.field_component]
: components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY]
},
},
methods: {
getFieldComponent() {
return this.$registry
.get('field', this.field.field.type)
.getFormViewFieldComponent(this.field.field)
},
getFieldComponentProperties() {
return this.$registry
.get('field', this.field.field.type)
.getFormViewFieldComponentProperties(this)
},
focus() {
this.$el.scrollIntoView({ behavior: 'smooth' })
this.$emit('focussed')

View file

@ -66,7 +66,7 @@
></a>
</div>
<component
:is="getFieldComponent()"
:is="selectedFieldComponent.component"
ref="field"
:slug="view.slug"
:workspace-id="database.workspace.id"
@ -74,9 +74,31 @@
:value="value"
:read-only="readOnly"
:lazy-load="true"
:touched="false"
:required="fieldOptions.required"
@update="updateValue"
/>
<div class="form-view__field-options">
<div
v-if="Object.keys(fieldComponents).length > 1"
class="control control--horizontal-variable"
>
<label class="control__label">
{{ $t('formViewField.showFieldAs') }}
</label>
<div class="control__elements">
<RadioButton
v-for="(v, key) in fieldComponents"
:key="key"
:model-value="fieldOptions.field_component"
:value="key"
@input="
$emit('updated-field-options', { field_component: $event })
"
>{{ v.name }}</RadioButton
>
</div>
</div>
<SwitchInput
:value="fieldOptions.required"
:large="true"
@ -150,6 +172,7 @@
<script>
import { isElement, onClickOutside } from '@baserow/modules/core/utils/dom'
import { clone } from '@baserow/modules/core/utils/object'
import { DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY } from '@baserow/modules/database/constants'
import FieldContext from '@baserow/modules/database/components/field/FieldContext'
import ViewFieldConditionsForm from '@baserow/modules/database/components/view/ViewFieldConditionsForm'
@ -205,6 +228,18 @@ export default {
const index = this.fields.findIndex((f) => f.id === this.field.id)
return this.fields.slice(0, index)
},
fieldComponents() {
return this.getFieldType().getFormViewFieldComponents(this.field, this)
},
selectedFieldComponent() {
const components = this.fieldComponents
return Object.prototype.hasOwnProperty.call(
components,
this.fieldOptions.field_component
)
? components[this.fieldOptions.field_component]
: components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY]
},
},
watch: {
field: {
@ -248,9 +283,6 @@ export default {
getFieldType() {
return this.$registry.get('field', this.field.type)
},
getFieldComponent() {
return this.getFieldType().getFormViewFieldComponent(this.field)
},
resetValue() {
this.value = this.getFieldType().getEmptyValue(this.field)
},

View file

@ -0,0 +1,123 @@
<template>
<div class="control__elements">
<div
v-for="(v, index) in value"
:key="index + '-' + value[index].id"
class="margin-bottom-2 flex"
>
<div class="flex-100">
<PaginatedDropdown
:fetch-page="fetchPage"
:value="value[index].id"
:initial-display-name="value[index].value"
class="dropdown--tiny"
:class="{
'dropdown--error':
touched && !valid && isInvalidValue(value[index]),
}"
:fetch-on-open="lazyLoad"
:disabled="readOnly"
:include-display-name-in-selected-event="true"
@input="updateValue($event, index)"
></PaginatedDropdown>
</div>
<div class="align-right">
<Button
tag="a"
icon="iconoir-trash"
type="ghost"
@click="remove(index)"
></Button>
</div>
</div>
<div>
<Button tag="a" icon="iconoir-plus" type="ghost" @click="add"></Button>
</div>
<div v-show="touched && !valid" class="error">
{{ error }}
</div>
</div>
</template>
<script>
import PaginatedDropdown from '@baserow/modules/core/components/PaginatedDropdown'
import rowEditField from '@baserow/modules/database/mixins/rowEditField'
import ViewService from '@baserow/modules/database/services/view'
import { clone } from '@baserow/modules/core/utils/object'
export default {
name: 'FormViewFieldMultipleLinkRow',
components: { PaginatedDropdown },
mixins: [rowEditField],
props: {
slug: {
type: String,
required: true,
},
/**
* In some cases, for example in the form view preview, we only want to fetch the
* first related rows after the user has opened the dropdown. This will prevent a
* race condition where the enabled state of the field might not yet been updated
* before we fetch the related rows. If the state has not yet been changed in the
* backend, it will result in an error.
*/
lazyLoad: {
type: Boolean,
required: false,
default: false,
},
},
created() {
if (this.value.length === 0 && this.required) {
this.add()
}
},
methods: {
getValidationError(value) {
const error = rowEditField.methods.getValidationError.call(this, value)
if (!this.required && error === null) {
const empty = value.some((v) => this.isInvalidValue(v))
if (empty) {
return this.$t('error.requiredField')
}
}
return error
},
isInvalidValue(value) {
return !Number.isInteger(value.id)
},
fetchPage(page, search) {
const publicAuthToken =
this.$store.getters['page/view/public/getAuthToken']
return ViewService(this.$client).linkRowFieldLookup(
this.slug,
this.field.id,
page,
search,
100,
publicAuthToken
)
},
add() {
const newValue = clone(this.value)
newValue.push({
id: false,
value: '',
})
this.$emit('update', newValue, this.value)
},
remove(index) {
const newValue = clone(this.value)
newValue.splice(index, 1)
this.$emit('update', newValue, this.value)
},
updateValue({ value, displayName }, index) {
const newValue = clone(this.value)
newValue[index] = { id: value, value: displayName }
this.$emit('update', newValue, this.value)
},
},
}
</script>

View file

@ -47,7 +47,10 @@ export default {
computed: {
compatible() {
const fieldType = this.$registry.get('field', this.field.type)
return fieldType.getFormViewFieldComponent(this.field) !== null
return (
Object.keys(fieldType.getFormViewFieldComponents(this.field, this))
.length > 0
)
},
},
}

View file

@ -0,0 +1,2 @@
// Must be the same as `src/baserow/contrib/database/constants.py`.
export const DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY = 'default'

View file

@ -93,6 +93,7 @@ import RowHistoryFieldBoolean from '@baserow/modules/database/components/row/Row
import RowHistoryFieldLinkRow from '@baserow/modules/database/components/row/RowHistoryFieldLinkRow'
import FormViewFieldLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldLinkRow'
import FormViewFieldMultipleLinkRow from '@baserow/modules/database/components/view/form/FormViewFieldMultipleLinkRow'
import { trueString } from '@baserow/modules/database/utils/constants'
import {
@ -111,6 +112,7 @@ import FieldLookupSubForm from '@baserow/modules/database/components/field/Field
import FieldCountSubForm from '@baserow/modules/database/components/field/FieldCountSubForm'
import FieldRollupSubForm from '@baserow/modules/database/components/field/FieldRollupSubForm'
import RowEditFieldFormula from '@baserow/modules/database/components/row/RowEditFieldFormula'
import { DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY } from '@baserow/modules/database/constants'
import ViewService from '@baserow/modules/database/services/view'
import FormService from '@baserow/modules/database/services/view/form'
import { UploadFileUserFileUploadType } from '@baserow/modules/core/userFileUploadTypes'
@ -181,19 +183,24 @@ export class FieldType extends Registerable {
}
/**
* By default the row edit field component is used in the form. This can
* optionally be another component if needed. If null is returned, then the field
* is marked as not compatible with the form view.
* By default, the edit field component is used in the form. This can optionally be
* replaced by another component if needed. If an empty object `{}` is returned,
* then the field is marked as not compatible with the form view.
*
* The returned object should have one key with an empty string `''` as default
* component. If the object has multiple keys, then the user will be presented
* with these as options. This can be used to for example display a `single_select`
* field type as dropdown or radio inputs.
*/
getFormViewFieldComponent(field) {
return this.getRowEditFieldComponent(field)
}
/*
* Optional properties for the FormViewFieldComponent
*/
getFormViewFieldComponentProperties(context) {
return {}
getFormViewFieldComponents(field) {
const { i18n } = this.app
return {
[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY]: {
name: i18n.t('fieldType.defaultFormViewComponent'),
component: this.getRowEditFieldComponent(field),
properties: {},
},
}
}
/**
@ -856,8 +863,20 @@ export class LinkRowFieldType extends FieldType {
return RowEditFieldLinkRow
}
getFormViewFieldComponent() {
return FormViewFieldLinkRow
getFormViewFieldComponents(field) {
const { i18n } = this.app
const components = super.getFormViewFieldComponents(field)
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].name = i18n.t(
'fieldType.linkRowSingle'
)
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].component =
FormViewFieldLinkRow
components.multiple = {
name: i18n.t('fieldType.linkRowMultiple'),
component: FormViewFieldMultipleLinkRow,
properties: {},
}
return components
}
getCardComponent() {
@ -1018,6 +1037,18 @@ export class LinkRowFieldType extends FieldType {
getCanImport() {
return true
}
isEmpty(field, value) {
if (super.isEmpty(field, value)) {
return true
}
if (value.some((v) => !Number.isInteger(v.id))) {
return true
}
return false
}
}
export class NumberFieldType extends FieldType {
@ -1705,8 +1736,8 @@ export class CreatedOnLastModifiedBaseFieldType extends BaseDateFieldType {
return FieldDateSubForm
}
getFormViewFieldComponent() {
return null
getFormViewFieldComponents(field) {
return {}
}
getRowEditFieldComponent(field) {
@ -2040,9 +2071,10 @@ export class FileFieldType extends FieldType {
return RowHistoryFieldFile
}
getFormViewFieldComponentProperties({ $store, $client, slug }) {
getFormViewFieldComponents(field, { $store, $client, slug }) {
const components = super.getFormViewFieldComponents(field)
const userFileUploadTypes = [UploadFileUserFileUploadType.getType()]
return {
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].properties = {
userFileUploadTypes,
uploadFile: (file, progress) => {
return FormService($client).uploadFile(
@ -2053,6 +2085,7 @@ export class FileFieldType extends FieldType {
)
},
}
return components
}
getCardComponent() {
@ -2229,10 +2262,12 @@ export class SingleSelectFieldType extends FieldType {
return RowHistoryFieldSingleSelect
}
getFormViewFieldComponentProperties() {
return {
getFormViewFieldComponents(field) {
const components = super.getFormViewFieldComponents(field)
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].properties = {
'allow-create-options': false,
}
return components
}
getCardComponent() {
@ -2419,10 +2454,12 @@ export class MultipleSelectFieldType extends FieldType {
return RowEditFieldMultipleSelect
}
getFormViewFieldComponentProperties() {
return {
getFormViewFieldComponents(field) {
const components = super.getFormViewFieldComponents(field)
components[DEFAULT_FORM_VIEW_FIELD_COMPONENT_KEY].properties = {
'allow-create-options': false,
}
return components
}
getCardComponent() {
@ -2884,8 +2921,8 @@ export class FormulaFieldType extends FieldType {
return true
}
getFormViewFieldComponent() {
return null
getFormViewFieldComponents(field) {
return {}
}
canBeReferencedByFormulaField() {
@ -3029,8 +3066,8 @@ export class MultipleCollaboratorsFieldType extends FieldType {
return value
}
getFormViewFieldComponent() {
return null
getFormViewFieldComponents(field) {
return {}
}
getEmptyValue() {

View file

@ -710,7 +710,8 @@
"descriptionPlaceholder": "Description",
"showWhenMatchingConditions": "show when conditions are met",
"addCondition": "Add condition",
"addConditionGroup": "Add condition group"
"addConditionGroup": "Add condition group",
"showFieldAs": "Show field as"
},
"duplicateFieldContext": {
"duplicate": "Duplicate field",

View file

@ -53,7 +53,10 @@ export default {
return true
}
const fieldType = this.$registry.get('field', field.type)
return fieldType.getFormViewFieldComponent(field) !== null
return (
Object.keys(fieldType.getFormViewFieldComponents(field, this))
.length > 0
)
})
.forEach((field) => {
newFieldOptions[field.id] = values