UBER-807: Allow to customize create issue dialog (#3669)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-09-08 11:33:23 +07:00 committed by GitHub
parent f3c3541964
commit 2d5a38eed5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 158 additions and 27 deletions

View File

@ -34,6 +34,7 @@ import {
Collection, Collection,
Hidden, Hidden,
Index, Index,
Mixin,
Model, Model,
Prop, Prop,
ReadOnly, ReadOnly,
@ -46,12 +47,12 @@ import {
} from '@hcengineering/model' } from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment' import attachment from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter' import chunter from '@hcengineering/model-chunter'
import core, { TAttachedDoc, TDoc, TStatus, TType } from '@hcengineering/model-core' import core, { TAttachedDoc, TClass, TDoc, TStatus, TType } from '@hcengineering/model-core'
import task, { TSpaceWithStates, TTask } from '@hcengineering/model-task' import task, { TSpaceWithStates, TTask } from '@hcengineering/model-task'
import view, { actionTemplates, classPresenter, createAction, showColorsViewOption } from '@hcengineering/model-view' import view, { actionTemplates, classPresenter, createAction, showColorsViewOption } from '@hcengineering/model-view'
import workbench, { createNavigateAction } from '@hcengineering/model-workbench' import workbench, { createNavigateAction } from '@hcengineering/model-workbench'
import notification from '@hcengineering/notification' import notification from '@hcengineering/notification'
import { IntlString } from '@hcengineering/platform' import { IntlString, Resource } from '@hcengineering/platform'
import setting from '@hcengineering/setting' import setting from '@hcengineering/setting'
import tags, { TagElement } from '@hcengineering/tags' import tags, { TagElement } from '@hcengineering/tags'
import { DoneState } from '@hcengineering/task' import { DoneState } from '@hcengineering/task'
@ -64,9 +65,11 @@ import {
IssueStatus, IssueStatus,
IssueTemplate, IssueTemplate,
IssueTemplateChild, IssueTemplateChild,
IssueUpdateFunction,
Milestone, Milestone,
MilestoneStatus, MilestoneStatus,
Project, Project,
ProjectIssueTargetOptions,
TimeReportDayType, TimeReportDayType,
TimeSpendReport, TimeSpendReport,
trackerId trackerId
@ -77,6 +80,7 @@ import tracker from './plugin'
import { generateClassNotificationTypes } from '@hcengineering/model-notification' import { generateClassNotificationTypes } from '@hcengineering/model-notification'
import presentation from '@hcengineering/model-presentation' import presentation from '@hcengineering/model-presentation'
import { defaultPriorities, issuePriorities } from '@hcengineering/tracker-resources/src/types' import { defaultPriorities, issuePriorities } from '@hcengineering/tracker-resources/src/types'
import { AnyComponent } from '@hcengineering/ui'
import { PaletteColorIndexes } from '@hcengineering/ui/src/colors' import { PaletteColorIndexes } from '@hcengineering/ui/src/colors'
export { trackerId } from '@hcengineering/tracker' export { trackerId } from '@hcengineering/tracker'
@ -336,6 +340,14 @@ export class TComponent extends TDoc implements Component {
declare space: Ref<Project> declare space: Ref<Project>
} }
@Mixin(tracker.mixin.ProjectIssueTargetOptions, core.class.Class)
export class TProjectIssueTargetOptions extends TClass implements ProjectIssueTargetOptions {
headerComponent!: AnyComponent
bodyComponent!: AnyComponent
footerComponent!: AnyComponent
update!: Resource<IssueUpdateFunction>
}
/** /**
* @public * @public
*/ */
@ -410,7 +422,8 @@ export function createModel (builder: Builder): void {
TMilestone, TMilestone,
TTypeMilestoneStatus, TTypeMilestoneStatus,
TTimeSpendReport, TTimeSpendReport,
TTypeReportedTime TTypeReportedTime,
TProjectIssueTargetOptions
) )
builder.createDoc( builder.createDoc(

View File

@ -92,6 +92,24 @@ export class Hierarchy {
} }
} }
findClassOrMixinMixin<D extends Doc, M extends D>(doc: Doc, mixin: Ref<Mixin<M>>): M | undefined {
const cc = this.classHierarchyMixin(doc._class, mixin)
if (cc !== undefined) {
return cc
}
const _doc = _toDoc(doc)
// Find all potential mixins of doc
for (const [k, v] of Object.entries(_doc)) {
if (typeof v === 'object' && this.classifiers.has(k as Ref<Classifier>)) {
const cc = this.classHierarchyMixin(k as Ref<Mixin<Doc>>, mixin)
if (cc !== undefined) {
return cc
}
}
}
}
isMixin (_class: Ref<Class<Doc>>): boolean { isMixin (_class: Ref<Class<Doc>>): boolean {
const data = this.classifiers.get(_class) const data = this.classifiers.get(_class)
return data !== undefined && this._isMixin(data) return data !== undefined && this._isMixin(data)

View File

@ -289,7 +289,7 @@ export class TxOperations implements Omit<Client, 'notify'> {
continue continue
} }
const dv = (doc as any)[k] const dv = (doc as any)[k]
if (!deepEqual(dv, v) && v != null) { if (!deepEqual(dv, v) && v !== undefined) {
;(documentUpdate as any)[k] = v ;(documentUpdate as any)[k] = v
} }
} }

View File

@ -111,7 +111,7 @@
await client.update(targetPerson, update) await client.update(targetPerson, update)
} }
for (const channel of resultChannels.values()) { for (const channel of resultChannels.values()) {
if (channel.attachedTo !== targetPerson._id) continue if (channel.attachedTo === targetPerson._id) continue
await client.update(channel, { attachedTo: targetPerson._id }) await client.update(channel, { attachedTo: targetPerson._id })
} }
for (const old of oldChannels) { for (const old of oldChannels) {

View File

@ -16,7 +16,7 @@
import { AttachmentStyledBox } from '@hcengineering/attachment-resources' import { AttachmentStyledBox } from '@hcengineering/attachment-resources'
import chunter from '@hcengineering/chunter' import chunter from '@hcengineering/chunter'
import { Employee } from '@hcengineering/contact' import { Employee } from '@hcengineering/contact'
import core, { Account, AttachedData, Doc, fillDefaults, generateId, Ref, SortingOrder } from '@hcengineering/core' import core, { Account, DocData, Class, Doc, fillDefaults, generateId, Ref, SortingOrder } from '@hcengineering/core'
import { getResource, translate } from '@hcengineering/platform' import { getResource, translate } from '@hcengineering/platform'
import { import {
Card, Card,
@ -38,7 +38,8 @@
IssueStatus, IssueStatus,
IssueTemplate, IssueTemplate,
Milestone, Milestone,
Project Project,
ProjectIssueTargetOptions
} from '@hcengineering/tracker' } from '@hcengineering/tracker'
import { import {
addNotification, addNotification,
@ -299,6 +300,18 @@
currentProject = res.shift() currentProject = res.shift()
}) })
$: targetSettings =
currentProject !== undefined
? client
.getHierarchy()
.findClassOrMixinMixin<Class<Doc>, ProjectIssueTargetOptions>(
currentProject,
tracker.mixin.ProjectIssueTargetOptions
)
: undefined
let targetSettingOptions: Record<string, any> = {}
async function updateIssueStatusId (object: IssueDraft, currentProject: Project | undefined) { async function updateIssueStatusId (object: IssueDraft, currentProject: Project | undefined) {
if (currentProject?.defaultIssueStatus && object.status === undefined) { if (currentProject?.defaultIssueStatus && object.status === undefined) {
object.status = currentProject.defaultIssueStatus object.status = currentProject.defaultIssueStatus
@ -340,7 +353,7 @@
} }
} }
async function createIssue () { async function createIssue (): Promise<void> {
const _id: Ref<Issue> = generateId() const _id: Ref<Issue> = generateId()
if (!canSave || object.status === undefined) { if (!canSave || object.status === undefined) {
return return
@ -357,7 +370,7 @@
true true
) )
const value: AttachedData<Issue> = { const value: DocData<Issue> = {
doneState: null, doneState: null,
title: getTitle(object.title), title: getTitle(object.title),
description: object.description, description: object.description,
@ -381,6 +394,11 @@
childInfo: [] childInfo: []
} }
if (targetSettings !== undefined) {
const updateOp = await getResource(targetSettings.update)
updateOp?.(_id, _space, value, targetSettingOptions)
}
await client.addCollection( await client.addCollection(
tracker.class.Issue, tracker.class.Issue,
_space, _space,
@ -569,6 +587,15 @@
docProps={{ disabled: true, noUnderline: true }} docProps={{ disabled: true, noUnderline: true }}
focusIndex={20000} focusIndex={20000}
/> />
{#if targetSettings?.headerComponent && currentProject}
<Component
is={targetSettings.headerComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="title" let:label> <svelte:fragment slot="title" let:label>
<div class="flex-row-center gap-1"> <div class="flex-row-center gap-1">
@ -649,6 +676,15 @@
component={object.component} component={object.component}
bind:subIssues={object.subIssues} bind:subIssues={object.subIssues}
/> />
{#if targetSettings?.bodyComponent && currentProject}
<Component
is={targetSettings.bodyComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
<svelte:fragment slot="pool"> <svelte:fragment slot="pool">
<div id="status-editor"> <div id="status-editor">
<StatusEditor <StatusEditor
@ -759,6 +795,15 @@
on:click={object.parentIssue ? clearParentIssue : setParentIssue} on:click={object.parentIssue ? clearParentIssue : setParentIssue}
/> />
</div> </div>
{#if targetSettings?.poolComponent && currentProject}
<Component
is={targetSettings.poolComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="attachments"> <svelte:fragment slot="attachments">
{#if attachments.size > 0} {#if attachments.size > 0}
@ -785,5 +830,14 @@
descriptionBox.attach() descriptionBox.attach()
}} }}
/> />
{#if targetSettings?.footerComponent && currentProject}
<Component
is={targetSettings.footerComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
</svelte:fragment> </svelte:fragment>
</Card> </Card>

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { PersonAccount } from '@hcengineering/contact' import { PersonAccount } from '@hcengineering/contact'
import { EmployeeBox, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources' import { EmployeeBox, personAccountByIdStore, personByIdStore } from '@hcengineering/contact-resources'
import core, { ClassifierKind, Doc, Mixin, Ref } from '@hcengineering/core' import core, { Class, ClassifierKind, Doc, Mixin, Ref } from '@hcengineering/core'
import { AttributeBarEditor, KeyedAttribute, createQuery, getClient } from '@hcengineering/presentation' import { AttributeBarEditor, KeyedAttribute, createQuery, getClient } from '@hcengineering/presentation'
import tags from '@hcengineering/tags' import tags from '@hcengineering/tags'
@ -68,7 +68,13 @@
} }
function getMixinKeys (mixin: Ref<Mixin<Doc>>): KeyedAttribute[] { function getMixinKeys (mixin: Ref<Mixin<Doc>>): KeyedAttribute[] {
const filtredKeys = getFiltredKeys(hierarchy, mixin, [], issue._class) const mixinClass = hierarchy.getClass(mixin)
const filtredKeys = getFiltredKeys(
hierarchy,
mixin,
[],
hierarchy.isMixin(mixinClass.extends as Ref<Class<Doc>>) ? mixinClass.extends : issue._class
)
const res = filtredKeys.filter((key) => !isCollectionAttr(hierarchy, key)) const res = filtredKeys.filter((key) => !isCollectionAttr(hierarchy, key))
return res return res
} }

View File

@ -19,11 +19,14 @@ import {
Attribute, Attribute,
Class, Class,
Doc, Doc,
DocData,
DocManager, DocManager,
IdMap, IdMap,
Markup, Markup,
Mixin,
Ref, Ref,
RelatedDocument, RelatedDocument,
Space,
Status, Status,
StatusCategory, StatusCategory,
Timestamp, Timestamp,
@ -52,6 +55,33 @@ export interface Project extends SpaceWithStates, IconProps {
defaultTimeReportDay: TimeReportDayType defaultTimeReportDay: TimeReportDayType
} }
/**
* @public
*/
export type IssueUpdateFunction = (
id: Ref<Issue>,
space: Ref<Space>,
issue: DocData<Issue>,
data: Record<string, any>
) => Promise<void>
/**
* @public
*
* Customization mixin for project class.
*
* Allow to customize create issue/move issue dialogs, in case of selecting project of special kind.
*/
export interface ProjectIssueTargetOptions extends Class<Doc> {
// Component receiving project and context data.
headerComponent?: AnyComponent
bodyComponent?: AnyComponent
footerComponent?: AnyComponent
poolComponent?: AnyComponent
update: Resource<IssueUpdateFunction>
}
/** /**
* @public * @public
*/ */
@ -452,5 +482,8 @@ export default plugin(trackerId, {
string: { string: {
ConfigLabel: '' as IntlString, ConfigLabel: '' as IntlString,
NewRelatedIssue: '' as IntlString NewRelatedIssue: '' as IntlString
},
mixin: {
ProjectIssueTargetOptions: '' as Ref<Mixin<ProjectIssueTargetOptions>>
} }
}) })

View File

@ -26,6 +26,7 @@
export let size: ButtonSize = 'small' export let size: ButtonSize = 'small'
export let justify: 'left' | 'center' = 'center' export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content' export let width: string | undefined = 'fit-content'
export let title: string | undefined
let shown: boolean = false let shown: boolean = false
</script> </script>
@ -53,10 +54,14 @@
}} }}
> >
<svelte:fragment slot="content"> <svelte:fragment slot="content">
{#if value} {#if title}
<span class="caption-color overflow-label pointer-events-none">{title}</span>
{:else if value}
<span class="caption-color overflow-label pointer-events-none">{value}</span> <span class="caption-color overflow-label pointer-events-none">{value}</span>
{:else} {:else}
<span class="content-dark-color pointer-events-none"><Label label={placeholder} /></span> <span class="content-dark-color pointer-events-none">
<Label label={placeholder} />
</span>
{/if} {/if}
</svelte:fragment> </svelte:fragment>
</Button> </Button>

View File

@ -59,20 +59,20 @@
<span class="content-dark-color"><Label label={placeholder} /></span> <span class="content-dark-color"><Label label={placeholder} /></span>
{/if} {/if}
</div> </div>
<Button
focusIndex={2}
kind={'ghost'}
size={'small'}
icon={IconClose}
disabled={value === ''}
on:click={() => {
if (input) {
value = ''
input.focus()
}
}}
/>
{#if editable} {#if editable}
<Button
focusIndex={2}
kind={'ghost'}
size={'small'}
icon={IconClose}
disabled={value === ''}
on:click={() => {
if (input) {
value = ''
input.focus()
}
}}
/>
<Button <Button
id="channel-ok" id="channel-ok"
focusIndex={3} focusIndex={3}
@ -89,6 +89,7 @@
kind={'ghost'} kind={'ghost'}
size={'small'} size={'small'}
icon={IconArrowRight} icon={IconArrowRight}
showTooltip={{ label: view.string.Open }}
on:click={() => { on:click={() => {
dispatch('update', value) dispatch('update', value)
dispatch('close', 'open') dispatch('close', 'open')

View File

@ -183,7 +183,8 @@ export {
StringPresenter, StringPresenter,
EditBoxPopup, EditBoxPopup,
SpaceHeader, SpaceHeader,
ViewletContentView ViewletContentView,
HyperlinkEditor
} }
function PositionElementAlignment (e?: Event): PopupAlignment | undefined { function PositionElementAlignment (e?: Event): PopupAlignment | undefined {