Support for review participants (#1139)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-03-14 16:05:02 +07:00 committed by GitHub
parent f7ea37e357
commit 728216c936
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 319 additions and 47 deletions

View File

@ -56,6 +56,9 @@ export class TReview extends TTask implements Review {
@Prop(Collection(chunter.class.Comment), chunter.string.Comments)
comments?: number
@Prop(Collection(contact.class.Employee), recruit.string.Participants)
participants!: Ref<Employee>[]
}
@Model(recruit.class.Opinion, core.class.AttachedDoc, 'recruit' as Domain)

View File

@ -1,7 +1,5 @@
import { Doc, FindOptions } from '@anticrm/core'
import { Builder } from '@anticrm/model'
import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter'
import contact from '@anticrm/model-contact'
import core from '@anticrm/model-core'
import task from '@anticrm/model-task'
@ -94,21 +92,22 @@ function createStatusTableViewlet (builder: Builder): void {
attachedTo: recruit.mixin.Candidate,
state: task.class.State,
assignee: contact.class.Employee,
doneState: task.class.DoneState
doneState: task.class.DoneState,
participants: contact.class.Employee
}
} as FindOptions<Doc>,
config: [
'',
'$lookup.attachedTo',
// '$lookup.assignee',
{ key: '$lookup.participants', presenter: recruit.component.PersonsPresenter, label: recruit.string.Participants, sortingKey: '$lookup.participants' },
// 'location',
'company',
'dueDate',
{ key: '', presenter: recruit.component.OpinionsPresenter, label: recruit.string.Opinions, sortingKey: 'opinions' },
'$lookup.state',
'$lookup.doneState',
{ presenter: attachment.component.AttachmentsPresenter, label: attachment.string.Files, sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: chunter.string.Comments, sortingKey: 'comments' },
// { presenter: attachment.component.AttachmentsPresenter, label: attachment.string.Files, sortingKey: 'attachments' },
// { presenter: chunter.component.CommentsPresenter, label: chunter.string.Comments, sortingKey: 'comments' },
'modifiedOn'
]
})
@ -138,10 +137,12 @@ function createKanbanViewlet (builder: Builder): void {
options: {
lookup: {
attachedTo: recruit.mixin.Candidate,
state: task.class.State
state: task.class.State,
assignee: contact.class.Employee,
participants: contact.class.Employee
}
} as FindOptions<Doc>,
config: ['$lookup.attachedTo', '$lookup.state']
config: ['$lookup.attachedTo', '$lookup.state', '$lookup.participants', '$lookup.assignee']
})
}
@ -155,21 +156,22 @@ function createTableViewlet (builder: Builder): void {
attachedTo: recruit.mixin.Candidate,
state: task.class.State,
assignee: contact.class.Employee,
doneState: task.class.DoneState
doneState: task.class.DoneState,
participants: contact.class.Employee
}
} as FindOptions<Doc>,
config: [
'',
'$lookup.attachedTo',
// '$lookup.assignee',
{ key: '$lookup.participants', presenter: recruit.component.PersonsPresenter, label: recruit.string.Participants, sortingKey: '$lookup.participants' },
// 'location',
'company',
'dueDate',
{ key: '', presenter: recruit.component.OpinionsPresenter, label: recruit.string.Opinions, sortingKey: 'opinions' },
'$lookup.state',
'$lookup.doneState',
{ presenter: attachment.component.AttachmentsPresenter, label: attachment.string.Files, sortingKey: 'attachments' },
{ presenter: chunter.component.CommentsPresenter, label: chunter.string.Comments, sortingKey: 'comments' },
// { presenter: attachment.component.AttachmentsPresenter, label: attachment.string.Files, sortingKey: 'attachments' },
// { presenter: chunter.component.CommentsPresenter, label: chunter.string.Comments, sortingKey: 'comments' },
'modifiedOn'
]
})

View File

@ -41,6 +41,12 @@ const predicates: Record<string, PredicateFactory> = {
}
return (docs) => execPredicate(docs, propertyKey, (value) => o.includes(value))
},
$nin: (o, propertyKey) => {
if (!Array.isArray(o)) {
throw new Error('$nin predicate requires array')
}
return (docs) => execPredicate(docs, propertyKey, (value) => !o.includes(value))
},
$like: (query: string, propertyKey: string): Predicate => {
const searchString = query.split('%').map(it => escapeLikeForRegexp(it)).join('.*')

View File

@ -21,8 +21,9 @@ import type { Tx } from './tx'
* @public
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type QuerySelector<T> = { // TODO: refactor this shit
export type QuerySelector<T> = {
$in?: T[]
$nin?: T[]
$like?: string
$regex?: string
$options?: string

View File

@ -14,7 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import type { Doc } from '@anticrm/core'
import type { AnyAttribute, Class, Doc, Ref } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import type { AnySvelteComponent } from '@anticrm/ui'
import { CircleButton, Label } from '@anticrm/ui'
@ -38,13 +38,18 @@
$: attributeKey = typeof key === 'string' ? key : key.key
$: typeClassId = attribute !== undefined ? getAttributePresenterClass(attribute) : undefined
let editor: Promise<AnySvelteComponent> | undefined
let editor: Promise<void | AnySvelteComponent> | undefined
$: if (typeClassId !== undefined) {
const typeClass = hierarchy.getClass(typeClassId)
const editorMixin = hierarchy.as(typeClass, view.mixin.AttributeEditor)
editor = getResource(editorMixin.editor)
function update (attribute: AnyAttribute, typeClassId?: Ref<Class<Doc>>): void {
if (typeClassId !== undefined) {
const typeClass = hierarchy.getClass(typeClassId)
const editorMixin = hierarchy.as(typeClass, view.mixin.AttributeEditor)
editor = getResource(editorMixin.editor).catch((cause) => {
console.error('failed to find editor for', _class, attribute, typeClassId)
})
}
}
$: update(attribute, typeClassId)
function onChange (value: any) {
const doc = object as Doc

View File

@ -0,0 +1,107 @@
<!--
// 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, { Person } from '@anticrm/contact'
import type { Class, Doc, Ref } from '@anticrm/core'
import { IntlString } from '@anticrm/platform'
import { ActionIcon, CircleButton, IconAdd, IconClose, Label, ShowMore, showPopup } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import { UserInfo } from '..'
import { createQuery } from '../utils'
import UsersPopup from './UsersPopup.svelte'
export let items: Ref<Person>[] = []
export let _class: Ref<Class<Doc>>
export let title: IntlString
export let noItems: IntlString
let persons: Person[] = []
const query = createQuery()
$: query.query<Person>(_class, { _id: { $in: items } }, (result) => {
persons = result
})
const dispatch = createEventDispatcher()
async function addRef (person: Person): Promise<void> {
dispatch('open', person)
}
async function addPerson (evt: Event): Promise<void> {
showPopup(
UsersPopup,
{
_class,
title,
allowDeselect: false,
ignoreUsers: items
},
evt.target as HTMLElement,
(result) => {
// We have some value selected
if (result !== undefined) {
addRef(result)
}
}
)
}
async function removePerson (person: Person): Promise<void> {
dispatch('delete', person)
}
</script>
<div class="flex-row">
<ShowMore>
<div class="persons-container">
<div class="flex flex-reverse">
<div class="ml-4">
<CircleButton icon={IconAdd} size={'small'} selected on:click={addPerson} />
</div>
<div class="person-items">
{#if items?.length === 0}
<div class="flex flex-grow title-center">
<Label label={noItems} />
</div>
{/if}
{#each persons as person}
<div class="antiComponentBox flex-center">
<UserInfo value={person} size={'medium'} />
<div class="ml-1">
<ActionIcon icon={IconClose} size={'small'} action={() => removePerson(person)} />
</div>
</div>
{/each}
</div>
</div>
</div>
</ShowMore>
</div>
<style lang="scss">
.persons-container {
padding: 0.5rem;
color: var(--theme-caption-color);
background: var(--theme-bg-accent-color);
border: 1px solid var(--theme-bg-accent-color);
border-radius: 0.75rem;
}
.person-items {
flex-grow: 1;
display: flex;
flex-wrap: wrap;
}
</style>

View File

@ -33,12 +33,14 @@
export let allowDeselect: boolean = false
export let titleDeselect: IntlString | undefined = undefined
export let ignoreUsers: Ref<Person>[] = []
let search: string = ''
let objects: Person[] = []
const dispatch = createEventDispatcher()
const query = createQuery()
$: query.query(_class, { name: { $like: '%' + search + '%' } }, result => { objects = result }, { limit: 200 })
$: query.query<Person>(_class, { name: { $like: '%' + search + '%' }, _id: { $nin: ignoreUsers } }, result => { objects = result }, { limit: 200 })
afterUpdate(() => { dispatch('update', Date.now()) })
</script>

View File

@ -23,6 +23,7 @@ export * from './types'
export { default as UserBox } from './components/UserBox.svelte'
export { default as UserInfo } from './components/UserInfo.svelte'
export { default as UserBoxList } from './components/UserBoxList.svelte'
export { default as Avatar } from './components/Avatar.svelte'
export { default as EditableAvatar } from './components/EditableAvatar.svelte'
export { default as MessageViewer } from './components/MessageViewer.svelte'

View File

@ -617,6 +617,29 @@ export class LiveQuery extends TxProcessor implements Client {
;(updatedDoc.$lookup as any)[key] = await this.client.findOne(lookup, { _id: ops[key] })
}
}
} else {
if (key === '$push') {
const pops = tx.operations[key] ?? {}
for (const pkey of Object.keys(pops)) {
if (q.options !== undefined) {
const lookup = (q.options.lookup as any)?.[pkey]
if (lookup !== undefined) {
;(updatedDoc.$lookup as any)[pkey].push(await this.client.findOne(lookup, { _id: (pops as any)[pkey] as Ref<Doc> }))
}
}
}
} else if (key === '$pull') {
const pops = tx.operations[key] ?? {}
for (const pkey of Object.keys(pops)) {
if (q.options !== undefined) {
const lookup = (q.options.lookup as any)?.[pkey]
if (lookup !== undefined) {
const pid = (pops as any)[pkey] as Ref<Doc>
;(updatedDoc.$lookup as any)[pkey] = ((updatedDoc.$lookup as any)[pkey]).filter((it: Doc) => it._id !== pid)
}
}
}
}
}
}
}

View File

@ -414,3 +414,13 @@
// Hide row menu in Tooltip
.popup-tooltip .antiTable .antiTable-body__row:hover .antiTable-cells__firstCell .antiTable-cells__firstCell-menuRow { visibility: hidden; }
// Basic component view.
.antiComponentBox {
margin: 0.25rem;
padding: 0.5rem;
background-color: var(--theme-button-bg-focused);
border: 1px solid var(--theme-button-border-enabled);
border-radius: .75rem;
box-shadow: 0px 3px 3px rgba(0, 0, 0, .2);
}

View File

@ -81,7 +81,10 @@
"NoReviewForCandidate": "No reviews",
"CreateAnReview": "Create review",
"CreateOpinion": "Create opinion",
"OpinionValuePlaceholder": "10/10"
"OpinionValuePlaceholder": "10/10",
"Participants": "Participants",
"NoParticipants": "No participants added",
"PersonsLabel": "{name}"
},
"status": {
"CandidateRequired": "Please select candidate",

View File

@ -82,7 +82,10 @@
"NoReviewForCandidate": "Нет оценок",
"CreateAnReview": "Добавить оценку",
"CreateOpinion": "Добавить мнение",
"OpinionValuePlaceholder": "10/10"
"OpinionValuePlaceholder": "10/10",
"Participants": "Участники",
"NoParticipants": "Участники не добавлены",
"PersonsLabel": "{name}"
},
"status": {
"CandidateRequired": "Пожалуйста выберите кандидата",

View File

@ -15,7 +15,7 @@
-->
<script lang="ts">
import contact from '@anticrm/contact'
import { createQuery, getClient } from '@anticrm/presentation'
import { createQuery, getClient, UserBoxList } from '@anticrm/presentation'
import type { Candidate, Review, ReviewCategory } from '@anticrm/recruit'
import { StyledTextBox } from '@anticrm/text-editor'
import { EditBox, Grid, Label } from '@anticrm/ui'
@ -48,7 +48,9 @@
const client = getClient()
onMount(() => {
dispatch('open', { ignoreKeys: ['location', 'company', 'number', 'comments', 'startDate', 'description'] })
dispatch('open', {
ignoreKeys: ['location', 'company', 'number', 'comments', 'startDate', 'description', 'verdict']
})
})
</script>
@ -59,27 +61,44 @@
<div class="card"><ReviewCategoryCard category={reviewCategory} /></div>
</div>
<div class="mt-4 mb-1">
<Grid column={1}>
<div class="mt-6 mb-2">
<Grid column={2}>
<EditBox
label={recruit.string.Company}
bind:value={object.company}
icon={contact.icon.Company}
placeholder={recruit.string.Company}
maxWidth="39rem"
focus
on:change={() => client.update(object, { company: object.company })}
label={recruit.string.Company}
bind:value={object.company}
icon={contact.icon.Company}
placeholder={recruit.string.Company}
maxWidth="39rem"
focus
on:change={() => client.update(object, { company: object.company })}
/>
<EditBox
label={recruit.string.Location}
bind:value={object.location}
icon={recruit.icon.Location}
placeholder={recruit.string.Location}
maxWidth="39rem"
focus
on:change={() => client.update(object, { location: object.location })}
label={recruit.string.Location}
bind:value={object.location}
icon={recruit.icon.Location}
placeholder={recruit.string.Location}
maxWidth="39rem"
focus
on:change={() => client.update(object, { location: object.location })}
/>
</Grid>
<div class="flex-row">
<div class="mt-4 mb-2">
<Label label={recruit.string.Participants} />
</div>
<UserBoxList
_class={contact.class.Employee}
items={object.participants}
title={recruit.string.Participants}
on:open={(evt) => {
client.update(object, { $push: { participants: evt.detail._id } })
}}
on:delete={(evt) => {
client.update(object, { $pull: { participants: evt.detail._id } })
}}
noItems={recruit.string.NoParticipants}
/>
</div>
</div>
<div class="mt-4 mb-1">

View File

@ -15,12 +15,13 @@
<script lang="ts">
import { AttachmentsPresenter } from '@anticrm/attachment-resources'
import { CommentsPresenter } from '@anticrm/chunter-resources'
import { formatName } from '@anticrm/contact'
import { Employee, formatName, Person } from '@anticrm/contact'
import type { WithLookup } from '@anticrm/core'
import { Avatar } from '@anticrm/presentation'
import type { Review } from '@anticrm/recruit'
import { ActionIcon, IconMoreH, showPanel } from '@anticrm/ui'
import view from '@anticrm/view'
import PersonsPresenter from './PersonsPresenter.svelte'
import ReviewPresenter from './ReviewPresenter.svelte'
export let object: WithLookup<Review>
@ -29,6 +30,14 @@
function showCandidate () {
showPanel(view.component.EditDoc, object.attachedTo, object.attachedToClass, 'full')
}
function getPersons (object: WithLookup<Review>): Person[] {
const r = (object.$lookup?.participants as unknown as Employee[] ?? [])
const assignee = object.$lookup?.assignee as Employee
if (assignee != null && r.findIndex(it => it._id === assignee._id) === -1) {
return [...r, assignee]
}
return r
}
</script>
<div class="card-container" {draggable} class:draggable on:dragstart on:dragend>
@ -56,7 +65,9 @@
<div class="step-lr75"><CommentsPresenter value={object} /></div>
{/if}
</div>
<Avatar size={'x-small'} />
{#if object.$lookup?.participants || object.$lookup?.assignee}
<PersonsPresenter value={getPersons(object)}></PersonsPresenter>
{/if}
</div>
</div>

View File

@ -0,0 +1,56 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 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 { formatName, Person } from '@anticrm/contact'
import { Hierarchy } from '@anticrm/core'
import { Avatar } from '@anticrm/presentation'
import recruit from '../../plugin'
import { showPanel, Tooltip } from '@anticrm/ui'
import view from '@anticrm/view'
export let value: Person | Person[]
export let inline: boolean = false
let persons: Person[] = []
$: persons = Array.isArray(value) ? value : [value]
async function onClick (p: Person) {
showPanel(view.component.EditDoc, p._id, Hierarchy.mixinOrClass(p), 'full')
}
</script>
{#if value}
<div class='flex persons'>
{#each persons as p}
<Tooltip label={recruit.string.PersonsLabel} props={{ name: formatName(p.name) }}>
<div class="flex-presenter" class:inline-presenter={inline} on:click={() => onClick(p)}>
<div class="icon">
<Avatar size={'x-small'} avatar={p.avatar} />
</div>
</div>
</Tooltip>
{/each}
</div>
{/if}
<style lang="scss">
.persons {
display: grid;
grid-template-columns: repeat(4, min-content);
.icon {
margin: 0.25rem;
}
}
</style>

View File

@ -48,6 +48,7 @@ import VacancyItemPresenter from './components/VacancyItemPresenter.svelte'
import VacancyPresenter from './components/VacancyPresenter.svelte'
import VacancyCountPresenter from './components/VacancyCountPresenter.svelte'
import recruit from './plugin'
import PersonsPresenter from './components/review/PersonsPresenter.svelte'
async function createApplication (object: Doc): Promise<void> {
showPopup(CreateApplication, { candidate: object._id, preserveCandidate: true })
@ -164,7 +165,8 @@ export default async (): Promise<Resources> => ({
Reviews,
Opinions,
OpinionPresenter,
OpinionsPresenter
OpinionsPresenter,
PersonsPresenter
},
completion: {
ApplicationQuery: async (client: Client, query: string) => await queryApplication(client, query)

View File

@ -100,7 +100,10 @@ export default mergeIds(recruitId, recruit, {
ReviewShortLabel: '' as IntlString,
StartDate: '' as IntlString,
DueDate: '' as IntlString,
CandidateReviews: '' as IntlString
CandidateReviews: '' as IntlString,
Participants: '' as IntlString,
NoParticipants: '' as IntlString,
PersonsLabel: '' as IntlString
},
space: {
CandidatesPublic: '' as Ref<Space>
@ -112,6 +115,7 @@ export default mergeIds(recruitId, recruit, {
component: {
VacancyItemPresenter: '' as AnyComponent,
VacancyCountPresenter: '' as AnyComponent,
OpinionsPresenter: '' as AnyComponent
OpinionsPresenter: '' as AnyComponent,
PersonsPresenter: '' as AnyComponent
}
})

View File

@ -13,7 +13,7 @@
// limitations under the License.
//
import type { Person } from '@anticrm/contact'
import type { Employee, Person } from '@anticrm/contact'
import type { AttachedDoc, Class, Doc, Mixin, Ref, Space, Timestamp } from '@anticrm/core'
import type { Asset, Plugin } from '@anticrm/platform'
import { plugin } from '@anticrm/platform'
@ -83,6 +83,8 @@ export interface Review extends Task {
dueDate: Timestamp | null
opinions?: number
participants?: Ref<Employee>[]
}
/**

View File

@ -339,6 +339,12 @@ function getResultIds (ids: Set<Ref<Doc>>, _id: ObjQueryType<Ref<Doc>> | undefin
result.push(id)
}
}
} else if (_id.$nin !== undefined) {
for (const id of Array.from(ids.values())) {
if (!_id.$nin.includes(id)) {
result.push(id)
}
}
}
} else {
result = Array.from(ids)

View File

@ -159,7 +159,13 @@ abstract class MongoAdapterBase extends TxProcessor {
const domain = this.hierarchy.getDomain(_class)
if (domain !== DOMAIN_MODEL) {
const arr = object[fullKey]
targetObject.$lookup[key] = arr?.[0]
if (arr !== undefined && Array.isArray(arr)) {
if (arr.length === 1) {
targetObject.$lookup[key] = arr[0]
} else if (arr.length > 1) {
targetObject.$lookup[key] = arr
}
}
} else {
targetObject.$lookup[key] = this.modelDb.getObject(targetObject[key])
}