Initial Estimations support. (#2251)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-08-16 17:19:33 +07:00 committed by GitHub
parent 42ba7c0944
commit 681d83a5d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1172 additions and 136 deletions

View File

@ -206,6 +206,7 @@ export function createModel (builder: Builder): void {
'',
{
key: '$lookup.contact.$lookup.channels',
label: contact.string.Channel,
sortingKey: ['$lookup.contact.$lookup.channels.lastMessage', '$lookup.contact.channels']
},
'modifiedOn'
@ -232,7 +233,11 @@ export function createModel (builder: Builder): void {
'attachments',
'modifiedOn',
{ key: '', presenter: view.component.RolePresenter, label: view.string.Role },
{ key: '$lookup.channels', sortingKey: ['$lookup.channels.lastMessage', 'channels'] }
{
key: '$lookup.channels',
label: contact.string.ContactInfo,
sortingKey: ['$lookup.channels.lastMessage', 'channels']
}
],
hiddenKeys: ['name']
},

View File

@ -342,6 +342,7 @@ export function createModel (builder: Builder): void {
'',
{
key: '$lookup.channels',
label: contact.string.ContactInfo,
sortingKey: ['$lookup.channels.lastMessage', 'channels']
},
'modifiedOn'

View File

@ -182,7 +182,11 @@ export function createModel (builder: Builder): void {
'$lookup._class',
'leads',
'modifiedOn',
{ key: '$lookup.channels', sortingKey: ['$lookup.channels.lastMessage', 'channels'] }
{
key: '$lookup.channels',
label: contact.string.ContactInfo,
sortingKey: ['$lookup.channels.lastMessage', 'channels']
}
],
hiddenKeys: ['name']
},

View File

@ -283,7 +283,11 @@ export function createModel (builder: Builder): void {
}
},
'modifiedOn',
{ key: '$lookup.channels', sortingKey: ['$lookup.channels.lastMessage', 'channels'] }
{
key: '$lookup.channels',
label: contact.string.ContactInfo,
sortingKey: ['$lookup.channels.lastMessage', 'channels']
}
],
hiddenKeys: ['name']
},

View File

@ -24,6 +24,7 @@ import {
Index,
Model,
Prop,
ReadOnly,
TypeDate,
TypeMarkup,
TypeNumber,
@ -34,11 +35,12 @@ import {
import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter'
import core, { DOMAIN_SPACE, TAttachedDoc, TDoc, TSpace, TType } from '@anticrm/model-core'
import view, { createAction } from '@anticrm/model-view'
import view, { classPresenter, createAction } from '@anticrm/model-view'
import workbench, { createNavigateAction } from '@anticrm/model-workbench'
import notification from '@anticrm/notification'
import { Asset, IntlString } from '@anticrm/platform'
import setting from '@anticrm/setting'
import tags from '@anticrm/tags'
import task from '@anticrm/task'
import {
Document,
@ -52,10 +54,10 @@ import {
Sprint,
SprintStatus,
Team,
TimeSpendReport,
trackerId
} from '@anticrm/tracker'
import { KeyBinding } from '@anticrm/view'
import tags from '@anticrm/tags'
import tracker from './plugin'
import presentation from '@anticrm/model-presentation'
@ -159,6 +161,13 @@ export class TTeam extends TSpace implements Team {
defaultIssueStatus!: Ref<IssueStatus>
}
/**
* @public
*/
export function TypeReportedTime (): Type<number> {
return { _class: tracker.class.TypeReportedTime, label: core.string.Number }
}
/**
* @public
*/
@ -219,8 +228,38 @@ export class TIssue extends TAttachedDoc implements Issue {
@Prop(TypeRef(tracker.class.Sprint), tracker.string.Sprint)
sprint!: Ref<Sprint> | null
@Prop(TypeNumber(), tracker.string.Estimation)
estimation!: number
@Prop(TypeReportedTime(), tracker.string.ReportedTime)
@ReadOnly()
reportedTime!: number
@Prop(Collection(tracker.class.TimeSpendReport), tracker.string.TimeSpendReports)
reports!: number
}
/**
* @public
*/
@Model(tracker.class.TimeSpendReport, core.class.AttachedDoc, DOMAIN_TRACKER)
@UX(tracker.string.TimeSpendReport, tracker.icon.TimeReport, tracker.string.TimeSpendReport)
export class TTimeSpendReport extends TAttachedDoc implements TimeSpendReport {
declare attachedTo: Ref<Issue>
@Prop(TypeRef(contact.class.Employee), contact.string.Employee)
employee!: Ref<Employee>
@Prop(TypeDate(), tracker.string.TimeSpendReportDate)
date!: Timestamp | null
@Prop(TypeNumber(), tracker.string.TimeSpendReportValue)
value!: number
@Prop(TypeString(), tracker.string.TimeSpendReportDescription)
description!: string
}
/**
* @public
*/
@ -321,6 +360,10 @@ export class TSprint extends TDoc implements Sprint {
declare space: Ref<Team>
}
@UX(core.string.Number)
@Model(tracker.class.TypeReportedTime, core.class.Type)
export class TTypeReportedTime extends TType {}
export function createModel (builder: Builder): void {
builder.createModel(
TTeam,
@ -331,7 +374,9 @@ export function createModel (builder: Builder): void {
TTypeIssuePriority,
TTypeProjectStatus,
TSprint,
TTypeSprintStatus
TTypeSprintStatus,
TTimeSpendReport,
TTypeReportedTime
)
builder.createDoc(view.class.Viewlet, core.space.Model, {
@ -357,6 +402,7 @@ export function createModel (builder: Builder): void {
presenter: tracker.component.SprintEditor,
props: { kind: 'list', size: 'small', shape: 'round', shouldShowPlaceholder: false }
},
{ key: '', presenter: tracker.component.EstimationEditor, props: { kind: 'list', size: 'small' } },
{ key: 'modifiedOn', presenter: tracker.component.ModificationDatePresenter, props: { fixed: 'right' } },
{
key: '$lookup.assignee',
@ -474,6 +520,10 @@ export function createModel (builder: Builder): void {
presenter: tracker.component.IssuePreview
})
builder.mixin(tracker.class.TimeSpendReport, core.class.Class, view.mixin.AttributePresenter, {
presenter: tracker.component.TimeSpendReport
})
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: tracker.function.IssueTitleProvider
})
@ -1015,4 +1065,11 @@ export function createModel (builder: Builder): void {
},
tracker.action.Relations
)
classPresenter(
builder,
tracker.class.TypeReportedTime,
view.component.NumberPresenter,
tracker.component.ReportedTimeEditor
)
}

View File

@ -317,6 +317,15 @@ async function upgradeProjects (tx: TxOperations): Promise<void> {
export const trackerOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await client.update(
DOMAIN_TRACKER,
{ _class: tracker.class.Issue, reports: { $exists: false } },
{
reports: 0,
estimation: 0,
reportedTime: 0
}
)
await Promise.all([migrateIssueProjects(client), migrateParentIssues(client)])
await migrateIssueParentInfo(client)
},

View File

@ -65,6 +65,7 @@ export interface UXObject extends Obj {
label: IntlString
icon?: Asset
hidden?: boolean
readonly?: boolean
}
/**

View File

@ -80,7 +80,7 @@ export default plugin(coreId, {
Type: '' as Ref<Class<Type<any>>>,
TypeString: '' as Ref<Class<Type<string>>>,
TypeIntlString: '' as Ref<Class<Type<IntlString>>>,
TypeNumber: '' as Ref<Class<Type<string>>>,
TypeNumber: '' as Ref<Class<Type<number>>>,
TypeMarkup: '' as Ref<Class<Type<string>>>,
TypeBoolean: '' as Ref<Class<Type<boolean>>>,
TypeTimestamp: '' as Ref<Class<Type<Timestamp>>>,

View File

@ -292,6 +292,48 @@ export abstract class TxProcessor implements WithTx {
return rawDoc
}
static buildDoc2Doc<D extends Doc>(txes: Tx[]): D | undefined {
let doc: Doc
let createTx = txes.find((tx) => tx._class === core.class.TxCreateDoc)
const collectionTxes = false
if (createTx === undefined) {
const collectionTxes = txes.filter((tx) => tx._class === core.class.TxCollectionCUD) as Array<
TxCollectionCUD<Doc, AttachedDoc>
>
createTx = collectionTxes.find((p) => p.tx._class === core.class.TxCreateDoc)
}
if (createTx === undefined) return
doc = TxProcessor.createDoc2Doc(createTx as TxCreateDoc<Doc>)
for (let tx of txes) {
if (collectionTxes) {
tx = TxProcessor.extractTx(tx)
}
if (tx._class === core.class.TxUpdateDoc) {
doc = TxProcessor.updateDoc2Doc(doc, tx as TxUpdateDoc<Doc>)
} else if (tx._class === core.class.TxMixin) {
const mixinTx = tx as TxMixin<Doc, Doc>
doc = TxProcessor.updateMixin4Doc(doc, mixinTx)
}
}
return doc as D
}
static extractTx (tx: Tx): Tx {
if (tx._class === core.class.TxCollectionCUD) {
const ctx = tx as TxCollectionCUD<Doc, AttachedDoc>
if (ctx.tx._class === core.class.TxCreateDoc) {
const create = ctx.tx as TxCreateDoc<AttachedDoc>
create.attributes.attachedTo = ctx.objectId
create.attributes.attachedToClass = ctx.objectClass
create.attributes.collection = ctx.collection
return create
}
return ctx.tx
}
return tx
}
protected abstract txCreateDoc (tx: TxCreateDoc<Doc>): Promise<TxResult>
protected abstract txPutBag (tx: TxPutBag<PropertyType>): Promise<TxResult>
protected abstract txUpdateDoc (tx: TxUpdateDoc<Doc>): Promise<TxResult>

View File

@ -158,6 +158,15 @@ export function Hidden () {
}
}
/**
* @public
*/
export function ReadOnly () {
return function (target: any, propertyKey: string): void {
setAttr(target, propertyKey, 'readonly', true)
}
}
/**
* @public
*/

View File

@ -92,6 +92,7 @@
type={attribute?.type}
{maxWidth}
value={getAttribute(client, object, { key: attributeKey, attr: attribute })}
readonly={attribute.readonly ?? false}
space={object.space}
{onChange}
{focus}
@ -105,6 +106,7 @@
type={attribute?.type}
{maxWidth}
value={getAttribute(client, object, { key: attributeKey, attr: attribute })}
readonly={attribute.readonly ?? false}
space={object.space}
{onChange}
{focus}

View File

@ -27,7 +27,7 @@
if (value < min) value = min
const lenghtC: number = Math.PI * 14 - 1
const procC: number = lenghtC / (max - min)
$: procC = lenghtC / (max - min)
$: dashOffset = (value - min) * procC
</script>

View File

@ -162,4 +162,9 @@
<path d="M1.33398 10H0.833984V10.5H1.33398V10ZM1.33398 10.5H7.33398V9.5H1.33398V10.5ZM7.33398 5.5H3.33398V6.5H7.33398V5.5ZM0.833984 8V10H1.83398V8H0.833984ZM3.33398 5.5C1.95327 5.5 0.833984 6.61929 0.833984 8H1.83398C1.83398 7.17157 2.50556 6.5 3.33398 6.5V5.5Z" fill="white"/>
<path d="M14.666 6H15.166V5.5H14.666V6ZM14.666 5.5H11.3327V6.5H14.666V5.5ZM11.3327 10.5H12.666V9.5H11.3327V10.5ZM15.166 8V6H14.166V8H15.166ZM12.666 10.5C14.0467 10.5 15.166 9.38071 15.166 8H14.166C14.166 8.82843 13.4944 9.5 12.666 9.5V10.5Z" fill="white"/>
</symbol>
<symbol id="timeReport" viewBox="0 0 16 16" fill="none">
<circle cx="7.99935" cy="7.99996" r="5.83333" stroke="white"/>
<path d="M10.8284 5.17157C10.0783 4.42143 9.06087 4 8 4C6.93913 4 5.92172 4.42143 5.17157 5.17157C4.42143 5.92172 4 6.93913 4 8C4 9.06087 4.42143 10.0783 5.17157 10.8284L8 8L10.8284 5.17157Z" fill="white"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -196,7 +196,19 @@
"AddToSprint": "Add to Sprint",
"NewSprint": "New Sprint",
"CreateSprint": "Create"
"CreateSprint": "Create",
"Estimation": "Estimation",
"ReportedTime": "Reported Time",
"TimeSpendReports": "Time spend reports",
"TimeSpendReport": "Time spend report",
"TimeSpendReportAdd": "Add time report",
"TimeSpendReportDate": "Date",
"TimeSpendReportValue": "Reported time",
"TimeSpendReportValueTooltip": "Reported time in man days",
"TimeSpendReportDescription": "Description",
"TimeSpendValue": "{value}d",
"SprintPassed": "{from}d/{to}d"
},
"status": {}
}

View File

@ -196,7 +196,19 @@
"AddToSprint": "Добавить в Спринт",
"NewSprint": "Новый Спринт",
"CreateSprint": "Создать"
"CreateSprint": "Создать",
"Estimation": "Оценка",
"ReportedTime": "Использовано",
"TimeSpendReports": "Отчеты по времени",
"TimeSpendReport": "Отчет по времени",
"TimeSpendReportAdd": "Добавить завтраченное время",
"TimeSpendReportDate": "Дата",
"TimeSpendReportValue": "Затраченное время",
"TimeSpendReportValueTooltip": "Затраченное время в человеко днях",
"TimeSpendReportDescription": "Описание",
"TimeSpendValue": "{value}d",
"SprintPassed": "{from}d/{to}d"
},
"status": {}
}

View File

@ -66,7 +66,9 @@ loadMetadata(tracker.icon, {
CopyID: `${icons}#copyID`,
CopyURL: `${icons}#copyURL`,
CopyBranch: `${icons}#copyBranch`
CopyBranch: `${icons}#copyBranch`,
TimeReport: `${icons}#timeReport`,
Estimation: `${icons}#timeReport`
})
addStringsLoader(trackerId, async (lang: string) => await import(`../lang/${lang}.json`))

View File

@ -68,7 +68,10 @@
dueDate: null,
comments: 0,
subIssues: 0,
parents: []
parents: [],
reportedTime: 0,
estimation: 0,
reports: 0
}
const dispatch = createEventDispatcher()
@ -151,7 +154,10 @@
dueDate: object.dueDate,
parents: parentIssue
? [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
: []
: [],
reportedTime: 0,
estimation: 0,
reports: 0
}
await client.addCollection(

View File

@ -184,6 +184,7 @@
shouldShowLabel: true,
value: groupByKey ? { [groupByKey]: category } : {},
statuses: groupByKey === 'status' ? statuses : undefined,
issues: groupedIssues[category],
size: 'inline',
kind: 'list'
}}
@ -241,6 +242,7 @@
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
groupBy={groupByKey}
{...attributeModel.props}
/>
</div>
@ -255,6 +257,7 @@
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
groupBy={groupByKey}
{...attributeModel.props}
/>
</FixedColumn>
@ -263,6 +266,7 @@
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
groupBy={groupByKey}
{...attributeModel.props}
/>
{:else if attributeModel.props?.fixed}
@ -275,6 +279,7 @@
<svelte:component
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
groupBy={groupByKey}
{...attributeModel.props}
/>
</FixedColumn>
@ -284,6 +289,7 @@
this={attributeModel.presenter}
value={getObjectValue(attributeModel.key, docObject) ?? ''}
issueId={docObject._id}
groupBy={groupByKey}
{...attributeModel.props}
/>
</div>

View File

@ -57,7 +57,8 @@
lookup: {
assignee: contact.class.Employee,
status: tracker.class.IssueStatus,
space: tracker.class.Team
space: tracker.class.Team,
sprint: tracker.class.Sprint
}
}
)

View File

@ -0,0 +1,126 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { getClient } from '@anticrm/presentation'
import { Issue } from '@anticrm/tracker'
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, Label, showPopup } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import EstimationPopup from './EstimationPopup.svelte'
import EstimationProgressCircle from './EstimationProgressCircle.svelte'
export let value: Issue
export let isEditable: boolean = true
export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'large'
export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = undefined
const client = getClient()
const dispatch = createEventDispatcher()
const handleestimationEditorOpened = (event: MouseEvent) => {
event.stopPropagation()
if (!isEditable) {
return
}
showPopup(
EstimationPopup,
{ value: value.estimation, format: 'number', object: value },
eventToHTMLElement(event),
(res) => {
if (res != null) {
changeEstimation(res)
}
}
)
}
const changeEstimation = async (newEstimation: number | undefined) => {
if (!isEditable || newEstimation === undefined || value.estimation === newEstimation) {
return
}
dispatch('change', newEstimation)
if ('_id' in value) {
await client.update(value, { estimation: newEstimation })
}
}
</script>
{#if value}
{#if kind === 'list'}
<div class="estimation-container" on:click={handleestimationEditorOpened}>
<div class="icon">
<EstimationProgressCircle value={value.reportedTime} max={value.estimation} />
</div>
<span class="overflow-label label">
{#if value.reportedTime > 0}
<Label label={tracker.string.TimeSpendValue} params={{ value: value.reportedTime }} />
/
{/if}
<Label label={tracker.string.TimeSpendValue} params={{ value: value.estimation }} />
</span>
</div>
{:else}
<Button
showTooltip={isEditable ? { label: tracker.string.Estimation } : undefined}
label={tracker.string.TimeSpendValue}
labelParams={{ value: value.estimation }}
icon={tracker.icon.Estimation}
{justify}
{width}
{size}
{kind}
disabled={!isEditable}
on:click={handleestimationEditorOpened}
/>
{/if}
{/if}
<style lang="scss">
.estimation-container {
display: flex;
align-items: center;
flex-shrink: 0;
min-width: 0;
cursor: pointer;
.icon {
display: flex;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 1rem;
height: 1rem;
color: var(--content-color);
}
.label {
margin-left: 0.5rem;
font-weight: 500;
font-size: 0.8125rem;
color: var(--accent-color);
}
&:hover {
.icon {
color: var(--caption-color) !important;
}
}
}
</style>

View File

@ -0,0 +1,92 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact from '@anticrm/contact'
import { FindOptions } from '@anticrm/core'
import { Card } from '@anticrm/presentation'
import { Issue, TimeSpendReport } from '@anticrm/tracker'
import { Button, EditBox, EditStyle, eventToHTMLElement, IconAdd, Scroller, showPopup } from '@anticrm/ui'
import { TableBrowser } from '@anticrm/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
import presentation from '@anticrm/presentation'
export let value: string | number | undefined
export let format: 'text' | 'password' | 'number'
export let kind: EditStyle = 'search-style'
export let maxWidth: string = '10rem'
export let object: Issue
let _value = value
const dispatch = createEventDispatcher()
function _onkeypress (ev: KeyboardEvent) {
if (ev.key === 'Enter') dispatch('close', _value)
}
const options: FindOptions<TimeSpendReport> = {
lookup: { employee: contact.class.Employee }
}
</script>
<Card
label={tracker.string.Estimation}
canSave={true}
okAction={() => {
dispatch('close', _value)
}}
okLabel={presentation.string.Save}
on:close={() => {
dispatch('close', null)
}}
>
<div class="header no-border flex-col p-1">
<div class="flex-row-center flex-between">
<EditBox
bind:value={_value}
{format}
{kind}
{maxWidth}
placeholder={tracker.string.Estimation}
focus
on:keypress={_onkeypress}
/>
</div>
</div>
<Scroller tableFade>
<TableBrowser
_class={tracker.class.TimeSpendReport}
query={{ attachedTo: object._id }}
config={['', '$lookup.employee', 'date', 'description']}
{options}
/>
</Scroller>
<svelte:fragment slot="buttons">
<Button
icon={IconAdd}
size={'small'}
on:click={(event) => {
showPopup(
TimeSpendReportPopup,
{ issueId: object._id, issueClass: object._class, space: object.space },
eventToHTMLElement(event)
)
}}
label={tracker.string.TimeSpendReportAdd}
/>
</svelte:fragment>
</Card>

View File

@ -0,0 +1,71 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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 { FernColor, FlamingoColor, IconSize, PlatinumColor } from '@anticrm/ui'
export let value: number
export let min: number = 0
export let max: number = 100
export let size: IconSize = 'small'
export let primary: boolean = false
export let color: string = PlatinumColor
export let greenColor: string = FernColor
export let overdueColor = FlamingoColor
if (value > max) value = max
if (value < min) value = min
const lenghtC: number = Math.PI * 14 - 1
$: procC = lenghtC / (max - min)
$: dashOffset = (Math.min(value, max) - min) * procC
$: color = value > max ? overdueColor : value === min ? color : greenColor
</script>
<svg class="svg-{size}" fill="none" viewBox="0 0 16 16">
<circle
cx={8}
cy={8}
r={7}
class="progress-circle"
style:stroke={'var(--divider-color)'}
style:opacity={'.5'}
style:transform={`rotate(${-78 + ((dashOffset + 1) * 360) / (lenghtC + 1)}deg)`}
style:stroke-dasharray={lenghtC}
style:stroke-dashoffset={dashOffset === 0 ? 0 : dashOffset + 3}
/>
<circle
cx={8}
cy={8}
r={7}
class="progress-circle"
style:stroke={primary ? 'var(--primary-bg-color)' : color}
style:opacity={dashOffset === 0 ? 0 : 1}
style:transform={'rotate(-82deg)'}
style:stroke-dasharray={lenghtC}
style:stroke-dashoffset={dashOffset === 0 ? lenghtC : lenghtC - dashOffset + 1}
/>
</svg>
<style lang="scss">
.progress-circle {
stroke-width: 2px;
stroke-linecap: round;
transform-origin: center;
transition: transform 0.6s ease 0s, stroke-dashoffset 0.6s ease 0s, stroke-dasharray 0.6s ease 0s,
opacity 0.6s ease 0s;
}
</style>

View File

@ -0,0 +1,82 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import type { IntlString } from '@anticrm/platform'
import { Issue } from '@anticrm/tracker'
import { ActionIcon, eventToHTMLElement, IconAdd, Label, showPopup } from '@anticrm/ui'
import ReportsPopup from './ReportsPopup.svelte'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
// export let label: IntlString
export let placeholder: IntlString
export let object: Issue
export let value: number
export let kind: 'no-border' | 'link' = 'no-border'
function addTimeReport (event: MouseEvent): void {
showPopup(
TimeSpendReportPopup,
{ issueId: object._id, issueClass: object._class, space: object.space },
eventToHTMLElement(event)
)
}
function showReports (event: MouseEvent): void {
showPopup(ReportsPopup, { issue: object }, eventToHTMLElement(event))
}
</script>
{#if kind === 'link'}
<div class="link-container flex-between" on:click={showReports}>
{#if value !== undefined}
<span class="overflow-label">{value}</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
<div class="add-action">
<ActionIcon icon={IconAdd} size={'small'} action={addTimeReport} />
</div>
</div>
{:else if value !== undefined}
<span class="overflow-label">{value}</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
<style lang="scss">
.link-container {
display: flex;
align-items: center;
padding: 0 0.875rem;
width: 100%;
height: 2rem;
border: 1px solid transparent;
border-radius: 0.25rem;
cursor: pointer;
.add-action {
visibility: hidden;
}
&:hover {
color: var(--caption-color);
background-color: var(--body-color);
border-color: var(--divider-color);
.add-action {
visibility: visible;
}
}
}
</style>

View File

@ -0,0 +1,59 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact from '@anticrm/contact'
import { FindOptions } from '@anticrm/core'
import presentation, { Card } from '@anticrm/presentation'
import { Issue, TimeSpendReport } from '@anticrm/tracker'
import { Button, eventToHTMLElement, IconAdd, Scroller, showPopup } from '@anticrm/ui'
import { TableBrowser } from '@anticrm/view-resources'
import tracker from '../../../plugin'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
export let issue: Issue
export function canClose (): boolean {
return true
}
const options: FindOptions<TimeSpendReport> = {
lookup: { employee: contact.class.Employee }
}
function addReport (event: MouseEvent): void {
showPopup(
TimeSpendReportPopup,
{ issueId: issue._id, issueClass: issue._class, space: issue.space },
eventToHTMLElement(event)
)
}
</script>
<Card
label={tracker.string.TimeSpendReports}
canSave={true}
on:close
okAction={() => {}}
okLabel={presentation.string.Ok}
>
<Scroller tableFade>
<TableBrowser
_class={tracker.class.TimeSpendReport}
query={{ attachedTo: issue._id }}
config={['', '$lookup.employee', 'description', 'date', 'modifiedOn', 'modifiedBy']}
{options}
/>
</Scroller>
<svelte:fragment slot="buttons">
<Button icon={IconAdd} size={'large'} on:click={addReport} />
</svelte:fragment>
</Card>

View File

@ -0,0 +1,86 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact, { Employee } from '@anticrm/contact'
import { WithLookup } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import type { TimeSpendReport } from '@anticrm/tracker'
import { eventToHTMLElement, Label, showPopup, tooltip } from '@anticrm/ui'
import view, { AttributeModel } from '@anticrm/view'
import { getObjectPresenter } from '@anticrm/view-resources'
import tracker from '../../../plugin'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
export let value: WithLookup<TimeSpendReport>
const client = getClient()
let presenter: AttributeModel
getObjectPresenter(client, contact.class.Employee, { key: '' }).then((p) => {
presenter = p
})
function editSpendReport (event: MouseEvent): void {
showPopup(
TimeSpendReportPopup,
{ issue: value.attachedTo, issueClass: value.attachedToClass, value: value },
eventToHTMLElement(event)
)
}
let employee: Employee | undefined | null = value.$lookup?.employee ?? null
$: if (employee === undefined) {
client.findOne(value.attachedToClass, { _id: value.attachedTo }).then((r) => {
employee = r as Employee
})
}
</script>
{#if value}
<span
class="issuePresenterRoot flex-row-center"
on:click={editSpendReport}
use:tooltip={value.employee
? {
label: tracker.string.TimeSpendReport,
component: view.component.ObjectPresenter,
props: {
objectId: value.employee,
_class: contact.class.Employee,
value: value.$lookup?.employee
}
}
: undefined}
>
<Label label={tracker.string.TimeSpendValue} params={{ value: value.value }} />
</span>
{/if}
<style lang="scss">
.issuePresenterRoot {
white-space: nowrap;
font-size: 0.8125rem;
color: var(--content-color);
cursor: pointer;
&:hover {
color: var(--caption-color);
text-decoration: underline;
}
&:active {
color: var(--accent-color);
}
}
</style>

View File

@ -0,0 +1,96 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import contact from '@anticrm/contact'
import { AttachedData, Class, DocumentUpdate, Ref, Space } from '@anticrm/core'
import type { IntlString } from '@anticrm/platform'
import presentation, { Card, getClient, UserBox } from '@anticrm/presentation'
import { Issue, TimeSpendReport } from '@anticrm/tracker'
import { DatePresenter, EditBox } from '@anticrm/ui'
import tracker from '../../../plugin'
export let issueId: Ref<Issue>
export let issueClass: Ref<Class<Issue>>
export let space: Ref<Space>
export let value: TimeSpendReport | undefined
export let placeholder: IntlString = tracker.string.TimeSpendReportValue
export let maxWidth: string = '10rem'
export function canClose (): boolean {
return true
}
const data = {
date: value?.date ?? Date.now(),
description: value?.description ?? '',
value: value?.value,
employee: value?.employee ?? null
}
async function create (): Promise<void> {
if (value === undefined) {
getClient().addCollection(
tracker.class.TimeSpendReport,
space,
issueId,
issueClass,
'reports',
data as AttachedData<TimeSpendReport>
)
} else {
const ops: DocumentUpdate<TimeSpendReport> = {}
if (value.value !== data.value) {
ops.value = data.value
}
if (value.employee !== data.employee) {
ops.employee = data.employee
}
if (value.description !== data.description) {
ops.description = data.description
}
if (value.date !== data.date) {
ops.date = data.date
}
if (Object.keys(ops).length > 0) {
getClient().update(value, ops)
}
}
}
</script>
<Card
label={tracker.string.TimeSpendReportAdd}
canSave={data.value !== 0}
okAction={create}
on:close
okLabel={value === undefined ? presentation.string.Create : presentation.string.Save}
>
<div class="flex-row-center gap-2">
<EditBox bind:value={data.value} {placeholder} format={'number'} kind={'editbox'} {maxWidth} focus />
<UserBox
_class={contact.class.Employee}
label={contact.string.Employee}
kind={'link-bordered'}
bind:value={data.employee}
/>
<DatePresenter kind={'link'} bind:value={data.date} editable />
</div>
<EditBox
bind:value={data.description}
placeholder={tracker.string.TimeSpendReportDescription}
kind={'editbox'}
{maxWidth}
/>
</Card>

View File

@ -20,6 +20,7 @@
import { IntlString } from '@anticrm/platform'
import tracker from '../../plugin'
import ProjectSelector from '../ProjectSelector.svelte'
import { activeProject } from '../../issues'
export let value: Issue
export let isEditable: boolean = true
@ -32,6 +33,7 @@
export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = '100%'
export let onlyIcon: boolean = false
export let groupBy: string | undefined
const client = getClient()
@ -52,7 +54,7 @@
}
</script>
{#if value.project || shouldShowPlaceholder}
{#if (value.project && value.project !== $activeProject && groupBy !== 'project') || shouldShowPlaceholder}
<div
class="clear-mins"
use:tooltip={{ label: value.project ? tracker.string.MoveToProject : tracker.string.AddToProject }}

View File

@ -14,11 +14,13 @@
-->
<script lang="ts">
import { Ref } from '@anticrm/core'
import { Issue, Sprint } from '@anticrm/tracker'
import { getClient } from '@anticrm/presentation'
import { ButtonKind, ButtonShape, ButtonSize, tooltip } from '@anticrm/ui'
import { IntlString } from '@anticrm/platform'
import { createQuery, getClient } from '@anticrm/presentation'
import { Issue, Sprint } from '@anticrm/tracker'
import { ButtonKind, ButtonShape, ButtonSize, isWeekend, Label, tooltip } from '@anticrm/ui'
import { activeSprint } from '../../issues'
import tracker from '../../plugin'
import EstimationProgressCircle from '../issues/timereport/EstimationProgressCircle.svelte'
import SprintSelector from './SprintSelector.svelte'
export let value: Issue
@ -32,6 +34,8 @@
export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = '100%'
export let onlyIcon: boolean = false
export let issues: Issue[] | undefined
export let groupBy: string | undefined
const client = getClient()
@ -50,9 +54,35 @@
{ sprint: newSprintId }
)
}
$: totalEstimation = (issues ?? [{ estimation: 0 }])
.map((it) => it.estimation)
.reduce((it, cur) => {
return it + cur
})
$: totalReported = (issues ?? [{ reportedTime: 0 }])
.map((it) => it.reportedTime)
.reduce((it, cur) => {
return it + cur
})
const sprintQuery = createQuery()
let sprint: Sprint | undefined
$: if (issues !== undefined && value.sprint) {
sprintQuery.query(tracker.class.Sprint, { _id: value.sprint }, (res) => {
sprint = res.shift()
})
}
function getDayOfSprint (startDate: number, now: number): number {
const days = Math.floor(Math.abs((1 + now - startDate) / 1000 / 60 / 60 / 24)) + 1
const stDate = new Date(startDate)
const stDateDate = stDate.getDate()
const ds = Array.from(Array(days).keys()).map((it) => stDateDate + it)
return ds.filter((it) => !isWeekend(new Date(stDate.setDate(it)))).length
}
</script>
{#if value.sprint || shouldShowPlaceholder}
{#if (value.sprint && value.sprint !== $activeSprint && groupBy !== 'sprint') || shouldShowPlaceholder}
<div
class="clear-mins"
use:tooltip={{ label: value.sprint ? tracker.string.MoveToSprint : tracker.string.AddToSprint }}
@ -72,3 +102,30 @@
/>
</div>
{/if}
{#if issues}
{#if sprint}
{@const now = Date.now()}
{#if sprint.startDate < now && now < sprint.targetDate}
<!-- Active sprint in time -->
<Label
label={tracker.string.SprintPassed}
params={{
from: getDayOfSprint(sprint.startDate, now),
to: getDayOfSprint(sprint.startDate, sprint.targetDate) - 1
}}
/>
{/if}
{/if}
<!-- <Label label={tracker.string.SprintDay} value={}/> -->
<div class="ml-4 flex-row-center">
<div class="mr-2">
<EstimationProgressCircle value={totalReported} max={totalEstimation} />
</div>
{#if totalReported > 0}
<Label label={tracker.string.TimeSpendValue} params={{ value: totalReported }} />
/
{/if}
<Label label={tracker.string.TimeSpendValue} params={{ value: totalEstimation }} />
</div>
{/if}

View File

@ -69,6 +69,10 @@ import SprintTitlePresenter from './components/sprints/SprintTitlePresenter.svel
import SprintSelector from './components/sprints/SprintSelector.svelte'
import SprintEditor from './components/sprints/SprintEditor.svelte'
import ReportedTimeEditor from './components/issues/timereport/ReportedTimeEditor.svelte'
import TimeSpendReport from './components/issues/timereport/TimeSpendReport.svelte'
import EstimationEditor from './components/issues/timereport/EstimationEditor.svelte'
export async function queryIssue<D extends Issue> (
_class: Ref<Class<D>>,
client: Client,
@ -170,7 +174,10 @@ export default async (): Promise<Resources> => ({
SprintStatusPresenter,
SprintTitlePresenter,
SprintSelector,
SprintEditor
SprintEditor,
ReportedTimeEditor,
TimeSpendReport,
EstimationEditor
},
completion: {
IssueQuery: async (client: Client, query: string) => await queryIssue(tracker.class.Issue, client, query)

View File

@ -213,7 +213,18 @@ export default mergeIds(trackerId, tracker, {
CreateSprint: '' as IntlString,
AddToSprint: '' as IntlString,
MoveToSprint: '' as IntlString,
NoSprint: '' as IntlString
NoSprint: '' as IntlString,
Estimation: '' as IntlString,
ReportedTime: '' as IntlString,
TimeSpendReport: '' as IntlString,
TimeSpendReportAdd: '' as IntlString,
TimeSpendReports: '' as IntlString,
TimeSpendReportDate: '' as IntlString,
TimeSpendReportValue: '' as IntlString,
TimeSpendReportDescription: '' as IntlString,
TimeSpendValue: '' as IntlString,
SprintPassed: '' as IntlString
},
component: {
NopeComponent: '' as AnyComponent,
@ -261,7 +272,10 @@ export default mergeIds(trackerId, tracker, {
Sprints: '' as AnyComponent,
SprintPresenter: '' as AnyComponent,
SprintStatusPresenter: '' as AnyComponent,
SprintTitlePresenter: '' as AnyComponent
SprintTitlePresenter: '' as AnyComponent,
ReportedTimeEditor: '' as AnyComponent,
TimeSpendReport: '' as AnyComponent,
EstimationEditor: '' as AnyComponent
},
function: {
IssueTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>) => Promise<string>>

View File

@ -299,7 +299,7 @@ const listIssueStatusOrder = [
export function getCategories (
key: IssuesGroupByKeys | undefined,
elements: Issue[],
elements: Array<WithLookup<Issue>>,
shouldShowAll: boolean,
statuses: IssueStatus[],
employees: Employee[]
@ -307,7 +307,6 @@ export function getCategories (
if (key === undefined) {
return [undefined] // No grouping
}
const defaultStatuses = listIssueStatusOrder.map(
(category) => statuses.find((status) => status.category === category)?._id
)
@ -348,6 +347,17 @@ export function getCategories (
})
}
if (key === 'sprint') {
const sprints = new Map(elements.map((x) => [x.sprint, x.$lookup?.sprint]))
existingCategories.sort((p1, p2) => {
const i1 = sprints.get(p1 as Ref<Sprint>)
const i2 = sprints.get(p2 as Ref<Sprint>)
return (i2?.startDate ?? 0) - (i1?.startDate ?? 0)
})
}
if (key === 'assignee') {
existingCategories.sort((a1, a2) => {
const employeeId1 = a1 as Ref<Employee> | null

View File

@ -157,6 +157,30 @@ export interface Issue extends AttachedDoc {
rank: string
sprint?: Ref<Sprint> | null
// Estimation in man days
estimation: number
// ReportedTime time, auto updated using trigger.
reportedTime: number
// Collection of reportedTime entries, for proper time estimations per person.
reports: number
}
/**
* @public
*
* Declares time spend entry
*/
export interface TimeSpendReport extends AttachedDoc {
attachedTo: Ref<Issue>
employee: Ref<Employee> | null
date: Timestamp | null
// Value in man days
value: number
description: string
}
/**
@ -245,7 +269,9 @@ export default plugin(trackerId, {
TypeIssuePriority: '' as Ref<Class<Type<IssuePriority>>>,
TypeProjectStatus: '' as Ref<Class<Type<ProjectStatus>>>,
Sprint: '' as Ref<Class<Sprint>>,
TypeSprintStatus: '' as Ref<Class<Type<SprintStatus>>>
TypeSprintStatus: '' as Ref<Class<Type<SprintStatus>>>,
TimeSpendReport: '' as Ref<Class<TimeSpendReport>>,
TypeReportedTime: '' as Ref<Class<Type<number>>>
},
ids: {
NoParent: '' as Ref<Issue>
@ -311,7 +337,10 @@ export default plugin(trackerId, {
CopyID: '' as Asset,
CopyURL: '' as Asset,
CopyBranch: '' as Asset
CopyBranch: '' as Asset,
TimeReport: '' as Asset,
Estimation: '' as Asset
},
category: {
Other: '' as Ref<TagCategory>,

View File

@ -120,13 +120,17 @@ export function filterActions (
const role = getCurrentAccount().role
const clazz = hierarchy.getClass(doc._class)
const ignoreActions = hierarchy.as(clazz, view.mixin.IgnoreActions)
const ignore: Array<Ref<Action>> = ignoreActions?.actions ?? []
const ignore: Array<Ref<Action>> = Array.from(ignoreActions?.actions ?? [])
// Collect ignores from parent
hierarchy.getAncestors(clazz._id).forEach((cl) => {
const ancestors = hierarchy.getAncestors(clazz._id)
for (const cl of ancestors) {
const ignoreActions = hierarchy.as(hierarchy.getClass(cl), view.mixin.IgnoreActions)
ignore.push(...(ignoreActions?.actions ?? []))
})
if (ignoreActions?.actions !== undefined) {
ignore.push(...ignoreActions.actions)
}
}
const overrideRemove: Array<Ref<Action>> = []
for (const action of actions) {
if (ignore.includes(action._id)) {

View File

@ -26,9 +26,6 @@
const client = getClient()
let actions: Action[] = []
let q = 0
addTxListener((tx) => {
if (tx._class === core.class.TxRemoveDoc) {
const docId = (tx as TxRemoveDoc<Doc>).objectId
@ -42,16 +39,14 @@
let lastKey: KeyboardEvent | undefined
async function updateActions (
async function getCurrentActions (
context: {
mode: ViewContextType
application?: Ref<Doc>
},
focus: Doc | undefined | null,
selection: Doc[]
): Promise<void> {
const t = ++q
): Promise<Action[]> {
let docs: Doc | Doc[] = []
if (selection.find((it) => it._id === focus?._id) === undefined && focus != null) {
docs = focus
@ -59,18 +54,13 @@
docs = selection
}
const r = await getContextActions(client, docs, context)
if (t === q) {
actions = r
}
return await getContextActions(client, docs, context)
}
$: ctx = $contextStore[$contextStore.length - 1]
$: mode = $contextStore[$contextStore.length - 1]?.mode
$: application = $contextStore[$contextStore.length - 1]?.application
$: if (ctx !== undefined) {
updateActions({ mode: mode as ViewContextType, application: application }, $focusStore.focus, $selectionStore)
}
function keyPrefix (key: KeyboardEvent): string {
return (
(key.altKey ? 'Alt + ' : '') +
@ -114,7 +104,11 @@
elm = prt
}
let currentActions = actions
let currentActions = await getCurrentActions(
{ mode: mode as ViewContextType, application: application },
$focusStore.focus,
$selectionStore
)
// For none we ignore all actions.
if (ctx.mode === 'none') {

View File

@ -25,6 +25,7 @@
export let maxWidth: string = '10rem'
export let onChange: (value: number) => void
export let kind: 'no-border' | 'link' = 'no-border'
export let readonly = false
let shown: boolean = false
@ -37,7 +38,7 @@
<div
class="link-container"
on:click={(ev) => {
if (!shown) {
if (!shown && !readonly) {
showPopup(EditBoxPopup, { value, format: 'number' }, eventToHTMLElement(ev), (res) => {
if (res !== undefined) {
value = res
@ -48,12 +49,18 @@
}
}}
>
{#if value}
{#if value !== undefined}
<span class="overflow-label">{value}</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
</div>
{:else if readonly}
{#if value !== undefined}
<span class="overflow-label">{value}</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
{:else}
<EditBox {placeholder} {maxWidth} bind:value format={'number'} {focus} on:change={_onchange} />
{/if}

View File

@ -25,6 +25,7 @@
export let maxWidth: string = '10rem'
export let onChange: (value: string) => void
export let kind: 'no-border' | 'link' = 'no-border'
export let readonly = false
let shown: boolean = false
@ -37,7 +38,7 @@
<div
class="link-container"
on:click={(ev) => {
if (!shown) {
if (!shown && !readonly) {
showPopup(EditBoxPopup, { value }, eventToHTMLElement(ev), (res) => {
if (res !== undefined) {
value = res
@ -54,6 +55,12 @@
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
</div>
{:else if readonly}
{#if value}
<span class="overflow-label">{value}</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
{:else}
<EditBox {placeholder} {maxWidth} bind:value {focus} on:change={_onchange} />
{/if}

View File

@ -29,7 +29,7 @@
showPopup,
Spinner
} from '@anticrm/ui'
import { BuildModelKey } from '@anticrm/view'
import { AttributeModel, BuildModelKey } from '@anticrm/view'
import { createEventDispatcher } from 'svelte'
import { buildConfigLookup, buildModel, LoadingProps } from '../utils'
import Menu from './Menu.svelte'
@ -170,6 +170,17 @@
}
return props
}
function getValue (attribute: AttributeModel, object: Doc): any {
if (attribute.castRequest) {
return (
getObjectValue(
attribute.key.substring(attribute.castRequest.length + 1),
client.getHierarchy().as(object, attribute.castRequest)
) ?? ''
)
}
return getObjectValue(attribute.key, object) ?? ''
}
</script>
{#await buildModel({ client, _class, keys: config, lookup })}
@ -267,7 +278,7 @@
<div class="antiTable-cells__firstCell">
<svelte:component
this={attribute.presenter}
value={getObjectValue(attribute.key, object) ?? ''}
value={getValue(attribute, object) ?? ''}
{...joinProps(attribute.collectionAttr, object, attribute.props)}
/>
<!-- <div
@ -283,7 +294,7 @@
<td>
<svelte:component
this={attribute.presenter}
value={getObjectValue(attribute.key, object) ?? ''}
value={getValue(attribute, object) ?? ''}
{...joinProps(attribute.collectionAttr, object, attribute.props)}
/>
</td>

View File

@ -15,7 +15,7 @@
<script lang="ts">
import presentation, { Card, createQuery, getAttributePresenterClass, getClient } from '@anticrm/presentation'
import { BuildModelKey, Viewlet, ViewletPreference } from '@anticrm/view'
import core, { ArrOf, Class, Doc, Lookup, Ref, Type } from '@anticrm/core'
import core, { AnyAttribute, ArrOf, Class, Doc, Lookup, Ref, Type } from '@anticrm/core'
import { Button, ToggleButton } from '@anticrm/ui'
import { createEventDispatcher } from 'svelte'
import { IntlString } from '@anticrm/platform'
@ -54,6 +54,7 @@
enabled: boolean
label: IntlString
value: string | BuildModelKey
_class: Ref<Class<Doc>>
}
function getObjectConfig (_class: Ref<Class<Doc>>, param: string): AttributeConfig {
@ -61,7 +62,8 @@
return {
value: param,
label: clazz.label,
enabled: true
enabled: true,
_class
}
}
@ -76,14 +78,16 @@
result.push({
value: param,
enabled: true,
label: getKeyLabel(viewlet.attachTo, param, lookup)
label: getKeyLabel(viewlet.attachTo, param, lookup),
_class: viewlet.attachTo
})
}
} else {
result.push({
value: param,
label: param.label as IntlString,
enabled: true
enabled: true,
_class: viewlet.attachTo
})
}
}
@ -100,33 +104,59 @@
return name
}
function processAttribute (attribute: AnyAttribute, result: AttributeConfig[], useMixinProxy = false): void {
if (attribute.hidden === true || attribute.label === undefined) return
if (viewlet.hiddenKeys?.includes(attribute.name)) return
if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) return
const value = getValue(attribute.name, attribute.type)
if (result.findIndex((p) => p.value === value) !== -1) return
const typeClassId = getAttributePresenterClass(hierarchy, attribute).attrClass
const typeClass = hierarchy.getClass(typeClassId)
let presenter = hierarchy.as(typeClass, view.mixin.AttributePresenter).presenter
let parent = typeClass.extends
while (presenter === undefined && parent !== undefined) {
const pclazz = hierarchy.getClass(parent)
presenter = hierarchy.as(pclazz, view.mixin.AttributePresenter).presenter
parent = pclazz.extends
}
if (presenter === undefined) return
if (useMixinProxy) {
result.push({
value: attribute.attributeOf + '.' + attribute.name,
label: attribute.label,
enabled: false,
_class: attribute.attributeOf
})
} else {
result.push({
value,
label: attribute.label,
enabled: false,
_class: attribute.attributeOf
})
}
}
function getConfig (viewlet: Viewlet, preference: ViewletPreference | undefined): AttributeConfig[] {
const result = getBaseConfig(viewlet)
const allAttributes = hierarchy.getAllAttributes(viewlet.attachTo)
for (const [, attribute] of allAttributes) {
if (attribute.hidden === true || attribute.label === undefined) continue
if (viewlet.hiddenKeys?.includes(attribute.name)) continue
if (hierarchy.isDerived(attribute.type._class, core.class.Collection)) continue
const value = getValue(attribute.name, attribute.type)
if (result.findIndex((p) => p.value === value) !== -1) continue
const typeClassId = getAttributePresenterClass(hierarchy, attribute).attrClass
const typeClass = hierarchy.getClass(typeClassId)
let presenter = hierarchy.as(typeClass, view.mixin.AttributePresenter).presenter
let parent = typeClass.extends
while (presenter === undefined && parent !== undefined) {
const pclazz = hierarchy.getClass(parent)
presenter = hierarchy.as(pclazz, view.mixin.AttributePresenter).presenter
parent = pclazz.extends
}
if (presenter === undefined) continue
result.push({
value,
label: attribute.label,
enabled: false
})
processAttribute(attribute, result)
}
hierarchy
.getDescendants(viewlet.attachTo)
.filter((it) => hierarchy.isMixin(it))
.forEach((it) =>
hierarchy.getAllAttributes(it, viewlet.attachTo).forEach((attr) => {
if (attr.isCustom === true) {
processAttribute(attr, result, true)
}
})
)
return preference === undefined ? result : setStatus(result, preference)
}

View File

@ -24,7 +24,8 @@ export default mergeIds(viewId, view, {
ValueFilter: '' as AnyComponent,
TimestampFilter: '' as AnyComponent,
FilterTypePopup: '' as AnyComponent,
ActionsPopup: '' as AnyComponent
ActionsPopup: '' as AnyComponent,
ProxyPresenter: '' as AnyComponent
},
string: {
MoveClass: '' as IntlString,

View File

@ -230,6 +230,22 @@ export async function buildModel (options: BuildModelOptions): Promise<Attribute
.map((key) => (typeof key === 'string' ? { key: key } : key))
.map(async (key) => {
try {
// Check if it is a mixin attribute configuration
const pos = key.key.lastIndexOf('.')
if (pos !== -1) {
const mixinName = key.key.substring(0, pos) as Ref<Class<Doc>>
if (options.client.getHierarchy().isMixin(mixinName)) {
const realKey = key.key.substring(pos + 1)
const rkey = { ...key, key: realKey }
return {
...(await getPresenter(options.client, mixinName, rkey, rkey, options.lookup)),
castRequest: mixinName,
key: key.key,
sortingKey: key.key
}
}
}
return await getPresenter(options.client, options._class, key, key, options.lookup)
} catch (err: any) {
if (options.ignoreMissing ?? false) {

View File

@ -357,6 +357,8 @@ export interface AttributeModel {
attribute?: AnyAttribute
collectionAttr: boolean
castRequest?: Ref<Mixin<Doc>>
}
/**

View File

@ -16,7 +16,6 @@
import type { Doc, Ref, Tx, TxCollectionCUD, TxCreateDoc, TxRemoveDoc } from '@anticrm/core'
import type { TriggerControl } from '@anticrm/server-core'
import { extractTx } from '@anticrm/server-core'
import type { Attachment } from '@anticrm/attachment'
import attachment from '@anticrm/attachment'
import core, { TxProcessor } from '@anticrm/core'
@ -51,7 +50,7 @@ export async function OnAttachmentDelete (
tx: Tx,
{ findAll, hierarchy, fulltextFx, storageFx }: TriggerControl
): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (actualTx._class !== core.class.TxRemoveDoc) {
return []
}

View File

@ -27,7 +27,7 @@ import core, {
TxProcessor
} from '@anticrm/core'
import gmail, { Message } from '@anticrm/gmail'
import { extractTx, TriggerControl } from '@anticrm/server-core'
import { TriggerControl } from '@anticrm/server-core'
/**
* @public
@ -54,7 +54,7 @@ export async function FindMessages (
* @public
*/
export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (actualTx._class !== core.class.TxCreateDoc) {
return []
}

View File

@ -14,9 +14,9 @@
//
import contact, { Employee } from '@anticrm/contact'
import core, { Ref, SortingOrder, Tx, TxFactory, TxMixin, TxUpdateDoc } from '@anticrm/core'
import core, { Ref, SortingOrder, Tx, TxFactory, TxMixin, TxProcessor, TxUpdateDoc } from '@anticrm/core'
import hr, { Department, DepartmentMember, Staff } from '@anticrm/hr'
import { extractTx, TriggerControl } from '@anticrm/server-core'
import { TriggerControl } from '@anticrm/server-core'
async function getOldDepartment (
currentTx: TxMixin<Employee, Staff> | TxUpdateDoc<Employee>,
@ -89,7 +89,7 @@ function getTxes (
* @public
*/
export async function OnDepartmentStaff (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (core.class.TxMixin !== actualTx._class) {
return []
}
@ -134,7 +134,7 @@ export async function OnDepartmentStaff (tx: Tx, control: TriggerControl): Promi
* @public
*/
export async function OnEmployeeDeactivate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (core.class.TxUpdateDoc !== actualTx._class) {
return []
}

View File

@ -42,7 +42,6 @@ import notification, {
} from '@anticrm/notification'
import { getResource } from '@anticrm/platform'
import type { TriggerControl } from '@anticrm/server-core'
import { extractTx } from '@anticrm/server-core'
import { createLastViewTx, getUpdateLastViewTx } from '@anticrm/server-notification'
import view, { HTMLPresenter, TextPresenter } from '@anticrm/view'
@ -139,7 +138,7 @@ async function getUpdateLastViewTxes (
* @public
*/
export async function UpdateLastView (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (![core.class.TxUpdateDoc, core.class.TxCreateDoc, core.class.TxMixin].includes(actualTx._class)) {
return []
}

View File

@ -27,7 +27,7 @@ import core, {
TxProcessor,
TxRemoveDoc
} from '@anticrm/core'
import { extractTx, TriggerControl } from '@anticrm/server-core'
import { TriggerControl } from '@anticrm/server-core'
import tags, { TagElement, TagReference } from '@anticrm/tags'
/**
@ -50,7 +50,7 @@ export async function TagElementRemove (
* @public
*/
export async function onTagReference (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
const isCreate = control.hierarchy.isDerived(actualTx._class, core.class.TxCreateDoc)
const isRemove = control.hierarchy.isDerived(actualTx._class, core.class.TxRemoveDoc)
if (!isCreate && !isRemove) return []
@ -68,7 +68,7 @@ export async function onTagReference (tx: Tx, control: TriggerControl): Promise<
await control.findAll(core.class.TxCollectionCUD, { 'tx.objectId': ctx.objectId }, { limit: 1 })
)[0]
if (createTx !== undefined) {
const actualCreateTx = extractTx(createTx)
const actualCreateTx = TxProcessor.extractTx(createTx)
const doc = TxProcessor.createDoc2Doc(actualCreateTx as TxCreateDoc<TagReference>)
const res = control.txFactory.createTxUpdateDoc(tags.class.TagElement, tags.space.Tags, doc.tag, {
$inc: { refCount: -1 }

View File

@ -13,10 +13,10 @@
// limitations under the License.
//
import core, { Doc, Tx, TxUpdateDoc } from '@anticrm/core'
import core, { Doc, Tx, TxProcessor, TxUpdateDoc } from '@anticrm/core'
import login from '@anticrm/login'
import { getMetadata } from '@anticrm/platform'
import { extractTx, TriggerControl } from '@anticrm/server-core'
import { TriggerControl } from '@anticrm/server-core'
import { getUpdateLastViewTx } from '@anticrm/server-notification'
import task, { Issue, Task, taskId } from '@anticrm/task'
import view from '@anticrm/view'
@ -43,7 +43,7 @@ export function issueTextPresenter (doc: Doc): string {
* @public
*/
export async function OnTaskUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (actualTx._class !== core.class.TxUpdateDoc) {
return []
}

View File

@ -26,7 +26,7 @@ import core, {
TxCreateDoc,
TxProcessor
} from '@anticrm/core'
import { extractTx, TriggerControl } from '@anticrm/server-core'
import { TriggerControl } from '@anticrm/server-core'
import telegram, { TelegramMessage } from '@anticrm/telegram'
/**
@ -54,7 +54,7 @@ export async function FindMessages (
* @public
*/
export async function OnMessageCreate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (actualTx._class !== core.class.TxCreateDoc) {
return []
}

View File

@ -13,9 +13,18 @@
// limitations under the License.
//
import core, { DocumentUpdate, Ref, Tx, TxUpdateDoc } from '@anticrm/core'
import { extractTx, TriggerControl } from '@anticrm/server-core'
import tracker, { Issue } from '@anticrm/tracker'
import core, {
DocumentUpdate,
Ref,
Tx,
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxProcessor,
TxUpdateDoc
} from '@anticrm/core'
import { TriggerControl } from '@anticrm/server-core'
import tracker, { Issue, TimeSpendReport } from '@anticrm/tracker'
async function updateSubIssues (
updateTx: TxUpdateDoc<Issue>,
@ -34,16 +43,93 @@ async function updateSubIssues (
* @public
*/
export async function OnIssueUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
// Check TimeReport operations
if (
actualTx._class === core.class.TxCreateDoc ||
actualTx._class === core.class.TxUpdateDoc ||
actualTx._class === core.class.TxRemoveDoc
) {
const cud = actualTx as TxCUD<TimeSpendReport>
if (cud.objectClass === tracker.class.TimeSpendReport) {
return await doTimeReportUpdate(cud, tx, control)
}
}
if (actualTx._class !== core.class.TxUpdateDoc) {
return []
}
const updateTx = actualTx as TxUpdateDoc<Issue>
if (!control.hierarchy.isDerived(updateTx.objectClass, tracker.class.Issue)) {
return []
if (control.hierarchy.isDerived(updateTx.objectClass, tracker.class.Issue)) {
return await doIssueUpdate(updateTx, control)
}
return []
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
trigger: {
OnIssueUpdate
}
})
async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control: TriggerControl): Promise<Tx[]> {
const parentTx = tx as TxCollectionCUD<Issue, TimeSpendReport>
switch (cud._class) {
case core.class.TxCreateDoc: {
const ccud = cud as TxCreateDoc<TimeSpendReport>
return [
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: ccud.attributes.value }
})
]
}
case core.class.TxUpdateDoc: {
const upd = cud as TxUpdateDoc<TimeSpendReport>
if (upd.operations.value !== undefined) {
const logTxes = Array.from(
await control.findAll(core.class.TxCollectionCUD, {
'tx.objectId': cud.objectId,
_id: { $nin: [parentTx._id] }
})
).map(TxProcessor.extractTx)
const doc: TimeSpendReport | undefined = TxProcessor.buildDoc2Doc(logTxes)
if (doc !== undefined) {
return [
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: -1 * doc.value }
}),
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: upd.operations.value }
})
]
}
}
break
}
case core.class.TxRemoveDoc: {
const logTxes = Array.from(
await control.findAll(core.class.TxCollectionCUD, {
'tx.objectId': cud.objectId,
_id: { $nin: [parentTx._id] }
})
).map(TxProcessor.extractTx)
const doc: TimeSpendReport | undefined = TxProcessor.buildDoc2Doc(logTxes)
if (doc !== undefined) {
return [
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: -1 * doc.value }
})
]
}
}
}
return []
}
async function doIssueUpdate (updateTx: TxUpdateDoc<Issue>, control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'attachedTo')) {
@ -95,10 +181,3 @@ export async function OnIssueUpdate (tx: Tx, control: TriggerControl): Promise<T
return res
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
trigger: {
OnIssueUpdate
}
})

View File

@ -19,5 +19,4 @@ export * from './types'
export * from './fulltext'
export * from './storage'
export * from './pipeline'
export * from './utils'
export { default } from './plugin'

View File

@ -54,7 +54,6 @@ import { FullTextIndex } from './fulltext'
import serverCore from './plugin'
import { Triggers } from './triggers'
import type { FullTextAdapter, FullTextAdapterFactory, ObjectDDParticipant } from './types'
import { extractTx } from './utils'
/**
* @public
@ -298,7 +297,7 @@ class TServerStorage implements ServerStorage {
}
async processRemove (ctx: MeasureContext, tx: Tx): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (!this.hierarchy.isDerived(actualTx._class, core.class.TxRemoveDoc)) return []
const rtx = actualTx as TxRemoveDoc<Doc>
const result: Tx[] = []
@ -375,7 +374,7 @@ class TServerStorage implements ServerStorage {
}
async processMove (ctx: MeasureContext, tx: Tx): Promise<Tx[]> {
const actualTx = extractTx(tx)
const actualTx = TxProcessor.extractTx(tx)
if (!this.hierarchy.isDerived(actualTx._class, core.class.TxUpdateDoc)) return []
const rtx = actualTx as TxUpdateDoc<Doc>
if (rtx.operations.space === undefined || rtx.operations.space === rtx.objectSpace) return []

View File

@ -1,20 +0,0 @@
import core, { AttachedDoc, Doc, Tx, TxCollectionCUD, TxCreateDoc } from '@anticrm/core'
/**
* @public
*/
export function extractTx (tx: Tx): Tx {
if (tx._class === core.class.TxCollectionCUD) {
const ctx = tx as TxCollectionCUD<Doc, AttachedDoc>
if (ctx.tx._class === core.class.TxCreateDoc) {
const create = ctx.tx as TxCreateDoc<AttachedDoc>
create.attributes.attachedTo = ctx.objectId
create.attributes.attachedToClass = ctx.objectClass
create.attributes.collection = ctx.collection
return create
}
return ctx.tx
}
return tx
}

View File

@ -78,12 +78,12 @@ describe('server', () => {
clean: async (domain: Domain, docs: Ref<Doc>[]) => {}
}),
(token, pipeline, broadcast) => new ClientSession(broadcast, token, pipeline),
3333
3335
)
function connect (): WebSocket {
const token: string = generateToken('', 'latest')
return new WebSocket(`ws://localhost:3333/${token}`)
return new WebSocket(`ws://localhost:3335/${token}`)
}
it('should connect to server', (done) => {
@ -97,7 +97,7 @@ describe('server', () => {
})
it('should not connect to server without token', (done) => {
const conn = new WebSocket('ws://localhost:3333/xyz')
const conn = new WebSocket('ws://localhost:3335/xyz')
conn.on('error', () => {
conn.close()
})

View File

@ -32,8 +32,8 @@ test.describe('project tests', () => {
await page.click(`.selectPopup button:has-text("${prjId}")`)
await page.click('form button:has-text("Save issue")')
await page.waitForSelector('form.antiCard', { state: 'detached' })
await page.click(`.gridElement button:has-text("${prjId}")`)
await page.click('.selectPopup button:has-text("No project")')
await page.click('.listGrid :has-text("issue")')
})
test('create-project-with-status', async ({ page }) => {