TSK-360: Assignee selection enhancements (#2509)

Signed-off-by: Denis Maslennikov <denis.maslennikov@gmail.com>
This commit is contained in:
Denis Maslennikov 2023-01-19 14:49:13 +07:00 committed by GitHub
parent 44ae8f2e81
commit e9cc9e7b47
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 602 additions and 22 deletions

View File

@ -28,6 +28,12 @@
"Edit": "Edit",
"SelectAvatar": "Select avatar",
"GravatarsManaged": "Gravatars are managed through",
"CategoryCurrentUser": "Current user",
"Assigned": "Assigned",
"CategoryPreviousAssigned": "Previously assigned",
"CategoryProjectLead": "Project lead",
"CategoryProjectMembers": "Project members",
"CategoryOther": "Other",
"InltPropsValue": "{value}"
}
}

View File

@ -22,12 +22,18 @@
"NumberSpaces": "{count, plural, =0 {В} =1 {В 1 месте} other {В # местах}}",
"InThis": "В этом {space}",
"NoMatchesInThis": "В этом {space} совпадения не обнаружены",
"NoMatchesFound": "Не найдено соответсвий",
"NoMatchesFound": "Не найдено соответствий",
"NotInThis": "Не в этом {space}",
"Add": "Добавить",
"Edit": "Редактировать",
"SelectAvatar": "Выбрать аватар",
"GravatarsManaged": "Граватары управляются через",
"CategoryCurrentUser": "Текущий пользователь",
"Assigned": "Назначен",
"CategoryPreviousAssigned": "Ранее назначенные",
"CategoryProjectLead": "Руководитель проекта",
"CategoryProjectMembers": "Участники проекта",
"CategoryOther": "Прочие",
"InltPropsValue": "{value}"
}
}

View File

@ -0,0 +1,177 @@
<!--
// 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, { Contact, Employee, formatName } from '@hcengineering/contact'
import { Class, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
import { getEmbeddedLabel, IntlString } from '@hcengineering/platform'
import {
ActionIcon,
Button,
ButtonKind,
ButtonSize,
getEventPositionElement,
getFocusManager,
Icon,
IconOpen,
Label,
LabelAndProps,
showPanel,
showPopup,
tooltip
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import presentation, { IconPerson, UserInfo, getClient } from '..'
import AssigneePopup from './AssigneePopup.svelte'
export let _class: Ref<Class<Employee>> = contact.class.Employee
export let excluded: Ref<Contact>[] | undefined = undefined
export let options: FindOptions<Employee> | undefined = undefined
export let docQuery: DocumentQuery<Employee> | undefined = {
active: true
}
export let label: IntlString
export let placeholder: IntlString = presentation.string.Search
export let value: Ref<Employee> | null | undefined
export let prevAssigned: Ref<Employee>[] | undefined = []
export let projectLead: Ref<Employee> | undefined = undefined
export let projectMembers: Ref<Employee>[] | undefined = []
export let titleDeselect: IntlString | undefined = undefined
export let readonly = false
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = undefined
export let focusIndex = -1
export let showTooltip: LabelAndProps | undefined = undefined
export let showNavigate = true
export let id: string | undefined = undefined
const icon = IconPerson
const dispatch = createEventDispatcher()
let selected: Employee | undefined
let container: HTMLElement
const client = getClient()
async function updateSelected (value: Ref<Employee> | null | undefined) {
selected = value ? await client.findOne(_class, { _id: value }) : undefined
}
$: updateSelected(value)
function getName (obj: Contact): string {
const isPerson = client.getHierarchy().isDerived(obj._class, contact.class.Person)
return isPerson ? formatName(obj.name) : obj.name
}
const mgr = getFocusManager()
const _click = (ev: MouseEvent): void => {
if (!readonly) {
showPopup(
AssigneePopup,
{
_class,
options,
docQuery,
prevAssigned,
projectLead,
projectMembers,
ignoreUsers: excluded ?? [],
icon,
selected: value,
placeholder,
titleDeselect
},
!$$slots.content ? container : getEventPositionElement(ev),
(result) => {
if (result === null) {
value = null
selected = undefined
dispatch('change', null)
} else if (result !== undefined && result._id !== value) {
value = result._id
dispatch('change', value)
}
mgr?.setFocusPos(focusIndex)
}
)
}
}
$: hideIcon = size === 'x-large' || (size === 'large' && kind !== 'link')
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div {id} bind:this={container} class="min-w-0" class:w-full={width === '100%'} class:h-full={$$slots.content}>
{#if $$slots.content}
<div
class="w-full h-full flex-streatch"
on:click={_click}
use:tooltip={selected !== undefined ? { label: getEmbeddedLabel(getName(selected)) } : undefined}
>
<slot name="content" />
</div>
{:else}
<Button {focusIndex} width={width ?? 'min-content'} {size} {kind} {justify} {showTooltip} on:click={_click}>
<span
slot="content"
class="overflow-label flex-grow"
class:flex-between={showNavigate && selected}
class:dark-color={value == null}
>
<div
class="disabled"
style:width={showNavigate && selected
? `calc(${width ?? 'min-content'} - 1.5rem)`
: `${width ?? 'min-content'}`}
use:tooltip={selected !== undefined ? { label: getEmbeddedLabel(getName(selected)) } : undefined}
>
{#if selected}
{#if hideIcon || selected}
<UserInfo value={selected} size={kind === 'link' ? 'x-small' : 'tiny'} {icon} />
{:else}
{getName(selected)}
{/if}
{:else}
<div class="flex-presenter">
{#if icon}
<div class="icon" class:small-gap={size === 'inline' || size === 'small'}>
<Icon {icon} size={kind === 'link' ? 'small' : size} />
</div>
{/if}
<div class="label no-underline">
<Label {label} />
</div>
</div>
{/if}
</div>
{#if selected && showNavigate}
<ActionIcon
icon={IconOpen}
size={'small'}
action={() => {
if (selected) {
showPanel(view.component.EditDoc, selected._id, selected._class, 'content')
}
}}
/>
{/if}
</span>
</Button>
{/if}
</div>

View File

@ -0,0 +1,285 @@
<!--
// 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, { Contact, Employee, EmployeeAccount, Person } from '@hcengineering/contact'
import { Doc, DocumentQuery, FindOptions, getCurrentAccount, Ref } from '@hcengineering/core'
import type { Asset, IntlString } from '@hcengineering/platform'
import {
createFocusManager,
EditBox,
FocusHandler,
Icon,
IconCheck,
ListView,
resizeObserver,
AnySvelteComponent,
Label,
tooltip
} from '@hcengineering/ui'
import presentation, {
AssigneeCategory,
UserInfo,
assigneeCategoryOrder,
createQuery,
getCategorytitle,
getClient
} from '..'
import { createEventDispatcher } from 'svelte'
export let options: FindOptions<Contact> | undefined = undefined
export let selected: Ref<Person> | undefined
export let docQuery: DocumentQuery<Contact> | undefined = undefined
export let prevAssigned: Ref<Employee>[] | undefined = []
export let projectLead: Ref<Employee> | undefined = undefined
export let projectMembers: Ref<Employee>[] | undefined = []
export let titleDeselect: IntlString | undefined
export let placeholder: IntlString = presentation.string.Search
export let ignoreUsers: Ref<Person>[] = []
export let shadows: boolean = true
export let width: 'medium' | 'large' | 'full' = 'medium'
export let size: 'small' | 'medium' | 'large' = 'small'
export let searchField: string = 'name'
export let showCategories: boolean = true
export let icon: Asset | AnySvelteComponent | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const currentEmployee = (getCurrentAccount() as EmployeeAccount).employee
let search: string = ''
let objects: Contact[] = []
let contacts: Contact[] = []
let categorizedPersons: Map<Ref<Person>, AssigneeCategory>
const dispatch = createEventDispatcher()
const query = createQuery()
$: query.query<Contact>(
contact.class.Employee,
{
...(docQuery ?? {}),
[searchField]: { $like: '%' + search + '%' },
_id: { $nin: ignoreUsers }
},
(result) => {
objects = result
},
{ ...(options ?? {}), limit: 200, sort: { name: 1 } }
)
$: updateCategories(objects, currentEmployee, prevAssigned, projectLead, projectMembers)
function updateCategories (
objects: Contact[],
currentEmployee: Ref<Person>,
prevAssigned: Ref<Person>[] | undefined,
projectLead: Ref<Person> | undefined,
projectMembers: Ref<Person>[] | undefined
) {
const persons = new Map<Ref<Person>, AssigneeCategory>(objects.map((t) => [t._id, 'Other']))
if (projectLead) {
persons.set(projectLead, 'ProjectLead')
}
projectMembers?.forEach((p) => persons.set(p, 'ProjectMembers'))
prevAssigned?.forEach((p) => persons.set(p, 'PreviouslyAssigned'))
if (selected) {
persons.set(selected, 'Assigned')
}
persons.set(currentEmployee, 'CurrentUser')
categorizedPersons = new Map<Ref<Person>, AssigneeCategory>(
[...persons].sort((a, b) => assigneeCategoryOrder.indexOf(a[1]) - assigneeCategoryOrder.indexOf(b[1]))
)
contacts = []
categorizedPersons.forEach((p, k) => {
const c = objects.find((e) => e._id === k)
if (c) {
contacts.push(c)
}
})
}
let selection = 0
let list: ListView
async function handleSelection (evt: Event | undefined, selection: number): Promise<void> {
const person = contacts[selection]
selected = person._id === selected ? undefined : person._id
dispatch('close', selected !== undefined ? person : undefined)
}
function onKeydown (key: KeyboardEvent): void {
if (key.code === 'ArrowUp') {
key.stopPropagation()
key.preventDefault()
list.select(selection - 1)
}
if (key.code === 'ArrowDown') {
key.stopPropagation()
key.preventDefault()
list.select(selection + 1)
}
if (key.code === 'Enter') {
key.preventDefault()
key.stopPropagation()
handleSelection(key, selection)
}
if (key.code === 'Escape') {
key.preventDefault()
key.stopPropagation()
dispatch('close')
}
}
const manager = createFocusManager()
function toAny (obj: any): any {
return obj
}
let selectedDiv: HTMLElement | undefined
let scrollDiv: HTMLElement | undefined
let cHeight = 0
const updateLocation = (scrollDiv?: HTMLElement, selectedDiv?: HTMLElement, objects?: Doc[], selected?: Ref<Doc>) => {
const objIt = objects?.find((it) => it._id === selected)
if (objIt === undefined) {
cHeight = 0
return
}
if (scrollDiv && selectedDiv) {
const r = selectedDiv.getBoundingClientRect()
const r2 = scrollDiv.getBoundingClientRect()
if (r && r2) {
if (r.top > r2.top && r.bottom < r2.bottom) {
cHeight = 0
} else {
if (r.bottom < r2.bottom) {
cHeight = 1
} else {
cHeight = -1
}
}
}
}
}
$: updateLocation(scrollDiv, selectedDiv, contacts, selected)
</script>
<FocusHandler {manager} />
<div
class="selectPopup"
class:full-width={width === 'full'}
class:plainContainer={!shadows}
class:width-40={width === 'large'}
on:keydown={onKeydown}
use:resizeObserver={() => {
dispatch('changeContent')
}}
>
<div class="header flex-between">
<EditBox kind={'search-style'} focusIndex={1} focus bind:value={search} {placeholder} />
</div>
{#if cHeight === 1}
<div class="background-theme-content-accent" style:height={'1px'} />
{/if}
<div
class="scroll"
on:scroll={() => updateLocation(scrollDiv, selectedDiv, contacts, selected)}
bind:this={scrollDiv}
>
<div class="box">
<ListView bind:this={list} count={contacts.length} bind:selection>
<svelte:fragment slot="category" let:item>
{#if showCategories}
{@const obj = toAny(contacts[item])}
{@const category = categorizedPersons.get(obj._id)}
{@const cl = hierarchy.getClass(contacts[item]._class)}
{#if item === 0 || (item > 0 && categorizedPersons.get(toAny(contacts[item - 1])._id) !== categorizedPersons.get(obj._id))}
<!--Category for first item-->
<div class="category-box">
<div class="flex flex-grow overflow-label">
<span class="fs-medium flex-center gap-2 mt-2 mb-2 ml-2">
{#if cl.icon}
<Icon icon={cl.icon} size={'small'} />
{/if}
<Label label={getCategorytitle(category)} />
</span>
</div>
</div>
{/if}
{/if}
</svelte:fragment>
<svelte:fragment slot="item" let:item>
{@const obj = contacts[item]}
<button
class="menu-item w-full"
class:background-bg-focused={obj._id === selected}
class:border-radius-1={obj._id === selected}
on:click={() => {
handleSelection(undefined, item)
}}
>
{#if selected}
<div class="icon">
{#if obj._id === selected}
<div bind:this={selectedDiv}>
{#if titleDeselect}
<div class="clear-mins" use:tooltip={{ label: titleDeselect }}>
<Icon icon={IconCheck} {size} />
</div>
{:else}
<Icon icon={IconCheck} {size} />
{/if}
</div>
{/if}
</div>
{/if}
<span class="label">
{#if obj._id === selected}
<div bind:this={selectedDiv}>
<div class="flex flex-grow overflow-label">
<UserInfo size={'x-small'} value={obj} {icon} />
</div>
</div>
{:else}
<div class="flex flex-grow overflow-label">
<UserInfo size={'x-small'} value={obj} {icon} />
</div>
{/if}
</span>
</button>
</svelte:fragment>
</ListView>
</div>
</div>
{#if cHeight === -1}
<div class="background-theme-content-accent" style:height={'3px'} />
{/if}
</div>
<style lang="scss">
.plainContainer {
color: var(--caption-color);
background-color: var(--body-color);
border: 1px solid var(--button-border-color);
border-radius: 0.25rem;
box-shadow: none;
}
</style>

View File

@ -21,6 +21,7 @@ export { default as AttributeBarEditor } from './components/AttributeBarEditor.s
export { default as AttributeEditor } from './components/AttributeEditor.svelte'
export { default as AttributesBar } from './components/AttributesBar.svelte'
export { default as Avatar } from './components/Avatar.svelte'
export { default as AssigneeBox } from './components/AssigneeBox.svelte'
export { default as Card } from './components/Card.svelte'
export { default as CombineAvatars } from './components/CombineAvatars.svelte'
export { default as EditableAvatar } from './components/EditableAvatar.svelte'
@ -41,6 +42,7 @@ export { default as EmployeeBox } from './components/EmployeeBox.svelte'
export { default as UsersPopup } from './components/UsersPopup.svelte'
export { default as MembersBox } from './components/MembersBox.svelte'
export { default as IconMembers } from './components/icons/Members.svelte'
export { default as IconPerson } from './components/icons/Person.svelte'
export { default as IconMembersOutline } from './components/icons/MembersOutline.svelte'
export { default as ObjectSearchPopup } from './components/ObjectSearchPopup.svelte'
export { default as IndexedDocumentPreview } from './components/IndexedDocumentPreview.svelte'

View File

@ -57,6 +57,12 @@ export default plugin(presentationId, {
Edit: '' as IntlString,
SelectAvatar: '' as IntlString,
GravatarsManaged: '' as IntlString,
CategoryCurrentUser: '' as IntlString,
Assigned: '' as IntlString,
CategoryPreviousAssigned: '' as IntlString,
CategoryProjectLead: '' as IntlString,
CategoryProjectMembers: '' as IntlString,
CategoryOther: '' as IntlString,
InltPropsValue: '' as IntlString
},
metadata: {

View File

@ -34,12 +34,13 @@ import core, {
TxResult
} from '@hcengineering/core'
import login from '@hcengineering/login'
import { getMetadata } from '@hcengineering/platform'
import { getMetadata, IntlString } from '@hcengineering/platform'
import { LiveQuery as LQ } from '@hcengineering/query'
import { onDestroy } from 'svelte'
import { deepEqual } from 'fast-equals'
import { IconSize, DropdownIntlItem } from '@hcengineering/ui'
import contact, { AvatarType, AvatarProvider } from '@hcengineering/contact'
import presentation from '..'
let liveQuery: LQ
let client: TxOperations
@ -272,3 +273,43 @@ export function getAvatarProviderId (avatar?: string | null): Ref<AvatarProvider
return contact.avatarProvider.Color
}
}
/**
* @public
*/
export type AssigneeCategory =
| 'CurrentUser'
| 'Assigned'
| 'PreviouslyAssigned'
| 'ProjectLead'
| 'ProjectMembers'
| 'Other'
const assigneeCategoryTitleMap: Record<AssigneeCategory, IntlString> = Object.freeze({
CurrentUser: presentation.string.CategoryCurrentUser,
Assigned: presentation.string.Assigned,
PreviouslyAssigned: presentation.string.CategoryPreviousAssigned,
ProjectLead: presentation.string.CategoryProjectLead,
ProjectMembers: presentation.string.CategoryProjectMembers,
Other: presentation.string.CategoryOther
})
/**
* @public
*/
export const assigneeCategoryOrder: AssigneeCategory[] = [
'CurrentUser',
'Assigned',
'PreviouslyAssigned',
'ProjectLead',
'ProjectMembers',
'Other'
]
/**
* @public
*/
export function getCategorytitle (category: AssigneeCategory | undefined): IntlString {
const cat: AssigneeCategory = category ?? 'Other'
return assigneeCategoryTitleMap[cat]
}

View File

@ -15,11 +15,12 @@
<script lang="ts">
import { Employee } from '@hcengineering/contact'
import { AttachedData, Ref } from '@hcengineering/core'
import { EmployeeBox, getClient } from '@hcengineering/presentation'
import { AssigneeBox, getClient } from '@hcengineering/presentation'
import { Issue, IssueTemplateData } from '@hcengineering/tracker'
import { ButtonKind, ButtonSize, TooltipAlignment } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import { getPreviousAssignees } from '../../utils'
export let value: Issue | AttachedData<Issue> | IssueTemplateData
export let size: ButtonSize = 'large'
@ -30,6 +31,27 @@
const client = getClient()
const dispatch = createEventDispatcher()
let prevAssigned: Ref<Employee>[] = []
let projectLead: Ref<Employee> | undefined = undefined
let projectMembers: Ref<Employee>[] = []
$: getPreviousAssignees(value).then((res) => {
prevAssigned = res
})
async function updateProjectMembers (issue: Issue | AttachedData<Issue> | IssueTemplateData) {
if (issue.project) {
const project = await client.findOne(tracker.class.Project, { _id: issue.project })
projectLead = project?.lead || undefined
projectMembers = project?.members || []
} else {
projectLead = undefined
projectMembers = []
}
}
$: updateProjectMembers(value)
const handleAssigneeChanged = async (newAssignee: Ref<Employee> | undefined) => {
if (newAssignee === undefined || value.assignee === newAssignee) {
return
@ -44,11 +66,13 @@
</script>
{#if value}
<EmployeeBox
<AssigneeBox
label={tracker.string.Assignee}
placeholder={tracker.string.Assignee}
value={value.assignee}
allowDeselect
{prevAssigned}
{projectLead}
{projectMembers}
titleDeselect={tracker.string.Unassigned}
{size}
{kind}

View File

@ -15,7 +15,7 @@
<script lang="ts">
import contact from '@hcengineering/contact'
import { Ref } from '@hcengineering/core'
import { UserBox } from '@hcengineering/presentation'
import { AssigneeBox } from '@hcengineering/presentation'
import { Issue, Team } from '@hcengineering/tracker'
import { deviceOptionsStore as deviceInfo, getEventPositionElement, ListView, showPopup } from '@hcengineering/ui'
import { ContextMenu, FixedColumn, ListSelectionProvider } from '@hcengineering/view-resources'
@ -60,21 +60,19 @@
</span>
</div>
<div class="flex-row-center clear-mins gap-2 self-end" class:p-text={twoRows}>
<FixedColumn key={'estimation_issue_assignee'} justify={'right'}>
<UserBox
width={'100%'}
label={tracker.string.Assignee}
_class={contact.class.Employee}
value={issue.assignee}
readonly
showNavigate={false}
/>
</FixedColumn>
<FixedColumn key={'estimation'} justify={'left'}>
<EstimationEditor value={issue} kind={'list'} />
</FixedColumn>
</div>
<FixedColumn key={'estimation_issue_assignee'} justify={'right'}>
<AssigneeBox
width={'100%'}
label={tracker.string.Assignee}
_class={contact.class.Employee}
value={issue.assignee}
readonly
showNavigate={false}
/>
</FixedColumn>
<FixedColumn key={'estimation'} justify={'left'}>
<EstimationEditor value={issue} kind={'list'} />
</FixedColumn>
</div>
</svelte:fragment>
</ListView>

View File

@ -13,7 +13,19 @@
// limitations under the License.
//
import { Doc, DocumentQuery, Ref, SortingOrder, toIdMap, TxOperations, WithLookup } from '@hcengineering/core'
import { Employee } from '@hcengineering/contact'
import core, {
AttachedData,
Doc,
DocumentQuery,
Ref,
SortingOrder,
toIdMap,
TxCollectionCUD,
TxOperations,
TxUpdateDoc,
WithLookup
} from '@hcengineering/core'
import { TypeState } from '@hcengineering/kanban'
import { Asset, IntlString, translate } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
@ -24,6 +36,7 @@ import {
IssuesGrouping,
IssuesOrdering,
IssueStatus,
IssueTemplateData,
ProjectStatus,
Sprint,
SprintStatus,
@ -592,3 +605,25 @@ export function subIssueListProvider (subIssues: Issue[], target: Ref<Issue>): v
listProvider.updateFocus(selectedIssue)
}
}
export async function getPreviousAssignees (
issue: Issue | AttachedData<Issue> | IssueTemplateData
): Promise<Array<Ref<Employee>>> {
return await new Promise((resolve) => {
const query = createQuery(true)
query.query(
core.class.Tx,
{
'tx.objectId': (issue as Issue)._id,
'tx.operations.assignee': { $exists: true }
},
(res) => {
const prevAssignee = res
.map((t) => ((t as TxCollectionCUD<Doc, Issue>).tx as TxUpdateDoc<Issue>).operations.assignee)
.filter((p) => !(p == null)) as Array<Ref<Employee>>
resolve(prevAssignee)
query.unsubscribe()
}
)
})
}