diff --git a/models/recruit/src/index.ts b/models/recruit/src/index.ts index ad79749ae3..6208ed9284 100644 --- a/models/recruit/src/index.ts +++ b/models/recruit/src/index.ts @@ -452,7 +452,7 @@ export function createModel (builder: Builder): void { }) builder.mixin(recruit.mixin.Candidate, core.class.Class, view.mixin.ClassFilters, { - filters: ['title', 'source', 'city', 'modifiedOn', 'onsite', 'remote'] + filters: ['title', 'source', 'city', 'skills', 'modifiedOn', 'onsite', 'remote'] }) builder.mixin(recruit.class.Applicant, core.class.Class, view.mixin.ClassFilters, { diff --git a/models/server-tags/src/index.ts b/models/server-tags/src/index.ts index 01c1f5aadd..e939fe228d 100644 --- a/models/server-tags/src/index.ts +++ b/models/server-tags/src/index.ts @@ -20,6 +20,10 @@ import serverTags from '@anticrm/server-tags' import tags from '@anticrm/tags' export function createModel (builder: Builder): void { + builder.createDoc(serverCore.class.Trigger, core.space.Model, { + trigger: serverTags.trigger.onTagReference + }) + builder.mixin<Class<Doc>, ObjectDDParticipant>( tags.class.TagElement, core.class.Class, diff --git a/models/tags/src/index.ts b/models/tags/src/index.ts index 5e5c8d0d76..2a4598a883 100644 --- a/models/tags/src/index.ts +++ b/models/tags/src/index.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Class, Doc, Domain, IndexKind, Ref } from '@anticrm/core' -import { ArrOf, Builder, Index, Model, Prop, TypeRef, TypeString, UX } from '@anticrm/model' +import { ArrOf, Builder, Index, Model, Prop, TypeNumber, TypeRef, TypeString, UX } from '@anticrm/model' import core, { TAttachedDoc, TDoc } from '@anticrm/model-core' import view from '@anticrm/model-view' import { Asset, IntlString } from '@anticrm/platform' @@ -45,6 +45,9 @@ export class TTagElement extends TDoc implements TagElement { @Prop(TypeRef(tags.class.TagCategory), tags.string.CategoryLabel) category!: Ref<TagCategory> + + @Prop(TypeNumber(), tags.string.TagReference) + refCount?: number } @Model(tags.class.TagReference, core.class.AttachedDoc, DOMAIN_TAGS) @@ -94,4 +97,8 @@ export function createModel (builder: Builder): void { builder.mixin(tags.class.TagElement, core.class.Class, view.mixin.AttributePresenter, { presenter: tags.component.TagElementPresenter }) + + builder.mixin(tags.class.TagReference, core.class.Class, view.mixin.AttributeFilter, { + component: tags.component.TagsFilter + }) } diff --git a/models/tags/src/migration.ts b/models/tags/src/migration.ts index a2869993cb..d1f84168a1 100644 --- a/models/tags/src/migration.ts +++ b/models/tags/src/migration.ts @@ -1,8 +1,36 @@ -import core, { TxOperations } from '@anticrm/core' -import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@anticrm/model' +import core, { Ref, TxOperations } from '@anticrm/core' +import { MigrateOperation, MigrationClient, MigrationResult, MigrationUpgradeClient } from '@anticrm/model' +import { TagElement, TagReference } from '@anticrm/tags' import { DOMAIN_TAGS } from './index' import tags from './plugin' +async function updateTagRefCount (client: MigrationClient): Promise<void> { + const tagElements = await client.find(DOMAIN_TAGS, { _class: tags.class.TagElement, refCount: { $exists: false } }) + const refs = await client.find<TagReference>(DOMAIN_TAGS, { + _class: tags.class.TagReference, + tag: { $in: tagElements.map((p) => p._id as Ref<TagElement>) } + }) + const map = new Map<Ref<TagElement>, number>() + for (const ref of refs) { + map.set(ref.tag, (map.get(ref.tag) ?? 0) + 1) + } + const promises: Promise<MigrationResult>[] = [] + for (const tag of map) { + promises.push( + client.update( + DOMAIN_TAGS, + { + _id: tag[0] + }, + { + refCount: tag[1] + } + ) + ) + } + await Promise.all(promises) +} + export const tagsOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise<void> { await client.update( @@ -15,6 +43,8 @@ export const tagsOperation: MigrateOperation = { category: 'recruit:category:Other' } ) + + await updateTagRefCount(client) }, async upgrade (client: MigrationUpgradeClient): Promise<void> { const tx = new TxOperations(client, core.account.System) diff --git a/models/tags/src/plugin.ts b/models/tags/src/plugin.ts index 175ab2af54..b5f3ef0a7c 100644 --- a/models/tags/src/plugin.ts +++ b/models/tags/src/plugin.ts @@ -25,7 +25,8 @@ export default mergeIds(tagsId, tags, { Tags: '' as AnyComponent, TagReferencePresenter: '' as AnyComponent, TagsPresenter: '' as AnyComponent, - TagsItemPresenter: '' as AnyComponent + TagsItemPresenter: '' as AnyComponent, + TagsFilter: '' as AnyComponent }, string: { TagElementLabel: '' as IntlString, diff --git a/packages/presentation/src/utils.ts b/packages/presentation/src/utils.ts index 92f5877ef6..ae89796555 100644 --- a/packages/presentation/src/utils.ts +++ b/packages/presentation/src/utils.ts @@ -78,11 +78,13 @@ export class LiveQuery { private oldCallback: ((result: FindResult<any>) => void) | undefined unsubscribe = () => {} - constructor () { - onDestroy(() => { - console.log('onDestroy query') - this.unsubscribe() - }) + constructor (dontDestroy: boolean = false) { + if (!dontDestroy) { + onDestroy(() => { + console.log('onDestroy query') + this.unsubscribe() + }) + } } query<T extends Doc>( @@ -121,8 +123,8 @@ export class LiveQuery { } } -export function createQuery (): LiveQuery { - return new LiveQuery() +export function createQuery (dontDestroy?: boolean): LiveQuery { + return new LiveQuery(dontDestroy) } export function getFileUrl (file: string): string { diff --git a/plugins/recruit-resources/src/components/Candidates.svelte b/plugins/recruit-resources/src/components/Candidates.svelte index 8293ade2ed..b4c7f0b138 100644 --- a/plugins/recruit-resources/src/components/Candidates.svelte +++ b/plugins/recruit-resources/src/components/Candidates.svelte @@ -15,10 +15,9 @@ --> <script lang="ts"> import contact from '@anticrm/contact' - import { Doc, DocumentQuery, Ref } from '@anticrm/core' + import { Doc, DocumentQuery } from '@anticrm/core' import { createQuery, getClient } from '@anticrm/presentation' - import tags, { selectedTagElements, TagCategory, TagElement } from '@anticrm/tags' - import { ActionIcon, showPopup, Component, Icon, Label, Loading, SearchEdit, Button, IconAdd } from '@anticrm/ui' + import { ActionIcon, showPopup, Icon, Label, Loading, SearchEdit, Button, IconAdd } from '@anticrm/ui' import view, { Viewlet, ViewletPreference } from '@anticrm/view' import { ActionContext, TableBrowser, ViewletSetting } from '@anticrm/view-resources' import recruit from '../plugin' @@ -57,29 +56,11 @@ } }) - let category: Ref<TagCategory> | undefined = undefined - - let documentIds: Ref<Doc>[] = [] - async function updateResultQuery (search: string, documentIds: Ref<Doc>[]): Promise<void> { + async function updateResultQuery (search: string): Promise<void> { resultQuery = search === '' ? {} : { $search: search } - if (documentIds.length > 0) { - resultQuery._id = { $in: documentIds } - } } - // Find all tags for object classe with matched elements - const query = createQuery() - - $: query.query(tags.class.TagReference, { tag: { $in: $selectedTagElements } }, (result) => { - documentIds = Array.from(new Set<Ref<Doc>>(result.map((it) => it.attachedTo)).values()) - }) - - $: updateResultQuery(search, documentIds) - - function updateCategory (detail: { category: Ref<TagCategory> | null; elements: TagElement[] }) { - category = detail.category ?? undefined - selectedTagElements.set(Array.from(detail.elements ?? []).map((it) => it._id)) - } + $: updateResultQuery(search) function showCreateDialog () { showPopup(CreateCandidate, {}, 'top') @@ -95,7 +76,7 @@ <SearchEdit bind:value={search} on:change={() => { - updateResultQuery(search, documentIds) + updateResultQuery(search) }} /> <Button icon={IconAdd} label={recruit.string.CandidateCreateLabel} kind={'primary'} on:click={showCreateDialog} /> @@ -111,12 +92,6 @@ {/if} </div> -<Component - is={tags.component.TagsCategoryBar} - props={{ targetClass: recruit.mixin.Candidate, category, selected: $selectedTagElements, mode: 'item' }} - on:change={(evt) => updateCategory(evt.detail)} -/> - <ActionContext context={{ mode: 'browser' diff --git a/plugins/tags-resources/package.json b/plugins/tags-resources/package.json index d879dff392..2739505601 100644 --- a/plugins/tags-resources/package.json +++ b/plugins/tags-resources/package.json @@ -37,6 +37,7 @@ "@anticrm/ui": "~0.6.0", "@anticrm/presentation": "~0.6.2", "@anticrm/core": "~0.6.16", + "@anticrm/view": "~0.6.0", "@anticrm/view-resources": "~0.6.0" } } diff --git a/plugins/tags-resources/src/components/TagsFilter.svelte b/plugins/tags-resources/src/components/TagsFilter.svelte new file mode 100644 index 0000000000..67214bf192 --- /dev/null +++ b/plugins/tags-resources/src/components/TagsFilter.svelte @@ -0,0 +1,229 @@ +<!-- +// 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 context="module" lang="ts"> + const liveQueries: Map<number, LiveQuery> = new Map<number, LiveQuery>() + const results: Map<number, Ref<Doc>[]> = new Map<number, Ref<Doc>[]>() +</script> + +<script lang="ts"> + import { Class, Doc, Ref } from '@anticrm/core' + import { translate } from '@anticrm/platform' + import presentation, { createQuery, getClient, LiveQuery } from '@anticrm/presentation' + import { Button, CheckBox, getPlatformColor } from '@anticrm/ui' + import { Filter } from '@anticrm/view' + import view from '@anticrm/view-resources/src/plugin' + import { createEventDispatcher, onMount } from 'svelte' + import tags from '../plugin' + import { TagCategory, TagElement } from '@anticrm/tags' + + export let _class: Ref<Class<Doc>> + export let filter: Filter + export let onChange: (e: Filter) => void + filter.onRemove = () => { + const lq = liveQueries.get(filter.index) + lq?.unsubscribe() + liveQueries.delete(filter.index) + results.delete(filter.index) + } + const lq = getLiveQuery(filter.index) + const client = getClient() + let selected: Ref<TagElement>[] = filter.value + + function getLiveQuery (index: number): LiveQuery { + let lq = liveQueries.get(index) + if (lq === undefined) { + lq = createQuery(true) + liveQueries.set(index, lq) + } + return lq + } + + async function getRefs (res: Ref<TagElement>[], onUpdate: () => void): Promise<Ref<Doc>[]> { + const promise = new Promise<Ref<Doc>[]>((resolve, reject) => { + const refresh = lq.query( + tags.class.TagReference, + { + tag: { $in: res } + }, + (refs) => { + const result = Array.from(new Set(refs.map((p) => p.attachedTo))) + results.set(filter.index, result) + resolve(result) + onUpdate() + } + ) + + if (!refresh) { + resolve(results.get(filter.index) ?? []) + } + }) + return promise + } + + filter.modes = [ + { + label: view.string.FilterIs, + isAvailable: (res: any[]) => res.length <= 1, + result: async (res: any[], onUpdate: () => void) => { + const result = await getRefs(res, onUpdate) + return { $in: result } + } + }, + { + label: view.string.FilterIsEither, + isAvailable: (res: any[]) => res.length > 1, + result: async (res: any[], onUpdate: () => void) => { + const result = await getRefs(res, onUpdate) + return { $in: result } + } + }, + { + label: view.string.FilterIsNot, + isAvailable: () => true, + result: async (res: any[], onUpdate: () => void) => { + const result = await getRefs(res, onUpdate) + return { $nin: result } + } + } + ] + + let categories: TagCategory[] = [] + let objects: TagElement[] = [] + client.findAll(tags.class.TagCategory, { targetClass: _class }).then((res) => { + categories = res + }) + + $: getValues(search) + + async function getValues (search: string): Promise<void> { + const resultQuery = + search !== '' + ? { + title: { $like: '%' + search + '%' }, + targetClass: _class + } + : { targetClass: _class } + objects = await client.findAll(tags.class.TagElement, resultQuery) + } + + function checkMode () { + if (filter.mode?.isAvailable(filter.value)) return + const newMode = filter.modes.find((p: any) => p.isAvailable(filter.value)) + filter.mode = newMode !== undefined ? newMode : filter.mode + } + + let search: string = '' + let phTraslate: string = '' + let searchInput: HTMLInputElement + $: translate(presentation.string.Search, {}).then((res) => { + phTraslate = res + }) + + onMount(() => { + if (searchInput) searchInput.focus() + }) + + const toggleGroup = (ev: MouseEvent): void => { + const el: HTMLElement = ev.currentTarget as HTMLElement + el.classList.toggle('show') + } + const show: boolean = false + + const getCount = (cat: TagCategory): string => { + const count = objects.filter((el) => el.category === cat._id).filter((it) => selected.includes(it._id)).length + if (count > 0) return count.toString() + return '' + } + + const isSelected = (element: TagElement): boolean => { + if (selected.filter((p) => p === element._id).length > 0) return true + return false + } + + const checkSelected = (element: TagElement): void => { + if (isSelected(element)) { + selected = selected.filter((p) => p !== element._id) + } else { + selected = [...selected, element._id] + } + objects = objects + categories = categories + } + + const dispatch = createEventDispatcher() +</script> + +<div class="selectPopup"> + <div class="header"> + <input bind:this={searchInput} type="text" bind:value={search} placeholder={phTraslate} /> + </div> + <div class="scroll"> + <div class="box"> + {#each categories as cat} + {#if objects.filter((el) => el.category === cat._id).length > 0} + <div class="sticky-wrapper"> + <button class="menu-group__header" class:show={search !== '' || show} on:click={toggleGroup}> + <div class="flex-row-center"> + <span class="mr-1-5">{cat.label}</span> + <div class="icon"> + <svg fill="var(--content-color)" viewBox="0 0 6 6" xmlns="http://www.w3.org/2000/svg"> + <path d="M0,0L6,3L0,6Z" /> + </svg> + </div> + </div> + <div class="flex-row-center text-xs"> + <span class="content-color mr-1">({objects.filter((el) => el.category === cat._id).length})</span> + <span class="counter">{getCount(cat)}</span> + </div> + </button> + <div class="menu-group"> + {#each objects.filter((el) => el.category === cat._id) as element} + <button + class="menu-item" + on:click={() => { + checkSelected(element) + }} + > + <div class="flex-between w-full"> + <div class="flex"> + <div class="check pointer-events-none"> + <CheckBox checked={isSelected(element)} primary /> + </div> + <div class="tag" style="background-color: {getPlatformColor(element.color)};" /> + {element.title} + </div> + <div class="content-trans-color ml-2"> + {element.refCount ?? 0} + </div> + </div> + </button> + {/each} + </div> + </div> + {/if} + {/each} + </div> + </div> + <Button + shape={'round'} + label={view.string.Apply} + on:click={async () => { + filter.value = selected + checkMode() + onChange(filter) + dispatch('close') + }} + /> +</div> diff --git a/plugins/tags-resources/src/index.ts b/plugins/tags-resources/src/index.ts index 6a081699d2..0c3c960f0b 100644 --- a/plugins/tags-resources/src/index.ts +++ b/plugins/tags-resources/src/index.ts @@ -26,6 +26,7 @@ import TagsItemPresenter from './components/TagsItemPresenter.svelte' import TagsPresenter from './components/TagsPresenter.svelte' import TagsView from './components/TagsView.svelte' import TagElementCountPresenter from './components/TagElementCountPresenter.svelte' +import TagsFilter from './components/TagsFilter.svelte' export default async (): Promise<Resources> => ({ component: { @@ -34,6 +35,7 @@ export default async (): Promise<Resources> => ({ TagElementPresenter, TagsPresenter, TagsView, + TagsFilter, TagsEditor, TagsDropdownEditor, TagsItemPresenter, diff --git a/plugins/tags/src/index.ts b/plugins/tags/src/index.ts index 89bf390238..4d6ef2b5dc 100644 --- a/plugins/tags/src/index.ts +++ b/plugins/tags/src/index.ts @@ -28,6 +28,7 @@ export interface TagElement extends Doc { description: string color: number category: Ref<TagCategory> + refCount?: number } /** diff --git a/plugins/view-assets/lang/en.json b/plugins/view-assets/lang/en.json index 3cded693be..86ddfd4430 100644 --- a/plugins/view-assets/lang/en.json +++ b/plugins/view-assets/lang/en.json @@ -45,6 +45,7 @@ "FilterIsEither": "is either of", "FilterStatesCount": "{value, plural, =1 {1 state} other {# states}}", "Before": "Before", - "After": "After" + "After": "After", + "Apply": "Apply" } } diff --git a/plugins/view-assets/lang/ru.json b/plugins/view-assets/lang/ru.json index 9bfa86a8d3..24f2dac41e 100644 --- a/plugins/view-assets/lang/ru.json +++ b/plugins/view-assets/lang/ru.json @@ -43,6 +43,7 @@ "FilterIsEither": "один из", "FilterStatesCount": "{value, plural, =1 {1 состоянию} other {# состояний}}", "Before": "До", - "After": "После" + "After": "После", + "Apply": "Применить" } } diff --git a/plugins/view-resources/src/components/filter/FilterBar.svelte b/plugins/view-resources/src/components/filter/FilterBar.svelte index cc3c5e0a40..6a5f7542b1 100644 --- a/plugins/view-resources/src/components/filter/FilterBar.svelte +++ b/plugins/view-resources/src/components/filter/FilterBar.svelte @@ -30,29 +30,29 @@ const dispatch = createEventDispatcher() let filters: Filter[] = [] - let isNew = true + let maxIndex = 0 function onChange (e: Filter | undefined) { if (e === undefined) return - if (isNew) { + const index = filters.findIndex((p) => p.index === e.index) + if (index === -1) { filters.push(e) - isNew = false filters = filters } else { - filters[filters.length - 1] = e + filters[index] = e filters = filters } } function add (e: MouseEvent) { const target = eventToHTMLElement(e) - isNew = true showPopup( FilterTypePopup, { _class, - makeQuery: (key: string) => makeQuery(query, filters, key), + query, target, + index: ++maxIndex, onChange }, target @@ -60,19 +60,21 @@ } function remove (i: number) { + filters[i]?.onRemove?.() filters.splice(i, 1) filters = filters } - function makeQuery (query: DocumentQuery<Doc>, filters: Filter[], skipKey?: string): DocumentQuery<Doc> { + async function makeQuery (query: DocumentQuery<Doc>, filters: Filter[]): Promise<void> { 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 + const newValue = await filter.mode.result(filter.value, () => { + makeQuery(query, filters) + }) if (newQuery[filter.key.key] === undefined) { - newQuery[filter.key.key] = filter.mode.result(filter.value) + newQuery[filter.key.key] = newValue } else { - const newValue = filter.mode.result(filter.value) let merged = false for (const key in newValue) { if (newQuery[filter.key.key][key] === undefined) { @@ -104,14 +106,11 @@ } } if (!merged) { - Object.assign(newQuery[filter.key.key], filter.mode.result(filter.value)) + Object.assign(newQuery[filter.key.key], newValue) } } } - if (skipKey === undefined) { - dispatch('change', newQuery) - } - return newQuery + dispatch('change', newQuery) } $: makeQuery(query, filters) @@ -125,7 +124,7 @@ {#each filters as filter, i} <FilterSection {_class} - query={makeQuery(query, filters, filter.key.key)} + {query} {filter} on:change={() => { makeQuery(query, filters) diff --git a/plugins/view-resources/src/components/filter/FilterTypePopup.svelte b/plugins/view-resources/src/components/filter/FilterTypePopup.svelte index c669fab5c7..1c11d02821 100644 --- a/plugins/view-resources/src/components/filter/FilterTypePopup.svelte +++ b/plugins/view-resources/src/components/filter/FilterTypePopup.svelte @@ -13,7 +13,17 @@ // limitations under the License. --> <script lang="ts"> - import core, { AnyAttribute, ArrOf, Class, Doc, DocumentQuery, Ref, Type } from '@anticrm/core' + import core, { + AnyAttribute, + ArrOf, + AttachedDoc, + Class, + Collection, + 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' @@ -21,8 +31,9 @@ import view from '../../plugin' export let _class: Ref<Class<Doc>> - export let makeQuery: (key: string) => DocumentQuery<Doc> + export let query: DocumentQuery<Doc> export let target: HTMLElement + export let index: number export let onChange: (e: Filter) => void const client = getClient() @@ -48,11 +59,13 @@ } function buildFilter (key: string, attribute: AnyAttribute): KeyFilter | undefined { - const clazz = hierarchy.getClass(attribute.type._class) + const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection) + const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class + const clazz = hierarchy.getClass(targetClass) const filter = hierarchy.as(clazz, view.mixin.AttributeFilter) if (filter.component === undefined) return undefined return { - key: key, + key: isCollection ? '_id' : key, label: attribute.label, icon: attribute.icon, component: filter.component @@ -108,10 +121,11 @@ type.component, { _class, - query: makeQuery(type.key), + query, filter: { key: type, - value: [] + value: [], + index }, onChange }, diff --git a/plugins/view-resources/src/components/filter/ObjectFilter.svelte b/plugins/view-resources/src/components/filter/ObjectFilter.svelte index faa153cea7..4fb5096235 100644 --- a/plugins/view-resources/src/components/filter/ObjectFilter.svelte +++ b/plugins/view-resources/src/components/filter/ObjectFilter.svelte @@ -16,11 +16,12 @@ import { Class, Doc, DocumentQuery, getObjectValue, Ref, RefTo } from '@anticrm/core' import { translate } from '@anticrm/platform' import presentation, { getClient } from '@anticrm/presentation' - import ui, { CheckBox, Label } from '@anticrm/ui' + import ui, { Button, CheckBox, Label } from '@anticrm/ui' import { Filter } from '@anticrm/view' import { onMount } from 'svelte' import { buildConfigLookup, getPresenter } from '../../utils' import view from '../../plugin' + import { createEventDispatcher } from 'svelte' export let _class: Ref<Class<Doc>> export let query: DocumentQuery<Doc> @@ -37,21 +38,21 @@ { label: view.string.FilterIs, isAvailable: (res: any[]) => res.length <= 1, - result: (res: any[]) => { + result: async (res: any[]) => { return { $in: res } } }, { label: view.string.FilterIsEither, isAvailable: (res: any[]) => res.length > 1, - result: (res: any[]) => { + result: async (res: any[]) => { return { $in: res } } }, { label: view.string.FilterIsNot, isAvailable: () => true, - result: (res: any[]) => { + result: async (res: any[]) => { return { $nin: res } } } @@ -107,7 +108,6 @@ } } checkMode() - onChange(filter) } let search: string = '' @@ -120,18 +120,13 @@ onMount(() => { if (searchInput) searchInput.focus() }) + + const dispatch = createEventDispatcher() </script> <div class="selectPopup"> <div class="header"> - <input - bind:this={searchInput} - type="text" - bind:value={search} - placeholder={phTraslate} - on:input={(ev) => {}} - on:change - /> + <input bind:this={searchInput} type="text" bind:value={search} placeholder={phTraslate} /> </div> <div class="scroll"> <div class="box"> @@ -154,7 +149,7 @@ <Label label={ui.string.NotSelected} /> {/if} </div> - <div class="content-trans-color"> + <div class="content-trans-color ml-2"> {targets.get(value?._id)} </div> </div> @@ -163,4 +158,12 @@ {/await} </div> </div> + <Button + shape={'round'} + label={view.string.Apply} + on:click={() => { + onChange(filter) + dispatch('close') + }} + /> </div> diff --git a/plugins/view-resources/src/components/filter/TimestampFilter.svelte b/plugins/view-resources/src/components/filter/TimestampFilter.svelte index 3ce8d89d2e..7f0a49a34c 100644 --- a/plugins/view-resources/src/components/filter/TimestampFilter.svelte +++ b/plugins/view-resources/src/components/filter/TimestampFilter.svelte @@ -27,14 +27,14 @@ { label: view.string.Before, isAvailable: (res: any[]) => true, - result: (res: any[]) => { + result: async (res: any[]) => { return { $lt: res[0] } } }, { label: view.string.After, isAvailable: () => true, - result: (res: any[]) => { + result: async (res: any[]) => { return { $gt: res[0] } } } diff --git a/plugins/view-resources/src/components/filter/ValueFilter.svelte b/plugins/view-resources/src/components/filter/ValueFilter.svelte index 6f5f08d74a..b07439127f 100644 --- a/plugins/view-resources/src/components/filter/ValueFilter.svelte +++ b/plugins/view-resources/src/components/filter/ValueFilter.svelte @@ -16,11 +16,12 @@ import { Class, Doc, DocumentQuery, getObjectValue, Ref } from '@anticrm/core' import { translate } from '@anticrm/platform' import presentation, { getClient } from '@anticrm/presentation' - import ui, { CheckBox, Label } from '@anticrm/ui' + import ui, { Button, CheckBox, Label } from '@anticrm/ui' import { Filter } from '@anticrm/view' import { onMount } from 'svelte' import { getPresenter } from '../../utils' import view from '../../plugin' + import { createEventDispatcher } from 'svelte' export let _class: Ref<Class<Doc>> export let query: DocumentQuery<Doc> @@ -31,22 +32,22 @@ { label: view.string.FilterIs, isAvailable: (res: any[]) => res.length <= 1, - result: (res: any[]) => { - return { $in: res } + result: async (res: [any, any[]][]) => { + return { $in: res.map((p) => p[1]).flat() } } }, { label: view.string.FilterIsEither, isAvailable: (res: any[]) => res.length > 1, - result: (res: any[]) => { - return { $in: res } + result: async (res: [any, any[]][]) => { + return { $in: res.map((p) => p[1]).flat() } } }, { label: view.string.FilterIsNot, isAvailable: () => true, - result: (res: any[]) => { - return { $nin: res } + result: async (res: [any, any[]][]) => { + return { $nin: res.map((p) => p[1]).flat() } } } ] @@ -56,7 +57,7 @@ const promise = getPresenter(client, _class, key, key) let values = new Map<any, number>() - let selectedValues: Set<any> = new Set<any>() + let selectedValues: Set<any> = new Set<any>(filter.value.map((p) => p[0])) const realValues = new Map<any, Set<any>>() $: getValues(search) @@ -98,11 +99,6 @@ selectedValues.add(value) } selectedValues = selectedValues - filter.value = Array.from(selectedValues.values()) - .map((p) => Array.from(realValues.get(p) ?? [])) - .flat() - checkMode() - onChange(filter) } let search: string = '' @@ -112,6 +108,8 @@ phTraslate = res }) + const dispatch = createEventDispatcher() + onMount(() => { if (searchInput) searchInput.focus() }) @@ -119,14 +117,7 @@ <div class="selectPopup"> <div class="header"> - <input - bind:this={searchInput} - type="text" - bind:value={search} - placeholder={phTraslate} - on:input={(ev) => {}} - on:change - /> + <input bind:this={searchInput} type="text" bind:value={search} placeholder={phTraslate} /> </div> <div class="scroll"> <div class="box"> @@ -149,7 +140,7 @@ <Label label={ui.string.NotSelected} /> {/if} </div> - <div class="content-trans-color"> + <div class="content-trans-color ml-2"> {values.get(value)} </div> </div> @@ -158,4 +149,16 @@ {/await} </div> </div> + <Button + shape={'round'} + label={view.string.Apply} + on:click={() => { + filter.value = Array.from(selectedValues.values()).map((p) => { + return [p, Array.from(realValues.get(p) ?? [])] + }) + checkMode() + onChange(filter) + dispatch('close') + }} + /> </div> diff --git a/plugins/view-resources/src/plugin.ts b/plugins/view-resources/src/plugin.ts index 66abde5fa2..d9d3bf69a0 100644 --- a/plugins/view-resources/src/plugin.ts +++ b/plugins/view-resources/src/plugin.ts @@ -48,6 +48,7 @@ export default mergeIds(viewId, view, { FilterIsEither: '' as IntlString, FilterStatesCount: '' as IntlString, Before: '' as IntlString, - After: '' as IntlString + After: '' as IntlString, + Apply: '' as IntlString } }) diff --git a/plugins/view/src/index.ts b/plugins/view/src/index.ts index e191c7ac55..bcc9abc2d8 100644 --- a/plugins/view/src/index.ts +++ b/plugins/view/src/index.ts @@ -52,7 +52,7 @@ export interface KeyFilter { export interface FilterMode { label: IntlString isAvailable: (values: any[]) => boolean - result: (values: any[]) => ObjQueryType<any> + result: (values: any[], onUpdate: () => void) => Promise<ObjQueryType<any>> } /** @@ -63,6 +63,8 @@ export interface Filter { mode: FilterMode modes: FilterMode[] value: any[] + index: number + onRemove?: () => void } /** diff --git a/server-plugins/tags-resources/src/index.ts b/server-plugins/tags-resources/src/index.ts index 80ba025b0f..bec338f513 100644 --- a/server-plugins/tags-resources/src/index.ts +++ b/server-plugins/tags-resources/src/index.ts @@ -13,27 +13,95 @@ // limitations under the License. // -import { Class, Doc, DocumentQuery, FindOptions, FindResult, Hierarchy, Ref } from '@anticrm/core' -import tags, { TagElement } from '@anticrm/tags' +import core, { + AttachedDoc, + Class, + Doc, + DocumentQuery, + FindOptions, + FindResult, + Hierarchy, + Ref, + Tx, + TxCollectionCUD, + TxCreateDoc, + TxCUD, + TxProcessor, + TxRemoveDoc +} from '@anticrm/core' +import { TriggerControl } from '@anticrm/server-core' +import tags, { TagElement, TagReference } from '@anticrm/tags' + +const extractTx = (tx: Tx): Tx => { + if (tx._class === core.class.TxCollectionCUD) { + const ctx = tx as TxCollectionCUD<Doc, AttachedDoc> + if (ctx.tx._class === core.class.TxCreateDoc) { + const create = ctx.tx as TxCreateDoc<AttachedDoc> + create.attributes.attachedTo = ctx.objectId + create.attributes.attachedToClass = ctx.objectClass + create.attributes.collection = ctx.collection + return create + } + return ctx.tx + } + + return tx +} /** * @public */ export async function TagElementRemove ( doc: Doc, - hiearachy: Hierarchy, + hierarchy: Hierarchy, findAll: <T extends Doc>( clazz: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T> ) => Promise<FindResult<T>> ): Promise<Doc[]> { - if (!hiearachy.isDerived(doc._class, tags.class.TagElement)) return [] + if (!hierarchy.isDerived(doc._class, tags.class.TagElement)) return [] return await findAll(tags.class.TagReference, { tag: doc._id as Ref<TagElement> }) } +/** + * @public + */ +export async function onTagReference (tx: Tx, control: TriggerControl): Promise<Tx[]> { + const actualTx = extractTx(tx) + const isCreate = control.hierarchy.isDerived(actualTx._class, core.class.TxCreateDoc) + const isRemove = control.hierarchy.isDerived(actualTx._class, core.class.TxRemoveDoc) + if (!isCreate && !isRemove) return [] + if (!control.hierarchy.isDerived((actualTx as TxCUD<Doc>).objectClass, tags.class.TagReference)) return [] + if (isCreate) { + const doc = TxProcessor.createDoc2Doc(actualTx as TxCreateDoc<TagReference>) + const res = control.txFactory.createTxUpdateDoc(tags.class.TagElement, tags.space.Tags, doc.tag, { + $inc: { refCount: 1 } + }) + return [res] + } + if (isRemove) { + const ctx = actualTx as TxRemoveDoc<TagReference> + const createTx = ( + await control.findAll(core.class.TxCollectionCUD, { 'tx.objectId': ctx.objectId }, { limit: 1 }) + )[0] + if (createTx !== undefined) { + const actualCreateTx = extractTx(createTx) + const doc = TxProcessor.createDoc2Doc(actualCreateTx as TxCreateDoc<TagReference>) + const res = control.txFactory.createTxUpdateDoc(tags.class.TagElement, tags.space.Tags, doc.tag, { + $inc: { refCount: -1 } + }) + return [res] + } + } + return [] +} + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type export default async () => ({ + trigger: { + onTagReference + }, function: { TagElementRemove } diff --git a/server-plugins/tags/src/index.ts b/server-plugins/tags/src/index.ts index 377d299d95..632055da5c 100644 --- a/server-plugins/tags/src/index.ts +++ b/server-plugins/tags/src/index.ts @@ -16,6 +16,7 @@ import { Class, Doc, Hierarchy, Ref, FindResult, FindOptions, DocumentQuery } from '@anticrm/core' import type { Plugin, Resource } from '@anticrm/platform' import { plugin } from '@anticrm/platform' +import { TriggerFunc } from '@anticrm/server-core' /** * @public @@ -26,6 +27,9 @@ export const serverTagsId = 'server-tags' as Plugin * @public */ export default plugin(serverTagsId, { + trigger: { + onTagReference: '' as Resource<TriggerFunc> + }, function: { TagElementRemove: '' as Resource< (