Tracker: Issue filters - main functionality (#1640)

This commit is contained in:
Artyom Grigorovich 2022-05-05 13:35:26 +07:00 committed by GitHub
parent 1b62b6223c
commit cc57821962
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 723 additions and 66 deletions

View File

@ -637,6 +637,8 @@ a.no-line {
.border-radius-3 { border-radius: 0.75rem; }
.border-radius-2 { border-radius: 0.5rem; }
.border-radius-1 { border-radius: 0.25rem; }
.border-radius-left-1 { border-top-left-radius: 0.25rem; border-bottom-left-radius: 0.25rem; }
.border-radius-right-1 { border-top-right-radius: 0.25rem; border-bottom-right-radius: 0.25rem; }
.border-bg-accent {border: 1px solid var(--theme-bg-accent-color);}
.border-primary-button { border-color: var(--primary-button-border); }
.border-button-enabled { border: 1px solid var(--theme-button-border-enabled); }

View File

@ -24,7 +24,7 @@
export let labelParams: Record<string, any> = {}
export let kind: ButtonKind = 'secondary'
export let size: ButtonSize = 'medium'
export let shape: 'circle' | 'round' | undefined = undefined
export let shape: 'rectangle' | 'rectangle-left' | 'rectangle-right' | 'circle' | 'round' | undefined = undefined
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let justify: 'left' | 'center' = 'center'
export let disabled: boolean = false
@ -36,6 +36,7 @@
export let focus: boolean = false
export let click: boolean = false
export let title: string | undefined = undefined
export let borderStyle: 'solid' | 'dashed' = 'solid'
export let input: HTMLButtonElement | undefined = undefined
@ -57,9 +58,13 @@
bind:this={input}
class="button {kind} {size} jf-{justify}"
class:only-icon={iconOnly}
class:border-radius-1={shape !== 'circle' && shape !== 'round'}
class:border-radius-1={shape === undefined}
class:border-radius-2={shape === 'round'}
class:border-radius-4={shape === 'circle'}
class:border-radius-left-1={shape === 'rectangle-right'}
class:border-radius-right-1={shape === 'rectangle-left'}
class:border-solid={borderStyle === 'solid'}
class:border-dashed={borderStyle === 'dashed'}
class:highlight
class:selected
disabled={disabled || loading}
@ -135,6 +140,14 @@
transition-property: border, background-color, color, box-shadow;
transition-duration: 0.15s;
&.border-solid {
border-style: solid;
}
&.border-dashed {
border-style: dashed;
}
.btn-icon {
color: var(--content-color);
transition: color 0.15s;

View File

@ -45,6 +45,7 @@
"Medium": "Medium",
"Low": "Low",
"Unassigned": "Unassigned",
"Back": "Back",
"CategoryBacklog": "Backlog",
"CategoryUnstarted": "Unstarted",
@ -101,7 +102,14 @@
"GotoBacklog": "Go to backlog",
"GotoBoard": "Go to issue board",
"GotoProjects": "Go to projects",
"GotoTrackerApplication": "Switch to Tracker Application"
"GotoTrackerApplication": "Switch to Tracker Application",
"Filter": "Filter",
"ClearFilters": "Clear filters",
"FilterIs": "is",
"FilterIsNot": "is not",
"FilterIsEither": "is either of",
"FilterStatesCount": "{value, plural, =1 {1 state} other {# states}}"
},
"status": {}
}

View File

@ -0,0 +1,89 @@
<!--
// Copyright © 2022 Anticrm Platform Contributors.
//
// 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 ui, { Label, Icon } from '@anticrm/ui'
import { createEventDispatcher, onMount } from 'svelte'
import { FilterAction } from '../utils'
export let actions: FilterAction[] = []
const dispatch = createEventDispatcher()
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()
}
}
onMount(() => {
actionElements[0]?.focus()
})
</script>
<div class="antiPopup">
<div class="ap-space" />
<div class="ap-scroll">
<div class="ap-box">
{#if actions.length === 0}
<div class="p-6 error-color">
<Label label={ui.string.NoActionsDefined} />
</div>
{/if}
{#each actions as action, i}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={actionElements[i]}
class="ap-menuItem flex-row-center withIcon"
on:keydown={(event) => keyDown(event, i)}
on:mouseover={(event) => {
event.currentTarget.focus()
}}
on:click={(event) => {
dispatch('close')
action.onSelect(event)
}}
>
{#if action.icon}
<div class="icon"><Icon icon={action.icon} size={'small'} /></div>
{/if}
{#if action.label}
<div class="ml-3 pr-1"><Label label={action.label} /></div>
{/if}
</button>
{/each}
</div>
</div>
<div class="ap-space" />
</div>
<style lang="scss">
.withIcon {
margin: 0;
.icon {
color: var(--content-color);
}
&:focus .icon {
color: var(--accent-color);
}
}
</style>

View File

@ -0,0 +1,132 @@
<!--
// Copyright © 2022 Anticrm Platform Contributors.
//
// 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 ui, { Label, Icon, CheckBox } from '@anticrm/ui'
import { createEventDispatcher, onMount } from 'svelte'
import { FilterSectionElement } from '../utils'
export let actions: FilterSectionElement[] = []
export let onBack: () => void
const dispatch = createEventDispatcher()
const actionElements: HTMLButtonElement[] = []
$: selectedElementsMap = getSelectedElementsMap(actions)
const getSelectedElementsMap = (actions: FilterSectionElement[]) => {
const result: { [k: number]: boolean } = {}
for (let i = 1; i < actions.length; ++i) {
result[i] = !!actions[i].isSelected
}
return result
}
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')
onBack()
}
}
onMount(() => {
if (actionElements[0]) actionElements[0].focus()
})
</script>
<div class="antiPopup">
<div class="ap-space" />
<div class="ap-scroll">
<div class="ap-box">
{#if actions.length === 0}
<div class="p-6 error-color">
<Label label={ui.string.NoActionsDefined} />
</div>
{/if}
{#each actions as action, i}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={actionElements[i]}
class="ap-menuItem flex-row-center withIcon"
on:keydown={(event) => keyDown(event, i)}
on:mouseover={(event) => {
event.currentTarget.focus()
}}
on:click={(event) => {
if (i === 0) {
dispatch('close')
}
action.onSelect(event)
if (i !== 0) {
selectedElementsMap[i] = !selectedElementsMap[i]
}
}}
>
<div class="buttonContent">
{#if i !== 0}
<div class="flex check pointer-events-none">
<CheckBox checked={selectedElementsMap[i]} primary />
</div>
{/if}
{#if action.icon}
<div class="icon" class:ml-3={i > 0}><Icon icon={action.icon} size={'small'} /></div>
{/if}
{#if action.title}
<div class="ml-3 pr-1">{action.title}</div>
{/if}
{#if action.count !== undefined}
<div class="pr-1 countContent">{action.count}</div>
{/if}
</div>
</button>
{/each}
</div>
</div>
<div class="ap-space" />
</div>
<style lang="scss">
.buttonContent {
display: flex;
align-items: center;
}
.countContent {
color: var(--content-color);
}
.withIcon {
margin: 0;
.icon {
color: var(--content-color);
}
&:focus .icon {
color: var(--accent-color);
}
}
</style>

View File

@ -0,0 +1,38 @@
<!--
// Copyright © 2022 Anticrm Platform Contributors.
//
// 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 { Button, IconAdd } from '@anticrm/ui'
import FilterSummarySection from './FilterSummarySection.svelte'
export let filters: { [p: string]: any[] } = {}
export let onDeleteFilter: (filterKey?: string) => void
</script>
<div class="root">
{#each Object.entries(filters) as [key, value]}
<FilterSummarySection type={key} selectedFilters={value} onDelete={() => onDeleteFilter(key)} />
{/each}
<div class="ml-2">
<Button kind={'link'} icon={IconAdd} />
</div>
</div>
<style lang="scss">
.root {
display: flex;
align-items: center;
}
</style>

View File

@ -0,0 +1,61 @@
<!--
// Copyright © 2022 Anticrm Platform Contributors.
//
// 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 { Button, IconClose } from '@anticrm/ui'
import { getIssueFilterAssetsByType } from '../utils'
import tracker from '../plugin'
export let type: string = ''
export let selectedFilters: any[] = []
export let onDelete: () => void
</script>
<div class="root">
<div class="buttonWrapper">
<Button shape={'rectangle-right'} {...getIssueFilterAssetsByType(type)} />
</div>
<div class="buttonWrapper">
<Button
shape="rectangle"
label={selectedFilters.length < 2 ? tracker.string.FilterIs : tracker.string.FilterIsEither}
/>
</div>
<div class="buttonWrapper">
<Button
shape={'rectangle'}
label={tracker.string.FilterStatesCount}
labelParams={{ value: selectedFilters.length }}
/>
</div>
<div class="buttonWrapper">
<Button shape={'rectangle-left'} icon={IconClose} on:click={onDelete} />
</div>
</div>
<style lang="scss">
.root {
display: flex;
align-items: center;
}
.buttonWrapper {
margin-right: 1px;
&:last-child {
margin-right: 0;
}
}
</style>

View File

@ -26,19 +26,22 @@
IssueStatusCategory,
IssuePriority
} from '@anticrm/tracker'
import { Button, Label, ScrollBox, IconOptions, showPopup, eventToHTMLElement } from '@anticrm/ui'
import { Button, Label, ScrollBox, IconOptions, showPopup, eventToHTMLElement, IconAdd, IconClose } from '@anticrm/ui'
import { IntlString } from '@anticrm/platform'
import { createEventDispatcher } from 'svelte'
import ViewOptionsPopup from './ViewOptionsPopup.svelte'
import IssuesListBrowser from './IssuesListBrowser.svelte'
import IssuesFilterMenu from './IssuesFilterMenu.svelte'
import FilterSummary from '../FilterSummary.svelte'
import tracker from '../../plugin'
import {
IssuesGroupByKeys,
issuesGroupKeyMap,
issuesOrderKeyMap,
getIssuesModificationDatePeriodTime,
groupBy,
issuesSortOrderMap
issuesSortOrderMap,
getGroupedIssues
} from '../../utils'
import ViewOptionsPopup from './ViewOptionsPopup.svelte'
import IssuesListBrowser from './IssuesListBrowser.svelte'
export let currentSpace: Ref<Team>
export let title: IntlString = tracker.string.AllIssues
@ -50,19 +53,25 @@
export let shouldShowEmptyGroups: boolean | undefined = false
export let includedGroups: Partial<Record<IssuesGroupByKeys, Array<any>>> = {}
const dispatch = createEventDispatcher()
const ENTRIES_LIMIT = 200
const spaceQuery = createQuery()
const issuesQuery = createQuery()
const resultIssuesQuery = createQuery()
const statusesQuery = createQuery()
const issuesMap: { [status: string]: number } = {}
let filterElement: HTMLElement | null = null
let filters: { [p: string]: any[] } = {}
let currentTeam: Team | undefined
let issues: Issue[] = []
let resultIssues: Issue[] = []
let statusesById: ReadonlyMap<Ref<IssueStatus>, WithLookup<IssueStatus>> = new Map()
let employees: (WithLookup<Employee> | undefined)[] = []
$: totalIssues = issues.length
$: totalIssuesCount = issues.length
$: resultIssuesCount = resultIssues.length
$: isFiltersEmpty = Object.keys(filters).length === 0
const options: FindOptions<Issue> = {
sort: { [issuesOrderKeyMap[orderingKey]]: issuesSortOrderMap[issuesOrderKeyMap[orderingKey]] },
@ -73,7 +82,7 @@
$: baseQuery = {
space: currentSpace,
...includedIssuesQuery,
...filteredIssuesQuery,
...modifiedOnIssuesQuery,
...query
}
@ -100,7 +109,7 @@
return includedGroups[groupByKey]?.includes(x)
})
$: includedIssuesQuery = getIncludedIssuesQuery(includedGroups, statuses)
$: filteredIssuesQuery = getModifiedOnIssuesFilterQuery(issues, completedIssuesPeriod)
$: modifiedOnIssuesQuery = getModifiedOnIssuesFilterQuery(issues, completedIssuesPeriod)
$: statuses = [...statusesById.values()]
const getIncludedIssuesQuery = (
@ -154,7 +163,7 @@
$: resultIssuesQuery.query<Issue>(
tracker.class.Issue,
{ ...resultQuery },
{ ...resultQuery, ...getFiltersQuery(filters) },
(result) => {
resultIssues = result
@ -175,29 +184,6 @@
}
)
const getGroupedIssues = (key: IssuesGroupByKeys | undefined, elements: Issue[], orderedCategories: any[]) => {
if (!groupByKey) {
return { [undefined as any]: issues }
}
const unorderedIssues = groupBy(elements, key)
return Object.keys(unorderedIssues)
.sort((o1, o2) => {
const key1 = o1 === 'null' ? null : o1
const key2 = o2 === 'null' ? null : o2
const i1 = orderedCategories.findIndex((x) => x === key1)
const i2 = orderedCategories.findIndex((x) => x === key2)
return i1 - i2
})
.reduce((obj: { [p: string]: any[] }, objKey) => {
obj[objKey] = unorderedIssues[objKey]
return obj
}, {})
}
const getCategories = (key: IssuesGroupByKeys | undefined, elements: Issue[], shouldShowAll: boolean) => {
if (!key) {
return [undefined] // No grouping
@ -331,38 +317,168 @@
handleOptionsUpdated
)
}
const getFiltersQuery = (filterParams: { [f: string]: any[] }) => {
const result: { [f: string]: { $in: any[] } } = {}
for (const [key, value] of Object.entries(filterParams)) {
result[key] = { $in: [...value] }
}
return result
}
const handleFilterDeleted = (filterKey?: string) => {
if (filterKey) {
delete filters[filterKey]
filters = filters
} else {
filters = {}
}
}
const handleAllFiltersDeleted = () => {
handleFilterDeleted()
}
const handleFiltersModified = (result: { [p: string]: any }) => {
const entries = Object.entries(result)
if (entries.length !== 1) {
return
}
const [filter, filterValue] = entries[0]
if (filter in filters) {
if (filters[filter].includes(filterValue)) {
filters[filter] = filters[filter].filter((x) => x !== filterValue)
if (Object.keys(filters).length === 1 && filters[filter].length === 0) {
filters = {}
}
} else {
filters[filter] = [...filters[filter], filterValue]
}
} else {
filters[filter] = [filterValue]
}
}
const handleFiltersBackButtonPressed = (event: MouseEvent) => {
dispatch('close')
handleFilterMenuOpened(event)
}
const handleFilterMenuOpened = (event: MouseEvent) => {
if (!currentSpace) {
return
}
if (filterElement === null) {
filterElement = eventToHTMLElement(event)
}
showPopup(
IssuesFilterMenu,
{
issues,
currentFilter: getFiltersQuery(filters),
defaultStatuses: statuses,
onBack: handleFiltersBackButtonPressed,
targetHtml: filterElement,
onUpdate: handleFiltersModified
},
filterElement
)
}
</script>
{#if currentTeam}
<ScrollBox vertical stretch>
<div class="fs-title flex-between mt-1 mr-1 ml-1">
<Label label={title} params={{ value: totalIssues }} />
<div class="fs-title flex-between header">
<div class="titleContainer">
{#if totalIssuesCount === resultIssuesCount}
<Label label={title} params={{ value: totalIssuesCount }} />
{:else}
<div class="labelsContainer">
<Label label={title} params={{ value: resultIssuesCount }} />
<div class="totalIssuesLabel">/{totalIssuesCount}</div>
</div>
{/if}
<div class="ml-3">
<Button
icon={isFiltersEmpty ? IconAdd : IconClose}
kind={'link-bordered'}
borderStyle={'dashed'}
label={isFiltersEmpty ? tracker.string.Filter : tracker.string.ClearFilters}
on:click={isFiltersEmpty ? handleFilterMenuOpened : handleAllFiltersDeleted}
/>
</div>
</div>
<Button icon={IconOptions} kind={'link'} on:click={handleOptionsEditorOpened} />
</div>
<div class="mt-4">
<IssuesListBrowser
_class={tracker.class.Issue}
{currentSpace}
{groupByKey}
orderBy={issuesOrderKeyMap[orderingKey]}
{statuses}
{employees}
categories={displayedCategories}
itemsConfig={[
{ key: '', presenter: tracker.component.PriorityEditor, props: { currentSpace } },
{ key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } },
{ key: '', presenter: tracker.component.StatusEditor, props: { currentSpace, statuses } },
{ key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true } },
{ key: '', presenter: tracker.component.DueDatePresenter, props: { currentSpace } },
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter },
{
key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter,
props: { currentSpace, defaultClass: contact.class.Employee, shouldShowLabel: false }
}
]}
{groupedIssues}
/>
</div>
{#if Object.keys(filters).length > 0}
<div class="filterSummaryWrapper">
<FilterSummary {filters} onDeleteFilter={handleFilterDeleted} />
</div>
{/if}
<IssuesListBrowser
_class={tracker.class.Issue}
{currentSpace}
{groupByKey}
orderBy={issuesOrderKeyMap[orderingKey]}
{statuses}
{employees}
categories={displayedCategories}
itemsConfig={[
{ key: '', presenter: tracker.component.PriorityEditor, props: { currentSpace } },
{ key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } },
{ key: '', presenter: tracker.component.StatusEditor, props: { currentSpace, statuses } },
{ key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true } },
{ key: '', presenter: tracker.component.DueDatePresenter, props: { currentSpace } },
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter },
{
key: '$lookup.assignee',
presenter: tracker.component.AssigneePresenter,
props: { currentSpace, defaultClass: contact.class.Employee, shouldShowLabel: false }
}
]}
{groupedIssues}
/>
</ScrollBox>
{/if}
<style lang="scss">
.header {
min-height: 3.5rem;
padding-left: 2.25rem;
padding-right: 1.35rem;
}
.titleContainer {
display: flex;
align-items: center;
justify-content: flex-start;
}
.labelsContainer {
display: flex;
align-items: center;
}
.totalIssuesLabel {
color: var(--content-color);
}
.filterSummaryWrapper {
display: flex;
align-items: center;
min-height: 3.5rem;
padding-left: 2.25rem;
padding-right: 1.35rem;
border-top: 1px solid var(--theme-button-border-hovered);
}
</style>

View File

@ -0,0 +1,61 @@
<!--
// 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 { WithLookup } from '@anticrm/core'
import { Issue, IssueStatus } from '@anticrm/tracker'
import { showPopup } from '@anticrm/ui'
import StatusFilterMenuSection from './StatusFilterMenuSection.svelte'
import FilterMenu from '../FilterMenu.svelte'
import { FilterAction, getGroupedIssues, getIssueFilterAssetsByType } from '../../utils'
export let targetHtml: HTMLElement
export let currentFilter: { [p: string]: { $in: any[] } } = {}
export let defaultStatuses: Array<WithLookup<IssueStatus>> = []
export let issues: Issue[] = []
export let onUpdate: (result: { [p: string]: any }) => void
export let onBack: () => void
$: defaultStatusIds = defaultStatuses.map((x) => x._id)
$: groupedByStatus = getGroupedIssues('status', issues, defaultStatusIds)
const handleStatusFilterMenuSectionOpened = (event: MouseEvent | KeyboardEvent) => {
const statusGroups: { [key: string]: number } = {}
for (const defaultStatus of defaultStatuses) {
statusGroups[defaultStatus._id] = groupedByStatus[defaultStatus._id]?.length ?? 0
}
showPopup(
StatusFilterMenuSection,
{
groups: statusGroups,
statuses: defaultStatuses,
selectedElements: currentFilter.status?.$in,
onUpdate,
onBack
},
targetHtml
)
}
const actions: FilterAction[] = [
{
...getIssueFilterAssetsByType('status'),
onSelect: handleStatusFilterMenuSectionOpened
}
]
</script>
<FilterMenu {actions} on:close />

View File

@ -124,7 +124,7 @@
handleRowFocused(combinedGroupedIssues[position])
if (objectRef !== undefined) {
if (objectRef) {
objectRef.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
@ -359,7 +359,7 @@
.gridElement {
display: flex;
align-items: center;
justify-content: start;
justify-content: flex-start;
margin-left: 0.5rem;
&:first-child {

View File

@ -0,0 +1,72 @@
<!--
// 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 { WithLookup } from '@anticrm/core'
import { translate } from '@anticrm/platform'
import { IssueStatus } from '@anticrm/tracker'
import { IconNavPrev } from '@anticrm/ui'
import FilterMenuSection from '../FilterMenuSection.svelte'
import tracker from '../../plugin'
import { FilterSectionElement } from '../../utils'
export let selectedElements: any[] = []
export let statuses: Array<WithLookup<IssueStatus>> = []
export let groups: { [key: string]: number }
export let onUpdate: (result: { [p: string]: any }) => void
export let onBack: () => void
let backButtonTitle = ''
$: actions = getFilterElements(groups, statuses, selectedElements, backButtonTitle)
$: translate(tracker.string.Back, {}).then((result) => {
backButtonTitle = result
})
const getFilterElements = (
groups: { [key: string]: number },
defaultStatuses: Array<WithLookup<IssueStatus>>,
selected: any[],
backButtonTitle: string
) => {
const elements: FilterSectionElement[] = [
{
icon: IconNavPrev,
title: backButtonTitle,
onSelect: onBack
}
]
for (const [key, value] of Object.entries(groups)) {
const status = defaultStatuses.find((x) => x._id === key)
if (!status) {
continue
}
elements.push({
icon: status.$lookup?.category?.icon,
title: status.name,
count: value,
isSelected: selected.includes(key),
onSelect: () => onUpdate({ status: key })
})
}
return elements
}
</script>
<FilterMenuSection {actions} {onBack} on:close />

View File

@ -112,6 +112,9 @@ export default mergeIds(trackerId, tracker, {
All: '' as IntlString,
PastWeek: '' as IntlString,
PastMonth: '' as IntlString,
Filter: '' as IntlString,
ClearFilters: '' as IntlString,
Back: '' as IntlString,
IssueTitlePlaceholder: '' as IntlString,
IssueDescriptionPlaceholder: '' as IntlString,
@ -119,7 +122,12 @@ export default mergeIds(trackerId, tracker, {
AddIssueTooltip: '' as IntlString,
CopyIssueUrl: '' as IntlString,
CopyIssueId: '' as IntlString
CopyIssueId: '' as IntlString,
FilterIs: '' as IntlString,
FilterIsNot: '' as IntlString,
FilterIsEither: '' as IntlString,
FilterStatesCount: '' as IntlString
},
component: {
NopeComponent: '' as AnyComponent,

View File

@ -24,7 +24,7 @@ import {
IssuesDateModificationPeriod,
ProjectStatus
} from '@anticrm/tracker'
import { AnyComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui'
import { AnyComponent, AnySvelteComponent, getMillisecondsInMonth, MILLISECONDS_IN_WEEK } from '@anticrm/ui'
import tracker from './plugin'
export interface NavigationItem {
@ -134,3 +134,60 @@ export const groupBy = (data: any, key: any): { [key: string]: any[] } => {
return storage
}, {})
}
export interface FilterAction {
icon?: Asset | AnySvelteComponent
label?: IntlString
onSelect: (event: MouseEvent | KeyboardEvent) => void
}
export interface FilterSectionElement extends Omit<FilterAction, 'label'> {
title?: string
count?: number
isSelected?: boolean
}
export const getGroupedIssues = (
key: IssuesGroupByKeys | undefined,
elements: Issue[],
orderedCategories?: any[]
): { [p: string]: Issue[] } => {
if (key === undefined) {
return { [undefined as any]: elements }
}
const unorderedIssues = groupBy(elements, key)
if (orderedCategories === undefined || orderedCategories.length === 0) {
return unorderedIssues
}
return Object.keys(unorderedIssues)
.sort((o1, o2) => {
const key1 = o1 === 'null' ? null : o1
const key2 = o2 === 'null' ? null : o2
const i1 = orderedCategories.findIndex((x) => x === key1)
const i2 = orderedCategories.findIndex((x) => x === key2)
return i1 - i2
})
.reduce((obj: { [p: string]: any[] }, objKey) => {
obj[objKey] = unorderedIssues[objKey]
return obj
}, {})
}
export const getIssueFilterAssetsByType = (type: string): { icon: Asset, label: IntlString } | undefined => {
switch (type) {
case 'status': {
return {
icon: tracker.icon.CategoryBacklog,
label: tracker.string.Status
}
}
default: {
return undefined
}
}
}