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:
parent
1a53ed6571
commit
d55ac7ccd4
31 changed files with 3224 additions and 1886 deletions
premium/web-frontend/modules/baserow_premium
web-frontend
Makefile
modules
core/assets/scss/components
database
components/view
ViewDecoratorContext.vueViewFieldConditionItem.vueViewFieldConditionsForm.vueViewFilter.vueViewFilterForm.vueViewFilterFormOperator.vue
form
locales
pages
services
store
utils
test/unit/database
components/view/__snapshots__
utils
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="margin-top-3">
|
||||
<ChooseSingleSelectField
|
||||
:view="view"
|
||||
:table="table"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -681,6 +681,7 @@ export const actions = {
|
|||
this.$registry,
|
||||
view.filter_type,
|
||||
view.filters,
|
||||
view.filter_groups,
|
||||
fields,
|
||||
values
|
||||
)
|
||||
|
|
|
@ -462,6 +462,7 @@ export const actions = {
|
|||
this.$registry,
|
||||
view.filter_type,
|
||||
view.filters,
|
||||
view.filter_groups,
|
||||
fields,
|
||||
values
|
||||
)
|
||||
|
|
|
@ -21,3 +21,6 @@ test: jest
|
|||
|
||||
ci-test-javascript:
|
||||
yarn test-coverage || exit;
|
||||
|
||||
update-snapshots:
|
||||
yarn run jest --updateSnapshot || exit;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.value-provider-list--read-only {
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
font-size: 12px;
|
||||
height: 14px;
|
||||
margin-left: 8px;
|
||||
color: #062e47;
|
||||
color: $palette-neutral-900;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -19,7 +19,6 @@
|
|||
ref="context"
|
||||
class="filters"
|
||||
:class="{ 'context--loading-overlay': view._.loading }"
|
||||
:overflow-scroll="true"
|
||||
:max-height-if-outside-viewport="true"
|
||||
>
|
||||
<ViewFilterForm
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
},
|
||||
/**
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -231,6 +231,7 @@ export default {
|
|||
this.$registry,
|
||||
conditionType,
|
||||
conditions,
|
||||
field.condition_groups,
|
||||
fieldsBefore,
|
||||
visibleValues
|
||||
)
|
||||
|
|
|
@ -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}/`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -492,6 +492,7 @@ export default ({ service, customPopulateRow }) => {
|
|||
this.$registry,
|
||||
view.filter_type,
|
||||
view.filters,
|
||||
view.filter_groups,
|
||||
fields,
|
||||
values
|
||||
)
|
||||
|
|
|
@ -2484,6 +2484,7 @@ export const actions = {
|
|||
this.$registry,
|
||||
view.filter_type,
|
||||
view.filters,
|
||||
view.filter_groups,
|
||||
fields,
|
||||
values
|
||||
)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
File diff suppressed because it is too large
Load diff
248
web-frontend/test/unit/database/utils/view.spec.js
Normal file
248
web-frontend/test/unit/database/utils/view.spec.js
Normal 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)
|
||||
})
|
||||
})
|
Loading…
Add table
Reference in a new issue