mirror of
https://github.com/hcengineering/platform.git
synced 2025-04-21 07:46:24 +00:00
Remember viewOptions (#2120)
Signed-off-by: Dvinyanin Alexandr <dvinyanin.alexandr@gmail.com>
This commit is contained in:
parent
de99835dc3
commit
0cadc19331
@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## 0.6.29 (upcoming)
|
## 0.6.29 (upcoming)
|
||||||
|
|
||||||
|
Tracker:
|
||||||
|
|
||||||
|
- Remember view options
|
||||||
|
-
|
||||||
Chunter:
|
Chunter:
|
||||||
|
|
||||||
- Reactions on messages
|
- Reactions on messages
|
||||||
|
@ -1,527 +0,0 @@
|
|||||||
<!--
|
|
||||||
// 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 contact, { Employee, formatName } from '@anticrm/contact'
|
|
||||||
import { DocumentQuery, FindOptions, Ref, SortingOrder, WithLookup } from '@anticrm/core'
|
|
||||||
import { createQuery } from '@anticrm/presentation'
|
|
||||||
import {
|
|
||||||
Issue,
|
|
||||||
Team,
|
|
||||||
IssuesGrouping,
|
|
||||||
IssuesOrdering,
|
|
||||||
IssuesDateModificationPeriod,
|
|
||||||
IssueStatus,
|
|
||||||
IssueStatusCategory
|
|
||||||
} from '@anticrm/tracker'
|
|
||||||
import { Button, Label, Scroller, showPopup, eventToHTMLElement, IconAdd, IconClose, Icon } 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,
|
|
||||||
issuesSortOrderMap,
|
|
||||||
getGroupedIssues,
|
|
||||||
defaultPriorities,
|
|
||||||
getArraysIntersection,
|
|
||||||
IssueFilter,
|
|
||||||
getArraysUnion
|
|
||||||
} from '../../utils'
|
|
||||||
import ViewOptionsButton from './ViewOptionsButton.svelte'
|
|
||||||
|
|
||||||
export let currentSpace: Ref<Team>
|
|
||||||
export let title: IntlString = tracker.string.AllIssues
|
|
||||||
export let query: DocumentQuery<Issue> = {}
|
|
||||||
export let search: string = ''
|
|
||||||
export let groupingKey: IssuesGrouping = IssuesGrouping.Status
|
|
||||||
export let orderingKey: IssuesOrdering = IssuesOrdering.LastUpdated
|
|
||||||
export let completedIssuesPeriod: IssuesDateModificationPeriod | null = IssuesDateModificationPeriod.All
|
|
||||||
export let shouldShowSubIssues: boolean | undefined = true
|
|
||||||
export let shouldShowEmptyGroups: boolean | undefined = false
|
|
||||||
export let includedGroups: Partial<Record<IssuesGroupByKeys, Array<any>>> = {}
|
|
||||||
export let label: string | undefined = undefined
|
|
||||||
|
|
||||||
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: IssueFilter[] = []
|
|
||||||
let currentTeam: Team | undefined
|
|
||||||
let issues: Issue[] = []
|
|
||||||
let resultIssues: Issue[] = []
|
|
||||||
let statusesById: ReadonlyMap<Ref<IssueStatus>, WithLookup<IssueStatus>> = new Map()
|
|
||||||
let employees: (WithLookup<Employee> | undefined)[] = []
|
|
||||||
|
|
||||||
$: totalIssuesCount = issues.length
|
|
||||||
$: resultIssuesCount = resultIssues.length
|
|
||||||
$: isFiltersEmpty = filters.length === 0
|
|
||||||
|
|
||||||
const options: FindOptions<Issue> = {
|
|
||||||
sort: { [issuesOrderKeyMap[orderingKey]]: issuesSortOrderMap[issuesOrderKeyMap[orderingKey]] },
|
|
||||||
limit: ENTRIES_LIMIT,
|
|
||||||
lookup: {
|
|
||||||
assignee: contact.class.Employee,
|
|
||||||
status: tracker.class.IssueStatus,
|
|
||||||
space: tracker.class.Team
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: baseQuery = {
|
|
||||||
space: currentSpace,
|
|
||||||
...includedIssuesQuery,
|
|
||||||
...modifiedOnIssuesQuery,
|
|
||||||
...query
|
|
||||||
}
|
|
||||||
|
|
||||||
$: resultQuery = search === '' ? baseQuery : { $search: search, ...baseQuery }
|
|
||||||
|
|
||||||
$: spaceQuery.query(tracker.class.Team, { _id: currentSpace }, (res) => {
|
|
||||||
currentTeam = res.shift()
|
|
||||||
})
|
|
||||||
|
|
||||||
$: groupByKey = issuesGroupKeyMap[groupingKey]
|
|
||||||
$: categories = getCategories(groupByKey, resultIssues, !!shouldShowEmptyGroups)
|
|
||||||
$: groupedIssues = getGroupedIssues(groupByKey, resultIssues, categories)
|
|
||||||
$: displayedCategories = (categories as any[]).filter((x) => {
|
|
||||||
if (groupByKey === undefined || includedGroups[groupByKey] === undefined) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupByKey === 'status') {
|
|
||||||
const category = statusesById.get(x as Ref<IssueStatus>)?.category
|
|
||||||
|
|
||||||
return !!(category && includedGroups.status?.includes(category))
|
|
||||||
}
|
|
||||||
|
|
||||||
return includedGroups[groupByKey]?.includes(x)
|
|
||||||
})
|
|
||||||
$: includedIssuesQuery = getIncludedIssuesQuery(includedGroups, statuses, shouldShowSubIssues)
|
|
||||||
$: modifiedOnIssuesQuery = getModifiedOnIssuesFilterQuery(issues, completedIssuesPeriod)
|
|
||||||
$: statuses = [...statusesById.values()]
|
|
||||||
|
|
||||||
const getIncludedIssuesQuery = (
|
|
||||||
groups: Partial<Record<IssuesGroupByKeys, Array<any>>>,
|
|
||||||
issueStatuses: IssueStatus[],
|
|
||||||
withSubIssues?: boolean
|
|
||||||
) => {
|
|
||||||
const resultMap: { [p: string]: { $in: any[] } } = {}
|
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(groups)) {
|
|
||||||
const includedCategories = key === 'status' ? filterIssueStatuses(issueStatuses, value) : value
|
|
||||||
resultMap[key] = { $in: includedCategories }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...resultMap, ...(withSubIssues ? {} : { attachedTo: tracker.ids.NoParent }) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const getModifiedOnIssuesFilterQuery = (
|
|
||||||
currentIssues: WithLookup<Issue>[],
|
|
||||||
period: IssuesDateModificationPeriod | null
|
|
||||||
) => {
|
|
||||||
const filter: { _id: { $in: Array<Ref<Issue>> } } = { _id: { $in: [] } }
|
|
||||||
|
|
||||||
if (!period || period === IssuesDateModificationPeriod.All) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const issue of currentIssues) {
|
|
||||||
if (
|
|
||||||
issue.$lookup?.status?.category === tracker.issueStatusCategory.Completed &&
|
|
||||||
issue.modifiedOn < getIssuesModificationDatePeriodTime(period)
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
filter._id.$in.push(issue._id)
|
|
||||||
}
|
|
||||||
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
|
|
||||||
$: issuesQuery.query<Issue>(
|
|
||||||
tracker.class.Issue,
|
|
||||||
{ ...includedIssuesQuery },
|
|
||||||
(result) => {
|
|
||||||
issues = result
|
|
||||||
|
|
||||||
employees = result.map((x) => x.$lookup?.assignee)
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
|
|
||||||
$: resultIssuesQuery.query<Issue>(
|
|
||||||
tracker.class.Issue,
|
|
||||||
{ ...resultQuery, ...getFiltersQuery(filters) },
|
|
||||||
(result) => {
|
|
||||||
resultIssues = result
|
|
||||||
|
|
||||||
employees = result.map((x) => x.$lookup?.assignee)
|
|
||||||
},
|
|
||||||
options
|
|
||||||
)
|
|
||||||
|
|
||||||
$: statusesQuery.query(
|
|
||||||
tracker.class.IssueStatus,
|
|
||||||
{ attachedTo: currentSpace },
|
|
||||||
(issueStatuses) => {
|
|
||||||
statusesById = new Map(issueStatuses.map((status) => [status._id, status]))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
lookup: { category: tracker.class.IssueStatusCategory },
|
|
||||||
sort: { rank: SortingOrder.Ascending }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const getCategories = (key: IssuesGroupByKeys | undefined, elements: Issue[], shouldShowAll: boolean) => {
|
|
||||||
if (!key) {
|
|
||||||
return [undefined] // No grouping
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultStatuses = Object.values(statuses).map((x) => x._id)
|
|
||||||
|
|
||||||
const existingCategories = Array.from(
|
|
||||||
new Set(
|
|
||||||
elements.map((x) => {
|
|
||||||
return x[key]
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (shouldShowAll) {
|
|
||||||
if (key === 'status') {
|
|
||||||
return defaultStatuses
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'priority') {
|
|
||||||
return defaultPriorities
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'status') {
|
|
||||||
existingCategories.sort((s1, s2) => {
|
|
||||||
const i1 = defaultStatuses.findIndex((x) => x === s1)
|
|
||||||
const i2 = defaultStatuses.findIndex((x) => x === s2)
|
|
||||||
|
|
||||||
return i1 - i2
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'priority') {
|
|
||||||
existingCategories.sort((p1, p2) => {
|
|
||||||
const i1 = defaultPriorities.findIndex((x) => x === p1)
|
|
||||||
const i2 = defaultPriorities.findIndex((x) => x === p2)
|
|
||||||
|
|
||||||
return i1 - i2
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'assignee') {
|
|
||||||
existingCategories.sort((a1, a2) => {
|
|
||||||
const employeeId1 = a1 as Ref<Employee> | null
|
|
||||||
const employeeId2 = a2 as Ref<Employee> | null
|
|
||||||
|
|
||||||
if (employeeId1 === null && employeeId2 !== null) {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (employeeId1 !== null && employeeId2 === null) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (employeeId1 !== null && employeeId2 !== null) {
|
|
||||||
const name1 = formatName(employees.find((x) => x?._id === employeeId1)?.name ?? '')
|
|
||||||
const name2 = formatName(employees.find((x) => x?._id === employeeId2)?.name ?? '')
|
|
||||||
|
|
||||||
if (name1 > name2) {
|
|
||||||
return 1
|
|
||||||
} else if (name2 > name1) {
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return existingCategories
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterIssueStatuses (
|
|
||||||
issueStatuses: IssueStatus[],
|
|
||||||
issueStatusCategories: Ref<IssueStatusCategory>[]
|
|
||||||
): Ref<IssueStatus>[] {
|
|
||||||
const statusCategories = new Set(issueStatusCategories)
|
|
||||||
|
|
||||||
return issueStatuses.filter((status) => statusCategories.has(status.category)).map((s) => s._id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOptionsUpdated = (
|
|
||||||
result:
|
|
||||||
| {
|
|
||||||
orderBy: IssuesOrdering
|
|
||||||
groupBy: IssuesGrouping
|
|
||||||
completedIssuesPeriod: IssuesDateModificationPeriod
|
|
||||||
shouldShowSubIssues: boolean
|
|
||||||
shouldShowEmptyGroups: boolean
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
) => {
|
|
||||||
if (result === undefined) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const prop of Object.getOwnPropertyNames(issuesMap)) {
|
|
||||||
delete issuesMap[prop]
|
|
||||||
}
|
|
||||||
|
|
||||||
groupingKey = result.groupBy
|
|
||||||
orderingKey = result.orderBy
|
|
||||||
completedIssuesPeriod = result.completedIssuesPeriod
|
|
||||||
shouldShowSubIssues = result.shouldShowSubIssues
|
|
||||||
shouldShowEmptyGroups = result.shouldShowEmptyGroups
|
|
||||||
|
|
||||||
if (result.groupBy === IssuesGrouping.Assignee || result.groupBy === IssuesGrouping.NoGrouping) {
|
|
||||||
shouldShowEmptyGroups = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOptionsEditorOpened = (event: MouseEvent) => {
|
|
||||||
if (!currentSpace) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showPopup(
|
|
||||||
ViewOptionsPopup,
|
|
||||||
{ groupBy: groupingKey, orderBy: orderingKey, completedIssuesPeriod, shouldShowSubIssues, shouldShowEmptyGroups },
|
|
||||||
eventToHTMLElement(event),
|
|
||||||
undefined,
|
|
||||||
handleOptionsUpdated
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFiltersQuery = (filters: IssueFilter[]) => {
|
|
||||||
const result: { [f: string]: { $in?: any[]; $nin?: any[] } } = {}
|
|
||||||
|
|
||||||
for (const filter of filters) {
|
|
||||||
for (const [key, value] of Object.entries(filter.query)) {
|
|
||||||
const { mode } = filter
|
|
||||||
|
|
||||||
if (result[key] === undefined) {
|
|
||||||
result[key] = { ...value }
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result[key][mode] === undefined) {
|
|
||||||
result[key][mode] = [...value[mode]]
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const resultFunction = mode === '$nin' ? getArraysUnion : getArraysIntersection
|
|
||||||
|
|
||||||
result[key][mode] = resultFunction(result[key]?.[mode] ?? [], value[mode])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilterDeleted = (filterIndex?: number) => {
|
|
||||||
if (filterIndex !== undefined) {
|
|
||||||
filters.splice(filterIndex, 1)
|
|
||||||
} else {
|
|
||||||
filters.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
filters = filters
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAllFiltersDeleted = () => {
|
|
||||||
handleFilterDeleted()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFiltersModified = (result: { [p: string]: any }, index?: number) => {
|
|
||||||
const i = index === undefined ? filters.length : index
|
|
||||||
const entries = Object.entries(result)
|
|
||||||
|
|
||||||
if (entries.length !== 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const [filterKey, filterValue] = entries[0]
|
|
||||||
|
|
||||||
if (filters[i]) {
|
|
||||||
const { mode, query: currentFilterQuery } = filters[i]
|
|
||||||
const currentFilterQueryConditions: any[] = currentFilterQuery[filterKey]?.[mode] ?? []
|
|
||||||
|
|
||||||
if (currentFilterQueryConditions.includes(filterValue)) {
|
|
||||||
const updatedFilterConditions = currentFilterQueryConditions.filter((x: any) => x !== filterValue)
|
|
||||||
|
|
||||||
filters[i] = { mode, query: { [filterKey]: { [mode]: updatedFilterConditions } } }
|
|
||||||
|
|
||||||
if (filters.length === 1 && updatedFilterConditions.length === 0) {
|
|
||||||
filters.length = 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filters[i] = { mode, query: { [filterKey]: { $in: [...currentFilterQueryConditions, filterValue] } } }
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filters[i] = { mode: '$in', query: { [filterKey]: { $in: [filterValue] } } }
|
|
||||||
}
|
|
||||||
|
|
||||||
filters = filters
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilterModeChanged = (index: number) => {
|
|
||||||
if (!filters[index]) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { mode: currentMode, query: currentQuery } = filters[index]
|
|
||||||
const newMode = currentMode === '$in' ? '$nin' : '$in'
|
|
||||||
const [filterKey, filterValue] = Object.entries(currentQuery)[0]
|
|
||||||
|
|
||||||
filters[index] = { mode: newMode, query: { [filterKey]: { [newMode]: [...filterValue[currentMode]] } } }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFiltersBackButtonPressed = (event: MouseEvent) => {
|
|
||||||
dispatch('close')
|
|
||||||
|
|
||||||
handleFilterMenuOpened(event, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFilterMenuOpened = (event: MouseEvent, shouldUpdateFilterTargetElement: boolean = true) => {
|
|
||||||
if (!currentSpace) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!filterElement || shouldUpdateFilterTargetElement) {
|
|
||||||
filterElement = eventToHTMLElement(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
showPopup(
|
|
||||||
IssuesFilterMenu,
|
|
||||||
{
|
|
||||||
issues,
|
|
||||||
filters: filters,
|
|
||||||
index: filters.length,
|
|
||||||
defaultStatuses: statuses,
|
|
||||||
onBack: handleFiltersBackButtonPressed,
|
|
||||||
targetHtml: filterElement,
|
|
||||||
onUpdate: handleFiltersModified
|
|
||||||
},
|
|
||||||
filterElement
|
|
||||||
)
|
|
||||||
}
|
|
||||||
$: value = totalIssuesCount === resultIssuesCount ? totalIssuesCount : `${resultIssuesCount}/${totalIssuesCount}`
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if currentTeam}
|
|
||||||
<div class="ac-header full divide">
|
|
||||||
<div class="ac-header__wrap-title">
|
|
||||||
<div class="ac-header__title">
|
|
||||||
{#if label}
|
|
||||||
{label}
|
|
||||||
{:else}
|
|
||||||
<Label label={title} params={{ value }} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
kind={'link-bordered'}
|
|
||||||
borderStyle={'dashed'}
|
|
||||||
on:click={isFiltersEmpty ? handleFilterMenuOpened : handleAllFiltersDeleted}
|
|
||||||
>
|
|
||||||
<svelte:fragment slot="content">
|
|
||||||
<div class="flex-row-center pointer-events-none">
|
|
||||||
{#if isFiltersEmpty}
|
|
||||||
<Icon icon={IconAdd} size={'x-small'} />
|
|
||||||
<span class="overflow-label ml-1"><Label label={tracker.string.Filter} /></span>
|
|
||||||
{:else}
|
|
||||||
<span class="overflow-label mr-1"><Label label={tracker.string.ClearFilters} /></span>
|
|
||||||
<Icon icon={IconClose} size={'x-small'} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</svelte:fragment>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ViewOptionsButton on:click={handleOptionsEditorOpened} />
|
|
||||||
</div>
|
|
||||||
{#if filters.length > 0}
|
|
||||||
<FilterSummary
|
|
||||||
{filters}
|
|
||||||
{issues}
|
|
||||||
defaultStatuses={statuses}
|
|
||||||
onAddFilter={handleFilterMenuOpened}
|
|
||||||
onUpdateFilter={handleFiltersModified}
|
|
||||||
onDeleteFilter={handleFilterDeleted}
|
|
||||||
onChangeMode={handleFilterModeChanged}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex h-full clear-mins">
|
|
||||||
<Scroller tableFade={displayedCategories.length > 1}>
|
|
||||||
<IssuesListBrowser
|
|
||||||
_class={tracker.class.Issue}
|
|
||||||
{currentSpace}
|
|
||||||
{groupByKey}
|
|
||||||
orderBy={issuesOrderKeyMap[orderingKey]}
|
|
||||||
{statuses}
|
|
||||||
{employees}
|
|
||||||
categories={displayedCategories}
|
|
||||||
itemsConfig={[
|
|
||||||
{ key: '', presenter: tracker.component.PriorityEditor, props: { kind: 'list', size: 'small' } },
|
|
||||||
{ key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } },
|
|
||||||
{ key: '', presenter: tracker.component.StatusEditor, props: { statuses, kind: 'list', size: 'small' } },
|
|
||||||
{ key: '', presenter: tracker.component.TitlePresenter, props: { shouldUseMargin: true, fixed: 'left' } },
|
|
||||||
{ key: '', presenter: tracker.component.DueDatePresenter, props: { kind: 'list' } },
|
|
||||||
{
|
|
||||||
key: '',
|
|
||||||
presenter: tracker.component.ProjectEditor,
|
|
||||||
props: { kind: 'list', size: 'small', shape: 'round', shouldShowPlaceholder: false }
|
|
||||||
},
|
|
||||||
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter, props: { fixed: 'right' } },
|
|
||||||
{
|
|
||||||
key: '$lookup.assignee',
|
|
||||||
presenter: tracker.component.AssigneePresenter,
|
|
||||||
props: { currentSpace, defaultClass: contact.class.Employee, shouldShowLabel: false }
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
{groupedIssues}
|
|
||||||
/>
|
|
||||||
</Scroller>
|
|
||||||
{#if $$slots.aside !== undefined}
|
|
||||||
<div class="antiPanel-component aside border-left">
|
|
||||||
<slot name="aside" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
@ -2,14 +2,14 @@
|
|||||||
import { DocumentQuery, Ref, SortingOrder, WithLookup } from '@anticrm/core'
|
import { DocumentQuery, Ref, SortingOrder, WithLookup } from '@anticrm/core'
|
||||||
import { Component } from '@anticrm/ui'
|
import { Component } from '@anticrm/ui'
|
||||||
import { BuildModelKey, Viewlet, ViewletPreference } from '@anticrm/view'
|
import { BuildModelKey, Viewlet, ViewletPreference } from '@anticrm/view'
|
||||||
import { Issue, IssueStatus, Team, ViewOptions } from '@anticrm/tracker'
|
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
|
||||||
import tracker from '../../plugin'
|
|
||||||
import { createQuery } from '@anticrm/presentation'
|
import { createQuery } from '@anticrm/presentation'
|
||||||
|
import { viewOptionsStore } from '../../viewOptions'
|
||||||
|
import tracker from '../../plugin'
|
||||||
|
|
||||||
export let currentSpace: Ref<Team>
|
export let currentSpace: Ref<Team>
|
||||||
export let viewlet: WithLookup<Viewlet>
|
export let viewlet: WithLookup<Viewlet>
|
||||||
export let query: DocumentQuery<Issue> = {}
|
export let query: DocumentQuery<Issue> = {}
|
||||||
export let viewOptions: ViewOptions
|
|
||||||
|
|
||||||
const statusesQuery = createQuery()
|
const statusesQuery = createQuery()
|
||||||
const spaceQuery = createQuery()
|
const spaceQuery = createQuery()
|
||||||
@ -62,14 +62,14 @@
|
|||||||
|
|
||||||
{#if viewlet?.$lookup?.descriptor?.component}
|
{#if viewlet?.$lookup?.descriptor?.component}
|
||||||
<Component
|
<Component
|
||||||
is={viewlet.$lookup?.descriptor?.component}
|
is={viewlet.$lookup.descriptor.component}
|
||||||
props={{
|
props={{
|
||||||
currentSpace,
|
currentSpace,
|
||||||
config: createConfig(viewlet, undefined),
|
config: createConfig(viewlet, undefined),
|
||||||
options: viewlet.options,
|
options: viewlet.options,
|
||||||
viewlet,
|
viewlet,
|
||||||
query,
|
query,
|
||||||
viewOptions
|
viewOptions: $viewOptionsStore
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
@ -1,28 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Ref, WithLookup } from '@anticrm/core'
|
import { Icon, TabList } from '@anticrm/ui'
|
||||||
import { Team, ViewOptions } from '@anticrm/tracker'
|
|
||||||
import { Icon, TabList, showPopup, eventToHTMLElement } from '@anticrm/ui'
|
|
||||||
import { Viewlet } from '@anticrm/view'
|
import { Viewlet } from '@anticrm/view'
|
||||||
import { FilterButton, setActiveViewletId } from '@anticrm/view-resources'
|
import { FilterButton, setActiveViewletId } from '@anticrm/view-resources'
|
||||||
import tracker from '../../plugin'
|
import tracker from '../../plugin'
|
||||||
import ViewOptionsPopup from './ViewOptionsPopup.svelte'
|
import { WithLookup } from '@anticrm/core'
|
||||||
import ViewOptionsButton from './ViewOptionsButton.svelte'
|
import ViewOptions from './ViewOptions.svelte'
|
||||||
|
|
||||||
export let currentSpace: Ref<Team>
|
|
||||||
export let viewlet: WithLookup<Viewlet> | undefined
|
export let viewlet: WithLookup<Viewlet> | undefined
|
||||||
export let viewlets: WithLookup<Viewlet>[] = []
|
export let viewlets: WithLookup<Viewlet>[] = []
|
||||||
export let label: string
|
export let label: string
|
||||||
export let viewOptions: ViewOptions
|
|
||||||
|
|
||||||
const handleOptionsEditorOpened = (event: MouseEvent) => {
|
|
||||||
if (!currentSpace) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
showPopup(ViewOptionsPopup, viewOptions, eventToHTMLElement(event), undefined, (result) => {
|
|
||||||
if (result) viewOptions = { ...result }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
$: viewslist = viewlets.map((views) => {
|
$: viewslist = viewlets.map((views) => {
|
||||||
return {
|
return {
|
||||||
@ -54,6 +40,6 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<ViewOptionsButton on:click={handleOptionsEditorOpened} />
|
<ViewOptions {viewlet} />
|
||||||
<slot name="extra" />
|
<slot name="extra" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,14 +2,7 @@
|
|||||||
import core, { DocumentQuery, Ref, Space, WithLookup } from '@anticrm/core'
|
import core, { DocumentQuery, Ref, Space, WithLookup } from '@anticrm/core'
|
||||||
import { IntlString, translate } from '@anticrm/platform'
|
import { IntlString, translate } from '@anticrm/platform'
|
||||||
import { getClient } from '@anticrm/presentation'
|
import { getClient } from '@anticrm/presentation'
|
||||||
import {
|
import { Issue, Team } from '@anticrm/tracker'
|
||||||
Issue,
|
|
||||||
IssuesDateModificationPeriod,
|
|
||||||
IssuesGrouping,
|
|
||||||
IssuesOrdering,
|
|
||||||
Team,
|
|
||||||
ViewOptions
|
|
||||||
} from '@anticrm/tracker'
|
|
||||||
import { Button, IconDetails } from '@anticrm/ui'
|
import { Button, IconDetails } from '@anticrm/ui'
|
||||||
import view, { Viewlet } from '@anticrm/view'
|
import view, { Viewlet } from '@anticrm/view'
|
||||||
import { FilterBar } from '@anticrm/view-resources'
|
import { FilterBar } from '@anticrm/view-resources'
|
||||||
@ -26,13 +19,6 @@
|
|||||||
export let panelWidth: number = 0
|
export let panelWidth: number = 0
|
||||||
|
|
||||||
let viewlet: WithLookup<Viewlet> | undefined = undefined
|
let viewlet: WithLookup<Viewlet> | undefined = undefined
|
||||||
let viewOptions: ViewOptions = {
|
|
||||||
groupBy: IssuesGrouping.Status,
|
|
||||||
orderBy: IssuesOrdering.Status,
|
|
||||||
completedIssuesPeriod: IssuesDateModificationPeriod.All,
|
|
||||||
shouldShowEmptyGroups: false,
|
|
||||||
shouldShowSubIssues: false
|
|
||||||
}
|
|
||||||
let resultQuery: DocumentQuery<Issue> = {}
|
let resultQuery: DocumentQuery<Issue> = {}
|
||||||
|
|
||||||
const client = getClient()
|
const client = getClient()
|
||||||
@ -77,7 +63,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if currentSpace}
|
{#if currentSpace}
|
||||||
<IssuesHeader {currentSpace} {viewlets} {label} bind:viewlet bind:viewOptions>
|
<IssuesHeader {viewlets} {label} bind:viewlet>
|
||||||
<svelte:fragment slot="extra">
|
<svelte:fragment slot="extra">
|
||||||
{#if asideFloat && $$slots.aside}
|
{#if asideFloat && $$slots.aside}
|
||||||
<Button
|
<Button
|
||||||
@ -95,7 +81,7 @@
|
|||||||
<FilterBar _class={tracker.class.Issue} {query} on:change={(e) => (resultQuery = e.detail)} />
|
<FilterBar _class={tracker.class.Issue} {query} on:change={(e) => (resultQuery = e.detail)} />
|
||||||
<div class="flex w-full h-full clear-mins">
|
<div class="flex w-full h-full clear-mins">
|
||||||
{#if viewlet}
|
{#if viewlet}
|
||||||
<IssuesContent {currentSpace} {viewlet} query={resultQuery} {viewOptions} />
|
<IssuesContent {currentSpace} {viewlet} query={resultQuery} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if $$slots.aside !== undefined && asideShown}
|
{#if $$slots.aside !== undefined && asideShown}
|
||||||
<div class="popupPanel-body__aside" class:float={asideFloat} class:shown={asideShown}>
|
<div class="popupPanel-body__aside" class:float={asideFloat} class:shown={asideShown}>
|
||||||
|
@ -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 { IssuesDateModificationPeriod, IssuesGrouping, IssuesOrdering, ViewOptions } from '@anticrm/tracker'
|
||||||
|
import { Button, eventToHTMLElement, IconDownOutline, showPopup } from '@anticrm/ui'
|
||||||
|
import { getViewOptions, setViewOptions } from '@anticrm/view-resources'
|
||||||
|
import view, { Viewlet } from '@anticrm/view'
|
||||||
|
|
||||||
|
import ViewOptionsPopup from './ViewOptionsPopup.svelte'
|
||||||
|
import { viewOptionsStore } from '../../viewOptions'
|
||||||
|
|
||||||
|
export let viewlet: Viewlet | undefined
|
||||||
|
|
||||||
|
let viewOptions: ViewOptions
|
||||||
|
$: if (viewlet) {
|
||||||
|
const savedViewOptions = getViewOptions(viewlet._id)
|
||||||
|
viewOptions = savedViewOptions
|
||||||
|
? JSON.parse(savedViewOptions)
|
||||||
|
: {
|
||||||
|
groupBy: IssuesGrouping.Status,
|
||||||
|
orderBy: IssuesOrdering.Status,
|
||||||
|
completedIssuesPeriod: IssuesDateModificationPeriod.All,
|
||||||
|
shouldShowEmptyGroups: false,
|
||||||
|
shouldShowSubIssues: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: $viewOptionsStore = viewOptions
|
||||||
|
|
||||||
|
const handleOptionsEditorOpened = (event: MouseEvent) => {
|
||||||
|
showPopup(ViewOptionsPopup, viewOptions, eventToHTMLElement(event), undefined, (result) => {
|
||||||
|
viewOptions = result
|
||||||
|
if (viewlet) setViewOptions(viewlet._id, JSON.stringify(viewOptions))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={view.icon.ViewButton}
|
||||||
|
kind={'secondary'}
|
||||||
|
size={'small'}
|
||||||
|
showTooltip={{ label: view.string.CustomizeView }}
|
||||||
|
on:click={handleOptionsEditorOpened}
|
||||||
|
>
|
||||||
|
<svelte:fragment slot="content">
|
||||||
|
<div class="flex-row-center clear-mins pointer-events-none">
|
||||||
|
<span class="text-sm font-medium">View</span>
|
||||||
|
<div class="icon"><IconDownOutline size={'full'} /></div>
|
||||||
|
</div>
|
||||||
|
</svelte:fragment>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.icon {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
width: 0.875rem;
|
||||||
|
height: 0.875rem;
|
||||||
|
color: var(--content-color);
|
||||||
|
}
|
||||||
|
</style>
|
@ -1,42 +0,0 @@
|
|||||||
<!--
|
|
||||||
// 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 { Button, IconDownOutline } from '@anticrm/ui'
|
|
||||||
import view from '@anticrm/view'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
icon={view.icon.ViewButton}
|
|
||||||
kind={'secondary'}
|
|
||||||
size={'small'}
|
|
||||||
showTooltip={{ label: view.string.CustomizeView }}
|
|
||||||
on:click
|
|
||||||
>
|
|
||||||
<svelte:fragment slot="content">
|
|
||||||
<div class="flex-row-center clear-mins pointer-events-none">
|
|
||||||
<span class="text-sm font-medium">View</span>
|
|
||||||
<div class="icon"><IconDownOutline size={'full'} /></div>
|
|
||||||
</div>
|
|
||||||
</svelte:fragment>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.icon {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
width: 0.875rem;
|
|
||||||
height: 0.875rem;
|
|
||||||
color: var(--content-color);
|
|
||||||
}
|
|
||||||
</style>
|
|
@ -26,7 +26,6 @@ import EditIssue from './components/issues/edit/EditIssue.svelte'
|
|||||||
import IssueItem from './components/issues/IssueItem.svelte'
|
import IssueItem from './components/issues/IssueItem.svelte'
|
||||||
import IssuePresenter from './components/issues/IssuePresenter.svelte'
|
import IssuePresenter from './components/issues/IssuePresenter.svelte'
|
||||||
import IssuePreview from './components/issues/IssuePreview.svelte'
|
import IssuePreview from './components/issues/IssuePreview.svelte'
|
||||||
import Issues from './components/issues/Issues.svelte'
|
|
||||||
import IssuesView from './components/issues/IssuesView.svelte'
|
import IssuesView from './components/issues/IssuesView.svelte'
|
||||||
import ListView from './components/issues/ListView.svelte'
|
import ListView from './components/issues/ListView.svelte'
|
||||||
import ModificationDatePresenter from './components/issues/ModificationDatePresenter.svelte'
|
import ModificationDatePresenter from './components/issues/ModificationDatePresenter.svelte'
|
||||||
@ -111,7 +110,6 @@ export default async (): Promise<Resources> => ({
|
|||||||
Active,
|
Active,
|
||||||
Backlog,
|
Backlog,
|
||||||
Inbox,
|
Inbox,
|
||||||
Issues,
|
|
||||||
MyIssues,
|
MyIssues,
|
||||||
Projects,
|
Projects,
|
||||||
Views,
|
Views,
|
||||||
|
@ -171,7 +171,6 @@ export default mergeIds(trackerId, tracker, {
|
|||||||
NopeComponent: '' as AnyComponent,
|
NopeComponent: '' as AnyComponent,
|
||||||
Inbox: '' as AnyComponent,
|
Inbox: '' as AnyComponent,
|
||||||
MyIssues: '' as AnyComponent,
|
MyIssues: '' as AnyComponent,
|
||||||
Issues: '' as AnyComponent,
|
|
||||||
Views: '' as AnyComponent,
|
Views: '' as AnyComponent,
|
||||||
Active: '' as AnyComponent,
|
Active: '' as AnyComponent,
|
||||||
Backlog: '' as AnyComponent,
|
Backlog: '' as AnyComponent,
|
||||||
|
7
plugins/tracker-resources/src/viewOptions.ts
Normal file
7
plugins/tracker-resources/src/viewOptions.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { ViewOptions } from '@anticrm/tracker'
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
export const viewOptionsStore = writable<ViewOptions>()
|
@ -82,7 +82,9 @@ export {
|
|||||||
getObjectPresenter,
|
getObjectPresenter,
|
||||||
LoadingProps,
|
LoadingProps,
|
||||||
setActiveViewletId,
|
setActiveViewletId,
|
||||||
getActiveViewletId
|
getActiveViewletId,
|
||||||
|
setViewOptions,
|
||||||
|
getViewOptions
|
||||||
} from './utils'
|
} from './utils'
|
||||||
export {
|
export {
|
||||||
HTMLPresenter,
|
HTMLPresenter,
|
||||||
|
@ -422,3 +422,24 @@ export function getActiveViewletId (): Ref<Viewlet> | null {
|
|||||||
const key = makeViewletKey()
|
const key = makeViewletKey()
|
||||||
return localStorage.getItem(key) as Ref<Viewlet> | null
|
return localStorage.getItem(key) as Ref<Viewlet> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeViewOptionsKey (viewletId: Ref<Viewlet>): string {
|
||||||
|
const loc = getCurrentLocation()
|
||||||
|
loc.fragment = undefined
|
||||||
|
loc.query = undefined
|
||||||
|
return `viewOptions:${viewletId}:${locationToUrl(loc)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setViewOptions (viewletId: Ref<Viewlet>, options: string | null): void {
|
||||||
|
const key = makeViewOptionsKey(viewletId)
|
||||||
|
if (options !== null) {
|
||||||
|
localStorage.setItem(key, options)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getViewOptions (viewletId: Ref<Viewlet>): string | null {
|
||||||
|
const key = makeViewOptionsKey(viewletId)
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
}
|
||||||
|
@ -157,17 +157,24 @@ test('issues-status-display', async ({ page }) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('save-active-viewlet', async ({ page }) => {
|
test('save-view-options', async ({ page }) => {
|
||||||
const panels = ['Issues', 'Active', 'Backlog']
|
const panels = ['Issues', 'Active', 'Backlog']
|
||||||
await navigate(page)
|
await navigate(page)
|
||||||
for (const viewletSelector of [viewletSelectors.Board, viewletSelectors.Table]) {
|
for (const viewletSelector of [viewletSelectors.Board, viewletSelectors.Table]) {
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
await page.click(`text="${panel}"`)
|
await page.click(`text="${panel}"`)
|
||||||
await page.click(viewletSelector)
|
await page.click(viewletSelector)
|
||||||
|
await page.click('button:has-text("View")')
|
||||||
|
await page.click('.antiCard >> button >> nth=0')
|
||||||
|
await page.click('.menu-item:has-text("Assignee")')
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
}
|
}
|
||||||
for (const panel of panels) {
|
for (const panel of panels) {
|
||||||
await page.click(`text="${panel}"`)
|
await page.click(`text="${panel}"`)
|
||||||
await expect(page.locator(viewletSelector)).toHaveClass(/selected/)
|
await expect(page.locator(viewletSelector)).toHaveClass(/selected/)
|
||||||
|
await page.click('button:has-text("View")')
|
||||||
|
await expect(page.locator('.antiCard >> button >> nth=0')).toContainText('Assignee')
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
Loading…
Reference in New Issue
Block a user