mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-10 23:50:12 +00:00
Merge branch 'search_in_view' into 'develop'
Search in view Closes #328 See merge request bramw/baserow!210
This commit is contained in:
commit
1473f56997
55 changed files with 1508 additions and 156 deletions
backend/src/baserow/contrib/database/api/views/grid
changelog.mdweb-frontend
.eslintrc.jsMakefilejest.config.jspackage.json
modules
core
database
test
yarn.lock
|
@ -71,7 +71,14 @@ class GridViewView(APIView):
|
|||
name='size', location=OpenApiParameter.QUERY, type=OpenApiTypes.INT,
|
||||
description='Can only be used in combination with the `page` parameter '
|
||||
'and defines how many rows should be returned.'
|
||||
)
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='search',
|
||||
location=OpenApiParameter.QUERY,
|
||||
type=OpenApiTypes.STR,
|
||||
description='If provided only rows with data that matches the search '
|
||||
'query are going to be returned.'
|
||||
),
|
||||
],
|
||||
tags=['Database table grid view'],
|
||||
operation_id='list_database_table_grid_view_rows',
|
||||
|
@ -113,6 +120,8 @@ class GridViewView(APIView):
|
|||
`field_options` are provided in the include GET parameter.
|
||||
"""
|
||||
|
||||
search = request.GET.get('search')
|
||||
|
||||
view_handler = ViewHandler()
|
||||
view = view_handler.get_view(view_id, GridView)
|
||||
view.table.database.group.has_user(request.user, raise_error=True)
|
||||
|
@ -123,6 +132,8 @@ class GridViewView(APIView):
|
|||
# Applies the view filters and sortings to the queryset if there are any.
|
||||
queryset = view_handler.apply_filters(view, queryset)
|
||||
queryset = view_handler.apply_sorting(view, queryset)
|
||||
if search:
|
||||
queryset = queryset.search_all_fields(search)
|
||||
|
||||
if 'count' in request.GET:
|
||||
return Response({'count': queryset.count()})
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
* Add Phone Number field.
|
||||
* Add support for Date, Number and Single Select fields to the Contains and Not Contains view
|
||||
filters.
|
||||
* Searching all rows can now be done by clicking the new search icon in the top right.
|
||||
|
||||
## Released (2021-03-01)
|
||||
|
||||
|
|
|
@ -3,6 +3,10 @@ module.exports = {
|
|||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
jest: true,
|
||||
// required as jest uses jasmine's fail method
|
||||
// https://stackoverflow.com/questions/64413927/jest-eslint-fail-is-not-defined
|
||||
jasmine: true,
|
||||
},
|
||||
parserOptions: {
|
||||
parser: 'babel-eslint',
|
||||
|
|
|
@ -13,3 +13,6 @@ jest:
|
|||
yarn run jest-all || exit;
|
||||
|
||||
test: jest
|
||||
|
||||
unit-test-watch:
|
||||
yarn run jest test/unit --watch || exit;
|
||||
|
|
|
@ -1,21 +1,3 @@
|
|||
module.exports = {
|
||||
testEnvironment: 'node',
|
||||
expand: true,
|
||||
forceExit: true,
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'^~/(.*)$': '<rootDir>/$1',
|
||||
'^vue$': 'vue/dist/vue.common.js',
|
||||
},
|
||||
moduleFileExtensions: ['js', 'vue', 'json'],
|
||||
transform: {
|
||||
'^.+\\.js$': 'babel-jest',
|
||||
'.*\\.(vue)$': 'vue-jest',
|
||||
},
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: [
|
||||
'<rootDir>/components/**/*.vue',
|
||||
'<rootDir>/pages/**/*.vue',
|
||||
],
|
||||
setupFilesAfterEnv: ['./jest.setup.js'],
|
||||
projects: ['test/server', 'test/unit'],
|
||||
}
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
.control {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&.control--align-right {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
}
|
||||
|
||||
.control__label {
|
||||
|
@ -82,6 +87,19 @@
|
|||
margin-top: -6px;
|
||||
color: $color-neutral-300;
|
||||
}
|
||||
|
||||
&.input__with-icon--loading {
|
||||
&::after {
|
||||
content: " ";
|
||||
|
||||
@include loading(14px);
|
||||
@include absolute(10px, 10px, auto, auto);
|
||||
}
|
||||
|
||||
i {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
|
|
|
@ -60,18 +60,27 @@
|
|||
}
|
||||
|
||||
.header__filter {
|
||||
@extend .clearfix;
|
||||
|
||||
flex: 0 0;
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: auto 0;
|
||||
|
||||
&.header__filter--full-width {
|
||||
flex: 1 1;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-item {
|
||||
@extend %first-last-no-margin;
|
||||
|
||||
float: left;
|
||||
margin-left: 12px;
|
||||
|
||||
&.header__filter-item--right {
|
||||
margin-left: auto;
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-link {
|
||||
|
@ -80,6 +89,7 @@
|
|||
color: $color-primary-900;
|
||||
padding: 0 10px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
@include fixed-height(32px, 13px);
|
||||
|
||||
|
@ -89,16 +99,20 @@
|
|||
}
|
||||
|
||||
&.active {
|
||||
background-color: $color-success-200;
|
||||
}
|
||||
|
||||
&.active--warning {
|
||||
background-color: $color-warning-200;
|
||||
background-color: $color-success-100;
|
||||
}
|
||||
|
||||
&.active--primary {
|
||||
background-color: $color-primary-100;
|
||||
}
|
||||
|
||||
&.active--warning {
|
||||
background-color: $color-warning-100;
|
||||
}
|
||||
|
||||
&.active--error {
|
||||
background-color: $color-error-100;
|
||||
}
|
||||
}
|
||||
|
||||
.header__filter-icon {
|
||||
|
@ -115,6 +129,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.header__search-icon {
|
||||
color: $color-primary-900;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.header__info {
|
||||
@extend .clearfix;
|
||||
|
||||
|
|
|
@ -249,6 +249,10 @@
|
|||
.grid-view__row--selected & {
|
||||
background-color: $color-neutral-50;
|
||||
}
|
||||
|
||||
&.grid-view__row-info--matches-search {
|
||||
background-color: $color-primary-100;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-view__row-count {
|
||||
|
@ -322,6 +326,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
.grid-view__column--matches-search & {
|
||||
background-color: $color-primary-100;
|
||||
|
||||
&.active {
|
||||
background-color: $color-primary-100;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-view__cell--error {
|
||||
@extend %ellipsis;
|
||||
|
||||
|
|
|
@ -101,10 +101,18 @@
|
|||
color: $color-primary-900;
|
||||
background-color: $color-primary-100;
|
||||
|
||||
.grid-view__column--matches-search & {
|
||||
background-color: $color-primary-200;
|
||||
}
|
||||
|
||||
@include center-text(22px, 11px);
|
||||
|
||||
&:hover {
|
||||
background-color: $color-primary-200;
|
||||
|
||||
.grid-view__column--matches-search & {
|
||||
background-color: $color-primary-300;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -32,6 +32,10 @@
|
|||
|
||||
.grid-field-link-row__cell.active & {
|
||||
background-color: $color-primary-100;
|
||||
|
||||
.grid-view__column--matches-search & {
|
||||
background-color: $color-primary-200;
|
||||
}
|
||||
}
|
||||
|
||||
&.grid-field-link-row__item--link {
|
||||
|
@ -41,6 +45,10 @@
|
|||
|
||||
&:hover {
|
||||
background-color: $color-primary-200;
|
||||
|
||||
.grid-view__column--matches-search & {
|
||||
background-color: $color-primary-300;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-field-link-row__cell.active & {
|
||||
|
|
|
@ -27,4 +27,8 @@
|
|||
&:focus {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.grid-view__column--matches-search & {
|
||||
background-color: $color-primary-100;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,12 @@
|
|||
max-width: 100%;
|
||||
margin-top: 6px;
|
||||
|
||||
&.background-color--light-blue {
|
||||
.grid-view__column--matches-search & {
|
||||
box-shadow: 0 0 2px 1px rgba($black, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
@include fixed-height(20px, 12px);
|
||||
}
|
||||
|
||||
|
|
|
@ -37,6 +37,10 @@
|
|||
margin-top: 40px !important;
|
||||
}
|
||||
|
||||
.margin-bottom-0 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.margin-bottom-1 {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
|
|
@ -140,6 +140,8 @@ export default {
|
|||
}
|
||||
window.addEventListener('scroll', this.$el.updatePositionEvent, true)
|
||||
window.addEventListener('resize', this.$el.updatePositionEvent)
|
||||
|
||||
this.$emit('shown')
|
||||
},
|
||||
/**
|
||||
* Hide the context menu and make sure the body event is removed.
|
||||
|
|
|
@ -4,3 +4,12 @@
|
|||
* be found.
|
||||
*/
|
||||
export class StoreItemLookupError extends Error {}
|
||||
|
||||
/**
|
||||
* This error can be raised when a view receives multiple refresh events and wishes to
|
||||
* cancel the older ones which could still be running some async slow query. It
|
||||
* indicates to the top level refresh event handler that it should abort this particular
|
||||
* refresh event but keep the "refreshing" state in progress as a new refresh event is
|
||||
* still being processed.
|
||||
*/
|
||||
export class RefreshCancelledError extends Error {}
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="!!values.type">
|
||||
<template v-if="hasFormComponent">
|
||||
<component
|
||||
:is="getFormComponent(values.type)"
|
||||
ref="childForm"
|
||||
|
@ -82,6 +82,9 @@ export default {
|
|||
fieldTypes() {
|
||||
return this.$registry.getAll('field')
|
||||
},
|
||||
hasFormComponent() {
|
||||
return !!this.values.type && this.getFormComponent(this.values.type)
|
||||
},
|
||||
},
|
||||
validations: {
|
||||
values: {
|
||||
|
|
46
web-frontend/modules/database/components/view/ViewSearch.vue
Normal file
46
web-frontend/modules/database/components/view/ViewSearch.vue
Normal file
|
@ -0,0 +1,46 @@
|
|||
<template>
|
||||
<div>
|
||||
<a
|
||||
ref="contextLink"
|
||||
class="header__filter-link"
|
||||
:class="{
|
||||
'active--primary': headerSearchTerm.length > 0,
|
||||
}"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
<i class="header__search-icon fas fa-search"></i>
|
||||
{{ headerSearchTerm }}
|
||||
</a>
|
||||
<ViewSearchContext
|
||||
ref="context"
|
||||
:view="view"
|
||||
@refresh="$emit('refresh', $event)"
|
||||
@search-changed="searchChanged"
|
||||
></ViewSearchContext>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ViewSearchContext from '@baserow/modules/database/components/view/ViewSearchContext'
|
||||
|
||||
export default {
|
||||
name: 'ViewSearch',
|
||||
components: { ViewSearchContext },
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => {
|
||||
return {
|
||||
headerSearchTerm: '',
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
searchChanged(newSearch) {
|
||||
this.headerSearchTerm = newSearch
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<Context
|
||||
:class="{ 'context--loading-overlay': view._.loading }"
|
||||
@shown="focus"
|
||||
>
|
||||
<form class="context__form" @submit.prevent="searchIfChanged">
|
||||
<div class="control margin-bottom-1">
|
||||
<div class="control__elements">
|
||||
<div
|
||||
class="input__with-icon"
|
||||
:class="{ 'input__with-icon--loading': loading }"
|
||||
>
|
||||
<input
|
||||
ref="activeSearchTermInput"
|
||||
v-model="activeSearchTerm"
|
||||
type="text"
|
||||
placeholder="Search in all rows"
|
||||
class="input"
|
||||
@keyup="searchIfChanged"
|
||||
/>
|
||||
<i class="fas fa-search"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control control--align-right margin-bottom-0">
|
||||
<SwitchInput
|
||||
v-model="hideRowsNotMatchingSearch"
|
||||
@input="searchIfChanged"
|
||||
>
|
||||
hide not matching rows
|
||||
</SwitchInput>
|
||||
</div>
|
||||
</form>
|
||||
</Context>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import debounce from 'lodash/debounce'
|
||||
|
||||
import context from '@baserow/modules/core/mixins/context'
|
||||
|
||||
export default {
|
||||
name: 'ViewSearchContext',
|
||||
mixins: [context],
|
||||
props: {
|
||||
view: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeSearchTerm: '',
|
||||
lastSearch: '',
|
||||
hideRowsNotMatchingSearch: true,
|
||||
lastHide: true,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focus() {
|
||||
this.$nextTick(function () {
|
||||
this.$refs.activeSearchTermInput.focus()
|
||||
})
|
||||
},
|
||||
searchIfChanged() {
|
||||
this.$emit('search-changed', this.activeSearchTerm)
|
||||
if (
|
||||
this.lastSearch === this.activeSearchTerm &&
|
||||
this.lastHide === this.hideRowsNotMatchingSearch
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.search()
|
||||
|
||||
this.lastSearch = this.activeSearchTerm
|
||||
this.lastHide = this.hideRowsNotMatchingSearch
|
||||
},
|
||||
search() {
|
||||
this.loading = true
|
||||
|
||||
// When the user toggles from hiding rows to not hiding rows we still
|
||||
// need to refresh as we need to fetch the un-searched rows from the server first.
|
||||
if (this.hideRowsNotMatchingSearch || this.lastHide) {
|
||||
// noinspection JSValidateTypes
|
||||
this.debouncedServerSearchRefresh()
|
||||
} else {
|
||||
// noinspection JSValidateTypes
|
||||
this.debouncedClientSideSearchRefresh()
|
||||
}
|
||||
},
|
||||
debouncedServerSearchRefresh: debounce(async function () {
|
||||
await this.$store.dispatch('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,
|
||||
})
|
||||
this.$emit('refresh', {
|
||||
callback: this.finishedLoading,
|
||||
})
|
||||
}, 400),
|
||||
// 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('view/grid/updateSearch', {
|
||||
activeSearchTerm: this.activeSearchTerm,
|
||||
hideRowsNotMatchingSearch: this.hideRowsNotMatchingSearch,
|
||||
refreshMatchesOnClient: true,
|
||||
})
|
||||
this.finishedLoading()
|
||||
}, 10),
|
||||
finishedLoading() {
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -263,8 +263,11 @@ export default {
|
|||
* to update the scrollbars.
|
||||
*/
|
||||
fieldsUpdated() {
|
||||
if (this.$refs.scrollbars) {
|
||||
this.$refs.scrollbars.update()
|
||||
const scrollbars = this.$refs.scrollbars
|
||||
// Vue can sometimes trigger this via watch before the child component
|
||||
// scrollbars has been created, check it exists and has the expected method
|
||||
if (scrollbars && scrollbars.update) {
|
||||
scrollbars.update()
|
||||
}
|
||||
},
|
||||
/**
|
||||
|
@ -294,16 +297,11 @@ export default {
|
|||
editValue({ field, row, value, oldValue }) {
|
||||
const overrides = {}
|
||||
overrides[`field_${field.id}`] = value
|
||||
this.$store.dispatch('view/grid/updateMatchFilters', {
|
||||
this.$store.dispatch('view/grid/onRowChange', {
|
||||
view: this.view,
|
||||
row,
|
||||
overrides,
|
||||
})
|
||||
this.$store.dispatch('view/grid/updateMatchSortings', {
|
||||
view: this.view,
|
||||
fields: this.fields,
|
||||
primary: this.primary,
|
||||
row,
|
||||
overrides,
|
||||
})
|
||||
},
|
||||
|
@ -606,7 +604,7 @@ export default {
|
|||
windowHeight: this.$refs.right.$refs.body.clientHeight,
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
this.$refs.scrollbars.update()
|
||||
this.fieldsUpdated()
|
||||
})
|
||||
},
|
||||
},
|
||||
|
|
|
@ -2,6 +2,11 @@
|
|||
<div
|
||||
ref="wrapper"
|
||||
class="grid-view__column"
|
||||
:class="{
|
||||
'grid-view__column--matches-search':
|
||||
props.row._.matchSearch &&
|
||||
props.row._.fieldSearchMatches.includes(props.field.id.toString()),
|
||||
}"
|
||||
:style="data.style"
|
||||
@click="$options.methods.select($event, parent, props.field.id)"
|
||||
>
|
||||
|
|
|
@ -1,8 +1,11 @@
|
|||
<template>
|
||||
<ul v-if="!tableLoading" class="header__filter">
|
||||
<ul v-if="!tableLoading" class="header__filter header__filter--full-width">
|
||||
<li class="header__filter-item">
|
||||
<GridViewHide :view="view" :fields="fields"></GridViewHide>
|
||||
</li>
|
||||
<li class="header__filter-item header__filter-item--right">
|
||||
<ViewSearch :view="view" @refresh="$emit('refresh', $event)"></ViewSearch>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
|
@ -10,10 +13,11 @@
|
|||
import { mapState } from 'vuex'
|
||||
|
||||
import GridViewHide from '@baserow/modules/database/components/view/grid/GridViewHide'
|
||||
import ViewSearch from '@baserow/modules/database/components/view/ViewSearch'
|
||||
|
||||
export default {
|
||||
name: 'GridViewHeader',
|
||||
components: { GridViewHide },
|
||||
components: { GridViewHide, ViewSearch },
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
ref="contextLink"
|
||||
class="header__filter-link"
|
||||
:class="{
|
||||
'active--primary': hiddenFields.length > 0,
|
||||
'active--error': hiddenFields.length > 0,
|
||||
}"
|
||||
@click="$refs.context.toggle($refs.contextLink, 'bottom', 'left', 4)"
|
||||
>
|
||||
|
|
|
@ -5,7 +5,8 @@
|
|||
'grid-view__row--selected': row._.selectedBy.length > 0,
|
||||
'grid-view__row--loading': row._.loading,
|
||||
'grid-view__row--hover': row._.hover,
|
||||
'grid-view__row--warning': !row._.matchFilters || !row._.matchSortings,
|
||||
'grid-view__row--warning':
|
||||
!row._.matchFilters || !row._.matchSortings || !row._.matchSearch,
|
||||
}"
|
||||
@mouseover="$emit('row-hover', { row, value: true })"
|
||||
@mouseleave="$emit('row-hover', { row, value: false })"
|
||||
|
@ -13,19 +14,28 @@
|
|||
>
|
||||
<template v-if="includeRowDetails">
|
||||
<div
|
||||
v-if="!row._.matchFilters || !row._.matchSortings"
|
||||
v-if="!row._.matchFilters || !row._.matchSortings || !row._.matchSearch"
|
||||
class="grid-view__row-warning"
|
||||
>
|
||||
<template v-if="!row._.matchFilters">
|
||||
Row does not match filters
|
||||
</template>
|
||||
<template v-else-if="!row._.matchSortings">Row has moved</template>
|
||||
<template v-else-if="!row._.matchSearch">
|
||||
Row does not match search
|
||||
</template>
|
||||
<template v-else-if="!row._.matchSortings"> Row has moved</template>
|
||||
</div>
|
||||
<div
|
||||
class="grid-view__column"
|
||||
:style="{ width: gridViewRowDetailsWidth + 'px' }"
|
||||
>
|
||||
<div class="grid-view__row-info">
|
||||
<div
|
||||
class="grid-view__row-info"
|
||||
:class="{
|
||||
'grid-view__row-info--matches-search':
|
||||
row._.matchSearch && row._.fieldSearchMatches.includes('row_id'),
|
||||
}"
|
||||
>
|
||||
<div class="grid-view__row-count" :title="row.id">
|
||||
{{ row.id }}
|
||||
</div>
|
||||
|
@ -42,7 +52,7 @@
|
|||
-->
|
||||
<GridViewCell
|
||||
v-for="field in fields"
|
||||
:key="'row-field-' + row.id + '-' + field.id"
|
||||
:key="'row-field-' + row.id + '-' + field.id.toString()"
|
||||
:field="field"
|
||||
:row="row"
|
||||
:state="state"
|
||||
|
|
|
@ -53,6 +53,10 @@ import {
|
|||
getDateMomentFormat,
|
||||
getTimeMomentFormat,
|
||||
} from '@baserow/modules/database/utils/date'
|
||||
import {
|
||||
filenameContainsFilter,
|
||||
genericContainsFilter,
|
||||
} from '@baserow/modules/database/utils/fieldFilters'
|
||||
|
||||
export class FieldType extends Registerable {
|
||||
/**
|
||||
|
@ -280,6 +284,43 @@ export class FieldType extends Registerable {
|
|||
getDocsResponseExample(field) {
|
||||
return this.getDocsRequestExample(field)
|
||||
}
|
||||
|
||||
/**
|
||||
* Should return a contains filter function unique for this field type.
|
||||
*/
|
||||
getContainsFilterFunction() {
|
||||
return (rowValue, humanReadableRowValue, filterValue) => false
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rowValue to its human readable form first before applying the
|
||||
* filter returned from getContainsFilterFunction.
|
||||
*/
|
||||
containsFilter(rowValue, filterValue, field) {
|
||||
return (
|
||||
filterValue === '' ||
|
||||
this.getContainsFilterFunction()(
|
||||
rowValue,
|
||||
this.toHumanReadableString(field, rowValue),
|
||||
filterValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts rowValue to its human readable form first before applying the field
|
||||
* filter returned by getContainsFilterFunction's notted.
|
||||
*/
|
||||
notContainsFilter(rowValue, filterValue, field) {
|
||||
return (
|
||||
filterValue === '' ||
|
||||
!this.getContainsFilterFunction()(
|
||||
rowValue,
|
||||
this.toHumanReadableString(field, rowValue),
|
||||
filterValue
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class TextFieldType extends FieldType {
|
||||
|
@ -337,6 +378,10 @@ export class TextFieldType extends FieldType {
|
|||
getDocsRequestExample(field) {
|
||||
return 'string'
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class LongTextFieldType extends FieldType {
|
||||
|
@ -390,6 +435,10 @@ export class LongTextFieldType extends FieldType {
|
|||
getDocsRequestExample(field) {
|
||||
return 'string'
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class LinkRowFieldType extends FieldType {
|
||||
|
@ -636,6 +685,10 @@ export class NumberFieldType extends FieldType {
|
|||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class BooleanFieldType extends FieldType {
|
||||
|
@ -807,6 +860,10 @@ export class DateFieldType extends FieldType {
|
|||
getDocsRequestExample(field) {
|
||||
return field.date_include_time ? '2020-01-01T12:00:00Z' : '2020-01-01'
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class URLFieldType extends FieldType {
|
||||
|
@ -861,6 +918,10 @@ export class URLFieldType extends FieldType {
|
|||
getDocsRequestExample(field) {
|
||||
return 'https://baserow.io'
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailFieldType extends FieldType {
|
||||
|
@ -915,6 +976,10 @@ export class EmailFieldType extends FieldType {
|
|||
getDocsRequestExample(field) {
|
||||
return 'example@baserow.io'
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class FileFieldType extends FieldType {
|
||||
|
@ -1026,6 +1091,10 @@ export class FileFieldType extends FieldType {
|
|||
},
|
||||
]
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return filenameContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class SingleSelectFieldType extends FieldType {
|
||||
|
@ -1135,6 +1204,10 @@ export class SingleSelectFieldType extends FieldType {
|
|||
color: 'light-blue',
|
||||
}
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
||||
export class PhoneNumberFieldType extends FieldType {
|
||||
|
@ -1197,4 +1270,8 @@ export class PhoneNumberFieldType extends FieldType {
|
|||
getDocsRequestExample(field) {
|
||||
return '+1-541-754-3010'
|
||||
}
|
||||
|
||||
getContainsFilterFunction() {
|
||||
return genericContainsFilter
|
||||
}
|
||||
}
|
||||
|
|
|
@ -63,11 +63,8 @@
|
|||
:view="view"
|
||||
:fields="fields"
|
||||
:primary="primary"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
<ul v-if="!tableLoading" class="header__info">
|
||||
<li>{{ database.name }}</li>
|
||||
<li>{{ table.name }}</li>
|
||||
</ul>
|
||||
</header>
|
||||
<div class="layout__col-2-2 content">
|
||||
<component
|
||||
|
@ -89,11 +86,15 @@
|
|||
<script>
|
||||
import { mapState } from 'vuex'
|
||||
|
||||
import { StoreItemLookupError } from '@baserow/modules/core/errors'
|
||||
import {
|
||||
RefreshCancelledError,
|
||||
StoreItemLookupError,
|
||||
} from '@baserow/modules/core/errors'
|
||||
import { notifyIf } from '@baserow/modules/core/utils/error'
|
||||
import ViewsContext from '@baserow/modules/database/components/view/ViewsContext'
|
||||
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'
|
||||
|
||||
/**
|
||||
* This page component is the skeleton for a table. Depending on the selected view it
|
||||
|
@ -104,6 +105,7 @@ export default {
|
|||
ViewsContext,
|
||||
ViewFilter,
|
||||
ViewSort,
|
||||
ViewSearch,
|
||||
},
|
||||
/**
|
||||
* When the user leaves to another page we want to unselect the selected table. This
|
||||
|
@ -257,7 +259,15 @@ export default {
|
|||
try {
|
||||
await type.refresh({ store: this.$store }, this.view)
|
||||
} catch (error) {
|
||||
notifyIf(error)
|
||||
if (error instanceof RefreshCancelledError) {
|
||||
// Multiple refresh calls have been made and the view has indicated that
|
||||
// this particular one should be cancelled. However we do not want to
|
||||
// set viewLoading back to false as the other non cancelled call/s might
|
||||
// still be loading.
|
||||
return
|
||||
} else {
|
||||
notifyIf(error)
|
||||
}
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this.$refs, 'view') &&
|
||||
|
|
|
@ -6,6 +6,7 @@ export default (client) => {
|
|||
offset = null,
|
||||
cancelToken = null,
|
||||
includeFieldOptions = false,
|
||||
search = false,
|
||||
}) {
|
||||
const config = {
|
||||
params: {
|
||||
|
@ -30,14 +31,25 @@ export default (client) => {
|
|||
config.params.include = include.join(',')
|
||||
}
|
||||
|
||||
if (search) {
|
||||
config.params.search = search
|
||||
}
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/`, config)
|
||||
},
|
||||
fetchCount(gridId) {
|
||||
fetchCount({ gridId, search, cancelToken = null }) {
|
||||
const config = {
|
||||
params: {
|
||||
count: true,
|
||||
},
|
||||
}
|
||||
if (cancelToken !== null) {
|
||||
config.cancelToken = cancelToken
|
||||
}
|
||||
|
||||
if (search) {
|
||||
config.params.search = search
|
||||
}
|
||||
|
||||
return client.get(`/database/views/grid/${gridId}/`, config)
|
||||
},
|
||||
|
|
|
@ -254,6 +254,9 @@ export const getters = {
|
|||
getAll(state) {
|
||||
return state.items
|
||||
},
|
||||
getAllWithPrimary(state) {
|
||||
return [state.primary, ...state.items]
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
|
@ -8,9 +8,11 @@ import { clone } from '@baserow/modules/core/utils/object'
|
|||
import GridService from '@baserow/modules/database/services/view/grid'
|
||||
import RowService from '@baserow/modules/database/services/row'
|
||||
import {
|
||||
calculateSingleRowSearchMatches,
|
||||
getRowSortFunction,
|
||||
rowMatchesFilters,
|
||||
matchSearchFilters,
|
||||
} from '@baserow/modules/database/utils/view'
|
||||
import { RefreshCancelledError } from '@baserow/modules/core/errors'
|
||||
|
||||
export function populateRow(row) {
|
||||
row._ = {
|
||||
|
@ -19,6 +21,12 @@ export function populateRow(row) {
|
|||
selectedBy: [],
|
||||
matchFilters: true,
|
||||
matchSortings: true,
|
||||
// Whether the row should be displayed based on the current activeSearchTerm term.
|
||||
matchSearch: true,
|
||||
// Contains the specific field ids which match the activeSearchTerm term.
|
||||
// Could be empty even when matchSearch is true when there is no
|
||||
// activeSearchTerm term applied.
|
||||
fieldSearchMatches: [],
|
||||
// Keeping the selected state with the row has the best performance when navigating
|
||||
// between cells.
|
||||
selected: false,
|
||||
|
@ -63,6 +71,12 @@ export const state = () => ({
|
|||
windowHeight: 0,
|
||||
// Indicates if the user is hovering over the add row button.
|
||||
addRowHover: false,
|
||||
// A user provided optional search term which can be used to filter down rows.
|
||||
activeSearchTerm: '',
|
||||
// If true then the activeSearchTerm will be sent to the server to filter rows
|
||||
// entirely out. When false no server filter will be applied and rows which do not
|
||||
// have any matching cells will still be displayed.
|
||||
hideRowsNotMatchingSearch: true,
|
||||
})
|
||||
|
||||
export const mutations = {
|
||||
|
@ -72,6 +86,10 @@ export const mutations = {
|
|||
SET_LOADED(state, value) {
|
||||
state.loaded = value
|
||||
},
|
||||
SET_SEARCH(state, { activeSearchTerm, hideRowsNotMatchingSearch }) {
|
||||
state.activeSearchTerm = activeSearchTerm
|
||||
state.hideRowsNotMatchingSearch = hideRowsNotMatchingSearch
|
||||
},
|
||||
SET_LAST_GRID_ID(state, gridId) {
|
||||
state.lastGridId = gridId
|
||||
},
|
||||
|
@ -88,6 +106,8 @@ export const mutations = {
|
|||
state.rowsStartIndex = 0
|
||||
state.rowsEndIndex = 0
|
||||
state.scrollTop = 0
|
||||
state.activeSearchTerm = ''
|
||||
state.hideRowsNotMatchingSearch = true
|
||||
},
|
||||
/**
|
||||
* It will add and remove rows to the state based on the provided values. For example
|
||||
|
@ -244,6 +264,21 @@ export const mutations = {
|
|||
SET_ROW_HOVER(state, { row, value }) {
|
||||
row._.hover = value
|
||||
},
|
||||
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
|
||||
},
|
||||
|
||||
SET_ROW_MATCH_FILTERS(state, { row, value }) {
|
||||
row._.matchFilters = value
|
||||
},
|
||||
|
@ -288,6 +323,8 @@ let lastScrollTop = null
|
|||
let lastRequest = null
|
||||
let lastRequestOffset = null
|
||||
let lastRequestLimit = null
|
||||
let lastRefreshRequest = null
|
||||
let lastRefreshRequestSource = null
|
||||
let lastSource = null
|
||||
|
||||
export const actions = {
|
||||
|
@ -402,6 +439,7 @@ export const actions = {
|
|||
offset: requestOffset,
|
||||
limit: requestLimit,
|
||||
cancelToken: lastSource.token,
|
||||
search: getters.getServerSearchTerm,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
data.results.forEach((part, index) => {
|
||||
|
@ -420,6 +458,7 @@ export const actions = {
|
|||
scrollTop: null,
|
||||
windowHeight: null,
|
||||
})
|
||||
dispatch('updateSearch', {})
|
||||
lastRequest = null
|
||||
})
|
||||
.catch((error) => {
|
||||
|
@ -539,6 +578,7 @@ export const actions = {
|
|||
offset: 0,
|
||||
limit,
|
||||
includeFieldOptions: true,
|
||||
search: getters.getServerSearchTerm,
|
||||
})
|
||||
data.results.forEach((part, index) => {
|
||||
populateRow(data.results[index])
|
||||
|
@ -560,48 +600,98 @@ export const actions = {
|
|||
top: 0,
|
||||
})
|
||||
commit('REPLACE_ALL_FIELD_OPTIONS', data.field_options)
|
||||
dispatch('updateSearch', {})
|
||||
},
|
||||
/**
|
||||
* Refreshes the current state with fresh data. It keeps the scroll offset the same
|
||||
* if possible. This can be used when a new filter or sort is created.
|
||||
* if possible. This can be used when a new filter or sort is created. Will also
|
||||
* update search highlighting if a new activeSearchTerm and hideRowsNotMatchingSearch
|
||||
* are provided in the refreshEvent.
|
||||
*/
|
||||
async refresh({ dispatch, commit, getters }, { gridId }) {
|
||||
const response = await GridService(this.$client).fetchCount(gridId)
|
||||
const count = response.data.count
|
||||
refresh({ dispatch, commit, getters }, { gridId }) {
|
||||
if (lastRefreshRequest !== null) {
|
||||
lastRefreshRequestSource.cancel('Cancelled in favor of new request')
|
||||
}
|
||||
lastRefreshRequestSource = axios.CancelToken.source()
|
||||
lastRefreshRequest = GridService(this.$client)
|
||||
.fetchCount({
|
||||
gridId,
|
||||
search: getters.getServerSearchTerm,
|
||||
cancelToken: lastRefreshRequestSource.token,
|
||||
})
|
||||
.then((response) => {
|
||||
const count = response.data.count
|
||||
|
||||
const limit = getters.getBufferRequestSize * 3
|
||||
const bufferEndIndex = getters.getBufferEndIndex
|
||||
const offset =
|
||||
count >= bufferEndIndex
|
||||
? getters.getBufferStartIndex
|
||||
: Math.max(0, count - limit)
|
||||
|
||||
const { data } = await GridService(this.$client).fetchRows({
|
||||
gridId,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
|
||||
// If there are results we can replace the existing rows so that the user stays
|
||||
// at the same scroll offset.
|
||||
data.results.forEach((part, index) => {
|
||||
populateRow(data.results[index])
|
||||
})
|
||||
await commit('ADD_ROWS', {
|
||||
rows: data.results,
|
||||
prependToRows: -getters.getBufferLimit,
|
||||
appendToRows: data.results.length,
|
||||
count: data.count,
|
||||
bufferStartIndex: offset,
|
||||
bufferLimit: data.results.length,
|
||||
})
|
||||
const limit = getters.getBufferRequestSize * 3
|
||||
const bufferEndIndex = getters.getBufferEndIndex
|
||||
const offset =
|
||||
count >= bufferEndIndex
|
||||
? getters.getBufferStartIndex
|
||||
: Math.max(0, count - limit)
|
||||
return { limit, offset }
|
||||
})
|
||||
.then(({ limit, offset }) =>
|
||||
GridService(this.$client)
|
||||
.fetchRows({
|
||||
gridId,
|
||||
offset,
|
||||
limit,
|
||||
cancelToken: lastRefreshRequestSource.token,
|
||||
search: getters.getServerSearchTerm,
|
||||
})
|
||||
.then(({ data }) => ({
|
||||
data,
|
||||
offset,
|
||||
}))
|
||||
)
|
||||
.then(({ data, offset }) => {
|
||||
// If there are results we can replace the existing rows so that the user stays
|
||||
// at the same scroll offset.
|
||||
data.results.forEach((part, index) => {
|
||||
populateRow(data.results[index])
|
||||
})
|
||||
commit('ADD_ROWS', {
|
||||
rows: data.results,
|
||||
prependToRows: -getters.getBufferLimit,
|
||||
appendToRows: data.results.length,
|
||||
count: data.count,
|
||||
bufferStartIndex: offset,
|
||||
bufferLimit: data.results.length,
|
||||
})
|
||||
dispatch('updateSearch', {})
|
||||
lastRefreshRequest = null
|
||||
})
|
||||
.catch((error) => {
|
||||
if (axios.isCancel(error)) {
|
||||
throw new RefreshCancelledError()
|
||||
} else {
|
||||
lastRefreshRequest = null
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return lastRefreshRequest
|
||||
},
|
||||
/**
|
||||
* Triggered when a row has been changed, or has a pending change in the provided
|
||||
* overrides.
|
||||
*/
|
||||
onRowChange(
|
||||
{ dispatch },
|
||||
{ view, row, fields, primary = null, overrides = {} }
|
||||
) {
|
||||
dispatch('updateMatchFilters', { view, row, fields, primary, overrides })
|
||||
dispatch('updateMatchSortings', { view, row, fields, primary, overrides })
|
||||
dispatch('updateSearchMatchesForRow', { row, overrides })
|
||||
},
|
||||
/**
|
||||
* Checks if the given row still matches the given view filters. The row's
|
||||
* matchFilters value is updated accordingly. It is also possible to provide some
|
||||
* override values that not actually belong to the row to do some preliminary checks.
|
||||
*/
|
||||
updateMatchFilters({ commit }, { view, row, overrides = {} }) {
|
||||
updateMatchFilters(
|
||||
{ commit },
|
||||
{ view, row, fields, primary, overrides = {} }
|
||||
) {
|
||||
const values = JSON.parse(JSON.stringify(row))
|
||||
Object.keys(overrides).forEach((key) => {
|
||||
values[key] = overrides[key]
|
||||
|
@ -609,14 +699,55 @@ export const actions = {
|
|||
// The value is always valid if the filters are disabled.
|
||||
const matches = view.filters_disabled
|
||||
? true
|
||||
: rowMatchesFilters(
|
||||
: matchSearchFilters(
|
||||
this.$registry,
|
||||
view.filter_type,
|
||||
view.filters,
|
||||
primary === null ? fields : [primary, ...fields],
|
||||
values
|
||||
)
|
||||
commit('SET_ROW_MATCH_FILTERS', { row, value: matches })
|
||||
},
|
||||
/**
|
||||
* 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 },
|
||||
{
|
||||
activeSearchTerm = state.activeSearchTerm,
|
||||
hideRowsNotMatchingSearch = state.hideRowsNotMatchingSearch,
|
||||
refreshMatchesOnClient = true,
|
||||
}
|
||||
) {
|
||||
commit('SET_SEARCH', { activeSearchTerm, hideRowsNotMatchingSearch })
|
||||
if (refreshMatchesOnClient) {
|
||||
getters.getAllRows.forEach((row) =>
|
||||
dispatch('updateSearchMatchesForRow', { row })
|
||||
)
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 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, overrides }
|
||||
) {
|
||||
const rowSearchMatches = calculateSingleRowSearchMatches(
|
||||
row,
|
||||
getters.getActiveSearchTerm,
|
||||
getters.isHidingRowsNotMatchingSearch,
|
||||
rootGetters['field/getAllWithPrimary'],
|
||||
this.$registry,
|
||||
overrides
|
||||
)
|
||||
|
||||
commit('SET_ROW_SEARCH_MATCHES', rowSearchMatches)
|
||||
},
|
||||
/**
|
||||
* Checks if the given row index is still the same. The row's matchSortings value is
|
||||
* updated accordingly. It is also possible to provide some override values that not
|
||||
|
@ -652,8 +783,7 @@ export const actions = {
|
|||
{ table, view, row, field, fields, primary, value, oldValue }
|
||||
) {
|
||||
commit('SET_VALUE', { row, field, value })
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
dispatch('updateMatchSortings', { view, fields, primary, row })
|
||||
dispatch('onRowChange', { view, row, fields, primary })
|
||||
|
||||
const fieldType = this.$registry.get('field', field._.type.type)
|
||||
const newValue = fieldType.prepareValueForUpdate(field, value)
|
||||
|
@ -664,7 +794,7 @@ export const actions = {
|
|||
await RowService(this.$client).update(table.id, row.id, values)
|
||||
} catch (error) {
|
||||
commit('SET_VALUE', { row, field, value: oldValue })
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
dispatch('onRowChange', { view, row, fields, primary })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
@ -719,11 +849,7 @@ export const actions = {
|
|||
windowHeight: null,
|
||||
})
|
||||
|
||||
// Check if the newly created row matches the filters.
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
|
||||
// Check if the newly created row matches the sortings.
|
||||
dispatch('updateMatchSortings', { view, fields, row })
|
||||
dispatch('onRowChange', { view, row, fields })
|
||||
|
||||
try {
|
||||
const { data } = await RowService(this.$client).create(
|
||||
|
@ -760,8 +886,7 @@ export const actions = {
|
|||
scrollTop: null,
|
||||
windowHeight: null,
|
||||
})
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
dispatch('updateMatchSortings', { view, fields, primary, row })
|
||||
dispatch('onRowChange', { view, row, fields, primary })
|
||||
dispatch('refreshRow', { grid: view, row, fields, primary, getScrollTop })
|
||||
},
|
||||
/**
|
||||
|
@ -787,8 +912,7 @@ export const actions = {
|
|||
commit('UPDATE_ROW', { row, values })
|
||||
}
|
||||
|
||||
dispatch('updateMatchFilters', { view, row })
|
||||
dispatch('updateMatchSortings', { view, fields, primary, row })
|
||||
dispatch('onRowChange', { view, row, fields, primary })
|
||||
dispatch('refreshRow', { grid: view, row, fields, primary, getScrollTop })
|
||||
},
|
||||
/**
|
||||
|
@ -976,7 +1100,8 @@ export const actions = {
|
|||
{ dispatch, commit, getters },
|
||||
{ grid, row, fields, primary, getScrollTop }
|
||||
) {
|
||||
if (row._.selectedBy.length === 0 && !row._.matchFilters) {
|
||||
const rowShouldBeHidden = !row._.matchFilters || !row._.matchSearch
|
||||
if (row._.selectedBy.length === 0 && rowShouldBeHidden) {
|
||||
dispatch('forceDelete', { grid, row, getScrollTop })
|
||||
return
|
||||
}
|
||||
|
@ -1087,6 +1212,15 @@ export const getters = {
|
|||
getAddRowHover(state) {
|
||||
return state.addRowHover
|
||||
},
|
||||
getActiveSearchTerm(state) {
|
||||
return state.activeSearchTerm
|
||||
},
|
||||
isHidingRowsNotMatchingSearch(state) {
|
||||
return state.hideRowsNotMatchingSearch
|
||||
},
|
||||
getServerSearchTerm(state) {
|
||||
return state.hideRowsNotMatchingSearch ? state.activeSearchTerm : false
|
||||
},
|
||||
}
|
||||
|
||||
export default {
|
||||
|
|
32
web-frontend/modules/database/utils/fieldFilters.js
Normal file
32
web-frontend/modules/database/utils/fieldFilters.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
// We can't use the humanReadable value here as:
|
||||
// A: it contains commas which we don't want to match against
|
||||
// B: even if we removed the commas and compared filterValue against the concatted
|
||||
// list of file names, we don't want the filterValue to accidentally match the end
|
||||
// of one filename and the start of another.
|
||||
export function filenameContainsFilter(
|
||||
rowValue,
|
||||
humanReadableRowValue,
|
||||
filterValue
|
||||
) {
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
|
||||
for (let i = 0; i < rowValue.length; i++) {
|
||||
const visibleName = rowValue[i].visible_name.toString().toLowerCase().trim()
|
||||
|
||||
if (visibleName.includes(filterValue)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function genericContainsFilter(
|
||||
rowValue,
|
||||
humanReadableRowValue,
|
||||
filterValue
|
||||
) {
|
||||
humanReadableRowValue = humanReadableRowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return humanReadableRowValue.includes(filterValue)
|
||||
}
|
|
@ -39,7 +39,13 @@ export function getRowSortFunction(
|
|||
* filters. Returning false indicates that the row should not be visible for that
|
||||
* view.
|
||||
*/
|
||||
export const rowMatchesFilters = ($registry, filterType, filters, values) => {
|
||||
export const matchSearchFilters = (
|
||||
$registry,
|
||||
filterType,
|
||||
filters,
|
||||
fields,
|
||||
values
|
||||
) => {
|
||||
// If there aren't any filters then it is not possible to check if the row
|
||||
// matches any of the filters, so we can mark it as valid.
|
||||
if (filters.length === 0) {
|
||||
|
@ -47,11 +53,14 @@ export const rowMatchesFilters = ($registry, filterType, filters, values) => {
|
|||
}
|
||||
|
||||
for (const i in filters) {
|
||||
const filterValue = filters[i].value
|
||||
const rowValue = values[`field_${filters[i].field}`]
|
||||
const filter = filters[i]
|
||||
const filterValue = filter.value
|
||||
const rowValue = values[`field_${filter.field}`]
|
||||
const field = fields.find((f) => f.id === filter.field)
|
||||
const fieldType = $registry.get('field', field.type)
|
||||
const matches = $registry
|
||||
.get('viewFilter', filters[i].type)
|
||||
.matches(rowValue, filterValue)
|
||||
.get('viewFilter', filter.type)
|
||||
.matches(rowValue, filterValue, field, fieldType)
|
||||
if (filterType === 'AND' && !matches) {
|
||||
// With an `AND` filter type, the row must match all the filters, so if
|
||||
// one of the filters doesn't match we can mark it as isvalid.
|
||||
|
@ -73,3 +82,62 @@ export const rowMatchesFilters = ($registry, filterType, filters, values) => {
|
|||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function _findFieldsInRowMatchingSearch(
|
||||
row,
|
||||
activeSearchTerm,
|
||||
fields,
|
||||
registry,
|
||||
overrides
|
||||
) {
|
||||
const fieldSearchMatches = new Set()
|
||||
if (row.id.toString().includes(activeSearchTerm)) {
|
||||
fieldSearchMatches.add('row_id')
|
||||
}
|
||||
for (const field of fields) {
|
||||
const fieldName = `field_${field.id}`
|
||||
const rowValue =
|
||||
fieldName in overrides ? overrides[fieldName] : row[fieldName]
|
||||
if (rowValue) {
|
||||
const doesMatch = registry
|
||||
.get('field', field.type)
|
||||
.containsFilter(rowValue, activeSearchTerm, field)
|
||||
if (doesMatch) {
|
||||
fieldSearchMatches.add(field.id.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fieldSearchMatches
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function which calculates if a given row and which of it's fields matches a
|
||||
* given search term. The rows values can be overridden by providing an overrides
|
||||
* object containing a mapping of the field name to override to a value that will be
|
||||
* used to check for matches instead of the rows real one. The rows values will not be
|
||||
* changed.
|
||||
*/
|
||||
export function calculateSingleRowSearchMatches(
|
||||
row,
|
||||
activeSearchTerm,
|
||||
hideRowsNotMatchingSearch,
|
||||
fields,
|
||||
registry,
|
||||
overrides = {}
|
||||
) {
|
||||
const searchIsBlank = activeSearchTerm === ''
|
||||
const fieldSearchMatches = searchIsBlank
|
||||
? new Set()
|
||||
: _findFieldsInRowMatchingSearch(
|
||||
row,
|
||||
activeSearchTerm,
|
||||
fields,
|
||||
registry,
|
||||
overrides
|
||||
)
|
||||
|
||||
const matchSearch =
|
||||
!hideRowsNotMatchingSearch || searchIsBlank || fieldSearchMatches.size > 0
|
||||
return { row, matchSearch, fieldSearchMatches }
|
||||
}
|
||||
|
|
|
@ -79,7 +79,7 @@ export class ViewFilterType extends Registerable {
|
|||
* alternative solution where we keep the real time check and we don't have
|
||||
* to wait for the server in order to tell us if the value matches.
|
||||
*/
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
throw new Error('The matches method must be implemented for every filter.')
|
||||
}
|
||||
}
|
||||
|
@ -101,7 +101,7 @@ export class EqualViewFilterType extends ViewFilterType {
|
|||
return ['text', 'long_text', 'url', 'email', 'number', 'phone_number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
@ -129,7 +129,7 @@ export class NotEqualViewFilterType extends ViewFilterType {
|
|||
return ['text', 'long_text', 'url', 'email', 'number', 'phone_number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
@ -157,25 +157,8 @@ export class FilenameContainsViewFilterType extends ViewFilterType {
|
|||
return ['file']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
|
||||
if (filterValue === '') {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < rowValue.length; i++) {
|
||||
const visibleName = rowValue[i].visible_name
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
|
||||
if (visibleName.includes(filterValue)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return fieldType.containsFilter(rowValue, filterValue, field)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -205,15 +188,8 @@ export class ContainsViewFilterType extends ViewFilterType {
|
|||
]
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
} else if (typeof rowValue === 'object' && 'value' in rowValue) {
|
||||
rowValue = rowValue.value
|
||||
}
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || rowValue.includes(filterValue)
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return fieldType.containsFilter(rowValue, filterValue, field)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -243,15 +219,8 @@ export class ContainsNotViewFilterType extends ViewFilterType {
|
|||
]
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
} else if (typeof rowValue === 'object' && 'value' in rowValue) {
|
||||
rowValue = rowValue.value
|
||||
}
|
||||
rowValue = rowValue.toString().toLowerCase().trim()
|
||||
filterValue = filterValue.toString().toLowerCase().trim()
|
||||
return filterValue === '' || !rowValue.includes(filterValue)
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return fieldType.notContainsFilter(rowValue, filterValue, field)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -276,7 +245,7 @@ export class DateEqualViewFilterType extends ViewFilterType {
|
|||
return ['date']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
@ -309,7 +278,7 @@ export class DateNotEqualViewFilterType extends ViewFilterType {
|
|||
return ['date']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
if (rowValue === null) {
|
||||
rowValue = ''
|
||||
}
|
||||
|
@ -342,7 +311,7 @@ export class HigherThanViewFilterType extends ViewFilterType {
|
|||
return ['number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
if (filterValue === '') {
|
||||
return true
|
||||
}
|
||||
|
@ -374,7 +343,7 @@ export class LowerThanViewFilterType extends ViewFilterType {
|
|||
return ['number']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
if (filterValue === '') {
|
||||
return true
|
||||
}
|
||||
|
@ -406,7 +375,7 @@ export class SingleSelectEqualViewFilterType extends ViewFilterType {
|
|||
return ['single_select']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return (
|
||||
filterValue === '' ||
|
||||
(rowValue !== null && rowValue.id === parseInt(filterValue))
|
||||
|
@ -435,7 +404,7 @@ export class SingleSelectNotEqualViewFilterType extends ViewFilterType {
|
|||
return ['single_select']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return (
|
||||
filterValue === '' ||
|
||||
rowValue === null ||
|
||||
|
@ -465,7 +434,7 @@ export class BooleanViewFilterType extends ViewFilterType {
|
|||
return ['boolean']
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
filterValue = trueString.includes(
|
||||
filterValue.toString().toLowerCase().trim()
|
||||
)
|
||||
|
@ -507,7 +476,7 @@ export class EmptyViewFilterType extends ViewFilterType {
|
|||
]
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return (
|
||||
rowValue === null ||
|
||||
rowValue === [] ||
|
||||
|
@ -550,7 +519,7 @@ export class NotEmptyViewFilterType extends ViewFilterType {
|
|||
]
|
||||
}
|
||||
|
||||
matches(rowValue, filterValue) {
|
||||
matches(rowValue, filterValue, field, fieldType) {
|
||||
return !(
|
||||
rowValue === null ||
|
||||
rowValue === [] ||
|
||||
|
|
|
@ -99,12 +99,13 @@ export class ViewType extends Registerable {
|
|||
fetch() {}
|
||||
|
||||
/**
|
||||
* Should refresh the data inside a few. This is method could be called when a filter
|
||||
* Should refresh the data inside a view. This is method could be called when a filter
|
||||
* or sort has been changed or when a field is updated or deleted. It should keep the
|
||||
* state as much the same as it was before. So for example the scroll offset should
|
||||
* stay the same if possible.
|
||||
* stay the same if possible. Can throw a RefreshCancelledException when the view
|
||||
* wishes to cancel the current refresh call due to a new refresh call.
|
||||
*/
|
||||
refresh() {}
|
||||
refresh({ store }, view) {}
|
||||
|
||||
/**
|
||||
* Method that is called when a field has been created. This can be useful to
|
||||
|
@ -217,6 +218,20 @@ export class GridViewType extends ViewType {
|
|||
})
|
||||
}
|
||||
|
||||
async fieldUpdated({ dispatch }, field, oldField, fieldType) {
|
||||
// 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(
|
||||
'view/grid/updateSearch',
|
||||
{},
|
||||
{
|
||||
root: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
isCurrentView(store, tableId) {
|
||||
const table = store.getters['table/getSelected']
|
||||
const grid = store.getters['view/getSelected']
|
||||
|
|
|
@ -54,6 +54,7 @@
|
|||
"eslint-plugin-promise": ">=4.0.1",
|
||||
"eslint-plugin-standard": ">=4.0.0",
|
||||
"eslint-plugin-vue": "^7.5.0",
|
||||
"flush-promises": "^1.0.2",
|
||||
"jest": "^26.6.3",
|
||||
"jsdom": "^16.2.2",
|
||||
"jsdom-global": "^3.0.2",
|
||||
|
|
22
web-frontend/test/fixtures/applications.js
vendored
Normal file
22
web-frontend/test/fixtures/applications.js
vendored
Normal file
|
@ -0,0 +1,22 @@
|
|||
export function createApplication(
|
||||
mock,
|
||||
{ applicationId = 1, groupId = 1, tables = [] }
|
||||
) {
|
||||
const application = {
|
||||
id: applicationId,
|
||||
name: 'Test Database App',
|
||||
order: applicationId,
|
||||
type: 'database',
|
||||
group: {
|
||||
id: groupId,
|
||||
name: 'Test group',
|
||||
},
|
||||
tables: tables.map((t) => ({
|
||||
...t,
|
||||
count: t.id,
|
||||
database_id: applicationId,
|
||||
})),
|
||||
}
|
||||
mock.onGet('/applications/').reply(200, [application])
|
||||
return application
|
||||
}
|
25
web-frontend/test/fixtures/fields.js
vendored
Normal file
25
web-frontend/test/fixtures/fields.js
vendored
Normal file
|
@ -0,0 +1,25 @@
|
|||
export function createFile(visibleName) {
|
||||
return {
|
||||
url: 'some_url',
|
||||
thumbnails: {},
|
||||
visible_name: visibleName,
|
||||
name: `actual_name_for_${visibleName}`,
|
||||
size: 10,
|
||||
mime_type: 'text/plain',
|
||||
is_image: false,
|
||||
image_width: 0,
|
||||
image_height: 0,
|
||||
uploaded_at: '2019-08-24T14:15:22Z',
|
||||
}
|
||||
}
|
||||
|
||||
export function createFields(mock, application, table, fields) {
|
||||
let nextId = 1
|
||||
const fieldsWithIds = fields.map((f) => ({
|
||||
id: nextId++,
|
||||
table_id: table.id,
|
||||
...f,
|
||||
}))
|
||||
mock.onGet(`/database/fields/table/${table.id}/`).reply(200, fieldsWithIds)
|
||||
return fieldsWithIds
|
||||
}
|
15
web-frontend/test/fixtures/grid.js
vendored
Normal file
15
web-frontend/test/fixtures/grid.js
vendored
Normal file
|
@ -0,0 +1,15 @@
|
|||
export function createRows(mock, gridView, fields, rows = []) {
|
||||
const fieldOptions = {}
|
||||
for (let i = 1; i < fields.length; i++) {
|
||||
fieldOptions[i] = {
|
||||
width: 200,
|
||||
hidden: false,
|
||||
order: i,
|
||||
}
|
||||
}
|
||||
mock.onGet(`/database/views/grid/${gridView.id}/`).reply(200, {
|
||||
count: rows.length,
|
||||
results: rows,
|
||||
field_options: fieldOptions,
|
||||
})
|
||||
}
|
10
web-frontend/test/fixtures/groups.js
vendored
Normal file
10
web-frontend/test/fixtures/groups.js
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
export function createGroup(mock, { groupId = 1 }) {
|
||||
const group = {
|
||||
order: groupId,
|
||||
permissions: 'ADMIN',
|
||||
id: groupId,
|
||||
name: `group_${groupId}`,
|
||||
}
|
||||
mock.onGet('/groups/').reply(200, [group])
|
||||
return group
|
||||
}
|
75
web-frontend/test/fixtures/mockServer.js
vendored
Normal file
75
web-frontend/test/fixtures/mockServer.js
vendored
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { createApplication } from '@baserow/test/fixtures/applications'
|
||||
import { createGroup } from '@baserow/test/fixtures/groups'
|
||||
import { createGridView } from '@baserow/test/fixtures/view'
|
||||
import { createFields } from '@baserow/test/fixtures/fields'
|
||||
import { createRows } from '@baserow/test/fixtures/grid'
|
||||
|
||||
/**
|
||||
* MockServer is responsible for being the single place where we mock out calls to the
|
||||
* baserow server API in tests. This way when an API change is made we should only
|
||||
* need to make one change in this class to reflect the change in the tests.
|
||||
*/
|
||||
export class MockServer {
|
||||
constructor(mock, store) {
|
||||
this.mock = mock
|
||||
this.store = store
|
||||
}
|
||||
|
||||
async createAppAndGroup(table) {
|
||||
const group = createGroup(this.mock, {})
|
||||
const application = createApplication(this.mock, {
|
||||
groupId: group.id,
|
||||
tables: [table],
|
||||
})
|
||||
await this.store.dispatch('group/fetchAll')
|
||||
await this.store.dispatch('application/fetchAll')
|
||||
return { application, group }
|
||||
}
|
||||
|
||||
createTable() {
|
||||
return { id: 1, name: 'Test Table 1' }
|
||||
}
|
||||
|
||||
createGridView(application, table, filters = []) {
|
||||
return createGridView(this.mock, application, table, {
|
||||
filters,
|
||||
})
|
||||
}
|
||||
|
||||
createFields(application, table, fields) {
|
||||
return createFields(this.mock, application, table, fields)
|
||||
}
|
||||
|
||||
createRows(gridView, fields, rows) {
|
||||
return createRows(this.mock, gridView, fields, rows)
|
||||
}
|
||||
|
||||
nextSearchForTermWillReturn(searchTerm, gridView, results) {
|
||||
this.mock
|
||||
.onGet(`/database/views/grid/${gridView.id}/`, {
|
||||
params: { count: true, search: searchTerm },
|
||||
})
|
||||
.reply(200, {
|
||||
count: results.length,
|
||||
})
|
||||
|
||||
this.mock
|
||||
.onGet(`/database/views/grid/${gridView.id}/`, {
|
||||
params: { limit: 120, offset: 0, search: searchTerm },
|
||||
})
|
||||
.reply(200, {
|
||||
count: results.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
||||
creatingRowInTableReturns(table, result) {
|
||||
this.mock.onPost(`/database/rows/table/${table.id}/`).reply(200, result)
|
||||
}
|
||||
|
||||
resetMockEndpoints() {
|
||||
this.mock.reset()
|
||||
}
|
||||
}
|
26
web-frontend/test/fixtures/view.js
vendored
Normal file
26
web-frontend/test/fixtures/view.js
vendored
Normal file
|
@ -0,0 +1,26 @@
|
|||
export function createGridView(
|
||||
mock,
|
||||
application,
|
||||
table,
|
||||
{ viewType = 'grid', viewId = 1, filters = [] }
|
||||
) {
|
||||
const tableId = table.id
|
||||
const gridView = {
|
||||
id: viewId,
|
||||
table_id: tableId,
|
||||
name: `mock_view_${viewId}`,
|
||||
order: 0,
|
||||
type: viewType,
|
||||
table: {
|
||||
id: tableId,
|
||||
name: table.name,
|
||||
order: 0,
|
||||
database_id: application.id,
|
||||
},
|
||||
filter_type: 'AND',
|
||||
filters_disabled: false,
|
||||
filters,
|
||||
}
|
||||
mock.onGet(`/database/views/table/${tableId}/`).reply(200, [gridView])
|
||||
return gridView
|
||||
}
|
58
web-frontend/test/helpers/components.js
Normal file
58
web-frontend/test/helpers/components.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
import Vuelidate from 'vuelidate'
|
||||
import Vue from 'vue'
|
||||
import Vuex from 'vuex'
|
||||
|
||||
const addVuex = (context) => {
|
||||
context.vuex = Vuex
|
||||
context.vue.use(context.vuex)
|
||||
}
|
||||
const addVuelidate = (context) => {
|
||||
context.vuelidate = Vuelidate
|
||||
context.vue.use(context.vuelidate)
|
||||
}
|
||||
const addBus = (context) => {
|
||||
context.vue_bus = Vue
|
||||
const EventBus = new Vue()
|
||||
|
||||
const EventBusPlugin = {
|
||||
install(v) {
|
||||
// Event bus
|
||||
v.prototype.$bus = EventBus
|
||||
},
|
||||
}
|
||||
context.vue.use(EventBusPlugin)
|
||||
}
|
||||
const compositeConfiguration = (...configs) => {
|
||||
return (context) => configs.forEach((config) => config(context))
|
||||
}
|
||||
|
||||
export const bootstrapVueContext = (configureContext) => {
|
||||
configureContext =
|
||||
configureContext || compositeConfiguration(addVuex, addVuelidate, addBus)
|
||||
|
||||
const context = {}
|
||||
const teardownVueContext = () => {
|
||||
jest.resetModules()
|
||||
}
|
||||
|
||||
jest.isolateModules(() => {
|
||||
context.vueTestUtils = require('@vue/test-utils')
|
||||
context.vueTestUtils.config.stubs.nuxt = { template: '<div />' }
|
||||
context.vueTestUtils.config.stubs['nuxt-link'] = {
|
||||
template: '<a><slot /></a>',
|
||||
}
|
||||
context.vueTestUtils.config.stubs['no-ssr'] = {
|
||||
template: '<span><slot /></span>',
|
||||
}
|
||||
context.vue = context.vueTestUtils.createLocalVue()
|
||||
|
||||
jest.doMock('vue', () => context.vue)
|
||||
|
||||
configureContext && configureContext(context)
|
||||
})
|
||||
|
||||
return {
|
||||
teardownVueContext,
|
||||
...context,
|
||||
}
|
||||
}
|
144
web-frontend/test/helpers/testApp.js
Normal file
144
web-frontend/test/helpers/testApp.js
Normal file
|
@ -0,0 +1,144 @@
|
|||
import setupCore from '@baserow/modules/core/plugin'
|
||||
import axios from 'axios'
|
||||
import setupDatabasePlugin from '@baserow/modules/database/plugin'
|
||||
import { bootstrapVueContext } from '@baserow/test/helpers/components'
|
||||
import MockAdapter from 'axios-mock-adapter'
|
||||
import _ from 'lodash'
|
||||
import { MockServer } from '@baserow/test/fixtures/mockServer'
|
||||
import flushPromises from 'flush-promises'
|
||||
|
||||
/**
|
||||
* Uses the real baserow plugins to setup a Vuex store and baserow registry
|
||||
* correctly.
|
||||
*/
|
||||
function _createBaserowStoreAndRegistry(app, vueContext) {
|
||||
const store = new vueContext.vuex.Store({})
|
||||
setupCore({ store, app }, (name, dep) => {
|
||||
app[`$${name}`] = dep
|
||||
})
|
||||
store.$registry = app.$registry
|
||||
store.$client = axios
|
||||
store.app = app
|
||||
app.$store = store
|
||||
setupDatabasePlugin({
|
||||
store,
|
||||
app,
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
/**
|
||||
* An acceptance testing framework for testing Baserow components and surrounding logic
|
||||
* like stores.
|
||||
* TestApp sets up baserow components, registries and stores so they work out of the
|
||||
* box and can be tested without having to:
|
||||
* - wait 30+ seconds for a Nuxt server to startup and build
|
||||
* - mock out stores, registries or carve arbitrary boundaries in
|
||||
* the tests causing problems when store and component logic is tightly
|
||||
* coupled.
|
||||
*
|
||||
* To use create an instance of this class in your beforeAll
|
||||
* test suite hook and make sure to call testApp.afterEach() in the afterEach hook.
|
||||
*
|
||||
* The following attributes are exposed for use in your tests:
|
||||
* testApp.mockServer : a helper class providing methods to initialize a fake
|
||||
* baserow server with consistent test data.
|
||||
* testApp.mock : a mock axios adapter used to mock out HTTP calls to the server,
|
||||
* also used by testApp.mockServer to actually do the server call
|
||||
* mocking.
|
||||
* testApp.store : a Vuex store populated with Baserow's stores ready for you to
|
||||
* commit, get and dispatch to.
|
||||
* UIHelpers : a collection of methods which know how to perform common actions
|
||||
* on Baserow's components.
|
||||
*
|
||||
*/
|
||||
export class TestApp {
|
||||
constructor() {
|
||||
// In the future we can extend this stub realtime implementation to perform
|
||||
// useful testing of realtime interaction in the frontend!
|
||||
this._realtime = {
|
||||
registerEvent(e, f) {},
|
||||
subscribe(e, f) {},
|
||||
}
|
||||
// Various stub and mock attributes which will be injected into components
|
||||
// mounted using TestApp.
|
||||
this._app = {
|
||||
$realtime: this._realtime,
|
||||
$cookies: {
|
||||
set(name, id, value) {},
|
||||
},
|
||||
$env: {
|
||||
PUBLIC_WEB_FRONTEND_URL: 'https://localhost/',
|
||||
},
|
||||
}
|
||||
this.mock = new MockAdapter(axios)
|
||||
this._vueContext = bootstrapVueContext()
|
||||
this.store = _createBaserowStoreAndRegistry(this._app, this._vueContext)
|
||||
this._initialCleanStoreState = _.cloneDeep(this.store.state)
|
||||
this.mockServer = new MockServer(this.mock, this.store)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up after a test run performed by TestApp. Make sure you call this
|
||||
* in your test suits afterEach method!
|
||||
*/
|
||||
async afterEach() {
|
||||
this.mock.reset()
|
||||
this.store.replaceState(_.cloneDeep(this._initialCleanStoreState))
|
||||
this._vueContext.teardownVueContext()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a fully rendered version of the provided Component and calls
|
||||
* asyncData on the component at the correct time with the provided params.
|
||||
*/
|
||||
async mount(Component, { asyncDataParams = {} }) {
|
||||
if (Object.prototype.hasOwnProperty.call(Component, 'asyncData')) {
|
||||
const data = await Component.asyncData({
|
||||
store: this.store,
|
||||
params: asyncDataParams,
|
||||
error: fail,
|
||||
app: this._app,
|
||||
})
|
||||
Component.data = function () {
|
||||
return data
|
||||
}
|
||||
}
|
||||
return this._vueContext.vueTestUtils.mount(Component, {
|
||||
localVue: this._vueContext.vue,
|
||||
mocks: this._app,
|
||||
})
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Various helper functions which interact with baserow components. Lean towards
|
||||
* putting and sharing any test code which relies on specific details of how baserow
|
||||
* components are structured and styled in here This way there is a single place
|
||||
* to fix when changes are made to the components instead of 30 different test cases.
|
||||
*/
|
||||
export const UIHelpers = {
|
||||
async performSearch(tableComponent, searchTerm) {
|
||||
const searchBox = tableComponent.get(
|
||||
'input[placeholder*="Search in all rows"]'
|
||||
)
|
||||
await searchBox.setValue(searchTerm)
|
||||
await searchBox.trigger('submit')
|
||||
await flushPromises()
|
||||
},
|
||||
async startEditForCellContaining(tableComponent, htmlInsideCellToSearchFor) {
|
||||
const targetCell = tableComponent
|
||||
.findAll('.grid-view__cell')
|
||||
.filter((w) => w.html().includes(htmlInsideCellToSearchFor))
|
||||
.at(0)
|
||||
|
||||
await targetCell.trigger('click')
|
||||
|
||||
const activeCell = tableComponent.get('.grid-view__cell.active')
|
||||
// Double click to start editing cell
|
||||
await activeCell.trigger('click')
|
||||
await activeCell.trigger('click')
|
||||
|
||||
return activeCell.find('input')
|
||||
},
|
||||
}
|
22
web-frontend/test/jest.base.config.js
Normal file
22
web-frontend/test/jest.base.config.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
module.exports = {
|
||||
rootDir: '../../',
|
||||
testEnvironment: 'node',
|
||||
expand: true,
|
||||
forceExit: true,
|
||||
moduleNameMapper: {
|
||||
'^@baserow/(.*)$': '<rootDir>/$1',
|
||||
'^@/(.*)$': '<rootDir>/$1',
|
||||
'^~/(.*)$': '<rootDir>/$1',
|
||||
'^vue$': 'vue/dist/vue.common.js',
|
||||
},
|
||||
moduleFileExtensions: ['js', 'vue', 'json'],
|
||||
transform: {
|
||||
'^.+\\.js$': 'babel-jest',
|
||||
'.*\\.(vue)$': 'vue-jest',
|
||||
},
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: [
|
||||
'<rootDir>/components/**/*.vue',
|
||||
'<rootDir>/pages/**/*.vue',
|
||||
],
|
||||
}
|
9
web-frontend/test/server/jest.config.js
Normal file
9
web-frontend/test/server/jest.config.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
const baseConfig = require('../jest.base.config')
|
||||
|
||||
module.exports = Object.assign({}, baseConfig, {
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['<rootDir>/test/server/**/*.spec.js'],
|
||||
displayName: 'server',
|
||||
name: 'server',
|
||||
setupFilesAfterEnv: ['./test/server/jest.setup.js'],
|
||||
})
|
47
web-frontend/test/unit/core/components/error.spec.js
Normal file
47
web-frontend/test/unit/core/components/error.spec.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
import Error from '@baserow/modules/core/components/Error'
|
||||
import { bootstrapVueContext } from '@baserow/test/helpers/components'
|
||||
|
||||
describe('Error Component Tests', () => {
|
||||
let vueContext = null
|
||||
|
||||
beforeEach(() => {
|
||||
vueContext = bootstrapVueContext()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vueContext.teardownVueContext()
|
||||
})
|
||||
|
||||
function errorComponent(props) {
|
||||
return vueContext.vueTestUtils.shallowMount(Error, {
|
||||
localVue: vueContext.vue,
|
||||
propsData: props,
|
||||
})
|
||||
}
|
||||
|
||||
test('When visible prop is true title and message are shown', () => {
|
||||
const error = errorComponent({
|
||||
error: {
|
||||
visible: true,
|
||||
title: 'TestError',
|
||||
message: 'message',
|
||||
},
|
||||
})
|
||||
|
||||
const html = error.html()
|
||||
expect(html).toMatch('TestError')
|
||||
expect(html).toMatch('message')
|
||||
})
|
||||
|
||||
test('When visible prop is false no html is rendered', () => {
|
||||
const error = errorComponent({
|
||||
error: {
|
||||
visible: false,
|
||||
title: 'TestError',
|
||||
message: 'message',
|
||||
},
|
||||
})
|
||||
|
||||
expect(error.html()).toStrictEqual('')
|
||||
})
|
||||
})
|
166
web-frontend/test/unit/database/table.spec.js
Normal file
166
web-frontend/test/unit/database/table.spec.js
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { TestApp, UIHelpers } from '@baserow/test/helpers/testApp'
|
||||
import Table from '@baserow/modules/database/pages/table'
|
||||
import flushPromises from 'flush-promises'
|
||||
|
||||
// Mock out debounce so we dont have to wait or simulate waiting for the various
|
||||
// debounces in the search functionality.
|
||||
jest.mock('lodash/debounce', () => jest.fn((fn) => fn))
|
||||
|
||||
describe('Table Component Tests', () => {
|
||||
let testApp = null
|
||||
let mockServer = null
|
||||
|
||||
beforeAll(() => {
|
||||
testApp = new TestApp()
|
||||
mockServer = testApp.mockServer
|
||||
})
|
||||
|
||||
afterEach(() => testApp.afterEach())
|
||||
|
||||
test('Adding a row to a table increases the row count', async () => {
|
||||
const {
|
||||
application,
|
||||
table,
|
||||
gridView,
|
||||
} = await givenASingleSimpleTableInTheServer()
|
||||
|
||||
const tableComponent = await testApp.mount(Table, {
|
||||
asyncDataParams: {
|
||||
databaseId: application.id,
|
||||
tableId: table.id,
|
||||
viewId: gridView.id,
|
||||
},
|
||||
})
|
||||
|
||||
expect(tableComponent.html()).toContain('1 rows')
|
||||
|
||||
mockServer.creatingRowInTableReturns(table, {
|
||||
id: 2,
|
||||
order: '2.00000000000000000000',
|
||||
field_1: '',
|
||||
field_2: '',
|
||||
field_3: '',
|
||||
field_4: false,
|
||||
})
|
||||
|
||||
const button = tableComponent.find('.grid-view__add-row')
|
||||
await button.trigger('click')
|
||||
|
||||
expect(tableComponent.html()).toContain('2 rows')
|
||||
})
|
||||
|
||||
test('Searching for a cells value highlights it', async () => {
|
||||
const {
|
||||
application,
|
||||
table,
|
||||
gridView,
|
||||
} = await givenASingleSimpleTableInTheServer()
|
||||
|
||||
const tableComponent = await testApp.mount(Table, {
|
||||
asyncDataParams: {
|
||||
databaseId: application.id,
|
||||
tableId: table.id,
|
||||
viewId: gridView.id,
|
||||
},
|
||||
})
|
||||
|
||||
mockServer.resetMockEndpoints()
|
||||
mockServer.nextSearchForTermWillReturn('last_name', gridView, [
|
||||
{
|
||||
id: 1,
|
||||
order: 0,
|
||||
field_1: 'name',
|
||||
field_2: 'last_name',
|
||||
field_3: 'notes',
|
||||
field_4: false,
|
||||
},
|
||||
])
|
||||
|
||||
await UIHelpers.performSearch(tableComponent, 'last_name')
|
||||
|
||||
expect(
|
||||
tableComponent
|
||||
.findAll('.grid-view__column--matches-search')
|
||||
.filter((w) => w.html().includes('last_name')).length
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
test('Editing a search highlighted cells value so it will no longer match warns', async () => {
|
||||
const {
|
||||
application,
|
||||
table,
|
||||
gridView,
|
||||
} = await givenASingleSimpleTableInTheServer()
|
||||
|
||||
const tableComponent = await testApp.mount(Table, {
|
||||
asyncDataParams: {
|
||||
databaseId: application.id,
|
||||
tableId: table.id,
|
||||
viewId: gridView.id,
|
||||
},
|
||||
})
|
||||
|
||||
mockServer.resetMockEndpoints()
|
||||
mockServer.nextSearchForTermWillReturn('last_name', gridView, [
|
||||
{
|
||||
id: 1,
|
||||
order: 0,
|
||||
field_1: 'name',
|
||||
field_2: 'last_name',
|
||||
field_3: 'notes',
|
||||
field_4: false,
|
||||
},
|
||||
])
|
||||
|
||||
await UIHelpers.performSearch(tableComponent, 'last_name')
|
||||
|
||||
const input = await UIHelpers.startEditForCellContaining(
|
||||
tableComponent,
|
||||
'last_name'
|
||||
)
|
||||
|
||||
await input.setValue('Doesnt Match Search Term')
|
||||
expect(tableComponent.html()).toContain('Row does not match search')
|
||||
|
||||
await input.setValue('last_name')
|
||||
expect(tableComponent.html()).not.toContain('Row does not match search')
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
async function givenASingleSimpleTableInTheServer() {
|
||||
const table = mockServer.createTable()
|
||||
const { application } = await mockServer.createAppAndGroup(table)
|
||||
const gridView = mockServer.createGridView(application, table)
|
||||
const fields = mockServer.createFields(application, table, [
|
||||
{
|
||||
name: 'Name',
|
||||
type: 'text',
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
name: 'Last name',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Notes',
|
||||
type: 'long_text',
|
||||
},
|
||||
{
|
||||
name: 'Active',
|
||||
type: 'boolean',
|
||||
},
|
||||
])
|
||||
|
||||
mockServer.createRows(gridView, fields, [
|
||||
{
|
||||
id: 1,
|
||||
order: 0,
|
||||
field_1: 'name',
|
||||
field_2: 'last_name',
|
||||
field_3: 'notes',
|
||||
field_4: false,
|
||||
},
|
||||
])
|
||||
return { application, table, gridView }
|
||||
}
|
||||
})
|
109
web-frontend/test/unit/database/viewFilters.spec.js
Normal file
109
web-frontend/test/unit/database/viewFilters.spec.js
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { TestApp } from '@baserow/test/helpers/testApp'
|
||||
import { createFile } from '@baserow/test/fixtures/fields'
|
||||
import {
|
||||
EqualViewFilterType,
|
||||
FilenameContainsViewFilterType,
|
||||
} from '@baserow/modules/database/viewFilters'
|
||||
|
||||
describe('View Filter Tests', () => {
|
||||
let testApp = null
|
||||
let mockServer = null
|
||||
let store = null
|
||||
|
||||
beforeAll(() => {
|
||||
testApp = new TestApp()
|
||||
mockServer = testApp.mockServer
|
||||
store = testApp.store
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
testApp.afterEach()
|
||||
})
|
||||
|
||||
test('When An Equals Filter is applied a string field correctly indicates if the row will continue to match after an edit', async () => {
|
||||
await thereIsATableWithRowAndFilter(
|
||||
{
|
||||
name: 'Text Field Field',
|
||||
type: 'text',
|
||||
primary: true,
|
||||
},
|
||||
{ id: 1, order: 0, field_1: 'exactly_matching_string' },
|
||||
{
|
||||
id: 1,
|
||||
view: 1,
|
||||
field: 1,
|
||||
type: EqualViewFilterType.getType(),
|
||||
value: 'exactly_matching_string',
|
||||
}
|
||||
)
|
||||
|
||||
const row = store.getters['view/grid/getRow'](1)
|
||||
|
||||
await editFieldWithoutSavingNewValue(row, 'exactly_matching_string')
|
||||
expect(row._.matchFilters).toBe(true)
|
||||
|
||||
await editFieldWithoutSavingNewValue(row, 'newly_edited_value_not_matching')
|
||||
expect(row._.matchFilters).toBe(false)
|
||||
})
|
||||
|
||||
async function thereIsATableWithRowAndFilter(field, row, filter) {
|
||||
const table = mockServer.createTable()
|
||||
const { application } = await mockServer.createAppAndGroup(table)
|
||||
const gridView = mockServer.createGridView(application, table, [filter])
|
||||
const fields = mockServer.createFields(application, table, [field])
|
||||
|
||||
mockServer.createRows(gridView, fields, [row])
|
||||
await store.dispatch('view/grid/fetchInitial', { gridId: 1 })
|
||||
await store.dispatch('view/fetchAll', { id: 1 })
|
||||
}
|
||||
|
||||
test('When An Filename Contains Filter is applied a file field correctly indicates if the row will continue to match after an edit', async () => {
|
||||
await thereIsATableWithRowAndFilter(
|
||||
{
|
||||
name: 'File Field',
|
||||
type: 'file',
|
||||
primary: true,
|
||||
},
|
||||
{ id: 1, order: 0, field_1: [createFile('test_file_name')] },
|
||||
{
|
||||
id: 1,
|
||||
view: 1,
|
||||
field: 1,
|
||||
type: FilenameContainsViewFilterType.getType(),
|
||||
value: 'test_file_name',
|
||||
}
|
||||
)
|
||||
|
||||
const row = store.getters['view/grid/getRow'](1)
|
||||
|
||||
await editFieldWithoutSavingNewValue(row, [createFile('test_file_name')])
|
||||
expect(row._.matchFilters).toBe(true)
|
||||
|
||||
await editFieldWithoutSavingNewValue(row, [
|
||||
createFile('not_matching_new_file_name'),
|
||||
])
|
||||
expect(row._.matchFilters).toBe(false)
|
||||
|
||||
await editFieldWithoutSavingNewValue(row, [
|
||||
createFile('test_file_name'),
|
||||
createFile('not_matching_new_file_name'),
|
||||
])
|
||||
expect(row._.matchFilters).toBe(true)
|
||||
})
|
||||
|
||||
async function editFieldWithoutSavingNewValue(row, newValue) {
|
||||
await store.dispatch('view/grid/updateMatchFilters', {
|
||||
view: store.getters['view/first'],
|
||||
fields: [],
|
||||
primary: {
|
||||
id: 1,
|
||||
type: 'file',
|
||||
primary: true,
|
||||
},
|
||||
row,
|
||||
overrides: {
|
||||
field_1: newValue,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
8
web-frontend/test/unit/jest.config.js
Normal file
8
web-frontend/test/unit/jest.config.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
const baseConfig = require('../jest.base.config')
|
||||
|
||||
module.exports = Object.assign({}, baseConfig, {
|
||||
testEnvironment: 'jsdom',
|
||||
testMatch: ['<rootDir>/test/unit/**/*.spec.js'],
|
||||
displayName: 'unit',
|
||||
setupFilesAfterEnv: ['./test/unit/jest.setup.js'],
|
||||
})
|
13
web-frontend/test/unit/jest.setup.js
Normal file
13
web-frontend/test/unit/jest.setup.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Vue from 'vue'
|
||||
import { config } from '@vue/test-utils'
|
||||
|
||||
Vue.config.silent = true
|
||||
|
||||
// Mock Nuxt components
|
||||
config.stubs.nuxt = { template: '<div />' }
|
||||
config.stubs['nuxt-link'] = { template: '<a><slot /></a>' }
|
||||
config.stubs['no-ssr'] = { template: '<span><slot /></span>' }
|
||||
|
||||
process.on('unhandledRejection', (err) => {
|
||||
fail(err)
|
||||
})
|
|
@ -5138,6 +5138,11 @@ flatten@^1.0.2:
|
|||
resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.3.tgz#c1283ac9f27b368abc1e36d1ff7b04501a30356b"
|
||||
integrity sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==
|
||||
|
||||
flush-promises@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/flush-promises/-/flush-promises-1.0.2.tgz#4948fd58f15281fed79cbafc86293d5bb09b2ced"
|
||||
integrity sha512-G0sYfLQERwKz4+4iOZYQEZVpOt9zQrlItIxQAAYAWpfby3gbHrx0osCHz5RLl/XoXevXk0xoN4hDFky/VV9TrA==
|
||||
|
||||
flush-write-stream@^1.0.0:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
|
||||
|
|
Loading…
Add table
Reference in a new issue