From c85b64b82d888d1e36de9339b40b1b91f9663921 Mon Sep 17 00:00:00 2001 From: Peter Evans <peter@baserow.io> Date: Wed, 6 Nov 2024 06:02:47 +0000 Subject: [PATCH] Resolve "Implement the collection element filter, sort and search menu." --- .../src/baserow/api/services/serializers.py | 8 +- .../baserow/contrib/database/views/filters.py | 3 +- .../integrations/local_baserow/mixins.py | 2 +- .../local_baserow/service_types.py | 2 +- .../local_baserow/test_service_types.py | 2 +- ...n_elements_to_be_filtered_sorted_and_.json | 7 + .../elements/baseComponents/ABDropdown.vue | 12 +- .../components/CollectionElementHeader.vue | 48 ++++ .../components/RecordSelectorElement.vue | 21 +- .../elements/components/RepeatElement.vue | 216 ++++++++++-------- .../elements/components/TableElement.vue | 9 +- .../general/RecordSelectorElementForm.vue | 26 +-- .../general/settings/PropertyOptionForm.vue | 3 + web-frontend/modules/builder/elementTypes.js | 81 +++++++ web-frontend/modules/builder/locales/en.json | 4 + .../builder/mixins/collectionElement.js | 20 ++ .../builder/mixins/collectionElementForm.js | 7 +- .../builder/services/publishedBuilder.js | 29 ++- .../modules/builder/store/elementContent.js | 11 +- .../assets/scss/components/builder/all.scss | 1 + .../builder/collection_element_header.scss | 4 + .../scss/components/integrations/all.scss | 1 + .../local_baserow_adhoc_header.scss | 10 + .../modules/core/components/Context.vue | 34 ++- web-frontend/modules/core/mixins/dropdown.js | 6 +- .../modules/core/plugins/featureFlags.js | 1 - .../database/components/view/ViewSearch.vue | 21 +- .../components/view/ViewSearchContext.vue | 10 + .../components/view/ViewSortContext.vue | 53 +++-- web-frontend/modules/database/store/view.js | 34 ++- .../integrations/LocalBaserowAdhocHeader.vue | 99 ++++++++ .../modules/integrations/serviceTypes.js | 28 ++- .../__snapshots__/ChoiceElement.spec.js.snap | 10 +- .../RecordSelectorElement.spec.js.snap | 15 +- .../__snapshots__/dropdown.spec.js.snap | 35 ++- .../exportTableModal.spec.js.snap | 6 + .../__snapshots__/viewFilterForm.spec.js.snap | 9 + 37 files changed, 690 insertions(+), 198 deletions(-) create mode 100644 changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json create mode 100644 web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue create mode 100644 web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss create mode 100644 web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss create mode 100644 web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue diff --git a/backend/src/baserow/api/services/serializers.py b/backend/src/baserow/api/services/serializers.py index 647cd10b8..83ea2aaa4 100644 --- a/backend/src/baserow/api/services/serializers.py +++ b/backend/src/baserow/api/services/serializers.py @@ -65,6 +65,7 @@ class PublicServiceSerializer(serializers.ModelSerializer): """ type = serializers.SerializerMethodField(help_text="The type of the service.") + schema = serializers.SerializerMethodField(help_text="The schema of the service.") @extend_schema_field(OpenApiTypes.STR) def get_type(self, instance): @@ -74,12 +75,17 @@ class PublicServiceSerializer(serializers.ModelSerializer): def get_context_data(self, instance): return instance.get_type().get_context_data(instance.specific) + @extend_schema_field(OpenApiTypes.OBJECT) + def get_schema(self, instance): + return instance.get_type().generate_schema(instance.specific) + class Meta: model = Service - fields = ("id", "type") + fields = ("id", "type", "schema") extra_kwargs = { "id": {"read_only": True}, "type": {"read_only": True}, + "schema": {"read_only": True}, "context_data": {"read_only": True}, } diff --git a/backend/src/baserow/contrib/database/views/filters.py b/backend/src/baserow/contrib/database/views/filters.py index 58e76d4e5..b613dd8a5 100644 --- a/backend/src/baserow/contrib/database/views/filters.py +++ b/backend/src/baserow/contrib/database/views/filters.py @@ -138,7 +138,8 @@ class AdHocFilters: data[key] = sanitize_adhoc_filter_value(value) api_filters = None - if (filters := data.get("filters", None)) and len(filters) > 0: + filter_object = {"filters": data} + if (filters := filter_object.get("filters", None)) and len(filters) > 0: api_filters = validate_api_grouped_filters( data, user_field_names=user_field_names, deserialize_filters=False ) diff --git a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py index 8a2629eb3..6d75504ce 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/mixins.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/mixins.py @@ -359,7 +359,7 @@ class LocalBaserowTableServiceSortableMixin: queryset = super().get_queryset(service, table, dispatch_context, model) adhoc_sort = dispatch_context.sortings() - if adhoc_sort is not None and dispatch_context.is_publicly_sortable: + if adhoc_sort and dispatch_context.is_publicly_sortable: field_names = [field.strip("-") for field in adhoc_sort.split(",")] dispatch_context.validate_filter_search_sort_fields( field_names, ServiceAdhocRefinements.SORT diff --git a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py index 1c0fa6f12..6ecc67e9b 100644 --- a/backend/src/baserow/contrib/integrations/local_baserow/service_types.py +++ b/backend/src/baserow/contrib/integrations/local_baserow/service_types.py @@ -429,7 +429,7 @@ class LocalBaserowTableServiceType(LocalBaserowServiceType): "id": { "type": "number", "title": "Id", - "sortable": True, + "sortable": False, "filterable": False, "searchable": False, } diff --git a/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py b/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py index 11d7ade5e..d04ce11a2 100644 --- a/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py +++ b/backend/tests/baserow/contrib/integrations/local_baserow/test_service_types.py @@ -868,7 +868,7 @@ def test_local_baserow_table_service_generate_schema_with_interesting_test_table "type": "number", "title": "Id", "metadata": {}, - "sortable": True, + "sortable": False, "filterable": False, "searchable": False, }, diff --git a/changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json b/changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json new file mode 100644 index 000000000..f8bcf56a9 --- /dev/null +++ b/changelog/entries/unreleased/feature/2516_builder_allow_collection_elements_to_be_filtered_sorted_and_.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "[Builder] Allow collection elements to be filtered, sorted and searched against.", + "issue_number": 2516, + "bullet_points": [], + "created_at": "2024-10-24" +} \ No newline at end of file diff --git a/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue b/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue index 629b186c6..5666045b5 100644 --- a/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue +++ b/web-frontend/modules/builder/components/elements/baseComponents/ABDropdown.vue @@ -59,7 +59,7 @@ class="select__search-input" :placeholder="searchText === null ? $t('action.search') : searchText" tabindex="0" - @keyup="search(query)" + @keyup="emitSearch ? $emit('query-change', query) : search(query)" /> </div> <ul @@ -91,5 +91,15 @@ import dropdown from '@baserow/modules/core/mixins/dropdown' export default { name: 'ABDropdown', mixins: [dropdown], + props: { + /** + * When `emitSearch` is set to `true`, this will emit the search + * query instead of performing local, per dropdown-item, search. + */ + emitSearch: { + type: Boolean, + default: false, + }, + }, } </script> diff --git a/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue b/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue new file mode 100644 index 000000000..185565291 --- /dev/null +++ b/web-frontend/modules/builder/components/elements/components/CollectionElementHeader.vue @@ -0,0 +1,48 @@ +<template> + <component + :is="serviceType.adhocHeaderComponent" + v-if="dataSource" + class="collection-element__header margin-bottom-1" + :sortable-properties=" + elementType.adhocSortableProperties(element, dataSource) + " + :filterable-properties=" + elementType.adhocFilterableProperties(element, dataSource) + " + :searchable-properties=" + elementType.adhocSearchableProperties(element, dataSource) + " + @filters-changed="$emit('filters-changed', $event)" + @sortings-changed="$emit('sortings-changed', $event)" + @search-changed="$emit('search-changed', $event)" + /> +</template> + +<script> +export default { + inject: ['builder', 'page'], + props: { + element: { + type: Object, + required: true, + }, + }, + computed: { + sharedPage() { + return this.$store.getters['page/getSharedPage'](this.builder) + }, + dataSource() { + return this.$store.getters['dataSource/getPagesDataSourceById']( + [this.page, this.sharedPage], + this.element.data_source_id + ) + }, + elementType() { + return this.$registry.get('element', this.element.type) + }, + serviceType() { + return this.$registry.get('service', this.dataSource?.type) + }, + }, +} +</script> diff --git a/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue b/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue index 467702e8a..b9c319bab 100644 --- a/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue +++ b/web-frontend/modules/builder/components/elements/components/RecordSelectorElement.vue @@ -8,12 +8,14 @@ <ABDropdown ref="recordSelectorDropdown" v-model="inputValue" + :show-search="adhocSearchEnabled" + :emit-search="adhocSearchEnabled" class="choice-element" - :show-search="false" :placeholder="resolvedPlaceholder" :multiple="element.multiple" :before-show="beforeShow" @hide="onFormElementTouch" + @query-change="adhocSearch = $event" @scroll="$refs.infiniteScroll.handleScroll($event)" > <template #value> @@ -24,6 +26,15 @@ {{ selectedValueDisplay }} </span> </template> + <template #emptyState> + {{ + adhocSearchEnabled + ? $t('recordSelectorElement.emptyAdhocState', { + query: adhocSearch, + }) + : $t('recordSelectorElement.emptyState') + }} + </template> <template #defaultValue> <template v-if="loading"> <div class="loading" /> @@ -99,6 +110,14 @@ export default { } }, computed: { + adhocSearchEnabled() { + return ( + this.elementType.adhocSearchableProperties( + this.element, + this.dataSource + ).length > 0 + ) + }, resolvedLabel() { return ensureString(this.resolveFormula(this.element.label)) }, diff --git a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue index 03c5f539f..43df6f715 100644 --- a/web-frontend/modules/builder/components/elements/components/RepeatElement.vue +++ b/web-frontend/modules/builder/components/elements/components/RepeatElement.vue @@ -1,109 +1,121 @@ <template> - <div - :class="{ - [`repeat-element--orientation-${element.orientation}`]: true, - }" - > - <!-- If we have any contents to repeat... --> - <template v-if="elementContent.length > 0"> - <div - class="repeat-element__repeated-elements" - :style="repeatedElementsStyles" - > - <!-- Iterate over each content --> - <div v-for="(content, index) in elementContent" :key="content.id"> - <!-- If the container has an children --> - <template v-if="children.length > 0"> - <!-- Iterate over each child --> - <template v-for="child in children"> - <!-- The first iteration is editable if we're in editing mode --> - <ElementPreview - v-if="index === 0 && isEditMode" - :key="`${child.id}-${index}`" - :element="child" - :application-context-additions="{ - recordIndexPath: [ - ...applicationContext.recordIndexPath, - index, - ], - }" - @move="moveElement(child, $event)" - /> - <!-- Other iterations are not editable --> - <!-- Override the mode so that any children are in public mode --> - <PageElement - v-else - v-show="!isCollapsed" - :key="`${child.id}_${index}`" - :element="child" - :force-mode="isEditMode ? 'public' : mode" - :application-context-additions="{ - recordIndexPath: [ - ...applicationContext.recordIndexPath, - index, - ], - }" - :class="{ - 'repeat-element__preview': index > 0 && isEditMode, - }" - /> + <div class="repeat-element--container"> + <CollectionElementHeader + :element="element" + @filters-changed="adhocFilters = $event" + @sortings-changed="adhocSortings = $event" + @search-changed="adhocSearch = $event" + ></CollectionElementHeader> + <div + :class="{ + [`repeat-element--orientation-${element.orientation}`]: true, + }" + > + <!-- If we have any contents to repeat... --> + <template v-if="elementContent.length > 0"> + <div + class="repeat-element__repeated-elements" + :style="repeatedElementsStyles" + > + <!-- Iterate over each content --> + <div v-for="(content, index) in elementContent" :key="content.id"> + <!-- If the container has an children --> + <template v-if="children.length > 0"> + <!-- Iterate over each child --> + <template v-for="child in children"> + <!-- The first iteration is editable if we're in editing mode --> + <ElementPreview + v-if="index === 0 && isEditMode" + :key="`${child.id}-${index}`" + :element="child" + :application-context-additions="{ + recordIndexPath: [ + ...applicationContext.recordIndexPath, + index, + ], + }" + @move="moveElement(child, $event)" + /> + <!-- Other iterations are not editable --> + <!-- Override the mode so that any children are in public mode --> + <PageElement + v-else + v-show="!isCollapsed" + :key="`${child.id}_${index}`" + :element="child" + :force-mode="isEditMode ? 'public' : mode" + :application-context-additions="{ + recordIndexPath: [ + ...applicationContext.recordIndexPath, + index, + ], + }" + :class="{ + 'repeat-element__preview': index > 0 && isEditMode, + }" + /> + </template> </template> - </template> + </div> </div> - </div> - <!-- We have contents, but the container has no children... --> - <template v-if="children.length === 0 && isEditMode"> - <!-- Give the designer the chance to add child elements --> - <AddElementZone - :disabled="elementIsInError && !elementHasSourceOfData" - :tooltip="addElementErrorTooltipMessage" - @add-element="showAddElementModal" - ></AddElementZone> - <AddElementModal - ref="addElementModal" - :page="page" - :element-types-allowed="elementType.childElementTypes(page, element)" - ></AddElementModal> - </template> - </template> - <!-- We have no contents to repeat --> - <template v-else> - <!-- If we also have no children, allow the designer to add elements --> - <template v-if="children.length === 0 && isEditMode"> - <AddElementZone - :disabled="elementIsInError && !elementHasSourceOfData" - :tooltip="addElementErrorTooltipMessage" - @add-element="showAddElementModal" - ></AddElementZone> - <AddElementModal - ref="addElementModal" - :page="page" - :element-types-allowed="elementType.childElementTypes(page, element)" - ></AddElementModal> - </template> - <!-- We have no contents, but we do have children in edit mode --> - <template v-else-if="isEditMode"> - <div v-if="contentLoading" class="loading"></div> - <template v-else> - <ElementPreview - v-for="child in children" - :key="child.id" - :element="child" - @move="moveElement(child, $event)" - /> + <!-- We have contents, but the container has no children... --> + <template v-if="children.length === 0 && isEditMode"> + <!-- Give the designer the chance to add child elements --> + <AddElementZone + :disabled="elementIsInError && !elementHasSourceOfData" + :tooltip="addElementErrorTooltipMessage" + @add-element="showAddElementModal" + ></AddElementZone> + <AddElementModal + ref="addElementModal" + :page="page" + :element-types-allowed=" + elementType.childElementTypes(page, element) + " + ></AddElementModal> </template> </template> - </template> - <div class="repeat-element__footer"> - <ABButton - v-if="hasMorePage && children.length > 0" - :style="getStyleOverride('button')" - :disabled="contentLoading || !contentFetchEnabled" - :loading="contentLoading" - @click="loadMore()" - > - {{ resolvedButtonLoadMoreLabel || $t('repeatElement.showMore') }} - </ABButton> + <!-- We have no contents to repeat --> + <template v-else> + <!-- If we also have no children, allow the designer to add elements --> + <template v-if="children.length === 0 && isEditMode"> + <AddElementZone + :disabled="elementIsInError && !elementHasSourceOfData" + :tooltip="addElementErrorTooltipMessage" + @add-element="showAddElementModal" + ></AddElementZone> + <AddElementModal + ref="addElementModal" + :page="page" + :element-types-allowed=" + elementType.childElementTypes(page, element) + " + ></AddElementModal> + </template> + <!-- We have no contents, but we do have children in edit mode --> + <template v-else-if="isEditMode"> + <div v-if="contentLoading" class="loading"></div> + <template v-else> + <ElementPreview + v-for="child in children" + :key="child.id" + :element="child" + @move="moveElement(child, $event)" + /> + </template> + </template> + </template> + <div class="repeat-element__footer"> + <ABButton + v-if="hasMorePage && children.length > 0" + :style="getStyleOverride('button')" + :disabled="contentLoading || !contentFetchEnabled" + :loading="contentLoading" + @click="loadMore()" + > + {{ resolvedButtonLoadMoreLabel || $t('repeatElement.showMore') }} + </ABButton> + </div> </div> </div> </template> @@ -120,10 +132,12 @@ import PageElement from '@baserow/modules/builder/components/page/PageElement' import { notifyIf } from '@baserow/modules/core/utils/error' import { ensureString } from '@baserow/modules/core/utils/validator' import { RepeatElementType } from '@baserow/modules/builder/elementTypes' +import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader' export default { name: 'RepeatElement', components: { + CollectionElementHeader, PageElement, ElementPreview, AddElementModal, diff --git a/web-frontend/modules/builder/components/elements/components/TableElement.vue b/web-frontend/modules/builder/components/elements/components/TableElement.vue index 84eed947a..b3b5a7bfe 100644 --- a/web-frontend/modules/builder/components/elements/components/TableElement.vue +++ b/web-frontend/modules/builder/components/elements/components/TableElement.vue @@ -1,5 +1,11 @@ <template> <div class="table-element"> + <CollectionElementHeader + :element="element" + @filters-changed="adhocFilters = $event" + @sortings-changed="adhocSortings = $event" + @search-changed="adhocSearch = $event" + ></CollectionElementHeader> <ABTable :fields="fields" :rows="rows" @@ -62,10 +68,11 @@ import { uuid } from '@baserow/modules/core/utils/string' import BaserowTable from '@baserow/modules/builder/components/elements/components/BaserowTable' import collectionElement from '@baserow/modules/builder/mixins/collectionElement' import { ensureString } from '@baserow/modules/core/utils/validator' +import CollectionElementHeader from '@baserow/modules/builder/components/elements/components/CollectionElementHeader' export default { name: 'TableElement', - components: { BaserowTable }, + components: { CollectionElementHeader, BaserowTable }, mixins: [element, collectionElement], props: { /** diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue index a6a599f40..bec26096e 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/RecordSelectorElementForm.vue @@ -194,6 +194,19 @@ export default { ) }, }, + watch: { + 'values.data_source_id': { + handler(value) { + this.values.data_source_id = value + + // If the data source was removed we should also delete the name formula + if (value === null) { + this.values.option_name_suffix = '' + } + }, + immediate: true, + }, + }, validations() { return { values: { @@ -208,18 +221,5 @@ export default { }, } }, - watch: { - 'values.data_source_id': { - handler(value) { - this.values.data_source_id = value - - // If the data source was removed we should also delete the name formula - if (value === null) { - this.values.option_name_suffix = '' - } - }, - immediate: true, - }, - }, } </script> diff --git a/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue b/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue index d10f28d2e..42abaa59e 100644 --- a/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue +++ b/web-frontend/modules/builder/components/elements/components/forms/general/settings/PropertyOptionForm.vue @@ -96,6 +96,9 @@ export default { .get('service', this.dataSource.type) .getDataSchema(this.dataSource) }, + elementType() { + return this.$registry.get('element', this.element.type) + }, /** * Returns an object with schema properties as keys and their corresponding * property options as values. It's a convenience computed method to easily diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js index cce99177e..75b96d066 100644 --- a/web-frontend/modules/builder/elementTypes.js +++ b/web-frontend/modules/builder/elementTypes.js @@ -822,6 +822,87 @@ const CollectionElementTypeMixin = (Base) => class extends Base { isCollectionElement = true + /** + * A helper function responsible for returning this collection element's + * schema properties. + */ + getSchemaProperties(dataSource) { + const serviceType = this.app.$registry.get('service', dataSource.type) + const schema = serviceType.getDataSchema(dataSource) + if (!schema) { + return [] + } + return schema.type === 'array' + ? schema.items.properties + : schema.properties + } + + /** + * Given a schema property name, is responsible for finding the matching + * property option in the element. If it doesn't exist, then we return + * an empty object, and it won't be included in the adhoc header. + * @param {object} element - the element we want to extract options from. + * @param {string} schemaProperty - the schema property name to check. + * @returns {object} - the matching property option, or an empty object. + */ + getPropertyOptionsByProperty(element, schemaProperty) { + return ( + element.property_options.find((option) => { + return option.schema_property === schemaProperty + }) || {} + ) + } + + /** + * Responsible for iterating over the schema's properties, filtering + * the results down to the properties which are `filterable`, `sortable`, + * and `searchable`, and then returning the property value. + * @param {string} option - the `filterable`, `sortable` or `searchable` + * property option. If the value is `true` then the property will be + * included in the adhoc header component. + * @param {object} element - the element we want to extract options from. + * @param {object} dataSource - the dataSource used by `element`. + * @returns {array} - an array of schema properties which are present + * in the element's property options where `option` = `true`. + */ + getPropertyOptionByType(option, element, dataSource) { + const schemaProperties = dataSource + ? this.getSchemaProperties(dataSource) + : [] + return Object.entries(schemaProperties) + .filter( + ([schemaProperty, _]) => + this.getPropertyOptionsByProperty(element, schemaProperty)[ + option + ] || false + ) + .map(([_, property]) => property) + } + + /** + * An array of properties within this element which have been flagged + * as filterable by the page designer. + */ + adhocFilterableProperties(element, dataSource) { + return this.getPropertyOptionByType('filterable', element, dataSource) + } + + /** + * An array of properties within this element which have been flagged + * as sortable by the page designer. + */ + adhocSortableProperties(element, dataSource) { + return this.getPropertyOptionByType('sortable', element, dataSource) + } + + /** + * An array of properties within this element which have been flagged + * as searchable by the page designer. + */ + adhocSearchableProperties(element, dataSource) { + return this.getPropertyOptionByType('searchable', element, dataSource) + } + /** * By default collection element will load their content at loading time * but if you don't want that you can return false here. diff --git a/web-frontend/modules/builder/locales/en.json b/web-frontend/modules/builder/locales/en.json index 099570e9b..17f27731e 100644 --- a/web-frontend/modules/builder/locales/en.json +++ b/web-frontend/modules/builder/locales/en.json @@ -599,6 +599,10 @@ "toggleEditorRepetitionsLabel": "Temporarily disable repetitions", "propertySelectorMissingArrays": "No multiple valued fields found to repeat with." }, + "recordSelectorElement": { + "emptyAdhocState": "No records matching '{query}' found.", + "emptyState": "No records found." + }, "recordSelectorElementForm": { "selectRecordsFrom": "Select records from", "noDataSourceMessage": "Choose a data source with multiple rows to list all results.", diff --git a/web-frontend/modules/builder/mixins/collectionElement.js b/web-frontend/modules/builder/mixins/collectionElement.js index 8ab4cff2a..7c4c4b99c 100644 --- a/web-frontend/modules/builder/mixins/collectionElement.js +++ b/web-frontend/modules/builder/mixins/collectionElement.js @@ -6,6 +6,9 @@ import _ from 'lodash' export default { data() { return { + adhocFilters: undefined, + adhocSortings: undefined, + adhocSearch: undefined, currentOffset: 0, errorNotified: false, resetTimeout: null, @@ -61,6 +64,13 @@ export default { elementHasSourceOfData() { return this.elementType.hasSourceOfData(this.element) }, + adhocRefinements() { + return { + filters: this.adhocFilters, + sortings: this.adhocSortings, + search: this.adhocSearch, + } + }, elementIsInError() { return this.elementType.isInError({ page: this.page, @@ -92,6 +102,13 @@ export default { }, deep: true, }, + adhocRefinements: { + handler(newValue, prevValue) { + if (!_.isEqual(newValue, prevValue)) { + this.debouncedReset() + } + }, + }, }, async fetch() { if (!this.elementIsInError && this.elementType.fetchAtLoad) { @@ -122,6 +139,9 @@ export default { dataSource: this.dataSource, data: this.dispatchContext, range, + filters: this.adhocRefinements.filters, + sortings: this.adhocRefinements.sortings, + search: this.adhocRefinements.search, mode: this.applicationContext.mode, replace, }) diff --git a/web-frontend/modules/builder/mixins/collectionElementForm.js b/web-frontend/modules/builder/mixins/collectionElementForm.js index a4a738a6d..5c41af409 100644 --- a/web-frontend/modules/builder/mixins/collectionElementForm.js +++ b/web-frontend/modules/builder/mixins/collectionElementForm.js @@ -1,7 +1,6 @@ import { mapGetters } from 'vuex' import applicationContextMixin from '@baserow/modules/builder/mixins/applicationContext' import { CurrentRecordDataProviderType } from '@baserow/modules/builder/dataProviderTypes' -import { FF_PROPERTY_OPTIONS } from '@baserow/modules/core/plugins/featureFlags' export default { mixins: [applicationContextMixin], @@ -49,11 +48,7 @@ export default { * @returns {boolean} - Whether the property options are available. */ propertyOptionsAvailable() { - return ( - this.selectedDataSource && - this.selectedDataSourceReturnsList && - this.$featureFlagIsEnabled(FF_PROPERTY_OPTIONS) - ) + return this.selectedDataSource && this.selectedDataSourceReturnsList }, /** * In collection element forms, the ability to view paging options diff --git a/web-frontend/modules/builder/services/publishedBuilder.js b/web-frontend/modules/builder/services/publishedBuilder.js index ffda87f25..ec27ee990 100644 --- a/web-frontend/modules/builder/services/publishedBuilder.js +++ b/web-frontend/modules/builder/services/publishedBuilder.js @@ -24,14 +24,35 @@ export default (client) => { `builder/domains/published/page/${pageId}/workflow_actions/` ) }, - dispatch(dataSourceId, dispatchContext, { range }) { + dispatch( + dataSourceId, + dispatchContext, + { range, filters = {}, sortings = null, search = '', searchMode = '' } + ) { // Using POST Http method here is not Restful but it the cleanest way to send // data with the call without relying on GET parameter and serialization of // complex object. - const params = {} + const params = new URLSearchParams() if (range) { - params.offset = range[0] - params.count = range[1] + params.append('offset', range[0]) + params.append('count', range[1]) + } + + Object.keys(filters).forEach((key) => { + filters[key].forEach((value) => { + params.append(key, value) + }) + }) + + if (sortings || sortings === '') { + params.append('order_by', sortings) + } + + if (search) { + params.append('search_query', search) + if (searchMode) { + params.append('search_mode', searchMode) + } } return client.post( diff --git a/web-frontend/modules/builder/store/elementContent.js b/web-frontend/modules/builder/store/elementContent.js index 8cbd3ab31..ebdbb783f 100644 --- a/web-frontend/modules/builder/store/elementContent.js +++ b/web-frontend/modules/builder/store/elementContent.js @@ -58,6 +58,11 @@ const actions = { * @param {object} element - the element object * @param {object} dataSource - the data source we want to dispatch * @param {object} range - the range of the data we want to fetch + * @param {object} filters - the adhoc filters to apply to the data + * @param {object} sortings - the adhoc sortings to apply to the data + * @param {object} search - the adhoc search to apply to the data + * @param {string} searchMode - the search mode to apply to the data. + * @param {string} mode - the mode of the application * @param {object} dispatchContext - the context to dispatch to the data * @param {bool} replace - if we want to replace the current content * @param {object} data - the query body @@ -69,6 +74,10 @@ const actions = { element, dataSource, range, + filters = {}, + sortings = null, + search = '', + searchMode = '', mode, data: dispatchContext, replace = false, @@ -203,7 +212,7 @@ const actions = { const { data } = await service(this.app.$client).dispatch( dataSource.id, dispatchContext, - { range: rangeToFetch } + { range: rangeToFetch, filters, sortings, search, searchMode } ) // With a list-type data source, the data object will return diff --git a/web-frontend/modules/core/assets/scss/components/builder/all.scss b/web-frontend/modules/core/assets/scss/components/builder/all.scss index 8f8e31535..f1bf658e3 100644 --- a/web-frontend/modules/core/assets/scss/components/builder/all.scss +++ b/web-frontend/modules/core/assets/scss/components/builder/all.scss @@ -34,3 +34,4 @@ @import 'padding_selector'; @import 'page'; @import 'data_source_item'; +@import 'collection_element_header'; diff --git a/web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss b/web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss new file mode 100644 index 000000000..0ccaf2a81 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/builder/collection_element_header.scss @@ -0,0 +1,4 @@ +.element--read-only .collection-element__header { + pointer-events: none; + user-select: none; +} diff --git a/web-frontend/modules/core/assets/scss/components/integrations/all.scss b/web-frontend/modules/core/assets/scss/components/integrations/all.scss index 583cec281..80b3679ff 100644 --- a/web-frontend/modules/core/assets/scss/components/integrations/all.scss +++ b/web-frontend/modules/core/assets/scss/components/integrations/all.scss @@ -1 +1,2 @@ @import 'local_baserow/local_baserow_form'; +@import 'local_baserow/local_baserow_adhoc_header'; diff --git a/web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss b/web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss new file mode 100644 index 000000000..9429fb3b6 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/integrations/local_baserow/local_baserow_adhoc_header.scss @@ -0,0 +1,10 @@ +.local-baserow-adhoc-header__container { + .header__filter-item { + margin-left: 0; + + &.header__filter-item--right { + margin-left: auto; + margin-right: 0; + } + } +} diff --git a/web-frontend/modules/core/components/Context.vue b/web-frontend/modules/core/components/Context.vue index 9ba804ea2..fc72b91b6 100644 --- a/web-frontend/modules/core/components/Context.vue +++ b/web-frontend/modules/core/components/Context.vue @@ -140,9 +140,10 @@ export default { // direction, then it will break out of it. We will therefore close it. This can // happen the height or width of the viewport decreases. if ( - (css.bottom && css.bottom < 0) || + (css.bottom && css.bottom < this.getWindowScrollHeight()) || (css.right && css.right < 0) || - (css.top && css.top > window.innerHeight) + (css.top && + css.top > window.innerHeight + this.getWindowScrollHeight()) ) { this.hide() return @@ -161,7 +162,9 @@ export default { const maxHeight = css.top || css.bottom ? `calc(100vh - ${ - (css.top || css.bottom) + this.maxHeightOffset + (css.top || css.bottom) + + this.maxHeightOffset - + this.getWindowScrollHeight() }px)` : 'none' this.$el.style['max-height'] = maxHeight @@ -426,15 +429,21 @@ export default { } if (verticalAdjusted === 'bottom') { - positions.top = targetBottom + verticalOffset + positions.top = + targetBottom + verticalOffset + this.getWindowScrollHeight() } if (verticalAdjusted === 'over-bottom' || verticalAdjusted === 'over') { - positions.top = targetTop + verticalOffset + positions.top = + targetTop + verticalOffset + this.getWindowScrollHeight() } if (verticalAdjusted === 'top') { - positions.bottom = window.innerHeight - targetTop + verticalOffset + positions.bottom = + window.innerHeight - + targetTop + + verticalOffset + + this.getWindowScrollHeight() } if (verticalAdjusted === 'over-top' || verticalAdjusted === 'over') { @@ -469,9 +478,15 @@ export default { // with the full height of the element without scrollbar to calculate the optimal // position. const scrollHeight = this.$el.scrollHeight - const canTop = targetRect.top - scrollHeight - verticalOffset > 0 + const canTop = + targetRect.top - + scrollHeight - + verticalOffset + + this.getWindowScrollHeight() > + 0 const canBottom = - window.innerHeight - + window.innerHeight + + this.getWindowScrollHeight() - targetRect.bottom - scrollHeight - this.maxHeightOffset - @@ -507,6 +522,9 @@ export default { return { vertical, horizontal } }, + getWindowScrollHeight() { + return window?.scrollY || 0 + }, isOpen() { return this.open }, diff --git a/web-frontend/modules/core/mixins/dropdown.js b/web-frontend/modules/core/mixins/dropdown.js index d03a8b477..a1c7bb243 100644 --- a/web-frontend/modules/core/mixins/dropdown.js +++ b/web-frontend/modules/core/mixins/dropdown.js @@ -225,7 +225,9 @@ export default { : [...items, ...traverse(child.$children)], [] ) - return traverse(this.$children) + const components = traverse(this.$children) + this.hasDropdownItem = components.length > 0 + return components }, focusout(event) { // Hide only if we loose focus in favor of another element which is not a @@ -271,8 +273,6 @@ export default { this.opener = isElementOrigin ? target : null this.$emit('show') - this.hasDropdownItem = this.getDropdownItemComponents().length > 0 - this.$nextTick(() => { // We have to wait for the input to be visible before we can focus. this.showSearch && this.$refs.search.focus() diff --git a/web-frontend/modules/core/plugins/featureFlags.js b/web-frontend/modules/core/plugins/featureFlags.js index 83458107b..5d74cc73a 100644 --- a/web-frontend/modules/core/plugins/featureFlags.js +++ b/web-frontend/modules/core/plugins/featureFlags.js @@ -1,7 +1,6 @@ const FF_ENABLE_ALL = '*' export const FF_EXPORT_WORKSPACE = 'export_workspace' export const FF_DASHBOARDS = 'dashboards' -export const FF_PROPERTY_OPTIONS = 'property_options' /** * A comma separated list of feature flags used to enable in-progress or not ready diff --git a/web-frontend/modules/database/components/view/ViewSearch.vue b/web-frontend/modules/database/components/view/ViewSearch.vue index 97ec13f6b..91135cea2 100644 --- a/web-frontend/modules/database/components/view/ViewSearch.vue +++ b/web-frontend/modules/database/components/view/ViewSearch.vue @@ -15,6 +15,7 @@ ref="context" :view="view" :fields="fields" + :read-only="readOnly" :store-prefix="storePrefix" :always-hide-rows-not-matching-search="alwaysHideRowsNotMatchingSearch" @refresh="$emit('refresh', $event)" @@ -38,9 +39,15 @@ export default { type: Array, required: true, }, + readOnly: { + type: Boolean, + required: false, + default: false, + }, storePrefix: { type: String, - required: true, + required: false, + default: '', }, alwaysHideRowsNotMatchingSearch: { type: Boolean, @@ -53,6 +60,18 @@ export default { headerSearchTerm: '', } }, + watch: { + $props: { + immediate: true, + handler() { + if (!this.storePrefix.length && !this.readOnly) { + throw new Error( + 'A storePrefix is required when the search is not read-only.' + ) + } + }, + }, + }, mounted() { this.$priorityBus.$on( 'start-search', diff --git a/web-frontend/modules/database/components/view/ViewSearchContext.vue b/web-frontend/modules/database/components/view/ViewSearchContext.vue index 07df57c0e..f5c1e7f89 100644 --- a/web-frontend/modules/database/components/view/ViewSearchContext.vue +++ b/web-frontend/modules/database/components/view/ViewSearchContext.vue @@ -48,6 +48,10 @@ export default { type: Array, required: true, }, + readOnly: { + type: Boolean, + required: true, + }, storePrefix: { type: String, required: true, @@ -88,6 +92,11 @@ export default { this.lastHide = this.hideRowsNotMatchingSearch }, search() { + if (this.readOnly) { + this.$emit('refresh', { activeSearchTerm: this.activeSearchTerm }) + return + } + this.loading = true // When the user toggles from hiding rows to not hiding rows we still @@ -114,6 +123,7 @@ export default { ) this.$emit('refresh', { callback: this.finishedLoading, + activeSearchTerm: this.activeSearchTerm, }) }, 400), // Debounce even the client side only refreshes as otherwise spamming the keyboard diff --git a/web-frontend/modules/database/components/view/ViewSortContext.vue b/web-frontend/modules/database/components/view/ViewSortContext.vue index 1609084fa..7b8cc0cd9 100644 --- a/web-frontend/modules/database/components/view/ViewSortContext.vue +++ b/web-frontend/modules/database/components/view/ViewSortContext.vue @@ -36,12 +36,12 @@ </a> <div class="sortings__description"> - <template v-if="index === 0">{{ - $t('viewSortContext.sortBy') - }}</template> - <template v-if="index > 0">{{ - $t('viewSortContext.thenBy') - }}</template> + <template v-if="index === 0" + >{{ $t('viewSortContext.sortBy') }} + </template> + <template v-if="index > 0" + >{{ $t('viewSortContext.thenBy') }} + </template> </div> <div class="sortings__field"> <Dropdown @@ -69,9 +69,9 @@ :class="{ active: sort.order === 'ASC' }" @click="updateSort(sort, { order: 'ASC' })" > - <template v-if="getSortIndicator(field, 0) === 'text'">{{ - getSortIndicator(field, 1) - }}</template> + <template v-if="getSortIndicator(field, 0) === 'text'" + >{{ getSortIndicator(field, 1) }} + </template> <i v-if="getSortIndicator(field, 0) === 'icon'" :class="getSortIndicator(field, 1)" @@ -79,9 +79,9 @@ <i class="iconoir-arrow-right"></i> - <template v-if="getSortIndicator(field, 0) === 'text'">{{ - getSortIndicator(field, 2) - }}</template> + <template v-if="getSortIndicator(field, 0) === 'text'" + >{{ getSortIndicator(field, 2) }} + </template> <i v-if="getSortIndicator(field, 0) === 'icon'" :class="getSortIndicator(field, 2)" @@ -92,9 +92,9 @@ :class="{ active: sort.order === 'DESC' }" @click="updateSort(sort, { order: 'DESC' })" > - <template v-if="getSortIndicator(field, 0) === 'text'">{{ - getSortIndicator(field, 2) - }}</template> + <template v-if="getSortIndicator(field, 0) === 'text'" + >{{ getSortIndicator(field, 2) }} + </template> <i v-if="getSortIndicator(field, 0) === 'icon'" :class="getSortIndicator(field, 2)" @@ -102,9 +102,9 @@ <i class="iconoir-arrow-right"></i> - <template v-if="getSortIndicator(field, 0) === 'text'">{{ - getSortIndicator(field, 1) - }}</template> + <template v-if="getSortIndicator(field, 0) === 'text'" + >{{ getSortIndicator(field, 1) }} + </template> <i v-if="getSortIndicator(field, 0) === 'icon'" :class="getSortIndicator(field, 1)" @@ -124,8 +124,8 @@ $refs.addContext.toggle($refs.addContextToggle, 'bottom', 'left', 4) " > - {{ $t('viewSortContext.addSort') }}</ButtonText - > + {{ $t('viewSortContext.addSort') }} + </ButtonText> <Context ref="addContext" class="sortings__add-context" @@ -142,7 +142,7 @@ <a class="context__menu-item-link" @click="addSort(field)"> <i class="context__menu-item-icon" - :class="field._.type.iconClass" + :class="getFieldType(field).iconClass" ></i> {{ field.name }} </a> @@ -188,8 +188,11 @@ export default { }, }, methods: { + getFieldType(field) { + return this.$registry.get('field', field.type) + }, getCanSortInView(field) { - return this.$registry.get('field', field.type).getCanSortInView(field) + return this.getFieldType(field).getCanSortInView(field) }, getField(fieldId) { for (const i in this.fields) { @@ -249,9 +252,9 @@ export default { } }, getSortIndicator(field, index) { - return this.$registry - .get('field', field.type) - .getSortIndicator(field, this.$registry)[index] + return this.getFieldType(field).getSortIndicator(field, this.$registry)[ + index + ] }, }, } diff --git a/web-frontend/modules/database/store/view.js b/web-frontend/modules/database/store/view.js index 1039a8c86..54f8377da 100644 --- a/web-frontend/modules/database/store/view.js +++ b/web-frontend/modules/database/store/view.js @@ -140,11 +140,15 @@ export const mutations = { if (!state.items.some((existingItem) => existingItem.id === item.id)) state.items = [...state.items, item].sort((a, b) => a.order - b.order) }, - UPDATE_ITEM(state, { id, values, repopulate }) { - const index = state.items.findIndex((item) => item.id === id) - Object.assign(state.items[index], state.items[index], values) - if (repopulate === true) { - populateView(state.items[index], this.$registry) + UPDATE_ITEM(state, { id, view, values, repopulate, readOnly }) { + if (!readOnly) { + const index = state.items.findIndex((item) => item.id === id) + Object.assign(state.items[index], state.items[index], values) + if (repopulate === true) { + populateView(state.items[index], this.$registry) + } + } else { + Object.assign(view, view, values) } }, ORDER_ITEMS(state, { ownershipType, order }) { @@ -440,7 +444,12 @@ export const actions = { } if (optimisticUpdate) { - dispatch('forceUpdate', { view, values: newValues, repopulate: true }) + dispatch('forceUpdate', { + view, + values: newValues, + repopulate: true, + readOnly, + }) } try { if (!readOnly) { @@ -484,8 +493,17 @@ export const actions = { /** * Forcefully update an existing view without making a request to the backend. */ - forceUpdate({ commit }, { view, values, repopulate = false }) { - commit('UPDATE_ITEM', { id: view.id, values, repopulate }) + forceUpdate( + { commit }, + { view, values, repopulate = false, readOnly = false } + ) { + commit('UPDATE_ITEM', { + id: view.id, + view, + values, + repopulate, + readOnly, + }) }, /** * Duplicates an existing view. diff --git a/web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue b/web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue new file mode 100644 index 000000000..634105dda --- /dev/null +++ b/web-frontend/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader.vue @@ -0,0 +1,99 @@ +<template> + <div class="local-baserow-adhoc-header__container"> + <ul class="header__filter"> + <li class="header__filter-item"> + <ViewFilter + v-if="filterableFields.length" + read-only + :view="view" + :fields="filterableFields" + :disable-filter="false" + @changed="handleFiltersChange" + ></ViewFilter> + </li> + <li class="header__filter-item"> + <ViewSort + v-if="sortableFields.length" + read-only + :view="view" + :fields="sortableFields" + @changed="handleSortingsChange" + ></ViewSort> + </li> + <li class="header__filter-item header__filter-item--right"> + <ViewSearch + v-if="searchableFields.length" + read-only + always-hide-rows-not-matching-search + :view="view" + :fields="searchableFields" + @refresh="handleSearchChange" + ></ViewSearch> + </li> + </ul> + </div> +</template> + +<script> +import ViewFilter from '@baserow/modules/database/components/view/ViewFilter' +import ViewSort from '@baserow/modules/database/components/view/ViewSort' +import ViewSearch from '@baserow/modules/database/components/view/ViewSearch' +import { getFilters, getOrderBy } from '@baserow/modules/database/utils/view' + +export default { + components: { ViewSearch, ViewSort, ViewFilter }, + props: { + /** + * An array of filterable, sortable and searchable *schema* properties. + * To access the Baserow field response, these need to be reduced down + * to just their `metadata`. This happens in the `computed` methods below. + */ + filterableProperties: { + type: Array, + required: true, + }, + sortableProperties: { + type: Array, + required: true, + }, + searchableProperties: { + type: Array, + required: true, + }, + }, + data() { + return { + view: { + filters: [], + sortings: [], + filter_groups: [], + filter_type: 'AND', + filters_disabled: false, + _: { loading: false }, + }, + } + }, + computed: { + filterableFields() { + return this.filterableProperties.map((prop) => prop.metadata) + }, + sortableFields() { + return this.sortableProperties.map((prop) => prop.metadata) + }, + searchableFields() { + return this.searchableProperties.map((prop) => prop.metadata) + }, + }, + methods: { + handleFiltersChange() { + this.$emit('filters-changed', getFilters(this.view, true)) + }, + handleSortingsChange() { + this.$emit('sortings-changed', getOrderBy(this.view, true)) + }, + handleSearchChange(value) { + this.$emit('search-changed', value.activeSearchTerm) + }, + }, +} +</script> diff --git a/web-frontend/modules/integrations/serviceTypes.js b/web-frontend/modules/integrations/serviceTypes.js index 49f963d2c..cf04dfb32 100644 --- a/web-frontend/modules/integrations/serviceTypes.js +++ b/web-frontend/modules/integrations/serviceTypes.js @@ -3,6 +3,7 @@ import { LocalBaserowIntegrationType } from '@baserow/modules/integrations/integ import LocalBaserowGetRowForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowGetRowForm' import LocalBaserowListRowsForm from '@baserow/modules/integrations/localBaserow/components/services/LocalBaserowListRowsForm' import { uuid } from '@baserow/modules/core/utils/string' +import LocalBaserowAdhocHeader from '@baserow/modules/integrations/localBaserow/components/integrations/LocalBaserowAdhocHeader' export class LocalBaserowGetRowServiceType extends ServiceType { static getType() { @@ -67,12 +68,12 @@ export class LocalBaserowGetRowServiceType extends ServiceType { const databases = integration.context_data?.databases - if (service.table_id && databases) { - const tableSelected = databases - .map((database) => database.tables) - .flat() - .find(({ id }) => id === service.table_id) + const tableSelected = databases + .map((database) => database.tables) + .flat() + .find(({ id }) => id === service.table_id) + if (service.table_id && tableSelected) { return `${this.name} - ${tableSelected.name}` } @@ -106,6 +107,13 @@ export class LocalBaserowListRowsServiceType extends ServiceType { return LocalBaserowListRowsForm } + /** + * The Local Baserow adhoc filtering, sorting and searching component. + */ + get adhocHeaderComponent() { + return LocalBaserowAdhocHeader + } + isValid(service) { return super.isValid(service) && Boolean(service.table_id) } @@ -199,12 +207,12 @@ export class LocalBaserowListRowsServiceType extends ServiceType { const databases = integration.context_data?.databases - if (service.table_id && databases) { - const tableSelected = databases - .map((database) => database.tables) - .flat() - .find(({ id }) => id === service.table_id) + const tableSelected = databases + .map((database) => database.tables) + .flat() + .find(({ id }) => id === service.table_id) + if (service.table_id && tableSelected) { return `${this.name} - ${tableSelected.name}` } diff --git a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap index 5a8ab4ee1..801f87a31 100644 --- a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap +++ b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/ChoiceElement.spec.js.snap @@ -47,10 +47,17 @@ exports[`ChoiceElement as default 1`] = ` <ul class="select__items" + style="display: none;" tabindex="-1" /> - <!----> + <div + class="select__items--empty" + > + + dropdown.empty + + </div> <!----> </div> @@ -180,6 +187,7 @@ exports[`ChoiceElement as manual dropdown 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li diff --git a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap index e8d05daec..1dcd2050f 100644 --- a/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap +++ b/web-frontend/test/unit/builder/components/elements/components/__snapshots__/RecordSelectorElement.spec.js.snap @@ -49,9 +49,10 @@ exports[`RecordSelectorElement does not paginate if API returns 400/404 1`] = ` <ul class="select__items" + style="" tabindex="-1" > - + <section class="infinite-scroll" > @@ -263,9 +264,10 @@ exports[`RecordSelectorElement does not paginate if API returns 400/404 2`] = ` <ul class="select__items" + style="" tabindex="-1" > - + <section class="infinite-scroll" > @@ -477,9 +479,10 @@ exports[`RecordSelectorElement does not paginate if API returns 400/404 3`] = ` <ul class="select__items" + style="" tabindex="-1" > - + <section class="infinite-scroll" > @@ -691,9 +694,10 @@ exports[`RecordSelectorElement resolves suffix formulas 1`] = ` <ul class="select__items" + style="" tabindex="-1" > - + <section class="infinite-scroll" > @@ -827,9 +831,10 @@ exports[`RecordSelectorElement resolves suffix formulas 2`] = ` <ul class="select__items" + style="" tabindex="-1" > - + <section class="infinite-scroll" > diff --git a/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap b/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap index aefbbba16..1459ae519 100644 --- a/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap +++ b/web-frontend/test/unit/core/components/__snapshots__/dropdown.spec.js.snap @@ -44,6 +44,7 @@ exports[`Dropdown component Test slots 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -114,6 +115,7 @@ exports[`Dropdown component With items 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -199,6 +201,7 @@ exports[`Dropdown component With items 2`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -510,10 +513,17 @@ exports[`Dropdown component basics 1`] = ` <ul class="select__items" + style="display: none;" tabindex="-1" /> - <!----> + <div + class="select__items--empty" + > + + dropdown.empty + + </div> <!----> </div> @@ -546,10 +556,17 @@ exports[`Dropdown component basics 2`] = ` <ul class="select__items" + style="display: none;" tabindex="-1" /> - <!----> + <div + class="select__items--empty" + > + + dropdown.empty + + </div> <!----> </div> @@ -595,10 +612,17 @@ exports[`Dropdown component basics 3`] = ` <ul class="select__items" + style="display: none;" tabindex="-1" /> - <!----> + <div + class="select__items--empty" + > + + dropdown.empty + + </div> <!----> </div> @@ -649,6 +673,7 @@ exports[`Dropdown component change value prop 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -765,6 +790,7 @@ exports[`Dropdown component test focus 1`] = ` <ul class="select__items prevent-scroll" + style="" tabindex="-1" > <li @@ -850,6 +876,7 @@ exports[`Dropdown component test focus 2`] = ` <ul class="select__items prevent-scroll" + style="" tabindex="-1" > <li @@ -935,6 +962,7 @@ exports[`Dropdown component test interactions 1`] = ` <ul class="select__items prevent-scroll" + style="" tabindex="-1" > <li @@ -1051,6 +1079,7 @@ exports[`Dropdown component test interactions 2`] = ` <ul class="select__items prevent-scroll" + style="" tabindex="-1" > <li diff --git a/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap b/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap index baa547c8c..44a58bd2b 100644 --- a/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap +++ b/web-frontend/test/unit/database/components/export/__snapshots__/exportTableModal.spec.js.snap @@ -95,6 +95,7 @@ exports[`Preview exportTableModal Modal with no view 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -314,6 +315,7 @@ exports[`Preview exportTableModal Modal with no view 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -589,6 +591,7 @@ exports[`Preview exportTableModal Modal with no view 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -1874,6 +1877,7 @@ exports[`Preview exportTableModal Modal with view 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -2093,6 +2097,7 @@ exports[`Preview exportTableModal Modal with view 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li @@ -2368,6 +2373,7 @@ exports[`Preview exportTableModal Modal with view 1`] = ` <ul class="select__items" + style="" tabindex="-1" > <li diff --git a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap index 124461a5d..245957739 100644 --- a/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap +++ b/web-frontend/test/unit/database/components/view/__snapshots__/viewFilterForm.spec.js.snap @@ -146,6 +146,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -293,6 +294,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -653,6 +655,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -776,6 +779,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -923,6 +927,7 @@ exports[`ViewFilterForm match snapshots Full view filter component 1`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -1291,6 +1296,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 1`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -1439,6 +1445,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 1`] = ` <ul class="select__items select__items--no-max-height prevent-scroll" + style="" tabindex="-1" > <li @@ -1807,6 +1814,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 2`] = ` <ul class="select__items select__items--no-max-height" + style="" tabindex="-1" > <li @@ -1955,6 +1963,7 @@ exports[`ViewFilterForm match snapshots Test rating filter 2`] = ` <ul class="select__items select__items--no-max-height prevent-scroll" + style="" tabindex="-1" > <li