mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-16 05:13:06 +00:00
Table filter (#1790)
Signed-off-by: Denis Bykhov <80476319+BykhovDenis@users.noreply.github.com>
This commit is contained in:
parent
9f5b620dfd
commit
298a277729
@ -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'] } })
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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": "Поиск",
|
||||
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,14 @@
|
||||
"ShowPreview": "Предпросмотр документа",
|
||||
"ShowActions": "Показать действия",
|
||||
"RestoreDefaults": "По умолчанию",
|
||||
"CustomizeView": "Настроить отображение"
|
||||
"CustomizeView": "Настроить отображение",
|
||||
"Filter": "Фильтр",
|
||||
"ClearFilters": "Очистить",
|
||||
"FilterIs": "равен",
|
||||
"FilterIsNot": "не равен",
|
||||
"FilterIsEither": "один из",
|
||||
"FilterStatesCount": "{value, plural, =1 {1 состоянию} other {# состояний}}",
|
||||
"Before": "До",
|
||||
"After": "После"
|
||||
}
|
||||
}
|
||||
|
@ -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}
|
||||
|
163
plugins/view-resources/src/components/filter/FilterBar.svelte
Normal file
163
plugins/view-resources/src/components/filter/FilterBar.svelte
Normal 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}
|
@ -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>
|
@ -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>
|
147
plugins/view-resources/src/components/filter/ObjectFilter.svelte
Normal file
147
plugins/view-resources/src/components/filter/ObjectFilter.svelte
Normal 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>
|
@ -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>
|
136
plugins/view-resources/src/components/filter/ValueFilter.svelte
Normal file
136
plugins/view-resources/src/components/filter/ValueFilter.svelte
Normal 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>
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
})
|
||||
|
@ -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,
|
||||
|
@ -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>>,
|
||||
|
Loading…
Reference in New Issue
Block a user