mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-22 02:51:54 +03:00
UBER-942: Rework skill optimization (#3941)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
734e200d16
commit
3b135f749a
@ -101,6 +101,9 @@ export class TTxApplyIf extends TTx implements TxApplyIf {
|
||||
// All matches should be false for all documents.
|
||||
notMatch!: DocumentClassQuery<Doc>[]
|
||||
txes!: TxCUD<Doc>[]
|
||||
|
||||
notify!: boolean
|
||||
extraNotify!: Ref<Class<Doc>>[]
|
||||
}
|
||||
|
||||
@Model(core.class.TxWorkspaceEvent, core.class.Doc)
|
||||
|
@ -55,6 +55,28 @@ export const recruitOperation: MigrateOperation = {
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
state: 'wrong-categories',
|
||||
func: async (client): Promise<void> => {
|
||||
const ops = new TxOperations(client, core.account.System)
|
||||
while (true) {
|
||||
const docs = await ops.findAll(
|
||||
tags.class.TagElement,
|
||||
{
|
||||
targetClass: recruit.mixin.Candidate,
|
||||
category: { $in: [tracker.category.Other, 'document:category:Other' as Ref<TagCategory>] }
|
||||
},
|
||||
{ limit: 1000 }
|
||||
)
|
||||
for (const d of docs) {
|
||||
await ops.update(d, { category: recruit.category.Other })
|
||||
}
|
||||
if (docs.length === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
@ -43,8 +43,8 @@ export function createModel (builder: Builder): void {
|
||||
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
|
||||
trigger: serverTracker.trigger.OnComponentRemove,
|
||||
txMatch: {
|
||||
_class: core.class.TxCollectionCUD,
|
||||
'tx._class': core.class.TxCreateDoc
|
||||
_class: core.class.TxRemoveDoc,
|
||||
objectClass: tracker.class.Component
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -441,10 +441,18 @@ export class ApplyOperations extends TxOperations {
|
||||
return this
|
||||
}
|
||||
|
||||
async commit (): Promise<boolean> {
|
||||
async commit (notify: boolean = true, extraNotify: Ref<Class<Doc>>[] = []): Promise<boolean> {
|
||||
if (this.txes.length > 0) {
|
||||
return await ((await this.ops.tx(
|
||||
this.ops.txFactory.createTxApplyIf(core.space.Tx, this.scope, this.matches, this.notMatches, this.txes)
|
||||
this.ops.txFactory.createTxApplyIf(
|
||||
core.space.Tx,
|
||||
this.scope,
|
||||
this.matches,
|
||||
this.notMatches,
|
||||
this.txes,
|
||||
notify,
|
||||
extraNotify
|
||||
)
|
||||
)) as Promise<boolean>)
|
||||
}
|
||||
return true
|
||||
|
@ -51,16 +51,17 @@ export enum WorkspaceEvent {
|
||||
Upgrade,
|
||||
IndexingUpdate,
|
||||
SecurityChange,
|
||||
MaintenanceNotification
|
||||
MaintenanceNotification,
|
||||
BulkUpdate
|
||||
}
|
||||
|
||||
/**
|
||||
* Event to be send by server during model upgrade procedure.
|
||||
* @public
|
||||
*/
|
||||
export interface TxWorkspaceEvent extends Tx {
|
||||
export interface TxWorkspaceEvent<T = any> extends Tx {
|
||||
event: WorkspaceEvent
|
||||
params: any
|
||||
params: T
|
||||
}
|
||||
|
||||
/**
|
||||
@ -70,6 +71,13 @@ export interface IndexingUpdateEvent {
|
||||
_class: Ref<Class<Doc>>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface BulkUpdateEvent {
|
||||
_class: Ref<Class<Doc>>[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
@ -125,6 +133,11 @@ export interface TxApplyIf extends Tx {
|
||||
|
||||
// If all matched execute following transactions.
|
||||
txes: TxCUD<Doc>[]
|
||||
|
||||
notify?: boolean // If false will not send notifications.
|
||||
|
||||
// If passed, will send WorkspaceEvent.BulkUpdate event with list of classes to update
|
||||
extraNotify?: Ref<Class<Doc>>[]
|
||||
}
|
||||
|
||||
/**
|
||||
@ -591,6 +604,8 @@ export class TxFactory {
|
||||
match: DocumentClassQuery<Doc>[],
|
||||
notMatch: DocumentClassQuery<Doc>[],
|
||||
txes: TxCUD<Doc>[],
|
||||
notify: boolean = true,
|
||||
extraNotify: Ref<Class<Doc>>[] = [],
|
||||
modifiedOn?: Timestamp,
|
||||
modifiedBy?: Ref<Account>
|
||||
): TxApplyIf {
|
||||
@ -604,7 +619,9 @@ export class TxFactory {
|
||||
scope,
|
||||
match,
|
||||
notMatch,
|
||||
txes
|
||||
txes,
|
||||
notify,
|
||||
extraNotify
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,7 @@
|
||||
IconCheck,
|
||||
IconSearch,
|
||||
ListView,
|
||||
Spinner,
|
||||
createFocusManager,
|
||||
deviceOptionsStore,
|
||||
resizeObserver,
|
||||
@ -59,6 +60,7 @@
|
||||
export let disallowDeselect: Ref<Doc>[] | undefined = undefined
|
||||
export let created: Doc[] = []
|
||||
export let embedded: boolean = false
|
||||
export let loading = false
|
||||
|
||||
let search: string = ''
|
||||
|
||||
@ -185,7 +187,7 @@
|
||||
icon={IconAdd}
|
||||
showTooltip={{ label: create.label }}
|
||||
on:click={onCreate}
|
||||
disabled={readonly}
|
||||
disabled={readonly || loading}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@ -210,20 +212,24 @@
|
||||
{@const isDeselectDisabled = selectedElements.has(obj._id) && forbiddenDeselectItemIds.has(obj._id)}
|
||||
<button
|
||||
class="menu-item withList w-full flex-row-center"
|
||||
disabled={readonly || isDeselectDisabled}
|
||||
disabled={readonly || isDeselectDisabled || loading}
|
||||
on:click={() => {
|
||||
handleSelection(undefined, objects, item)
|
||||
}}
|
||||
>
|
||||
<span class="label" class:disabled={readonly || isDeselectDisabled}>
|
||||
<span class="label" class:disabled={readonly || isDeselectDisabled || loading}>
|
||||
<slot name="item" item={obj} />
|
||||
</span>
|
||||
{#if (allowDeselect && selected) || multiSelect || selected}
|
||||
<div class="check" class:disabled={readonly}>
|
||||
{#if obj._id === selected || selectedElements.has(obj._id)}
|
||||
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
|
||||
<Icon icon={IconCheck} size={'small'} />
|
||||
</div>
|
||||
{#if loading}
|
||||
<Spinner size={'small'} />
|
||||
{:else}
|
||||
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
|
||||
<Icon icon={IconCheck} size={'small'} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
@ -16,11 +16,11 @@
|
||||
import type { Class, Doc, DocumentQuery, FindOptions, Ref } from '@hcengineering/core'
|
||||
import type { IntlString } from '@hcengineering/platform'
|
||||
import { Label } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import presentation from '..'
|
||||
import { ObjectCreate } from '../types'
|
||||
import { createQuery } from '../utils'
|
||||
import DocPopup from './DocPopup.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let _class: Ref<Class<Doc>>
|
||||
export let options: FindOptions<Doc> | undefined = undefined
|
||||
@ -47,6 +47,7 @@
|
||||
export let readonly = false
|
||||
export let disallowDeselect: Ref<Doc>[] | undefined = undefined
|
||||
export let embedded: boolean = false
|
||||
export let loading = false
|
||||
|
||||
export let filter: (it: Doc) => boolean = () => {
|
||||
return true
|
||||
@ -110,6 +111,7 @@
|
||||
{readonly}
|
||||
{disallowDeselect}
|
||||
{embedded}
|
||||
{loading}
|
||||
on:update
|
||||
on:close
|
||||
on:changeContent
|
||||
|
@ -28,6 +28,7 @@ import core, {
|
||||
getObjectValue,
|
||||
Hierarchy,
|
||||
IndexingUpdateEvent,
|
||||
BulkUpdateEvent,
|
||||
Lookup,
|
||||
LookupData,
|
||||
matchQuery,
|
||||
@ -1034,6 +1035,31 @@ export class LiveQuery extends TxProcessor implements Client {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (evt.event === WorkspaceEvent.BulkUpdate) {
|
||||
const params = evt.params as BulkUpdateEvent
|
||||
for (const q of [...this.queue]) {
|
||||
if (params._class.includes(q._class)) {
|
||||
if (!this.removeFromQueue(q)) {
|
||||
try {
|
||||
await this.refresh(q)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const v of this.queries.values()) {
|
||||
for (const q of v) {
|
||||
if (params._class.includes(q._class)) {
|
||||
try {
|
||||
await this.refresh(q)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async changePrivateHandler (tx: Tx): Promise<void> {
|
||||
|
@ -21,6 +21,7 @@
|
||||
import Icon from './Icon.svelte'
|
||||
import Button from './Button.svelte'
|
||||
import IconClose from './icons/Close.svelte'
|
||||
import Spinner from './Spinner.svelte'
|
||||
|
||||
export let icon: Asset | AnySvelteComponent | ComponentType
|
||||
export let width: string | undefined = undefined
|
||||
@ -29,6 +30,7 @@
|
||||
export let placeholderParam: any | undefined = undefined
|
||||
export let autoFocus: boolean = false
|
||||
export let size: 'small' | 'medium' | 'large' = 'medium'
|
||||
export let loading = false
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let textHTML: HTMLInputElement
|
||||
@ -52,21 +54,28 @@
|
||||
<div class="mr-2 content-dark-color"><Icon {icon} size={'small'} /></div>
|
||||
<input bind:this={textHTML} type="text" bind:value placeholder={phTraslate} on:change on:input on:keydown />
|
||||
<slot name="extra" />
|
||||
{#if value}
|
||||
<div class="ml-2">
|
||||
<Button
|
||||
icon={IconClose}
|
||||
kind={'ghost'}
|
||||
size={'small'}
|
||||
noFocus
|
||||
on:click={() => {
|
||||
value = ''
|
||||
dispatch('change', '')
|
||||
textHTML.focus()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-row-center">
|
||||
{#if value}
|
||||
<div class="ml-2">
|
||||
<Button
|
||||
icon={IconClose}
|
||||
kind={'ghost'}
|
||||
size={'small'}
|
||||
noFocus
|
||||
on:click={() => {
|
||||
value = ''
|
||||
dispatch('change', '')
|
||||
textHTML.focus()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div class="ml-1 mr-2">
|
||||
<Spinner size={'medium'} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -148,17 +148,17 @@
|
||||
class:anim={element === 'float' || element === 'centered'}
|
||||
bind:this={modalHTML}
|
||||
style={`z-index: ${zIndex + 1};`}
|
||||
style:top={options.props.top}
|
||||
style:bottom={options.props.bottom}
|
||||
style:left={options.props.left}
|
||||
style:right={options.props.right}
|
||||
style:width={options.props.width}
|
||||
style:height={options.props.height}
|
||||
style:max-width={options.props.maxWidth}
|
||||
style:max-height={options.props.maxHeight}
|
||||
style:min-width={options.props.minWidth}
|
||||
style:min-height={options.props.minHeight}
|
||||
style:transform={options.props.transform}
|
||||
style:top={options?.props?.top}
|
||||
style:bottom={options?.props?.bottom}
|
||||
style:left={options?.props?.left}
|
||||
style:right={options?.props?.right}
|
||||
style:width={options?.props?.width}
|
||||
style:height={options?.props?.height}
|
||||
style:max-width={options?.props?.maxWidth}
|
||||
style:max-height={options?.props?.maxHeight}
|
||||
style:min-width={options?.props?.minWidth}
|
||||
style:min-height={options?.props?.minHeight}
|
||||
style:transform={options?.props?.transform}
|
||||
use:resizeObserver={(element) => {
|
||||
clientWidth = element.clientWidth
|
||||
clientHeight = element.clientHeight
|
||||
@ -187,7 +187,7 @@
|
||||
{#if overlay}
|
||||
<div
|
||||
class="modal-overlay"
|
||||
class:antiOverlay={options.showOverlay}
|
||||
class:antiOverlay={options?.showOverlay}
|
||||
style={`z-index: ${zIndex};`}
|
||||
on:click={handleOverlayClick}
|
||||
on:keydown|stopPropagation|preventDefault={() => {}}
|
||||
|
@ -23,6 +23,7 @@
|
||||
import Icon from './Icon.svelte'
|
||||
import Label from './Label.svelte'
|
||||
import ListView from './ListView.svelte'
|
||||
import Spinner from './Spinner.svelte'
|
||||
import IconCheck from './icons/Check.svelte'
|
||||
import IconSearch from './icons/Search.svelte'
|
||||
|
||||
@ -35,6 +36,7 @@
|
||||
export let onSelect: ((value: SelectPopupValueType['id']) => void) | undefined = undefined
|
||||
export let showShadow: boolean = true
|
||||
export let embedded: boolean = false
|
||||
export let loading = false
|
||||
|
||||
let popupElement: HTMLDivElement | undefined = undefined
|
||||
let search: string = ''
|
||||
@ -46,7 +48,10 @@
|
||||
let selection = 0
|
||||
let list: ListView
|
||||
|
||||
let selected: any
|
||||
|
||||
function sendSelect (id: SelectPopupValueType['id']): void {
|
||||
selected = id
|
||||
if (onSelect) {
|
||||
onSelect(id)
|
||||
} else {
|
||||
@ -129,7 +134,7 @@
|
||||
>
|
||||
<svelte:fragment slot="item" let:item={itemId}>
|
||||
{@const item = filteredObjects[itemId]}
|
||||
<button class="menu-item withList w-full" on:click={() => sendSelect(item.id)}>
|
||||
<button class="menu-item withList w-full" on:click={() => sendSelect(item.id)} disabled={loading}>
|
||||
<div class="flex-row-center flex-grow pointer-events-none">
|
||||
{#if item.component}
|
||||
<div class="flex-grow clear-mins"><svelte:component this={item.component} {...item.props} /></div>
|
||||
@ -154,6 +159,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.id === selected && loading}
|
||||
<Spinner size={'small'} />
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
@ -162,11 +170,6 @@
|
||||
{#if obj.category && ((row === 0 && obj.category.label !== undefined) || obj.category.label !== filteredObjects[row - 1]?.category?.label)}
|
||||
{#if row > 0}<div class="menu-separator" />{/if}
|
||||
<div class="menu-group__header flex-row-center">
|
||||
<!-- {#if obj.category.icon}
|
||||
<div class="flex-no-shrink mr-2">
|
||||
<Icon icon={obj.category.icon} size={'small'} />
|
||||
</div>
|
||||
{/if} -->
|
||||
<span class="overflow-label">
|
||||
<Label label={obj.category.label} />
|
||||
</span>
|
||||
|
@ -14,11 +14,12 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Event } from '@hcengineering/calendar'
|
||||
import { Doc } from '@hcengineering/core'
|
||||
import { DateRangeMode, Doc } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { DatePresenter, DateTimeRangePresenter, Label, showPopup } from '@hcengineering/ui'
|
||||
import view, { ObjectEditor } from '@hcengineering/view'
|
||||
import calendar from '../plugin'
|
||||
import DateRangePresenter from '@hcengineering/ui/src/components/calendar/DateRangePresenter.svelte'
|
||||
|
||||
export let value: Event
|
||||
export let hideDetails: boolean = false
|
||||
@ -50,7 +51,10 @@
|
||||
{#if value.allDay}
|
||||
<DatePresenter value={value.date} />
|
||||
{:else}
|
||||
<DateTimeRangePresenter value={value.date} />
|
||||
<div class="flex-row-center">
|
||||
<DateTimeRangePresenter value={value.date} /> <span class="p-1">-</span>
|
||||
<DateRangePresenter value={value.dueDate} mode={DateRangeMode.TIME} editable={false} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
@ -55,7 +55,8 @@ export {
|
||||
EventTimeExtraButton,
|
||||
EventReminders,
|
||||
VisibilityEditor,
|
||||
CalendarSelector
|
||||
CalendarSelector,
|
||||
EventPresenter
|
||||
}
|
||||
|
||||
export type {
|
||||
|
@ -245,26 +245,30 @@ class Connection implements ClientConnection {
|
||||
}
|
||||
void broadcastEvent(client.event.NetworkRequests, this.requests.size)
|
||||
} else {
|
||||
const tx = resp.result as Tx
|
||||
if (
|
||||
(tx?._class === core.class.TxWorkspaceEvent && (tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) ||
|
||||
tx?._class === core.class.TxModelUpgrade
|
||||
) {
|
||||
console.log('Processing upgrade')
|
||||
websocket.send(
|
||||
serialize(
|
||||
{
|
||||
method: '#upgrading',
|
||||
params: [],
|
||||
id: -1
|
||||
},
|
||||
false
|
||||
const txArr = Array.isArray(resp.result) ? (resp.result as Tx[]) : [resp.result as Tx]
|
||||
|
||||
for (const tx of txArr) {
|
||||
if (
|
||||
(tx?._class === core.class.TxWorkspaceEvent &&
|
||||
(tx as TxWorkspaceEvent).event === WorkspaceEvent.Upgrade) ||
|
||||
tx?._class === core.class.TxModelUpgrade
|
||||
) {
|
||||
console.log('Processing upgrade')
|
||||
websocket.send(
|
||||
serialize(
|
||||
{
|
||||
method: '#upgrading',
|
||||
params: [],
|
||||
id: -1
|
||||
},
|
||||
false
|
||||
)
|
||||
)
|
||||
)
|
||||
this.onUpgrade?.()
|
||||
return
|
||||
this.onUpgrade?.()
|
||||
return
|
||||
}
|
||||
this.handler(tx)
|
||||
}
|
||||
this.handler(tx)
|
||||
|
||||
clearTimeout(this.incomingTimer)
|
||||
void broadcastEvent(client.event.NetworkRequests, this.requests.size + 1)
|
||||
@ -292,7 +296,8 @@ class Connection implements ClientConnection {
|
||||
params: [],
|
||||
id: -1,
|
||||
binary: useBinary,
|
||||
compression: useCompression
|
||||
compression: useCompression,
|
||||
broadcast: true
|
||||
}
|
||||
websocket.send(serialize(helloRequest, false))
|
||||
}
|
||||
|
@ -26,6 +26,7 @@
|
||||
IconSearch,
|
||||
Label,
|
||||
ListView,
|
||||
Spinner,
|
||||
createFocusManager,
|
||||
deviceOptionsStore,
|
||||
resizeObserver,
|
||||
@ -49,6 +50,7 @@
|
||||
export let width: 'medium' | 'large' | 'full' = 'medium'
|
||||
export let searchField: string = 'name'
|
||||
export let icon: Asset | AnySvelteComponent | undefined = undefined
|
||||
export let loading = false
|
||||
|
||||
$: showCategories = categories !== undefined && categories.length > 0
|
||||
|
||||
@ -77,7 +79,14 @@
|
||||
{ ...(options ?? {}), limit: 200, sort: { name: 1 } }
|
||||
)
|
||||
|
||||
$: updateCategories(objects, categories)
|
||||
let dataLoading = false
|
||||
|
||||
$: {
|
||||
dataLoading = true
|
||||
updateCategories(objects, categories).then(() => {
|
||||
dataLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
const currentUserCategory: AssigneeCategory = {
|
||||
label: contact.string.CategoryCurrentUser,
|
||||
@ -117,6 +126,7 @@
|
||||
if (c) {
|
||||
contacts.push(c)
|
||||
}
|
||||
contacts = contacts
|
||||
})
|
||||
}
|
||||
|
||||
@ -174,6 +184,7 @@
|
||||
bind:value={search}
|
||||
{placeholder}
|
||||
{placeholderParam}
|
||||
loading={dataLoading}
|
||||
on:change
|
||||
/>
|
||||
</div>
|
||||
@ -209,13 +220,17 @@
|
||||
<UserInfo size={'smaller'} value={obj} {icon} />
|
||||
</div>
|
||||
{#if allowDeselect && selected}
|
||||
<div class="check">
|
||||
{#if obj._id === selected}
|
||||
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
|
||||
<Icon icon={IconCheck} size={'small'} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if loading && obj._id === selected}
|
||||
<Spinner size={'small'} />
|
||||
{:else}
|
||||
<div class="check">
|
||||
{#if obj._id === selected}
|
||||
<div use:tooltip={{ label: titleDeselect ?? presentation.string.Deselect }}>
|
||||
<Icon icon={IconCheck} size={'small'} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
</svelte:fragment>
|
||||
|
@ -157,17 +157,23 @@
|
||||
const elementQuery = createQuery()
|
||||
let elementsPromise: Promise<void>
|
||||
$: elementsPromise = new Promise((resolve) => {
|
||||
elementQuery.query(tags.class.TagElement, {}, (result) => {
|
||||
const ne = new Map<Ref<TagElement>, TagElement>()
|
||||
const nne = new Map<string, TagElement>()
|
||||
for (const t of newElements.concat(result)) {
|
||||
ne.set(t._id, t)
|
||||
nne.set(t.title.trim().toLowerCase(), t)
|
||||
elementQuery.query(
|
||||
tags.class.TagElement,
|
||||
{
|
||||
targetClass: recruit.mixin.Candidate
|
||||
},
|
||||
(result) => {
|
||||
const ne = new Map<Ref<TagElement>, TagElement>()
|
||||
const nne = new Map<string, TagElement>()
|
||||
for (const t of newElements.concat(result)) {
|
||||
ne.set(t._id, t)
|
||||
nne.set(t.title.trim().toLowerCase(), t)
|
||||
}
|
||||
elements = ne
|
||||
namedElements = nne
|
||||
resolve()
|
||||
}
|
||||
elements = ne
|
||||
namedElements = nne
|
||||
resolve()
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
async function createCandidate () {
|
||||
|
551
plugins/recruit-resources/src/components/OptimizeSkills.svelte
Normal file
551
plugins/recruit-resources/src/components/OptimizeSkills.svelte
Normal file
@ -0,0 +1,551 @@
|
||||
<!--
|
||||
// Copyright © 2023 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 { Class, Doc, Ref, toIdMap } from '@hcengineering/core'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import { Card, getClient } from '@hcengineering/presentation'
|
||||
import tags, { TagCategory, TagElement, TagReference } from '@hcengineering/tags'
|
||||
import { EditBox, ListView, Loading } from '@hcengineering/ui'
|
||||
import { FILTER_DEBOUNCE_MS } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import recruit from '../plugin'
|
||||
|
||||
export let targetClass: Ref<Class<Doc>>
|
||||
|
||||
export function canClose (): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// const query = createQuery()
|
||||
// const elementsQuery = createQuery()
|
||||
// const refsQuery = createQuery()
|
||||
|
||||
let categories: TagCategory[] = []
|
||||
let elements: TagElement[] = []
|
||||
// let refs: TagReference[] = []
|
||||
|
||||
let loading1: boolean = false
|
||||
let loading2: boolean = false
|
||||
$: {
|
||||
loading1 = true
|
||||
getClient()
|
||||
.findAll(tags.class.TagCategory, { targetClass })
|
||||
.then((result) => {
|
||||
categories = result
|
||||
loading1 = false
|
||||
})
|
||||
}
|
||||
|
||||
$: {
|
||||
loading2 = true
|
||||
getClient()
|
||||
.findAll(
|
||||
tags.class.TagElement,
|
||||
{ category: { $in: Array.from(categories.map((it) => it._id)) } },
|
||||
{ sort: { title: 1 } }
|
||||
)
|
||||
.then((res) => {
|
||||
elements = res.toSorted((a, b) => prepareTitle(a.title).localeCompare(prepareTitle(b.title)))
|
||||
loading2 = false
|
||||
})
|
||||
}
|
||||
|
||||
// $: refsQuery.query(tags.class.TagReference, { tag: { $in: Array.from(elements.map((it) => it._id)) } }, (res) => {
|
||||
// refs = res
|
||||
// })
|
||||
const selection = 0
|
||||
$: selEl = elements[selection]
|
||||
|
||||
interface TagUpdatePlan {
|
||||
// Just updated or new elements
|
||||
elements: {
|
||||
original: TagElement
|
||||
element?: TagElement
|
||||
move: Ref<TagElement>[]
|
||||
toDelete: boolean
|
||||
total?: number
|
||||
}[]
|
||||
move: number
|
||||
}
|
||||
|
||||
function prepareTitle (title: string): string {
|
||||
// Replace all non letter or digit characters with spaces
|
||||
let result = ''
|
||||
let last = ''
|
||||
for (const c of title) {
|
||||
if (c === '-') {
|
||||
continue
|
||||
}
|
||||
if (isLetterOrDigit(c) || (result.length > 0 && (c === '+' || c === '#'))) {
|
||||
if (c !== '+' && last === '+') {
|
||||
// Remove + in the middle
|
||||
result = result.substring(0, result.length - 1)
|
||||
}
|
||||
if (c !== '#' && last === '#') {
|
||||
// Remove # in the middle
|
||||
result = result.substring(0, result.length - 1)
|
||||
}
|
||||
result += c
|
||||
last = c
|
||||
} else {
|
||||
if (last !== ' ') {
|
||||
result += ' '
|
||||
last = ' '
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.trim()
|
||||
}
|
||||
|
||||
function isLetter (c?: string): boolean {
|
||||
if (c == null) {
|
||||
return false
|
||||
}
|
||||
return c.toLowerCase() !== c.toUpperCase()
|
||||
}
|
||||
|
||||
function isDigit (c?: string): boolean {
|
||||
return (
|
||||
c === '0' ||
|
||||
c === '1' ||
|
||||
c === '2' ||
|
||||
c === '3' ||
|
||||
c === '4' ||
|
||||
c === '5' ||
|
||||
c === '6' ||
|
||||
c === '7' ||
|
||||
c === '8' ||
|
||||
c === '9'
|
||||
)
|
||||
}
|
||||
|
||||
function isLetterOrDigit (c?: string): boolean {
|
||||
if (c == null) {
|
||||
return false
|
||||
}
|
||||
if (isDigit(c)) {
|
||||
return true
|
||||
}
|
||||
return c.toLowerCase() !== c.toUpperCase()
|
||||
}
|
||||
|
||||
function isForRemove (cc: string): boolean {
|
||||
for (const c of cc) {
|
||||
if (isLetter(c)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Only digits and non letters
|
||||
return true
|
||||
}
|
||||
|
||||
let plan: TagUpdatePlan = {
|
||||
elements: [],
|
||||
move: 0
|
||||
}
|
||||
// Will return a set of operations over tag elements
|
||||
async function updateTagsList (tagElements: TagElement[]): Promise<void> {
|
||||
const _plan: TagUpdatePlan = {
|
||||
elements: [],
|
||||
move: 0
|
||||
}
|
||||
|
||||
const tagMap = toIdMap(tagElements)
|
||||
|
||||
const namedElements = new Map<string, Ref<TagElement>>()
|
||||
const goodTags: TagElement[] = []
|
||||
for (const tag of tagElements) {
|
||||
if (tag.category.indexOf(recruit.category.Category) >= 0) {
|
||||
namedElements.set(prepareTitle(tag.title.toLowerCase()), tag._id)
|
||||
goodTags.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
const expertRefs = await getClient().findAll(
|
||||
tags.class.TagReference,
|
||||
{
|
||||
tag: {
|
||||
$in: Array.from(tagElements.map((it) => it._id))
|
||||
},
|
||||
weight: { $gt: 5 } // We need expert ones.
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
tag: 1,
|
||||
_id: 1,
|
||||
title: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const toGoodTags = Array.from(new Set(expertRefs.map((it) => it.tag)))
|
||||
.map((it) => tagMap.get(it))
|
||||
.filter((it) => it) as TagElement[]
|
||||
let goodTagMap = toIdMap(goodTags)
|
||||
|
||||
for (const tag of toGoodTags) {
|
||||
const tt = prepareTitle(tag.title.toLowerCase())
|
||||
if (!goodTagMap.has(tag._id) && !namedElements.has(tt)) {
|
||||
namedElements.set(tt, tag._id)
|
||||
goodTags.push(tag)
|
||||
}
|
||||
}
|
||||
goodTagMap = toIdMap(goodTags)
|
||||
|
||||
const goodSortedTags = goodTags
|
||||
.toSorted((a, b) => b.title.length - a.title.length)
|
||||
.filter((t) => t.title.length > 2)
|
||||
const goodSortedTagsTitles = new Map<Ref<TagElement>, string>()
|
||||
processed = -1
|
||||
for (const tag of tagElements.toSorted((a, b) => prepareTitle(a.title).length - prepareTitle(b.title).length)) {
|
||||
processed++
|
||||
if (goodTagMap.has(tag._id)) {
|
||||
_plan.elements.push({
|
||||
original: tag,
|
||||
move: [],
|
||||
toDelete: false,
|
||||
total: -1
|
||||
})
|
||||
continue
|
||||
}
|
||||
let title = prepareTitle(tag.title)
|
||||
if (title.length === 1) {
|
||||
_plan.elements.push({
|
||||
original: tag,
|
||||
move: [],
|
||||
toDelete: true
|
||||
})
|
||||
continue
|
||||
}
|
||||
const namedIdx = namedElements.get(prepareTitle(title.toLowerCase()))
|
||||
if (namedIdx !== undefined || title.length === 0) {
|
||||
_plan.elements.push({
|
||||
original: tag,
|
||||
move: namedIdx !== undefined ? [namedIdx] : [],
|
||||
toDelete: true
|
||||
})
|
||||
_plan.move++
|
||||
continue
|
||||
}
|
||||
|
||||
// Search for included tags
|
||||
const toReplace = goodSortedTags.filter((t) => {
|
||||
const lowTitle = title.toLowerCase()
|
||||
let tt = goodSortedTagsTitles.get(t._id)
|
||||
if (tt === undefined) {
|
||||
tt = prepareTitle(t.title.toLowerCase())
|
||||
goodSortedTagsTitles.set(t._id, tt)
|
||||
}
|
||||
if (lowTitle.indexOf(tt) !== -1) {
|
||||
// We need to be sure we have some non word character at the end of match
|
||||
let spos = 0
|
||||
while (true) {
|
||||
const pos = title.toLowerCase().indexOf(tt, spos)
|
||||
if (pos === -1) {
|
||||
return false
|
||||
}
|
||||
if (!isLetter(title[pos - 1]) && !isLetter(title[pos + tt.length])) {
|
||||
// Begin and end
|
||||
return true
|
||||
}
|
||||
spos = pos + 1
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if (toReplace.length > 0) {
|
||||
const mve: TagUpdatePlan['elements'][0] = {
|
||||
original: tag,
|
||||
move: [],
|
||||
toDelete: false
|
||||
}
|
||||
for (const t of toReplace) {
|
||||
let tt = goodSortedTagsTitles.get(t._id)
|
||||
if (tt === undefined) {
|
||||
tt = prepareTitle(t.title.toLowerCase())
|
||||
goodSortedTagsTitles.set(t._id, tt)
|
||||
}
|
||||
|
||||
let p = 0
|
||||
while (true) {
|
||||
const pos = title.toLowerCase().indexOf(tt.toLowerCase())
|
||||
if (pos === -1) {
|
||||
break
|
||||
}
|
||||
p++
|
||||
title = prepareTitle((title.substring(0, pos) + ' ' + title.substring(pos + tt.length)).replace(' ', ' '))
|
||||
}
|
||||
if (p === 0) {
|
||||
continue
|
||||
}
|
||||
mve.move.push(t._id)
|
||||
}
|
||||
const namedIdx = namedElements.get(prepareTitle(title).toLowerCase())
|
||||
if (namedIdx !== undefined) {
|
||||
title = ''
|
||||
mve.move.push(namedIdx)
|
||||
}
|
||||
mve.element = { ...tag, title: prepareTitle(title) }
|
||||
mve.toDelete = prepareTitle(title).length <= 1 || isForRemove(title)
|
||||
|
||||
if (isForRemove(title)) {
|
||||
mve.element.title = ''
|
||||
} else {
|
||||
// Candidate to have in list.
|
||||
const refs = await getClient().findAll(tags.class.TagReference, { tag: tag._id }, { limit: 2, total: true })
|
||||
if (refs.length < 2) {
|
||||
mve.toDelete = true
|
||||
mve.total = refs.total
|
||||
}
|
||||
}
|
||||
if (!mve.toDelete) {
|
||||
namedElements.set(prepareTitle(mve.element.title.toLowerCase()), tag._id)
|
||||
goodSortedTags.push(mve.element)
|
||||
goodSortedTags.sort((a, b) => b.title.length - a.title.length).filter((t) => t.title.length > 2)
|
||||
goodSortedTagsTitles.delete(mve.element._id)
|
||||
}
|
||||
|
||||
_plan.elements.push(mve)
|
||||
_plan.move++
|
||||
continue
|
||||
}
|
||||
|
||||
// Candidate to have in list.
|
||||
const refs = await getClient().findAll(tags.class.TagReference, { tag: tag._id }, { limit: 2, total: true })
|
||||
if (isForRemove(title) || refs.length < 2) {
|
||||
_plan.elements.push({
|
||||
original: tag,
|
||||
move: [],
|
||||
toDelete: true
|
||||
})
|
||||
_plan.move++
|
||||
continue
|
||||
}
|
||||
namedElements.set(prepareTitle(title.toLowerCase()), tag._id)
|
||||
const ee = {
|
||||
original: tag,
|
||||
element: { ...tag, title },
|
||||
move: [],
|
||||
toDelete: false,
|
||||
total: refs.total
|
||||
}
|
||||
|
||||
_plan.elements.push(ee)
|
||||
if (ee.element?.title.length > 2) {
|
||||
goodSortedTags.push(ee.element)
|
||||
goodSortedTagsTitles.delete(ee.element._id)
|
||||
goodSortedTags.sort((a, b) => b.title.length - a.title.length).filter((t) => t.title.length > 2)
|
||||
}
|
||||
goodTags.push(ee.element)
|
||||
}
|
||||
_plan.elements.sort((a, b) => prepareTitle(a.original.title).localeCompare(prepareTitle(b.original.title)))
|
||||
plan = _plan
|
||||
processed = 0
|
||||
}
|
||||
|
||||
let doProcessing = false
|
||||
$: {
|
||||
doProcessing = true
|
||||
updateTagsList(elements).then(() => {
|
||||
doProcessing = false
|
||||
})
|
||||
}
|
||||
let search: string = ''
|
||||
|
||||
let _search: string = ''
|
||||
|
||||
$: setTimeout(() => {
|
||||
_search = search
|
||||
}, FILTER_DEBOUNCE_MS)
|
||||
|
||||
$: searchPlanElements = plan.elements.filter(
|
||||
(it) => it.original.title.toLowerCase().indexOf(_search.toLowerCase()) !== -1
|
||||
)
|
||||
|
||||
let processed: number = 0
|
||||
|
||||
async function applyPlan (): Promise<void> {
|
||||
processed = 0
|
||||
const updateClasses = new Set<Ref<Class<Doc>>>()
|
||||
const client = getClient()
|
||||
for (const item of searchPlanElements) {
|
||||
console.log('Apply', item.original.title)
|
||||
const st = Date.now()
|
||||
const ops = client.apply('optimize:' + item.original._id)
|
||||
let allRefs: TagReference[] = await client.findAll(tags.class.TagReference, { tag: item.original._id })
|
||||
|
||||
allRefs.sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0))
|
||||
|
||||
const uniqRefs = new Set()
|
||||
const uniqTags = new Set()
|
||||
for (const d of allRefs) {
|
||||
if (!uniqTags.has(d.tag + d.attachedTo)) {
|
||||
uniqTags.add(d.tag + d.attachedTo)
|
||||
uniqRefs.add(d._id)
|
||||
} else {
|
||||
await ops.remove(d)
|
||||
}
|
||||
}
|
||||
|
||||
allRefs = allRefs.filter((it) => uniqRefs.has(it._id))
|
||||
|
||||
if (item.move.length > 0) {
|
||||
// We need to find all objects and add new tag elements to them and preserve skill level
|
||||
for (const a of allRefs) {
|
||||
for (const m of item.move) {
|
||||
const me = plan.elements.find((it) => it.original._id === m && it.toDelete === false)
|
||||
if (me !== undefined) {
|
||||
const id = await ops.addCollection(
|
||||
tags.class.TagReference,
|
||||
a.space,
|
||||
a.attachedTo,
|
||||
a.attachedToClass,
|
||||
a.collection,
|
||||
{
|
||||
tag: m,
|
||||
color: me.original.color,
|
||||
title: me.element?.title ?? me.original.title,
|
||||
weight: a.weight
|
||||
}
|
||||
)
|
||||
uniqRefs.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// We could delete original and it's all refs
|
||||
if (item.toDelete) {
|
||||
updateClasses.add(item.original._class)
|
||||
await ops.remove(item.original)
|
||||
for (const a of allRefs) {
|
||||
updateClasses.add(a._class)
|
||||
await ops.remove(a)
|
||||
}
|
||||
} else {
|
||||
if (item.element !== undefined) {
|
||||
updateClasses.add(item.original._class)
|
||||
for (const a of allRefs) {
|
||||
if (prepareTitle(a.title.toLowerCase()) === prepareTitle(item.element.title.toLowerCase())) {
|
||||
updateClasses.add(a._class)
|
||||
await ops.diffUpdate(a, {
|
||||
title: item.element.title,
|
||||
color: item.element.color
|
||||
})
|
||||
} else {
|
||||
updateClasses.add(a._class)
|
||||
// Let's remove and add new tag reference
|
||||
await ops.remove(a)
|
||||
updateClasses.add(tags.class.TagReference)
|
||||
await ops.addCollection(tags.class.TagReference, a.space, a.attachedTo, a.attachedToClass, a.collection, {
|
||||
tag: item.element._id,
|
||||
color: item.element.color,
|
||||
title: item.element.title,
|
||||
weight: a.weight
|
||||
})
|
||||
}
|
||||
}
|
||||
await ops.diffUpdate(item.original, {
|
||||
...item.element,
|
||||
refCount: uniqRefs.size
|
||||
})
|
||||
}
|
||||
}
|
||||
console.log('Apply:commit', item.original.title, Date.now() - st)
|
||||
await ops.commit(false)
|
||||
processed++
|
||||
}
|
||||
const ops = client.apply('optimize:done')
|
||||
await ops.commit(true, Array.from(updateClasses))
|
||||
console.log('Apply:done')
|
||||
processed = 0
|
||||
}
|
||||
function toColor (el: TagUpdatePlan['elements'][0]): string | undefined {
|
||||
if (el.total === -1) {
|
||||
return 'blue'
|
||||
}
|
||||
if (el.toDelete && el.move.length === 0) {
|
||||
return 'red'
|
||||
}
|
||||
|
||||
if (el.move.length > 0) {
|
||||
return 'purple'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card
|
||||
label={getEmbeddedLabel('Skills optimizer')}
|
||||
okAction={applyPlan}
|
||||
okLabel={getEmbeddedLabel('Apply')}
|
||||
canSave={true}
|
||||
on:close={() => {
|
||||
dispatch('close')
|
||||
}}
|
||||
on:changeContent
|
||||
>
|
||||
<div class="flex-row-center">
|
||||
<EditBox kind={'search-style'} bind:value={search} />
|
||||
|
||||
{#if processed > 0}
|
||||
<div class="p-1">
|
||||
<Loading />
|
||||
Processing: {processed} / {searchPlanElements.length}
|
||||
</div>
|
||||
{/if}
|
||||
{#if (loading1 || loading2) && !doProcessing}
|
||||
<Loading />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex clear-mins" style:overflow={'auto'}>
|
||||
<div class="flex-grow flex-nowrap no-word-wrap">
|
||||
<div class="flex-row-center">
|
||||
{elements.length} => {plan.elements.length - plan.move}
|
||||
</div>
|
||||
<ListView count={searchPlanElements.length}>
|
||||
<svelte:fragment slot="item" let:item>
|
||||
{@const el = searchPlanElements[item]}
|
||||
<div class="flex-row-center" style:color={toColor(el)}>
|
||||
{el.original.title}
|
||||
{#if el.element}
|
||||
=> {el.element?.title}
|
||||
{/if}
|
||||
{#each el.move as mid}
|
||||
{@const orig = plan.elements.find((it) => it.original._id === mid)}
|
||||
{#if orig !== undefined}
|
||||
➡︎ {orig?.element?.title ?? orig?.original.title}
|
||||
{:else}
|
||||
{mid}
|
||||
{/if}
|
||||
{/each}
|
||||
{#if (el.total ?? 0) > 0}
|
||||
({el.total})
|
||||
{/if}
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</ListView>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<style lang="scss">
|
||||
.color {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import tags, { selectedTagElements, TagElement } from '@hcengineering/tags'
|
||||
import { Component, getCurrentResolvedLocation, navigate } from '@hcengineering/ui'
|
||||
import { Button, Component, getCurrentResolvedLocation, navigate, showPopup } from '@hcengineering/ui'
|
||||
import recruit from '../plugin'
|
||||
import { buildFilterKey, setFilters } from '@hcengineering/view-resources'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Filter } from '@hcengineering/view'
|
||||
import { getEmbeddedLabel } from '@hcengineering/platform'
|
||||
import OptimizeSkills from './OptimizeSkills.svelte'
|
||||
|
||||
function setFilterTag (tag: TagElement) {
|
||||
const client = getClient()
|
||||
@ -43,4 +45,12 @@
|
||||
сreateItemLabel: recruit.string.SkillCreateLabel,
|
||||
onTag
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
label={getEmbeddedLabel('Optimize')}
|
||||
kind={'regular'}
|
||||
on:click={() => {
|
||||
showPopup(OptimizeSkills, { targetClass: recruit.mixin.Candidate })
|
||||
}}
|
||||
/>
|
||||
</Component>
|
||||
|
@ -21,12 +21,14 @@
|
||||
import tags from '../plugin'
|
||||
import { getTagStyle } from '../utils'
|
||||
import TagsCategoryPopup from './TagsCategoryPopup.svelte'
|
||||
import { TagElementInfo } from '../utils'
|
||||
|
||||
export let targetClass: Ref<Class<Doc>>
|
||||
export let category: Ref<TagCategory> | undefined = undefined
|
||||
export let selected: Ref<TagElement>[] = []
|
||||
export let gap: 'small' | 'big' = 'small'
|
||||
export let mode: 'item' | 'category' = 'category'
|
||||
export let tagElements: Map<Ref<TagElement>, TagElementInfo> | undefined
|
||||
|
||||
let categories: TagCategory[] = []
|
||||
let visibleCategories: TagCategory[] = []
|
||||
@ -58,32 +60,6 @@
|
||||
elementsQuery.unsubscribe()
|
||||
}
|
||||
|
||||
type TagElementInfo = { count: number; modifiedOn: number }
|
||||
let tagElements: Map<Ref<TagElement>, TagElementInfo> | undefined = undefined
|
||||
const refQuery = createQuery()
|
||||
$: refQuery.query(
|
||||
tags.class.TagReference,
|
||||
{},
|
||||
(res) => {
|
||||
const result = new Map<Ref<TagElement>, TagElementInfo>()
|
||||
|
||||
for (const d of res) {
|
||||
const v = result.get(d.tag) ?? { count: 0, modifiedOn: 0 }
|
||||
v.count++
|
||||
v.modifiedOn = Math.max(v.modifiedOn, d.modifiedOn)
|
||||
result.set(d.tag, v)
|
||||
}
|
||||
|
||||
tagElements = result
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
_id: 1,
|
||||
tag: 1
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
$: {
|
||||
const counts = new Map<Ref<TagCategory>, TagElement[]>()
|
||||
for (const e of elements) {
|
||||
@ -103,7 +79,9 @@
|
||||
const categoriesQuery = createQuery()
|
||||
$: categoriesQuery.query(
|
||||
tags.class.TagCategory,
|
||||
{},
|
||||
{
|
||||
targetClass
|
||||
},
|
||||
(res) => {
|
||||
categories = res
|
||||
},
|
||||
|
@ -49,9 +49,13 @@
|
||||
class="tag-item-inline overflow-label max-w-40"
|
||||
on:click
|
||||
use:tooltip={{
|
||||
label: element?.description ? tags.string.TagTooltip : undefined,
|
||||
props: { text: element?.description },
|
||||
direction: 'right'
|
||||
label: tags.string.TagTooltip,
|
||||
props: {
|
||||
text: `${name} ${
|
||||
element?.description !== undefined && element?.description.length > 0 ? ': ' + element?.description : ''
|
||||
}`
|
||||
},
|
||||
direction: 'top'
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
@ -63,9 +67,13 @@
|
||||
on:click
|
||||
on:keydown
|
||||
use:tooltip={{
|
||||
label: element?.description ? tags.string.TagTooltip : undefined,
|
||||
props: { text: element?.description },
|
||||
direction: 'right'
|
||||
label: tags.string.TagTooltip,
|
||||
props: {
|
||||
text: `${name} ${
|
||||
element?.description !== undefined && element?.description.length > 0 ? ': ' + element?.description : ''
|
||||
}`
|
||||
},
|
||||
direction: 'top'
|
||||
}}
|
||||
>
|
||||
<span class="overflow-label max-w-40">{name}</span>
|
||||
|
@ -23,6 +23,7 @@
|
||||
import CategoryBar from './CategoryBar.svelte'
|
||||
import CreateTagElement from './CreateTagElement.svelte'
|
||||
// import { deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
|
||||
import { TagElementInfo } from '../utils'
|
||||
|
||||
export let title: IntlString = tags.string.Tags
|
||||
export let icon: Asset | AnySvelteComponent = tags.icon.Tags
|
||||
@ -56,7 +57,6 @@
|
||||
}
|
||||
let category: Ref<TagCategory> | undefined = undefined
|
||||
|
||||
type TagElementInfo = { count: number; modifiedOn: number }
|
||||
let tagElements: Map<Ref<TagElement>, TagElementInfo> | undefined
|
||||
const refQuery = createQuery()
|
||||
$: refQuery.query(
|
||||
@ -94,8 +94,9 @@
|
||||
<span class="ac-header__title"><Label label={title} /></span>
|
||||
</div>
|
||||
|
||||
<div class="clear-mins mb-1">
|
||||
<div class="clear-mins mb-1 flex-row-center">
|
||||
<Button icon={IconAdd} label={сreateItemLabel} kind={'primary'} on:click={showCreateDialog} />
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<div class="ac-header full divide search-start">
|
||||
@ -108,6 +109,7 @@
|
||||
<CategoryBar
|
||||
{targetClass}
|
||||
{category}
|
||||
{tagElements}
|
||||
on:change={(evt) => {
|
||||
category = evt.detail.category ?? undefined
|
||||
updateResultQuery(search, category)
|
||||
|
@ -46,3 +46,11 @@ export const tagLevel: Record<0 | 1 | 2 | 3, Asset> = {
|
||||
1: tags.icon.Level1,
|
||||
0: tags.icon.Tags
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface TagElementInfo {
|
||||
count: number
|
||||
modifiedOn: number
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Attribute, Class, IdMap, Ref, Status } from '@hcengineering/core'
|
||||
import { Attribute, Class, IdMap, Ref, Status, generateId } from '@hcengineering/core'
|
||||
import { IntlString } from '@hcengineering/platform'
|
||||
import { DocPopup, createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Project, ProjectType, Task, getStates } from '@hcengineering/task'
|
||||
@ -17,19 +17,22 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const client = getClient()
|
||||
let progress = false
|
||||
const changeStatus = async (newStatus: any) => {
|
||||
if (newStatus === undefined) {
|
||||
dispatch('close', undefined)
|
||||
return
|
||||
}
|
||||
progress = true
|
||||
const docs = Array.isArray(value) ? value : [value]
|
||||
|
||||
const ops = client.apply('set-status' + generateId())
|
||||
const changed = (d: Task) => d.status !== newStatus
|
||||
await Promise.all(
|
||||
docs.filter(changed).map((it) => {
|
||||
return client.update(it, { status: newStatus })
|
||||
})
|
||||
)
|
||||
for (const it of docs.filter(changed)) {
|
||||
await ops.update(it, { status: newStatus })
|
||||
}
|
||||
await ops.commit()
|
||||
progress = false
|
||||
|
||||
dispatch('close', newStatus)
|
||||
}
|
||||
@ -82,6 +85,7 @@
|
||||
{placeholder}
|
||||
{width}
|
||||
{embedded}
|
||||
loading={progress}
|
||||
on:changeContent
|
||||
>
|
||||
<svelte:fragment slot="item" let:item>
|
||||
|
@ -13,28 +13,29 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { AttachedData } from '@hcengineering/core'
|
||||
import { DatePopup } from '@hcengineering/ui'
|
||||
import { AttachedData, generateId } from '@hcengineering/core'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Issue, IssueDraft } from '@hcengineering/tracker'
|
||||
import { DatePopup } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let value: Issue | AttachedData<Issue> | Issue[] | IssueDraft
|
||||
export let mondayStart = true
|
||||
export let withTime = false
|
||||
|
||||
const client = getClient()
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function onUpdate ({ detail }: CustomEvent<Date | null | undefined>) {
|
||||
const newDueDate = detail && detail?.getTime()
|
||||
|
||||
const vv = Array.isArray(value) ? value : [value]
|
||||
const ops = getClient().apply(generateId())
|
||||
for (const docValue of vv) {
|
||||
if ('_class' in docValue && newDueDate !== undefined && newDueDate !== docValue.dueDate) {
|
||||
await client.update(docValue, { dueDate: newDueDate })
|
||||
await ops.update(docValue, { dueDate: newDueDate })
|
||||
}
|
||||
}
|
||||
await ops.commit()
|
||||
|
||||
dispatch('update', newDueDate)
|
||||
}
|
||||
|
@ -16,7 +16,7 @@
|
||||
import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact'
|
||||
import { AssigneeBox, AssigneePopup, personAccountByIdStore } from '@hcengineering/contact-resources'
|
||||
import { AssigneeCategory } from '@hcengineering/contact-resources/src/assignee'
|
||||
import { Account, Doc, DocumentQuery, Ref, Space } from '@hcengineering/core'
|
||||
import { Account, Doc, DocumentQuery, Ref, Space, generateId } from '@hcengineering/core'
|
||||
import { RuleApplyResult, getClient, getDocRules } from '@hcengineering/presentation'
|
||||
import { Component, Issue } from '@hcengineering/tracker'
|
||||
import { ButtonKind, ButtonSize, IconSize, TooltipAlignment } from '@hcengineering/ui'
|
||||
@ -48,26 +48,30 @@
|
||||
|
||||
const client = getClient()
|
||||
const dispatch = createEventDispatcher()
|
||||
let progress = false
|
||||
|
||||
const handleAssigneeChanged = async (newAssignee: Ref<Person> | undefined | null) => {
|
||||
if (newAssignee === undefined || (!Array.isArray(_object) && _object?.assignee === newAssignee)) {
|
||||
return
|
||||
}
|
||||
|
||||
progress = true
|
||||
const ops = client.apply(generateId())
|
||||
if (Array.isArray(_object)) {
|
||||
await Promise.all(
|
||||
_object.map(async (p) => {
|
||||
if ('_class' in p) {
|
||||
await client.update(p, { assignee: newAssignee })
|
||||
}
|
||||
})
|
||||
)
|
||||
for (const p of _object) {
|
||||
if ('_class' in p) {
|
||||
await ops.update(p, { assignee: newAssignee })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ('_class' in _object) {
|
||||
await client.update(_object as any, { assignee: newAssignee })
|
||||
await ops.update(_object as any, { assignee: newAssignee })
|
||||
}
|
||||
}
|
||||
|
||||
await ops.commit()
|
||||
|
||||
progress = false
|
||||
|
||||
dispatch('change', newAssignee)
|
||||
if (isAction) dispatch('close')
|
||||
}
|
||||
@ -156,6 +160,7 @@
|
||||
selected={sel}
|
||||
allowDeselect={true}
|
||||
titleDeselect={undefined}
|
||||
loading={progress}
|
||||
on:close={(evt) => {
|
||||
const result = evt.detail
|
||||
if (result === null) {
|
||||
|
@ -530,7 +530,7 @@ export async function getPreviousAssignees (objectId: Ref<Doc> | undefined): Pro
|
||||
{ sort: { modifiedOn: -1 } }
|
||||
)
|
||||
const set: Set<Ref<Contact>> = new Set()
|
||||
const createAssignee = (createTx.tx as TxCreateDoc<Issue>).attributes.assignee
|
||||
const createAssignee = (createTx?.tx as TxCreateDoc<Issue>)?.attributes?.assignee
|
||||
for (const tx of updateTxes) {
|
||||
const assignee = (tx.tx as TxUpdateDoc<Issue>).operations.assignee
|
||||
if (assignee == null) continue
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Class, Doc, DocumentQuery, FindOptions, Hierarchy, Mixin, Ref } from '@hcengineering/core'
|
||||
import { Class, Doc, DocumentQuery, FindOptions, Hierarchy, Mixin, Ref, generateId } from '@hcengineering/core'
|
||||
import { Asset, IntlString } from '@hcengineering/platform'
|
||||
import { getClient, ObjectPopup, updateAttribute } from '@hcengineering/presentation'
|
||||
import { ObjectPopup, getClient, updateAttribute } from '@hcengineering/presentation'
|
||||
import { Label, SelectPopup, resizeObserver } from '@hcengineering/ui'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import view from '../plugin'
|
||||
@ -32,6 +32,7 @@
|
||||
export let size: 'small' | 'medium' | 'large' = 'small'
|
||||
export let embedded: boolean = false
|
||||
|
||||
let progress = false
|
||||
const dispatch = createEventDispatcher()
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
@ -44,24 +45,24 @@
|
||||
dispatch('close', undefined)
|
||||
return
|
||||
}
|
||||
progress = true
|
||||
const docs = Array.isArray(value) ? value : [value]
|
||||
|
||||
const changed = (d: Doc) => (d as any)[attribute] !== newStatus
|
||||
await Promise.all(
|
||||
docs.filter(changed).map((it) => {
|
||||
// c.update(it, { [attribute]: newStatus } )
|
||||
const cl = Hierarchy.mixinOrClass(it)
|
||||
const attr =
|
||||
castRequest !== undefined
|
||||
? hierarchy.getAttribute(castRequest, attribute)
|
||||
: hierarchy.getAttribute(cl, attribute)
|
||||
if (attr === undefined) {
|
||||
throw new Error('attribute not found')
|
||||
}
|
||||
return updateAttribute(client, it, cl, { key: attribute, attr }, newStatus)
|
||||
})
|
||||
)
|
||||
|
||||
const ops = client.apply('value-selector:' + generateId())
|
||||
for (const it of docs.filter(changed)) {
|
||||
const cl = Hierarchy.mixinOrClass(it)
|
||||
const attr =
|
||||
castRequest !== undefined
|
||||
? hierarchy.getAttribute(castRequest, attribute)
|
||||
: hierarchy.getAttribute(cl, attribute)
|
||||
if (attr === undefined) {
|
||||
throw new Error('attribute not found')
|
||||
}
|
||||
await updateAttribute(ops, it, cl, { key: attribute, attr }, newStatus)
|
||||
}
|
||||
await ops.commit()
|
||||
progress = false
|
||||
dispatch('close', newStatus)
|
||||
}
|
||||
|
||||
@ -135,6 +136,7 @@
|
||||
{width}
|
||||
{size}
|
||||
{embedded}
|
||||
loading={progress}
|
||||
on:changeContent
|
||||
/>
|
||||
{:else if _class !== undefined}
|
||||
@ -152,6 +154,7 @@
|
||||
{width}
|
||||
{size}
|
||||
{embedded}
|
||||
loading={progress}
|
||||
on:changeContent
|
||||
>
|
||||
<svelte:fragment slot="item" let:item>
|
||||
|
@ -101,6 +101,19 @@
|
||||
$: initialLimit = !lastLevel ? undefined : singleCat ? singleCategoryLimit : defaultLimit
|
||||
$: limit = initialLimit
|
||||
|
||||
$: selection = $focusStore.provider?.selection
|
||||
|
||||
let selectedMatch: Ref<Doc>[] = []
|
||||
|
||||
$: if (itemProj !== undefined && itemProj.length > 0 && $selection !== undefined && $selection.length > 0) {
|
||||
// update limit if we have selected items.
|
||||
const prj = new Set(itemProj.map((it) => it._id))
|
||||
selectedMatch = $selection.filter((it) => prj.has(it._id)).map((it) => it._id)
|
||||
if (selectedMatch.length > (limit ?? 0)) {
|
||||
limit = (limit ?? 0) + selectedMatch.length
|
||||
}
|
||||
}
|
||||
|
||||
$: if (lastLevel) {
|
||||
limiter.add(async () => {
|
||||
loading = docsQuery.query(
|
||||
|
@ -551,7 +551,7 @@
|
||||
res((status) => handleSupportStatusChanged(status))
|
||||
)
|
||||
onDestroy(async () => {
|
||||
await supportClient?.then((support) => support.destroy())
|
||||
await supportClient?.then((support) => support?.destroy())
|
||||
})
|
||||
|
||||
let supportWidgetLoading = false
|
||||
|
@ -104,7 +104,8 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
|
||||
if (currentVersionStr !== reconnectVersionStr) {
|
||||
// It seems upgrade happened
|
||||
location.reload()
|
||||
// location.reload()
|
||||
versionError = `${currentVersionStr} != ${reconnectVersionStr}`
|
||||
}
|
||||
const serverVersion: { version: string } = await (
|
||||
await fetch(serverEndpoint + '/api/v1/version', {})
|
||||
@ -151,9 +152,6 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
|
||||
if (version === undefined || requiredVersion !== versionStr) {
|
||||
versionError = `${versionStr} => ${requiredVersion}`
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 5000)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@ -168,17 +166,10 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
) {
|
||||
const versionStr = version !== undefined ? versionToString(version) : 'unknown'
|
||||
versionError = `${versionStr} => ${serverVersion.version}`
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
versionError = 'server version not available'
|
||||
setTimeout(() => {
|
||||
window.location.reload()
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
} catch (err: any) {
|
||||
|
@ -314,20 +314,29 @@ async function BacklinksCreate (tx: Tx, control: TriggerControl): Promise<Tx[]>
|
||||
|
||||
async function BacklinksUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
|
||||
const ctx = TxProcessor.extractTx(tx) as TxUpdateDoc<Doc>
|
||||
if (ctx._class !== core.class.TxUpdateDoc) return []
|
||||
if (control.hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
|
||||
|
||||
const rawDoc = (await control.findAll(ctx.objectClass, { _id: ctx.objectId }))[0]
|
||||
let hasUpdates = false
|
||||
const attributes = control.hierarchy.getAllAttributes(ctx.objectClass)
|
||||
for (const attr of attributes.values()) {
|
||||
if (attr.type._class === core.class.TypeMarkup && attr.name in ctx.operations) {
|
||||
hasUpdates = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (rawDoc !== undefined) {
|
||||
const txFactory = new TxFactory(control.txFactory.account)
|
||||
if (hasUpdates) {
|
||||
const rawDoc = (await control.findAll(ctx.objectClass, { _id: ctx.objectId }))[0]
|
||||
|
||||
const doc = TxProcessor.updateDoc2Doc(rawDoc, ctx)
|
||||
const targetTx = guessBacklinkTx(control.hierarchy, tx as TxCUD<Doc>)
|
||||
const txes: Tx[] = await getUpdateBacklinksTxes(control, txFactory, doc, targetTx.objectId, targetTx.objectClass)
|
||||
if (rawDoc !== undefined) {
|
||||
const txFactory = new TxFactory(control.txFactory.account)
|
||||
|
||||
if (txes.length !== 0) {
|
||||
await control.apply(txes, true)
|
||||
const doc = TxProcessor.updateDoc2Doc(rawDoc, ctx)
|
||||
const targetTx = guessBacklinkTx(control.hierarchy, tx as TxCUD<Doc>)
|
||||
const txes: Tx[] = await getUpdateBacklinksTxes(control, txFactory, doc, targetTx.objectId, targetTx.objectClass)
|
||||
|
||||
if (txes.length !== 0) {
|
||||
await control.apply(txes, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -335,16 +344,24 @@ async function BacklinksUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]>
|
||||
}
|
||||
|
||||
async function BacklinksRemove (tx: Tx, control: TriggerControl): Promise<Tx[]> {
|
||||
const hierarchy = control.hierarchy
|
||||
const ctx = TxProcessor.extractTx(tx) as TxRemoveDoc<Doc>
|
||||
if (ctx._class !== core.class.TxRemoveDoc) return []
|
||||
if (hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
|
||||
|
||||
const txFactory = new TxFactory(control.txFactory.account)
|
||||
let hasMarkdown = false
|
||||
const attributes = control.hierarchy.getAllAttributes(ctx.objectClass)
|
||||
for (const attr of attributes.values()) {
|
||||
if (attr.type._class === core.class.TypeMarkup) {
|
||||
hasMarkdown = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const txes: Tx[] = await getRemoveBacklinksTxes(control, txFactory, ctx.objectId)
|
||||
if (txes.length !== 0) {
|
||||
await control.apply(txes, true)
|
||||
if (hasMarkdown) {
|
||||
const txFactory = new TxFactory(control.txFactory.account)
|
||||
|
||||
const txes: Tx[] = await getRemoveBacklinksTxes(control, txFactory, ctx.objectId)
|
||||
if (txes.length !== 0) {
|
||||
await control.apply(txes, true)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
@ -416,9 +433,21 @@ export async function OnMessageSent (tx: Tx, control: TriggerControl): Promise<T
|
||||
* @public
|
||||
*/
|
||||
export async function BacklinkTrigger (tx: Tx, control: TriggerControl): Promise<Tx[]> {
|
||||
const promises = [BacklinksCreate(tx, control), BacklinksUpdate(tx, control), BacklinksRemove(tx, control)]
|
||||
const res = await Promise.all(promises)
|
||||
return res.flat()
|
||||
const result: Tx[] = []
|
||||
|
||||
const ctx = TxProcessor.extractTx(tx) as TxCreateDoc<Doc>
|
||||
if (control.hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
|
||||
|
||||
if (ctx._class === core.class.TxCreateDoc) {
|
||||
result.push(...(await BacklinksCreate(tx, control)))
|
||||
}
|
||||
if (ctx._class === core.class.TxUpdateDoc) {
|
||||
result.push(...(await BacklinksUpdate(tx, control)))
|
||||
}
|
||||
if (ctx._class === core.class.TxRemoveDoc) {
|
||||
result.push(...(await BacklinksRemove(tx, control)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -687,16 +687,17 @@ export async function createCollaboratorDoc (
|
||||
const res: Tx[] = []
|
||||
const hierarchy = control.hierarchy
|
||||
const mixin = hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
|
||||
const doc = TxProcessor.createDoc2Doc(tx)
|
||||
if (mixin !== undefined) {
|
||||
const doc = TxProcessor.createDoc2Doc(tx)
|
||||
const collaborators = await getDocCollaborators(doc, mixin, control)
|
||||
|
||||
const mixinTx = getMixinTx(tx, control, collaborators)
|
||||
const notificationTxes = await createCollabDocInfo(collaborators, control, tx, originTx, doc, true)
|
||||
res.push(mixinTx)
|
||||
res.push(...notificationTxes)
|
||||
|
||||
res.push(...(await getSpaceCollabTxes(control, doc, tx, originTx)))
|
||||
}
|
||||
res.push(...(await getSpaceCollabTxes(control, doc, tx, originTx)))
|
||||
return res
|
||||
}
|
||||
|
||||
@ -786,11 +787,14 @@ async function collectionCollabDoc (tx: TxCollectionCUD<Doc, AttachedDoc>, contr
|
||||
const actualTx = TxProcessor.extractTx(tx) as TxCUD<Doc>
|
||||
let res = await collaboratorDocHandler(actualTx, control, tx)
|
||||
if ([core.class.TxCreateDoc, core.class.TxRemoveDoc].includes(actualTx._class)) {
|
||||
const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0]
|
||||
if (doc !== undefined) {
|
||||
if (control.hierarchy.hasMixin(doc, notification.mixin.Collaborators)) {
|
||||
const collabMixin = control.hierarchy.as(doc, notification.mixin.Collaborators)
|
||||
res = res.concat(await createCollabDocInfo(collabMixin.collaborators, control, actualTx, tx, doc, false))
|
||||
const mixin = control.hierarchy.classHierarchyMixin(tx.objectClass, notification.mixin.ClassCollaborators)
|
||||
if (mixin !== undefined) {
|
||||
const doc = (await control.findAll(tx.objectClass, { _id: tx.objectId }, { limit: 1 }))[0]
|
||||
if (doc !== undefined) {
|
||||
if (control.hierarchy.hasMixin(doc, notification.mixin.Collaborators)) {
|
||||
const collabMixin = control.hierarchy.as(doc, notification.mixin.Collaborators)
|
||||
res = res.concat(await createCollabDocInfo(collabMixin.collaborators, control, actualTx, tx, doc, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -165,7 +165,7 @@ export async function getIssueNotificationContent (
|
||||
* @public
|
||||
*/
|
||||
export async function OnComponentRemove (tx: Tx, control: TriggerControl): Promise<Tx[]> {
|
||||
const ctx = TxProcessor.extractTx(tx) as TxUpdateDoc<Component>
|
||||
const ctx = TxProcessor.extractTx(tx) as TxRemoveDoc<Component>
|
||||
|
||||
const issues = await control.findAll(tracker.class.Issue, {
|
||||
component: ctx.objectId
|
||||
|
@ -644,7 +644,6 @@ export async function restore (
|
||||
|
||||
let idx: number | undefined
|
||||
let loaded = 0
|
||||
let last = 0
|
||||
let el = 0
|
||||
let chunks = 0
|
||||
while (true) {
|
||||
@ -660,9 +659,7 @@ export async function restore (
|
||||
loaded++
|
||||
}
|
||||
|
||||
const mr = Math.round(loaded / 10000)
|
||||
if (mr !== last) {
|
||||
last = mr
|
||||
if (el > 2500) {
|
||||
console.log(' loaded from server', loaded, el, chunks)
|
||||
el = 0
|
||||
chunks = 0
|
||||
|
@ -564,7 +564,9 @@ export class FullTextIndexPipeline implements FullTextPipeline {
|
||||
if (modelVersion !== undefined) {
|
||||
const modelVersionString = versionToString(modelVersion)
|
||||
if (modelVersionString !== process.env.MODEL_VERSION) {
|
||||
console.error('Indexer: Model version mismatch', modelVersionString, process.env.MODEL_VERSION)
|
||||
console.error(
|
||||
`Indexer: Model version mismatch model: ${modelVersionString} env: ${process.env.MODEL_VERSION}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -569,9 +569,7 @@ class TServerStorage implements ServerStorage {
|
||||
}
|
||||
const triggers = await ctx.with('process-triggers', {}, async (ctx) => {
|
||||
const result: Tx[] = []
|
||||
for (const tx of txes) {
|
||||
result.push(...(await this.triggers.apply(tx.modifiedBy, tx, triggerControl)))
|
||||
}
|
||||
result.push(...(await this.triggers.apply(ctx, txes, triggerControl)))
|
||||
return result
|
||||
})
|
||||
|
||||
@ -652,8 +650,7 @@ class TServerStorage implements ServerStorage {
|
||||
derived = result[1]
|
||||
|
||||
if (broadcast) {
|
||||
this.options?.broadcast?.(txes)
|
||||
this.options?.broadcast?.(derived)
|
||||
this.options?.broadcast?.([...txes, ...derived])
|
||||
}
|
||||
|
||||
return [...txes, ...derived]
|
||||
|
@ -15,23 +15,23 @@
|
||||
//
|
||||
|
||||
import core, {
|
||||
Tx,
|
||||
Doc,
|
||||
TxCreateDoc,
|
||||
Ref,
|
||||
Account,
|
||||
TxCollectionCUD,
|
||||
AttachedDoc,
|
||||
DocumentQuery,
|
||||
matchQuery,
|
||||
TxFactory,
|
||||
Hierarchy,
|
||||
Class,
|
||||
Obj
|
||||
Doc,
|
||||
DocumentQuery,
|
||||
Hierarchy,
|
||||
MeasureContext,
|
||||
Obj,
|
||||
Ref,
|
||||
Tx,
|
||||
TxCollectionCUD,
|
||||
TxCreateDoc,
|
||||
TxFactory,
|
||||
matchQuery
|
||||
} from '@hcengineering/core'
|
||||
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import type { Trigger, TriggerFunc, TriggerControl } from './types'
|
||||
import { Resource, getResource } from '@hcengineering/platform'
|
||||
import type { Trigger, TriggerControl, TriggerFunc } from './types'
|
||||
|
||||
import serverCore from './plugin'
|
||||
|
||||
@ -39,7 +39,7 @@ import serverCore from './plugin'
|
||||
* @public
|
||||
*/
|
||||
export class Triggers {
|
||||
private readonly triggers: [DocumentQuery<Tx> | undefined, TriggerFunc][] = []
|
||||
private readonly triggers: [DocumentQuery<Tx> | undefined, TriggerFunc, Resource<TriggerFunc>][] = []
|
||||
|
||||
constructor (protected readonly hierarchy: Hierarchy) {}
|
||||
|
||||
@ -53,25 +53,29 @@ export class Triggers {
|
||||
const trigger = (createTx as TxCreateDoc<Trigger>).attributes.trigger
|
||||
const match = (createTx as TxCreateDoc<Trigger>).attributes.txMatch
|
||||
const func = await getResource(trigger)
|
||||
this.triggers.push([match, func])
|
||||
this.triggers.push([match, func, trigger])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async apply (account: Ref<Account>, tx: Tx, ctrl: Omit<TriggerControl, 'txFactory'>): Promise<Tx[]> {
|
||||
const control = { ...ctrl, txFactory: new TxFactory(account, true) }
|
||||
const derived = this.triggers
|
||||
.filter(([query]) => {
|
||||
if (query === undefined) {
|
||||
return true
|
||||
}
|
||||
async apply (ctx: MeasureContext, tx: Tx[], ctrl: Omit<TriggerControl, 'txFactory'>): Promise<Tx[]> {
|
||||
const result: Tx[] = []
|
||||
for (const [query, trigger, resource] of this.triggers) {
|
||||
let matches = tx
|
||||
if (query !== undefined) {
|
||||
this.addDerived(query, 'objectClass')
|
||||
this.addDerived(query, 'tx.objectClass')
|
||||
return matchQuery([tx], query, core.class.Tx, control.hierarchy).length > 0
|
||||
})
|
||||
.map(([, trigger]) => trigger(tx, control))
|
||||
const result = await Promise.all(derived)
|
||||
return result.flatMap((x) => x)
|
||||
matches = matchQuery(tx, query, core.class.Tx, ctrl.hierarchy) as Tx[]
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
await ctx.with(resource, {}, async (ctx) => {
|
||||
for (const tx of matches) {
|
||||
result.push(...(await trigger(tx, { ...ctrl, txFactory: new TxFactory(tx.modifiedBy, true) })))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private addDerived (q: DocumentQuery<Tx>, key: string): void {
|
||||
|
@ -38,6 +38,7 @@ export interface Request<P extends any[]> {
|
||||
export interface HelloRequest extends Request<any[]> {
|
||||
binary?: boolean
|
||||
compression?: boolean
|
||||
broadcast?: boolean
|
||||
}
|
||||
/**
|
||||
* @public
|
||||
|
@ -16,6 +16,7 @@
|
||||
import core, {
|
||||
Account,
|
||||
AccountRole,
|
||||
BulkUpdateEvent,
|
||||
Class,
|
||||
Doc,
|
||||
DocumentQuery,
|
||||
@ -26,7 +27,13 @@ import core, {
|
||||
Ref,
|
||||
Timestamp,
|
||||
Tx,
|
||||
TxResult
|
||||
TxApplyIf,
|
||||
TxCUD,
|
||||
TxProcessor,
|
||||
TxResult,
|
||||
TxWorkspaceEvent,
|
||||
WorkspaceEvent,
|
||||
generateId
|
||||
} from '@hcengineering/core'
|
||||
import { Pipeline, SessionContext } from '@hcengineering/server-core'
|
||||
import { Token } from '@hcengineering/server-token'
|
||||
@ -39,6 +46,7 @@ export class ClientSession implements Session {
|
||||
requests: Map<string, SessionRequest> = new Map()
|
||||
binaryResponseMode: boolean = false
|
||||
useCompression: boolean = true
|
||||
useBroadcast: boolean = false
|
||||
sessionId = ''
|
||||
|
||||
total: StatisticsElement = { find: 0, tx: 0 }
|
||||
@ -109,10 +117,66 @@ export class ClientSession implements Session {
|
||||
context.userEmail = this.token.email
|
||||
const [result, derived, target] = await this._pipeline.tx(context, tx)
|
||||
|
||||
this.broadcast(this, this.token.workspace, { result: tx }, target)
|
||||
for (const dtx of derived) {
|
||||
this.broadcast(null, this.token.workspace, { result: dtx }, target)
|
||||
let shouldBroadcast = true
|
||||
|
||||
if (tx._class === core.class.TxApplyIf) {
|
||||
const apply = tx as TxApplyIf
|
||||
shouldBroadcast = apply.notify ?? true
|
||||
}
|
||||
|
||||
if (tx._class !== core.class.TxApplyIf) {
|
||||
this.broadcast(this, this.token.workspace, { result: tx }, target)
|
||||
}
|
||||
if (shouldBroadcast) {
|
||||
if (this.useBroadcast) {
|
||||
if (derived.length > 250) {
|
||||
const classes = new Set<Ref<Class<Doc>>>()
|
||||
for (const dtx of derived) {
|
||||
if (this._pipeline.storage.hierarchy.isDerived(dtx._class, core.class.TxCUD)) {
|
||||
classes.add((dtx as TxCUD<Doc>).objectClass)
|
||||
}
|
||||
const etx = TxProcessor.extractTx(dtx)
|
||||
if (this._pipeline.storage.hierarchy.isDerived(etx._class, core.class.TxCUD)) {
|
||||
classes.add((etx as TxCUD<Doc>).objectClass)
|
||||
}
|
||||
}
|
||||
console.log('Broadcasting bulk', derived.length)
|
||||
this.broadcast(null, this.token.workspace, { result: this.createBroadcastEvent(Array.from(classes)) }, target)
|
||||
} else {
|
||||
while (derived.length > 0) {
|
||||
const part = derived.splice(0, 250)
|
||||
console.log('Broadcasting part', part.length, derived.length)
|
||||
this.broadcast(null, this.token.workspace, { result: part }, target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const dtx of derived) {
|
||||
this.broadcast(null, this.token.workspace, { result: dtx }, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tx._class === core.class.TxApplyIf) {
|
||||
const apply = tx as TxApplyIf
|
||||
|
||||
if (apply.extraNotify !== undefined && apply.extraNotify.length > 0) {
|
||||
this.broadcast(null, this.token.workspace, { result: this.createBroadcastEvent(apply.extraNotify) }, target)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private createBroadcastEvent (classes: Ref<Class<Doc>>[]): TxWorkspaceEvent<BulkUpdateEvent> {
|
||||
return {
|
||||
_class: core.class.TxWorkspaceEvent,
|
||||
_id: generateId(),
|
||||
event: WorkspaceEvent.BulkUpdate,
|
||||
params: {
|
||||
_class: classes
|
||||
},
|
||||
modifiedBy: core.account.System,
|
||||
modifiedOn: Date.now(),
|
||||
objectSpace: core.space.DerivedTx,
|
||||
space: core.space.DerivedTx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -243,7 +243,12 @@ class TSessionManager implements SessionManager {
|
||||
for (const session of sessions.splice(0, 1)) {
|
||||
if (targets !== undefined && !targets.includes(session.session.getUser())) continue
|
||||
for (const _tx of tx) {
|
||||
void session.socket.send(ctx, { result: _tx }, session.session.binaryResponseMode, false)
|
||||
void session.socket.send(
|
||||
ctx,
|
||||
{ result: _tx },
|
||||
session.session.binaryResponseMode,
|
||||
session.session.useCompression
|
||||
)
|
||||
}
|
||||
}
|
||||
if (sessions.length > 0) {
|
||||
@ -461,9 +466,19 @@ class TSessionManager implements SessionManager {
|
||||
for (const sessionRef of sessions.splice(0, 1)) {
|
||||
if (sessionRef.session.sessionId !== from?.sessionId) {
|
||||
if (target === undefined) {
|
||||
void sessionRef.socket.send(ctx, resp, sessionRef.session.binaryResponseMode, false)
|
||||
void sessionRef.socket.send(
|
||||
ctx,
|
||||
resp,
|
||||
sessionRef.session.binaryResponseMode,
|
||||
sessionRef.session.useCompression
|
||||
)
|
||||
} else if (target.includes(sessionRef.session.getUser())) {
|
||||
void sessionRef.socket.send(ctx, resp, sessionRef.session.binaryResponseMode, false)
|
||||
void sessionRef.socket.send(
|
||||
ctx,
|
||||
resp,
|
||||
sessionRef.session.binaryResponseMode,
|
||||
sessionRef.session.useCompression
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -497,6 +512,7 @@ class TSessionManager implements SessionManager {
|
||||
const hello = request as HelloRequest
|
||||
service.binaryResponseMode = hello.binary ?? false
|
||||
service.useCompression = hello.compression ?? false
|
||||
service.useBroadcast = hello.broadcast ?? false
|
||||
|
||||
if (LOGGING_ENABLED) {
|
||||
console.timeLog(
|
||||
|
@ -54,6 +54,7 @@ export interface Session {
|
||||
|
||||
binaryResponseMode: boolean
|
||||
useCompression: boolean
|
||||
useBroadcast: boolean
|
||||
|
||||
total: StatisticsElement
|
||||
current: StatisticsElement
|
||||
|
Loading…
Reference in New Issue
Block a user