UBER-299 Array enum filter (#3311)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-06-01 00:46:24 +06:00 committed by GitHub
parent 79121cb8b0
commit 3fc89fbb52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 309 additions and 5 deletions

View File

@ -719,6 +719,10 @@ export function createModel (builder: Builder): void {
component: view.component.ValueFilter
})
builder.mixin(core.class.ArrOf, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ArrayFilter
})
builder.mixin(core.class.RefTo, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ObjectFilter
})
@ -728,6 +732,26 @@ export function createModel (builder: Builder): void {
group: 'bottom'
})
builder.createDoc(
view.class.FilterMode,
core.space.Model,
{
label: view.string.FilterArrayAll,
result: view.function.FilterArrayAllResult
},
view.filter.FilterArrayAll
)
builder.createDoc(
view.class.FilterMode,
core.space.Model,
{
label: view.string.FilterArrayAny,
result: view.function.FilterArrayAnyResult
},
view.filter.FilterArrayAny
)
builder.createDoc(
view.class.FilterMode,
core.space.Model,

View File

@ -99,6 +99,8 @@ export default mergeIds(viewId, view, {
MarkdownFormatting: '' as IntlString
},
function: {
FilterArrayAllResult: '' as FilterFunction,
FilterArrayAnyResult: '' as FilterFunction,
FilterObjectInResult: '' as FilterFunction,
FilterObjectNinResult: '' as FilterFunction,
FilterValueInResult: '' as FilterFunction,

View File

@ -41,8 +41,27 @@ const predicates: Record<string, PredicateFactory> = {
if (!Array.isArray(o)) {
throw new Error('$in predicate requires array')
}
// eslint-disable-next-line eqeqeq
return (docs) => execPredicate(docs, propertyKey, (value) => o.some((p) => p == value))
return (docs) =>
execPredicate(docs, propertyKey, (value) => {
if (Array.isArray(value)) {
return o.some((p) => value.includes(p))
} else {
// eslint-disable-next-line eqeqeq
return o.some((p) => p == value)
}
})
},
$all: (o, propertyKey) => {
if (!Array.isArray(o)) {
throw new Error('$all predicate requires array')
}
return (docs) =>
execPredicate(docs, propertyKey, (value: any[]) => {
for (const val of o) {
if (!value.includes(val)) return false
}
return true
})
},
$nin: (o, propertyKey) => {
if (!Array.isArray(o)) {

View File

@ -23,6 +23,7 @@ import type { Tx } from './tx'
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type QuerySelector<T> = {
$in?: T[]
$all?: T extends Array<any> ? T : never
$nin?: T[]
$ne?: T
$gt?: T extends number ? number : never

View File

@ -98,6 +98,8 @@
"And": "and",
"Between": "is between",
"ShowColors": "Use colors",
"Show": "Show"
"Show": "Show",
"FilterArrayAll": "includes all",
"FilterArrayAny": "includes any"
}
}

View File

@ -94,6 +94,8 @@
"And": "и",
"Between": "между",
"ShowColors": "Использовать цвета",
"Show": "Отображение"
"Show": "Отображение",
"FilterArrayAll": "содержит все",
"FilterArrayAny": "содержит любое из"
}
}

View File

@ -0,0 +1,225 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import core, {
ArrOf,
Class,
Doc,
EnumOf,
FindResult,
getObjectValue,
Ref,
SortingOrder,
Space
} from '@hcengineering/core'
import presentation, { getClient } from '@hcengineering/presentation'
import ui, {
deviceOptionsStore,
EditWithIcon,
Icon,
IconCheck,
IconSearch,
Label,
Loading,
resizeObserver
} from '@hcengineering/ui'
import { Filter } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { FILTER_DEBOUNCE_MS } from '../../filter'
import view from '../../plugin'
import { getPresenter } from '../../utils'
export let _class: Ref<Class<Doc>>
export let space: Ref<Space> | undefined = undefined
export let filter: Filter
export let onChange: (e: Filter) => void
filter.modes = [view.filter.FilterArrayAll, view.filter.FilterArrayAny]
filter.mode = filter.mode === undefined ? filter.modes[0] : filter.mode
const client = getClient()
const key = { key: filter.key.key }
const promise = getPresenter(client, filter.key._class, key, key)
let values = new Set<any>()
let selectedValues: Set<any> = new Set<any>(filter.value.map((p) => p[0]))
const realValues = new Map<any, Set<any>>()
let objectsPromise: Promise<FindResult<Doc>> | undefined
let filterUpdateTimeout: number | undefined
async function getEnumValues (search: string): Promise<void> {
const enumId = ((filter.key.attribute.type as ArrOf<Doc>).of as EnumOf).of
const enumVal = await client.findOne(core.class.Enum, { _id: enumId })
if (enumVal !== undefined) {
for (const realValue of enumVal.enumValues) {
const value = getValue(realValue) as string
if (search !== '' && !value.includes(search.toUpperCase())) continue
values.add(value)
realValues.set(value, (realValues.get(value) ?? new Set()).add(realValue))
}
values = values
}
}
async function getValues (search: string): Promise<void> {
if (objectsPromise) {
await objectsPromise
}
objectsPromise = undefined
values.clear()
realValues.clear()
if ((filter.key.attribute.type as ArrOf<Doc>).of._class === core.class.EnumOf) {
return getEnumValues(search)
}
const resultQuery =
search !== ''
? {
[filter.key.key]: { $like: '%' + search + '%' }
}
: {}
let prefix = ''
const hieararchy = client.getHierarchy()
const attr = hieararchy.getAttribute(filter.key._class, filter.key.key)
if (hieararchy.isMixin(attr.attributeOf)) {
prefix = attr.attributeOf + '.'
}
objectsPromise = client.findAll(
_class,
{ ...resultQuery, ...(space ? { space } : {}) },
{
sort: { [filter.key.key]: SortingOrder.Ascending },
projection: { [prefix + filter.key.key]: 1, space: 1 }
}
)
const res = await objectsPromise
for (const object of res) {
let asDoc = object
if (hieararchy.isMixin(filter.key._class)) {
asDoc = hieararchy.as(object, filter.key._class)
}
const arr = getObjectValue(filter.key.key, asDoc)
if (!Array.isArray(arr)) continue
for (const realValue of arr) {
const value = getValue(realValue)
values.add(value)
realValues.set(value, (realValues.get(value) ?? new Set()).add(realValue))
}
}
for (const object of filter.value.map((p) => p[0])) {
values.add(object)
}
values = values
objectsPromise = undefined
}
function getValue (obj: any): any {
if (typeof obj === 'string') {
const trim = obj.trim()
return trim.length > 0 ? trim.toUpperCase() : undefined
} else {
return obj ?? undefined
}
}
function isSelected (value: any, values: Set<any>): boolean {
return values.has(value)
}
function handleFilterToggle (value: any): void {
if (isSelected(value, selectedValues)) {
selectedValues.delete(value)
} else {
selectedValues.add(value)
}
selectedValues = selectedValues
updateFilter(selectedValues)
}
function updateFilter (newValues: Set<any>) {
clearTimeout(filterUpdateTimeout)
filterUpdateTimeout = setTimeout(() => {
filter.value = [...newValues.values()].map((v) => {
return [v, [...(realValues.get(v) ?? [])]]
})
onChange(filter)
}, FILTER_DEBOUNCE_MS)
}
let search: string = ''
const dispatch = createEventDispatcher()
getValues(search)
</script>
<div class="selectPopup" use:resizeObserver={() => dispatch('changeContent')}>
<div class="header">
<EditWithIcon
icon={IconSearch}
size={'large'}
width={'100%'}
autoFocus={!$deviceOptionsStore.isMobile}
bind:value={search}
placeholder={presentation.string.Search}
on:change={() => {
getValues(search)
}}
/>
</div>
<div class="scroll">
<div class="box">
{#await promise then attribute}
{#if objectsPromise}
<Loading />
{:else}
{#each [...values.keys()] as value}
{@const realValue = [...(realValues.get(value) ?? [])][0]}
<button
class="menu-item no-focus content-pointer-events-none"
on:click={() => {
handleFilterToggle(value)
}}
>
{#if value !== undefined}
<div class="clear-mins flex-grow">
<svelte:component
this={attribute.presenter}
value={typeof value === 'string' ? realValue : value}
{...attribute.props}
oneLine
/>
</div>
{:else}
<span class="overflow-label flex-grow"><Label label={ui.string.NotSelected} /></span>
{/if}
<div class="check pointer-events-none">
{#if isSelected(value, selectedValues)}
<Icon icon={IconCheck} size={'small'} />
{/if}
</div>
</button>
{/each}
{/if}
{/await}
</div>
</div>
<div class="menu-space" />
</div>

View File

@ -51,6 +51,14 @@ export function updateFilter (filter: Filter): void {
filterStore.set(old)
}
export async function arrayAllResult (filter: Filter): Promise<ObjQueryType<any>> {
return { $all: filter.value.map((p) => p[1]).flat() }
}
export async function arrayAnyResult (filter: Filter): Promise<ObjQueryType<any>> {
return { $in: filter.value.map((p) => p[1]).flat() }
}
export async function objectInResult (filter: Filter): Promise<ObjQueryType<any>> {
return { $in: filter.value }
}
@ -213,7 +221,7 @@ export async function getRefs (filter: Filter, onUpdate: () => void): Promise<Ar
return await promise
}
export function buildFilterKey (
function buildRefFilterKey (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
key: string,
@ -234,6 +242,16 @@ export function buildFilterKey (
}
}
}
}
export function buildFilterKey (
hierarchy: Hierarchy,
_class: Ref<Class<Doc>>,
key: string,
attribute: AnyAttribute
): KeyFilter | undefined {
const ref = buildRefFilterKey(hierarchy, _class, key, attribute)
if (ref != null) return ref
const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection)
const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class

View File

@ -76,9 +76,12 @@ import UpDownNavigator from './components/UpDownNavigator.svelte'
import ValueSelector from './components/ValueSelector.svelte'
import ViewletSettingButton from './components/ViewletSettingButton.svelte'
import DateFilterPresenter from './components/filter/DateFilterPresenter.svelte'
import ArrayFilter from './components/filter/ArrayFilter.svelte'
import {
afterResult,
arrayAllResult,
arrayAnyResult,
beforeResult,
containsResult,
dateCustom,
@ -179,6 +182,7 @@ function PositionElementAlignment (e?: Event): PopupAlignment | undefined {
export default async (): Promise<Resources> => ({
actionImpl,
component: {
ArrayFilter,
ClassPresenter,
ClassRefPresenter,
ObjectFilter,
@ -232,6 +236,8 @@ export default async (): Promise<Resources> => ({
PositionElementAlignment
},
function: {
FilterArrayAllResult: arrayAllResult,
FilterArrayAnyResult: arrayAnyResult,
FilterObjectInResult: objectInResult,
FilterObjectNinResult: objectNinResult,
FilterValueInResult: valueInResult,

View File

@ -23,6 +23,7 @@ export default mergeIds(viewId, view, {
ObjectFilter: '' as AnyComponent,
DateFilter: '' as AnyComponent,
ValueFilter: '' as AnyComponent,
ArrayFilter: '' as AnyComponent,
StringFilter: '' as AnyComponent,
TimestampFilter: '' as AnyComponent,
FilterTypePopup: '' as AnyComponent,
@ -50,6 +51,8 @@ export default mergeIds(viewId, view, {
ClearFilters: '' as IntlString,
FilterIsNot: '' as IntlString,
FilterIsEither: '' as IntlString,
FilterArrayAll: '' as IntlString,
FilterArrayAny: '' as IntlString,
FilterStatesCount: '' as IntlString,
FilterRemoved: '' as IntlString,
FilterUpdated: '' as IntlString,

View File

@ -787,6 +787,8 @@ const view = plugin(viewId, {
MarkdownFormatting: '' as Ref<ActionCategory>
},
filter: {
FilterArrayAll: '' as Ref<FilterMode>,
FilterArrayAny: '' as Ref<FilterMode>,
FilterObjectIn: '' as Ref<FilterMode>,
FilterObjectNin: '' as Ref<FilterMode>,
FilterValueIn: '' as Ref<FilterMode>,