Table filter (#1790)

Signed-off-by: Denis Bykhov <80476319+BykhovDenis@users.noreply.github.com>
This commit is contained in:
Denis Bykhov 2022-05-19 12:38:12 +06:00 committed by GitHub
parent 9f5b620dfd
commit 298a277729
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 938 additions and 7 deletions

View File

@ -471,6 +471,15 @@ export function createModel (builder: Builder): void {
builder.mixin(recruit.class.Vacancy, core.class.Class, view.mixin.IgnoreActions, {
actions: [view.action.Delete]
})
builder.mixin(recruit.mixin.Candidate, core.class.Class, view.mixin.ClassFilters, {
filters: ['title', 'source', 'city', 'modifiedOn']
})
builder.mixin(recruit.class.Applicant, core.class.Class, view.mixin.ClassFilters, {
filters: ['attachedTo', 'assignee', 'modifiedOn']
})
createReviewModel(builder)
// createAction(builder, { ...viewTemplates.open, target: recruit.class.Vacancy, context: { mode: ['browser', 'context'] } })

View File

@ -23,12 +23,15 @@ import type {
Action,
ActionCategory,
AttributeEditor,
AttributeFilter,
AttributePresenter,
BuildModelKey,
ClassFilters,
CollectionEditor,
HTMLPresenter,
IgnoreActions,
KeyBinding,
KeyFilter,
LinkPresenter,
ObjectEditor,
ObjectEditorHeader,
@ -76,6 +79,16 @@ export function classPresenter (
}
}
@Mixin(view.mixin.ClassFilters, core.class.Class)
export class TClassFilters extends TClass implements ClassFilters {
filters!: (string | KeyFilter)[]
}
@Mixin(view.mixin.AttributeFilter, core.class.Class)
export class TAttributeFilter extends TClass implements AttributeFilter {
component!: AnyComponent
}
@Mixin(view.mixin.AttributeEditor, core.class.Class)
export class TAttributeEditor extends TClass implements AttributeEditor {
editor!: AnyComponent
@ -224,6 +237,8 @@ export const actionTemplates = template({
export function createModel (builder: Builder): void {
builder.createModel(
TClassFilters,
TAttributeFilter,
TAttributeEditor,
TAttributePresenter,
TCollectionEditor,
@ -474,6 +489,26 @@ export function createModel (builder: Builder): void {
pattern: '(www.)?github.com/',
component: view.component.GithubPresenter
})
builder.mixin(core.class.TypeString, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ValueFilter
})
builder.mixin(core.class.TypeNumber, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ValueFilter
})
builder.mixin(core.class.TypeDate, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ValueFilter
})
builder.mixin(core.class.RefTo, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ObjectFilter
})
builder.mixin(core.class.TypeTimestamp, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.TimestampFilter
})
}
export default view

View File

@ -60,6 +60,9 @@ export default mergeIds(viewId, view, {
Open: '' as ViewAction
},
component: {
ObjectFilter: '' as AnyComponent,
ValueFilter: '' as AnyComponent,
TimestampFilter: '' as AnyComponent,
StringEditor: '' as AnyComponent,
StringPresenter: '' as AnyComponent,
IntlStringPresenter: '' as AnyComponent,

View File

@ -151,7 +151,10 @@ export abstract class MemDb extends TxProcessor {
result = result.filter((r) => (r as any)[_class] !== undefined)
}
if (options?.lookup !== undefined) result = await this.lookup(result as T[], options.lookup)
if (options?.lookup !== undefined) {
result = await this.lookup(result as T[], options.lookup)
result = matchQuery(result, query, _class, this.hierarchy)
}
if (options?.sort !== undefined) resultSort(result, options?.sort, _class, this.hierarchy)
const total = result.length

View File

@ -7,6 +7,8 @@
"Minutes": "{minutes, plural, =0 {less than a minute ago} =1 {a minute ago} other {# minutes ago}}",
"Hours": "{hours, plural, =0 {less than an hour ago} =1 {an hour ago} other {# hours ago}}",
"Days": "{days, plural, =0 {today} =1 {yesterday} other {# days ago}}",
"Months": "{months, plural, =0 {this month} =1 {a month aago} other {# months ago}}",
"Years": "{years, plural, =0 {this year} =1 {a year ago} other {# years ago}}",
"ShowMore": "Show more",
"ShowLess": "Show less",
"Search": "Search",

View File

@ -7,6 +7,8 @@
"Minutes": "{minutes, plural, =0 {меньше минуты назад} =1 {минуту назад} other {# минут назад}}",
"Hours": "{hours, plural, =0 {меньше часа назад} =1 {час назад} other {# часов назад}}",
"Days": "{days, plural, =0 {сегода} =1 {вчера} other {# дней назад}}",
"Months": "{months, plural, =0 {в этом месяце} =1 {месяц назад} =2 {2 месяца назад} =3 {3 месяца назад} =4 {4 месяца назад} other {# месяцев назад}}",
"Years": "{years, plural, =0 {в этом году} =1 {год назад} =2 {2 года назад} =3 {3 года назад} =4 {4 года назад} other {# лет назад}}",
"ShowMore": "Показать больше",
"ShowLess": "Показать меньше",
"Search": "Поиск",

View File

@ -25,6 +25,8 @@
const MINUTE = SECOND * 60
const HOUR = MINUTE * 60
const DAY = HOUR * 24
const MONTH = DAY * 30
const YEAR = MONTH * 12
let time: string = ''
@ -35,8 +37,12 @@
time = await translate(ui.string.Minutes, { minutes: Math.floor(passed / MINUTE) })
} else if (passed < DAY) {
time = await translate(ui.string.Hours, { hours: Math.floor(passed / HOUR) })
} else {
} else if (passed < MONTH) {
time = await translate(ui.string.Days, { days: Math.floor(passed / DAY) })
} else if (passed < YEAR) {
time = await translate(ui.string.Months, { months: Math.floor(passed / MONTH) })
} else {
time = await translate(ui.string.Years, { years: Math.floor(passed / YEAR) })
}
}

View File

@ -32,6 +32,8 @@ export default plugin(uiId, {
Minutes: '' as IntlString,
Hours: '' as IntlString,
Days: '' as IntlString,
Months: '' as IntlString,
Years: '' as IntlString,
ShowMore: '' as IntlString,
ShowLess: '' as IntlString,
Search: '' as IntlString,

View File

@ -37,6 +37,14 @@
"WithTime": "WithTime",
"CreatingAttribute": "Creating an attribute",
"CreatingAttributeConfirm": "Warning: It will not be possible for now to change or delete created attribute.",
"CustomizeView": "Customize view"
"CustomizeView": "Customize view",
"Filter": "Filter",
"ClearFilters": "Clear filters",
"FilterIs": "is",
"FilterIsNot": "is not",
"FilterIsEither": "is either of",
"FilterStatesCount": "{value, plural, =1 {1 state} other {# states}}",
"Before": "Before",
"After": "After"
}
}

View File

@ -35,6 +35,14 @@
"ShowPreview": "Предпросмотр документа",
"ShowActions": "Показать действия",
"RestoreDefaults": "По умолчанию",
"CustomizeView": "Настроить отображение"
"CustomizeView": "Настроить отображение",
"Filter": "Фильтр",
"ClearFilters": "Очистить",
"FilterIs": "равен",
"FilterIsNot": "не равен",
"FilterIsEither": "один из",
"FilterStatesCount": "{value, plural, =1 {1 состоянию} other {# состояний}}",
"Before": "До",
"After": "После"
}
}

View File

@ -20,6 +20,7 @@
import { ActionContext } from '..'
import { focusStore, ListSelectionProvider, SelectDirection, selectionStore } from '../selection'
import { LoadingProps } from '../utils'
import FilterBar from './filter/FilterBar.svelte'
import Table from './Table.svelte'
export let _class: Ref<Class<Doc>>
@ -32,6 +33,8 @@
// If defined, will show a number of dummy items before real data will appear.
export let loadingProps: LoadingProps | undefined = undefined
let resultQuery = query
let table: Table
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
if (dir === 'vertical') {
@ -51,13 +54,14 @@
}}
/>
<FilterBar {_class} {query} on:change={(e) => (resultQuery = e.detail)} />
<Scroller tableFade>
<Table
bind:this={table}
{_class}
{config}
{options}
{query}
query={resultQuery}
{showNotification}
{baseMenuClass}
{loadingProps}

View File

@ -0,0 +1,163 @@
<!--
// Copyright © 2022 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 { Class, Doc, DocumentQuery, Ref } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Button, eventToHTMLElement, IconAdd, IconClose, showPopup } from '@anticrm/ui'
import { Filter } from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import view from '../../plugin'
import FilterSection from './FilterSection.svelte'
import FilterTypePopup from './FilterTypePopup.svelte'
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc>
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
let filters: Filter[] = []
let isNew = true
function onChange (e: Filter | undefined) {
if (e === undefined) return
if (isNew) {
filters.push(e)
isNew = false
filters = filters
} else {
filters[filters.length - 1] = e
filters = filters
}
}
function add (e: MouseEvent) {
const target = eventToHTMLElement(e)
isNew = true
showPopup(
FilterTypePopup,
{
_class,
makeQuery: (key: string) => makeQuery(query, filters, key),
target,
onChange
},
target
)
}
function remove (i: number) {
filters.splice(i, 1)
filters = filters
}
function makeQuery (query: DocumentQuery<Doc>, filters: Filter[], skipKey?: string): DocumentQuery<Doc> {
const newQuery = hierarchy.clone(query)
for (let i = 0; i < filters.length; i++) {
const filter = filters[i]
if (skipKey !== undefined && filter.key.key === skipKey) continue
if (newQuery[filter.key.key] === undefined) {
newQuery[filter.key.key] = filter.mode.result(filter.value)
} else {
const newValue = filter.mode.result(filter.value)
let merged = false
for (const key in newValue) {
if (newQuery[filter.key.key][key] === undefined) {
newQuery[filter.key.key][key] = newValue[key]
merged = true
continue
}
if (key === '$in') {
newQuery[filter.key.key][key] = newQuery[filter.key.key][key].filter((p: any) => newValue[key].includes(p))
merged = true
continue
}
if (key === '$nin') {
newQuery[filter.key.key][key] = [...newQuery[filter.key.key][key], ...newValue[key]]
merged = true
continue
}
if (key === '$lt') {
newQuery[filter.key.key][key] =
newQuery[filter.key.key][key] < newValue[key] ? newQuery[filter.key.key][key] : newValue[key]
merged = true
continue
}
if (key === '$gt') {
newQuery[filter.key.key][key] =
newQuery[filter.key.key][key] > newValue[key] ? newQuery[filter.key.key][key] : newValue[key]
merged = true
continue
}
}
if (!merged) {
Object.assign(newQuery[filter.key.key], filter.mode.result(filter.value))
}
}
}
if (skipKey === undefined) {
dispatch('change', newQuery)
}
return newQuery
}
$: makeQuery(query, filters)
$: clazz = hierarchy.getClass(_class)
$: visible = hierarchy.hasMixin(clazz, view.mixin.ClassFilters)
</script>
{#if visible}
<div class="flex pl-4 pr-4">
{#each filters as filter, i}
<FilterSection
{_class}
query={makeQuery(query, filters, filter.key.key)}
{filter}
on:change={() => {
makeQuery(query, filters)
}}
on:remove={() => {
remove(i)
}}
/>
{/each}
<div class="ml-2">
<Button
size="small"
icon={IconAdd}
kind={'link-bordered'}
borderStyle={'dashed'}
label={view.string.Filter}
on:click={add}
/>
</div>
{#if filters.length}
<div class="ml-2">
<Button
size="small"
icon={IconClose}
kind={'link-bordered'}
borderStyle={'dashed'}
label={view.string.ClearFilters}
on:click={() => {
filters = []
}}
/>
</div>
{/if}
</div>
{/if}

View File

@ -0,0 +1,96 @@
<!--
// Copyright © 2022 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 { Class, Doc, DocumentQuery, Ref } from '@anticrm/core'
import { Button, eventToHTMLElement, IconClose, showPopup } from '@anticrm/ui'
import { Filter } from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import view from '../../plugin'
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc>
export let filter: Filter
let current = 0
const dispatch = createEventDispatcher()
function toggle () {
const modes = filter.modes.filter((p) => p.isAvailable(filter.value))
current++
filter.mode = modes[current % modes.length]
dispatch('change')
}
function onChange (e: Filter | undefined) {
filter = filter
dispatch('change')
}
</script>
<div class="root">
<div class="buttonWrapper">
<Button shape={'rectangle-right'} label={filter.key.label} icon={filter.key.icon} />
</div>
<div class="buttonWrapper">
<Button shape="rectangle" label={filter.mode.label} on:click={toggle} />
</div>
<div class="buttonWrapper">
<Button
shape={'rectangle'}
label={view.string.FilterStatesCount}
labelParams={{ value: filter.value.length }}
on:click={(e) => {
showPopup(
filter.key.component,
{
_class,
query,
filter,
onChange
},
eventToHTMLElement(e)
)
}}
/>
</div>
<div class="buttonWrapper">
<Button
shape={'rectangle-left'}
icon={IconClose}
on:click={() => {
dispatch('remove')
}}
/>
</div>
</div>
<style lang="scss">
.root {
display: flex;
align-items: center;
&:not(:first-child) {
margin-left: 0.5rem;
}
}
.buttonWrapper {
margin-right: 1px;
&:last-child {
margin-right: 0;
}
}
</style>

View File

@ -0,0 +1,162 @@
<!--
// Copyright © 2022 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, { AnyAttribute, ArrOf, Class, Doc, DocumentQuery, Ref, Type } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Icon, Label, showPopup } from '@anticrm/ui'
import { Filter, KeyFilter } from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import view from '../../plugin'
export let _class: Ref<Class<Doc>>
export let makeQuery: (key: string) => DocumentQuery<Doc>
export let target: HTMLElement
export let onChange: (e: Filter) => void
const client = getClient()
const hierarchy = client.getHierarchy()
function getFilters (_class: Ref<Class<Doc>>): KeyFilter[] {
const clazz = hierarchy.getClass(_class)
const mixin = hierarchy.as(clazz, view.mixin.ClassFilters)
if (mixin.filters === undefined) return []
const filters = mixin.filters.map((p) => {
return typeof p === 'string' ? buildFilterFromKey(p) : p
})
const result: KeyFilter[] = []
for (const filter of filters) {
if (filter !== undefined) result.push(filter)
}
return result
}
function buildFilterFromKey (key: string): KeyFilter | undefined {
const attribute = hierarchy.getAttribute(_class, key)
return buildFilter(key, attribute)
}
function buildFilter (key: string, attribute: AnyAttribute): KeyFilter | undefined {
const clazz = hierarchy.getClass(attribute.type._class)
const filter = hierarchy.as(clazz, view.mixin.AttributeFilter)
if (filter.component === undefined) return undefined
return {
key: key,
label: attribute.label,
icon: attribute.icon,
component: filter.component
}
}
function getValue (name: string, type: Type<any>): string {
if (hierarchy.isDerived(type._class, core.class.ArrOf)) {
return getValue(name, (type as ArrOf<any>).of)
}
return name
}
function getTypes (_class: Ref<Class<Doc>>): KeyFilter[] {
const result = getFilters(_class)
const allAttributes = hierarchy.getAllAttributes(_class)
for (const [, attribute] of allAttributes) {
if (attribute.isCustom !== true) continue
if (attribute.label === undefined || attribute.hidden) continue
const value = getValue(attribute.name, attribute.type)
if (result.findIndex((p) => p.key === value) !== -1) continue
const filter = buildFilter(value, attribute)
if (filter !== undefined) {
result.push(filter)
}
}
return result
}
const actionElements: HTMLButtonElement[] = []
const keyDown = (event: KeyboardEvent, index: number) => {
if (event.key === 'ArrowDown') {
actionElements[(index + 1) % actionElements.length].focus()
}
if (event.key === 'ArrowUp') {
actionElements[(actionElements.length + index - 1) % actionElements.length].focus()
}
if (event.key === 'ArrowLeft') {
dispatch('close')
}
}
const dispatch = createEventDispatcher()
function click (type: KeyFilter): void {
dispatch('close')
showPopup(
type.component,
{
_class,
query: makeQuery(type.key),
filter: {
key: type,
value: []
},
onChange
},
target
)
}
</script>
<div class="antiPopup">
<div class="ap-space" />
<div class="ap-scroll">
<div class="ap-box">
{#each getTypes(_class) as type, i}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
class="ap-menuItem flex-row-center withIcon"
on:keydown={(event) => keyDown(event, i)}
on:mouseover={(event) => {
event.currentTarget.focus()
}}
on:click={(event) => {
click(type)
}}
>
{#if type.icon}
<div class="icon"><Icon icon={type.icon} size={'small'} /></div>
{/if}
<div class="ml-3 pr-1"><Label label={type.label} /></div>
</button>
{/each}
</div>
</div>
<div class="ap-space" />
</div>
<style lang="scss">
.withIcon {
margin: 0;
.icon {
color: var(--content-color);
}
&:focus .icon {
color: var(--accent-color);
}
}
</style>

View File

@ -0,0 +1,147 @@
<!--
// Copyright © 2022 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 { Class, Doc, DocumentQuery, getObjectValue, Ref } from '@anticrm/core'
import { translate } from '@anticrm/platform'
import presentation, { getClient } from '@anticrm/presentation'
import { CheckBox } from '@anticrm/ui'
import { Filter } from '@anticrm/view'
import { onMount } from 'svelte'
import { buildConfigLookup, getPresenter } from '../../utils'
import view from '../../plugin'
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc>
export let filter: Filter
export let onChange: (e: Filter) => void
const client = getClient()
const hierarchy = client.getHierarchy()
const tkey = '$lookup.' + filter.key.key
const key = { key: tkey }
const lookup = buildConfigLookup(hierarchy, _class, [tkey])
const promise = getPresenter(client, _class, key, key, lookup)
filter.modes = [
{
label: view.string.FilterIs,
isAvailable: (res: any[]) => res.length <= 1,
result: (res: any[]) => {
return { $in: res }
}
},
{
label: view.string.FilterIsEither,
isAvailable: (res: any[]) => res.length > 1,
result: (res: any[]) => {
return { $in: res }
}
},
{
label: view.string.FilterIsNot,
isAvailable: () => true,
result: (res: any[]) => {
return { $nin: res }
}
}
]
let values: Set<Doc> = new Set<Doc>()
$: getValues(search)
async function getValues (search: string): Promise<void> {
const attrib = await promise
const resultQuery =
search !== ''
? {
[attrib.sortingKey]: { $like: '%' + search + '%' },
...query
}
: query
const res = await client.findAll(_class, resultQuery, { lookup })
const objects = []
const set: Set<any> = new Set<any>()
for (const obj of res) {
const value = (obj as any)[filter.key.key]
if (set.has(value)) continue
objects.push(obj)
set.add(value)
}
values = new Set(objects.map((obj) => getObjectValue(tkey, obj)))
}
function isSelected (value: Doc, values: any[]): boolean {
return values.includes(value._id)
}
function checkMode () {
if (filter.mode?.isAvailable(filter.value)) return
const newMode = filter.modes.find((p) => p.isAvailable(filter.value))
filter.mode = newMode !== undefined ? newMode : filter.mode
}
function toggle (value: Doc): void {
if (isSelected(value, filter.value)) {
filter.value = filter.value.filter((p) => p !== value._id)
} else {
filter.value = [...filter.value, value._id]
}
checkMode()
onChange(filter)
}
let search: string = ''
let phTraslate: string = ''
let searchInput: HTMLInputElement
$: translate(presentation.string.Search, {}).then((res) => {
phTraslate = res
})
onMount(() => {
if (searchInput) searchInput.focus()
})
</script>
<div class="selectPopup">
<div class="header">
<input
bind:this={searchInput}
type="text"
bind:value={search}
placeholder={phTraslate}
on:input={(ev) => {}}
on:change
/>
</div>
<div class="scroll">
<div class="box">
{#await promise then attribute}
{#each Array.from(values) as value}
<button
class="menu-item"
on:click={() => {
toggle(value)
}}
>
<div class="check pointer-events-none">
<CheckBox checked={isSelected(value, filter.value)} primary />
</div>
<svelte:component this={attribute.presenter} {value} {...attribute.props} />
</button>
{/each}
{/await}
</div>
</div>
</div>

View File

@ -0,0 +1,84 @@
<!--
// Copyright © 2022 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 { Filter } from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import view from '../../plugin'
import TimestampPresenter from '../TimestampPresenter.svelte'
export let filter: Filter
export let onChange: (e: Filter) => void
const dispatch = createEventDispatcher()
filter.modes = [
{
label: view.string.Before,
isAvailable: (res: any[]) => true,
result: (res: any[]) => {
return { $lt: res[0] }
}
},
{
label: view.string.After,
isAvailable: () => true,
result: (res: any[]) => {
return { $gt: res[0] }
}
}
]
filter.mode = filter.mode === undefined ? filter.modes[0] : filter.mode
function click (value: number): void {
filter.value = [value]
onChange(filter)
dispatch('close')
}
const today = new Date().setHours(0, 0, 0, 0)
function shiftDays (diff: number): number {
return new Date(today).setDate(new Date(today).getDate() - diff)
}
const values = [
shiftDays(1),
shiftDays(2),
shiftDays(3),
shiftDays(7),
shiftDays(14),
shiftDays(21),
shiftDays(30),
shiftDays(90),
shiftDays(180),
shiftDays(365)
]
</script>
<div class="selectPopup">
<div class="scroll">
<div class="box">
{#each values as value, i}
<div
class="menu-item"
on:click={() => {
click(value)
}}
>
<TimestampPresenter {value} />
</div>
{/each}
</div>
</div>
</div>

View File

@ -0,0 +1,136 @@
<!--
// Copyright © 2022 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 { Class, Doc, DocumentQuery, getObjectValue, Ref } from '@anticrm/core'
import { translate } from '@anticrm/platform'
import presentation, { getClient } from '@anticrm/presentation'
import { CheckBox } from '@anticrm/ui'
import { Filter } from '@anticrm/view'
import { onMount } from 'svelte'
import { getPresenter } from '../../utils'
import view from '../../plugin'
export let _class: Ref<Class<Doc>>
export let query: DocumentQuery<Doc>
export let filter: Filter
export let onChange: (e: Filter) => void
filter.modes = [
{
label: view.string.FilterIs,
isAvailable: (res: any[]) => res.length <= 1,
result: (res: any[]) => {
return { $in: res }
}
},
{
label: view.string.FilterIsEither,
isAvailable: (res: any[]) => res.length > 1,
result: (res: any[]) => {
return { $in: res }
}
},
{
label: view.string.FilterIsNot,
isAvailable: () => true,
result: (res: any[]) => {
return { $nin: res }
}
}
]
const client = getClient()
const key = { key: filter.key.key }
const promise = getPresenter(client, _class, key, key)
let values: Set<any> = new Set<any>()
$: getValues(search)
async function getValues (search: string): Promise<void> {
const resultQuery =
search !== ''
? {
[filter.key.key]: { $like: '%' + search + '%' },
...query
}
: query
const res = await client.findAll(_class, resultQuery)
values = new Set(res.map((obj) => getObjectValue(filter.key.key, obj)))
}
function isSelected (value: any, values: any[]): boolean {
return values.includes(value)
}
function checkMode () {
if (filter.mode?.isAvailable(filter.value)) return
const newMode = filter.modes.find((p) => p.isAvailable(filter.value))
filter.mode = newMode !== undefined ? newMode : filter.mode
}
function toggle (value: any): void {
if (isSelected(value, filter.value)) {
filter.value = filter.value.filter((p) => p !== value)
} else {
filter.value = [...filter.value, value]
}
checkMode()
onChange(filter)
}
let search: string = ''
let phTraslate: string = ''
let searchInput: HTMLInputElement
$: translate(presentation.string.Search, {}).then((res) => {
phTraslate = res
})
onMount(() => {
if (searchInput) searchInput.focus()
})
</script>
<div class="selectPopup">
<div class="header">
<input
bind:this={searchInput}
type="text"
bind:value={search}
placeholder={phTraslate}
on:input={(ev) => {}}
on:change
/>
</div>
<div class="scroll">
<div class="box">
{#await promise then attribute}
{#each Array.from(values) as value}
<button
class="menu-item"
on:click={() => {
toggle(value)
}}
>
<div class="check pointer-events-none">
<CheckBox checked={isSelected(value, filter.value)} primary />
</div>
<svelte:component this={attribute.presenter} {value} {...attribute.props} />
</button>
{/each}
{/await}
</div>
</div>
</div>

View File

@ -46,6 +46,9 @@ import RefEditor from './components/typeEditors/RefEditor.svelte'
import DocAttributeBar from './components/DocAttributeBar.svelte'
import ViewletSetting from './components/ViewletSetting.svelte'
import TableBrowser from './components/TableBrowser.svelte'
import ValueFilter from './components/filter/ValueFilter.svelte'
import ObjectFilter from './components/filter/ObjectFilter.svelte'
import TimestampFilter from './components/filter/TimestampFilter.svelte'
export { getActions, invokeAction } from './actions'
export { default as ActionContext } from './components/ActionContext.svelte'
@ -72,6 +75,9 @@ export {
export default async (): Promise<Resources> => ({
actionImpl: actionImpl,
component: {
ObjectFilter,
ValueFilter,
TimestampFilter,
TableBrowser,
ViewletSetting,
CreateAttribute,

View File

@ -40,6 +40,14 @@ export default mergeIds(viewId, view, {
ActionPlaceholder: '' as IntlString,
CreatingAttribute: '' as IntlString,
RestoreDefaults: '' as IntlString,
CreatingAttributeConfirm: '' as IntlString
CreatingAttributeConfirm: '' as IntlString,
Filter: '' as IntlString,
ClearFilters: '' as IntlString,
FilterIs: '' as IntlString,
FilterIsNot: '' as IntlString,
FilterIsEither: '' as IntlString,
FilterStatesCount: '' as IntlString,
Before: '' as IntlString,
After: '' as IntlString
}
})

View File

@ -123,7 +123,7 @@ async function getAttributePresenter (
}
}
async function getPresenter<T extends Doc> (
export async function getPresenter<T extends Doc> (
client: Client,
_class: Ref<Class<T>>,
key: BuildModelKey,

View File

@ -24,8 +24,10 @@ import type {
Lookup,
Mixin,
Obj,
ObjQueryType,
Ref,
Space,
Type,
UXObject
} from '@anticrm/core'
import type { Asset, IntlString, Plugin, Resource, Status } from '@anticrm/platform'
@ -34,6 +36,49 @@ import type { AnyComponent, AnySvelteComponent } from '@anticrm/ui'
import { PopupPosAlignment } from '@anticrm/ui/src/types'
import type { Preference } from '@anticrm/preference'
/**
* @public
*/
export interface KeyFilter {
key: string
component: AnyComponent
label: IntlString
icon: Asset | undefined
}
/**
* @public
*/
export interface FilterMode {
label: IntlString
isAvailable: (values: any[]) => boolean
result: (values: any[]) => ObjQueryType<any>
}
/**
* @public
*/
export interface Filter {
key: KeyFilter
mode: FilterMode
modes: FilterMode[]
value: any[]
}
/**
* @public
*/
export interface ClassFilters extends Class<Doc> {
filters: (KeyFilter | string)[]
}
/**
* @public
*/
export interface AttributeFilter extends Class<Type<any>> {
component: AnyComponent
}
/**
* @public
*/
@ -303,6 +348,8 @@ export interface ViewletPreference extends Preference {
*/
const view = plugin(viewId, {
mixin: {
ClassFilters: '' as Ref<Mixin<ClassFilters>>,
AttributeFilter: '' as Ref<Mixin<AttributeFilter>>,
AttributeEditor: '' as Ref<Mixin<AttributeEditor>>,
CollectionEditor: '' as Ref<Mixin<CollectionEditor>>,
AttributePresenter: '' as Ref<Mixin<AttributePresenter>>,