mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-23 03:22:19 +03:00
Tracker: Issue filters - main functionality (#1640)
This commit is contained in:
parent
1b62b6223c
commit
cc57821962
@ -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); }
|
||||
|
@ -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;
|
||||
|
@ -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": {}
|
||||
}
|
89
plugins/tracker-resources/src/components/FilterMenu.svelte
Normal file
89
plugins/tracker-resources/src/components/FilterMenu.svelte
Normal 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>
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
@ -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 />
|
@ -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 {
|
||||
|
@ -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 />
|
@ -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,
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user