mirror of
https://github.com/hcengineering/platform.git
synced 2024-12-22 11:01:54 +03:00
UBERF-5418: Fix status editing (#4590)
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
parent
2e4d07fb1b
commit
ed5f00e842
@ -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
|
||||
|
@ -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"
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 []
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 => ({
|
||||
|
@ -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 = ''
|
||||
|
||||
|
@ -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."
|
||||
}
|
||||
}
|
@ -95,6 +95,8 @@
|
||||
"Type": "Тип",
|
||||
"Group": "Группа",
|
||||
"Color": "Цвет",
|
||||
"Identifier": "Идентификатор"
|
||||
"Identifier": "Идентификатор",
|
||||
"RenameStatus": "Переименование статуса в новое имя",
|
||||
"UpdateTasksStatusRequest": "Статус сейчас используется в {total} задачах, потребуется обновление всеи их. Пожалуйста подтвердите."
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
||||
|
@ -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
|
||||
}}
|
||||
|
@ -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>
|
@ -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>
|
@ -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>
|
@ -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)
|
||||
|
@ -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]}
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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> {}
|
||||
|
@ -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 {
|
||||
|
@ -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> {}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user