mirror of
https://github.com/hcengineering/platform.git
synced 2025-01-03 08:57:14 +03: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)
|
||||
|
||||
Tracker:
|
||||
|
||||
- Remember view options
|
||||
-
|
||||
Chunter:
|
||||
|
||||
- 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 { Component } from '@anticrm/ui'
|
||||
import { BuildModelKey, Viewlet, ViewletPreference } from '@anticrm/view'
|
||||
import { Issue, IssueStatus, Team, ViewOptions } from '@anticrm/tracker'
|
||||
import tracker from '../../plugin'
|
||||
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
|
||||
import { createQuery } from '@anticrm/presentation'
|
||||
import { viewOptionsStore } from '../../viewOptions'
|
||||
import tracker from '../../plugin'
|
||||
|
||||
export let currentSpace: Ref<Team>
|
||||
export let viewlet: WithLookup<Viewlet>
|
||||
export let query: DocumentQuery<Issue> = {}
|
||||
export let viewOptions: ViewOptions
|
||||
|
||||
const statusesQuery = createQuery()
|
||||
const spaceQuery = createQuery()
|
||||
@ -62,14 +62,14 @@
|
||||
|
||||
{#if viewlet?.$lookup?.descriptor?.component}
|
||||
<Component
|
||||
is={viewlet.$lookup?.descriptor?.component}
|
||||
is={viewlet.$lookup.descriptor.component}
|
||||
props={{
|
||||
currentSpace,
|
||||
config: createConfig(viewlet, undefined),
|
||||
options: viewlet.options,
|
||||
viewlet,
|
||||
query,
|
||||
viewOptions
|
||||
viewOptions: $viewOptionsStore
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
@ -1,28 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Ref, WithLookup } from '@anticrm/core'
|
||||
import { Team, ViewOptions } from '@anticrm/tracker'
|
||||
import { Icon, TabList, showPopup, eventToHTMLElement } from '@anticrm/ui'
|
||||
import { Icon, TabList } from '@anticrm/ui'
|
||||
import { Viewlet } from '@anticrm/view'
|
||||
import { FilterButton, setActiveViewletId } from '@anticrm/view-resources'
|
||||
import tracker from '../../plugin'
|
||||
import ViewOptionsPopup from './ViewOptionsPopup.svelte'
|
||||
import ViewOptionsButton from './ViewOptionsButton.svelte'
|
||||
import { WithLookup } from '@anticrm/core'
|
||||
import ViewOptions from './ViewOptions.svelte'
|
||||
|
||||
export let currentSpace: Ref<Team>
|
||||
export let viewlet: WithLookup<Viewlet> | undefined
|
||||
export let viewlets: WithLookup<Viewlet>[] = []
|
||||
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) => {
|
||||
return {
|
||||
@ -54,6 +40,6 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<ViewOptionsButton on:click={handleOptionsEditorOpened} />
|
||||
<ViewOptions {viewlet} />
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
|
@ -2,14 +2,7 @@
|
||||
import core, { DocumentQuery, Ref, Space, WithLookup } from '@anticrm/core'
|
||||
import { IntlString, translate } from '@anticrm/platform'
|
||||
import { getClient } from '@anticrm/presentation'
|
||||
import {
|
||||
Issue,
|
||||
IssuesDateModificationPeriod,
|
||||
IssuesGrouping,
|
||||
IssuesOrdering,
|
||||
Team,
|
||||
ViewOptions
|
||||
} from '@anticrm/tracker'
|
||||
import { Issue, Team } from '@anticrm/tracker'
|
||||
import { Button, IconDetails } from '@anticrm/ui'
|
||||
import view, { Viewlet } from '@anticrm/view'
|
||||
import { FilterBar } from '@anticrm/view-resources'
|
||||
@ -26,13 +19,6 @@
|
||||
export let panelWidth: number = 0
|
||||
|
||||
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> = {}
|
||||
|
||||
const client = getClient()
|
||||
@ -77,7 +63,7 @@
|
||||
</script>
|
||||
|
||||
{#if currentSpace}
|
||||
<IssuesHeader {currentSpace} {viewlets} {label} bind:viewlet bind:viewOptions>
|
||||
<IssuesHeader {viewlets} {label} bind:viewlet>
|
||||
<svelte:fragment slot="extra">
|
||||
{#if asideFloat && $$slots.aside}
|
||||
<Button
|
||||
@ -95,7 +81,7 @@
|
||||
<FilterBar _class={tracker.class.Issue} {query} on:change={(e) => (resultQuery = e.detail)} />
|
||||
<div class="flex w-full h-full clear-mins">
|
||||
{#if viewlet}
|
||||
<IssuesContent {currentSpace} {viewlet} query={resultQuery} {viewOptions} />
|
||||
<IssuesContent {currentSpace} {viewlet} query={resultQuery} />
|
||||
{/if}
|
||||
{#if $$slots.aside !== undefined && 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 IssuePresenter from './components/issues/IssuePresenter.svelte'
|
||||
import IssuePreview from './components/issues/IssuePreview.svelte'
|
||||
import Issues from './components/issues/Issues.svelte'
|
||||
import IssuesView from './components/issues/IssuesView.svelte'
|
||||
import ListView from './components/issues/ListView.svelte'
|
||||
import ModificationDatePresenter from './components/issues/ModificationDatePresenter.svelte'
|
||||
@ -111,7 +110,6 @@ export default async (): Promise<Resources> => ({
|
||||
Active,
|
||||
Backlog,
|
||||
Inbox,
|
||||
Issues,
|
||||
MyIssues,
|
||||
Projects,
|
||||
Views,
|
||||
|
@ -171,7 +171,6 @@ export default mergeIds(trackerId, tracker, {
|
||||
NopeComponent: '' as AnyComponent,
|
||||
Inbox: '' as AnyComponent,
|
||||
MyIssues: '' as AnyComponent,
|
||||
Issues: '' as AnyComponent,
|
||||
Views: '' as AnyComponent,
|
||||
Active: '' 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,
|
||||
LoadingProps,
|
||||
setActiveViewletId,
|
||||
getActiveViewletId
|
||||
getActiveViewletId,
|
||||
setViewOptions,
|
||||
getViewOptions
|
||||
} from './utils'
|
||||
export {
|
||||
HTMLPresenter,
|
||||
|
@ -422,3 +422,24 @@ export function getActiveViewletId (): Ref<Viewlet> | null {
|
||||
const key = makeViewletKey()
|
||||
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']
|
||||
await navigate(page)
|
||||
for (const viewletSelector of [viewletSelectors.Board, viewletSelectors.Table]) {
|
||||
for (const panel of panels) {
|
||||
await page.click(`text="${panel}"`)
|
||||
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) {
|
||||
await page.click(`text="${panel}"`)
|
||||
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