UBERF-5418: Fix status editing (#4590)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-02-09 22:55:03 +07:00 committed by GitHub
parent 2e4d07fb1b
commit ed5f00e842
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 775 additions and 403 deletions

View File

@ -89,6 +89,7 @@ services:
- MINIO_SECRET_KEY=minioadmin
- TITLE=DevPlatform
- DEFAULT_LANGUAGE=ru
- LAST_NAME_FIRST=true
restart: unless-stopped
collaborator:
image: hardcoreeng/collaborator
@ -156,6 +157,7 @@ services:
# - APM_SERVER_URL=http://apm-server:8200
- SERVER_PROVIDER=ws
- ACCOUNTS_URL=http://account:3000
- LAST_NAME_FIRST=true
restart: unless-stopped
rekoni:
image: hardcoreeng/rekoni-service

View File

@ -6,5 +6,6 @@
"GMAIL_URL": "http://localhost:8088",
"CALENDAR_URL": "http://localhost:8095",
"REKONI_URL": "http://localhost:4004",
"COLLABORATOR_URL": "ws://localhost:3078"
"COLLABORATOR_URL": "ws://localhost:3078",
"LAST_NAME_FIRST": "true"
}

View File

@ -44,17 +44,11 @@ class InMemoryTxAdapter extends DummyDbAdapter implements TxAdapter {
return await this.txdb.findAll(_class, query, options)
}
async tx (...tx: Tx[]): Promise<TxResult> {
async tx (...tx: Tx[]): Promise<TxResult[]> {
const r: TxResult[] = []
for (const t of tx) {
r.push(await this.txdb.tx(t))
}
if (r.length === 1) {
return r[0]
}
if (r.length === 0) {
return {}
}
return r
}

View File

@ -258,9 +258,9 @@ export class TxDb extends MemDb {
throw new Error('Method not implemented.')
}
async tx (tx: Tx): Promise<TxResult> {
async tx (tx: Tx): Promise<TxResult[]> {
this.addDoc(tx)
return {}
return []
}
}

View File

@ -19,13 +19,13 @@ import type {
DocumentQuery,
FindOptions,
FindResult,
SearchQuery,
SearchOptions,
SearchQuery,
SearchResult,
TxResult,
WithLookup
} from './storage'
import { DocumentClassQuery, Tx, TxCUD, TxFactory, TxProcessor } from './tx'
import { DocumentClassQuery, Tx, TxApplyResult, TxCUD, TxFactory, TxProcessor } from './tx'
/**
* @public
@ -464,17 +464,19 @@ export class ApplyOperations extends TxOperations {
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,
notify,
extraNotify
)
)) as Promise<boolean>)
return (
await ((await this.ops.tx(
this.ops.txFactory.createTxApplyIf(
core.space.Tx,
this.scope,
this.matches,
this.notMatches,
this.txes,
notify,
extraNotify
)
)) as Promise<TxApplyResult>)
).success
}
return true
}

View File

@ -140,6 +140,11 @@ export interface TxApplyIf extends Tx {
extraNotify?: Ref<Class<Doc>>[]
}
export interface TxApplyResult {
success: boolean
derived: Tx[] // Some derived transactions to handle.
}
/**
* @public
*/
@ -318,14 +323,14 @@ export const DOMAIN_TX = 'tx' as Domain
* @public
*/
export interface WithTx {
tx: (...txs: Tx[]) => Promise<TxResult>
tx: (...txs: Tx[]) => Promise<TxResult[]>
}
/**
* @public
*/
export abstract class TxProcessor implements WithTx {
async tx (...txes: Tx[]): Promise<TxResult> {
async tx (...txes: Tx[]): Promise<TxResult[]> {
const result: TxResult[] = []
for (const tx of txes) {
switch (tx._class) {
@ -346,15 +351,9 @@ export abstract class TxProcessor implements WithTx {
break
case core.class.TxApplyIf:
// Apply if processed on server
return await Promise.resolve({})
return await Promise.resolve([])
}
}
if (result.length === 0) {
return {}
}
if (result.length === 1) {
return result[0]
}
return result
}

View File

@ -683,6 +683,18 @@ export class LiveQuery extends TxProcessor implements Client {
}
}
private triggerRefresh (q: Query): void {
const r: Promise<FindResult<Doc>> | FindResult<Doc> = this.client.findAll(q._class, q.query, q.options)
q.result = r
void r.then(async (qr) => {
const oldResult = q.result
if (!deepEqual(qr, oldResult) || (qr.total !== q.total && q.options?.total === true)) {
q.total = qr.total
await this.callback(q)
}
})
}
// Check if query is partially matched.
private async matchQuery (q: Query, tx: TxUpdateDoc<Doc>, docCache?: Map<string, Doc>): Promise<boolean> {
const clazz = this.getHierarchy().isMixin(q._class) ? this.getHierarchy().getBaseClass(q._class) : q._class
@ -1056,24 +1068,31 @@ export class LiveQuery extends TxProcessor implements Client {
return result
}
async tx (tx: Tx): Promise<TxResult> {
if (tx._class === core.class.TxWorkspaceEvent) {
await this.checkUpdateFulltextQueries(tx)
await this.changePrivateHandler(tx)
return {}
async tx (...txes: Tx[]): Promise<TxResult[]> {
const result: TxResult[] = []
for (const tx of txes) {
if (tx._class === core.class.TxWorkspaceEvent) {
await this.checkUpdateEvents(tx)
await this.changePrivateHandler(tx)
}
result.push(...(await super.tx(tx)))
}
return await super.tx(tx)
return result
}
private async checkUpdateFulltextQueries (tx: Tx): Promise<void> {
private async checkUpdateEvents (tx: Tx): Promise<void> {
const evt = tx as TxWorkspaceEvent
const h = this.client.getHierarchy()
function hasClass (q: Query, classes: Ref<Class<Doc>>[]): boolean {
return classes.includes(q._class) || classes.some((it) => h.isDerived(q._class, it) || h.isDerived(it, q._class))
}
if (evt.event === WorkspaceEvent.IndexingUpdate) {
const indexingParam = evt.params as IndexingUpdateEvent
for (const q of [...this.queue]) {
if (indexingParam._class.includes(q._class) && q.query.$search !== undefined) {
if (hasClass(q, indexingParam._class) && q.query.$search !== undefined) {
if (!this.removeFromQueue(q)) {
try {
await this.refresh(q)
this.triggerRefresh(q)
} catch (err) {
console.error(err)
}
@ -1082,9 +1101,9 @@ export class LiveQuery extends TxProcessor implements Client {
}
for (const v of this.queries.values()) {
for (const q of v) {
if (indexingParam._class.includes(q._class) && q.query.$search !== undefined) {
if (hasClass(q, indexingParam._class) && q.query.$search !== undefined) {
try {
await this.refresh(q)
this.triggerRefresh(q)
} catch (err) {
console.error(err)
}
@ -1095,10 +1114,10 @@ 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 (hasClass(q, params._class)) {
if (!this.removeFromQueue(q)) {
try {
await this.refresh(q)
this.triggerRefresh(q)
} catch (err) {
console.error(err)
}
@ -1107,9 +1126,9 @@ export class LiveQuery extends TxProcessor implements Client {
}
for (const v of this.queries.values()) {
for (const q of v) {
if (params._class.includes(q._class)) {
if (hasClass(q, params._class)) {
try {
await this.refresh(q)
this.triggerRefresh(q)
} catch (err) {
console.error(err)
}

View File

@ -27,19 +27,20 @@ import core, {
FindOptions,
FindResult,
LoadModelResponse,
MeasureDoneOperation,
Ref,
SearchOptions,
SearchQuery,
SearchResult,
Timestamp,
Tx,
TxApplyIf,
TxApplyResult,
TxHandler,
TxResult,
TxWorkspaceEvent,
WorkspaceEvent,
generateId,
SearchQuery,
SearchOptions,
SearchResult,
MeasureDoneOperation
generateId
} from '@hcengineering/core'
import { PlatformError, UNAUTHORIZED, broadcastEvent, getMetadata, unknownError } from '@hcengineering/platform'
@ -57,7 +58,8 @@ class RequestPromise {
reconnect?: () => void
constructor (
readonly method: string,
readonly params: any[]
readonly params: any[],
readonly handleResult?: (result: any) => Promise<void>
) {
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve
@ -263,7 +265,13 @@ class Connection implements ClientConnection {
)
promise.reject(new PlatformError(resp.error))
} else {
promise.resolve(resp.result)
if (request?.handleResult !== undefined) {
void request.handleResult(resp.result).then(() => {
promise.resolve(resp.result)
})
} else {
promise.resolve(resp.result)
}
}
void broadcastEvent(client.event.NetworkRequests, this.requests.size)
} else {
@ -336,13 +344,14 @@ class Connection implements ClientConnection {
params: any[]
// If not defined, on reconnect with timeout, will retry automatically.
retry?: () => Promise<boolean>
handleResult?: (result: any) => Promise<void>
}): Promise<any> {
if (this.closed) {
throw new PlatformError(unknownError('connection closed'))
}
const id = this.lastId++
const promise = new RequestPromise(data.method, data.params)
const promise = new RequestPromise(data.method, data.params, data.handleResult)
const sendData = async (): Promise<void> => {
if (this.websocket instanceof Promise) {
@ -423,7 +432,19 @@ class Connection implements ClientConnection {
return (await this.findAll(core.class.Tx, { _id: (tx as TxApplyIf).txes[0]._id }, { limit: 1 })).length === 0
}
return (await this.findAll(core.class.Tx, { _id: tx._id }, { limit: 1 })).length === 0
}
},
handleResult:
tx._class === core.class.TxApplyIf
? async (result) => {
if (tx._class === core.class.TxApplyIf) {
// We need to check extra broadcast's and perform them before
const r = result as TxApplyResult
for (const d of r?.derived ?? []) {
this.handler(d)
}
}
}
: undefined
})
}

View File

@ -25,8 +25,9 @@
import { ObjectPresenter } from '@hcengineering/view-resources'
export let object: Doc | Doc[]
export let deleteAction: () => void
export let deleteAction: () => void | Promise<void>
export let skipCheck: boolean = false
export let canDeleteExtra: boolean = true
const objectArray = Array.isArray(object) ? object : [object]
const owners: PersonAccount[] = Array.from($personAccountByIdStore.values()).filter(
(acc) => acc.role === AccountRole.Owner
@ -34,9 +35,10 @@
const dispatch = createEventDispatcher()
$: creators = [...new Set(objectArray.map((obj) => obj.createdBy as Ref<PersonAccount>))]
$: canDelete =
skipCheck ||
(creators.length === 1 && creators.includes(getCurrentAccount()._id as Ref<PersonAccount>)) ||
getCurrentAccount().role === AccountRole.Owner
(skipCheck ||
(creators.length === 1 && creators.includes(getCurrentAccount()._id as Ref<PersonAccount>)) ||
getCurrentAccount().role === AccountRole.Owner) &&
canDeleteExtra
$: label = canDelete ? view.string.DeleteObject : view.string.DeletePopupNoPermissionTitle
</script>
@ -56,9 +58,11 @@
{#if canDelete}
<div class="mb-2">
<Label label={view.string.DeleteObjectConfirm} params={{ count: objectArray.length }} />
{#if objectArray.length === 1}
<ObjectPresenter _class={objectArray[0]._class} objectId={objectArray[0]._id} value={objectArray[0]} />
{/if}
<div class="mt-2">
{#if objectArray.length === 1}
<ObjectPresenter _class={objectArray[0]._class} objectId={objectArray[0]._id} value={objectArray[0]} />
{/if}
</div>
</div>
{:else}
<div class="mb-2">
@ -82,4 +86,5 @@
</div>
{/if}
</div>
<slot />
</Card>

View File

@ -139,6 +139,7 @@ export {
EmployeeArrayEditor,
EmployeeEditor,
PersonAccountRefPresenter,
PersonAccountPresenter,
MembersPresenter,
EditPerson,
EmployeeRefPresenter,
@ -168,7 +169,8 @@ export {
UsersList,
SelectUsersPopup,
IconAddMember,
UserDetails
UserDetails,
DeleteConfirmationPopup
}
const toObjectSearchResult = (e: WithLookup<Contact>): ObjectSearchResult => ({

View File

@ -47,7 +47,7 @@
let appliedTemplateId: Ref<ProjectType> | undefined
let objectId: Ref<VacancyClass> = generateId()
let issueTemplates: FindResult<IssueTemplate>
let issueTemplates: FindResult<IssueTemplate> = []
let fullDescription: string = ''

View File

@ -95,6 +95,8 @@
"Type": "Type",
"Group": "Group",
"Color": "Color",
"Identifier": "Identifier"
"Identifier": "Identifier",
"RenameStatus": "Rename a status to new name",
"UpdateTasksStatusRequest": "Status is used with {total} tasks, it will require update of all of them. Please approve."
}
}

View File

@ -95,6 +95,8 @@
"Type": "Тип",
"Group": "Группа",
"Color": "Цвет",
"Identifier": "Идентификатор"
"Identifier": "Идентификатор",
"RenameStatus": "Переименование статуса в новое имя",
"UpdateTasksStatusRequest": "Статус сейчас используется в {total} задачах, потребуется обновление всеи их. Пожалуйста подтвердите."
}
}

View File

@ -1,251 +0,0 @@
<!--
// 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 core, { Attribute, Class, Ref, Status, StatusCategory } from '@hcengineering/core'
import { Asset, getEmbeddedLabel } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import { clearSettingsStore } from '@hcengineering/setting-resources'
import { ProjectType, TaskType, calculateStatuses, createState } from '@hcengineering/task'
import {
ButtonIcon,
ButtonMenu,
EmojiPopup,
IconCopy,
IconDelete,
IconSettings,
IconWithEmoji,
Label,
Modal,
ModernEditbox,
TextArea,
getPlatformColorDef,
themeStore
} from '@hcengineering/ui'
import { ColorsPopup, statusStore } from '@hcengineering/view-resources'
import view from '@hcengineering/view-resources/src/plugin'
import { taskTypeStore } from '..'
import task from '../plugin'
export let status: Status | undefined = undefined
export let _class: Ref<Class<Status>> | undefined = status?._class
export let taskType: TaskType
export let type: ProjectType
export let ofAttribute: Ref<Attribute<Status>>
export let category: Ref<StatusCategory> | undefined = status?.category
export let value = status?.name ?? ''
export let color: number | undefined = undefined
export let icons: Asset[]
export let iconWithEmoji: Asset = view.ids.IconWithEmoji
export let icon: Asset | undefined
const client = getClient()
let description: string | undefined = status?.description
$: allowEditCategory = status === undefined
$: needUpdate =
(status === undefined ||
status.name.trim() !== value.trim() ||
description !== status?.description ||
color !== status.color) &&
value.trim() !== ''
async function save (): Promise<void> {
if (taskType === undefined || _class === undefined) return
if (status === undefined) {
const _id = await createState(client, _class, {
ofAttribute,
name: value.trim(),
category,
color,
description
})
const states = taskType.statuses.map((p) => $statusStore.byId.get(p)).filter((p) => p !== undefined) as Status[]
const lastIndex = states.findLastIndex((p) => p.category === category)
const statuses = [...taskType.statuses.slice(0, lastIndex + 1), _id, ...taskType.statuses.slice(lastIndex + 1)]
type.statuses.push({
_id,
color,
icon,
taskType: taskType._id
})
await client.update(type, {
statuses: calculateStatuses(type, $taskTypeStore, [{ taskTypeId: taskType._id, statuses }])
})
await client.update(taskType, {
statuses
})
} else if (needUpdate) {
const _id = await createState(client, _class, {
ofAttribute,
name: value.trim(),
category,
color,
description
})
const index = taskType.statuses.indexOf(status._id)
const statuses = [...taskType.statuses.slice(0, index), _id, ...taskType.statuses.slice(index + 1)]
for (const status of type.statuses) {
if (status._id === _id) {
status.color = color
status.icon = icon as any // Fix me
}
}
await client.update(type, {
statuses: calculateStatuses(type, $taskTypeStore, [{ taskTypeId: taskType._id, statuses }])
})
await client.update(taskType, {
statuses
})
if (status._id !== _id) {
const projects = await client.findAll(task.class.Project, { type: type._id })
while (true) {
const docs = await client.findAll(
task.class.Task,
{
status: status._id,
space: { $in: projects.map((p) => p._id) }
},
{ limit: 1000 }
)
if (docs.length === 0) {
break
}
const op = client.apply(_id)
docs.map((p) => op.update(p, { status: _id }))
await op.commit()
}
}
}
}
let selected: number = icon === iconWithEmoji ? 1 : 0
const items = [
{
id: 'color',
label: task.string.Color
},
{
id: 'emoji',
label: view.string.EmojiCategory
}
]
$: allCategories = getClient()
.getModel()
.findAllSync(core.class.StatusCategory, { _id: { $in: taskType.statusCategories } })
$: categories = allCategories.map((it) => ({
id: it._id,
label: it.label,
icon: it.icon
}))
</script>
<Modal
label={task.string.StatusPopupTitle}
type={'type-aside'}
okLabel={status === undefined ? presentation.string.Create : presentation.string.Save}
okAction={save}
canSave={needUpdate}
onCancel={() => {
clearSettingsStore()
}}
>
<svelte:fragment slot="actions">
<ButtonIcon icon={IconDelete} size={'small'} kind={'tertiary'} />
<ButtonIcon icon={IconCopy} size={'small'} kind={'tertiary'} />
</svelte:fragment>
<div class="hulyModal-content__titleGroup">
<ModernEditbox bind:value label={task.string.StatusName} size={'large'} kind={'ghost'} />
<TextArea
placeholder={task.string.Description}
width={'100%'}
height={'4.5rem'}
margin={'var(--spacing-1) var(--spacing-2)'}
noFocusBorder
bind:value={description}
/>
</div>
<div class="hulyModal-content__settingsSet">
<div class="hulyModal-content__settingsSet-line">
<span class="label"><Label label={getEmbeddedLabel('Status Category')} /></span>
<ButtonMenu
items={categories}
selected={category}
disabled={!allowEditCategory}
icon={categories.find((it) => it.id === category)?.icon}
label={categories.find((it) => it.id === category)?.label}
kind={'secondary'}
size={'medium'}
on:selected={(it) => {
category = it.detail
}}
/>
</div>
</div>
<div class="hulyModal-content__settingsSet table">
<div class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12 withButton">
<ButtonMenu
{items}
{selected}
icon={IconSettings}
label={items[selected].label}
kind={'secondary'}
size={'small'}
on:selected={(event) => {
if (event.detail) {
selected = items.findIndex((it) => it.id === event.detail)
if (selected === 1) {
icon = undefined
}
}
}}
/>
{#if icon === iconWithEmoji}
<IconWithEmoji icon={color ?? 0} size={'medium'} />
{/if}
</div>
<div class="hulyTableAttr-content" class:mb-2={selected === 1}>
{#if selected === 0}
<ColorsPopup
selected={getPlatformColorDef(color ?? 0, $themeStore.dark).name}
embedded
columns={'auto'}
on:close={(evt) => {
color = evt.detail
icon = undefined
}}
/>
{:else}
<EmojiPopup
embedded
selected={String.fromCodePoint(color ?? 0)}
on:close={(evt) => {
color = evt.detail.codePointAt(0)
icon = iconWithEmoji
}}
/>
{/if}
</div>
</div>
</div>
</Modal>

View File

@ -51,11 +51,9 @@
if (kind === undefined) {
statuses = []
} else {
if (kind !== undefined) {
const type = taskTypes.get(kind)
if (type !== undefined) {
statuses = type.statuses.map((p) => store.get(p)).filter((p) => p !== undefined) as Status[]
}
const type = taskTypes.get(kind)
if (type !== undefined) {
statuses = type.statuses.map((p) => store.get(p)).filter((p) => p !== undefined) as Status[]
}
}
}
@ -71,7 +69,7 @@
allowDeselect={true}
selected={current}
on:close={(evt) => {
changeStatus(evt.detail === null ? null : evt.detail?._id)
void changeStatus(evt.detail === null ? null : evt.detail?._id)
}}
{placeholder}
{width}
@ -87,7 +85,14 @@
value={item}
inline={false}
noUnderline
props={{ disabled: true, inline: false, size: 'small', avatarSize: 'smaller' }}
props={{
disabled: true,
inline: false,
size: 'small',
avatarSize: 'smaller',
taskType: kind,
projectType: kind !== undefined ? $taskTypeStore.get(kind)?.parent : undefined
}}
/>
</div>
</svelte:fragment>

View File

@ -14,51 +14,49 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'
import { ComponentExtensions, createQuery, getClient } from '@hcengineering/presentation'
import task, { Project, ProjectType, ProjectTypeDescriptor, Task, TaskType } from '@hcengineering/task'
import { createEventDispatcher, onDestroy } from 'svelte'
import { Ref, SortingOrder } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import type { Asset, IntlString } from '@hcengineering/platform'
import ui, {
import type { IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import { ClassAttributes, clearSettingsStore, settingsStore } from '@hcengineering/setting-resources'
import {
Breadcrumbs,
ButtonIcon,
Component,
ModernEditbox,
TextArea,
Header,
IconAdd,
IconCopy,
IconDelete,
IconSquareExpand,
IconMoreV,
IconDescription,
IconFolder,
IconMoreV,
IconSquareExpand,
Label,
Location,
ModernButton,
ModernEditbox,
NavItem,
Scroller,
Separator,
TextArea,
defineSeparators,
eventToHTMLElement,
getCurrentResolvedLocation,
navigate,
resolvedLocationStore,
showPopup,
Header,
Breadcrumbs,
ModernButton,
IconSend,
IconDescription,
Separator,
Scroller,
defineSeparators,
secondNavSeparators,
NavItem
showPopup
} from '@hcengineering/ui'
import { ContextMenu } from '@hcengineering/view-resources'
import plugin from '../../plugin'
import setting from '@hcengineering/setting'
import { ClassAttributes, clearSettingsStore, settingsStore } from '@hcengineering/setting-resources'
import IconLayers from '../icons/Layers.svelte'
import CreateTaskType from '../taskTypes/CreateTaskType.svelte'
import TaskTypeEditor from '../taskTypes/TaskTypeEditor.svelte'
import TaskTypeIcon from '../taskTypes/TaskTypeIcon.svelte'
import TaskTypeKindEditor from '../taskTypes/TaskTypeKindEditor.svelte'
import IconLayers from '../icons/Layers.svelte'
export let type: ProjectType
export let descriptor: ProjectTypeDescriptor | undefined
@ -182,6 +180,7 @@
icon={IconCopy}
size={'small'}
kind={'secondary'}
disabled
on:click={(ev) => {
// Do copy of type
}}
@ -190,6 +189,7 @@
icon={IconDelete}
size={'small'}
kind={'secondary'}
disabled
on:click={(ev) => {
// Ask for delete
}}

View File

@ -0,0 +1,40 @@
<!--
// 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 { IntlString } from '@hcengineering/platform'
import { Card } from '@hcengineering/presentation'
import { Button, Label } from '@hcengineering/ui'
import view from '@hcengineering/view-resources/src/plugin'
import { createEventDispatcher } from 'svelte'
import task from '../../plugin'
export let total: number
export let okAction: () => void | Promise<void>
export let label: IntlString
const dispatch = createEventDispatcher()
</script>
<Card {label} {okAction} canSave={true} okLabel={view.string.LabelYes} on:close={() => dispatch('close')}>
<svelte:fragment slot="buttons">
<Button label={view.string.LabelNo} on:click={() => dispatch('close')} />
</svelte:fragment>
<div class="flex-grow flex-col">
<div class="mb-2 fs-title">
<Label label={task.string.UpdateTasksStatusRequest} params={{ total }} />
</div>
</div>
<slot />
</Card>

View File

@ -0,0 +1,434 @@
<!--
// 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 core, { Attribute, Class, Ref, Status, StatusCategory } from '@hcengineering/core'
import { Asset, getEmbeddedLabel } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { clearSettingsStore, settingsStore } from '@hcengineering/setting-resources'
import { ProjectType, TaskType, calculateStatuses, createState } from '@hcengineering/task'
import {
ButtonIcon,
ButtonMenu,
EmojiPopup,
IconCopy,
IconDelete,
IconSettings,
IconWithEmoji,
Label,
Modal,
ModernEditbox,
TextArea,
closePopup,
getPlatformColorDef,
showPopup,
themeStore
} from '@hcengineering/ui'
import { ColorsPopup, statusStore } from '@hcengineering/view-resources'
import view from '@hcengineering/view-resources/src/plugin'
import { taskTypeStore, typeStore } from '../..'
import task from '../../plugin'
import ApproveStatusRenamePopup from './ApproveStatusRenamePopup.svelte'
import DeleteStateConfirmationPopup from './DeleteStateConfirmationPopup.svelte'
export let status: Status | undefined = undefined
export let _class: Ref<Class<Status>> | undefined = status?._class
export let taskType: TaskType
export let type: ProjectType
export let ofAttribute: Ref<Attribute<Status>>
export let category: Ref<StatusCategory> | undefined = status?.category
export let value: string
export let valuePattern: string | undefined = ''
export let color: number | undefined = undefined
export let icons: Asset[]
export let iconWithEmoji: Asset = view.ids.IconWithEmoji
export let icon: Asset | undefined
export let canDelete: boolean = true
export let selectableStates: Status[] = []
$: _taskType = $taskTypeStore.get(taskType._id) as TaskType
$: _type = $typeStore.get(type._id) as ProjectType
value = status?.name ?? valuePattern ?? ''
const client = getClient()
let description: string | undefined = status?.description
$: allowEditCategory = status === undefined
let total: number = 0
const query = createQuery()
$: if (status?._id !== undefined) {
query.query(
task.class.Task,
{ status: status?._id, kind: _taskType._id },
(res) => {
total = res.total
},
{ limit: 1, total: true }
)
} else {
query.unsubscribe()
total = 0
}
$: needUpdate =
(status === undefined ||
status.name.trim() !== value.trim() ||
description !== status?.description ||
color !== status.color) &&
value.trim() !== '' &&
!selectableStates.some((it) => it.name === value)
async function save (): Promise<void> {
if (total > 0 && value.trim() !== status?.name?.trim()) {
// We should ask for changes approve.
showPopup(
ApproveStatusRenamePopup,
{
object: status,
total,
okAction: async () => {
await performSave()
closePopup()
},
label: task.string.RenameStatus
},
undefined
)
} else {
await performSave()
}
}
async function performSave (): Promise<void> {
if (_taskType === undefined || _class === undefined) return
if (status === undefined) {
const _id = await createState(client, _class, {
ofAttribute,
name: value.trim(),
category,
color,
description
})
const states = _taskType.statuses.map((p) => $statusStore.byId.get(p)).filter((p) => p !== undefined) as Status[]
const lastIndex = states.findLastIndex((p) => p.category === category)
const statuses = [..._taskType.statuses.slice(0, lastIndex + 1), _id, ..._taskType.statuses.slice(lastIndex + 1)]
_type.statuses.push({
_id,
color,
icon,
taskType: _taskType._id
})
await client.update(_type, {
statuses: calculateStatuses(_type, $taskTypeStore, [{ taskTypeId: _taskType._id, statuses }])
})
await client.update(_taskType, {
statuses
})
_taskType.statuses = statuses
status = await client.findOne(_class, { _id })
} else if (needUpdate) {
const _id = await createState(client, _class, {
ofAttribute,
name: value.trim(),
category,
color,
description
})
const index = _taskType.statuses.indexOf(status._id)
let statuses = [..._taskType.statuses.slice(0, index), _id, ..._taskType.statuses.slice(index + 1)]
statuses = statuses.filter((it, idx, arr) => arr.indexOf(it) === idx)
let found = false
for (const status of _type.statuses) {
if (status._id === _id) {
status.color = color
status.icon = icon as any // Fix me
found = true
}
}
if (!found) {
_type.statuses.push({
_id,
color,
icon,
taskType: _taskType._id
})
}
await client.update(_taskType, {
statuses
})
_taskType.statuses = statuses
await client.update(_type, {
statuses: calculateStatuses(_type, $taskTypeStore, [{ taskTypeId: _taskType._id, statuses }])
})
if (status._id !== _id) {
const oldStatus = status._id
await renameStatuses(_type, _taskType, oldStatus, _id)
}
status = await client.findOne(_class, { _id })
}
const sameCategory = (
_taskType.statuses
.map((it) => $statusStore.byId.get(it))
.filter((it) => it !== undefined)
.filter((it) => it?.category === status?.category) as Status[]
).filter((it, idx, arr) => arr.findIndex((qt) => qt._id === it._id) === idx)
canDelete = sameCategory.length > 1
selectableStates = sameCategory.filter((it) => it._id !== status?._id)
}
let selected: number = icon === iconWithEmoji ? 1 : 0
const items = [
{
id: 'color',
label: task.string.Color
},
{
id: 'emoji',
label: view.string.EmojiCategory
}
]
$: allCategories = getClient()
.getModel()
.findAllSync(core.class.StatusCategory, { _id: { $in: _taskType.statusCategories } })
$: categories = allCategories.map((it) => ({
id: it._id,
label: it.label,
icon: it.icon
}))
async function renameStatuses (
type: ProjectType,
taskType: TaskType,
oldStatus: Ref<Status>,
newStatus: Ref<Status>
): Promise<void> {
const projects = await client.findAll(task.class.Project, { type: type._id })
while (true) {
const docs = await client.findAll(
task.class.Task,
{
status: oldStatus,
kind: taskType._id,
space: { $in: projects.map((p) => p._id) }
},
{ limit: 1000 }
)
if (docs.length === 0) {
break
}
const op = client.apply(oldStatus)
for (const d of docs) {
await op.update(d, { status: newStatus })
}
await op.commit()
}
}
function onDelete (): void {
if (status === undefined) return
const estatus = status
showPopup(
DeleteStateConfirmationPopup,
{
object: estatus,
selectableStates,
taskType: _taskType,
deleteAction: async (newStatus: Status) => {
const statuses = _taskType.statuses.filter((it) => it !== estatus._id)
await client.update(_type, {
statuses: calculateStatuses(_type, $taskTypeStore, [{ taskTypeId: _taskType._id, statuses }])
})
await client.update(_taskType, {
statuses
})
await renameStatuses(_type, _taskType, estatus._id, newStatus._id)
closePopup()
$settingsStore = {
id: newStatus._id,
component: task.component.CreateStatePopup,
props: {
status: newStatus,
taskType: _taskType,
_class,
category,
type: _type,
ofAttribute,
icon,
color,
icons
}
}
}
},
undefined
)
}
function onDuplicate (): void {
let pattern = ''
let inc = 2
let prefix = value
const g = value.match(/- ([0-9]+)/g)
if (g != null) {
prefix = value.split(g[0])[0].trim()
}
while (true) {
pattern = prefix + ' - ' + (selectableStates.length + inc)
if (selectableStates.some((it) => it.name === pattern) || status?.name === pattern) {
// Duplicate
inc++
} else {
break
}
}
$settingsStore = {
id: '#',
component: task.component.CreateStatePopup,
props: {
status: undefined,
taskType: _taskType,
_class,
category,
type: _type,
ofAttribute,
icon,
color,
icons,
valuePattern: pattern
}
}
}
</script>
<Modal
label={task.string.StatusPopupTitle}
type={'type-aside'}
okLabel={status === undefined ? presentation.string.Create : presentation.string.Save}
okAction={save}
canSave={needUpdate}
onCancel={() => {
clearSettingsStore()
}}
>
<svelte:fragment slot="actions">
<ButtonIcon
icon={IconDelete}
size={'small'}
kind={'tertiary'}
disabled={status === undefined || !canDelete}
on:click={onDelete}
/>
<ButtonIcon
icon={IconCopy}
size={'small'}
kind={'tertiary'}
disabled={status === undefined}
on:click={onDuplicate}
/>
</svelte:fragment>
<div class="hulyModal-content__titleGroup">
<ModernEditbox bind:value label={task.string.StatusName} size={'large'} kind={'ghost'} />
<TextArea
placeholder={task.string.Description}
width={'100%'}
height={'4.5rem'}
margin={'var(--spacing-1) var(--spacing-2)'}
noFocusBorder
bind:value={description}
/>
</div>
<div class="hulyModal-content__settingsSet">
<div class="hulyModal-content__settingsSet-line">
<span class="label"><Label label={getEmbeddedLabel('Status Category')} /></span>
<ButtonMenu
items={categories}
selected={category}
disabled={!allowEditCategory}
icon={categories.find((it) => it.id === category)?.icon}
label={categories.find((it) => it.id === category)?.label}
kind={'secondary'}
size={'medium'}
on:selected={(it) => {
category = it.detail
}}
/>
</div>
</div>
<div class="hulyModal-content__settingsSet table">
<div class="hulyTableAttr-container">
<div class="hulyTableAttr-header font-medium-12 withButton">
<ButtonMenu
{items}
{selected}
icon={IconSettings}
label={items[selected].label}
kind={'secondary'}
size={'small'}
on:selected={(event) => {
if (event.detail) {
selected = items.findIndex((it) => it.id === event.detail)
if (selected === 1) {
icon = undefined
}
}
}}
/>
{#if icon === iconWithEmoji}
<IconWithEmoji icon={color ?? 0} size={'medium'} />
{/if}
</div>
<div class="hulyTableAttr-content" class:mb-2={selected === 1}>
{#if selected === 0}
<ColorsPopup
selected={getPlatformColorDef(color ?? 0, $themeStore.dark).name}
embedded
columns={'auto'}
on:close={(evt) => {
color = evt.detail
icon = undefined
}}
/>
{:else}
<EmojiPopup
embedded
selected={String.fromCodePoint(color ?? 0)}
on:close={(evt) => {
color = evt.detail.codePointAt(0)
icon = iconWithEmoji
}}
/>
{/if}
</div>
</div>
</div>
</Modal>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { DeleteConfirmationPopup } from '@hcengineering/contact-resources'
import { Status } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import task, { TaskType } from '@hcengineering/task'
import { ButtonMenu } from '@hcengineering/ui'
export let object: Status
export let taskType: TaskType
export let deleteAction: (newStatus: Status) => Promise<void>
export let selectableStates: Status[] = []
const query = createQuery()
let total: number = 0
$: query.query(
task.class.Task,
{ status: object._id, kind: taskType._id },
(res) => {
total = res.total
},
{ limit: 1, total: true }
)
const items = selectableStates.map((it) => ({ id: it._id, label: getEmbeddedLabel(it.name) }))
let selected = items?.[0]?.id ?? ''
$: selectedState = selectableStates.find((it) => it._id === selected)
</script>
<DeleteConfirmationPopup
{object}
deleteAction={async () => {
if (selectedState !== undefined) {
await deleteAction(selectedState)
}
}}
on:close
canDeleteExtra={selectedState !== undefined}
>
<div class="flex-grow mt-4">
{#if total > 0}
<div class="flex-grow justify-between">
<span class="fs-title">
A status {object.name} is in use by {total} tasks.
</span>
</div>
<div class="flex-grow justify-between mt-2">
<span> A new status should be selected. </span>
<ButtonMenu
{items}
{selected}
label={getEmbeddedLabel(selectedState?.name ?? '')}
kind={'secondary'}
size={'medium'}
on:selected={(it) => {
selected = it.detail
}}
/>
</div>
{:else}
<span> It is safe to delete status, it is not used. </span>
{/if}
</div>
</DeleteConfirmationPopup>

View File

@ -113,7 +113,7 @@
$: icon = projectState?.icon === view.ids.IconWithEmoji ? IconWithEmoji : projectState?.icon
const dispatchAccentColor = (color?: ColorDefinition, icon?: Asset | typeof IconWithEmoji): void => {
if (icon === undefined) {
if (icon == null) {
dispatch('accent-color', color)
} else {
dispatch('accent-color', null)

View File

@ -19,7 +19,7 @@
import { settingsStore } from '@hcengineering/setting-resources'
import { ProjectStatus, ProjectType, TaskType } from '@hcengineering/task'
import { IconMoreV2, IconOpenedArrow, Label } from '@hcengineering/ui'
import { ObjectPresenter } from '@hcengineering/view-resources'
import { ObjectPresenter, statusStore } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import task from '../../plugin'
@ -83,7 +83,11 @@
return map
}
$: groups = group(categories, states)
$: taskTypeStates = states
.filter((it) => taskType.statuses.includes(it._id))
.filter((it, idx, arr) => arr.findIndex((qt) => qt._id === it._id) === idx)
$: groups = group(categories, taskTypeStates)
function getPrevIndex (groups: Map<Ref<StatusCategory>, Status[]>, categories: Ref<StatusCategory>): number {
let index = 0
@ -107,6 +111,13 @@
const category = _status.category !== undefined ? categoriesMap.get(_status.category) : undefined
const projectStatus = getProjectStatus(type, _status)
const color = getProjectStatus(type, _status)?.color ?? _status.color ?? category?.color
const sameCategory = (
taskType.statuses
.map((it) => $statusStore.byId.get(it))
.filter((it) => it !== undefined)
.filter((it) => it?.category === _status.category) as Status[]
).filter((it, idx, arr) => arr.findIndex((qt) => qt._id === it._id) === idx)
$settingsStore = {
id: opened,
component: task.component.CreateStatePopup,
@ -117,14 +128,16 @@
ofAttribute: _status.ofAttribute,
icon: projectStatus?.icon,
color,
icons
icons,
canDelete: sameCategory.length > 1,
selectableStates: sameCategory.filter((it) => it._id !== _status._id)
}
}
}
}
</script>
{#each categories as cat, i}
{#each categories as cat (cat._id)}
{@const states = groups.get(cat._id) ?? []}
{@const prevIndex = getPrevIndex(groups, cat._id)}
<div class="hulyTableAttr-content class withTitle">
@ -132,7 +145,7 @@
<Label label={cat.label} />
</div>
<div class="hulyTableAttr-content__wrapper">
{#each states as state, i}
{#each states as state, i (state._id)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<button
bind:this={elements[prevIndex + i]}

View File

@ -52,7 +52,7 @@
}
)
}
function handleAddStatus (el: MouseEvent): void {
function handleAddStatus (): void {
const icons: Asset[] = []
const attr = findStatusAttr(getClient().getHierarchy(), taskType.ofClass)
$settingsStore = {
@ -64,7 +64,7 @@
_class: taskType.statusClass,
category: task.statusCategory.Active,
type: projectType,
ofAttribute: attr,
ofAttribute: attr._id,
icon: undefined,
color: 0,
icons

View File

@ -41,7 +41,7 @@ import { type ViewletDescriptor } from '@hcengineering/view'
import { CategoryQuery, statusStore } from '@hcengineering/view-resources'
import AssignedTasks from './components/AssignedTasks.svelte'
import CreateStatePopup from './components/CreateStatePopup.svelte'
import CreateStatePopup from './components/state/CreateStatePopup.svelte'
import Dashboard from './components/Dashboard.svelte'
import DueDateEditor from './components/DueDateEditor.svelte'
import KanbanTemplatePresenter from './components/KanbanTemplatePresenter.svelte'

View File

@ -88,7 +88,10 @@ export default mergeIds(taskId, task, {
ProcessStates: '' as IntlString,
Type: '' as IntlString,
Group: '' as IntlString,
Color: '' as IntlString
Color: '' as IntlString,
RenameStatus: '' as IntlString,
UpdateTasksStatusRequest: '' as IntlString
},
status: {
AssigneeRequired: '' as IntlString

View File

@ -53,7 +53,7 @@ export interface DbAdapter {
domain?: Domain // Allow to find for Doc's in specified domain only.
}
) => Promise<FindResult<T>>
tx: (...tx: Tx[]) => Promise<TxResult>
tx: (...tx: Tx[]) => Promise<TxResult[]>
find: (domain: Domain) => StorageIterator
@ -107,8 +107,8 @@ export class DummyDbAdapter implements DbAdapter {
async createIndexes (domain: Domain, config: Pick<IndexingConfiguration<Doc>, 'indexes'>): Promise<void> {}
async removeOldIndex (domain: Domain, deletePattern: RegExp, keepPattern: RegExp): Promise<void> {}
async tx (...tx: Tx[]): Promise<TxResult> {
return {}
async tx (...tx: Tx[]): Promise<TxResult[]> {
return []
}
async close (): Promise<void> {}
@ -147,7 +147,7 @@ class InMemoryAdapter extends DummyDbAdapter implements DbAdapter {
return await this.modeldb.findAll(_class, query, options)
}
async tx (...tx: Tx[]): Promise<TxResult> {
async tx (...tx: Tx[]): Promise<TxResult[]> {
return await this.modeldb.tx(...tx)
}

View File

@ -187,7 +187,7 @@ class TServerStorage implements ServerStorage {
return adapter
}
private async routeTx (ctx: MeasureContext, removedDocs: Map<Ref<Doc>, Doc>, ...txes: Tx[]): Promise<TxResult> {
private async routeTx (ctx: MeasureContext, removedDocs: Map<Ref<Doc>, Doc>, ...txes: Tx[]): Promise<TxResult[]> {
let part: TxCUD<Doc>[] = []
let lastDomain: Domain | undefined
const result: TxResult[] = []
@ -244,13 +244,6 @@ class TServerStorage implements ServerStorage {
}
}
await processPart()
if (result.length === 1) {
return result[0]
}
if (result.length === 0) {
return false
}
return result
}
@ -720,7 +713,7 @@ class TServerStorage implements ServerStorage {
return { passed, onEnd }
}
async apply (ctx: MeasureContext, txes: Tx[], broadcast: boolean, target?: string[]): Promise<TxResult> {
async apply (ctx: MeasureContext, txes: Tx[], broadcast: boolean, target?: string[]): Promise<TxResult[]> {
const result = await this.processTxes(ctx, txes)
let derived: Tx[] = []
@ -749,7 +742,7 @@ class TServerStorage implements ServerStorage {
}
}
async processTxes (ctx: MeasureContext, txes: Tx[]): Promise<[TxResult, Tx[]]> {
async processTxes (ctx: MeasureContext, txes: Tx[]): Promise<[TxResult[], Tx[]]> {
// store tx
const _findAll: ServerStorage['findAll'] = async <T extends Doc>(
ctx: MeasureContext,
@ -766,7 +759,7 @@ class TServerStorage implements ServerStorage {
const triggerFx = new Effects()
const removedMap = new Map<Ref<Doc>, Doc>()
const onEnds: (() => void)[] = []
let result: TxResult = {}
const result: TxResult[] = []
let derived: Tx[] = []
try {
@ -777,8 +770,17 @@ class TServerStorage implements ServerStorage {
const passed = await this.verifyApplyIf(ctx, applyIf, _findAll)
onEnds.push(passed.onEnd)
if (passed.passed) {
result.push({
derived: [],
success: true
})
this.fillTxes(applyIf.txes, txToStore, modelTx, txToProcess, applyTxes)
derived = [...applyIf.txes]
} else {
result.push({
derived: [],
success: false
})
}
}
for (const tx of modelTx) {
@ -790,7 +792,7 @@ class TServerStorage implements ServerStorage {
await this.modelDb.tx(tx)
}
await ctx.with('domain-tx', {}, async () => await this.getAdapter(DOMAIN_TX).tx(...txToStore))
result = await ctx.with('apply', {}, (ctx) => this.routeTx(ctx, removedMap, ...txToProcess))
result.push(...(await ctx.with('apply', {}, (ctx) => this.routeTx(ctx, removedMap, ...txToProcess))))
// invoke triggers and store derived objects
derived = derived.concat(await this.processDerived(ctx, txToProcess, triggerFx, _findAll, removedMap))
@ -816,12 +818,14 @@ class TServerStorage implements ServerStorage {
p()
})
}
return [result, derived]
}
async tx (ctx: MeasureContext, tx: Tx): Promise<[TxResult, Tx[]]> {
return await ctx.with('client-tx', { _class: tx._class }, async (ctx) => await this.processTxes(ctx, [tx]))
return await ctx.with('client-tx', { _class: tx._class }, async (ctx) => {
const result = await this.processTxes(ctx, [tx])
return [result[0][0], result[1]]
})
}
find (domain: Domain): StorageIterator {

View File

@ -52,8 +52,8 @@ class ElasticDataAdapter implements DbAdapter {
return Object.assign([], { total: 0 })
}
async tx (...tx: Tx[]): Promise<TxResult> {
return {}
async tx (...tx: Tx[]): Promise<TxResult[]> {
return []
}
async init (model: Tx[]): Promise<void> {}

View File

@ -144,8 +144,8 @@ abstract class MongoAdapterBase implements DbAdapter {
}
}
async tx (...tx: Tx[]): Promise<TxResult> {
return {}
async tx (...tx: Tx[]): Promise<TxResult[]> {
return []
}
async close (): Promise<void> {
@ -830,7 +830,7 @@ class MongoAdapter extends MongoAdapterBase {
console.error('Unknown/Unsupported operation:', tx._class, tx)
}
async tx (...txes: Tx[]): Promise<TxResult> {
async tx (...txes: Tx[]): Promise<TxResult[]> {
const result: TxResult[] = []
const bulkOperations: DomainOperation[] = []
@ -875,13 +875,10 @@ class MongoAdapter extends MongoAdapterBase {
}
await bulkExecute()
} else {
return (await this.getOperations(txes[0])?.raw()) ?? {}
}
if (result.length === 0) {
return false
}
if (result.length === 1) {
return result[0]
const r = await this.getOperations(txes[0])?.raw()
if (r !== undefined) {
result.push(r)
}
}
return result
}
@ -1163,12 +1160,12 @@ class MongoAdapter extends MongoAdapterBase {
class MongoTxAdapter extends MongoAdapterBase implements TxAdapter {
txColl: Collection | undefined
override async tx (...tx: Tx[]): Promise<TxResult> {
override async tx (...tx: Tx[]): Promise<TxResult[]> {
if (tx.length === 0) {
return {}
return []
}
await this.txCollection().insertMany(tx.map((it) => translateDoc(it)))
return {}
return []
}
private txCollection (): Collection {

View File

@ -49,8 +49,8 @@ class MinioBlobAdapter implements DbAdapter {
return Object.assign([], { total: 0 })
}
async tx (...tx: Tx[]): Promise<TxResult> {
return {}
async tx (...tx: Tx[]): Promise<TxResult[]> {
return []
}
async init (model: Tx[]): Promise<void> {}

View File

@ -14,8 +14,12 @@
//
import core, {
type Account,
AccountRole,
TxFactory,
TxProcessor,
WorkspaceEvent,
generateId,
type Account,
type BulkUpdateEvent,
type Class,
type Doc,
@ -25,19 +29,16 @@ import core, {
type LoadModelResponse,
type MeasureContext,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
type Timestamp,
type Tx,
type TxApplyIf,
type TxApplyResult,
type TxCUD,
TxProcessor,
type TxResult,
type TxWorkspaceEvent,
WorkspaceEvent,
generateId,
type SearchQuery,
type SearchOptions,
type SearchResult,
TxFactory
type TxWorkspaceEvent
} from '@hcengineering/core'
import { type Pipeline, type SessionContext } from '@hcengineering/server-core'
import { type Token } from '@hcengineering/server-token'
@ -166,12 +167,24 @@ export class ClientSession implements Session {
}
}
console.log('Broadcasting bulk', derived.length)
this.broadcast(null, this.token.workspace, { result: this.createBroadcastEvent(Array.from(classes)) }, target)
const bevent = this.createBroadcastEvent(Array.from(classes))
if (tx._class === core.class.TxApplyIf) {
;(result as TxApplyResult).derived.push(bevent)
}
this.broadcast(this, this.token.workspace, { result: bevent }, target)
} else {
if (tx._class === core.class.TxApplyIf) {
;(result as TxApplyResult).derived.push(...derived)
}
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)
this.broadcast(
tx._class === core.class.TxApplyIf ? this : null,
this.token.workspace,
{ result: part },
target
)
}
}
} else {
@ -184,7 +197,7 @@ export class ClientSession implements Session {
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)
;(result as TxApplyResult).derived.push(this.createBroadcastEvent(apply.extraNotify))
}
}
return result