1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-12 16:28:06 +00:00

Merge branch '2090_field_description' into 'develop'

 field description

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2024-05-28 18:39:39 +00:00
commit 3db2391fa7
30 changed files with 709 additions and 104 deletions
backend
src/baserow/contrib/database
tests/baserow/contrib/database
changelog/entries/unreleased/feature
enterprise/web-frontend/modules/baserow_enterprise/components
premium/backend/tests/baserow_premium_tests/api/views/views
web-frontend

View file

@ -34,10 +34,25 @@ class FieldSerializer(serializers.ModelSerializer):
class Meta:
model = Field
fields = ("id", "table_id", "name", "order", "type", "primary", "read_only")
fields = (
"id",
"table_id",
"name",
"order",
"type",
"primary",
"read_only",
"description",
)
extra_kwargs = {
"id": {"read_only": True},
"table_id": {"read_only": True},
"description": {
"required": False,
"default": None,
"allow_null": True,
"allow_blank": True,
},
}
@extend_schema_field(OpenApiTypes.STR)
@ -84,7 +99,15 @@ class CreateFieldSerializer(serializers.ModelSerializer):
class Meta:
model = Field
fields = ("name", "type")
fields = ("name", "type", "description")
extra_kwargs = {
"description": {
"required": False,
"default": None,
"allow_null": True,
"allow_blank": True,
}
}
class UpdateFieldSerializer(serializers.ModelSerializer):
@ -94,9 +117,15 @@ class UpdateFieldSerializer(serializers.ModelSerializer):
class Meta:
model = Field
fields = ("name", "type")
fields = ("name", "type", "description")
extra_kwargs = {
"name": {"required": False},
"description": {
"required": False,
"default": None,
"allow_null": True,
"allow_blank": True,
},
}

View file

@ -291,6 +291,7 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
return_updated_fields=False,
primary_key=None,
skip_search_updates=False,
description: Optional[str] = None,
**kwargs,
) -> Union[Field, Tuple[Field, List[Field]]]:
"""
@ -366,6 +367,7 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
primary=primary,
pk=primary_key,
tsvector_column_created=table.tsvectors_are_supported,
description=description,
**field_values,
)
@ -510,7 +512,7 @@ class FieldHandler(metaclass=baserow_trace_methods(tracer)):
dependants_broken_due_to_type_change = []
to_field_type = from_field_type
allowed_fields = ["name"] + to_field_type.allowed_fields
allowed_fields = ["name", "description"] + to_field_type.allowed_fields
field_values = extract_allowed(kwargs, allowed_fields)
self._validate_name_and_optionally_rename_if_collision(

View file

@ -122,6 +122,9 @@ class Field(
"search release which haven't been lazily migrated yet. Or for "
"users who have turned off full text search entirely.",
)
description = models.TextField(
help_text="Field description", default=None, null=True
)
class Meta:
ordering = (

View file

@ -0,0 +1,19 @@
# Generated by Django 4.1.13 on 2024-05-13 12:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('database', '0157_alter_airtableimportjob_database')
]
operations = [
migrations.AddField(
model_name="field",
name="description",
field=models.TextField(
default=None, help_text="Field description", null=True
),
),
]

View file

@ -209,7 +209,31 @@ def test_create_field(api_client, data_fixture):
response = api_client.post(
reverse("api:database:fields:list", kwargs={"table_id": table.id}),
{"name": "Test 1", "type": "text", "text_default": "default!"},
{"name": "Test no description", "type": "text", "text_default": "default!"},
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["type"] == "text"
text = TextField.objects.filter()[0]
assert response_json["id"] == text.id
assert response_json["name"] == "Test no description"
assert response_json["order"] == text.order
assert response_json["text_default"] == "default!"
assert response_json["description"] is None
text.delete()
description_txt_a = "This is a description"
response = api_client.post(
reverse("api:database:fields:list", kwargs={"table_id": table.id}),
{
"name": "Test 1",
"type": "text",
"text_default": "default!",
"description": description_txt_a,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {jwt_token}",
)
@ -222,6 +246,7 @@ def test_create_field(api_client, data_fixture):
assert response_json["name"] == text.name
assert response_json["order"] == text.order
assert response_json["text_default"] == "default!"
assert response_json["description"] == description_txt_a
# Test authentication with token
response = api_client.post(
@ -310,6 +335,7 @@ def test_get_field(api_client, data_fixture):
assert response_json["name"] == text.name
assert response_json["table_id"] == text.table_id
assert not response_json["text_default"]
assert response_json["description"] is None
response = api_client.delete(
reverse(
@ -380,7 +406,7 @@ def test_update_field(api_client, data_fixture):
url = reverse("api:database:fields:item", kwargs={"field_id": text.id})
response = api_client.patch(
url,
{"name": "Test 1", "text_default": "Something"},
{"name": "Test 1", "text_default": "Something", "description": "a description"},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -389,6 +415,7 @@ def test_update_field(api_client, data_fixture):
assert response_json["id"] == text.id
assert response_json["name"] == "Test 1"
assert response_json["text_default"] == "Something"
assert response_json["description"] == "a description"
text.refresh_from_db()
assert text.name == "Test 1"
@ -397,7 +424,12 @@ def test_update_field(api_client, data_fixture):
url = reverse("api:database:fields:item", kwargs={"field_id": text.id})
response = api_client.patch(
url,
{"name": "Test 1", "type": "text", "text_default": "Something"},
{
"name": "Test 1",
"type": "text",
"text_default": "Something",
"description": None,
},
format="json",
HTTP_AUTHORIZATION=f"JWT {token}",
)
@ -405,6 +437,7 @@ def test_update_field(api_client, data_fixture):
assert response.status_code == HTTP_200_OK
assert response_json["name"] == "Test 1"
assert response_json["type"] == "text"
assert response_json["description"] is None
url = reverse("api:database:fields:item", kwargs={"field_id": text.id})
response = api_client.patch(

View file

@ -816,6 +816,7 @@ def test_get_public_gallery_view(api_client, data_fixture):
"text_default": "",
"type": "text",
"read_only": False,
"description": None,
}
],
"view": {

View file

@ -3240,6 +3240,7 @@ def test_get_public_grid_view(api_client, data_fixture):
"text_default": "",
"type": "text",
"read_only": False,
"description": None,
}
],
"view": {

View file

@ -222,6 +222,7 @@ def test_when_field_unhidden_in_public_view_force_refresh_sent(
"primary": False,
"text_default": "",
"read_only": False,
"description": None,
}
],
"view": view_serialized["view"],
@ -298,6 +299,7 @@ def test_when_only_field_options_updated_in_public_grid_view_force_refresh_sent(
"primary": False,
"text_default": "",
"read_only": False,
"description": None,
}
],
"view": view_serialized["view"],

View file

@ -0,0 +1,7 @@
{
"type": "feature",
"message": "Support for rich text field description",
"issue_number": 2090,
"bullet_points": [],
"created_at": "2024-04-29"
}

View file

@ -35,7 +35,7 @@
<HelpIcon
v-if="roleUidSelected === 'ADMIN'"
:tooltip="$t('membersRoleField.adminHelpText')"
is-warning
icon="warning-triangle"
/>
</div>
</template>

View file

@ -966,6 +966,7 @@ def test_get_public_calendar_view_with_single_select_and_cover(
"date_include_time": False,
"date_show_tzinfo": False,
"date_time_format": "24",
"description": None,
},
{
"id": public_field.id,
@ -976,6 +977,7 @@ def test_get_public_calendar_view_with_single_select_and_cover(
"text_default": "",
"type": "text",
"read_only": False,
"description": None,
},
],
"view": {

View file

@ -1566,6 +1566,7 @@ def test_get_public_kanban_without_with_single_select_and_cover(
"text_default": "",
"type": "text",
"read_only": False,
"description": None,
},
],
"view": {
@ -1646,6 +1647,7 @@ def test_get_public_kanban_view_with_single_select_and_cover(
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"type": "single_select",
"read_only": False,
"description": None,
},
{
"id": cover_field.id,
@ -1655,6 +1657,7 @@ def test_get_public_kanban_view_with_single_select_and_cover(
"table_id": PUBLIC_PLACEHOLDER_ENTITY_ID,
"type": "file",
"read_only": False,
"description": None,
},
{
"id": public_field.id,
@ -1665,6 +1668,7 @@ def test_get_public_kanban_view_with_single_select_and_cover(
"text_default": "",
"type": "text",
"read_only": False,
"description": None,
},
],
"view": {

View file

@ -175,6 +175,14 @@
&--multiple-actions {
justify-content: space-between;
}
&--align-left {
justify-content: left;
}
&--align-right {
justify-content: right;
}
}
.context__menu-active-icon {

View file

@ -329,3 +329,20 @@ span[data-type='mention'] {
background-color: $palette-neutral-100;
}
}
.rich-text-editor--fixed-size {
display: block;
width: 100%;
max-width: 400px;
max-height: 400px;
border: 1px solid $palette-neutral-400;
padding: 6px 12px;
outline: none;
line-height: 100%;
height: auto;
overflow: auto;
scrollbar-gutter: stable;
border-radius: 4px;
@include elevation($elevation-low);
}

View file

@ -45,4 +45,35 @@
@include fixed-height(26px, 12px);
@include rounded($rounded);
&--expandable {
padding: 12px;
text-align: left;
white-space: nowrap;
max-height: 320px;
line-height: 20px;
font-size: 12px;
overflow: auto;
height: auto;
max-width: 320px;
text-wrap: wrap;
@include rounded($rounded);
&::-webkit-scrollbar {
width: 10px;
}
&::-webkit-scrollbar-track {
border-radius: 10px;
background-color: transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 10px;
background: $color-neutral-500;
background-clip: content-box;
border: 2px solid transparent;
}
}
}

View file

@ -615,11 +615,20 @@
.grid-view__description-options {
margin-left: auto;
height: 100%;
display: flex;
align-items: center;
vertical-align: center;
}
.grid-view__description-icon-trigger {
color: $color-neutral-600;
cursor: pointer;
padding: 0 10px;
height: 100%;
display: flex;
align-items: center;
vertical-align: center;
&:hover {
color: $color-neutral-900;

View file

@ -1,8 +1,18 @@
<template>
<i v-tooltip="tooltip" class="help-icon" :class="iconClass"> </i>
<i
v-tooltip:[tooltipOptions]="tooltipText"
class="help-icon"
:class="iconClass"
>
</i>
</template>
<script>
import Markdown from 'markdown-it'
const TooltipContentPlain = 'plain'
const TooltipContentMarkdown = 'markdown'
export default {
name: 'HelpIcon',
props: {
@ -11,17 +21,81 @@ export default {
required: false,
default: null,
},
isWarning: {
type: Boolean,
/**
* How many seconds the tooltip should be displayed after mouse moves out of icon/contents?
* */
tooltipDuration: {
type: Number,
required: false,
default: false,
default: 0,
},
/**
* Hints on tooltip content type.
* Possible values are: `plain` | `markdown`
* */
tooltipContentType: {
type: String,
required: false,
default: TooltipContentPlain,
validators: (contentType) => {
return [TooltipContentMarkdown, TooltipContentPlain].includes(
contentType
)
},
},
/**
* Iconoir icon name without iconoir- prefix.
* */
icon: {
type: String,
required: false,
default: 'chat-bubble-question',
},
/**
* Additional css classes for tooltip icon
* */
tooltipClasses: {
type: String,
required: false,
default: '',
},
/**
* Additional css classes for tooltip content container
* */
tooltipContentClasses: {
type: String,
requred: false,
default: '',
},
},
computed: {
tooltipOptions() {
return {
duration: this.tooltipDuration,
contentIsHtml: this.tooltipContentIsHtml(),
contentClasses: this.tooltipContentClasses,
}
},
tooltipText() {
if (this.tooltipContentType === TooltipContentMarkdown) {
const md = new Markdown()
return md.render(this.tooltip)
}
return this.tooltip
},
iconClass() {
return this.isWarning
? 'iconoir-warning-triangle'
: 'iconoir-chat-bubble-question'
const clsNames = this.tooltipClasses ? [this.tooltipClasses] : []
if (this.icon) {
clsNames.push(`iconoir-${this.icon}`)
}
return clsNames.join(' ')
},
},
methods: {
tooltipContentIsHtml() {
// tooltip directive doesn't need to know anything about markdown conversion,
// just whether the content is html or not
return this.tooltipContentType !== 'plain'
},
},
}

View file

@ -1,13 +1,70 @@
import _ from 'lodash'
import { onClickOutside } from '@baserow/modules/core/utils/dom'
/**
* helper function to extract options from element
*
* If binding provides .arg, the .arg property should be an object:
*
* {duration: Number, contentIsHtml: Bool, contentClasses: String}
*
* @param el
* @param binding directive binding value
* @returns {{duration: number, contentClasses: (*|null), value, contentType: (*|string)}}
*/
const getOptions = (el, binding) => {
// defaults
const tooltipOptions = {
duration: 0,
contentIsHtml: false,
value: null,
contentClasses: '',
}
if (binding.arg && _.isObject(binding.arg)) {
_.merge(tooltipOptions, binding.arg)
}
tooltipOptions.value = binding.value
return tooltipOptions
}
// make sure only one tooltip is visible
let currentTooltip = null
const switchToTooltip = (el) => {
if (currentTooltip != null) {
currentTooltip.tooltipClose()
}
currentTooltip = el
}
/**
* This is a very simple and fast tooltip directive. It will add the binding value as
* tooltip content. The tooltip only shows if there is a value.
*
* tooltip directive can be customized with arg, where arg is an object:
* {
* // duration number of seconds the tooltip should be visible after pointer
* // leaves i icon or tooltip content
* duration: Number,
* // contentIsHtml informs the value is html and should not be escaped (sets .innerHtml directly)
* contentIsHtml: Bool,
* // contentClasses is a string with additional css classes for tooltip content container
* contentClasses: String
* }
*
* <i v-tooltip:[configObject]="someValue"/>
*/
export default {
/**
* If there is a value and the tooltip has not yet been initialized we can add the
* mouse events to show and hide the tooltip.
*
*/
initialize(el, value) {
initialize(el, binding) {
el.tooltipOptions = getOptions(el, binding)
el.onClickOutsideCallback = null
el.updatePositionEvent = () => {
const rect = el.getBoundingClientRect()
const position = el.getAttribute('tooltip-position') || 'bottom'
@ -22,7 +79,15 @@ export default {
const width = rect.right - rect.left
el.tooltipElement.style.left = rect.left + width / 2 + 'px'
}
el.removeTimeout = () => {
if (el.tooltipTimeout) {
clearTimeout(el.tooltipTimeout)
}
el.tooltipTimeout = null
}
el.tooltipMouseEnterEvent = () => {
switchToTooltip(el)
const position = el.getAttribute('tooltip-position') || 'bottom'
const hide = el.getAttribute('hide-tooltip')
@ -30,34 +95,92 @@ export default {
return
}
if (el.tooltipElement) {
this.terminate(el)
if (!el.tooltipElement) {
el.tooltipElement = document.createElement('div')
const classes = ['tooltip', 'tooltip--body', 'tooltip--center']
if (position === 'top') {
classes.push('tooltip--top')
}
el.tooltipElement.className = classes.join(' ')
document.body.insertBefore(el.tooltipElement, document.body.firstChild)
el.tooltipContentElement = document.createElement('div')
el.tooltipElement.appendChild(el.tooltipContentElement)
}
el.tooltipElement = document.createElement('div')
const classes = ['tooltip', 'tooltip--body', 'tooltip--center']
if (position === 'top') {
classes.push('tooltip--top')
if (el.tooltipOptions.contentIsHtml) {
el.tooltipContentElement.innerHTML = el.tooltipOptions.value
} else {
el.tooltipContentElement.textContent = el.tooltipOptions.value
}
el.tooltipElement.className = classes.join(' ')
document.body.insertBefore(el.tooltipElement, document.body.firstChild)
el.tooltipContentElement = document.createElement('div')
el.tooltipContentElement.className = 'tooltip__content'
el.tooltipContentElement.textContent = value
el.tooltipElement.appendChild(el.tooltipContentElement)
// additional css classes for content container
const contentClass = ['tooltip__content']
if (el.tooltipOptions.contentClasses) {
contentClass.push(el.tooltipOptions.contentClasses)
}
el.tooltipContentElement.className = contentClass.join(' ')
el.updatePositionEvent()
// we just entered, so we don't want any previously set timeout to close
// the tooltip content
el.removeTimeout()
// make tooltip content preserved if pointer hovers
el.tooltipContentElement.addEventListener('mouseenter', el.removeTimeout)
el.tooltipContentElement.addEventListener(
'mouseleave',
el.tooltipMoveLeaveEvent
)
// When the user scrolls or resizes the window it could be possible that the
// element where the tooltip is anchored to has moved, so then the position
// needs to be updated. We only want to do this when the tooltip is visible.
window.addEventListener('scroll', el.updatePositionEvent, true)
window.addEventListener('resize', el.updatePositionEvent)
// refresh the callback - old callback should be removed because old instance
// is may be longer present, and window object may still have click event handlers
// that check if the pointer is in or out of tooltip content element (which itself
// is long time gone)
el.removeTooltipOutsideClickCallback()
el.onClickOutsideCallback = onClickOutside(
el.tooltipContentElement,
el.tooltipClose
)
}
/**
* queue a close tooltip action.
*
* This can be called multiple times. Each call will postpone tooltipClose() call.
* This way user can hover in and hover out several times and the tooltip still be
* visible, if duration is > 0.
*/
el.tooltipMoveLeaveEvent = () => {
// we should remove any pending timeout before setting new one, because timeout
// should be counted from the last mouse leave event.
el.removeTimeout()
el.tooltipTimeout = setTimeout(
el.tooltipClose,
// timeout from caller is in seconds. remember to convert to mseconds
el.tooltipOptions.duration * 1000
)
}
el.removeTooltipOutsideClickCallback = () => {
if (el.onClickOutsideCallback) {
el.onClickOutsideCallback()
el.onClickOutsideCallback = null
}
}
/**
* actually closing the tooltip here
*/
el.tooltipClose = () => {
// cleanup actions: remove window handlers set with onClickOutside()
el.removeTooltipOutsideClickCallback()
if (el.tooltipElement) {
el.tooltipElement.parentNode.removeChild(el.tooltipElement)
el.tooltipElement = null
@ -66,10 +189,13 @@ export default {
window.removeEventListener('scroll', el.updatePositionEvent, true)
window.removeEventListener('resize', el.updatePositionEvent)
el.removeTimeout()
}
// those event listeners should be bind all the time to the el element
el.addEventListener('mouseenter', el.tooltipMouseEnterEvent)
el.addEventListener('mouseleave', el.tooltipMoveLeaveEvent)
},
/**
* If there isn't a value or if the directive is unbinded the tooltipElement can
* be destroyed if it wasn't already and all the events can be removed.
@ -94,9 +220,9 @@ export default {
const { value } = binding
if (!!value && el.tooltipElement) {
el.tooltipContentElement.textContent = value
el.tooltipOptions = getOptions(el, binding)
} else if (!!value && el.tooltipElement === null) {
binding.def.initialize(el, value)
binding.def.initialize(el, binding)
} else if (!value) {
binding.def.terminate(el)
}

View file

@ -109,7 +109,10 @@ export default {
* Returns true if the field value has no errors
*/
fieldHasErrors(fieldName) {
return this.$v.values[fieldName].$error
// a field can be without any validators
return this.$v.values[fieldName]
? this.$v.values[fieldName].$error
: false
},
/**
* Returns true is everything is valid.

View file

@ -56,6 +56,14 @@ export const findScrollableParent = (element) => {
}
}
/**
* Detects clicks outside el element and call callback
*
* Returns a callback to unregister click handlers after successful outside click
* @param el
* @param callback
* @returns {(function(): void)|*}
*/
export const onClickOutside = (el, callback) => {
const insideEvent = new Set()

View file

@ -33,6 +33,9 @@
<APIDocsParameter name="read_only" :optional="false" type="boolean">
{{ $t('apiDocsTableListFields.readOnly') }}
</APIDocsParameter>
<APIDocsParameter name="description" :optional="false" type="string">
{{ $t('apiDocsTableListFields.descriptionField') }}
</APIDocsParameter>
</ul>
<p class="api-docs__content">
{{ $t('apiDocsTableListFields.extraProps') }}

View file

@ -4,7 +4,10 @@
class="field-form-context"
:overflow-scroll="true"
:max-height-if-outside-viewport="true"
@shown="$emit('shown', $event)"
@shown="
onShow()
$emit('shown', $event)
"
>
<FieldForm
ref="form"
@ -16,7 +19,20 @@
@submitted="submit"
@keydown-enter="$refs.submitButton.focus()"
>
<div class="context__form-actions">
<div
class="context__form-actions context__form-actions--multiple-actions"
>
<span class="context__form-actions--alight-left">
<ButtonText
v-if="!showDescription"
ref="showDescription"
icon="iconoir iconoir-plus"
type="secondary"
@click="showDescriptionField"
>
{{ $t('fieldForm.addDescription') }}
</ButtonText>
</span>
<Button
ref="submitButton"
type="primary"
@ -66,6 +82,7 @@ export default {
data() {
return {
loading: false,
showDescription: false,
}
},
methods: {
@ -111,6 +128,18 @@ export default {
showFieldTypesDropdown(target) {
this.$refs.form.showFieldTypesDropdown(target)
},
showDescriptionField(evt) {
this.hideDescriptionLink()
this.$refs.form.showDescriptionField()
evt.stopPropagation()
evt.preventDefault()
},
hideDescriptionLink() {
this.showDescription = true
},
onShow() {
this.showDescription = this.$refs.form.isDescriptionFieldNotEmpty()
},
},
}
</script>

View file

@ -108,6 +108,29 @@
@suggested-field-name="handleSuggestedFieldName($event)"
/>
</template>
<FormElement
v-if="showDescription"
:error="fieldHasErrors('description')"
class="control"
>
<label class="control__label control__label--small">{{
$t('fieldForm.description')
}}</label>
<div class="control__elements">
<RichTextEditor
ref="description"
:value="editorValue"
class="field-form__editor rich-text-editor rich-text-editor--fixed-size"
:editable="true"
:enter-stop-edit="false"
:thin-scrollbar="true"
:enable-rich-text-formatting="false"
:placeholder="$t('fieldForm.description')"
@blur="onDescriptionBlur"
/>
</div>
</FormElement>
<slot v-if="!selectedFieldIsDeactivated"></slot>
</form>
</template>
@ -117,6 +140,7 @@ import { mapGetters } from 'vuex'
import { required, maxLength } from 'vuelidate/lib/validators'
import { getNextAvailableNameInSequence } from '@baserow/modules/core/utils/string'
import RichTextEditor from '@baserow/modules/core/components/editor/RichTextEditor.vue'
import form from '@baserow/modules/core/mixins/form'
import {
RESERVED_BASEROW_FIELD_NAMES,
@ -126,6 +150,7 @@ import {
// @TODO focus form on open
export default {
name: 'FieldForm',
components: { RichTextEditor },
mixins: [form],
props: {
table: {
@ -158,13 +183,15 @@ export default {
},
data() {
return {
allowedValues: ['name', 'type'],
allowedValues: ['name', 'type', 'description'],
values: {
name: '',
type: this.forcedType || '',
description: null,
},
isPrefilledWithSuggestedFieldName: false,
oldValueType: null,
showDescription: false,
}
},
computed: {
@ -190,6 +217,11 @@ export default {
return false
}
},
editorValue() {
// temp fix to have proper line breaks
// this will not be needed when RTE will be in minimal mode
return (this.values.description || '').replaceAll('\n', '<br/>')
},
...mapGetters({
fields: 'field/getAll',
}),
@ -277,6 +309,29 @@ export default {
this.$refs[`deactivatedClickModal-${fieldType.type}`][0].show()
}
},
/**
* This sets the showDescription flag to display description text editor, even
* if values.description is empty.
*
* Used by parent components.
*/
showDescriptionField() {
this.showDescription = true
},
/**
* Helper method to get information if description is not empty.
* Used by parent components
*/
isDescriptionFieldNotEmpty() {
this.showDescription = !!this.values.description
return this.showDescription
},
onDescriptionBlur() {
// Handle blur event on field description text editor.
// A bit hacky way to get current state of description editor once the
// edition finished.
this.values.description = this.$refs.description.editor.getText()
},
},
}
</script>

View file

@ -4,6 +4,7 @@
class="field-form-context"
:overflow-scroll="true"
:max-height-if-outside-viewport="true"
@shown="onShow"
>
<FieldForm
ref="form"
@ -14,17 +15,31 @@
:all-fields-in-table="allFieldsInTable"
:database="database"
@submitted="submit"
@description-shown="hideDescriptionLink"
>
<div
class="context__form-actions context__form-actions--multiple-actions"
>
<a @click="cancel">
{{ $t('action.cancel') }}
</a>
<span class="context__form-actions--alight-left">
<ButtonText
v-if="!showDescription"
ref="showDescription"
icon="iconoir iconoir-plus"
type="secondary"
@click="showDescriptionField"
>
{{ $t('fieldForm.addDescription') }}
</ButtonText>
</span>
<Button :loading="loading" :disabled="loading || fieldTypeDisabled">
{{ $t('action.save') }}
</Button>
<span class="context__form-actions--align-right">
<span class="margin-right-2">
<a class="form-action" @click="cancel">{{ $t('action.cancel') }}</a>
</span>
<Button :loading="loading" :disabled="loading || fieldTypeDisabled">
{{ $t('action.save') }}
</Button>
</span>
</div>
</FieldForm>
</Context>
@ -64,6 +79,7 @@ export default {
data() {
return {
loading: false,
showDescription: false,
}
},
computed: {
@ -87,6 +103,7 @@ export default {
},
methods: {
reset() {
this.showDescription = false
this.$nextTick(() => {
this.$refs.form && this.$refs.form.reset()
})
@ -131,6 +148,19 @@ export default {
this.reset()
this.hide()
},
onShow() {
this.showDescription = this.$refs.form.isDescriptionFieldNotEmpty()
},
showDescriptionField(evt) {
this.hideDescriptionLink()
this.$refs.form.showDescriptionField()
evt.stopPropagation()
evt.preventDefault()
},
hideDescriptionLink() {
this.showDescription = true
},
},
}
</script>

View file

@ -7,6 +7,15 @@
></a>
<i class="control__label-icon" :class="field._.type.iconClass"></i>
{{ field.name }}
<span v-if="field.description" class="margin-left-1">
<HelpIcon
:tooltip="descriptionText"
:tooltip-duration="3"
:tooltip-content-type="'html'"
:tooltip-content-classes="'tooltip__content--expandable'"
:icon="'info-empty'"
/>
</span>
<i
v-if="!readOnly && canModifyFields"
ref="contextLink"
@ -112,6 +121,11 @@ export default {
default: () => true,
},
},
computed: {
descriptionText() {
return (this.field.description || '').replaceAll('\n', '<br/>')
},
},
methods: {
getFieldComponent(type) {
return this.$registry

View file

@ -28,15 +28,25 @@
<div v-if="field.error" class="grid-view__description-icon-error">
<i v-tooltip="field.error" class="iconoir-warning-triangle"></i>
</div>
<a
v-if="!readOnly && showFieldContext"
ref="contextLink"
class="grid-view__description-options"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
@mousedown.stop
>
<i class="iconoir-nav-arrow-down"></i>
</a>
<span class="grid-view__description-options">
<HelpIcon
v-if="field.description"
:tooltip="descriptionText"
:tooltip-content-type="'html'"
:tooltip-content-classes="'tooltip__content--expandable'"
:icon="'info-empty'"
/>
<a
v-if="!readOnly && showFieldContext"
ref="contextLink"
class="grid-view__description-icon-trigger"
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'right', 0)"
@mousedown.stop
>
<i class="iconoir-nav-arrow-down"></i>
</a>
</span>
<FieldContext
v-if="!readOnly"
@ -303,6 +313,9 @@ export default {
}
},
computed: {
descriptionText() {
return (this.field.description || '').replaceAll('\n', '<br/>')
},
width() {
return this.getFieldWidth(this.field.id)
},

View file

@ -568,7 +568,7 @@ export class FieldType extends Registerable {
* @returns a sample for this field.
*/
getDocsFieldResponseExample(
{ id, table_id: tableId, name, order, type, primary },
{ id, table_id: tableId, name, order, type, primary, description },
readOnly
) {
return {
@ -579,6 +579,7 @@ export class FieldType extends Registerable {
type,
primary,
read_only: readOnly,
description: description || 'A sample description',
}
}

View file

@ -170,7 +170,8 @@
"primary": "Indicates if the field is a primary field. If `true` the field cannot be deleted and the value should represent the whole row.",
"type": "Type defined for this field.",
"extraProps": "Some extra properties are not described here because they are type specific.",
"readOnly": "Indicates whether the field is a read only field. If true, it's not possible to update the cell value."
"readOnly": "Indicates whether the field is a read only field. If true, it's not possible to update the cell value.",
"descriptionField": "Field description"
},
"apiDocsTableDeleteRow": {
"description": "Deletes an existing {name} row.",
@ -280,9 +281,11 @@
},
"fieldForm": {
"name": "Name",
"description": "Description",
"fieldAlreadyExists": "A field with this name already exists.",
"nameNotAllowed": "This field name is not allowed.",
"nameTooLong": "This field name is too long."
"nameTooLong": "This field name is too long.",
"addDescription": "Add description"
},
"fieldSelectThroughFieldSubForm": {
"noTable": "You need at least one link to table field to create this field.",

View file

@ -393,7 +393,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
<!---->
<!---->
<span
class="grid-view__description-options"
>
<!---->
<!---->
</span>
<!---->
@ -427,7 +433,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
<!---->
<!---->
<span
class="grid-view__description-options"
>
<!---->
<!---->
</span>
<!---->
@ -461,7 +473,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
<!---->
<!---->
<span
class="grid-view__description-options"
>
<!---->
<!---->
</span>
<!---->
@ -495,7 +513,13 @@ exports[`Public View Page Tests Can see a publicly shared grid view 1`] = `
<!---->
<!---->
<span
class="grid-view__description-options"
>
<!---->
<!---->
</span>
<!---->

View file

@ -208,13 +208,19 @@ exports[`GridView component with decoration Default component with first_cell de
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -247,13 +253,19 @@ exports[`GridView component with decoration Default component with first_cell de
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -286,13 +298,19 @@ exports[`GridView component with decoration Default component with first_cell de
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -642,13 +660,19 @@ exports[`GridView component with decoration Default component with row wrapper d
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -681,13 +705,19 @@ exports[`GridView component with decoration Default component with row wrapper d
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -720,13 +750,19 @@ exports[`GridView component with decoration Default component with row wrapper d
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -1079,13 +1115,19 @@ exports[`GridView component with decoration Default component with unavailable d
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -1118,13 +1160,19 @@ exports[`GridView component with decoration Default component with unavailable d
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div
@ -1157,13 +1205,19 @@ exports[`GridView component with decoration Default component with unavailable d
<!---->
<a
<span
class="grid-view__description-options"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
<!---->
<a
class="grid-view__description-icon-trigger"
>
<i
class="iconoir-nav-arrow-down"
/>
</a>
</span>
<div