mirror of
https://github.com/hcengineering/platform.git
synced 2024-11-22 21:50:34 +03:00
Tracker: Add keyboard support for issues list (#1539)
Signed-off-by: Artyom Grigorovich <grigorovichartyom@gmail.com>
This commit is contained in:
parent
ff3b12f282
commit
38a8940a8b
@ -241,7 +241,7 @@ export function createModel (builder: Builder): void {
|
||||
|
||||
createAction(builder, view.action.Delete, view.string.Delete, view.actionImpl.Delete, {
|
||||
icon: view.icon.Delete,
|
||||
keyBinding: ['Meta + Backspace']
|
||||
keyBinding: ['Meta + Backspace', 'Ctrl + Backspace']
|
||||
})
|
||||
actionTarget(builder, view.action.Delete, core.class.Doc, { mode: ['context', 'browser'], group: 'tools' })
|
||||
|
||||
@ -280,7 +280,7 @@ export function createModel (builder: Builder): void {
|
||||
actionTarget(builder, view.action.SelectItem, core.class.Doc, { mode: 'browser' })
|
||||
|
||||
createAction(builder, view.action.SelectItemAll, view.string.SelectItemAll, view.actionImpl.SelectItemAll, {
|
||||
keyBinding: ['meta + keyA']
|
||||
keyBinding: ['meta + keyA', 'ctrl + keyA']
|
||||
})
|
||||
actionTarget(builder, view.action.SelectItemAll, core.class.Doc, { mode: 'browser' })
|
||||
|
||||
@ -290,7 +290,7 @@ export function createModel (builder: Builder): void {
|
||||
actionTarget(builder, view.action.SelectItemNone, core.class.Doc, { mode: 'browser' })
|
||||
|
||||
createAction(builder, view.action.ShowActions, view.string.ShowActions, view.actionImpl.ShowActions, {
|
||||
keyBinding: ['meta + keyk']
|
||||
keyBinding: ['meta + keyK', 'ctrl + keyK']
|
||||
})
|
||||
actionTarget(builder, view.action.ShowActions, core.class.Doc, {
|
||||
mode: ['workbench', 'browser', 'popup', 'panel', 'editor']
|
||||
|
@ -24,6 +24,7 @@
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let value: WithLookup<Issue>
|
||||
export let employees: (WithLookup<Employee> | undefined)[] = []
|
||||
export let currentSpace: Ref<Team> | undefined = undefined
|
||||
export let isEditable: boolean = true
|
||||
export let shouldShowLabel: boolean = false
|
||||
@ -32,14 +33,12 @@
|
||||
const client = getClient()
|
||||
|
||||
let defaultNameString: string = ''
|
||||
let assignee: Employee | undefined = undefined
|
||||
|
||||
$: employee = (value?.$lookup?.assignee ?? assignee) as Employee | undefined
|
||||
$: employee = (value?.$lookup?.assignee ?? employees.find(x => x?._id === value?.assignee)) as Employee | undefined
|
||||
$: avatar = employee?.avatar
|
||||
$: formattedName = employee?.name ? formatName(employee.name) : defaultNameString
|
||||
$: label = employee ? tracker.string.AssignedTo : tracker.string.AssignTo
|
||||
|
||||
$: findEmployeeById(value.assignee)
|
||||
$: getDefaultNameString = async () => {
|
||||
if (!defaultName) {
|
||||
return
|
||||
@ -58,20 +57,6 @@
|
||||
getDefaultNameString()
|
||||
})
|
||||
|
||||
const findEmployeeById = async (id: Ref<Employee> | null) => {
|
||||
if (!id) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const current = await client.findOne(contact.class.Employee, { _id: id })
|
||||
|
||||
if (current === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
assignee = current
|
||||
}
|
||||
|
||||
const handleAssigneeChanged = async (result: Employee | null | undefined) => {
|
||||
if (!isEditable || result === undefined) {
|
||||
return
|
||||
|
@ -1,116 +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 from '@anticrm/contact'
|
||||
import { DocumentQuery, FindOptions, Ref, WithLookup } from '@anticrm/core'
|
||||
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
|
||||
import { Component, Button, eventToHTMLElement, IconAdd, Scroller, showPopup, Tooltip } from '@anticrm/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import tracker from '../../plugin'
|
||||
import { IssuesGroupByKeys, IssuesOrderByKeys, issuesGroupPresenterMap, issuesSortOrderMap } from '../../utils'
|
||||
import CreateIssue from '../CreateIssue.svelte'
|
||||
import IssuesList from './IssuesList.svelte'
|
||||
|
||||
export let query: DocumentQuery<Issue>
|
||||
export let groupBy: { key: IssuesGroupByKeys | undefined; group: Issue[IssuesGroupByKeys] | undefined }
|
||||
export let orderBy: IssuesOrderByKeys
|
||||
export let statuses: WithLookup<IssueStatus>[]
|
||||
export let currentSpace: Ref<Team> | undefined = undefined
|
||||
export let currentTeam: Team
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const options: FindOptions<Issue> = {
|
||||
lookup: {
|
||||
assignee: contact.class.Employee,
|
||||
status: tracker.class.IssueStatus
|
||||
}
|
||||
}
|
||||
|
||||
let issuesAmount = 0
|
||||
|
||||
$: grouping = groupBy.key !== undefined && groupBy.group !== undefined ? { [groupBy.key]: groupBy.group } : {}
|
||||
$: headerComponent = groupBy.key !== undefined ? issuesGroupPresenterMap[groupBy.key] : null
|
||||
|
||||
const handleNewIssueAdded = (event: MouseEvent) => {
|
||||
if (!currentSpace) {
|
||||
return
|
||||
}
|
||||
|
||||
showPopup(CreateIssue, { space: currentSpace, ...grouping }, eventToHTMLElement(event))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="category">
|
||||
{#if headerComponent}
|
||||
<div class="header categoryHeader flex-between label">
|
||||
<div class="flex-row-center gap-2">
|
||||
<Component
|
||||
is={headerComponent}
|
||||
props={{
|
||||
isEditable: false,
|
||||
shouldShowLabel: true,
|
||||
value: grouping,
|
||||
defaultName: groupBy.key === 'assignee' ? tracker.string.NoAssignee : undefined,
|
||||
statuses: groupBy.key === 'status' ? statuses : undefined
|
||||
}}
|
||||
/>
|
||||
<span class="eLabelCounter ml-2">{issuesAmount}</span>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<Tooltip label={tracker.string.AddIssueTooltip} direction={'left'}>
|
||||
<Button icon={IconAdd} kind={'transparent'} on:click={handleNewIssueAdded} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<Scroller>
|
||||
<IssuesList
|
||||
_class={tracker.class.Issue}
|
||||
itemsConfig={[
|
||||
{ key: '', presenter: tracker.component.PriorityPresenter, props: { currentSpace } },
|
||||
{ key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } },
|
||||
{ key: '', presenter: tracker.component.StatusPresenter, 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: '', presenter: tracker.component.AssigneePresenter, props: { currentSpace } }
|
||||
]}
|
||||
options={{ ...options, sort: { [orderBy]: issuesSortOrderMap[orderBy] } }}
|
||||
query={{ ...query, ...grouping }}
|
||||
on:content={(evt) => {
|
||||
issuesAmount = evt.detail.length
|
||||
dispatch('content', issuesAmount)
|
||||
}}
|
||||
/>
|
||||
</Scroller>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.categoryHeader {
|
||||
height: 2.5rem;
|
||||
background-color: var(--theme-table-bg-hover);
|
||||
padding-left: 2.25rem;
|
||||
padding-right: 1.35rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: var(--theme-caption-color);
|
||||
.eLabelCounter {
|
||||
opacity: 0.8;
|
||||
font-weight: initial;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -13,8 +13,8 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import contact from '@anticrm/contact'
|
||||
import { DocumentQuery, Ref, SortingOrder, WithLookup } from '@anticrm/core'
|
||||
import contact, { Employee } from '@anticrm/contact'
|
||||
import { DocumentQuery, FindOptions, Ref, SortingOrder, WithLookup } from '@anticrm/core'
|
||||
import { createQuery } from '@anticrm/presentation'
|
||||
import {
|
||||
Issue,
|
||||
@ -23,10 +23,10 @@
|
||||
IssuesOrdering,
|
||||
IssuesDateModificationPeriod,
|
||||
IssueStatus,
|
||||
IssueStatusCategory
|
||||
IssueStatusCategory,
|
||||
IssuePriority
|
||||
} from '@anticrm/tracker'
|
||||
import { Button, Label, ScrollBox, IconOptions, showPopup, eventToHTMLElement } from '@anticrm/ui'
|
||||
import CategoryPresenter from './CategoryPresenter.svelte'
|
||||
import tracker from '../../plugin'
|
||||
import { IntlString } from '@anticrm/platform'
|
||||
import ViewOptionsPopup from './ViewOptionsPopup.svelte'
|
||||
@ -34,9 +34,11 @@
|
||||
IssuesGroupByKeys,
|
||||
issuesGroupKeyMap,
|
||||
issuesOrderKeyMap,
|
||||
defaultIssueCategories,
|
||||
getIssuesModificationDatePeriodTime
|
||||
getIssuesModificationDatePeriodTime,
|
||||
groupBy,
|
||||
issuesSortOrderMap
|
||||
} from '../../utils'
|
||||
import IssuesListBrowser from './IssuesListBrowser.svelte'
|
||||
|
||||
export let currentSpace: Ref<Team>
|
||||
export let title: IntlString = tracker.string.AllIssues
|
||||
@ -51,13 +53,22 @@
|
||||
const ENTRIES_LIMIT = 200
|
||||
const spaceQuery = createQuery()
|
||||
const issuesQuery = createQuery()
|
||||
const resultIssuesQuery = createQuery()
|
||||
const statusesQuery = createQuery()
|
||||
const issuesMap: { [status: string]: number } = {}
|
||||
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 = getTotalIssues(issuesMap)
|
||||
$: totalIssues = issues.length
|
||||
|
||||
const options: FindOptions<Issue> = {
|
||||
sort: { [issuesOrderKeyMap[orderingKey]]: issuesSortOrderMap[issuesOrderKeyMap[orderingKey]] },
|
||||
limit: ENTRIES_LIMIT,
|
||||
lookup: { assignee: contact.class.Employee, status: tracker.class.IssueStatus }
|
||||
}
|
||||
|
||||
$: baseQuery = {
|
||||
space: currentSpace,
|
||||
@ -73,7 +84,8 @@
|
||||
})
|
||||
|
||||
$: groupByKey = issuesGroupKeyMap[groupingKey]
|
||||
$: categories = getCategories(groupByKey, issues, !!shouldShowEmptyGroups)
|
||||
$: categories = getCategories(groupByKey, resultIssues, !!shouldShowEmptyGroups)
|
||||
$: groupedIssues = getGroupedIssues(groupByKey, resultIssues, categories)
|
||||
$: displayedCategories = (categories as any[]).filter((x) => {
|
||||
if (groupByKey === undefined || includedGroups[groupByKey] === undefined) {
|
||||
return true
|
||||
@ -134,8 +146,21 @@
|
||||
{ ...includedIssuesQuery },
|
||||
(result) => {
|
||||
issues = result
|
||||
|
||||
employees = result.map((x) => x.$lookup?.assignee)
|
||||
},
|
||||
{ limit: ENTRIES_LIMIT, lookup: { assignee: contact.class.Employee } }
|
||||
options
|
||||
)
|
||||
|
||||
$: resultIssuesQuery.query<Issue>(
|
||||
tracker.class.Issue,
|
||||
{ ...resultQuery },
|
||||
(result) => {
|
||||
resultIssues = result
|
||||
|
||||
employees = result.map((x) => x.$lookup?.assignee)
|
||||
},
|
||||
options
|
||||
)
|
||||
|
||||
$: statusesQuery.query(
|
||||
@ -150,11 +175,40 @@
|
||||
}
|
||||
)
|
||||
|
||||
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 i1 = orderedCategories.findIndex((x) => x === o1)
|
||||
const i2 = orderedCategories.findIndex((x) => x === o2)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
const defaultPriorities = [
|
||||
IssuePriority.NoPriority,
|
||||
IssuePriority.Urgent,
|
||||
IssuePriority.High,
|
||||
IssuePriority.Medium,
|
||||
IssuePriority.Low
|
||||
]
|
||||
const defaultStatuses = Object.values(statuses).map((x) => x._id)
|
||||
|
||||
const existingCategories = Array.from(
|
||||
new Set(
|
||||
elements.map((x) => {
|
||||
@ -163,7 +217,35 @@
|
||||
)
|
||||
)
|
||||
|
||||
return shouldShowAll ? defaultIssueCategories[key] ?? existingCategories : existingCategories
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
return existingCategories
|
||||
}
|
||||
|
||||
function filterIssueStatuses (
|
||||
@ -175,16 +257,6 @@
|
||||
return issueStatuses.filter((status) => statusCategories.has(status.category)).map((s) => s._id)
|
||||
}
|
||||
|
||||
const getTotalIssues = (map: { [status: string]: number }) => {
|
||||
let total = 0
|
||||
|
||||
for (const amount of Object.values(map)) {
|
||||
total += amount
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
const handleOptionsUpdated = (
|
||||
result:
|
||||
| {
|
||||
@ -235,19 +307,25 @@
|
||||
<Button icon={IconOptions} kind={'link'} on:click={handleOptionsEditorOpened} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
{#each displayedCategories as category}
|
||||
<CategoryPresenter
|
||||
groupBy={{ key: groupByKey, group: category }}
|
||||
orderBy={issuesOrderKeyMap[orderingKey]}
|
||||
query={resultQuery}
|
||||
{statuses}
|
||||
{currentSpace}
|
||||
{currentTeam}
|
||||
on:content={(event) => {
|
||||
issuesMap[category] = event.detail
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
<IssuesListBrowser
|
||||
_class={tracker.class.Issue}
|
||||
{currentSpace}
|
||||
{groupByKey}
|
||||
orderBy={issuesOrderKeyMap[orderingKey]}
|
||||
{statuses}
|
||||
{employees}
|
||||
categories={displayedCategories}
|
||||
itemsConfig={[
|
||||
{ key: '', presenter: tracker.component.PriorityPresenter, props: { currentSpace } },
|
||||
{ key: '', presenter: tracker.component.IssuePresenter, props: { currentTeam } },
|
||||
{ key: '', presenter: tracker.component.StatusPresenter, 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: '', presenter: tracker.component.AssigneePresenter, props: { currentSpace } }
|
||||
]}
|
||||
{groupedIssues}
|
||||
/>
|
||||
</div>
|
||||
</ScrollBox>
|
||||
{/if}
|
||||
|
@ -13,75 +13,126 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Class, Doc, DocumentQuery, FindOptions, Ref, getObjectValue } from '@anticrm/core'
|
||||
import { createQuery, getClient } from '@anticrm/presentation'
|
||||
import { CheckBox, Loading, showPopup, Spinner, IconMoreV, Tooltip } from '@anticrm/ui'
|
||||
import { Class, Doc, FindOptions, Ref, getObjectValue, WithLookup } from '@anticrm/core'
|
||||
import contact, { Employee } from '@anticrm/contact'
|
||||
import { getClient } from '@anticrm/presentation'
|
||||
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
|
||||
import {
|
||||
CheckBox,
|
||||
showPopup,
|
||||
Spinner,
|
||||
IconMoreV,
|
||||
Tooltip,
|
||||
Component,
|
||||
Button,
|
||||
IconAdd,
|
||||
eventToHTMLElement
|
||||
} from '@anticrm/ui'
|
||||
import { BuildModelKey } from '@anticrm/view'
|
||||
import { buildModel, LoadingProps, Menu, SelectDirection } from '@anticrm/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { buildModel, LoadingProps, Menu } from '@anticrm/view-resources'
|
||||
import CreateIssue from '../CreateIssue.svelte'
|
||||
import tracker from '../../plugin'
|
||||
import { IssuesGroupByKeys, issuesGroupPresenterMap, IssuesOrderByKeys, issuesSortOrderMap } from '../../utils'
|
||||
|
||||
export let _class: Ref<Class<Doc>>
|
||||
export let currentSpace: Ref<Team> | undefined = undefined
|
||||
export let groupByKey: IssuesGroupByKeys | undefined = undefined
|
||||
export let orderBy: IssuesOrderByKeys
|
||||
export let statuses: WithLookup<IssueStatus>[]
|
||||
export let employees: (WithLookup<Employee> | undefined)[] = []
|
||||
export let categories: any[] = []
|
||||
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
|
||||
export let itemsConfig: (BuildModelKey | string)[]
|
||||
export let options: FindOptions<Doc> | undefined = undefined
|
||||
export let query: DocumentQuery<Doc>
|
||||
|
||||
// If defined, will show a number of dummy items before real data will appear.
|
||||
export let selectedObjectIds: Doc[] = []
|
||||
export let selectedRowIndex: number | undefined = undefined
|
||||
export let groupedIssues: { [key: string | number | symbol]: Issue[] } = {}
|
||||
export let loadingProps: LoadingProps | undefined = undefined
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const DOCS_MAX_AMOUNT = 200
|
||||
const liveQuery = createQuery()
|
||||
const client = getClient()
|
||||
const objectRefs: HTMLElement[] = []
|
||||
const baseOptions: FindOptions<Issue> = {
|
||||
lookup: {
|
||||
assignee: contact.class.Employee,
|
||||
status: tracker.class.IssueStatus
|
||||
}
|
||||
}
|
||||
|
||||
let selectedIssueIds = new Set<Ref<Doc>>()
|
||||
let selectedRowIndex: number | undefined
|
||||
let isLoading = false
|
||||
let docObjects: Doc[] | undefined
|
||||
let queryIndex = 0
|
||||
$: combinedGroupedIssues = Object.values(groupedIssues).flat(1)
|
||||
$: options = { ...baseOptions, sort: { [orderBy]: issuesSortOrderMap[orderBy] } } as FindOptions<Issue>
|
||||
$: headerComponent = groupByKey === undefined ? null : issuesGroupPresenterMap[groupByKey]
|
||||
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
|
||||
$: objectRefs.length = combinedGroupedIssues.length
|
||||
|
||||
const updateData = async (_class: Ref<Class<Doc>>, query: DocumentQuery<Doc>, options?: FindOptions<Doc>) => {
|
||||
const i = ++queryIndex
|
||||
const handleMenuOpened = async (event: MouseEvent, object: Doc, rowIndex: number) => {
|
||||
selectedRowIndex = rowIndex
|
||||
|
||||
isLoading = true
|
||||
if (!selectedObjectIdsSet.has(object._id)) {
|
||||
onObjectChecked(combinedGroupedIssues, false)
|
||||
|
||||
liveQuery.query(
|
||||
_class,
|
||||
query,
|
||||
(result) => {
|
||||
if (i !== queryIndex) {
|
||||
return // our data is invalid.
|
||||
}
|
||||
selectedObjectIds = []
|
||||
}
|
||||
|
||||
docObjects = result
|
||||
dispatch('content', docObjects)
|
||||
isLoading = false
|
||||
const items = selectedObjectIds.length > 0 ? selectedObjectIds : object
|
||||
|
||||
showPopup(
|
||||
Menu,
|
||||
{ object: items, baseMenuClass },
|
||||
{
|
||||
getBoundingClientRect: () => DOMRect.fromRect({ width: 1, height: 1, x: event.clientX, y: event.clientY })
|
||||
},
|
||||
{ ...options, limit: DOCS_MAX_AMOUNT }
|
||||
() => {
|
||||
selectedRowIndex = undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
$: updateData(_class, query, options)
|
||||
|
||||
const client = getClient()
|
||||
|
||||
const showMenu = async (event: MouseEvent, docObject: Doc, rowIndex: number) => {
|
||||
selectedRowIndex = rowIndex
|
||||
|
||||
showPopup(Menu, { object: docObject, baseMenuClass }, event.target as HTMLElement, () => {
|
||||
selectedRowIndex = undefined
|
||||
})
|
||||
export const onObjectChecked = (docs: Doc[], value: boolean) => {
|
||||
dispatch('check', { docs, value })
|
||||
}
|
||||
|
||||
const handleIssueSelected = (id: Ref<Doc>, event: CustomEvent<boolean>) => {
|
||||
if (event.detail) {
|
||||
selectedIssueIds.add(id)
|
||||
} else {
|
||||
selectedIssueIds.delete(id)
|
||||
const handleRowFocused = (object: Doc) => {
|
||||
dispatch('row-focus', object)
|
||||
}
|
||||
|
||||
export const onElementSelected = (offset: 1 | -1 | 0, docObject?: Doc, dir?: SelectDirection) => {
|
||||
let position =
|
||||
(docObject !== undefined ? combinedGroupedIssues.findIndex((x) => x._id === docObject?._id) : selectedRowIndex) ??
|
||||
-1
|
||||
|
||||
position += offset
|
||||
|
||||
if (position < 0) {
|
||||
position = 0
|
||||
}
|
||||
|
||||
selectedIssueIds = selectedIssueIds
|
||||
if (position >= combinedGroupedIssues.length) {
|
||||
position = combinedGroupedIssues.length - 1
|
||||
}
|
||||
|
||||
const objectRef = objectRefs[position]
|
||||
|
||||
selectedRowIndex = position
|
||||
|
||||
handleRowFocused(combinedGroupedIssues[position])
|
||||
|
||||
if (objectRef !== undefined) {
|
||||
objectRef.scrollIntoView({ behavior: 'auto', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewIssueAdded = (event: MouseEvent, category: any) => {
|
||||
if (!currentSpace) {
|
||||
return
|
||||
}
|
||||
|
||||
showPopup(
|
||||
CreateIssue,
|
||||
{ space: currentSpace, ...(groupByKey ? { [groupByKey]: category } : {}) },
|
||||
eventToHTMLElement(event)
|
||||
)
|
||||
}
|
||||
|
||||
const getLoadingElementsLength = (props: LoadingProps, options?: FindOptions<Doc>) => {
|
||||
@ -93,98 +144,148 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await buildModel({ client, _class, keys: itemsConfig, options })}
|
||||
{#if !isLoading}
|
||||
<Loading />
|
||||
{/if}
|
||||
{:then itemModels}
|
||||
<div class="listRoot">
|
||||
{#if docObjects}
|
||||
{#each docObjects as docObject, rowIndex (docObject._id)}
|
||||
<div
|
||||
class="listGrid"
|
||||
class:mListGridChecked={selectedIssueIds.has(docObject._id)}
|
||||
class:mListGridFixed={rowIndex === selectedRowIndex}
|
||||
>
|
||||
<div class="contentWrapper">
|
||||
{#each itemModels as attributeModel, attributeModelIndex}
|
||||
{#if attributeModelIndex === 0}
|
||||
<div class="gridElement">
|
||||
<Tooltip direction={'bottom'} label={tracker.string.SelectIssue}>
|
||||
<div class="eListGridCheckBox">
|
||||
<CheckBox
|
||||
checked={selectedIssueIds.has(docObject._id)}
|
||||
on:value={(event) => {
|
||||
handleIssueSelected(docObject._id, event)
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
{#each categories as category}
|
||||
{#if headerComponent}
|
||||
<div class="header categoryHeader flex-between label">
|
||||
<div class="flex-row-center gap-2">
|
||||
<Component
|
||||
is={headerComponent}
|
||||
props={{
|
||||
isEditable: false,
|
||||
shouldShowLabel: true,
|
||||
value: groupByKey ? { [groupByKey]: category } : {},
|
||||
defaultName: groupByKey === 'assignee' ? tracker.string.NoAssignee : undefined,
|
||||
statuses: groupByKey === 'status' ? statuses : undefined,
|
||||
employees: groupByKey === 'assignee' ? employees : undefined
|
||||
}}
|
||||
/>
|
||||
<span class="eLabelCounter ml-2">{(groupedIssues[category] ?? []).length}</span>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<Tooltip label={tracker.string.AddIssueTooltip} direction={'left'}>
|
||||
<Button icon={IconAdd} kind={'transparent'} on:click={(event) => handleNewIssueAdded(event, category)} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#await buildModel({ client, _class, keys: itemsConfig, options }) then itemModels}
|
||||
<div class="listRoot">
|
||||
{#if groupedIssues[category]}
|
||||
{#each groupedIssues[category] as docObject (docObject._id)}
|
||||
<div
|
||||
bind:this={objectRefs[combinedGroupedIssues.findIndex((x) => x === docObject)]}
|
||||
class="listGrid"
|
||||
class:mListGridChecked={selectedObjectIdsSet.has(docObject._id)}
|
||||
class:mListGridFixed={selectedRowIndex === combinedGroupedIssues.findIndex((x) => x === docObject)}
|
||||
class:mListGridSelected={selectedRowIndex === combinedGroupedIssues.findIndex((x) => x === docObject)}
|
||||
on:contextmenu|preventDefault={(event) =>
|
||||
handleMenuOpened(
|
||||
event,
|
||||
docObject,
|
||||
combinedGroupedIssues.findIndex((x) => x === docObject)
|
||||
)}
|
||||
on:focus={() => {}}
|
||||
on:mouseover={() => handleRowFocused(docObject)}
|
||||
>
|
||||
<div class="contentWrapper">
|
||||
{#each itemModels as attributeModel, attributeModelIndex}
|
||||
{#if attributeModelIndex === 0}
|
||||
<div class="gridElement">
|
||||
<Tooltip direction={'bottom'} label={tracker.string.SelectIssue}>
|
||||
<div class="eListGridCheckBox">
|
||||
<CheckBox
|
||||
checked={selectedObjectIdsSet.has(docObject._id)}
|
||||
on:value={(event) => {
|
||||
onObjectChecked([docObject], event.detail)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="priorityPresenter">
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div class="priorityPresenter">
|
||||
{:else if attributeModelIndex === 1}
|
||||
<div class="issuePresenter">
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
<div
|
||||
id="context-menu"
|
||||
class="eIssuePresenterContextMenu"
|
||||
on:click={(event) =>
|
||||
handleMenuOpened(
|
||||
event,
|
||||
docObject,
|
||||
combinedGroupedIssues.findIndex((x) => x === docObject)
|
||||
)}
|
||||
>
|
||||
<IconMoreV size={'small'} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if attributeModelIndex === 3}
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if attributeModelIndex === 1}
|
||||
<div class="issuePresenter">
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
<div
|
||||
id="context-menu"
|
||||
class="eIssuePresenterContextMenu"
|
||||
on:click={(event) => showMenu(event, docObject, rowIndex)}
|
||||
>
|
||||
<IconMoreV size={'small'} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if attributeModelIndex === 3}
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
<div class="filler" />
|
||||
{:else}
|
||||
<div class="gridElement">
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if loadingProps !== undefined}
|
||||
{#each Array(getLoadingElementsLength(loadingProps, options)) as _, rowIndex}
|
||||
<div class="listGrid" class:fixed={rowIndex === selectedRowIndex}>
|
||||
<div class="contentWrapper">
|
||||
<div class="gridElement">
|
||||
<CheckBox checked={false} />
|
||||
<div class="ml-4">
|
||||
<Spinner size="small" />
|
||||
<div class="filler" />
|
||||
{:else}
|
||||
<div class="gridElement">
|
||||
<svelte:component
|
||||
this={attributeModel.presenter}
|
||||
value={getObjectValue(attributeModel.key, docObject) ?? ''}
|
||||
{...attributeModel.props}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
{#if isLoading}
|
||||
<Loading />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if loadingProps !== undefined}
|
||||
{#each Array(getLoadingElementsLength(loadingProps, options)) as _, rowIndex}
|
||||
<div class="listGrid" class:fixed={rowIndex === selectedRowIndex}>
|
||||
<div class="contentWrapper">
|
||||
<div class="gridElement">
|
||||
<CheckBox checked={false} />
|
||||
<div class="ml-4">
|
||||
<Spinner size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/await}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.categoryHeader {
|
||||
height: 2.5rem;
|
||||
background-color: var(--theme-table-bg-hover);
|
||||
padding-left: 2.25rem;
|
||||
padding-right: 1.35rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: var(--theme-caption-color);
|
||||
.eLabelCounter {
|
||||
opacity: 0.8;
|
||||
font-weight: initial;
|
||||
}
|
||||
}
|
||||
|
||||
.listRoot {
|
||||
width: 100%;
|
||||
}
|
||||
@ -217,6 +318,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.mListGridSelected {
|
||||
background-color: var(--theme-table-bg-hover);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-table-bg-hover);
|
||||
}
|
||||
|
@ -0,0 +1,86 @@
|
||||
<!--
|
||||
// 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 type { Class, Doc, Ref, WithLookup } from '@anticrm/core'
|
||||
import { BuildModelKey } from '@anticrm/view'
|
||||
import {
|
||||
ActionContext,
|
||||
focusStore,
|
||||
ListSelectionProvider,
|
||||
SelectDirection,
|
||||
selectionStore,
|
||||
LoadingProps
|
||||
} from '@anticrm/view-resources'
|
||||
import IssuesList from './IssuesList.svelte'
|
||||
import { Issue, IssueStatus, Team } from '@anticrm/tracker'
|
||||
import { Employee } from '@anticrm/contact'
|
||||
import { onMount } from 'svelte'
|
||||
import { IssuesGroupByKeys, IssuesOrderByKeys } from '../../utils'
|
||||
|
||||
export let _class: Ref<Class<Doc>>
|
||||
export let baseMenuClass: Ref<Class<Doc>> | undefined = undefined
|
||||
export let itemsConfig: (BuildModelKey | string)[]
|
||||
export let currentSpace: Ref<Team> | undefined = undefined
|
||||
export let groupByKey: IssuesGroupByKeys | undefined = undefined
|
||||
export let orderBy: IssuesOrderByKeys
|
||||
export let statuses: WithLookup<IssueStatus>[]
|
||||
export let employees: (WithLookup<Employee> | undefined)[] = []
|
||||
export let categories: any[] = []
|
||||
export let groupedIssues: { [key: string | number | symbol]: Issue[] } = {}
|
||||
export let loadingProps: LoadingProps | undefined = undefined
|
||||
|
||||
const listProvider = new ListSelectionProvider((offset: 1 | -1 | 0, of?: Doc, dir?: SelectDirection) => {
|
||||
if (dir === 'vertical') {
|
||||
issuesList.onElementSelected(offset, of, dir)
|
||||
}
|
||||
})
|
||||
|
||||
let issuesList: IssuesList
|
||||
|
||||
$: listProvider.update(Object.values(groupedIssues).flat(1))
|
||||
|
||||
onMount(() => {
|
||||
;(document.activeElement as HTMLElement)?.blur()
|
||||
})
|
||||
</script>
|
||||
|
||||
<ActionContext
|
||||
context={{
|
||||
mode: 'browser'
|
||||
}}
|
||||
/>
|
||||
|
||||
<IssuesList
|
||||
bind:this={issuesList}
|
||||
{_class}
|
||||
{baseMenuClass}
|
||||
{currentSpace}
|
||||
{groupByKey}
|
||||
{orderBy}
|
||||
{statuses}
|
||||
{employees}
|
||||
{categories}
|
||||
{itemsConfig}
|
||||
{groupedIssues}
|
||||
{loadingProps}
|
||||
selectedObjectIds={$selectionStore ?? []}
|
||||
selectedRowIndex={listProvider.current($focusStore)}
|
||||
on:row-focus={(event) => {
|
||||
listProvider.updateFocus(event.detail)
|
||||
}}
|
||||
on:check={(event) => {
|
||||
listProvider.updateSelection(event.detail.docs, event.detail.value)
|
||||
}}
|
||||
/>
|
@ -98,10 +98,6 @@ export const issuesGroupPresenterMap: Record<IssuesGroupByKeys, AnyComponent | u
|
||||
assignee: tracker.component.AssigneePresenter
|
||||
}
|
||||
|
||||
export const defaultIssueCategories: Partial<Record<IssuesGroupByKeys, Array<Issue[IssuesGroupByKeys]> | undefined>> = {
|
||||
priority: [IssuePriority.NoPriority, IssuePriority.Urgent, IssuePriority.High, IssuePriority.Medium, IssuePriority.Low]
|
||||
}
|
||||
|
||||
export const getIssuesModificationDatePeriodTime = (
|
||||
period: IssuesDateModificationPeriod | null
|
||||
): number => {
|
||||
@ -119,3 +115,15 @@ export const getIssuesModificationDatePeriodTime = (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const groupBy = (data: any, key: any): { [key: string]: any[] } => {
|
||||
return data.reduce((storage: { [key: string]: any[] }, item: any) => {
|
||||
const group = item[key]
|
||||
|
||||
storage[group] = storage[group] ?? []
|
||||
|
||||
storage[group].push(item)
|
||||
|
||||
return storage
|
||||
}, {})
|
||||
}
|
||||
|
@ -20,9 +20,9 @@
|
||||
addTxListener((tx) => {
|
||||
if (tx._class === core.class.TxRemoveDoc) {
|
||||
const docId = (tx as TxRemoveDoc<Doc>).objectId
|
||||
if ($selectionStore.find(it => it._id === docId) !== undefined) {
|
||||
if ($selectionStore.find((it) => it._id === docId) !== undefined) {
|
||||
selectionStore.update((old) => {
|
||||
return old.filter(it => it._id !== docId)
|
||||
return old.filter((it) => it._id !== docId)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,11 @@
|
||||
|
||||
let lastKey: KeyboardEvent | undefined
|
||||
|
||||
async function updateActions (context: ViewContext, selectionClass: Ref<Class<Doc>> | undefined, multiSelection: boolean): Promise<void> {
|
||||
async function updateActions (
|
||||
context: ViewContext,
|
||||
selectionClass: Ref<Class<Doc>> | undefined,
|
||||
multiSelection: boolean
|
||||
): Promise<void> {
|
||||
const t = ++q
|
||||
const r = await getContextActions(client, selectionClass ?? core.class.Doc, context, multiSelection)
|
||||
if (t === q) {
|
||||
@ -41,8 +45,20 @@
|
||||
$: if (ctx !== undefined) updateActions(ctx, selectionClass, $selectionStore.length > 1)
|
||||
|
||||
function matchKey (key: KeyboardEvent, pattern: string): boolean {
|
||||
const fp = ((key.altKey ? 'Alt + ' : '') + (key.shiftKey ? 'Shift + ' : '') + (key.metaKey ? 'Meta + ' : '') + key.key).toLowerCase()
|
||||
const fp2 = ((key.altKey ? 'Alt + ' : '') + (key.shiftKey ? 'Shift + ' : '') + (key.metaKey ? 'Meta + ' : '') + key.code).toLowerCase()
|
||||
const fp = (
|
||||
(key.altKey ? 'Alt + ' : '') +
|
||||
(key.shiftKey ? 'Shift + ' : '') +
|
||||
(key.metaKey ? 'Meta + ' : '') +
|
||||
(key.ctrlKey ? 'Ctrl + ' : '') +
|
||||
key.key
|
||||
).toLowerCase()
|
||||
const fp2 = (
|
||||
(key.altKey ? 'Alt + ' : '') +
|
||||
(key.shiftKey ? 'Shift + ' : '') +
|
||||
(key.metaKey ? 'Meta + ' : '') +
|
||||
(key.ctrlKey ? 'Ctrl + ' : '') +
|
||||
key.code
|
||||
).toLowerCase()
|
||||
return fp === pattern.toLowerCase() || fp2 === pattern.toLowerCase()
|
||||
}
|
||||
|
||||
@ -55,7 +71,7 @@
|
||||
lastKey = evt
|
||||
for (const a of actions) {
|
||||
// TODO: Handle multiple keys here
|
||||
if (a.keyBinding?.find(it => matchKey(evt, it)) !== undefined) {
|
||||
if (a.keyBinding?.find((it) => matchKey(evt, it)) !== undefined) {
|
||||
const action = await getResource(a.action)
|
||||
if (action !== undefined) {
|
||||
action($focusStore.focus, evt)
|
||||
@ -68,15 +84,15 @@
|
||||
async function updatePreviewPresenter (doc?: Doc): Promise<void> {
|
||||
presenter = doc !== undefined ? await getObjectPreview(client, doc._class) : undefined
|
||||
}
|
||||
|
||||
|
||||
$: updatePreviewPresenter($previewDocument)
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeys} />
|
||||
|
||||
{#if $previewDocument !== undefined && presenter }
|
||||
{#if $previewDocument !== undefined && presenter}
|
||||
<div transition:fly|local style:position="fixed" style:right={'0'} style:top={'10rem'} style:z-index={'50000'}>
|
||||
<div class='antiPanel p-10'>
|
||||
<div class="antiPanel p-10">
|
||||
<Component is={presenter} props={{ object: $previewDocument }} />
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,7 +21,7 @@
|
||||
import { CheckBox, Component, IconDown, IconUp, Label, Loading, showPopup, Spinner } from '@anticrm/ui'
|
||||
import { BuildModelKey } from '@anticrm/view'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { SelectDirection } from '../selection'
|
||||
import { SelectDirection } from '../selection'
|
||||
import { buildModel, LoadingProps } from '../utils'
|
||||
import Menu from './Menu.svelte'
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user