*Candidate Flow & Notifications (#2272)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-09-20 13:42:24 +07:00 committed by GitHub
parent 096a14578b
commit 1adb05e4ae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 506 additions and 239 deletions

View File

@ -15,7 +15,7 @@
// To help typescript locate view plugin properly
import automation, { AutomationSupport } from '@anticrm/automation'
import { Board, Card, MenuPage, CommonBoardPreference, CardCover, boardId } from '@anticrm/board'
import { Board, boardId, Card, CardCover, CommonBoardPreference, MenuPage } from '@anticrm/board'
import type { Employee } from '@anticrm/contact'
import { Class, DOMAIN_MODEL, IndexKind, Markup, Ref, Type } from '@anticrm/core'
import {
@ -35,13 +35,13 @@ import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter'
import contact from '@anticrm/model-contact'
import core, { TDoc, TType } from '@anticrm/model-core'
import preference, { TPreference } from '@anticrm/model-preference'
import tags from '@anticrm/model-tags'
import task, { TSpaceWithStates, TTask } from '@anticrm/model-task'
import view, { actionTemplates, createAction } from '@anticrm/model-view'
import view, { actionTemplates, createAction, actionTemplates as viewTemplates } from '@anticrm/model-view'
import workbench, { Application } from '@anticrm/model-workbench'
import { IntlString } from '@anticrm/platform'
import type { AnyComponent } from '@anticrm/ui'
import preference, { TPreference } from '@anticrm/model-preference'
import tags from '@anticrm/model-tags'
import board from './plugin'
@Model(board.class.Board, task.class.SpaceWithStates)
@ -526,6 +526,19 @@ export function createModel (builder: Builder): void {
},
board.action.ConvertToCard
)
createAction(builder, {
...viewTemplates.open,
target: board.class.Board,
context: {
mode: ['browser', 'context'],
group: 'create'
},
action: workbench.actionImpl.Navigate,
actionProps: {
mode: 'space'
}
})
}
export { boardOperation } from './migration'

View File

@ -47,7 +47,7 @@ import attachment from '@anticrm/model-attachment'
import core, { TAttachedDoc, TSpace } from '@anticrm/model-core'
import notification from '@anticrm/model-notification'
import preference, { TPreference } from '@anticrm/model-preference'
import view, { createAction } from '@anticrm/model-view'
import view, { actionTemplates as viewTemplates, createAction } from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import chunter from './plugin'
@ -478,6 +478,19 @@ export function createModel (builder: Builder): void {
builder.mixin(chunter.class.Channel, core.class.Class, view.mixin.ClassFilters, {
filters: ['private', 'archived']
})
createAction(builder, {
...viewTemplates.open,
target: chunter.class.Channel,
context: {
mode: ['browser', 'context'],
group: 'create'
},
action: workbench.actionImpl.Navigate,
actionProps: {
mode: 'space'
}
})
}
export { chunterOperation } from './migration'

View File

@ -87,7 +87,9 @@
if (r instanceof Promise) {
r.then(() => {
okProcessing = false
dispatch('close')
if (!createMore) {
dispatch('close')
}
})
} else if (!createMore) {
okProcessing = false

View File

@ -28,10 +28,10 @@
showPopup,
tooltip
} from '@anticrm/ui'
import { createEventDispatcher, afterUpdate } from 'svelte'
import { afterUpdate, createEventDispatcher } from 'svelte'
import presentation from '..'
import { createQuery, getClient } from '../utils'
import { ObjectCreate } from '../types'
import { createQuery, getClient } from '../utils'
export let _class: Ref<Class<Doc>>
export let options: FindOptions<Doc> | undefined = undefined
@ -155,6 +155,35 @@
}
afterUpdate(() => dispatch('changeContent'))
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, objects, selected)
</script>
<FocusHandler {manager} />
@ -181,7 +210,10 @@
</div>
{/if}
</div>
<div class="scroll">
{#if cHeight === 1}
<div class="background-theme-content-accent" style:height={'2px'} />
{/if}
<div class="scroll" on:scroll={() => updateLocation(scrollDiv, selectedDiv)} bind:this={scrollDiv}>
<div class="box">
<ListView bind:this={list} count={objects.length} bind:selection>
<svelte:fragment slot="category" let:item>
@ -199,6 +231,8 @@
{@const obj = objects[item]}
<button
class="menu-item w-full"
class:background-bg-focused={!allowDeselect && obj._id === selected}
class:border-radius-1={!allowDeselect && obj._id === selected}
on:click={() => {
handleSelection(undefined, objects, item)
}}
@ -206,19 +240,27 @@
{#if allowDeselect && selected}
<div class="icon">
{#if obj._id === selected}
{#if titleDeselect}
<div class="clear-mins" use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
<div bind:this={selectedDiv}>
{#if titleDeselect}
<div class="clear-mins" use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
<Icon icon={IconCheck} {size} />
</div>
{:else}
<Icon icon={IconCheck} {size} />
</div>
{:else}
<Icon icon={IconCheck} {size} />
{/if}
{/if}
</div>
{/if}
</div>
{/if}
<span class="label">
<slot name="item" item={obj} />
{#if obj._id === selected}
<div bind:this={selectedDiv}>
<slot name="item" item={obj} />
</div>
{:else}
<slot name="item" item={obj} />
{/if}
</span>
{#if multiSelect}
<div class="check-right pointer-events-none">
@ -230,6 +272,9 @@
</ListView>
</div>
</div>
{#if cHeight === -1}
<div class="background-theme-content-accent" style:height={'2px'} />
{/if}
</div>
<style lang="scss">

View File

@ -17,24 +17,27 @@
import { getClient } from '../utils'
import {
AnySvelteComponent,
Button,
ButtonKind,
ButtonSize,
eventToHTMLElement,
getEventPositionElement,
getFocusManager,
IconFolder,
Label,
showPopup,
IconFolder,
Button,
eventToHTMLElement,
getFocusManager,
TooltipAlignment,
ButtonKind,
ButtonSize
TooltipAlignment
} from '@anticrm/ui'
import SpacesPopup from './SpacesPopup.svelte'
import type { Ref, Class, Space, DocumentQuery } from '@anticrm/core'
import type { Class, DocumentQuery, FindOptions, Ref, Space } from '@anticrm/core'
import { createEventDispatcher } from 'svelte'
import { ObjectCreate } from '../types'
export let _class: Ref<Class<Space>>
export let spaceQuery: DocumentQuery<Space> | undefined = { archived: false }
export let spaceOptions: FindOptions<Space> | undefined = {}
export let label: IntlString
export let value: Ref<Space> | undefined
export let focusIndex = -1
@ -46,6 +49,8 @@
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = undefined
export let allowDeselect = false
export let component: AnySvelteComponent | undefined = undefined
export let componentProps: any | undefined = undefined
let selected: Space | undefined
@ -74,12 +79,14 @@
_class,
label,
allowDeselect,
options: { sort: { modifiedOn: -1 } },
selected,
spaceOptions: { ...(spaceOptions ?? {}), sort: { ...(spaceOptions?.sort ?? {}), modifiedOn: -1 } },
selected: selected?._id,
spaceQuery,
create
create,
component,
componentProps
},
eventToHTMLElement(ev),
!$$slots.content ? eventToHTMLElement(ev) : getEventPositionElement(ev),
(result) => {
if (result) {
value = result._id
@ -91,19 +98,25 @@
}
</script>
<Button
id="space.selector"
{focus}
{focusIndex}
icon={IconFolder}
{size}
{kind}
{justify}
{width}
showTooltip={{ label, direction: labelDirection }}
on:click={showSpacesPopup}
>
<span slot="content" class="overflow-label disabled text-sm">
{#if selected}{selected.name}{:else}<Label {label} />{/if}
</span>
</Button>
{#if $$slots.content}
<div id="space.selector" class="w-full h-full flex-streatch" on:click={showSpacesPopup}>
<slot name="content" />
</div>
{:else}
<Button
id="space.selector"
{focus}
{focusIndex}
icon={IconFolder}
{size}
{kind}
{justify}
{width}
showTooltip={{ label, direction: labelDirection }}
on:click={showSpacesPopup}
>
<span slot="content" class="overflow-label disabled text-sm">
{#if selected}{selected.name}{:else}<Label {label} />{/if}
</span>
</Button>
{/if}

View File

@ -13,15 +13,16 @@
// limitations under the License.
-->
<script lang="ts">
import type { Class, DocumentQuery, Ref, Space } from '@anticrm/core'
import type { Class, DocumentQuery, FindOptions, Ref, Space } from '@anticrm/core'
import type { IntlString } from '@anticrm/platform'
import { ButtonKind, ButtonSize } from '@anticrm/ui'
import { AnySvelteComponent, ButtonKind, ButtonSize } from '@anticrm/ui'
import { ObjectCreate } from '../types'
import SpaceSelect from './SpaceSelect.svelte'
export let space: Ref<Space> | undefined = undefined
export let _class: Ref<Class<Space>>
export let query: DocumentQuery<Space> = { archived: false }
export let queryOptions: FindOptions<Space> | undefined = {}
export let label: IntlString
export let kind: ButtonKind = 'no-border'
export let size: ButtonSize = 'small'
@ -29,6 +30,8 @@
export let width: string | undefined = undefined
export let allowDeselect = false
export let focus = true
export let component: AnySvelteComponent | undefined = undefined
export let componentProps: any | undefined = undefined
export let create: ObjectCreate | undefined = undefined
</script>
@ -39,12 +42,15 @@
{focus}
{_class}
spaceQuery={query}
spaceOptions={queryOptions}
{allowDeselect}
{label}
{size}
{kind}
{justify}
{width}
{component}
{componentProps}
bind:value={space}
on:change={(evt) => {
space = evt.detail

View File

@ -13,7 +13,8 @@
// limitations under the License.
-->
<script lang="ts">
import type { Class, Doc, DocumentQuery, Ref, Space } from '@anticrm/core'
import type { Class, Doc, DocumentQuery, FindOptions, Ref, Space } from '@anticrm/core'
import { AnySvelteComponent } from '@anticrm/ui'
import { ObjectCreate } from '../types'
import ObjectPopup from './ObjectPopup.svelte'
import SpaceInfo from './SpaceInfo.svelte'
@ -21,8 +22,11 @@
export let _class: Ref<Class<Space>>
export let selected: Ref<Space> | undefined
export let spaceQuery: DocumentQuery<Space> | undefined
export let spaceOptions: FindOptions<Space> | undefined = {}
export let create: ObjectCreate | undefined = undefined
export let allowDeselect = false
export let component: AnySvelteComponent | undefined = undefined
export let componentProps: any | undefined = undefined
$: _create =
create !== undefined
@ -35,6 +39,7 @@
<ObjectPopup
{_class}
options={spaceOptions}
{selected}
bind:docQuery={spaceQuery}
multiSelect={false}
@ -45,6 +50,10 @@
on:close
>
<svelte:fragment slot="item" let:item={space}>
<SpaceInfo size={'large'} value={space} />
{#if component}
<svelte:component this={component} {...componentProps} size={'large'} value={space} />
{:else}
<SpaceInfo size={'large'} value={space} />
{/if}
</svelte:fragment>
</ObjectPopup>

View File

@ -23,6 +23,7 @@
Button,
ButtonKind,
ButtonSize,
getEventPositionElement,
getFocusManager,
Icon,
IconOpen,
@ -59,6 +60,7 @@
export let focusIndex = -1
export let showTooltip: LabelAndProps | undefined = undefined
export let showNavigate = true
export let id: string | undefined = undefined
export let create: ObjectCreate | undefined = undefined
@ -81,7 +83,7 @@
}
const mgr = getFocusManager()
const _click = (): void => {
const _click = (ev: MouseEvent): void => {
if (!readonly) {
showPopup(
UsersPopup,
@ -97,7 +99,7 @@
placeholder,
create
},
container,
!$$slots.content ? container : getEventPositionElement(ev),
(result) => {
if (result === null) {
value = null
@ -116,44 +118,54 @@
$: hideIcon = size === 'x-large' || (size === 'large' && kind !== 'link')
</script>
<div bind:this={container} class="min-w-0" class:w-full={width === '100%'}>
<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}>
<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-row-center">
{#if icon}
<Icon {icon} size={kind === 'link' ? 'small' : size} />
<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}>
<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}
<div class="ml-2">
<Label {label} />
{:else}
<div class="flex-row-center">
{#if icon}
<Icon {icon} size={kind === 'link' ? 'small' : size} />
{/if}
<div class="ml-2">
<Label {label} />
</div>
</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}
</div>
{#if selected && showNavigate}
<ActionIcon
icon={IconOpen}
size={'small'}
action={() => {
if (selected) {
showPanel(view.component.EditDoc, selected._id, selected._class, 'content')
}
}}
/>
{/if}
</span>
</Button>
</span>
</Button>
{/if}
</div>

View File

@ -700,6 +700,7 @@ a.no-line {
.background-card-divider { background-color: var(--theme-card-divider); }
.background-primary-color { background-color: var(--primary-button-enabled); }
.background-bg-accent { background-color: var(--theme-bg-accent-color); }
.background-bg-focused {background-color: var(--theme-bg-focused-color); }
.background-bg-accent-normal { background-color: var(--theme-bg-accent-normal); }
.dark-color { color: var(--dark-color); }

View File

@ -22,8 +22,9 @@
NotificationStatus,
NotificationType
} from '@anticrm/notification'
import { createQuery } from '@anticrm/presentation'
import { getCurrentLocation } from '@anticrm/ui'
import { createQuery, getClient } from '@anticrm/presentation'
import { getCurrentLocation, showPanel } from '@anticrm/ui'
import view from '@anticrm/view'
import notification from '../plugin'
import { NotificationClientImpl } from '../utils'
@ -76,7 +77,7 @@
notification.class.Notification,
{
attachedTo: (getCurrentAccount() as EmployeeAccount).employee,
status: NotificationStatus.New
status: { $nin: [NotificationStatus.Read] }
},
(res) => {
process(res.reverse())
@ -110,15 +111,23 @@
await notify(text, notification)
}
}
const client = getClient()
let clearTimer: number | undefined
async function notify (text: string, notifyInstance: PlatformNotification): Promise<void> {
if (notifyInstance.status !== NotificationStatus.New) {
return
}
if (alreadyShown.has(notifyInstance._id)) {
return
}
alreadyShown.add(notifyInstance._id)
client.updateDoc(notifyInstance._class, notifyInstance.space, notifyInstance._id, {
status: NotificationStatus.Notified
})
if (clearTimer) {
clearTimeout(clearTimer)
}
@ -129,8 +138,6 @@
const lastView = $lastViews.get(lastViewId)
if ((lastView ?? notifyInstance.modifiedOn) > 0) {
// eslint-disable-next-line
new Notification(getCurrentLocation().path[1], { tag: notifyInstance._id, icon: '/favicon.png', body: text })
await notificationClient.updateLastView(
lastViewId,
contact.class.Employee,
@ -138,5 +145,16 @@
lastView === undefined
)
}
// eslint-disable-next-line
const notification = new Notification(getCurrentLocation().path[1], {
tag: notifyInstance._id,
icon: '/favicon.png',
body: text
})
notification.onclick = () => {
showPanel(view.component.EditDoc, notifyInstance.attachedTo, notifyInstance.attachedToClass, 'content')
}
}
</script>

View File

@ -49,14 +49,19 @@
</script>
{#if displayTx}
{@const isNew = notification.status === NotificationStatus.New}
{@const isNew = notification.status !== NotificationStatus.Read}
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<div class="content">
<div class="flex-row">
<div class="bottom-divider mb-2">
<div class="flex-row-center mb-2 mt-2">
<div class="notify mr-4" style:color={isNew ? getPlatformColor(11) : '#555555'} />
<div class="flex-shrink">
<div
class="flex-shrink"
on:click={() => {
changeState(notification, NotificationStatus.Read)
}}
>
<Component
is={view.component.ObjectPresenter}
props={{
@ -82,7 +87,7 @@
label={plugin.string.MarkAsRead}
size={'medium'}
action={() => {
changeState(notification, isNew ? NotificationStatus.Read : NotificationStatus.New)
changeState(notification, isNew ? NotificationStatus.Read : NotificationStatus.Notified)
}}
/>
</div>

View File

@ -54,6 +54,7 @@ export interface EmailNotification extends Doc {
*/
export enum NotificationStatus {
New,
Notified,
Read
}

View File

@ -13,46 +13,55 @@
// limitations under the License.
-->
<script lang="ts">
import contact, { Channel, formatName } from '@anticrm/contact'
import attachment from '@anticrm/attachment'
import chunter from '@anticrm/chunter'
import contact, { Channel, formatName, Person } from '@anticrm/contact'
import { ChannelsEditor } from '@anticrm/contact-resources'
import { Avatar, createQuery } from '@anticrm/presentation'
import type { Candidate } from '@anticrm/recruit'
import { Avatar, createQuery, getClient } from '@anticrm/presentation'
import { Component, Label, showPanel } from '@anticrm/ui'
import view from '@anticrm/view'
import chunter from '@anticrm/chunter'
import attachment from '@anticrm/attachment'
import recruit from '../plugin'
export let candidate: Candidate
export let candidate: Person | undefined
export let disabled: boolean = false
let channels: Channel[] = []
const channelsQuery = createQuery()
channelsQuery.query(
contact.class.Channel,
{
attachedTo: candidate._id
},
(res) => {
channels = res
}
)
$: if (candidate !== undefined) {
channelsQuery.query(
contact.class.Channel,
{
attachedTo: candidate._id
},
(res) => {
channels = res
}
)
} else {
channelsQuery.unsubscribe()
}
const client = getClient()
</script>
<div class="flex-col h-full card-container">
<div class="flex-col h-full flex-grow card-container">
<div class="label uppercase"><Label label={recruit.string.Talent} /></div>
<Avatar avatar={candidate.avatar} size={'large'} />
<Avatar avatar={candidate?.avatar} size={'large'} />
{#if candidate}
<div
class="name lines-limit-2"
class:over-underline={!disabled}
on:click={() => {
if (!disabled) showPanel(view.component.EditDoc, candidate._id, candidate._class, 'content')
if (!disabled && candidate) {
showPanel(view.component.EditDoc, candidate._id, candidate._class, 'content')
}
}}
>
{formatName(candidate.name)}
</div>
<div class="description lines-limit-2">{candidate.title ?? ''}</div>
{#if client.getHierarchy().hasMixin(candidate, recruit.mixin.Candidate)}
{@const cand = client.getHierarchy().as(candidate, recruit.mixin.Candidate)}
<div class="description lines-limit-2">{cand.title ?? ''}</div>
{/if}
<div class="description overflow-label">{candidate.city ?? ''}</div>
<div class="footer flex flex-reverse flex-grow">
<div class="flex-center flex-wrap">
@ -89,6 +98,8 @@
transition-timing-function: var(--timing-shadow);
transition-duration: 0.15s;
user-select: text;
min-width: 15rem;
min-height: 15rem;
&:hover {
background-color: var(--board-card-bg-hover);

View File

@ -15,10 +15,11 @@
<script lang="ts">
import type { Contact, Employee, Person } from '@anticrm/contact'
import contact from '@anticrm/contact'
import { Account, Class, Client, Doc, generateId, Ref, SortingOrder, Space } from '@anticrm/core'
import ExpandRightDouble from '@anticrm/contact-resources/src/components/icons/ExpandRightDouble.svelte'
import { Account, Class, Client, Doc, FindOptions, generateId, Ref, SortingOrder, Space } from '@anticrm/core'
import { getResource, OK, Resource, Severity, Status } from '@anticrm/platform'
import { Card, createQuery, EmployeeBox, getClient, SpaceSelector, UserBox } from '@anticrm/presentation'
import type { Applicant, Candidate } from '@anticrm/recruit'
import { Card, createQuery, EmployeeBox, getClient, SpaceSelect, UserBox } from '@anticrm/presentation'
import type { Applicant, Candidate, Vacancy } from '@anticrm/recruit'
import task, { calcRank, SpaceWithStates, State } from '@anticrm/task'
import ui, {
Button,
@ -34,6 +35,9 @@
import view from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import recruit from '../plugin'
import CandidateCard from './CandidateCard.svelte'
import VacancyCard from './VacancyCard.svelte'
import VacancyOrgPresenter from './VacancyOrgPresenter.svelte'
export let space: Ref<SpaceWithStates>
export let candidate: Ref<Candidate>
@ -46,6 +50,8 @@
let _space = space
$: _candidate = candidate
let doc: Applicant = {
state: '' as Ref<State>,
doneState: null,
@ -69,7 +75,7 @@
const hierarchy = client.getHierarchy()
export function canClose (): boolean {
return (preserveCandidate || candidate === undefined) && assignee === undefined
return (preserveCandidate || _candidate === undefined) && assignee === undefined
}
async function createApplication () {
@ -92,7 +98,7 @@
)
const incResult = await client.update(sequence, { $inc: { sequence: 1 } }, true)
const candidateInstance = await client.findOne(contact.class.Person, { _id: doc.attachedTo as Ref<Person> })
const candidateInstance = await client.findOne(contact.class.Person, { _id: _candidate })
if (candidateInstance === undefined) {
throw new Error('contact not found')
}
@ -125,13 +131,14 @@
if (createMore) {
// Prepare for next
_candidate = '' as Ref<Candidate>
doc = {
state: selectedState?._id as Ref<State>,
doneState: null,
number: 0,
assignee: assignee,
rank: '',
attachedTo: candidate,
attachedTo: _candidate,
attachedToClass: recruit.mixin.Candidate,
_class: recruit.class.Applicant,
space: _space,
@ -152,24 +159,35 @@
return await impl({ ...doc, space: _space }, client)
}
async function validate (doc: Applicant, space: Ref<Space>, _class: Ref<Class<Doc>>): Promise<void> {
async function validate (
doc: Applicant,
space: Ref<Space>,
_class: Ref<Class<Doc>>,
candidate: Ref<Candidate>
): Promise<void> {
if (doc.attachedTo !== _candidate) {
doc.attachedTo = _candidate
}
const clazz = hierarchy.getClass(_class)
const validatorMixin = hierarchy.as(clazz, view.mixin.ObjectValidator)
if (validatorMixin?.validator != null) {
status = await invokeValidate(validatorMixin.validator)
} else if (clazz.extends != null) {
await validate(doc, space, clazz.extends)
await validate(doc, space, clazz.extends, candidate)
} else {
status = OK
}
}
$: validate(doc, _space, doc._class)
$: validate(doc, _space, doc._class, _candidate)
let states: Array<{ id: number | string; color: number; label: string }> = []
let selectedState: State | undefined
let rawStates: State[] = []
const statesQuery = createQuery()
const spaceQuery = createQuery()
let vacancy: Vacancy | undefined
$: if (_space) {
statesQuery.query(
@ -180,6 +198,9 @@
},
{ sort: { rank: SortingOrder.Ascending } }
)
spaceQuery.query(recruit.class.Vacancy, { _id: _space }, (res) => {
vacancy = res.shift()
})
}
$: if (rawStates.findIndex((it) => it._id === selectedState?._id) === -1) {
@ -209,6 +230,22 @@
}
}
)
const orgOptions: FindOptions<Vacancy> = {
lookup: {
company: contact.class.Organization
}
}
const candidateQuery = createQuery()
let _candidateInstance: Person | undefined
$: if (_candidate !== undefined) {
candidateQuery.query(contact.class.Person, { _id: _candidate }, (res) => {
_candidateInstance = res.shift()
})
} else {
candidateQuery.unsubscribe()
}
</script>
<FocusHandler {manager} />
@ -222,53 +259,60 @@
dispatch('close')
}}
>
<svelte:fragment slot="header">
<SpaceSelector
_class={recruit.class.Vacancy}
query={{ archived: false }}
label={recruit.string.Vacancy}
create={{
component: recruit.component.CreateVacancy,
label: recruit.string.CreateVacancy
}}
bind:space={_space}
/>
</svelte:fragment>
<svelte:fragment slot="title">
<div class="flex-row-center gap-2">
{#if preserveCandidate}
<UserBox
readonly
_class={contact.class.Person}
options={{ sort: { modifiedOn: -1 } }}
excluded={existingApplicants}
label={recruit.string.Talent}
placeholder={recruit.string.Talents}
bind:value={doc.attachedTo}
kind={'no-border'}
size={'small'}
/>
{/if}
<Label label={recruit.string.CreateApplication} />
</div>
</svelte:fragment>
<StatusControl slot="error" {status} />
<div class="candidate-vacancy">
<div class="flex flex-stretch">
<UserBox
id={'vacancy.talant.selector'}
focusIndex={1}
readonly={preserveCandidate}
_class={contact.class.Person}
options={{ sort: { modifiedOn: -1 } }}
excluded={existingApplicants}
label={recruit.string.Talent}
placeholder={recruit.string.Talents}
bind:value={_candidate}
kind={'no-border'}
size={'small'}
width={'100%'}
create={{ component: recruit.component.CreateCandidate, label: recruit.string.CreateTalent }}
>
<svelte:fragment slot="content">
<CandidateCard candidate={_candidateInstance} on:click disabled={true} />
</svelte:fragment>
</UserBox>
</div>
<div class="flex-center">
<ExpandRightDouble />
</div>
<div class="flex-grow">
<SpaceSelect
_class={recruit.class.Vacancy}
spaceQuery={{ archived: false }}
spaceOptions={orgOptions}
label={recruit.string.Vacancy}
create={{
component: recruit.component.CreateVacancy,
label: recruit.string.CreateVacancy
}}
bind:value={_space}
component={VacancyOrgPresenter}
componentProps={{ inline: true }}
>
<svelte:fragment slot="content">
<VacancyCard {vacancy} disabled={true} />
</svelte:fragment>
</SpaceSelect>
</div>
</div>
<svelte:fragment slot="pool">
{#key doc}
{#if !preserveCandidate}
<UserBox
focusIndex={1}
_class={contact.class.Person}
options={{ sort: { modifiedOn: -1 } }}
excluded={existingApplicants}
label={recruit.string.Talent}
placeholder={recruit.string.Talents}
bind:value={doc.attachedTo}
kind={'no-border'}
size={'small'}
create={{ component: recruit.component.CreateCandidate, label: recruit.string.CreateTalent }}
/>
{/if}
<EmployeeBox
focusIndex={2}
label={recruit.string.AssignRecruiter}
@ -310,22 +354,11 @@
</Card>
<style lang="scss">
// .card {
// align-self: stretch;
// width: calc(50% - 3rem);
// min-height: 16rem;
// &.empty {
// display: flex;
// justify-content: center;
// align-items: center;
// font-size: .75rem;
// color: var(--dark-color);
// border: 1px solid var(--divider-color);
// border-radius: .25rem;
// }
// }
// .arrows { width: 4rem; }
.candidate-vacancy {
display: grid;
grid-template-columns: 3fr 1fr 3fr;
grid-template-rows: 1fr;
}
.color {
margin-right: 0.375rem;
width: 0.875rem;

View File

@ -13,41 +13,69 @@
// limitations under the License.
-->
<script lang="ts">
import attachment from '@anticrm/attachment'
import chunter from '@anticrm/chunter'
import contact, { Channel, Organization } from '@anticrm/contact'
import { ChannelsEditor } from '@anticrm/contact-resources'
import { Ref, WithLookup } from '@anticrm/core'
import { createQuery, getClient } from '@anticrm/presentation'
import type { Vacancy } from '@anticrm/recruit'
import { closePanel, closePopup, closeTooltip, getCurrentLocation, Label, navigate } from '@anticrm/ui'
import VacancyIcon from './icons/Vacancy.svelte'
import contact, { Organization } from '@anticrm/contact'
import { closePanel, closePopup, closeTooltip, Component, getCurrentLocation, Label, navigate } from '@anticrm/ui'
import recruit from '../plugin'
import { getClient } from '@anticrm/presentation'
import { Ref } from '@anticrm/core'
import VacancyIcon from './icons/Vacancy.svelte'
export let vacancy: Vacancy
export let vacancy: WithLookup<Vacancy> | undefined
export let disabled: boolean = false
export let inline: boolean = false
let company: Organization | undefined
$: getOrganization(vacancy?.company)
$: getOrganization(vacancy, vacancy?.company)
const client = getClient()
async function getOrganization (_id: Ref<Organization> | undefined): Promise<void> {
async function getOrganization (
vacancy: WithLookup<Vacancy> | undefined,
_id: Ref<Organization> | undefined
): Promise<void> {
if (vacancy?.$lookup?.company !== undefined) {
company = vacancy.$lookup?.company
}
if (_id === undefined) {
company = undefined
} else {
company = await client.findOne(contact.class.Organization, { _id })
}
}
let channels: Channel[] = []
const channelsQuery = createQuery()
$: if (vacancy?.company !== undefined) {
channelsQuery.query(
contact.class.Channel,
{
attachedTo: vacancy?.company
},
(res) => {
channels = res
}
)
} else {
channelsQuery.unsubscribe()
}
</script>
<div class="flex-col h-full card-container">
<div class="label uppercase"><Label label={recruit.string.Vacancy} /></div>
<div class="flex-center logo">
<VacancyIcon size={'large'} />
</div>
<div class="flex-col h-full card-container" class:inline>
{#if !inline}
<div class="label uppercase"><Label label={recruit.string.Vacancy} /></div>
<div class="flex-center logo">
<VacancyIcon size={'large'} />
</div>
{/if}
{#if vacancy}
<div
class="name lines-limit-2"
class:over-underline={!disabled}
on:click={() => {
if (!disabled) {
if (!disabled && vacancy) {
closeTooltip()
closePopup()
closePanel()
@ -58,12 +86,46 @@
}
}}
>
{vacancy.name}
{#if inline}
<div class="flex-row-center">
<VacancyIcon size={'small'} />
<span class="ml-1">
{vacancy.name}
</span>
</div>
{:else}
{vacancy.name}
{/if}
</div>
{#if company}
<span class="label">{company.name}</span>
{/if}
<div class="description lines-limit-2">{vacancy.description ?? ''}</div>
{#if !inline || vacancy.description}
<div class="description lines-limit-2">{vacancy.description ?? ''}</div>
{/if}
<div class="footer flex flex-reverse flex-grow">
<div class="flex-center flex-wrap">
<Component
is={chunter.component.CommentsPresenter}
props={{ value: vacancy.comments, object: vacancy, size: 'medium', showCounter: true }}
/>
<Component
is={attachment.component.AttachmentsPresenter}
props={{ value: vacancy.attachments, object: vacancy, size: 'medium', showCounter: true }}
/>
</div>
{#if channels[0]}
<div class="flex flex-grow">
<ChannelsEditor
attachedTo={channels[0].attachedTo}
attachedClass={channels[0].attachedToClass}
length={'short'}
editable={false}
/>
</div>
{/if}
</div>
{/if}
</div>
@ -77,6 +139,8 @@
transition-timing-function: var(--timing-shadow);
transition-duration: 0.15s;
user-select: text;
min-width: 15rem;
min-height: 15rem;
&:hover {
background-color: var(--board-card-bg-hover);
@ -85,8 +149,8 @@
}
.logo {
width: 5rem;
height: 5rem;
width: 4.5rem;
height: 4.5rem;
color: var(--primary-button-color);
background-color: var(--primary-button-enabled);
border-radius: 50%;
@ -107,5 +171,22 @@
font-size: 0.75rem;
color: var(--theme-content-dark-color);
}
&.inline {
padding: 0.5rem 0.5rem 0.25rem;
min-width: 1rem;
min-height: 1rem;
background-color: inherit;
border: inherit;
border-radius: inherit;
.name {
margin: 0.25rem 0 0.25rem;
font-size: 0.75rem;
}
.label {
margin-bottom: 0rem;
}
}
}
</style>

View File

@ -0,0 +1,26 @@
<!--
// 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 { WithLookup } from '@anticrm/core'
import type { Vacancy } from '@anticrm/recruit'
import VacancyCard from './VacancyCard.svelte'
export let value: WithLookup<Vacancy>
export let inline: boolean = false
</script>
{#if value}
<VacancyCard vacancy={value} disabled {inline} />
{/if}

View File

@ -30,6 +30,7 @@ export interface Vacancy extends SpaceWithStates {
dueTo?: Timestamp
location?: string
company?: Ref<Organization>
comments?: number
}
/**

View File

@ -53,7 +53,6 @@
},
{
sort: { [orderByKey]: issuesSortOrderMap[orderByKey] },
limit: 200,
lookup: {
assignee: contact.class.Employee,
status: tracker.class.IssueStatus,

View File

@ -122,19 +122,25 @@
if (presenter === undefined) return
if (useMixinProxy) {
result.push({
const newValue = {
value: attribute.attributeOf + '.' + attribute.name,
label: attribute.label,
enabled: false,
_class: attribute.attributeOf
})
}
if (result.find((it) => it._class === newValue._class && it.value === newValue.value) === undefined) {
result.push(newValue)
}
} else {
result.push({
const newValue = {
value,
label: attribute.label,
enabled: false,
_class: attribute.attributeOf
})
}
if (result.find((it) => it._class === newValue._class && it.value === newValue.value) === undefined) {
result.push(newValue)
}
}
}
function getConfig (viewlet: Viewlet, preference: ViewletPreference | undefined): AttributeConfig[] {

View File

@ -126,7 +126,7 @@
notification.class.Notification,
{
attachedTo: account.employee,
status: NotificationStatus.New
status: { $nin: [NotificationStatus.Read] }
},
(res) => {
hasNotification = res.length > 0

View File

@ -13,25 +13,14 @@
// limitations under the License.
-->
<script lang="ts">
import type { Class, Doc, Ref, Space } from '@anticrm/core'
import type { Doc, Ref, Space } from '@anticrm/core'
import core from '@anticrm/core'
import notification from '@anticrm/notification'
import { NotificationClientImpl } from '@anticrm/notification-resources'
import { getResource } from '@anticrm/platform'
import { getClient } from '@anticrm/presentation'
import {
Action,
AnyComponent,
getCurrentLocation,
IconAdd,
IconEdit,
IconSearch,
navigate,
showPanel,
showPopup
} from '@anticrm/ui'
import view from '@anticrm/view'
import preference from '@anticrm/preference'
import { getClient } from '@anticrm/presentation'
import { Action, getCurrentLocation, IconAdd, IconEdit, IconSearch, navigate, showPopup } from '@anticrm/ui'
import { getActions as getContributedActions } from '@anticrm/view-resources'
import { SpacesNavModel } from '@anticrm/workbench'
import { createEventDispatcher } from 'svelte'
@ -69,16 +58,6 @@
}
}
const editSpace: Action = {
label: plugin.string.Open,
icon: IconEdit,
action: async (_id: Ref<Doc>): Promise<void> => {
const editor = await getEditor(model.spaceClass)
dispatch('open')
showPanel(editor ?? plugin.component.SpacePanel, _id, model.spaceClass, 'content')
}
}
const starSpace: Action = {
label: preference.string.Star,
icon: preference.icon.Star,
@ -89,27 +68,19 @@
}
}
async function getEditor (_class: Ref<Class<Doc>>): Promise<AnyComponent | undefined> {
const hierarchy = client.getHierarchy()
const clazz = hierarchy.getClass(_class)
const editorMixin = hierarchy.as(clazz, view.mixin.ObjectEditor)
if (editorMixin?.editor == null && clazz.extends != null) return getEditor(clazz.extends)
return editorMixin.editor
}
function selectSpace (id: Ref<Space>, spaceSpecial?: string) {
dispatch('space', { space: id, spaceSpecial })
}
async function getActions (space: Space): Promise<Action[]> {
const result = [editSpace, starSpace]
const result = [starSpace]
const extraActions = await getContributedActions(client, space, core.class.Space)
for (const act of extraActions) {
result.push({
icon: act.icon ?? IconEdit,
label: act.label,
action: async (evt: Event) => {
action: async (ctx: any, evt: Event) => {
const impl = await getResource(act.action)
await impl(space, evt, act.actionProps)
}

View File

@ -1 +1,2 @@
playwright-report
test-results/*

View File

@ -81,7 +81,7 @@ test.describe('recruit tests', () => {
// await page.click('.applications-container .flex-row-center .flex-center')
await page.click('button[id="appls.add"]')
await page.click('button[id = "space.selector"]')
await page.click('[id = "space.selector"]')
await page.fill('[placeholder="Search..."]', vacancyId)
await page.click(`button:has-text("${vacancyId}")`)
@ -110,7 +110,7 @@ test.describe('recruit tests', () => {
// Create Applicatio n1
await page.click('button:has-text("Application")')
await page.click('form[id="recruit:string:CreateApplication"] button:has-text("Talent")')
await page.click('form[id="recruit:string:CreateApplication"] [id="vacancy.talant.selector"]')
await page.click('button:has-text("Alex P.")')
await page.click('form[id="recruit:string:CreateApplication"] button:has-text("Create")')
await page.waitForSelector('form.antiCard', { state: 'detached' })