1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-16 10:01:05 +00:00

🔍 2️⃣ - UI for advanced filtering

This commit is contained in:
Davide Silvestri 2023-10-19 11:09:25 +00:00
parent 1a53ed6571
commit d55ac7ccd4
31 changed files with 3224 additions and 1886 deletions

View file

@ -1,18 +1,18 @@
.conditional-color-value-provider-form__color {
background-color: $color-neutral-100;
padding: 12px 30px;
margin: 0 -20px;
margin-bottom: 12px;
& .filters__item {
margin-left: 1px;
}
background-color: $color-neutral-10;
padding-top: 12px;
padding-right: 2px;
margin-top: 4px;
border-radius: 6px;
border: 1px solid $color-neutral-200;
}
.conditional-color-value-provider-form__color-header {
display: flex;
justify-content: left;
align-items: center;
padding: 0 16px;
margin-bottom: 12px;
}
.conditional-color-value-provider-form__color-handle {
@ -26,25 +26,25 @@
}
.conditional-color-value-provider-form__color-trash-link {
display: flex;
justify-content: center;
align-items: center;
color: $color-neutral-900;
padding: 6px;
margin-left: 4px;
color: $palette-neutral-900;
@include flex-align-items(4px, inline-flex);
@include rounded($rounded);
&:hover {
text-decoration: none;
background-color: $color-neutral-100;
color: $palette-neutral-1100;
}
}
.conditional-color-value-provider-form__color-trash-icon {
font-size: 16px;
}
.conditional-color-value-provider-form__color-color {
@extend %option-shadow;
padding: 6px 9px;
padding: 9px;
text-align: center;
color: $color-neutral-900;
font-size: 14px;
@ -55,15 +55,71 @@
}
.conditional-color-value-provider-form__color-filter--empty {
padding: 12px;
padding: 0 12px;
white-space: normal;
text-align: center;
margin-left: 12px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
:first-child {
font-weight: 600;
font-size: 14px;
}
}
.conditional-color-value-provider-form__color-filter-add {
color: $palette-neutral-900;
margin-right: 12px;
@include flex-align-items(4px, inline-flex);
&:hover {
text-decoration: none;
color: $palette-neutral-1100;
}
}
.conditional-color-value-provider-form__color-filter-action-icon {
font-size: 20px;
}
.conditional-color-value-provider-form__color-add {
display: flex;
align-items: center;
gap: 4px;
color: $palette-neutral-900;
&:hover {
text-decoration: none;
color: $palette-neutral-1100;
}
}
.conditional-color-value-provider-form__color-filters {
margin-bottom: 12px;
padding: 0 6px 0 12px;
}
.conditional-color-value-provider-form__color-filter-add {
color: $color-neutral-900;
.conditional-color-value-provider-form__color-filter-actions {
display: flex;
align-items: center;
border-top: 1px solid $color-neutral-200;
padding: 12px 16px;
}
.conditional-color-value-provider-form__colors-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24px 4px 0 4px;
}
.conditional-color-value-provider-form__colors-header-title {
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
line-height: 140%;
}

View file

@ -1,6 +1,20 @@
<template>
<div>
<div>
<div class="conditional-color-value-provider-form__colors-header">
<div class="conditional-color-value-provider-form__colors-header-title">
{{ $t('conditionalColorValueProviderForm.title') }}
</div>
<a
class="conditional-color-value-provider-form__color-add"
@click.prevent="addColor()"
>
<i
class="iconoir-plus conditional-color-value-provider-form__color-filter-action-icon"
></i>
{{ $t('conditionalColorValueProviderForm.addColor') }}
</a>
</div>
<div
v-for="color in options.colors || []"
:key="color.id"
@ -16,7 +30,7 @@
<div
class="conditional-color-value-provider-form__color-handle"
data-sortable-handle
/>
></div>
<a
:ref="`colorSelect-${color.id}`"
class="conditional-color-value-provider-form__color-color"
@ -25,51 +39,78 @@
>
<i class="iconoir-nav-arrow-down"></i>
</a>
<div :style="{ flex: 1 }" />
<a
v-if="options.colors.length > 1"
class="conditional-color-value-provider-form__color-trash-link"
@click="deleteColor(color)"
<div
v-if="color.filters.length === 0"
class="conditional-color-value-provider-form__color-filter--empty"
>
<i class="iconoir-bin" />
</a>
</div>
<div
v-if="color.filters.length === 0"
class="conditional-color-value-provider-form__color-filter--empty"
>
{{ $t('conditionalColorValueProviderForm.colorAlwaysApply') }}
<div>
{{
$t('conditionalColorValueProviderForm.colorAlwaysApplyTitle')
}}
</div>
<div>
{{ $t('conditionalColorValueProviderForm.colorAlwaysApply') }}
</div>
</div>
</div>
<ViewFieldConditionsForm
v-show="color.filters.length !== 0"
class="conditional-color-value-provider-form__color-filters"
:filters="color.filters"
:filter-groups="color.filter_groups"
:disable-filter="false"
:filter-type="color.operator"
:fields="fields"
:view="view"
:read-only="readOnly"
:variant="'dark'"
@addFilter="addFilter(color, $event)"
@deleteFilter="deleteFilter(color, $event)"
@updateFilter="updateFilter(color, $event)"
@selectOperator="updateColor(color, { operator: $event })"
@deleteFilterGroup="deleteFilterGroup(color, $event)"
@selectFilterGroupOperator="updateFilterGroupOperator(color, $event)"
/>
<a
class="conditional-color-value-provider-form__color-filter-add"
@click.prevent="addFilter(color)"
>
<i class="iconoir-plus"></i>
{{ $t('conditionalColorValueProviderForm.addCondition') }}</a
<div
class="conditional-color-value-provider-form__color-filter-actions"
>
<a
class="conditional-color-value-provider-form__color-filter-add"
@click.prevent="addFilter(color)"
>
<i
class="iconoir-plus conditional-color-value-provider-form__color-filter-action-icon"
></i>
{{ $t('conditionalColorValueProviderForm.addCondition') }}
</a>
<a
v-if="$featureFlagIsEnabled('advanced-filters')"
class="conditional-color-value-provider-form__color-filter-add"
@click.prevent="addFilterGroup(color)"
>
<i
class="iconoir-plus conditional-color-value-provider-form__color-filter-action-icon"
></i>
{{ $t('conditionalColorValueProviderForm.addConditionGroup') }}
</a>
<div :style="{ flex: '1 1 auto' }"></div>
<a
v-if="options.colors.length > 1"
class="conditional-color-value-provider-form__color-trash-link"
@click="deleteColor(color)"
>
<i
class="iconoir-bin conditional-color-value-provider-form__color-trash-link-icon"
></i>
{{ $t('conditionalColorValueProviderForm.deleteColor') }}
</a>
</div>
<ColorSelectContext
:ref="`colorContext-${color.id}`"
@selected="updateColor(color, { color: $event })"
></ColorSelectContext>
</div>
</div>
<a class="colors__add" @click.prevent="addColor()">
<i class="iconoir-plus"></i>
{{ $t('conditionalColorValueProviderForm.addColor') }}</a
>
</div>
</template>
@ -82,6 +123,10 @@ export default {
name: 'ConditionalColorValueProvider',
components: { ViewFieldConditionsForm, ColorSelectContext },
props: {
workspace: {
type: Object,
required: true,
},
options: {
type: Object,
required: true,
@ -158,7 +203,55 @@ export default {
colors: newColors,
})
},
addFilter(color) {
addFilterGroup(color) {
const filterGroup =
ConditionalColorValueProviderType.getDefaultFilterGroupConf()
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.id === color.id) {
return {
...colorConf,
filter_groups: [...(colorConf.filter_groups || []), filterGroup],
filters: [
...colorConf.filters,
ConditionalColorValueProviderType.getDefaultFilterConf(
this.$registry,
{
fields: this.fields,
filterGroupId: filterGroup.id,
}
),
],
}
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
updateFilterGroupOperator(color, { value, filterGroup }) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.id === color.id) {
const newFilterGroups = colorConf.filter_groups.map((group) => {
if (group.id === filterGroup.id) {
return { ...group, filter_type: value }
}
return group
})
return {
...colorConf,
filter_groups: newFilterGroups,
}
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
addFilter(color, filterGroupId = null) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.id === color.id) {
return {
@ -169,6 +262,7 @@ export default {
this.$registry,
{
fields: this.fields,
filterGroupId,
}
),
],
@ -216,6 +310,28 @@ export default {
return colorConf
})
this.$emit('update', {
colors: newColors,
})
},
deleteFilterGroup(color, { group }) {
const newColors = this.options.colors.map((colorConf) => {
if (colorConf.id === color.id) {
const newFilters = colorConf.filters.filter((filter) => {
return filter.group !== group.id
})
const newFilterGroups = colorConf.filter_groups.filter((g) => {
return group.id !== g.id
})
return {
...colorConf,
filters: newFilters,
filter_groups: newFilterGroups,
}
}
return colorConf
})
this.$emit('update', {
colors: newColors,
})

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="margin-top-3">
<ChooseSingleSelectField
:view="view"
:table="table"

View file

@ -55,7 +55,7 @@ export class ConditionalColorValueProviderType extends DecoratorValueProviderTyp
return 'conditional_color'
}
static getDefaultFilterConf(registry, { fields }) {
static getDefaultFilterConf(registry, { fields, filterGroupId = null }) {
const field = fields[0]
const filter = { field: field.id }
@ -71,10 +71,18 @@ export class ConditionalColorValueProviderType extends DecoratorValueProviderTyp
filter.value = viewFilterType.getDefaultValue(field)
filter.preload_values = {}
filter.id = uuid()
filter.group = filterGroupId
return filter
}
static getDefaultFilterGroupConf() {
return {
filter_type: 'AND',
id: uuid(),
}
}
static getDefaultColorConf(
registry,
{ fields },
@ -111,11 +119,23 @@ export class ConditionalColorValueProviderType extends DecoratorValueProviderTyp
getValue({ options, fields, row }) {
const { $registry } = this.app
for (const { color, filters, operator } of options.colors) {
for (const {
color,
filters,
operator,
filter_groups: filterGroups,
} of options.colors) {
if (
row.id !== -1 &&
row.id !== undefined &&
matchSearchFilters($registry, operator, filters, fields, row)
matchSearchFilters(
$registry,
operator,
filters,
filterGroups,
fields,
row
)
) {
return color
}

View file

@ -295,9 +295,13 @@
"chooseAColor": "Which single select field should the row be colored by?"
},
"conditionalColorValueProviderForm": {
"addCondition": "add condition",
"colorAlwaysApply": "This color applies by default. You can add conditions by clicking on the \"Add condition\" button.",
"addColor": "add color"
"addCondition": "Add condition",
"addConditionGroup": "Add condition group",
"colorAlwaysApplyTitle": "This color applies by default.",
"colorAlwaysApply": "You can add conditions by clicking on the \"Add condition\" button.",
"addColor": "Add color",
"deleteColor": "Delete color",
"title": "Colors"
},
"redirectToBaserowModal": {
"title": "Redirecting to http://baserow.io",

View file

@ -681,6 +681,7 @@ export const actions = {
this.$registry,
view.filter_type,
view.filters,
view.filter_groups,
fields,
values
)

View file

@ -462,6 +462,7 @@ export const actions = {
this.$registry,
view.filter_type,
view.filters,
view.filter_groups,
fields,
values
)

View file

@ -21,3 +21,6 @@ test: jest
ci-test-javascript:
yarn test-coverage || exit;
update-snapshots:
yarn run jest --updateSnapshot || exit;

View file

@ -1,17 +1,18 @@
.decorator-context {
width: 600px;
max-height: inherit;
display: flex;
flex-direction: column;
}
.decorator-context__list {
max-height: calc(100vh - 120px);
padding: 12px;
overflow-y: auto;
padding: 12px 24px;
overflow-y: scroll;
min-width: 540px;
}
.decorator-context__decorator {
border-bottom: 1px solid $color-neutral-200;
padding: 18px 0 18px 0;
margin: 0 8px;
&:last-child {
border: none;
@ -23,7 +24,6 @@
display: flex;
width: 100%;
align-items: center;
margin-bottom: 16px;
}
.decorator-context__decorator-header-info {
@ -42,26 +42,32 @@
.decorator-context__decorator-header-trash {
margin-left: 12px;
border-radius: 6px;
border: 1px solid #b5b5b7;
/* Interactive/Elevation/Low */
box-shadow: 0 1px 2px 0 rgba(7, 8, 16, 0.1);
}
.decorator-context__decorator-header-trash-link {
color: $color-neutral-500;
display: flex;
justify-content: center;
align-items: center;
color: $color-neutral-900;
padding: 10px;
border-radius: 4px;
margin-left: 4px;
padding: 9px 7px;
&:hover {
text-decoration: none;
background-color: $color-neutral-100;
color: $color-neutral-900;
}
i {
font-size: 16px;
}
}
.decorator-context__footer {
border-top: 1px solid $color-neutral-200;
box-shadow: 0 -3px 3px 0 rgba($black, 0.05);
border-top: 1px solid rgba(217, 219, 222, 0.5);
padding: 12px;
}

View file

@ -2,6 +2,7 @@
display: flex;
flex-wrap: nowrap;
width: 100%;
margin-top: 12px;
}
.value-provider-list--read-only {

View file

@ -1,14 +1,18 @@
.filters {
padding: 12px;
.dropdown__selected {
@extend %ellipsis;
}
}
.filters__content {
max-height: inherit;
display: flex;
flex-direction: column;
}
.filters__none {
padding: 4px;
margin-bottom: 6px;
margin: 16px 20px;
}
.filters__none-title {
@ -22,54 +26,98 @@
}
.filters__items {
min-width: 540px;
display: flex;
flex-direction: column;
gap: 10px;
&--with-padding {
padding: 16px 8px 10px 10px;
}
&--scrollable {
overflow-y: scroll;
}
}
.filters__item {
position: relative;
display: grid;
align-items: center;
// 142px = 20 + 72 + 10 * 4 (gaps)
grid-template-columns: 20px 72px calc(50% - 132px) 22% 28%;
padding: 6px 0;
grid-template-columns: 68px 1fr;
margin-left: 5px;
column-gap: 10px;
@include rounded($rounded);
&:not(:last-child) {
margin-bottom: 6px;
}
&.filters__item--loading {
&::before {
content: '';
margin-top: -7px;
@include loading(14px);
@include absolute(50%, auto, 0, 3px);
&.filters__item--level-1 {
.filters__items--full-width & {
padding-right: 8px;
}
}
}
.filters__group-item-wrapper {
display: grid;
grid-template-columns: 68px 1fr;
column-gap: 10px;
margin-left: 5px;
align-items: baseline;
}
.filters__group-item {
background-color: $color-neutral-100;
border-radius: 6px;
border: 1px solid $color-neutral-200;
}
.filters__group-item-filters {
min-height: 20px;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filters__group-item-actions {
border-top: 1px solid rgba(217, 219, 222, 0.5);
padding: 8px 16px;
display: flex;
}
.filters__remove {
color: $color-neutral-900;
line-height: 30px;
font-size: 18px;
position: relative;
top: 3px;
color: $color-neutral-500;
font-size: 16px;
border-radius: 6px;
border: 1px solid #b5b5b7;
background: #fff;
box-shadow: 0 1px 2px 0 rgba(7, 8, 16, 0.1);
padding: 8px 6px 3px;
&:hover {
text-decoration: none;
color: $color-neutral-500;
color: $color-neutral-900;
}
.filters__item--loading & {
.filters__condition-item--loading & {
visibility: hidden;
}
}
.filters__remove--disabled {
color: $color-neutral-500;
background: $color-neutral-50;
border-color: $color-neutral-400;
&:hover {
cursor: not-allowed;
color: $color-neutral-500;
}
}
.filters__operator-where {
margin-left: 10px;
}
.filters__operator-text {
padding-left: 12px;
margin-left: 12px;
}
.filters__value {
@ -124,6 +172,7 @@
border: solid 1px $color-neutral-400;
padding: 0 10px;
background-color: $white;
width: 100%;
@include rounded($rounded);
&:hover {
@ -157,25 +206,61 @@
text-align: center;
}
.filters_footer {
.filters__footer {
border: 1px solid rgba(217, 219, 222, 0.5);
padding: 8px 12px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.filters__add {
display: inline-block;
margin: 12px 0 6px 4px;
.filters__actions {
@include flex-align-items(16px);
}
.filters__add {
@include flex-align-items(4px);
&:hover {
text-decoration: none;
color: $color-neutral-900;
}
.filters__container--dark & {
color: $palette-neutral-900;
&:hover {
color: $palette-neutral-1100;
}
}
}
.filters__add-icon {
font-size: 20px;
}
.filters__condition-item {
display: grid;
width: 500px;
/* 48px = 30px + 8px (gap) * 3 (number of gaps) */
grid-template-columns: calc(42% - 54px) 26% 32% 30px;
column-gap: 8px;
align-items: center;
position: relative;
&.filters__condition-item--loading {
&::before {
content: '';
margin-top: -7px;
@include loading(14px);
@include absolute(50%, 8px, 0, auto);
}
}
.filters__items--full-width & {
width: 100%;
}
}

View file

@ -1,5 +1,4 @@
.row-edit-modal-sidebar {
background-color: $palette-neutral-25;
border-top-right-radius: $rounded-md;
border-bottom-right-radius: $rounded-md;
}

View file

@ -26,7 +26,7 @@
font-size: 12px;
height: 14px;
margin-left: 8px;
color: #062e47;
color: $palette-neutral-900;
font-weight: 400;
}

View file

@ -280,7 +280,7 @@
}
.form-view__body {
padding: 20px;
padding: 16px;
max-width: 100%;
width: 680px;
margin: 0 auto;
@ -456,14 +456,31 @@
}
}
.form-view__add-condition {
margin-top: 6px;
color: $palette-neutral-900;
margin-right: 16px;
@include flex-align-items(4px, inline-flex);
&:hover {
text-decoration: none;
color: $palette-neutral-1100;
}
i {
font-size: 20px;
}
}
.form-view__conditions {
margin-top: 6px;
}
.form-view__add-condition {
display: inline-block;
margin-top: 6px;
color: $color-neutral-700;
.form-view__condition-actions {
border-top: 1px solid rgba(217, 219, 222, 0.5);
margin-top: 12px;
padding-top: 8px;
}
.form-view__actions {

View file

@ -1,5 +1,5 @@
<template>
<Context :overflow-scroll="true" :max-height-if-outside-viewport="true">
<Context :max-height-if-outside-viewport="true">
<ViewDecoratorList
v-if="activeDecorations.length === 0"
:database="database"
@ -7,7 +7,7 @@
@select="addDecoration($event)"
/>
<div v-else class="decorator-context">
<div class="decorator-context__list">
<div v-auto-overflow-scroll class="decorator-context__list">
<div
v-for="dec in activeDecorations"
:key="dec.decoration.id"

View file

@ -0,0 +1,133 @@
<template>
<div
class="filters__condition-item"
:class="{ 'filters__condition-item--loading': filter._?.loading }"
>
<div class="filters__field">
<Dropdown
:value="filter.field"
:disabled="disableFilter"
:fixed-items="true"
class="dropdown--tiny"
@input="$emit('updateFilter', { field: $event })"
>
<DropdownItem
v-for="field in fields"
:key="'field-' + field.id"
:name="field.name"
:value="field.id"
:disabled="!hasCompatibleFilterTypes(field, filterTypes)"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__type">
<Dropdown
:disabled="disableFilter"
:value="filter.type"
:fixed-items="true"
class="dropdown--tiny"
@input="$emit('updateFilter', { type: $event })"
>
<DropdownItem
v-for="fType in allowedFilters(filterTypes, fields, filter.field)"
:key="fType.type"
:name="fType.getName()"
:value="fType.type"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__value">
<component
:is="getInputComponent(filter.type, filter.field)"
v-if="
fieldIdExists(fields, filter.field) &&
fieldIsCompatible(filter.type, filter.field)
"
ref="filter-value"
:filter="filter"
:view="view"
:fields="fields"
:disabled="disableFilter"
:read-only="readOnly"
@input="$emit('updateFilter', { value: $event })"
/>
<i
v-else-if="!fieldIdExists(fields, filter.field)"
v-tooltip="$t('viewFilterContext.relatedFieldNotFound')"
class="fas fa-exclamation-triangle color-error"
></i>
<i
v-else-if="!fieldIsCompatible(filter.type, filter.field)"
v-tooltip="$t('viewFilterContext.filterTypeNotFound')"
class="fas fa-exclamation-triangle color-error"
></i>
</div>
<a
class="filters__remove"
:class="{ 'filters__remove--disabled': disableFilter }"
@click="!disableFilter && $emit('deleteFilter', $event)"
>
<i class="iconoir-bin"></i>
</a>
</div>
</template>
<script>
import { hasCompatibleFilterTypes } from '@baserow/modules/database/utils/field'
export default {
name: 'ViewFieldConditionItem',
props: {
filter: {
type: Object,
required: true,
},
fields: {
type: Array,
required: true,
},
view: {
type: Object,
required: true,
},
disableFilter: {
type: Boolean,
default: false,
},
readOnly: {
type: Boolean,
required: true,
},
},
computed: {
filterTypes() {
return this.$registry.getAll('viewFilter')
},
},
methods: {
hasCompatibleFilterTypes,
focusValue() {
this.$refs['filter-value'].focus()
},
allowedFilters(filterTypes, fields, fieldId) {
const field = fields.find((f) => f.id === fieldId)
return Object.values(filterTypes).filter((filterType) => {
return field !== undefined && filterType.fieldIsCompatible(field)
})
},
getInputComponent(type, fieldId) {
const field = this.fields.find(({ id }) => id === fieldId)
return this.$registry.get('viewFilter', type).getInputComponent(field)
},
fieldIdExists(fields, fieldId) {
return fields.findIndex((field) => field.id === fieldId) !== -1
},
fieldIsCompatible(filterType, fieldId) {
const field = this.fields.find(({ id }) => id === fieldId)
return this.$registry
.get('viewFilter', filterType)
.fieldIsCompatible(field)
},
},
}
</script>

View file

@ -1,126 +1,150 @@
<template>
<div>
<div
class="filters__items"
:class="{
'filters__container--dark': variant === 'dark',
'filters__items--full-width': fullWidth,
}"
>
<!--
Here we use the index as key to avoid loosing focus when filter id change.
-->
<div
v-for="(filter, index) in filters"
v-for="(filter, index) in filtersTree.filters"
:key="index"
class="filters__item"
:class="{
'filters__item--loading': filter._ && filter._.loading,
}"
class="filters__item-wrapper"
>
<a
v-if="!disableFilter"
class="filters__remove"
@click="deleteFilter($event, filter)"
>
<i class="iconoir-cancel"></i>
</a>
<span v-else class="filters__remove"></span>
<div class="filters__operator">
<span v-if="index === 0" class="filters__operator-text">{{
$t('viewFilterContext.where')
}}</span>
<Dropdown
v-if="index === 1 && !disableFilter"
:value="filterType"
:show-search="false"
:fixed-items="true"
class="dropdown--tiny"
@input="selectBooleanOperator($event)"
>
<DropdownItem
:name="$t('viewFilterContext.and')"
value="AND"
></DropdownItem>
<DropdownItem
:name="$t('viewFilterContext.or')"
value="OR"
></DropdownItem>
</Dropdown>
<span v-if="index > 1 || (index > 0 && disableFilter)">
{{
filterType === 'AND'
? $t('viewFilterContext.and')
: $t('viewFilterContext.or')
}}
</span>
</div>
<div class="filters__field">
<Dropdown
:value="filter.field"
:disabled="disableFilter"
:fixed-items="true"
class="dropdown--tiny"
@input="updateFilter(filter, { field: $event })"
>
<DropdownItem
v-for="field in fields"
:key="'field-' + field.id"
:name="field.name"
:value="field.id"
:disabled="!hasCompatibleFilterTypes(field, filterTypes)"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__type">
<Dropdown
:disabled="disableFilter"
:value="filter.type"
:fixed-items="true"
class="dropdown--tiny"
@input="updateFilter(filter, { type: $event })"
>
<DropdownItem
v-for="fType in allowedFilters(filterTypes, fields, filter.field)"
:key="fType.type"
:name="fType.getName()"
:value="fType.type"
></DropdownItem>
</Dropdown>
</div>
<div class="filters__value">
<component
:is="getInputComponent(filter.type, filter.field)"
v-if="
fieldIdExists(fields, filter.field) &&
fieldIsCompatible(filter.type, filter.field)
"
:ref="`filter-value-${index}`"
<div class="filters__item filters__item--level-1">
<ViewFilterFormOperator
:index="index"
:filter-type="filterType"
:disable-filter="disableFilter"
@select-boolean-operator="$emit('selectOperator', $event)"
/>
<ViewFieldConditionItem
:ref="`condition-${filter.id}`"
:filter="filter"
:view="view"
:fields="fields"
:disabled="disableFilter"
:disable-filter="disableFilter"
:read-only="readOnly"
@input="updateFilter(filter, { value: $event })"
@updateFilter="updateFilter(filter, $event)"
@deleteFilter="deleteFilter(filter, $event)"
/>
<i
v-else-if="!fieldIdExists(fields, filter.field)"
v-tooltip="$t('viewFilterContext.relatedFieldNotFound')"
class="iconoir-warning-triangle color-error"
></i>
<i
v-else-if="!fieldIsCompatible(filter.type, filter.field)"
v-tooltip="$t('viewFilterContext.filterTypeNotFound')"
class="iconoir-warning-triangle color-error"
></i>
</div>
</div>
<div
v-for="(groupNode, groupIndex) in filtersTree.groups"
:key="filtersTree.filters.length + groupIndex"
class="filters__group-item-wrapper"
>
<ViewFilterFormOperator
:index="filtersTree.filters.length + groupIndex"
:filter-type="filterType"
:disable-filter="disableFilter"
@select-boolean-operator="$emit('selectOperator', $event)"
/>
<div class="filters__group-item">
<div class="filters__group-item-filters">
<div
v-for="(filter, index) in groupNode.filters"
:key="`${groupIndex}-${index}`"
class="filters__item-wrapper"
>
<div class="filters__item filters__item--level-2">
<ViewFilterFormOperator
:index="index"
:filter-type="groupNode.group.filter_type"
:disable-filter="disableFilter"
@select-boolean-operator="
$emit('selectFilterGroupOperator', {
value: $event,
filterGroup: groupNode.group,
})
"
/>
<ViewFieldConditionItem
:ref="`condition-${filter.id}`"
:filter="filter"
:view="view"
:fields="fields"
:disable-filter="disableFilter"
:read-only="readOnly"
@updateFilter="updateFilter(filter, $event)"
@deleteFilter="deleteFilter(filter, $event)"
/>
</div>
</div>
</div>
<div v-if="!disableFilter" class="filters__group-item-actions">
<a
class="filters__add"
@click.prevent="$emit('addFilter', groupNode.group.id)"
>
<i class="filters__add-icon iconoir-plus"></i>
{{ addConditionLabel }}</a
>
</div>
</div>
</div>
</div>
</template>
<script>
import { hasCompatibleFilterTypes } from '@baserow/modules/database/utils/field'
import ViewFilterFormOperator from '@baserow/modules/database/components/view/ViewFilterFormOperator'
import ViewFieldConditionItem from '@baserow/modules/database/components/view/ViewFieldConditionItem'
const GroupNode = class {
constructor(group, parent = null) {
this.group = group
this.parent = parent
this.groups = []
this.filters = []
if (parent) {
parent.groups.push(this)
}
}
findGroup(id) {
if (this.group && this.group.id === id) {
return this
}
for (const group of this.groups) {
const found = group.findGroup(id)
if (found) {
return found
}
}
return null
}
addFilter(filter) {
this.filters.push(filter)
}
remove() {
if (this.parent) {
this.parent.groups = this.parent.groups.filter((g) => g !== this)
}
}
}
export default {
name: 'ViewFieldConditionsForm',
components: {
ViewFilterFormOperator,
ViewFieldConditionItem,
},
props: {
filters: {
type: Array,
required: true,
},
filterGroups: {
type: Array,
required: false,
default: () => [],
},
disableFilter: {
type: Boolean,
required: true,
@ -141,8 +165,34 @@ export default {
type: Boolean,
required: true,
},
addConditionString: {
type: String,
required: false,
default: null,
},
variant: {
type: String,
required: false,
default: 'light',
},
fullWidth: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
groups: {},
}
},
computed: {
addConditionLabel() {
return (
this.addConditionString ||
this.$t('viewFieldConditionsForm.addCondition')
)
},
filterTypes() {
return this.$registry.getAll('viewFilter')
},
@ -150,31 +200,43 @@ export default {
// Copy the filters
return [...this.filters]
},
filtersTree() {
const root = new GroupNode(null)
const groups = { '': root }
for (const filterGroup of this.filterGroups) {
const parentId = filterGroup.parent || ''
const parent = groups[parentId]
const node = new GroupNode(filterGroup, parent)
groups[filterGroup.id] = node
}
for (const filter of this.filters) {
const groupId = filter.group != null ? filter.group : ''
const group = groups[groupId]
if (group) {
group.addFilter(filter)
}
}
return root
},
},
watch: {
/**
* When a filter has been created or removed we want to focus on last value. By
* watching localFilters instead of filters, the new and old values are differents.
* watching localFilters instead of filters, the new and old values are different.
*/
localFilters(value, old) {
if (value.length !== old.length) {
if (value.length !== old.length && value.length > 0) {
this.$nextTick(() => {
this.focusValue(value.length - 1)
this.focusFilterValue(value[value.length - 1])
})
}
},
},
methods: {
hasCompatibleFilterTypes,
focusValue(position) {
const ref = `filter-value-${position}`
if (
position >= 0 &&
Object.prototype.hasOwnProperty.call(this.$refs, ref) &&
this.$refs[ref][0] &&
Object.prototype.hasOwnProperty.call(this.$refs[ref][0], 'focus')
) {
this.$refs[ref][0].focus()
focusFilterValue(filter) {
const ref = `condition-${filter.id}`
if (this.$refs[ref] && this.$refs[ref].length > 0) {
this.$refs[ref][0]?.focusValue()
}
},
/**
@ -186,9 +248,15 @@ export default {
return field !== undefined && filterType.fieldIsCompatible(field)
})
},
deleteFilter(event, filter) {
deleteFilter(filter, event) {
event.deletedFilterEvent = true
this.$emit('deleteFilter', filter)
const groupNode = this.filtersTree.findGroup(filter.group)
const lastInGroup = groupNode && groupNode.filters.length === 1
if (lastInGroup) {
this.$emit('deleteFilterGroup', groupNode)
} else {
this.$emit('deleteFilter', filter)
}
},
/**
* Updates a filter with the given values. Some data manipulation will also be done
@ -232,26 +300,6 @@ export default {
this.$emit('updateFilter', { filter, values })
},
selectBooleanOperator(value) {
this.$emit('selectOperator', value)
},
/**
* Returns the input component related to the filter type. This component is
* responsible for updating the filter value.
*/
getInputComponent(type, fieldId) {
const field = this.fields.find(({ id }) => id === fieldId)
return this.$registry.get('viewFilter', type).getInputComponent(field)
},
fieldIdExists(fields, fieldId) {
return fields.findIndex((field) => field.id === fieldId) !== -1
},
fieldIsCompatible(filterType, fieldId) {
const field = this.fields.find(({ id }) => id === fieldId)
return this.$registry
.get('viewFilter', filterType)
.fieldIsCompatible(field)
},
},
}
</script>

View file

@ -19,7 +19,6 @@
ref="context"
class="filters"
:class="{ 'context--loading-overlay': view._.loading }"
:overflow-scroll="true"
:max-height-if-outside-viewport="true"
>
<ViewFilterForm

View file

@ -1,5 +1,5 @@
<template>
<div>
<div class="filters__content">
<div v-if="view.filters.length === 0">
<div class="filters__none">
<div class="filters__none-title">
@ -11,22 +11,39 @@
</div>
</div>
<ViewFieldConditionsForm
v-if="view.filters.length > 0"
v-auto-overflow-scroll
:filters="view.filters"
:filter-groups="view.filter_groups"
:disable-filter="disableFilter"
:filter-type="view.filter_type"
:fields="fields"
:view="view"
:read-only="readOnly"
class="filters__items"
:add-condition-string="$t('viewFilterContext.addFilter')"
class="filters__items--with-padding filters__items--scrollable"
@addFilter="addFilter($event)"
@deleteFilter="deleteFilter($event)"
@deleteFilterGroup="deleteFilterGroup($event)"
@updateFilter="updateFilter($event)"
@selectOperator="updateView(view, { filter_type: $event })"
@selectFilterGroupOperator="updateFilterGroupOperator(view, $event)"
/>
<div v-if="!disableFilter" class="filters_footer">
<a class="filters__add" @click.prevent="addFilter()">
<i class="filters__add-icon iconoir-plus"></i>
{{ $t('viewFilterContext.addFilter') }}</a
>
<div v-if="!disableFilter" class="filters__footer">
<div class="filters__actions">
<a class="filters__add" @click.prevent="addFilter()">
<i class="filters__add-icon iconoir-plus"></i>
{{ $t('viewFilterContext.addFilter') }}</a
>
<a
v-if="$featureFlagIsEnabled('advanced-filters')"
class="filters__add"
@click.prevent="addFilter(uuid())"
>
<i class="filters__add-icon iconoir-plus"></i>
{{ $t('viewFilterContext.addFilterGroup') }}</a
>
</div>
<div v-if="view.filters.length > 0">
<SwitchInput
:value="view.filters_disabled"
@ -40,6 +57,7 @@
<script>
import { notifyIf } from '@baserow/modules/core/utils/error'
import { uuid } from '@baserow/modules/core/utils/string'
import ViewFieldConditionsForm from '@baserow/modules/database/components/view/ViewFieldConditionsForm'
import { hasCompatibleFilterTypes } from '@baserow/modules/database/utils/field'
@ -72,7 +90,8 @@ export default {
},
},
methods: {
async addFilter(values) {
uuid,
async addFilter(filterGroupId = null) {
try {
const field = this.getFirstCompatibleField(this.fields)
if (field === undefined) {
@ -93,6 +112,7 @@ export default {
},
emitEvent: false,
readOnly: this.readOnly,
filterGroupId,
})
this.$emit('changed')
}
@ -118,6 +138,18 @@ export default {
notifyIf(error, 'view')
}
},
async deleteFilterGroup({ group }) {
try {
await this.$store.dispatch('view/deleteFilterGroup', {
view: this.view,
filterGroup: group,
readOnly: this.readOnly,
})
this.$emit('changed')
} catch (error) {
notifyIf(error, 'view')
}
},
/**
* Updates a filter with the given values. Some data manipulation will also be done
* because some filter types are not compatible with certain field types.
@ -154,6 +186,20 @@ export default {
this.$store.dispatch('view/setItemLoading', { view, value: false })
},
async updateFilterGroupOperator(view, { filterGroup, value }) {
this.$store.dispatch('view/setItemLoading', { view, value: true })
try {
await this.$store.dispatch('view/updateFilterGroup', {
filterGroup,
values: { filter_type: value },
readOnly: this.readOnly,
})
this.$emit('changed')
} catch (error) {
notifyIf(error, 'view')
}
this.$store.dispatch('view/setItemLoading', { view, value: false })
},
},
}
</script>

View file

@ -0,0 +1,54 @@
<template>
<div>
<span v-if="index === 0" class="filters__operator-where">{{
$t('viewFilterContext.where')
}}</span>
<Dropdown
v-if="index === 1 && !disableFilter"
:value="filterType"
:show-search="false"
:fixed-items="true"
class="dropdown--tiny"
@input="$emit('select-boolean-operator', $event)"
>
<DropdownItem
:name="$t('viewFilterContext.and')"
value="AND"
></DropdownItem>
<DropdownItem
:name="$t('viewFilterContext.or')"
value="OR"
></DropdownItem>
</Dropdown>
<span
v-if="index > 1 || (index > 0 && disableFilter)"
class="filters__operator-text"
>
{{
filterType === 'AND'
? $t('viewFilterContext.and')
: $t('viewFilterContext.or')
}}
</span>
</div>
</template>
<script>
export default {
name: 'ViewFilterFormOperator',
props: {
index: {
type: Number,
required: true,
},
filterType: {
type: String,
required: true,
},
disableFilter: {
type: Boolean,
required: true,
},
},
}
</script>

View file

@ -101,24 +101,42 @@
>
<ViewFieldConditionsForm
:filters="fieldOptions.conditions"
:filter-groups="fieldOptions.condition_groups"
:disable-filter="readOnly"
:filter-type="fieldOptions.condition_type"
:view="view"
:fields="allowedConditionalFields"
:read-only="readOnly"
@deleteFilter="deleteCondition(fieldOptions.conditions, $event)"
@updateFilter="updateCondition(fieldOptions.conditions, $event)"
:full-width="true"
:variant="'dark'"
@addFilter="addCondition(fieldOptions, $event)"
@deleteFilter="deleteCondition(fieldOptions, $event)"
@updateFilter="updateCondition(fieldOptions, $event)"
@selectOperator="
$emit('updated-field-options', { condition_type: $event })
"
@deleteFilterGroup="deleteConditionGroup(fieldOptions, $event)"
@selectFilterGroupOperator="
updateConditionGroupOperator(fieldOptions, $event)
"
/>
<a
class="form-view__add-condition"
@click="addCondition(fieldOptions.conditions)"
>
<i class="iconoir-plus"></i>
{{ $t('formViewField.addCondition') }}
</a>
<div class="form-view__condition-actions">
<a
class="form-view__add-condition"
@click="addCondition(fieldOptions)"
>
<i class="iconoir-plus"></i>
{{ $t('formViewField.addCondition') }}
</a>
<a
v-if="$featureFlagIsEnabled('advanced-filters')"
class="form-view__add-condition"
@click="addConditionGroup(fieldOptions)"
>
<i class="iconoir-plus"></i>
{{ $t('formViewField.addConditionGroup') }}
</a>
</div>
</div>
</div>
</div>
@ -233,7 +251,13 @@ export default {
resetValue() {
this.value = this.getFieldType().getEmptyValue(this.field)
},
generateCompatibleCondition() {
createConditionGroup() {
return {
id: 0,
filter_type: 'AND',
}
},
generateCompatibleCondition(conditionGroupId = null) {
const field =
this.allowedConditionalFields[this.allowedConditionalFields.length - 1]
const viewFilterTypes = this.$registry.getAll('viewFilter')
@ -242,12 +266,16 @@ export default {
return viewFilterType.fieldIsCompatible(field)
}
)
return {
const newCondition = {
id: 0,
field: field.id,
type: compatibleType.type,
value: '',
}
if (conditionGroupId !== null) {
newCondition.group = conditionGroupId
}
return newCondition
},
setShowWhenMatchingConditions(value) {
const values = { show_when_matching_conditions: value }
@ -256,26 +284,91 @@ export default {
}
this.$emit('updated-field-options', values)
},
addCondition(conditions) {
addCondition(
{ conditions, condition_groups: conditionGroups },
conditionGroupId = null
) {
const newConditions = conditions.slice()
newConditions.push(this.generateCompatibleCondition())
this.$emit('updated-field-options', { conditions: newConditions })
newConditions.push(this.generateCompatibleCondition(conditionGroupId))
this.$emit('updated-field-options', {
conditions: newConditions,
condition_groups: conditionGroups,
})
},
updateCondition(conditions, condition) {
addConditionGroup({ conditions, condition_groups: filterGroups }) {
const newConditionGroup = this.createConditionGroup()
const newConditionGroups = filterGroups.slice()
newConditionGroups.push(newConditionGroup)
const newConditions = conditions.slice()
newConditions.push(this.generateCompatibleCondition(newConditionGroup.id))
this.$emit('updated-field-options', {
condition_groups: newConditionGroups,
conditions: newConditions,
})
},
updateConditionGroupOperator(
{ conditions, condition_groups: conditioGroups },
{ value, filterGroup }
) {
conditioGroups = clone(conditioGroups.slice())
conditioGroups.forEach((g, index) => {
if (g.id === filterGroup.id) {
Object.assign(conditioGroups[index], { filter_type: value })
}
})
this.$emit('updated-field-options', {
conditions,
condition_groups: conditioGroups,
})
},
updateCondition(
{ conditions, condition_groups: conditionGroups },
condition
) {
conditions = clone(conditions.slice())
conditions.forEach((c, index) => {
if (c.id === condition.filter.id) {
Object.assign(conditions[index], condition.values)
}
})
this.$emit('updated-field-options', { conditions })
this.$emit('updated-field-options', {
conditions,
condition_groups: conditionGroups,
})
},
deleteCondition(conditions, condition) {
deleteCondition(
{ conditions, condition_groups: conditionGroups },
condition
) {
// We need to wait for the next tick, otherwise the condition is already deleted
// before the event completes, resulting in a click outside of the field.
this.$nextTick(() => {
conditions = conditions.filter((c) => c.id !== condition.id)
this.$emit('updated-field-options', { conditions })
this.$emit('updated-field-options', {
conditions,
condition_groups: conditionGroups,
})
})
},
deleteConditionGroup(
{ conditions, condition_groups: conditionGroups },
{ group }
) {
// We need to wait for the next tick, otherwise the condition is already deleted
// before the event completes, resulting in a click outside of the field.
this.$nextTick(() => {
const conditionIdsToRemove = conditions
.filter((c) => c.group === group.id)
.map((c) => c.id)
conditions = conditions.filter(
(c) => !conditionIdsToRemove.includes(c.id)
)
conditionGroups = conditionGroups.filter((g) => g.id !== group.id)
this.$emit('updated-field-options', {
conditions,
condition_groups: conditionGroups,
})
})
},
/**

View file

@ -430,8 +430,12 @@
"unnamed": "unnamed row {value}",
"choose": "Choose row"
},
"viewFieldConditionsForm": {
"addCondition": "Add condition"
},
"viewFilterContext": {
"addFilter": "Add filter",
"addFilterGroup": "Add filter group",
"disableAllFilters": "all disabled",
"noFilterTitle": "You have not yet created a filter",
"noFilterText": "Filters allow you to show rows that apply to your conditions.",
@ -692,11 +696,11 @@
"linkError": "The link should look like: https://airtable.com/shrxxxxxxxxxxxxxx"
},
"chooseSingleSelectField": {
"addSelectField": "add single select field",
"addSelectField": "Add single select field",
"warningWhenNothingToChooseOrCreate": "There are no single select fields to choose from and you do not have permissions to make one."
},
"viewDecoratorContext": {
"addDecorator": "add decorator"
"addDecorator": "Add decorator"
},
"databaseDashboardSidebarLinks": {
"apiDocumentation": "API documentation"
@ -705,7 +709,8 @@
"required": "required",
"descriptionPlaceholder": "Description",
"showWhenMatchingConditions": "show when conditions are met",
"addCondition": "Add condition"
"addCondition": "Add condition",
"addConditionGroup": "Add condition group"
},
"duplicateFieldContext": {
"duplicate": "Duplicate field",

View file

@ -231,6 +231,7 @@ export default {
this.$registry,
conditionType,
conditions,
field.condition_groups,
fieldsBefore,
visibleValues
)

View file

@ -6,14 +6,26 @@ export default (client) => {
create(viewId, values) {
return client.post(`/database/views/${viewId}/filters/`, values)
},
createGroup(viewId) {
return client.post(`/database/views/${viewId}/filter-groups/`)
},
get(viewFilterId) {
return client.get(`/database/views/filter/${viewFilterId}/`)
},
update(viewFilterId, values) {
return client.patch(`/database/views/filter/${viewFilterId}/`, values)
},
updateGroup(viewFilterGroupId, values) {
return client.patch(
`/database/views/filter-group/${viewFilterGroupId}/`,
values
)
},
delete(viewFilterId) {
return client.delete(`/database/views/filter/${viewFilterId}/`)
},
deleteGroup(viewFilterGroupId) {
return client.delete(`/database/views/filter-group/${viewFilterGroupId}/`)
},
}
}

View file

@ -17,6 +17,14 @@ export function populateFilter(filter) {
return filter
}
export function populateFilterGroup(filterGroup) {
filterGroup._ = {
hover: false,
loading: false,
}
return filterGroup
}
export function populateSort(sort) {
sort._ = {
hover: false,
@ -56,6 +64,14 @@ export function populateView(view, registry) {
view.filters = []
}
if (Object.prototype.hasOwnProperty.call(view, 'filter_groups')) {
view.filter_groups.forEach((filterGroup) => {
populateFilterGroup(filterGroup)
})
} else {
view.filter_groups = []
}
if (Object.prototype.hasOwnProperty.call(view, 'sortings')) {
view.sortings.forEach((sort) => {
populateSort(sort)
@ -167,6 +183,25 @@ export const mutations = {
view.filters.splice(index, 1)
}
},
ADD_FILTER_GROUP(state, { view, filterGroup }) {
view.filter_groups.push(filterGroup)
},
FINALIZE_FILTER_GROUP(state, { view, oldId, id }) {
const index = view.filter_groups.findIndex((item) => item.id === oldId)
if (index !== -1) {
view.filter_groups[index].id = id
view.filter_groups[index]._.loading = false
}
},
UPDATE_FILTER_GROUP(state, { filterGroup, values }) {
Object.assign(filterGroup, filterGroup, values)
},
DELETE_FILTER_GROUP(state, { view, id }) {
const index = view.filter_groups.findIndex((item) => item.id === id)
if (index !== -1) {
view.filter_groups.splice(index, 1)
}
},
DELETE_FIELD_FILTERS(state, { view, fieldId }) {
for (let i = view.filters.length - 1; i >= 0; i--) {
if (view.filters[i].field === fieldId) {
@ -603,10 +638,19 @@ export const actions = {
/**
* Creates a new filter and adds it to the store right away. If the API call succeeds
* the filter ID will be added, but if it fails it will be removed from the store.
* It also create the filter group if it doesn't exist yet in the same optimistic
* way, removing it if the API call fails.
*/
async createFilter(
{ commit },
{ view, field, values, emitEvent = true, readOnly = false }
{
view,
field,
values,
emitEvent = true,
readOnly = false,
filterGroupId = null,
}
) {
// If the type is not provided we are going to choose the first available type.
if (!Object.prototype.hasOwnProperty.call(values, 'type')) {
@ -637,32 +681,102 @@ export const actions = {
values.preload_values = {}
}
// If the filter group doesn't exist yet optimistically create it.
// If we first create the filter group and only once that succeeds create the
// filter itself, we can run into a situation where a user with a slow connection
// will see an empty group first and the filter only after a while. This code
// will optimistically create both the group and the filter to provide a smoother
// experience.
const createNewFilterGroup =
filterGroupId &&
view.filter_groups.findIndex((group) => group.id === filterGroupId) === -1
const filterGroup = {}
if (createNewFilterGroup) {
populateFilterGroup(filterGroup)
filterGroup.id = filterGroupId
filterGroup._.loading = !readOnly
filterGroup.filter_type = 'AND'
commit('ADD_FILTER_GROUP', { view, filterGroup })
}
const filter = Object.assign({}, values)
populateFilter(filter)
filter.id = uuid()
filter._.loading = !readOnly
filter.group = filterGroupId
values.group = filterGroupId
commit('ADD_FILTER', { view, filter })
if (emitEvent) {
this.$bus.$emit('view-filter-created', { view, filter })
}
try {
if (!readOnly) {
if (!readOnly) {
if (createNewFilterGroup) {
// The group needs to be created first before we can create the filter
// in the case we're trying to create a new filter in a new group.
try {
const { data } = await FilterService(this.$client).createGroup(
view.id
)
commit('FINALIZE_FILTER_GROUP', {
view,
oldId: filterGroup.id,
id: data.id,
})
// update the group id with the created group id
values.group = data.id
commit('UPDATE_FILTER', { filter, values: { group: data.id } })
} catch (error) {
commit('DELETE_FILTER_GROUP', { view, id: filterGroup.id })
commit('DELETE_FILTER', { view, id: filter.id })
throw error
}
}
try {
const { data } = await FilterService(this.$client).create(
view.id,
values
)
commit('FINALIZE_FILTER', { view, oldId: filter.id, id: data.id })
} catch (error) {
commit('DELETE_FILTER', { view, id: filter.id })
throw error
}
} catch (error) {
commit('DELETE_FILTER', { view, id: filter.id })
throw error
}
return { filter }
},
/**
* Creates a new filter group and adds it to the store right away. If the API
* call succeeds the filter group ID will be updated, but if it fails it will be
* removed from the store.
*/
async createFilterGroup({ commit }, { view, readOnly = false }) {
const filterGroup = {}
populateFilterGroup(filterGroup)
filterGroup.id = uuid()
filterGroup._.loading = !readOnly
filterGroup.filter_type = 'AND'
commit('ADD_FILTER_GROUP', { view, filterGroup })
try {
const { data } = await FilterService(this.$client).createGroup(view.id)
commit('FINALIZE_FILTER_GROUP', {
view,
oldId: filterGroup.id,
id: data.id,
})
} catch (error) {
commit('DELETE_FILTER_GROUP', { view, id: filterGroup.id })
throw error
}
return { filterGroup }
},
/**
* Forcefully create a new view filter without making a request to the backend.
*/
@ -707,6 +821,45 @@ export const actions = {
throw error
}
},
/**
*
*/
async updateFilterGroup(
{ dispatch },
{ filterGroup, values, readOnly = false }
) {
const oldValues = {}
const newValues = {}
Object.keys(values).forEach((name) => {
if (Object.prototype.hasOwnProperty.call(filterGroup, name)) {
oldValues[name] = filterGroup[name]
newValues[name] = values[name]
}
})
dispatch('forceUpdateFilterGroup', {
filterGroup,
values: newValues,
})
try {
if (!readOnly) {
await FilterService(this.$client).updateGroup(filterGroup.id, values)
}
} catch (error) {
dispatch('forceUpdateFilterGroup', {
filterGroup,
values: oldValues,
})
throw error
}
},
/**
* Forcefully update an existing view filter group without making a request to the backend.
*/
forceUpdateFilterGroup({ commit }, { filterGroup, values }) {
commit('UPDATE_FILTER_GROUP', { filterGroup, values })
},
/**
* Forcefully update an existing view filter without making a request to the backend.
*/
@ -736,6 +889,44 @@ export const actions = {
forceDeleteFilter({ commit }, { view, filter }) {
commit('DELETE_FILTER', { view, id: filter.id })
},
/**
* Deletes an existing filter. A request to the server will be made first and
* after that it will be deleted.
*/
async deleteFilterGroup(
{ dispatch, commit },
{ view, filterGroup, readOnly = false }
) {
const filters = view.filters.filter((f) => f.group === filterGroup.id)
for (const filter of filters) {
commit('SET_FILTER_LOADING', { filter, value: true })
}
try {
if (!readOnly) {
await FilterService(this.$client).deleteGroup(filterGroup.id)
}
dispatch('forceDeleteFilterGroup', {
view,
filterGroup,
})
} catch (error) {
for (const filter of filters) {
commit('SET_FILTER_LOADING', { filter, value: false })
}
throw error
}
},
/**
* Forcefully delete an existing filter without making a request to the backend.
*/
forceDeleteFilterGroup({ commit }, { view, filterGroup }) {
const filters = view.filters.filter((f) => f.group === filterGroup.id)
filters.forEach((filter) => {
commit('DELETE_FILTER', { view, id: filter.id })
})
commit('DELETE_FILTER_GROUP', { view, id: filterGroup.id })
},
/**
* When a field is deleted the related filters are also automatically deleted in the
* backend so they need to be removed here.

View file

@ -492,6 +492,7 @@ export default ({ service, customPopulateRow }) => {
this.$registry,
view.filter_type,
view.filters,
view.filter_groups,
fields,
values
)

View file

@ -2484,6 +2484,7 @@ export const actions = {
this.$registry,
view.filter_type,
view.filters,
view.filter_groups,
fields,
values
)

View file

@ -86,6 +86,140 @@ export function filterHiddenFieldsFunction(fieldOptions) {
}
}
/**
* Represents a node in a tree structure used for grouped filters.
* A group node is made of a filterType (AND or OR), a list of filters and a parent.
* If the parent is null it means that it is the root node. If the parent is not
* null it means that it is a child of the parent node, and the constructor will take care to
* add itself to the children of the parent node, so that we can later traverse the tree
* from the root node and check if a row matches the filters.
*/
export const TreeGroupNode = class {
/**
* Constructs a new TreeGroupNode.
*
* @param {string} filterType - The type of filter (e.g., 'AND' or 'OR').
* @param {TreeGroupNode} [parent=null] - The parent node of this node. Null for the root node.
*/
constructor(filterType, parent = null) {
this.filterType = filterType
this.parent = parent
this.filters = []
this.children = []
if (parent) {
parent.children.push(this)
}
}
/**
* Checks if this node or any of its descendants has filters.
*
* @returns {boolean} - True if there are filters, false otherwise.
*/
hasFilters() {
return this.filters.length > 0 || this.children.some((c) => c.hasFilters())
}
/**
* Adds a filter object to this node list of filters.
*
* @param {object} filter - The filter to add.
*/
addFilter(filter) {
this.filters.push(filter)
}
/**
* Determines if a given row matches the conditions of this node and its descendants.
* This function will recursively check if the row matches the filters of this node
* and its descendants. If the filter type of this node is 'AND' then it will return
* true if the row matches all the filters. If the filter type of this node is 'OR'
* then it will return true if the row matches at least one of the filters.
*
* @param {object} $registry - The registry containing field and filter type information.
* @param {Array} fields - The list of fields.
* @param {object} rowValues - The values of the row being checked.
* @returns {boolean} - True if the row matches, false otherwise.
*/
matches($registry, fields, rowValues) {
for (const child of this.children) {
const matches = child.matches($registry, fields, rowValues)
if (this.filterType === 'AND' && !matches) {
return false
} else if (this.filterType === 'OR' && matches) {
return true
}
}
for (const filter of this.filters) {
const filterValue = filter.value
const field = fields.find((f) => f.id === filter.field)
const fieldType = $registry.get('field', field.type)
const viewFilterType = $registry.get('viewFilter', filter.type)
const rowValue = rowValues[`field_${field.id}`]
const matches = viewFilterType.matches(
rowValue,
filterValue,
field,
fieldType
)
if (this.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 invalid.
return false
} else if (this.filterType === 'OR' && matches) {
// With an 'OR' filter type, the row only has to match one of the filters,
// that is the case here so we can mark it as valid.
return true
}
}
if (this.filterType === 'AND') {
// At this point with an `AND` condition the filter type matched all the
// filters and therefore we can mark it as valid.
return true
} else if (this.filterType === 'OR') {
// At this point with an `OR` condition none of the filters matched and
// therefore we can mark it as invalid.
return false
}
}
}
/**
* Creates a tree structure from given filters and filter groups. Groups are
* first sorted by ID because parent groups have smaller IDs since they were
* created before their children. In this way, we ensure that when a child node
* is added to the tree, its parent will already be present.
* Once the tree has been created, it adds all the filters to the respective
* groups.
*
* @param {string} filterType - The root filter type.
* @param {Array} filters - The list of filters.
* @param {Array} filterGroups - The list of filter groups.
* @returns {TreeGroupNode} - The root of the filter tree.
*/
export const createFiltersTree = (filterType, filters, filterGroups) => {
const rootGroup = new TreeGroupNode(filterType)
const filterGroupsById = { '': rootGroup }
const filterGroupsOrderedById = filterGroups
? [...filterGroups].sort((a, b) => a.id - b.id)
: []
for (const filterGroup of filterGroupsOrderedById) {
const parent = filterGroupsById[filterGroup.parent || '']
filterGroupsById[filterGroup.id] = new TreeGroupNode(
filterGroup.filter_type,
parent
)
}
for (const filter of filters) {
const filterGroupId = filter.group || ''
const filterGroup = filterGroupsById[filterGroupId]
filterGroup.addFilter(filter)
}
return rootGroup
}
/**
* A helper function that checks if the provided row values match the provided view
* filters. Returning false indicates that the row should not be visible for that
@ -95,6 +229,7 @@ export const matchSearchFilters = (
$registry,
filterType,
filters,
filterGroups,
fields,
values
) => {
@ -104,35 +239,8 @@ export const matchSearchFilters = (
return true
}
for (const i in filters) {
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', 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.
return false
} else if (filterType === 'OR' && matches) {
// With an 'OR' filter type, the row only has to match one of the filters,
// that is the case here so we can mark it as valid.
return true
}
}
if (filterType === 'AND') {
// When this point has been reached with an `AND` filter type it means that
// the row matches all the filters and therefore we can mark it as valid.
return true
} else if (filterType === 'OR') {
// When this point has been reached with an `OR` filter type it means that
// the row matches none of the filters and therefore we can mark it as invalid.
return false
}
const filterTree = createFiltersTree(filterType, filters, filterGroups)
return filterTree.matches($registry, fields, values)
}
function _fullTextSearch(registry, field, value, activeSearchTerm) {

View file

@ -2,7 +2,7 @@
exports[`GridViewRows component with decoration Default component 1`] = `
<div
class="context context--overflow-scroll"
class="context"
style="max-height: none;"
>
<div
@ -60,7 +60,7 @@ exports[`GridViewRows component with decoration Should show cant add decorator t
</div>
</div>
<div
class="context context--overflow-scroll"
class="context"
style="max-height: none;"
>
<div
@ -119,7 +119,7 @@ exports[`GridViewRows component with decoration Should show unavailable decorato
</div>
</div>
<div
class="context context--overflow-scroll"
class="context"
style="max-height: none;"
>
<div
@ -173,7 +173,7 @@ exports[`GridViewRows component with decoration Should show unavailable decorato
exports[`GridViewRows component with decoration View with decoration configured 1`] = `
<div
class="context context--overflow-scroll"
class="context"
style="max-height: none;"
>
<div

View file

@ -0,0 +1,248 @@
import {
TreeGroupNode,
createFiltersTree,
matchSearchFilters,
} from '@baserow/modules/database/utils/view'
import { TestApp } from '@baserow/test/helpers/testApp'
describe('TreeGroupNode', () => {
it('should initialize correctly', () => {
const node = new TreeGroupNode('AND')
expect(node.filterType).toBe('AND')
expect(node.parent).toBeNull()
expect(node.filters).toEqual([])
expect(node.children).toEqual([])
})
it('should add child nodes', () => {
const parentNode = new TreeGroupNode('AND')
const childNode = new TreeGroupNode('OR', parentNode)
expect(parentNode.children[0]).toBe(childNode)
})
})
describe('createFiltersTree', () => {
it('should create a tree with root node', () => {
const rootNode = createFiltersTree('AND', [], [])
expect(rootNode.filterType).toBe('AND')
expect(rootNode.hasFilters()).toBe(false)
expect(rootNode.filters).toEqual([])
expect(rootNode.children).toEqual([])
})
it('should correctly add filters to the tree', () => {
const filters = [
{ field: 1, type: 'type1', value: 'value1', group: 1 },
{ field: 2, type: 'type2', value: 'value2', group: 2 },
{ field: 3, type: 'type3', value: 'value3' },
]
const filterGroups = [
{ id: 1, filter_type: 'AND' },
{ id: 2, filter_type: 'OR' },
{ id: 3, filter_type: 'AND' },
]
const rootNode = createFiltersTree('AND', filters, filterGroups)
expect(rootNode.filterType).toBe('AND')
expect(rootNode.hasFilters()).toBe(true)
expect(rootNode.filters).toEqual([
{ field: 3, type: 'type3', value: 'value3' },
])
expect(rootNode.children).toHaveLength(3)
expect(rootNode.children[0].filterType).toBe('AND')
expect(rootNode.children[0].filters).toEqual([
{ field: 1, type: 'type1', value: 'value1', group: 1 },
])
expect(rootNode.children[0].children).toEqual([])
expect(rootNode.children[1].filterType).toBe('OR')
expect(rootNode.children[1].filters).toEqual([
{ field: 2, type: 'type2', value: 'value2', group: 2 },
])
expect(rootNode.children[2].filters).toEqual([])
expect(rootNode.children[2].hasFilters()).toBe(false)
})
it('should correctly nest groups into the tree', () => {
const filterGroups = [
{ id: 1, filter_type: 'AND' },
{ id: 2, filter_type: 'OR', parent: 1 },
{ id: 3, filter_type: 'AND', parent: 1 },
{ id: 4, filter_type: 'OR', parent: 3 },
]
const rootNode = createFiltersTree('AND', [], filterGroups)
expect(rootNode.hasFilters()).toBe(false)
expect(rootNode.children).toHaveLength(1)
expect(rootNode.children[0].filterType).toBe('AND')
expect(rootNode.children[0].children).toHaveLength(2)
expect(rootNode.children[0].children[0].filterType).toBe('OR')
expect(rootNode.children[0].children[0].children).toEqual([])
expect(rootNode.children[0].children[1].filterType).toBe('AND')
expect(rootNode.children[0].children[1].children).toHaveLength(1)
expect(rootNode.children[0].children[1].children[0].filterType).toBe('OR')
expect(rootNode.children[0].children[1].children[0].children).toEqual([])
})
it('should correctly add filters to nested groups', () => {
const filterGroups = [
{ id: 1, filter_type: 'AND' },
{ id: 2, filter_type: 'OR', parent: 1 },
{ id: 3, filter_type: 'OR', parent: 2 },
]
const rootNode = createFiltersTree('AND', [], filterGroups)
expect(rootNode.hasFilters()).toBe(false)
rootNode.children[0].children[0].children[0].addFilter({
field: 1,
type: 'type1',
value: 'value1',
group: 4,
})
expect(rootNode.hasFilters()).toBe(true)
})
})
describe('matchSearchFilters', () => {
let testApp = null
let registry = null
beforeAll(() => {
testApp = new TestApp()
registry = testApp.getRegistry()
})
afterEach((done) => {
testApp.afterEach().then(done)
})
it('should return true with no filters', () => {
const filters = []
const filterGroups = []
const fields = {}
const rowValues = {}
expect(
matchSearchFilters(
registry,
'AND',
filters,
filterGroups,
fields,
rowValues
)
).toBe(true)
})
it('should match a single filter', () => {
const filters = [{ field: 1, type: 'equal', value: 'a' }]
const filterGroups = []
const fields = [{ id: 1, type: 'text' }]
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'a',
})
).toBe(true)
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'b',
})
).toBe(false)
})
it('should match a single filter in a group', () => {
const filters = [{ field: 1, type: 'equal', value: 'a', group: 1 }]
const filterGroups = [{ filter_type: 'OR', id: 1 }]
const fields = [{ id: 1, type: 'text' }]
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'a',
})
).toBe(true)
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'b',
})
).toBe(false)
})
it('should match filters correctly with OR and AND', () => {
// Match rows where field_1=Ada OR (field_1=John AND field_2=Turing)
const filters = [
{ field: 1, type: 'equal', value: 'Ada' },
{ field: 1, type: 'equal', value: 'John', group: 1 },
{ field: 2, type: 'equal', value: 'Turing', group: 1 },
]
const filterGroups = [{ filter_type: 'AND', id: 1 }]
const fields = [
{ id: 1, type: 'text' },
{ id: 2, type: 'text' },
]
expect(
matchSearchFilters(registry, 'OR', filters, filterGroups, fields, {
field_1: 'Ada',
field_2: 'Lovelace',
})
).toBe(true)
expect(
matchSearchFilters(registry, 'OR', filters, filterGroups, fields, {
field_1: 'Alan',
field_2: 'Turing',
})
).toBe(false)
expect(
matchSearchFilters(registry, 'OR', filters, filterGroups, fields, {
field_1: 'John',
field_2: 'Travolta',
})
).toBe(false)
expect(
matchSearchFilters(registry, 'OR', filters, filterGroups, fields, {
field_1: 'John',
field_2: 'Turing',
})
).toBe(true)
})
it('should match filters correctly with AND and OR', () => {
// Match rows where field_1=John AND (field_2=Travolta OR field_2=Turing)
const filters = [
{ field: 1, type: 'equal', value: 'John' },
{ field: 2, type: 'equal', value: 'Travolta', group: 1 },
{ field: 2, type: 'equal', value: 'Turing', group: 1 },
]
const filterGroups = [{ filter_type: 'OR', id: 1 }]
const fields = [
{ id: 1, type: 'text' },
{ id: 2, type: 'text' },
]
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'John',
field_2: 'Lennon',
})
).toBe(false)
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'Alan',
field_2: 'Turing',
})
).toBe(false)
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'John',
field_2: 'Travolta',
})
).toBe(true)
expect(
matchSearchFilters(registry, 'AND', filters, filterGroups, fields, {
field_1: 'John',
field_2: 'Turing',
})
).toBe(true)
})
})