From d38678c5c7c2c297358ed60865b1fae9438afab0 Mon Sep 17 00:00:00 2001 From: Cezary Statkiewicz <cezary@baserow.io> Date: Tue, 28 May 2024 18:39:39 +0000 Subject: [PATCH] #2090 field description --- .../database/api/fields/serializers.py | 35 +++- .../contrib/database/fields/handler.py | 4 +- .../baserow/contrib/database/fields/models.py | 3 + .../migrations/0158_field_description.py | 19 ++ .../database/api/fields/test_field_views.py | 39 ++++- .../views/gallery/test_gallery_view_views.py | 1 + .../api/views/grid/test_grid_view_views.py | 1 + .../ws/public/test_public_ws_view_signals.py | 2 + .../feature/2090_field_description.json | 7 + .../components/MembersRoleField.vue | 2 +- .../api/views/views/test_calendar_views.py | 2 + .../api/views/views/test_kanban_views.py | 4 + .../core/assets/scss/components/context.scss | 8 + .../scss/components/rich_text_editor.scss | 17 ++ .../core/assets/scss/components/tooltip.scss | 31 ++++ .../assets/scss/components/views/grid.scss | 9 + .../modules/core/components/HelpIcon.vue | 88 +++++++++- .../modules/core/directives/tooltip.js | 162 ++++++++++++++++-- web-frontend/modules/core/mixins/form.js | 5 +- web-frontend/modules/core/utils/dom.js | 8 + .../docs/sections/APIDocsTableListFields.vue | 3 + .../components/field/CreateFieldContext.vue | 33 +++- .../database/components/field/FieldForm.vue | 57 +++++- .../components/field/UpdateFieldContext.vue | 42 ++++- .../components/row/RowEditModalField.vue | 14 ++ .../view/grid/GridViewFieldType.vue | 31 +++- web-frontend/modules/database/fieldTypes.js | 3 +- web-frontend/modules/database/locales/en.json | 7 +- .../__snapshots__/publicView.spec.js.snap | 32 +++- .../gridViewDecoration.spec.js.snap | 144 +++++++++++----- 30 files changed, 709 insertions(+), 104 deletions(-) create mode 100644 backend/src/baserow/contrib/database/migrations/0158_field_description.py create mode 100644 changelog/entries/unreleased/feature/2090_field_description.json diff --git a/backend/src/baserow/contrib/database/api/fields/serializers.py b/backend/src/baserow/contrib/database/api/fields/serializers.py index 28fbfdb24..c14ab6feb 100644 --- a/backend/src/baserow/contrib/database/api/fields/serializers.py +++ b/backend/src/baserow/contrib/database/api/fields/serializers.py @@ -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, + }, } diff --git a/backend/src/baserow/contrib/database/fields/handler.py b/backend/src/baserow/contrib/database/fields/handler.py index ac684cd09..f3655563e 100644 --- a/backend/src/baserow/contrib/database/fields/handler.py +++ b/backend/src/baserow/contrib/database/fields/handler.py @@ -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( diff --git a/backend/src/baserow/contrib/database/fields/models.py b/backend/src/baserow/contrib/database/fields/models.py index 4e9b3c1a8..4e887061e 100644 --- a/backend/src/baserow/contrib/database/fields/models.py +++ b/backend/src/baserow/contrib/database/fields/models.py @@ -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 = ( diff --git a/backend/src/baserow/contrib/database/migrations/0158_field_description.py b/backend/src/baserow/contrib/database/migrations/0158_field_description.py new file mode 100644 index 000000000..4a11f42bc --- /dev/null +++ b/backend/src/baserow/contrib/database/migrations/0158_field_description.py @@ -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 + ), + ), + ] diff --git a/backend/tests/baserow/contrib/database/api/fields/test_field_views.py b/backend/tests/baserow/contrib/database/api/fields/test_field_views.py index e0c66d89e..ff5b38441 100644 --- a/backend/tests/baserow/contrib/database/api/fields/test_field_views.py +++ b/backend/tests/baserow/contrib/database/api/fields/test_field_views.py @@ -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( diff --git a/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py b/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py index f7a1c4d85..b3995a5b7 100644 --- a/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/gallery/test_gallery_view_views.py @@ -816,6 +816,7 @@ def test_get_public_gallery_view(api_client, data_fixture): "text_default": "", "type": "text", "read_only": False, + "description": None, } ], "view": { diff --git a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py index 031beb627..429508d36 100644 --- a/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py +++ b/backend/tests/baserow/contrib/database/api/views/grid/test_grid_view_views.py @@ -3240,6 +3240,7 @@ def test_get_public_grid_view(api_client, data_fixture): "text_default": "", "type": "text", "read_only": False, + "description": None, } ], "view": { diff --git a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py index 1051ac147..7ee56de54 100644 --- a/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py +++ b/backend/tests/baserow/contrib/database/ws/public/test_public_ws_view_signals.py @@ -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"], diff --git a/changelog/entries/unreleased/feature/2090_field_description.json b/changelog/entries/unreleased/feature/2090_field_description.json new file mode 100644 index 000000000..1dd25841d --- /dev/null +++ b/changelog/entries/unreleased/feature/2090_field_description.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "Support for rich text field description", + "issue_number": 2090, + "bullet_points": [], + "created_at": "2024-04-29" +} diff --git a/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue b/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue index cba4f2e05..b98abadf6 100644 --- a/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue +++ b/enterprise/web-frontend/modules/baserow_enterprise/components/MembersRoleField.vue @@ -35,7 +35,7 @@ <HelpIcon v-if="roleUidSelected === 'ADMIN'" :tooltip="$t('membersRoleField.adminHelpText')" - is-warning + icon="warning-triangle" /> </div> </template> diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py index 413a7d444..7d875292b 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_calendar_views.py @@ -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": { diff --git a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py index 4ecc1eee7..042731f47 100644 --- a/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py +++ b/premium/backend/tests/baserow_premium_tests/api/views/views/test_kanban_views.py @@ -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": { diff --git a/web-frontend/modules/core/assets/scss/components/context.scss b/web-frontend/modules/core/assets/scss/components/context.scss index d49a01320..6cefc0e08 100644 --- a/web-frontend/modules/core/assets/scss/components/context.scss +++ b/web-frontend/modules/core/assets/scss/components/context.scss @@ -175,6 +175,14 @@ &--multiple-actions { justify-content: space-between; } + + &--align-left { + justify-content: left; + } + + &--align-right { + justify-content: right; + } } .context__menu-active-icon { diff --git a/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss b/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss index b410519c1..93bdf71af 100644 --- a/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss +++ b/web-frontend/modules/core/assets/scss/components/rich_text_editor.scss @@ -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); +} diff --git a/web-frontend/modules/core/assets/scss/components/tooltip.scss b/web-frontend/modules/core/assets/scss/components/tooltip.scss index a95bbc872..2b1c46ed2 100644 --- a/web-frontend/modules/core/assets/scss/components/tooltip.scss +++ b/web-frontend/modules/core/assets/scss/components/tooltip.scss @@ -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; + } + } } diff --git a/web-frontend/modules/core/assets/scss/components/views/grid.scss b/web-frontend/modules/core/assets/scss/components/views/grid.scss index 4e3549a60..322b7012f 100644 --- a/web-frontend/modules/core/assets/scss/components/views/grid.scss +++ b/web-frontend/modules/core/assets/scss/components/views/grid.scss @@ -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; diff --git a/web-frontend/modules/core/components/HelpIcon.vue b/web-frontend/modules/core/components/HelpIcon.vue index 79a84425c..8c229a002 100644 --- a/web-frontend/modules/core/components/HelpIcon.vue +++ b/web-frontend/modules/core/components/HelpIcon.vue @@ -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' }, }, } diff --git a/web-frontend/modules/core/directives/tooltip.js b/web-frontend/modules/core/directives/tooltip.js index 031ef02bf..9df93beaa 100644 --- a/web-frontend/modules/core/directives/tooltip.js +++ b/web-frontend/modules/core/directives/tooltip.js @@ -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) } diff --git a/web-frontend/modules/core/mixins/form.js b/web-frontend/modules/core/mixins/form.js index 87e00849e..5350c7816 100644 --- a/web-frontend/modules/core/mixins/form.js +++ b/web-frontend/modules/core/mixins/form.js @@ -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. diff --git a/web-frontend/modules/core/utils/dom.js b/web-frontend/modules/core/utils/dom.js index d0db99cd6..46a9a0513 100644 --- a/web-frontend/modules/core/utils/dom.js +++ b/web-frontend/modules/core/utils/dom.js @@ -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() diff --git a/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue b/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue index c24853e85..0a1b2a089 100644 --- a/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue +++ b/web-frontend/modules/database/components/docs/sections/APIDocsTableListFields.vue @@ -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') }} diff --git a/web-frontend/modules/database/components/field/CreateFieldContext.vue b/web-frontend/modules/database/components/field/CreateFieldContext.vue index fbe25a187..d0306cfae 100644 --- a/web-frontend/modules/database/components/field/CreateFieldContext.vue +++ b/web-frontend/modules/database/components/field/CreateFieldContext.vue @@ -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> diff --git a/web-frontend/modules/database/components/field/FieldForm.vue b/web-frontend/modules/database/components/field/FieldForm.vue index 778710588..8f6563318 100644 --- a/web-frontend/modules/database/components/field/FieldForm.vue +++ b/web-frontend/modules/database/components/field/FieldForm.vue @@ -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> diff --git a/web-frontend/modules/database/components/field/UpdateFieldContext.vue b/web-frontend/modules/database/components/field/UpdateFieldContext.vue index a9bf7ee18..5ee0bf8d6 100644 --- a/web-frontend/modules/database/components/field/UpdateFieldContext.vue +++ b/web-frontend/modules/database/components/field/UpdateFieldContext.vue @@ -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> diff --git a/web-frontend/modules/database/components/row/RowEditModalField.vue b/web-frontend/modules/database/components/row/RowEditModalField.vue index c8f0695f5..a5538d9f7 100644 --- a/web-frontend/modules/database/components/row/RowEditModalField.vue +++ b/web-frontend/modules/database/components/row/RowEditModalField.vue @@ -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 diff --git a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue index d24e54165..67fd1f5f6 100644 --- a/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue +++ b/web-frontend/modules/database/components/view/grid/GridViewFieldType.vue @@ -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) }, diff --git a/web-frontend/modules/database/fieldTypes.js b/web-frontend/modules/database/fieldTypes.js index b1b51676a..85f27afc2 100644 --- a/web-frontend/modules/database/fieldTypes.js +++ b/web-frontend/modules/database/fieldTypes.js @@ -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', } } diff --git a/web-frontend/modules/database/locales/en.json b/web-frontend/modules/database/locales/en.json index 3b98b258e..0d8e59210 100644 --- a/web-frontend/modules/database/locales/en.json +++ b/web-frontend/modules/database/locales/en.json @@ -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.", diff --git a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap index c7d8c8be0..ee82bbe64 100644 --- a/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap +++ b/web-frontend/test/unit/database/__snapshots__/publicView.spec.js.snap @@ -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> <!----> diff --git a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap index 2bf957a11..d1af6c569 100644 --- a/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/grid/__snapshots__/gridViewDecoration.spec.js.snap @@ -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