1
0
Fork 0
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 

See merge request 
This commit is contained in:
Nigel Gott 2021-04-08 11:37:37 +00:00
commit 1473f56997
55 changed files with 1508 additions and 156 deletions

View file

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

View file

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

View file

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

View file

@ -13,3 +13,6 @@ jest:
yarn run jest-all || exit;
test: jest
unit-test-watch:
yarn run jest test/unit --watch || exit;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -27,4 +27,8 @@
&:focus {
outline: 0;
}
.grid-view__column--matches-search & {
background-color: $color-primary-100;
}
}

View file

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

View file

@ -37,6 +37,10 @@
margin-top: 40px !important;
}
.margin-bottom-0 {
margin-bottom: 0 !important;
}
.margin-bottom-1 {
margin-bottom: 8px !important;
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -254,6 +254,9 @@ export const getters = {
getAll(state) {
return state.items
},
getAllWithPrimary(state) {
return [state.primary, ...state.items]
},
}
export default {

View file

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

View 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)
}

View file

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

View file

@ -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 === [] ||

View file

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

View file

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

View 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
View 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
View 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
View 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
}

View 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
View 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
}

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

View 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')
},
}

View 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',
],
}

View 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'],
})

View 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('')
})
})

View 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 }
}
})

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

View 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'],
})

View 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)
})

View file

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