1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-14 17:18:33 +00:00

Merge branch '704-gallery-view-search' into 'develop'

Resolve "Make it possible to search in the gallery view"

Closes 

See merge request 
This commit is contained in:
Petr Stribny 2022-02-21 18:33:27 +00:00
commit 7f5dc1c390
28 changed files with 647 additions and 88 deletions
backend
src/baserow/contrib/database
api/views/gallery
views
tests/baserow/contrib/database/api/views/gallery
changelog.md
premium/web-frontend/modules/baserow_premium
web-frontend

View file

@ -120,8 +120,11 @@ class GalleryViewView(APIView):
view.table.database.group.has_user(
request.user, raise_error=True, allow_if_template=True
)
search = request.GET.get("search")
model = view.table.get_model()
queryset = view_handler.get_queryset(view, None, model)
queryset = view_handler.get_queryset(view, search, model)
if "count" in request.GET:
return Response({"count": queryset.count()})

View file

@ -240,7 +240,7 @@ class GridView(View):
class GridViewFieldOptions(ParentFieldTrashableModelMixin, models.Model):
grid_view = models.ForeignKey(GridView, on_delete=models.CASCADE)
field = models.ForeignKey(Field, on_delete=models.CASCADE)
# The defaults should be the same as in the `fieldCreated` of the `GridViewType`
# The defaults should match the ones in `afterFieldCreated` of the `GridViewType`
# abstraction in the web-frontend.
width = models.PositiveIntegerField(
default=200,

View file

@ -150,6 +150,43 @@ def test_list_rows_include_field_options(api_client, data_fixture):
assert "filters_disabled" not in response_json
@pytest.mark.django_db
def test_list_rows_search(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(
email="test@test.nl", password="password", first_name="Test1"
)
table = data_fixture.create_database_table(user=user)
text_field = data_fixture.create_text_field(
table=table, order=0, name="Name", text_default=""
)
gallery = data_fixture.create_gallery_view(table=table)
search_term = "Smith"
model = gallery.table.get_model()
not_matching_row1 = model.objects.create(
**{f"field_{text_field.id}": "Mark Spencer"}
)
matching_row1 = model.objects.create(
**{f"field_{text_field.id}": f"Elon {search_term}"}
)
matching_row2 = model.objects.create(
**{f"field_{text_field.id}": f"James {search_term}"}
)
not_matching_row2 = model.objects.create(
**{f"field_{text_field.id}": "Robin Backham"}
)
url = reverse("api:database:views:gallery:list", kwargs={"view_id": gallery.id})
response = api_client.get(
url, {"search": search_term}, **{"HTTP_AUTHORIZATION": f"JWT {token}"}
)
response_json = response.json()
assert response.status_code == HTTP_200_OK
assert response_json["count"] == 2
assert response_json["results"][0]["id"] == matching_row1.id
assert response_json["results"][1]["id"] == matching_row2.id
@pytest.mark.django_db
def test_patch_gallery_view_field_options(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(

View file

@ -14,6 +14,8 @@
* Fix missing translation when importing empty CSV
* Fixed OpenAPI spec. The specification is now valid and can be used for imports to other
tools, e.g. to various REST clients.
* Added search to gallery views.
* Views supporting search are properly updated when a column with a matching default value is added.
## Released (2022-01-13 1.8.2)

View file

@ -84,7 +84,7 @@
@update="updateValue"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
@refresh="$emit('refresh', $event)"
@field-created="fieldCreated"
></RowEditModal>
</div>
</template>
@ -93,6 +93,7 @@
import { mapGetters } from 'vuex'
import { clone } from '@baserow/modules/core/utils/object'
import { notifyIf } from '@baserow/modules/core/utils/error'
import viewHelpers from '@baserow/modules/database/mixins/viewHelpers'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import RowCreateModal from '@baserow/modules/database/components/row/RowCreateModal'
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
@ -110,7 +111,7 @@ export default {
KanbanViewStackedBy,
KanbanViewStack,
},
mixins: [kanbanViewHelper],
mixins: [viewHelpers, kanbanViewHelper],
props: {
database: {
type: Object,

View file

@ -29,6 +29,7 @@
ref="createFieldContext"
:table="table"
:forced-type="forcedFieldType"
@field-created="$event.callback()"
></CreateFieldContext>
</div>
</div>

View file

@ -159,7 +159,13 @@ export class KanbanViewType extends PremiumViewType {
}
}
async fieldCreated({ dispatch }, table, field, fieldType, storePrefix = '') {
async afterFieldCreated(
{ dispatch },
table,
field,
fieldType,
storePrefix = ''
) {
await dispatch(
storePrefix + 'view/kanban/setFieldOptionsOfField',
{
@ -175,7 +181,7 @@ export class KanbanViewType extends PremiumViewType {
)
}
fieldUpdated(context, field, oldField, fieldType, storePrefix) {
afterFieldUpdated(context, field, oldField, fieldType, storePrefix) {
// Make sure that all Kanban views don't depend on fields that
// have been converted to another type
const type = SingleSelectFieldType.getType()
@ -185,7 +191,7 @@ export class KanbanViewType extends PremiumViewType {
}
}
fieldDeleted(context, field, fieldType, storePrefix = '') {
afterFieldDeleted(context, field, fieldType, storePrefix = '') {
// Make sure that all Kanban views don't depend on fields that
// have been deleted
this._setFieldToNull(context, field, 'single_select_field')

View file

@ -52,7 +52,7 @@ export default {
delete values.type
try {
const { forceCreateCallback, refreshNeeded } =
const { forceCreateCallback, fetchNeeded, newField } =
await this.$store.dispatch('field/create', {
type,
values,
@ -66,11 +66,7 @@ export default {
this.$refs.form.reset()
this.hide()
}
if (refreshNeeded) {
this.$emit('refresh', { callback })
} else {
await callback()
}
this.$emit('field-created', { callback, newField, fetchNeeded })
} catch (error) {
this.loading = false
const handledByForm = this.$refs.form.handleErrorByForm(error)

View file

@ -34,7 +34,7 @@
<CreateFieldContext
ref="createFieldContext"
:table="table"
@refresh="$emit('refresh', $event)"
@field-created="$emit('field-created', $event)"
></CreateFieldContext>
</div>
</template>

View file

@ -310,13 +310,18 @@ export default {
const includeFieldOptions =
typeof event === 'object' ? event.includeFieldOptions : false
const fieldsToRefresh =
typeof event === 'object' && event.newField
? [...this.fields, event.newField]
: this.fields
this.viewLoading = true
const type = this.$registry.get('view', this.view.type)
try {
await type.refresh(
{ store: this.$store },
this.view,
this.fields,
fieldsToRefresh,
this.primary,
this.storePrefix,
includeFieldOptions

View file

@ -17,6 +17,7 @@
:fields="fields"
:primary="primary"
:store-prefix="storePrefix"
:always-hide-rows-not-matching-search="alwaysHideRowsNotMatchingSearch"
@refresh="$emit('refresh', $event)"
@search-changed="searchChanged"
></ViewSearchContext>
@ -46,6 +47,11 @@ export default {
type: String,
required: true,
},
alwaysHideRowsNotMatchingSearch: {
type: Boolean,
required: false,
default: false,
},
},
data: () => {
return {

View file

@ -22,7 +22,10 @@
</div>
</div>
</div>
<div class="control control--align-right margin-bottom-0">
<div
v-if="!alwaysHideRowsNotMatchingSearch"
class="control control--align-right margin-bottom-0"
>
<SwitchInput
v-model="hideRowsNotMatchingSearch"
@input="searchIfChanged"
@ -59,6 +62,11 @@ export default {
type: String,
required: true,
},
alwaysHideRowsNotMatchingSearch: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
@ -103,15 +111,18 @@ export default {
}
},
debouncedServerSearchRefresh: debounce(async function () {
await this.$store.dispatch(this.storePrefix + 'view/grid/updateSearch', {
activeSearchTerm: this.activeSearchTerm,
hideRowsNotMatchingSearch: this.hideRowsNotMatchingSearch,
// The refresh event we fire below will cause the table to refresh it state from
// the server using the newly set search terms.
refreshMatchesOnClient: false,
fields: this.fields,
primary: this.primary,
})
await this.$store.dispatch(
`${this.storePrefix}view/${this.view.type}/updateSearch`,
{
activeSearchTerm: this.activeSearchTerm,
hideRowsNotMatchingSearch: this.hideRowsNotMatchingSearch,
// The refresh event we fire below will cause the table to refresh it state from
// the server using the newly set search terms.
refreshMatchesOnClient: false,
fields: this.fields,
primary: this.primary,
}
)
this.$emit('refresh', {
callback: this.finishedLoading,
})
@ -119,13 +130,16 @@ export default {
// Debounce even the client side only refreshes as otherwise spamming the keyboard
// can cause many refreshes to queue up quickly bogging down the UI.
debouncedClientSideSearchRefresh: debounce(async function () {
await this.$store.dispatch(this.storePrefix + 'view/grid/updateSearch', {
activeSearchTerm: this.activeSearchTerm,
hideRowsNotMatchingSearch: this.hideRowsNotMatchingSearch,
refreshMatchesOnClient: true,
fields: this.fields,
primary: this.primary,
})
await this.$store.dispatch(
`${this.storePrefix}view/${this.view.type}/updateSearch`,
{
activeSearchTerm: this.activeSearchTerm,
hideRowsNotMatchingSearch: this.hideRowsNotMatchingSearch,
refreshMatchesOnClient: true,
fields: this.fields,
primary: this.primary,
}
)
this.finishedLoading()
}, 10),
finishedLoading() {

View file

@ -63,7 +63,7 @@
<CreateFieldContext
ref="createFieldContext"
:table="table"
@refresh="$emit('refresh', $event)"
@field-created="$event.callback()"
></CreateFieldContext>
</div>
</div>

View file

@ -71,6 +71,7 @@
@update="updateValue"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
@field-created="fieldCreated"
></RowEditModal>
</div>
</template>
@ -91,11 +92,12 @@ import RowCard from '@baserow/modules/database/components/card/RowCard'
import RowCreateModal from '@baserow/modules/database/components/row/RowCreateModal'
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import bufferedRowsDragAndDrop from '@baserow/modules/database/mixins/bufferedRowsDragAndDrop'
import viewHelpers from '@baserow/modules/database/mixins/viewHelpers'
export default {
name: 'GalleryView',
components: { RowCard, RowCreateModal, RowEditModal },
mixins: [bufferedRowsDragAndDrop],
mixins: [viewHelpers, bufferedRowsDragAndDrop],
props: {
primary: {
type: Object,

View file

@ -31,6 +31,16 @@
@update-cover-image-field="updateCoverImageField"
></ViewFieldsContext>
</li>
<li class="header__filter-item header__filter-item--right">
<ViewSearch
:view="view"
:fields="fields"
:primary="primary"
:store-prefix="storePrefix"
:always-hide-rows-not-matching-search="true"
@refresh="$emit('refresh', $event)"
></ViewSearch>
</li>
</ul>
</template>
@ -39,10 +49,11 @@ import { mapGetters, mapState } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
import ViewFieldsContext from '@baserow/modules/database/components/view/ViewFieldsContext'
import ViewSearch from '@baserow/modules/database/components/view/ViewSearch'
export default {
name: 'GalleryViewHeader',
components: { ViewFieldsContext },
components: { ViewFieldsContext, ViewSearch },
props: {
database: {
type: Object,

View file

@ -20,6 +20,7 @@
:store-prefix="storePrefix"
:style="{ width: leftWidth + 'px' }"
@refresh="$emit('refresh', $event)"
@field-created="fieldCreated"
@row-hover="setRowHover($event.row, $event.value)"
@row-context="showRowContext($event.event, $event.row)"
@row-dragging="rowDragStart"
@ -65,6 +66,7 @@
:store-prefix="storePrefix"
:style="{ left: leftWidth + 'px' }"
@refresh="$emit('refresh', $event)"
@field-created="fieldCreated"
@row-hover="setRowHover($event.row, $event.value)"
@row-context="showRowContext($event.event, $event.row)"
@add-row="addRow()"
@ -144,11 +146,11 @@
:fields="fields"
:rows="allRows"
:read-only="readOnly"
@refresh="$emit('refresh', $event)"
@update="updateValue"
@hidden="rowEditModalHidden"
@field-updated="$emit('refresh', $event)"
@field-deleted="$emit('refresh')"
@field-created="fieldCreated"
></RowEditModal>
</div>
</template>
@ -163,6 +165,7 @@ import GridViewRowDragging from '@baserow/modules/database/components/view/grid/
import RowEditModal from '@baserow/modules/database/components/row/RowEditModal'
import gridViewHelpers from '@baserow/modules/database/mixins/gridViewHelpers'
import { maxPossibleOrderValue } from '@baserow/modules/database/viewTypes'
import viewHelpers from '@baserow/modules/database/mixins/viewHelpers'
export default {
name: 'GridView',
@ -172,7 +175,7 @@ export default {
GridViewRowDragging,
RowEditModal,
},
mixins: [gridViewHelpers],
mixins: [viewHelpers, gridViewHelpers],
props: {
primary: {
type: Object,

View file

@ -33,7 +33,7 @@
<CreateFieldContext
ref="createFieldContext"
:table="table"
@refresh="$emit('refresh', $event)"
@field-created="$emit('field-created', $event)"
></CreateFieldContext>
</div>
</div>

View file

@ -10,6 +10,7 @@
:include-add-field="includeAddField"
:read-only="readOnly"
:store-prefix="storePrefix"
@field-created="$emit('field-created', $event)"
@refresh="$emit('refresh', $event)"
@dragging="
canOrderFields &&

View file

@ -466,11 +466,11 @@ export class FieldType extends Registerable {
}
/**
* Determines whether a view refresh should be executed after the specific field
* has been added to a table. This is for example needed when a value depends on
* the backend and can't be guessed or calculated by the web-frontend.
* Determines whether row data of the field should be fetched again after the
* field has been created. This is for example needed when a value depends on the
* backend and can't be guessed or calculated by the web-frontend.
*/
shouldRefreshWhenAdded() {
shouldFetchDataWhenAdded() {
return false
}
@ -1268,7 +1268,7 @@ export class CreatedOnLastModifiedBaseFieldType extends BaseDateFieldType {
return moment().utc().format()
}
shouldRefreshWhenAdded() {
shouldFetchDataWhenAdded() {
return true
}
@ -2118,7 +2118,7 @@ export class FormulaFieldType extends FieldType {
return true
}
shouldRefreshWhenAdded() {
shouldFetchDataWhenAdded() {
return true
}

View file

@ -0,0 +1,26 @@
export default {
methods: {
/**
* Must be called when a new field is created. It emits the refresh event when
* needed. It expects the event parameter propagated from the
* `CreateFieldContext` component.
*/
fieldCreated({ fetchNeeded, ...context }) {
const viewType = this.$registry.get('view', this.view.type)
if (
fetchNeeded ||
viewType.shouldRefreshWhenFieldCreated(
this.$registry,
this.$store,
context.newField,
this.storePrefix
)
) {
this.$emit('refresh', context)
} else if (context.callback) {
context.callback()
}
},
},
}

View file

@ -1,5 +1,5 @@
import { clone } from '@baserow/modules/core/utils/object'
import { anyFieldsNeedRefresh } from '@baserow/modules/database/store/field'
import { anyFieldsNeedFetch } from '@baserow/modules/database/store/field'
/**
* Registers the real time events related to the database module. When a message comes
@ -58,17 +58,30 @@ export const registerRealtimeEvents = (realtime) => {
relatedFields,
})
}
const view = store.getters['view/getSelected']
const viewMustBeRefreshed =
view &&
app.$registry
.get('view', view.type)
.shouldRefreshWhenFieldCreated(
app.$registry,
store,
data.field,
'page/'
)
if (
!fieldType.shouldRefreshWhenAdded() &&
!anyFieldsNeedRefresh(relatedFields, registry)
fieldType.shouldFetchDataWhenAdded() ||
anyFieldsNeedFetch(relatedFields, registry) ||
viewMustBeRefreshed
) {
callback()
} else {
app.$bus.$emit('table-refresh', {
tableId: store.getters['table/getSelectedId'],
newField: data.field,
includeFieldOptions: true,
callback,
})
} else {
callback()
}
}
})

View file

@ -135,15 +135,16 @@ export const actions = {
}
const fieldType = this.$registry.get('field', type)
const refreshNeeded =
fieldType.shouldRefreshWhenAdded() ||
anyFieldsNeedRefresh(data.related_fields, this.$registry)
const fetchNeeded =
fieldType.shouldFetchDataWhenAdded() ||
anyFieldsNeedFetch(data.related_fields, this.$registry)
const callback = forceCreate
? await forceCreateCallback()
: forceCreateCallback
return {
forceCreateCallback: callback,
refreshNeeded,
fetchNeeded,
newField: data,
}
},
/**
@ -186,7 +187,7 @@ export const actions = {
// need to change things in loaded data. For example the grid field will add the
// field to all of the rows that are in memory.
for (const viewType of Object.values(this.$registry.getAll('view'))) {
await viewType.fieldCreated(context, table, data, fieldType, 'page/')
await viewType.afterFieldCreated(context, table, data, fieldType, 'page/')
}
await dispatch('forceUpdateFields', {
@ -251,7 +252,13 @@ export const actions = {
// Call the field updated event on all the registered views because they might
// need to change things in loaded data. For example the changed rows.
for (const viewType of Object.values(this.$registry.getAll('view'))) {
await viewType.fieldUpdated(context, data, oldField, fieldType, 'page/')
await viewType.afterFieldUpdated(
context,
data,
oldField,
fieldType,
'page/'
)
}
await dispatch('forceUpdateFields', {
@ -315,7 +322,7 @@ export const actions = {
// field options of that field.
const fieldType = this.$registry.get('field', field.type)
for (const viewType of Object.values(this.$registry.getAll('view'))) {
await viewType.fieldDeleted(context, field, fieldType, 'page/')
await viewType.afterFieldDeleted(context, field, fieldType, 'page/')
}
},
}
@ -351,9 +358,9 @@ export default {
mutations,
}
export function anyFieldsNeedRefresh(fields, registry) {
export function anyFieldsNeedFetch(fields, registry) {
return fields.some((f) => {
const relatedFieldType = registry.get('field', f.type)
return relatedFieldType.shouldRefreshWhenAdded()
return relatedFieldType.shouldFetchDataWhenAdded()
})
}

View file

@ -5,6 +5,7 @@ import { clone } from '@baserow/modules/core/utils/object'
import {
getRowSortFunction,
matchSearchFilters,
calculateSingleRowSearchMatches,
} from '@baserow/modules/database/utils/view'
import RowService from '@baserow/modules/database/services/row'
@ -39,9 +40,22 @@ import RowService from '@baserow/modules/database/services/row'
* ]
* ```
*/
export default ({ service, populateRow }) => {
export default ({ service, customPopulateRow }) => {
let lastRequestSource = null
const populateRow = (row) => {
if (customPopulateRow) {
customPopulateRow(row)
}
row._ ??= {}
// Matching rows for front-end only search is not yet properly
// supported and tested in this store mixin. Only server-side search
// implementation is finished.
row._.matchSearch = true
row._.fieldSearchMatches = []
return row
}
/**
* This helper function calculates the most optimal `limit` `offset` range of rows
* that must be fetched. Based on the provided visible `startIndex` and `endIndex`
@ -130,6 +144,7 @@ export default ({ service, populateRow }) => {
// This is needed to revert the position if anything goes wrong or the escape
// key was pressed.
draggingOriginalBefore: null,
activeSearchTerm: '',
})
const mutations = {
@ -207,6 +222,23 @@ export default ({ service, populateRow }) => {
state.draggingRow = null
state.draggingOriginalBefore = null
},
SET_SEARCH(state, { activeSearchTerm }) {
state.activeSearchTerm = activeSearchTerm
},
SET_ROW_SEARCH_MATCHES(state, { row, matchSearch, fieldSearchMatches }) {
row._.fieldSearchMatches.slice(0).forEach((value) => {
if (!fieldSearchMatches.has(value)) {
const index = row._.fieldSearchMatches.indexOf(value)
row._.fieldSearchMatches.splice(index, 1)
}
})
fieldSearchMatches.forEach((value) => {
if (!row._.fieldSearchMatches.includes(value)) {
row._.fieldSearchMatches.push(value)
}
})
row._.matchSearch = matchSearch
},
}
const actions = {
@ -221,10 +253,14 @@ export default ({ service, populateRow }) => {
) {
const { commit, getters } = context
commit('SET_VIEW_ID', viewId)
commit('SET_SEARCH', {
activeSearchTerm: '',
})
const { data } = await service(this.$client).fetchRows({
viewId,
offset: 0,
limit: getters.getRequestSize,
search: getters.getServerSearchTerm,
...initialRowArguments,
})
const rows = Array(data.count).fill(null)
@ -293,6 +329,7 @@ export default ({ service, populateRow }) => {
offset: rangeToFetch.offset,
limit: rangeToFetch.limit,
cancelToken: lastRequestSource.token,
search: getters.getServerSearchTerm,
})
commit('UPDATE_ROWS', {
offset: rangeToFetch.offset,
@ -345,6 +382,7 @@ export default ({ service, populateRow }) => {
} = await service(this.$client).fetchCount({
viewId: getters.getViewId,
cancelToken: lastRequestSource.token,
search: getters.getServerSearchTerm,
})
// Create a new empty array containing un-fetched rows.
@ -359,9 +397,9 @@ export default ({ service, populateRow }) => {
startIndex = currentVisible.startIndex
endIndex = currentVisible.endIndex
const difference = count - endIndex
if (difference < 0) {
startIndex += difference
startIndex = startIndex >= 0 ? startIndex : 0
endIndex += difference
}
@ -384,6 +422,7 @@ export default ({ service, populateRow }) => {
limit: rangeToFetch.limit,
includeFieldOptions,
cancelToken: lastRequestSource.token,
search: getters.getServerSearchTerm,
})
results.forEach((row, index) => {
@ -570,11 +609,19 @@ export default ({ service, populateRow }) => {
let row = clone(values)
populateRow(row)
// Check if the row matches the filters. If not, we don't have to do anything
// because we know this row does not exist in the view.
if (
!(await dispatch('rowMatchesFilters', { view, fields, primary, row }))
) {
const rowMatchesFilters = await dispatch('rowMatchesFilters', {
view,
fields,
primary,
row,
})
await dispatch('updateSearchMatchesForRow', {
view,
fields,
primary,
row,
})
if (!rowMatchesFilters || !row._.matchSearch) {
return
}
@ -679,18 +726,33 @@ export default ({ service, populateRow }) => {
populateRow(oldRow)
populateRow(newRow)
const oldRowMatches = await dispatch('rowMatchesFilters', {
const oldMatchesFilters = await dispatch('rowMatchesFilters', {
view,
fields,
primary,
row: oldRow,
})
const newRowMatches = await dispatch('rowMatchesFilters', {
const newMatchesFilters = await dispatch('rowMatchesFilters', {
view,
fields,
primary,
row: newRow,
})
await dispatch('updateSearchMatchesForRow', {
view,
fields,
primary,
row: oldRow,
})
await dispatch('updateSearchMatchesForRow', {
view,
fields,
primary,
row: newRow,
})
const oldRowMatches = oldMatchesFilters && oldRow._.matchSearch
const newRowMatches = newMatchesFilters && newRow._.matchSearch
if (oldRowMatches && !newRowMatches) {
// If the old row did match the filters, but after the update it does not
@ -773,11 +835,19 @@ export default ({ service, populateRow }) => {
row = clone(row)
populateRow(row)
// Check if the row matches the filters. If not, we don't have to do anything
// because we know this row does not exist in the view.
if (
!(await dispatch('rowMatchesFilters', { view, fields, primary, row }))
) {
const rowMatchesFilters = await dispatch('rowMatchesFilters', {
view,
fields,
primary,
row,
})
await dispatch('updateSearchMatchesForRow', {
view,
fields,
primary,
row,
})
if (!rowMatchesFilters || !row._.matchSearch) {
return
}
@ -876,6 +946,55 @@ export default ({ service, populateRow }) => {
return false
},
/**
* Changes the current search parameters if provided and optionally refreshes which
* cells match the new search parameters by updating every rows row._.matchSearch and
* row._.fieldSearchMatches attributes.
*/
updateSearch(
{ commit, dispatch, getters, state },
{
fields,
primary = null,
activeSearchTerm = state.activeSearchTerm,
refreshMatchesOnClient = true,
}
) {
commit('SET_SEARCH', { activeSearchTerm })
if (refreshMatchesOnClient) {
getters.getRows.forEach((row) =>
dispatch('updateSearchMatchesForRow', {
row,
fields,
primary,
forced: true,
})
)
}
},
/**
* Updates a single row's row._.matchSearch and row._.fieldSearchMatches based on the
* current search parameters and row data. Overrides can be provided which can be used
* to override a row's field values when checking if they match the search parameters.
*/
updateSearchMatchesForRow(
{ commit, getters, rootGetters },
{ row, fields, primary = null, overrides, forced = false }
) {
// Avoid computing search on table loading
if (getters.getActiveSearchTerm || forced) {
const rowSearchMatches = calculateSingleRowSearchMatches(
row,
getters.getActiveSearchTerm,
getters.isHidingRowsNotMatchingSearch,
[primary, ...fields],
this.$registry,
overrides
)
commit('SET_ROW_SEARCH_MATCHES', rowSearchMatches)
}
},
}
const getters = {
@ -903,6 +1022,15 @@ export default ({ service, populateRow }) => {
getDraggingOriginalBefore(state) {
return state.draggingOriginalBefore
},
getActiveSearchTerm(state) {
return state.activeSearchTerm
},
getServerSearchTerm(state) {
return state.activeSearchTerm
},
isHidingRowsNotMatchingSearch(state) {
return true
},
}
return {

View file

@ -83,6 +83,17 @@ export const matchSearchFilters = (
}
}
export function valueMatchesActiveSearchTerm(
registry,
field,
value,
activeSearchTerm
) {
return registry
.get('field', field.type)
.containsFilter(value, activeSearchTerm, field)
}
function _findFieldsInRowMatchingSearch(
row,
activeSearchTerm,
@ -102,9 +113,12 @@ function _findFieldsInRowMatchingSearch(
const rowValue =
fieldName in overrides ? overrides[fieldName] : row[fieldName]
if (rowValue) {
const doesMatch = registry
.get('field', field.type)
.containsFilter(rowValue, activeSearchTerm, field)
const doesMatch = valueMatchesActiveSearchTerm(
registry,
field,
rowValue,
activeSearchTerm
)
if (doesMatch) {
fieldSearchMatches.add(field.id.toString())
}
@ -144,3 +158,25 @@ export function calculateSingleRowSearchMatches(
!hideRowsNotMatchingSearch || searchIsBlank || fieldSearchMatches.size > 0
return { row, matchSearch, fieldSearchMatches }
}
/**
* Returns true is the empty value of the provided field matches the active search term.
*/
export function newFieldMatchesActiveSearchTerm(
registry,
newField,
activeSearchTerm
) {
if (newField && activeSearchTerm !== '') {
const fieldType = registry.get('field', newField.type)
const emptyValue = fieldType.getEmptyValue(newField)
return valueMatchesActiveSearchTerm(
registry,
newField,
emptyValue,
activeSearchTerm
)
}
return false
}

View file

@ -7,6 +7,7 @@ import GalleryViewHeader from '@baserow/modules/database/components/view/gallery
import FormView from '@baserow/modules/database/components/view/form/FormView'
import FormViewHeader from '@baserow/modules/database/components/view/form/FormViewHeader'
import { FileFieldType } from '@baserow/modules/database/fieldTypes'
import { newFieldMatchesActiveSearchTerm } from '@baserow/modules/database/utils/view'
export const maxPossibleOrderValue = 32767
@ -174,11 +175,20 @@ export class ViewType extends Registerable {
includeFieldOptions = false
) {}
/**
* Should return true if the view must be refreshed when a new field has been
* created. This could for example be used to check whether the empty value matches
* the active search term.
*/
shouldRefreshWhenFieldCreated(registry, store, field, storePrefix) {
return false
}
/**
* Method that is called when a field has been created. This can be useful to
* maintain data integrity for example to add the field to the grid view store.
*/
fieldCreated(context, table, field, fieldType, storePrefix) {}
afterFieldCreated(context, table, field, fieldType, storePrefix) {}
/**
* Method that is called when a field has been restored . This can be useful to
@ -190,13 +200,13 @@ export class ViewType extends Registerable {
* Method that is called when a field has been deleted. This can be useful to
* maintain data integrity.
*/
fieldDeleted(context, field, fieldType, storePrefix) {}
afterFieldDeleted(context, field, fieldType, storePrefix) {}
/**
* Method that is called when a field has been changed. This can be useful to
* maintain data integrity by updating the values.
*/
fieldUpdated(context, field, oldField, fieldType, storePrefix) {}
afterFieldUpdated(context, field, oldField, fieldType, storePrefix) {}
/**
* Method that is called when the field options of a view are updated.
@ -387,7 +397,19 @@ export class GridViewType extends ViewType {
)
}
async fieldCreated({ dispatch }, table, field, fieldType, storePrefix = '') {
shouldRefreshWhenFieldCreated(registry, store, field, storePrefix) {
const searchTerm =
store.getters[storePrefix + 'view/grid/getActiveSearchTerm']
return newFieldMatchesActiveSearchTerm(registry, field, searchTerm)
}
async afterFieldCreated(
{ dispatch },
table,
field,
fieldType,
storePrefix = ''
) {
const value = fieldType.getEmptyValue(field)
await dispatch(
storePrefix + 'view/grid/addField',
@ -410,7 +432,7 @@ export class GridViewType extends ViewType {
)
}
async fieldDeleted({ dispatch }, field, fieldType, storePrefix = '') {
async afterFieldDeleted({ dispatch }, field, fieldType, storePrefix = '') {
await dispatch(
storePrefix + 'view/grid/forceDeleteFieldOptions',
field.id,
@ -420,7 +442,7 @@ export class GridViewType extends ViewType {
)
}
async fieldUpdated(
async afterFieldUpdated(
{ dispatch, rootGetters },
field,
oldField,
@ -590,7 +612,21 @@ class BaseBufferedRowView extends ViewType {
)
}
async fieldCreated({ dispatch }, table, field, fieldType, storePrefix = '') {
shouldRefreshWhenFieldCreated(registry, store, field, storePrefix) {
const searchTerm =
store.getters[
storePrefix + 'view/' + this.getType() + '/getActiveSearchTerm'
]
return newFieldMatchesActiveSearchTerm(registry, field, searchTerm)
}
async afterFieldCreated(
{ dispatch },
table,
field,
fieldType,
storePrefix = ''
) {
const value = fieldType.getEmptyValue(field)
await dispatch(
storePrefix + 'view/' + this.getType() + '/addField',
@ -607,7 +643,7 @@ class BaseBufferedRowView extends ViewType {
)
}
async fieldDeleted({ dispatch }, field, fieldType, storePrefix = '') {
async afterFieldDeleted({ dispatch }, field, fieldType, storePrefix = '') {
await dispatch(
storePrefix + 'view/' + this.getType() + '/forceDeleteFieldOptions',
field.id,
@ -723,17 +759,41 @@ export class GalleryViewType extends BaseBufferedRowView {
}
}
fieldUpdated(context, field, oldField, fieldType, storePrefix) {
async afterFieldUpdated(
{ dispatch, rootGetters },
field,
oldField,
fieldType,
storePrefix
) {
// If the field type has changed from a file field to something else, it could
// be that there are gallery views that depending on that field. So we need to
// change to type to null if that's the case.
const type = FileFieldType.getType()
if (oldField.type === type && field.type !== type) {
this._setFieldToNull(context, field, 'card_cover_image_field')
this._setFieldToNull(
{ dispatch, rootGetters },
field,
'card_cover_image_field'
)
}
// The field changing may change which cells in the field should be highlighted so
// we refresh them to ensure that they still correctly match. E.g. changing a date
// fields date_format needs a search update as search string might no longer
// match the new format.
await dispatch(
storePrefix + 'view/gallery/updateSearch',
{
fields: rootGetters['field/getAll'],
primary: rootGetters['field/getPrimary'],
},
{
root: true,
}
)
}
fieldDeleted(context, field, fieldType, storePrefix = '') {
afterFieldDeleted(context, field, fieldType, storePrefix = '') {
// We want to loop over all gallery views that we have in the store and check if
// they were depending on this deleted field. If that's case, we can set it to null
// because it doesn't exist anymore.
@ -801,7 +861,13 @@ export class FormViewType extends ViewType {
})
}
async fieldCreated({ dispatch }, table, field, fieldType, storePrefix = '') {
async afterFieldCreated(
{ dispatch },
table,
field,
fieldType,
storePrefix = ''
) {
await dispatch(
storePrefix + 'view/form/setFieldOptionsOfField',
{
@ -820,7 +886,7 @@ export class FormViewType extends ViewType {
)
}
async fieldDeleted({ dispatch }, field, fieldType, storePrefix = '') {
async afterFieldDeleted({ dispatch }, field, fieldType, storePrefix = '') {
await dispatch(
storePrefix + 'view/form/forceDeleteFieldOptions',
field.id,

View file

@ -23,3 +23,17 @@ export function createFields(mock, application, table, fields) {
mock.onGet(`/database/fields/table/${table.id}/`).reply(200, fieldsWithIds)
return fieldsWithIds
}
export function createPrimaryField(data) {
const primaryField = {
id: 1,
name: 'Primary field',
type: 'text',
primary: true,
}
return {
...primaryField,
...data,
}
}

View file

@ -1,5 +1,19 @@
import { PUBLIC_PLACEHOLDER_ENTITY_ID } from '@baserow/modules/database/utils/constants'
export function createView(data) {
const view = {
id: 1,
filters_disabled: false,
filters: [],
sortings: [],
}
return {
...view,
...data,
}
}
export function createPublicGridView(
mock,
viewSlug,

View file

@ -1,6 +1,8 @@
import bufferedRows from '@baserow/modules/database/store/view/bufferedRows'
import { TestApp } from '@baserow/test/helpers/testApp'
import { ContainsViewFilterType } from '@baserow/modules/database/viewFilters'
import { createPrimaryField } from '@baserow/test/fixtures/fields'
import { createView } from '@baserow/test/fixtures/view'
describe('Buffered rows view store helper', () => {
let testApp = null
@ -1995,3 +1997,168 @@ describe('Buffered rows view store helper', () => {
expect(rowsInStore[11]).toBe(null)
})
})
describe('Buffered rows search', () => {
let testApp = null
let store = null
let bufferedRowsModule = null
let view = null
const storeName = 'test'
const activeSearchTerm = 'searchterm'
beforeEach(() => {
testApp = new TestApp()
store = testApp.store
bufferedRowsModule = bufferedRows({ service: null, populateRow: null })
view = createView()
})
afterEach(() => {
testApp.afterEach()
})
test('Rows are fetched on refresh based on search term', async () => {
const serviceStub = () => {
return {
fetchRows(params) {
if (params.search === activeSearchTerm) {
return {
data: {
results: [
{
id: 1,
order: '1.00000000000000000000',
field_1: 'Row matching search',
},
],
},
}
}
return {
data: {
results: [
{
id: 1,
order: '1.00000000000000000000',
field_1: 'Row matching search',
},
{
id: 2,
order: '2.00000000000000000000',
field_1: 'Row not search',
},
],
},
}
},
fetchCount(params) {
if (params.search === activeSearchTerm) {
return { data: { count: 1 } }
}
return { data: { count: 2 } }
},
}
}
bufferedRowsModule = bufferedRows({
service: serviceStub,
populateRow: null,
})
const state = Object.assign(bufferedRowsModule.state(), {
viewId: view.id,
rows: [],
activeSearchTerm,
})
bufferedRowsModule.state = () => state
store.registerModule(storeName, bufferedRowsModule)
await store.dispatch(`${storeName}/refresh`, {
fields: [],
primary: createPrimaryField(),
})
const rowsInStore = store.getters[`${storeName}/getRows`]
expect(rowsInStore.length).toBe(1)
})
test('A new row matching search has been added', async () => {
const state = Object.assign(bufferedRowsModule.state(), {
viewId: view.id,
rows: [{ id: 2, order: '2.00000000000000000000', field_1: 'Row 2' }],
activeSearchTerm,
})
bufferedRowsModule.state = () => state
store.registerModule(storeName, bufferedRowsModule)
const newMatchingRow = {
id: 1,
order: '1.00000000000000000000',
field_1: `matching the ${activeSearchTerm}`,
}
await store.dispatch(`${storeName}/afterNewRowCreated`, {
view,
fields: [],
primary: createPrimaryField(),
values: newMatchingRow,
})
const rowsInStore = store.getters[`${storeName}/getRows`]
expect(rowsInStore[0].id).toBe(newMatchingRow.id)
})
test('A new row not matching search has not been added', async () => {
const state = Object.assign(bufferedRowsModule.state(), {
viewId: view.id,
rows: [{ id: 2, order: '2.00000000000000000000', field_1: 'Row 2' }],
activeSearchTerm,
})
bufferedRowsModule.state = () => state
store.registerModule(storeName, bufferedRowsModule)
const newNotMatchingRow = {
id: 1,
order: '1.00000000000000000000',
field_1: `not matching`,
}
await store.dispatch(`${storeName}/afterNewRowCreated`, {
view,
fields: [],
primary: createPrimaryField(),
values: newNotMatchingRow,
})
const rowsInStore = store.getters[`${storeName}/getRows`]
expect(rowsInStore[0].id).not.toBe(newNotMatchingRow.id)
})
test('A row not matching search anymore has been removed', async () => {
const matchingRow = {
id: 2,
order: '2.00000000000000000000',
field_1: `matching the ${activeSearchTerm}`,
}
const state = Object.assign(bufferedRowsModule.state(), {
viewId: view.id,
rows: [matchingRow],
activeSearchTerm,
})
bufferedRowsModule.state = () => state
store.registerModule(storeName, bufferedRowsModule)
const newValues = {
field_1: 'not matching',
}
await store.dispatch(`${storeName}/afterExistingRowUpdated`, {
view,
fields: [],
primary: createPrimaryField(),
row: matchingRow,
values: newValues,
})
const rowsInStore = store.getters[`${storeName}/getRows`]
expect(rowsInStore[0]).toBeUndefined()
})
})