TSK-1404 Improve sprint filter (#3164)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov 2023-05-11 13:26:17 +06:00 committed by GitHub
parent 2e6181acf1
commit bbf81ada7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 339 additions and 74 deletions

View File

@ -1374,6 +1374,22 @@ export function createModel (builder: Builder): void {
builder.mixin(tracker.class.Sprint, core.class.Class, view.mixin.ClassFilters, { builder.mixin(tracker.class.Sprint, core.class.Class, view.mixin.ClassFilters, {
filters: [] filters: []
}) })
builder.mixin(tracker.class.Sprint, core.class.Class, view.mixin.ClassFilters, {
filters: ['status'],
strict: true
})
builder.mixin(tracker.class.Sprint, core.class.Class, view.mixin.AttributeFilter, {
component: tracker.component.SprintFilter
})
builder.mixin(tracker.class.TypeSprintStatus, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.SprintStatusPresenter
})
builder.mixin(tracker.class.TypeSprintStatus, core.class.Class, view.mixin.AttributeFilter, {
component: view.component.ValueFilter
})
builder.mixin(tracker.class.Component, core.class.Class, view.mixin.ClassFilters, { builder.mixin(tracker.class.Component, core.class.Class, view.mixin.ClassFilters, {
filters: [] filters: []
@ -1805,8 +1821,7 @@ export function createModel (builder: Builder): void {
viewOptions: sprintOptions, viewOptions: sprintOptions,
config: [ config: [
{ {
key: '', key: 'status',
presenter: tracker.component.SprintStatusPresenter,
props: { width: '1rem', kind: 'list', size: 'small', justify: 'center' } props: { width: '1rem', kind: 'list', size: 'small', justify: 'center' }
}, },
{ key: '', presenter: tracker.component.SprintPresenter, props: { shouldUseMargin: true } }, { key: '', presenter: tracker.component.SprintPresenter, props: { shouldUseMargin: true } },

View File

@ -52,7 +52,8 @@ export default mergeIds(trackerId, tracker, {
SprintSelector: '' as AnyComponent, SprintSelector: '' as AnyComponent,
IssueStatistics: '' as AnyComponent, IssueStatistics: '' as AnyComponent,
TimeSpendReportPopup: '' as AnyComponent, TimeSpendReportPopup: '' as AnyComponent,
NotificationIssuePresenter: '' as AnyComponent NotificationIssuePresenter: '' as AnyComponent,
SprintFilter: '' as AnyComponent
}, },
app: { app: {
Tracker: '' as Ref<Application> Tracker: '' as Ref<Application>

View File

@ -42,7 +42,7 @@ import type {
IgnoreActions, IgnoreActions,
InlineAttributEditor, InlineAttributEditor,
KeyBinding, KeyBinding,
KeyFilter, KeyFilterPreset,
LinkPresenter, LinkPresenter,
LinkProvider, LinkProvider,
ListHeaderExtra, ListHeaderExtra,
@ -126,7 +126,7 @@ export class TFilterMode extends TDoc implements FilterMode {
@Mixin(view.mixin.ClassFilters, core.class.Class) @Mixin(view.mixin.ClassFilters, core.class.Class)
export class TClassFilters extends TClass implements ClassFilters { export class TClassFilters extends TClass implements ClassFilters {
filters!: (string | KeyFilter)[] filters!: (string | KeyFilterPreset)[]
ignoreKeys?: string[] | undefined ignoreKeys?: string[] | undefined
strict?: boolean | undefined strict?: boolean | undefined
} }

View File

@ -0,0 +1,168 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, FindResult, Ref, SortingOrder } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import { Project, Sprint, SprintStatus } from '@hcengineering/tracker'
import ui, { deviceOptionsStore, Icon, Label, CheckBox, Loading, resizeObserver } from '@hcengineering/ui'
import view, { Filter } from '@hcengineering/view'
import { createEventDispatcher, onMount } from 'svelte'
import tracker from '../../plugin'
import { sprintStatusAssets } from '../../types'
import SprintTitlePresenter from './SprintTitlePresenter.svelte'
export let _class: Ref<Class<Doc>>
export let space: Ref<Project> | undefined = undefined
export let filter: Filter
export let onChange: (e: Filter) => void
filter.modes = filter.modes ?? [view.filter.FilterObjectIn, view.filter.FilterObjectNin]
filter.mode = filter.mode ?? filter.modes[0]
const dispatch = createEventDispatcher()
let search: string = ''
let phTraslate: string = ''
let searchInput: HTMLInputElement
$: translate(presentation.string.Search, {}).then((res) => {
phTraslate = res
})
let values: Sprint[] = []
let objectsPromise: Promise<FindResult<Sprint>> | undefined = undefined
let selectedValues: Set<Ref<Sprint> | undefined | null> = new Set()
const client = getClient()
async function getValues (search: string): Promise<void> {
if (objectsPromise) {
await objectsPromise
}
values = []
selectedValues.clear()
const spaceQuery: DocumentQuery<Sprint> = space ? { space } : {}
const resultQuery: DocumentQuery<Sprint> =
search !== ''
? {
label: { $like: '%' + search + '%' }
}
: {}
objectsPromise = client.findAll(
tracker.class.Sprint,
{ ...resultQuery, ...spaceQuery },
{
sort: { label: SortingOrder.Ascending }
}
)
const res = await objectsPromise
values = res
selectedValues = new Set(res.map((p) => p._id).filter((p) => filter.value.includes(p)))
if (filter.value.includes(undefined) || filter.value.includes(null)) {
selectedValues.add(undefined)
}
objectsPromise = undefined
}
function isSelected (value: Ref<Sprint> | undefined, values: Set<Ref<Sprint> | undefined | null>): boolean {
return values.has(value)
}
function toggle (value: Ref<Sprint> | undefined): void {
if (isSelected(value, selectedValues)) {
selectedValues.delete(value)
} else {
selectedValues.add(value)
}
selectedValues = selectedValues
filter.value = Array.from(selectedValues)
onChange(filter)
}
function getStatusItem (status: SprintStatus, docs: Sprint[]): Sprint[] {
return docs.filter((p) => p.status === status)
}
function getStatuses (): SprintStatus[] {
const res = Array.from(Object.values(SprintStatus).filter((v) => !isNaN(Number(v)))) as SprintStatus[]
return res
}
onMount(() => {
if (searchInput && !$deviceOptionsStore.isMobile) searchInput.focus()
})
getValues(search)
</script>
<div class="selectPopup" use:resizeObserver={() => dispatch('changeContent')}>
<div class="header">
<input
bind:this={searchInput}
type="text"
bind:value={search}
on:change={() => {
getValues(search)
}}
placeholder={phTraslate}
/>
</div>
<div class="scroll">
<div class="box">
{#if objectsPromise}
<Loading />
{:else}
<button
class="menu-item"
on:click={() => {
toggle(undefined)
}}
>
<div class="flex clear-mins">
<div class="check pointer-events-none">
<CheckBox checked={isSelected(undefined, selectedValues)} primary />
</div>
<Label label={ui.string.NotSelected} />
</div>
</button>
{#each getStatuses() as group}
{@const status = sprintStatusAssets[group]}
{@const items = getStatusItem(group, values)}
{#if items.length > 0}
<div class="flex-row-center p-1">
<Icon icon={status.icon} size={'small'} />
<div class="ml-2">
<Label label={status.label} />
</div>
</div>
{#each items as doc}
<button
class="menu-item"
on:click={() => {
toggle(doc._id)
}}
>
<div class="flex clear-mins">
<div class="check pointer-events-none">
<CheckBox checked={isSelected(doc._id, selectedValues)} primary />
</div>
<SprintTitlePresenter value={doc} />
</div>
</button>
{/each}
{/if}
{/each}
{/if}
</div>
</div>
</div>

View File

@ -20,7 +20,7 @@
export let value: WithLookup<Sprint> export let value: WithLookup<Sprint>
export let shouldShowAvatar: boolean = true export let shouldShowAvatar: boolean = true
export let onClick: () => void | undefined export let onClick: (() => void) | undefined = undefined
export let disabled = false export let disabled = false
export let inline: boolean = false export let inline: boolean = false

View File

@ -13,42 +13,29 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <script lang="ts">
import { getClient } from '@hcengineering/presentation' import { SprintStatus } from '@hcengineering/tracker'
import { Sprint, SprintStatus } from '@hcengineering/tracker'
import type { ButtonKind, ButtonSize } from '@hcengineering/ui' import type { ButtonKind, ButtonSize } from '@hcengineering/ui'
import tracker from '../../plugin' import tracker from '../../plugin'
import SprintStatusSelector from './SprintStatusSelector.svelte' import SprintStatusSelector from './SprintStatusSelector.svelte'
export let value: Sprint export let value: SprintStatus
export let isEditable: boolean = true export let onChange: ((value: SprintStatus | undefined) => void) | undefined = undefined
export let shouldShowLabel: boolean = false
export let kind: ButtonKind = 'link' export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'large' export let size: ButtonSize = 'large'
export let justify: 'left' | 'center' = 'left' export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = '100%' export let width: string | undefined = '100%'
const client = getClient() $: isEditable = onChange !== undefined
const handleComponentStatusChanged = async (newStatus: SprintStatus | undefined) => {
if (!isEditable || newStatus === undefined || value.status === newStatus) {
return
}
await client.update(value, { status: newStatus })
}
</script> </script>
{#if value} <SprintStatusSelector
<SprintStatusSelector {kind}
{kind} {size}
{size} {width}
{width} {justify}
{justify} {isEditable}
{isEditable} showTooltip={isEditable ? { label: tracker.string.SetStatus } : undefined}
{shouldShowLabel} selectedSprintStatus={value}
showTooltip={isEditable ? { label: tracker.string.SetStatus } : undefined} onSprintStatusChange={onChange}
selectedSprintStatus={value.status} />
onSprintStatusChange={handleComponentStatusChanged}
/>
{/if}

View File

@ -136,6 +136,7 @@ import ProjectPresenter from './components/projects/ProjectPresenter.svelte'
import ProjectSpacePresenter from './components/projects/ProjectSpacePresenter.svelte' import ProjectSpacePresenter from './components/projects/ProjectSpacePresenter.svelte'
import IssueStatistics from './components/sprints/IssueStatistics.svelte' import IssueStatistics from './components/sprints/IssueStatistics.svelte'
import SprintRefPresenter from './components/sprints/SprintRefPresenter.svelte' import SprintRefPresenter from './components/sprints/SprintRefPresenter.svelte'
import SprintFilter from './components/sprints/SprintFilter.svelte'
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte' export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
@ -445,7 +446,8 @@ export default async (): Promise<Resources> => ({
TimeSpendReportPopup, TimeSpendReportPopup,
SprintDatePresenter, SprintDatePresenter,
SprintLeadPresenter, SprintLeadPresenter,
NotificationIssuePresenter NotificationIssuePresenter,
SprintFilter
}, },
completion: { completion: {
IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) => IssueQuery: async (client: Client, query: string, filter?: { in?: RelatedDocument[], nin?: RelatedDocument[] }) =>

View File

@ -10,6 +10,7 @@
"lint": "svelte-check && eslint", "lint": "svelte-check && eslint",
"lint:fix": "eslint --fix src", "lint:fix": "eslint --fix src",
"format": "prettier --write --plugin-search-dir=. src && eslint --fix src", "format": "prettier --write --plugin-search-dir=. src && eslint --fix src",
"svelte-check": "svelte-check --output human",
"build:watch": "tsc --incremental --noEmit --outDir ./dist_cache" "build:watch": "tsc --incremental --noEmit --outDir ./dist_cache"
}, },
"devDependencies": { "devDependencies": {

View File

@ -25,7 +25,7 @@
showPopup, showPopup,
Submenu Submenu
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { ClassFilters, Filter, KeyFilter } from '@hcengineering/view' import { ClassFilters, Filter, KeyFilter, KeyFilterPreset } from '@hcengineering/view'
import { createEventDispatcher } from 'svelte' import { createEventDispatcher } from 'svelte'
import { buildFilterKey, FilterQuery } from '../../filter' import { buildFilterKey, FilterQuery } from '../../filter'
import view from '../../plugin' import view from '../../plugin'
@ -36,6 +36,7 @@
export let filter: Filter | undefined export let filter: Filter | undefined
export let index: number export let index: number
export let onChange: (e: Filter) => void export let onChange: (e: Filter) => void
export let nestedFrom: KeyFilter | undefined = undefined
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -43,7 +44,7 @@
function getFilters (_class: Ref<Class<Doc>>, mixin: ClassFilters): KeyFilter[] { function getFilters (_class: Ref<Class<Doc>>, mixin: ClassFilters): KeyFilter[] {
if (mixin.filters === undefined) return [] if (mixin.filters === undefined) return []
const filters = mixin.filters.map((p) => { const filters = mixin.filters.map((p) => {
return typeof p === 'string' ? buildFilterFromKey(_class, p) : p return typeof p === 'string' ? buildFilterFromKey(_class, p) : buildFilterFromPreset(p)
}) })
const result: KeyFilter[] = [] const result: KeyFilter[] = []
for (const filter of filters) { for (const filter of filters) {
@ -52,6 +53,19 @@
return result return result
} }
function buildFilterFromPreset (p: KeyFilterPreset): KeyFilter | undefined {
if (p.key !== '') {
const attribute = hierarchy.getAttribute(p._class, p.key)
const clazz = hierarchy.getClass(p._class)
return {
...p,
attribute,
label: p.label ?? attribute.label,
icon: p.icon ?? attribute.icon ?? clazz.icon ?? view.icon.Setting
}
}
}
function buildFilterFromKey (_class: Ref<Class<Doc>>, key: string): KeyFilter | undefined { function buildFilterFromKey (_class: Ref<Class<Doc>>, key: string): KeyFilter | undefined {
const attribute = hierarchy.getAttribute(_class, key) const attribute = hierarchy.getAttribute(_class, key)
return buildFilterKey(hierarchy, _class, key, attribute) return buildFilterKey(hierarchy, _class, key, attribute)
@ -69,7 +83,7 @@
return return
} }
const value = getValue(attribute.name, attribute.type) const value = getValue(attribute.name, attribute.type)
if (result.findIndex((p) => p.attribute.name === value) !== -1) { if (result.findIndex((p) => p.attribute?.name === value) !== -1) {
return return
} }
const filter = buildFilterKey(hierarchy, _class, value, attribute) const filter = buildFilterKey(hierarchy, _class, value, attribute)
@ -93,7 +107,20 @@
} }
} }
function getTypes (_class: Ref<Class<Doc>>): KeyFilter[] { function getTypes (_class: Ref<Class<Doc>>, nestedFrom: KeyFilter | undefined): KeyFilter[] {
if (nestedFrom !== undefined) {
return getNestedTypes(nestedFrom)
} else {
return getOwnTypes(_class)
}
}
function getNestedTypes (type: KeyFilter): KeyFilter[] {
const targetClass = (hierarchy.getAttribute(type._class, type.key).type as RefTo<Doc>).to
return getOwnTypes(targetClass)
}
function getOwnTypes (_class: Ref<Class<Doc>>): KeyFilter[] {
const clazz = hierarchy.getClass(_class) const clazz = hierarchy.getClass(_class)
const mixin = hierarchy.as(clazz, view.mixin.ClassFilters) const mixin = hierarchy.as(clazz, view.mixin.ClassFilters)
const result = getFilters(_class, mixin) const result = getFilters(_class, mixin)
@ -159,24 +186,48 @@
closePopup() closePopup()
closeTooltip() closeTooltip()
showPopup( if (nestedFrom !== undefined && type !== nestedFrom) {
type.component, const change = (e: Filter | undefined) => {
{ if (nestedFrom) {
_class, setNestedFilter(nestedFrom, e)
space, }
filter: filter || { }
key: type, const targetClass = (hierarchy.getAttribute(nestedFrom._class, nestedFrom.key).type as RefTo<Doc>).to
value: [], showPopup(
index type.component,
{
_class: targetClass,
space,
filter: filter || {
key: type,
value: [],
index
},
onChange: change
}, },
onChange target
}, )
target } else {
) showPopup(
type.component,
{
_class,
space,
filter: filter || {
key: type,
value: [],
index
},
onChange
},
target
)
}
} }
function hasNested (type: KeyFilter): boolean { function hasNested (type: KeyFilter): boolean {
const targetClass = (hierarchy.getAttribute(type._class, type.key).type as RefTo<Doc>).to const targetClass = (hierarchy.getAttribute(type._class, type.key).type as RefTo<Doc>).to
if (targetClass === undefined) return false
const clazz = hierarchy.getClass(targetClass) const clazz = hierarchy.getClass(targetClass)
return hierarchy.hasMixin(clazz, view.mixin.ClassFilters) return hierarchy.hasMixin(clazz, view.mixin.ClassFilters)
} }
@ -199,38 +250,51 @@
dispatch('close') dispatch('close')
} }
function getNestedProps (type: KeyFilter): any {
const targetClass = (hierarchy.getAttribute(type._class, type.key).type as RefTo<Doc>).to
return {
_class: targetClass,
space,
index,
target,
onChange: (e: Filter | undefined) => {
setNestedFilter(type, e)
}
}
}
const elements: HTMLElement[] = [] const elements: HTMLElement[] = []
</script> </script>
<div class="selectPopup" use:resizeObserver={() => dispatch('changeContent')}> <div class="selectPopup" use:resizeObserver={() => dispatch('changeContent')}>
<Scroller> <Scroller>
{#each getTypes(_class) as type, i} {#if nestedFrom}
{#if filter === undefined && type.component === view.component.ObjectFilter && hasNested(type)} <!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
class="menu-item withIcon"
on:keydown={(event) => keyDown(event, -1)}
on:mouseover={(event) => {
event.currentTarget.focus()
}}
on:click={() => {
if (nestedFrom) {
click(nestedFrom)
}
}}
>
<div class="icon mr-3">
{#if nestedFrom.icon}
<Icon icon={nestedFrom.icon} size={'small'} />
{/if}
</div>
<div class="overflow-label pr-1"><Label label={nestedFrom.label} /></div>
</button>
{/if}
{#each getTypes(_class, nestedFrom) as type, i}
{#if filter === undefined && hasNested(type)}
<Submenu <Submenu
bind:element={elements[i]} bind:element={elements[i]}
on:keydown={(event) => keyDown(event, i)} on:keydown={(event) => keyDown(event, i)}
on:mouseover={() => { on:mouseover={() => {
elements[i]?.focus() elements[i]?.focus()
}} }}
on:click={() => {
click(type)
}}
icon={type.icon} icon={type.icon}
label={type.label} label={type.label}
props={getNestedProps(type)} props={{
_class,
space,
index,
target,
onChange,
nestedFrom: type
}}
options={{ component: view.component.FilterTypePopup }} options={{ component: view.component.FilterTypePopup }}
withHover withHover
/> />

View File

@ -7,7 +7,8 @@ import core, {
FindResult, FindResult,
Hierarchy, Hierarchy,
ObjQueryType, ObjQueryType,
Ref Ref,
RefTo
} from '@hcengineering/core' } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform' import { getResource } from '@hcengineering/platform'
import { LiveQuery, createQuery, getClient } from '@hcengineering/presentation' import { LiveQuery, createQuery, getClient } from '@hcengineering/presentation'
@ -198,12 +199,28 @@ export function buildFilterKey (
key: string, key: string,
attribute: AnyAttribute attribute: AnyAttribute
): KeyFilter | undefined { ): KeyFilter | undefined {
const attrOf = hierarchy.getClass(attribute.attributeOf)
const isRef = hierarchy.isDerived(attribute.type._class, core.class.RefTo)
if (isRef) {
const targetClass = (attribute.type as RefTo<Doc>).to
const filter = hierarchy.classHierarchyMixin(targetClass, view.mixin.AttributeFilter)
if (filter?.component !== undefined) {
return {
_class,
key,
attribute,
label: attribute.label,
icon: attribute.icon ?? filter.icon ?? attrOf.icon ?? view.icon.Setting,
component: filter.component
}
}
}
const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection) const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection)
const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class
const clazz = hierarchy.getClass(targetClass) const clazz = hierarchy.getClass(targetClass)
const filter = hierarchy.as(clazz, view.mixin.AttributeFilter) const filter = hierarchy.as(clazz, view.mixin.AttributeFilter)
const attrOf = hierarchy.getClass(attribute.attributeOf)
if (filter.component === undefined) return undefined if (filter.component === undefined) return undefined
return { return {
_class, _class,

View File

@ -49,9 +49,19 @@ import type {
/** /**
* @public * @public
*/ */
export interface KeyFilter { export interface KeyFilterPreset {
_class: Ref<Class<Doc>> _class: Ref<Class<Doc>>
key: string key: string
attribute?: AnyAttribute
component: AnyComponent
label?: IntlString
icon?: Asset | AnySvelteComponent | undefined
}
/**
* @public
*/
export interface KeyFilter extends KeyFilterPreset {
attribute: AnyAttribute attribute: AnyAttribute
component: AnyComponent component: AnyComponent
label: IntlString label: IntlString
@ -102,7 +112,7 @@ export interface FilteredView extends Preference {
* @public * @public
*/ */
export interface ClassFilters extends Class<Doc> { export interface ClassFilters extends Class<Doc> {
filters: (KeyFilter | string)[] filters: (KeyFilterPreset | string)[]
ignoreKeys?: string[] ignoreKeys?: string[]
// Ignore attributes not specified in the "filters" array // Ignore attributes not specified in the "filters" array