1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-06 22:08:52 +00:00

Resolve "Implement the collection element filter, sort and search menu."

This commit is contained in:
Peter Evans 2024-11-06 06:02:47 +00:00
parent 8cd1bcc45a
commit c85b64b82d
37 changed files with 690 additions and 198 deletions
backend
src/baserow
api/services
contrib
database/views
integrations/local_baserow
tests/baserow/contrib/integrations/local_baserow
changelog/entries/unreleased/feature
web-frontend
modules
test/unit
builder/components/elements/components/__snapshots__
core/components/__snapshots__
database/components
export/__snapshots__
view/__snapshots__

View file

@ -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},
}

View file

@ -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
)

View file

@ -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

View file

@ -429,7 +429,7 @@ class LocalBaserowTableServiceType(LocalBaserowServiceType):
"id": {
"type": "number",
"title": "Id",
"sortable": True,
"sortable": False,
"filterable": False,
"searchable": False,
}

View file

@ -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,
},

View file

@ -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"
}

View file

@ -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>

View file

@ -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>

View file

@ -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))
},

View file

@ -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,

View file

@ -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: {
/**

View file

@ -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>

View file

@ -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

View file

@ -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.

View file

@ -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.",

View file

@ -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,
})

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -34,3 +34,4 @@
@import 'padding_selector';
@import 'page';
@import 'data_source_item';
@import 'collection_element_header';

View file

@ -0,0 +1,4 @@
.element--read-only .collection-element__header {
pointer-events: none;
user-select: none;
}

View file

@ -1 +1,2 @@
@import 'local_baserow/local_baserow_form';
@import 'local_baserow/local_baserow_adhoc_header';

View file

@ -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;
}
}
}

View file

@ -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
},

View file

@ -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()

View file

@ -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

View file

@ -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',

View file

@ -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

View file

@ -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
]
},
},
}

View file

@ -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.

View file

@ -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>

View file

@ -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}`
}

View file

@ -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

View file

@ -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"
>

View file

@ -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

View file

@ -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

View file

@ -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