UBER-937: Extensibility changes (#3874)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-10-24 15:53:33 +07:00 committed by GitHub
parent 1fd913a2b5
commit e09bd25a87
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 1206 additions and 606 deletions

View File

@ -113,7 +113,7 @@ It may also be necessary to upgrade the running database.
```bash
cd ./dev/tool
rushx upgrade
rushx upgrade -f
```
In cases where the project fails to build for any logical reason, try the following steps:

View File

@ -124,7 +124,8 @@ services:
- SERVER_SECRET=secret
- ELASTIC_URL=http://elastic:9200
- MONGO_URL=mongodb://mongodb:27017
- METRICS_CONSOLE=true
- METRICS_CONSOLE=false
- METRICS_FILE=metrics.txt
- MINIO_ENDPOINT=minio
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin

View File

@ -50,7 +50,7 @@ import { diffWorkspace } from './workspace'
import { Data, getWorkspaceId, RateLimitter, Tx, Version } from '@hcengineering/core'
import { MinioService } from '@hcengineering/minio'
import { MigrateOperation } from '@hcengineering/model'
import { consoleModelLogger, MigrateOperation } from '@hcengineering/model'
import { openAIConfigDefaults } from '@hcengineering/openai'
import path from 'path'
import { benchmark } from './benchmark'
@ -237,8 +237,13 @@ export function devTool (
.option('-p|--parallel <parallel>', 'Parallel upgrade', '0')
.option('-l|--logs <logs>', 'Default logs folder', './logs')
.option('-r|--retry <retry>', 'Number of apply retries', '0')
.option(
'-c|--console',
'Display all information into console(default will create logs folder with {workspace}.log files',
false
)
.option('-f|--force [force]', 'Force update', false)
.action(async (cmd: { parallel: string, logs: string, retry: string, force: boolean }) => {
.action(async (cmd: { parallel: string, logs: string, retry: string, force: boolean, console: boolean }) => {
const { mongodbUri, version, txes, migrateOperations } = prepareTools()
return await withDatabase(mongodbUri, async (db) => {
const workspaces = await listWorkspaces(db, productId)
@ -246,8 +251,10 @@ export function devTool (
async function _upgradeWorkspace (ws: WorkspaceInfoOnly): Promise<void> {
const t = Date.now()
const logger = new FileModelLogger(path.join(cmd.logs, `${ws.workspace}.log`))
console.log('---UPGRADING----', ws.workspace, logger.file)
const logger = cmd.console
? consoleModelLogger
: new FileModelLogger(path.join(cmd.logs, `${ws.workspace}.log`))
console.log('---UPGRADING----', ws.workspace, !cmd.console ? (logger as FileModelLogger).file : '')
try {
await upgradeWorkspace(version, txes, migrateOperations, productId, db, ws.workspace, logger, cmd.force)
console.log('---UPGRADING-DONE----', ws.workspace, Date.now() - t)
@ -256,7 +263,9 @@ export function devTool (
logger.log('error', JSON.stringify(err))
console.log('---UPGRADING-FAILED----', ws.workspace, Date.now() - t)
} finally {
logger.close()
if (!cmd.console) {
;(logger as FileModelLogger).close()
}
}
}
if (cmd.parallel !== '0') {

View File

@ -1,11 +1,17 @@
//
import { Class, DOMAIN_TX, Doc, Domain, Ref, TxOperations } from '@hcengineering/core'
import { MigrateOperation, MigrationClient, MigrationUpgradeClient } from '@hcengineering/model'
import {
MigrateOperation,
MigrationClient,
MigrationUpgradeClient,
ModelLogger,
tryMigrate
} from '@hcengineering/model'
import { DOMAIN_COMMENT } from '@hcengineering/model-chunter'
import core from '@hcengineering/model-core'
import { DOMAIN_VIEW } from '@hcengineering/model-view'
import contact, { DOMAIN_CONTACT } from './index'
import contact, { DOMAIN_CONTACT, contactId } from './index'
async function createSpace (tx: TxOperations): Promise<void> {
const current = await tx.findOne(core.class.Space, {
@ -76,118 +82,125 @@ async function createEmployeeEmail (client: TxOperations): Promise<void> {
}
export const contactOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await client.update(
DOMAIN_TX,
async migrate (client: MigrationClient, logger: ModelLogger): Promise<void> {
await tryMigrate(client, contactId, [
{
objectClass: 'contact:class:EmployeeAccount'
},
{
$rename: { 'attributes.employee': 'attributes.person' },
$set: { objectClass: contact.class.PersonAccount }
}
)
state: 'employees',
func: async (client) => {
await client.update(
DOMAIN_TX,
{
objectClass: 'contact:class:EmployeeAccount'
},
{
$rename: { 'attributes.employee': 'attributes.person' },
$set: { objectClass: contact.class.PersonAccount }
}
)
await client.update(
DOMAIN_TX,
{
objectClass: 'contact:class:Employee'
},
{
$set: { objectClass: contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
objectClass: 'contact:class:Employee'
},
{
$set: { objectClass: contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
'tx.attributes.backlinkClass': 'contact:class:Employee'
},
{
$set: { 'tx.attributes.backlinkClass': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
'tx.attributes.backlinkClass': 'contact:class:Employee'
},
{
$set: { 'tx.attributes.backlinkClass': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
'tx.attributes.backlinkClass': 'contact:class:Employee'
},
{
$set: { 'tx.attributes.backlinkClass': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
'tx.attributes.backlinkClass': 'contact:class:Employee'
},
{
$set: { 'tx.attributes.backlinkClass': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
objectClass: core.class.Attribute,
'attributes.type.to': 'contact:class:Employee'
},
{
$set: { 'attributes.type.to': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
objectClass: core.class.Attribute,
'operations.type.to': 'contact:class:Employee'
},
{
$set: { 'operations.type.to': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
objectClass: core.class.Attribute,
'attributes.type.to': 'contact:class:Employee'
},
{
$set: { 'attributes.type.to': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
objectClass: core.class.Attribute,
'operations.type.to': 'contact:class:Employee'
},
{
$set: { 'operations.type.to': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
'attributes.extends': 'contact:class:Employee'
},
{
$set: { 'attributes.extends': contact.mixin.Employee }
}
)
await client.update(
DOMAIN_TX,
{
'attributes.extends': 'contact:class:Employee'
},
{
$set: { 'attributes.extends': contact.mixin.Employee }
}
)
for (const d of client.hierarchy.domains()) {
await client.update(
d,
{ attachedToClass: 'contact:class:Employee' },
{ $set: { attachedToClass: contact.mixin.Employee } }
)
}
await client.update(
DOMAIN_COMMENT,
{ backlinkClass: 'contact:class:Employee' },
{ $set: { backlinkClass: contact.mixin.Employee } }
)
await client.update(
'tags' as Domain,
{ targetClass: 'contact:class:Employee' },
{ $set: { targetClass: contact.mixin.Employee } }
)
await client.update(
DOMAIN_VIEW,
{ filterClass: 'contact:class:Employee' },
{ $set: { filterClass: contact.mixin.Employee } }
)
await client.update(
DOMAIN_CONTACT,
{
_class: 'contact:class:Employee' as Ref<Class<Doc>>
},
{
$rename: {
active: `${contact.mixin.Employee as string}.active`,
statuses: `${contact.mixin.Employee as string}.statuses`,
displayName: `${contact.mixin.Employee as string}.displayName`,
position: `${contact.mixin.Employee as string}.position`
},
$set: {
_class: contact.class.Person
for (const d of client.hierarchy.domains()) {
await client.update(
d,
{ attachedToClass: 'contact:class:Employee' },
{ $set: { attachedToClass: contact.mixin.Employee } }
)
}
await client.update(
DOMAIN_COMMENT,
{ backlinkClass: 'contact:class:Employee' },
{ $set: { backlinkClass: contact.mixin.Employee } }
)
await client.update(
'tags' as Domain,
{ targetClass: 'contact:class:Employee' },
{ $set: { targetClass: contact.mixin.Employee } }
)
await client.update(
DOMAIN_VIEW,
{ filterClass: 'contact:class:Employee' },
{ $set: { filterClass: contact.mixin.Employee } }
)
await client.update(
DOMAIN_CONTACT,
{
_class: 'contact:class:Employee' as Ref<Class<Doc>>
},
{
$rename: {
active: `${contact.mixin.Employee as string}.active`,
statuses: `${contact.mixin.Employee as string}.statuses`,
displayName: `${contact.mixin.Employee as string}.displayName`,
position: `${contact.mixin.Employee as string}.position`
},
$set: {
_class: contact.class.Person
}
}
)
}
}
)
])
},
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)

View File

@ -13,23 +13,28 @@
// limitations under the License.
//
import { DOMAIN_MODEL } from '@hcengineering/core'
import { Builder, Model } from '@hcengineering/model'
import { Class, DOMAIN_MODEL, Doc, Ref } from '@hcengineering/core'
import { Builder, Model, Prop, TypeRef } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import { Asset, IntlString, Resource } from '@hcengineering/platform'
// Import types to prevent .svelte components to being exposed to type typescript.
import { PresentationMiddlewareCreator, PresentationMiddlewareFactory } from '@hcengineering/presentation'
import {
ComponentPointExtension,
CreateExtensionKind,
DocAttributeRule,
DocRules,
DocCreateExtension,
DocCreateFunction,
ObjectSearchCategory,
ObjectSearchFactory
} from '@hcengineering/presentation/src/types'
import presentation from './plugin'
import { PresentationMiddlewareCreator, PresentationMiddlewareFactory } from '@hcengineering/presentation'
import { AnyComponent, ComponentExtensionId } from '@hcengineering/ui'
import presentation from './plugin'
export { presentationId } from '@hcengineering/presentation/src/plugin'
export { default } from './plugin'
export { ObjectSearchCategory, ObjectSearchFactory }
export { CreateExtensionKind, DocCreateExtension, DocCreateFunction, ObjectSearchCategory, ObjectSearchFactory }
@Model(presentation.class.ObjectSearchCategory, core.class.Doc, DOMAIN_MODEL)
export class TObjectSearchCategory extends TDoc implements ObjectSearchCategory {
@ -53,6 +58,29 @@ export class TComponentPointExtension extends TDoc implements ComponentPointExte
order!: number
}
export function createModel (builder: Builder): void {
builder.createModel(TObjectSearchCategory, TPresentationMiddlewareFactory, TComponentPointExtension)
@Model(presentation.class.DocCreateExtension, core.class.Doc, DOMAIN_MODEL)
export class TDocCreateExtension extends TDoc implements DocCreateExtension {
@Prop(TypeRef(core.class.Class), core.string.Class)
ofClass!: Ref<Class<Doc>>
components!: Record<CreateExtensionKind, AnyComponent>
apply!: Resource<DocCreateFunction>
}
@Model(presentation.class.DocRules, core.class.Doc, DOMAIN_MODEL)
export class TDocRules extends TDoc implements DocRules {
@Prop(TypeRef(core.class.Class), core.string.Class)
ofClass!: Ref<Class<Doc>>
fieldRules!: DocAttributeRule[]
}
export function createModel (builder: Builder): void {
builder.createModel(
TObjectSearchCategory,
TPresentationMiddlewareFactory,
TComponentPointExtension,
TDocCreateExtension,
TDocRules
)
}

View File

@ -35,7 +35,15 @@ export const recruitOperation: MigrateOperation = {
async upgrade (client: MigrationUpgradeClient): Promise<void> {
const tx = new TxOperations(client, core.account.System)
await createDefaults(tx)
await fixTemplateSpace(tx)
await tryUpgrade(client, recruitId, [
{
state: 'fix-template-space',
func: async (client) => {
await fixTemplateSpace(tx)
}
}
])
await tryUpgrade(client, recruitId, [
{

View File

@ -419,13 +419,15 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
action: view.actionImpl.AttributeSelector,
actionPopup: tracker.component.AssigneeEditor,
actionProps: {
attribute: 'assignee',
_class: contact.mixin.Employee,
query: {},
placeholder: tracker.string.AssignTo
isAction: true,
valueKey: 'object'
// _class: contact.mixin.Employee,
// query: {},
// placeholder: tracker.string.AssignTo
},
label: tracker.string.Assignee,
icon: contact.icon.Person,
@ -445,16 +447,11 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
createAction(
builder,
{
action: view.actionImpl.ValueSelector,
actionPopup: view.component.ValueSelector,
action: view.actionImpl.AttributeSelector,
actionPopup: tracker.component.ComponentEditor,
actionProps: {
attribute: 'component',
_class: tracker.class.Component,
query: {},
fillQuery: { space: 'space' },
docMatches: ['space'],
searchField: 'label',
placeholder: tracker.string.Component
isAction: true
},
label: tracker.string.Component,
icon: tracker.icon.Component,

View File

@ -36,7 +36,6 @@ import {
TIssueTemplate,
TMilestone,
TProject,
TProjectIssueTargetOptions,
TRelatedIssueTarget,
TTimeSpendReport,
TTypeEstimation,
@ -431,7 +430,6 @@ export function createModel (builder: Builder): void {
TTypeMilestoneStatus,
TTimeSpendReport,
TTypeReportedTime,
TProjectIssueTargetOptions,
TRelatedIssueTarget,
TTypeEstimation,
TTypeRemainingTime

View File

@ -30,7 +30,6 @@ import {
Collection,
Hidden,
Index,
Mixin,
Model,
Prop,
ReadOnly,
@ -43,9 +42,9 @@ import {
} from '@hcengineering/model'
import attachment from '@hcengineering/model-attachment'
import chunter from '@hcengineering/model-chunter'
import core, { TAttachedDoc, TClass, TDoc, TStatus, TType } from '@hcengineering/model-core'
import core, { TAttachedDoc, TDoc, TStatus, TType } from '@hcengineering/model-core'
import task, { TSpaceWithStates, TTask } from '@hcengineering/model-task'
import { IntlString, Resource } from '@hcengineering/platform'
import { IntlString } from '@hcengineering/platform'
import tags, { TagElement } from '@hcengineering/tags'
import { DoneState } from '@hcengineering/task'
import {
@ -57,18 +56,15 @@ import {
IssueStatus,
IssueTemplate,
IssueTemplateChild,
IssueUpdateFunction,
Milestone,
MilestoneStatus,
Project,
ProjectIssueTargetOptions,
RelatedClassRule,
RelatedIssueTarget,
RelatedSpaceRule,
TimeReportDayType,
TimeSpendReport
} from '@hcengineering/tracker'
import { AnyComponent } from '@hcengineering/ui'
import tracker from './plugin'
export const DOMAIN_TRACKER = 'tracker' as Domain
@ -357,14 +353,6 @@ export class TComponent extends TDoc implements Component {
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
*/

View File

@ -483,7 +483,8 @@ export function defineViewlets (builder: Builder): void {
{
key: '',
presenter: tracker.component.ComponentPresenter,
props: { kind: 'list' }
props: { kind: 'list' },
displayProps: { key: 'component', fixed: 'left' }
},
{ key: '', displayProps: { grow: true } },
{

View File

@ -110,6 +110,26 @@ export class Hierarchy {
}
}
findMixinMixins<D extends Doc, M extends D>(doc: Doc, mixin: Ref<Mixin<M>>): M[] {
const _doc = _toDoc(doc)
const result: M[] = []
const resultSet = new Set<string>()
// 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 clazz = this.getClass(k as Ref<Classifier>)
if (this.hasMixin(clazz, mixin)) {
const cc = this.as(clazz, mixin) as any as M
if (cc !== undefined && !resultSet.has(cc._id)) {
result.push(cc)
resultSet.add(cc._id)
}
}
}
}
return result
}
isMixin (_class: Ref<Class<Doc>>): boolean {
const data = this.classifiers.get(_class)
return data !== undefined && this._isMixin(data)

View File

@ -14,11 +14,11 @@
//
import { PlatformError, Severity, Status } from '@hcengineering/platform'
import { getObjectValue, Lookup, ReverseLookups } from '.'
import { Lookup, ReverseLookups, getObjectValue } from '.'
import type { Class, Doc, Ref } from './classes'
import core from './component'
import { Hierarchy } from './hierarchy'
import { matchQuery, resultSort, checkMixinKey } from './query'
import { checkMixinKey, matchQuery, resultSort } from './query'
import type { DocumentQuery, FindOptions, FindResult, LookupData, Storage, TxResult, WithLookup } from './storage'
import type { Tx, TxCreateDoc, TxMixin, TxRemoveDoc, TxUpdateDoc } from './tx'
import { TxProcessor } from './tx'
@ -175,6 +175,34 @@ export abstract class MemDb extends TxProcessor implements Storage {
return toFindResult(res, total)
}
/**
* Only in model find without lookups and sorting.
*/
findAllSync<T extends Doc>(_class: Ref<Class<T>>, query: DocumentQuery<T>, options?: FindOptions<T>): FindResult<T> {
let result: WithLookup<Doc>[]
const baseClass = this.hierarchy.getBaseClass(_class)
if (
Object.prototype.hasOwnProperty.call(query, '_id') &&
(typeof query._id === 'string' || query._id?.$in !== undefined || query._id === undefined || query._id === null)
) {
result = this.getByIdQuery(query, baseClass)
} else {
result = this.getObjectsByClass(baseClass)
}
result = matchQuery(result, query, _class, this.hierarchy, true)
if (baseClass !== _class) {
// We need to filter instances without mixin was set
result = result.filter((r) => (r as any)[_class] !== undefined)
}
const total = result.length
result = result.slice(0, options?.limit)
const tresult = this.hierarchy.clone(result) as WithLookup<T>[]
const res = tresult.map((it) => this.hierarchy.updateLookupMixin(_class, it, options))
return toFindResult(res, total)
}
addDoc (doc: Doc): void {
this.hierarchy.getAncestors(doc._class).forEach((_class) => {
const arr = this.getObjectsByClass(_class)

View File

@ -325,8 +325,6 @@
<KanbanRow
bind:this={stateRows[si]}
on:obj-focus
index={si}
{groupByDocs}
{stateObjects}
{isDragging}
{dragCard}

View File

@ -18,17 +18,14 @@
import { createEventDispatcher } from 'svelte'
import { slide } from 'svelte/transition'
import { CardDragEvent, DocWithRank, Item } from '../types'
import Spinner from '@hcengineering/ui/src/components/Spinner.svelte'
export let stateObjects: Item[]
export let isDragging: boolean
export let dragCard: Item | undefined
export let objects: Item[]
export let groupByDocs: Record<string | number, Item[]>
export let selection: number | undefined = undefined
export let checkedSet: Set<Ref<Doc>>
export let state: CategoryType
export let index: number
export let cardDragOver: (evt: CardDragEvent, object: Item) => void
export let cardDrop: (evt: CardDragEvent, object: Item) => void
@ -56,23 +53,7 @@
let limit = 50
let limitedObjects: DocWithRank[] = []
let loading = false
let loadingTimeout: any | undefined = undefined
function update (stateObjects: Item[], limit: number | undefined, index: number): void {
clearTimeout(loadingTimeout)
if (limitedObjects.length > 0 || index === 0) {
limitedObjects = stateObjects.slice(0, limit)
} else {
loading = true
loadingTimeout = setTimeout(() => {
limitedObjects = stateObjects.slice(0, limit)
loading = false
}, index)
}
}
$: update(stateObjects, limit, index)
$: limitedObjects = stateObjects.slice(0, limit)
</script>
{#each limitedObjects as object, i (object._id)}
@ -110,21 +91,17 @@
{/each}
{#if stateObjects.length > limitedObjects.length}
<div class="p-1 flex-no-shrink clear-mins">
{#if loading}
<Spinner />
{:else}
<div class="card-container flex-between p-4">
<span class="caption-color">{limitedObjects.length}</span> / {stateObjects.length}
<Button
size={'small'}
icon={IconMoreH}
label={ui.string.ShowMore}
on:click={() => {
limit = limit + 20
}}
/>
</div>
{/if}
<div class="card-container flex-between p-4">
<span class="caption-color">{limitedObjects.length}</span> / {stateObjects.length}
<Button
size={'small'}
icon={IconMoreH}
label={ui.string.ShowMore}
on:click={() => {
limit = limit + 20
}}
/>
</div>
</div>
{/if}
@ -133,11 +110,7 @@
background-color: var(--theme-kanban-card-bg-color);
border: 1px solid var(--theme-kanban-card-border);
border-radius: 0.25rem;
// transition: box-shadow .15s ease-in-out;
// &:hover {
// background-color: var(--highlight-hover);
// }
&.checked {
background-color: var(--highlight-select);
box-shadow: 0 0 1px 1px var(--highlight-select-border);

View File

@ -1,8 +1,8 @@
<script lang="ts">
import { Component, ComponentExtensionId } from '@hcengineering/ui'
import plugin from '../plugin'
import { ComponentPointExtension } from '../types'
import { getClient } from '../utils'
import plugin from '../../plugin'
import { ComponentPointExtension } from '../../types'
import { getClient } from '../../utils'
export let extension: ComponentExtensionId
export let props: Record<string, any> = {}

View File

@ -0,0 +1,23 @@
<script lang="ts">
import { Component } from '@hcengineering/ui'
import { CreateExtensionKind } from '../../types'
import { DocCreateExtensionManager } from './manager'
import { Space } from '@hcengineering/core'
export let manager: DocCreateExtensionManager
export let kind: CreateExtensionKind
export let props: Record<string, any> = {}
export let space: Space | undefined
$: extensions = manager.extensions
$: filteredExtensions = $extensions.filter((it) => it.components[kind] !== undefined)
</script>
{#each filteredExtensions as extension}
{@const state = manager.getState(extension._id)}
{@const component = extension.components[kind]}
{#if component}
<Component is={component} props={{ kind, state, space, ...props }} />
{/if}
{/each}

View File

@ -0,0 +1,56 @@
import { Class, Doc, DocData, Ref, SortingOrder, Space, TxOperations } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { onDestroy } from 'svelte'
import { Writable, writable } from 'svelte/store'
import { LiveQuery } from '../..'
import presentation from '../../plugin'
import { DocCreateExtension } from '../../types'
import { createQuery } from '../../utils'
export class DocCreateExtensionManager {
query: LiveQuery
_extensions: DocCreateExtension[] = []
extensions: Writable<DocCreateExtension[]> = writable([])
states: Map<Ref<DocCreateExtension>, Writable<any>> = new Map()
static create (_class: Ref<Class<Doc>>): DocCreateExtensionManager {
const mgr = new DocCreateExtensionManager(_class)
onDestroy(() => {
mgr.close()
})
return mgr
}
getState (ref: Ref<DocCreateExtension>): Writable<any> {
let state = this.states.get(ref)
if (state === undefined) {
state = writable({})
this.states.set(ref, state)
}
return state
}
private constructor (readonly _class: Ref<Class<Doc>>) {
this.query = createQuery()
this.query.query(
presentation.class.DocCreateExtension,
{ ofClass: _class },
(res) => {
this._extensions = res
this.extensions.set(res)
},
{ sort: { ofClass: SortingOrder.Ascending } }
)
}
async commit (ops: TxOperations, docId: Ref<Doc>, space: Ref<Space>, data: DocData<Doc>): Promise<void> {
for (const e of this._extensions) {
const applyOp = await getResource(e.apply)
await applyOp?.(ops, docId, space, data, this.getState(e._id))
}
}
close (): void {
this.query.unsubscribe()
}
}

View File

@ -41,7 +41,8 @@ export { default as NavLink } from './components/NavLink.svelte'
export { default as IconForward } from './components/icons/Forward.svelte'
export { default as Breadcrumbs } from './components/breadcrumbs/Breadcrumbs.svelte'
export { default as BreadcrumbsElement } from './components/breadcrumbs/BreadcrumbsElement.svelte'
export { default as ComponentExtensions } from './components/ComponentExtensions.svelte'
export { default as ComponentExtensions } from './components/extensions/ComponentExtensions.svelte'
export { default as DocCreateExtComponent } from './components/extensions/DocCreateExtComponent.svelte'
export { default } from './plugin'
export * from './types'
export * from './utils'
@ -50,3 +51,5 @@ export { presentationId }
export * from './configuration'
export * from './context'
export * from './pipeline'
export * from './components/extensions/manager'
export * from './rules'

View File

@ -10,7 +10,8 @@ import {
Ref,
Tx,
TxResult,
WithLookup
WithLookup,
toFindResult
} from '@hcengineering/core'
import { Resource } from '@hcengineering/platform'
@ -240,3 +241,53 @@ export abstract class BasePresentationMiddleware {
export interface PresentationMiddlewareFactory extends Doc {
createPresentationMiddleware: Resource<PresentationMiddlewareCreator>
}
/**
* @public
*/
export class OptimizeQueryMiddleware extends BasePresentationMiddleware implements PresentationMiddleware {
private constructor (client: Client, next?: PresentationMiddleware) {
super(client, next)
}
static create (client: Client, next?: PresentationMiddleware): OptimizeQueryMiddleware {
return new OptimizeQueryMiddleware(client, next)
}
async notifyTx (tx: Tx): Promise<void> {
await this.provideNotifyTx(tx)
}
async close (): Promise<void> {
return await this.provideClose()
}
async tx (tx: Tx): Promise<TxResult> {
return await this.provideTx(tx)
}
async subscribe<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options: FindOptions<T> | undefined,
refresh: () => void
): Promise<{
unsubscribe: () => void
query?: DocumentQuery<T>
options?: FindOptions<T>
}> {
return await this.provideSubscribe(_class, query, options, refresh)
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T> | undefined
): Promise<FindResult<T>> {
if (_class == null || typeof query !== 'object' || ('_class' in query && query._class == null)) {
console.error('_class must be specified in query', query)
return toFindResult([], 0)
}
return await this.provideFindAll(_class, query, options)
}
}

View File

@ -18,7 +18,7 @@ import { Class, Ref } from '@hcengineering/core'
import type { Asset, IntlString, Metadata, Plugin } from '@hcengineering/platform'
import { plugin } from '@hcengineering/platform'
import { PresentationMiddlewareFactory } from './pipeline'
import { ComponentPointExtension, ObjectSearchCategory } from './types'
import { ComponentPointExtension, DocRules, DocCreateExtension, ObjectSearchCategory } from './types'
/**
* @public
@ -29,7 +29,9 @@ export default plugin(presentationId, {
class: {
ObjectSearchCategory: '' as Ref<Class<ObjectSearchCategory>>,
PresentationMiddlewareFactory: '' as Ref<Class<PresentationMiddlewareFactory>>,
ComponentPointExtension: '' as Ref<Class<ComponentPointExtension>>
ComponentPointExtension: '' as Ref<Class<ComponentPointExtension>>,
DocCreateExtension: '' as Ref<Class<DocCreateExtension>>,
DocRules: '' as Ref<Class<DocRules>>
},
string: {
Create: '' as IntlString,

View File

@ -0,0 +1,108 @@
import { Class, Doc, DocumentQuery, Ref, Space, matchQuery } from '@hcengineering/core'
import { getClient } from '.'
import presentation from './plugin'
export interface RuleApplyResult<T extends Doc> {
fieldQuery: DocumentQuery<T>
disableUnset: boolean
disableEdit: boolean
}
export const emptyRuleApplyResult: RuleApplyResult<Doc> = {
fieldQuery: {},
disableUnset: false,
disableEdit: false
}
/**
* @public
*/
export function getDocRules<T extends Doc> (documents: Doc | Doc[], field: string): RuleApplyResult<T> | undefined {
const docs = Array.isArray(documents) ? documents : [documents]
if (docs.length === 0) {
return emptyRuleApplyResult as RuleApplyResult<T>
}
const c = getClient()
const h = c.getHierarchy()
const _class = docs[0]._class
for (const d of docs) {
if (d._class !== _class) {
// If we have different classes, we should return undefined.
return undefined
}
}
const rulesSet = c.getModel().findAllSync(presentation.class.DocRules, { ofClass: { $in: h.getAncestors(_class) } })
let fieldQuery: DocumentQuery<T> = {}
let disableUnset = false
let disableEdit = false
for (const rules of rulesSet) {
if (h.isDerived(_class, rules.ofClass)) {
// Check individual rules and form a result query
for (const r of rules.fieldRules) {
if (r.field === field) {
const _docs = docs.map((doc) =>
r.mixin !== undefined && h.hasMixin(doc, r.mixin) ? h.as(doc, r.mixin) : doc
)
if (matchQuery(_docs, r.query, r.mixin ?? rules.ofClass, h).length === _docs.length) {
// We have rule match.
if (r.disableUnset === true) {
disableUnset = true
}
if (r.disableEdit === true) {
disableEdit = true
}
if (r.fieldQuery != null) {
fieldQuery = { ...fieldQuery, ...r.fieldQuery }
}
for (const [sourceK, targetK] of Object.entries(r.fieldQueryFill ?? {})) {
const v = (_docs[0] as any)[sourceK]
for (const d of _docs) {
const newV = (d as any)[sourceK]
if (newV !== v && r.allowConflict === false) {
// Value conflict, we could not choose one.
return undefined
}
;(fieldQuery as any)[targetK] = newV
}
}
}
}
}
}
}
return {
fieldQuery,
disableUnset,
disableEdit
}
}
/**
* @public
*/
export function isCreateAllowed (_class: Ref<Class<Doc>>, space: Space): boolean {
const c = getClient()
const h = c.getHierarchy()
const rules = c.getModel().findAllSync(presentation.class.DocRules, { ofClass: _class })
for (const r of rules) {
if (r.createRule !== undefined) {
if (r.createRule.mixin !== undefined) {
if (h.hasMixin(space, r.createRule.mixin)) {
const _mixin = h.as(space, r.createRule.mixin)
if (matchQuery([_mixin], r.createRule.disallowQuery, r.createRule.mixin ?? space._class, h).length === 1) {
return false
}
}
} else {
if (matchQuery([space], r.createRule.disallowQuery, r.createRule.mixin ?? space._class, h).length === 1) {
return false
}
}
}
}
return true
}

View File

@ -1,4 +1,15 @@
import { Client, Doc, RelatedDocument } from '@hcengineering/core'
import {
Class,
Client,
Doc,
DocData,
DocumentQuery,
Mixin,
Ref,
RelatedDocument,
Space,
TxOperations
} from '@hcengineering/core'
import { Asset, IntlString, Resource } from '@hcengineering/platform'
import { AnyComponent, AnySvelteComponent, ComponentExtensionId } from '@hcengineering/ui'
@ -48,21 +59,93 @@ export interface ObjectSearchCategory extends Doc {
query: Resource<ObjectSearchFactory>
}
export interface ComponentExt {
component: AnyComponent
props?: Record<string, any>
order?: number // Positioning of elements, into groups.
}
/**
* @public
*
* An component extension to various places of platform.
*/
export interface ComponentPointExtension extends Doc {
export interface ComponentPointExtension extends Doc, ComponentExt {
// Extension point we should extend.
extension: ComponentExtensionId
// Component to be instantiated with at least following properties:
// size: 'tiny' | 'small' | 'medium' | 'large'
component: AnyComponent
// Extra properties to be passed to the component
props?: Record<string, any>
order?: number // Positioning of elements, into groups.
}
/**
* @public
*/
export type DocCreateFunction = (
client: TxOperations,
id: Ref<Doc>,
space: Ref<Space>,
document: DocData<Doc>,
extraData: Record<string, any>
) => Promise<void>
/**
* @public
*/
export type CreateExtensionKind = 'header' | 'title' | 'body' | 'footer' | 'pool' | 'buttons'
/**
* @public
*
* Customization for document creation
*
* Allow to customize create document/move issue dialogs, in case of selecting project of special kind.
*/
export interface DocCreateExtension extends Doc {
ofClass: Ref<Class<Doc>>
components: Partial<Record<CreateExtensionKind, AnyComponent>>
apply: Resource<DocCreateFunction>
}
export interface DocAttributeRule {
// A field name
field: string
// If document is matched, rule will be used.
query: DocumentQuery<Doc>
// If specified, will check for mixin to exists and cast to it
mixin?: Ref<Mixin<Doc>>
// If specified, should be applied to field value queries, if field is reference to some document.
fieldQuery?: DocumentQuery<Doc>
// If specified will fill document properties to fieldQuery
fieldQueryFill?: Record<string, string>
// If specified, should disable unset of field value.
disableUnset?: boolean
// If specified should disable edit of this field value.
disableEdit?: boolean
// In case of conflict values for multiple documents, will not be applied
// Or will continue processing
allowConflict?: boolean
}
/**
* A configurable rule's for some type of document
*/
export interface DocRules extends Doc {
// Could be mixin, will be applied if mixin will be set for document.
ofClass: Ref<Class<Doc>>
// attribute modification rules
fieldRules: DocAttributeRule[]
// Check if document create is allowed for project based on query.
createRule?: {
// If query matched, document create is disallowed.
disallowQuery: DocumentQuery<Space>
mixin?: Ref<Mixin<Space>>
}
}

View File

@ -43,7 +43,7 @@ import view, { AttributeEditor } from '@hcengineering/view'
import { deepEqual } from 'fast-equals'
import { onDestroy } from 'svelte'
import { KeyedAttribute } from '..'
import { PresentationPipeline, PresentationPipelineImpl } from './pipeline'
import { OptimizeQueryMiddleware, PresentationPipeline, PresentationPipelineImpl } from './pipeline'
import plugin from './plugin'
let liveQuery: LQ
@ -115,7 +115,7 @@ export async function setClient (_client: Client): Promise<void> {
const factories = await _client.findAll(plugin.class.PresentationMiddlewareFactory, {})
const promises = factories.map(async (it) => await getResource(it.createPresentationMiddleware))
const creators = await Promise.all(promises)
pipeline = PresentationPipelineImpl.create(_client, creators)
pipeline = PresentationPipelineImpl.create(_client, [OptimizeQueryMiddleware.create, ...creators])
const needRefresh = liveQuery !== undefined
liveQuery = new LQ(pipeline)

View File

@ -63,6 +63,7 @@
on:delete
on:action
on:valid
on:validate
>
<slot />
</Ctor>
@ -79,6 +80,7 @@
on:delete
on:action
on:valid
on:validate
/>
{/if}
</ErrorBoundary>

View File

@ -16,9 +16,11 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte'
import Spinner from './Spinner.svelte'
import { ButtonSize } from '../types'
export let shrink: boolean = false
export let label: string = ''
export let size: ButtonSize = 'medium'
const dispatch = createEventDispatcher()
let timer: any
@ -34,7 +36,7 @@
<div class="spinner-container" class:fullSize={!shrink}>
<div data-label={label} class="inner" class:labeled={label !== ''}>
<Spinner />
<Spinner {size} />
</div>
</div>

View File

@ -13,44 +13,26 @@
// limitations under the License.
-->
<script lang="ts">
import type { Asset, IntlString } from '@hcengineering/platform'
import type { IntlString } from '@hcengineering/platform'
import { createEventDispatcher } from 'svelte'
import { deviceOptionsStore, resizeObserver } from '..'
import { createFocusManager } from '../focus'
import type { AnySvelteComponent } from '../types'
import type { SelectPopupValueType } from '../types'
import EditWithIcon from './EditWithIcon.svelte'
import FocusHandler from './FocusHandler.svelte'
import Icon from './Icon.svelte'
import IconCheck from './icons/Check.svelte'
import IconSearch from './icons/Search.svelte'
import Label from './Label.svelte'
import ListView from './ListView.svelte'
interface ValueType {
id: number | string | null
icon?: Asset | AnySvelteComponent
iconProps?: Record<string, any>
iconColor?: string
label?: IntlString
text?: string
isSelected?: boolean
component?: AnySvelteComponent
props?: Record<string, any>
category?: {
icon?: Asset
label: IntlString
}
}
import IconCheck from './icons/Check.svelte'
import IconSearch from './icons/Search.svelte'
export let placeholder: IntlString | undefined = undefined
export let placeholderParam: any | undefined = undefined
export let searchable: boolean = false
export let value: Array<ValueType>
export let value: Array<SelectPopupValueType>
export let width: 'medium' | 'large' | 'full' = 'medium'
export let size: 'small' | 'medium' | 'large' = 'small'
export let onSelect: ((value: ValueType['id']) => void) | undefined = undefined
export let onSelect: ((value: SelectPopupValueType['id']) => void) | undefined = undefined
export let showShadow: boolean = true
export let embedded: boolean = false
@ -63,7 +45,7 @@
let selection = 0
let list: ListView
function sendSelect (id: ValueType['id']): void {
function sendSelect (id: SelectPopupValueType['id']): void {
if (onSelect) {
onSelect(id)
} else {

View File

@ -447,3 +447,24 @@ export interface SeparatedElement {
resize: boolean
float?: string | undefined
}
/**
* @public
*/
export interface SelectPopupValueType {
id: number | string | null
icon?: Asset | AnySvelteComponent
iconProps?: Record<string, any>
iconColor?: string
label?: IntlString
text?: string
isSelected?: boolean
component?: AnySvelteComponent
props?: Record<string, any>
category?: {
icon?: Asset
label: IntlString
}
}

View File

@ -31,6 +31,7 @@ import {
import AccountArrayEditor from './components/AccountArrayEditor.svelte'
import AccountBox from './components/AccountBox.svelte'
import AssigneeBox from './components/AssigneeBox.svelte'
import AssigneePopup from './components/AssigneePopup.svelte'
import Avatar from './components/Avatar.svelte'
import ChannelFilter from './components/ChannelFilter.svelte'
import ChannelPanel from './components/ChannelPanel.svelte'
@ -134,6 +135,7 @@ export {
EditableAvatar,
UserBox,
AssigneeBox,
AssigneePopup,
Avatar,
UsersPopup,
EmployeeBox,

View File

@ -22,6 +22,8 @@
import preference, { SpacePreference } from '@hcengineering/preference'
import {
Card,
DocCreateExtComponent,
DocCreateExtensionManager,
DraftController,
KeyedAttribute,
MessageBox,
@ -40,7 +42,6 @@
IssueTemplate,
Milestone,
Project,
ProjectIssueTargetOptions,
calcRank
} from '@hcengineering/tracker'
import {
@ -61,7 +62,7 @@
import { createEventDispatcher, onDestroy } from 'svelte'
import { activeComponent, activeMilestone, generateIssueShortLink, getIssueId, updateIssueRelation } from '../issues'
import tracker from '../plugin'
import ComponentSelector from './ComponentSelector.svelte'
import ComponentSelector from './components/ComponentSelector.svelte'
import SetParentIssueActionPopup from './SetParentIssueActionPopup.svelte'
import SubIssues from './SubIssues.svelte'
import AssigneeEditor from './issues/AssigneeEditor.svelte'
@ -300,17 +301,7 @@
currentProject = res.shift()
})
$: targetSettings =
currentProject !== undefined
? client
.getHierarchy()
.findClassOrMixinMixin<Class<Doc>, ProjectIssueTargetOptions>(
currentProject,
tracker.mixin.ProjectIssueTargetOptions
)
: undefined
let targetSettingOptions: Record<string, any> = {}
const docCreateManager = DocCreateExtensionManager.create(tracker.class.Issue)
async function updateIssueStatusId (object: IssueDraft, currentProject: Project | undefined) {
if (currentProject?.defaultIssueStatus && object.status === undefined) {
@ -359,6 +350,8 @@
return
}
const operations = client.apply(_id)
const lastOne = await client.findOne<Issue>(tracker.class.Issue, {}, { sort: { rank: SortingOrder.Descending } })
const incResult = await client.updateDoc(
tracker.class.Project,
@ -395,12 +388,9 @@
childInfo: []
}
if (targetSettings !== undefined) {
const updateOp = await getResource(targetSettings.update)
updateOp?.(_id, _space as Ref<Project>, value, targetSettingOptions)
}
await docCreateManager.commit(operations, _id, _space, value)
await client.addCollection(
await operations.addCollection(
tracker.class.Issue,
_space,
parentIssue?._id ?? tracker.ids.NoParent,
@ -410,7 +400,7 @@
_id
)
for (const label of object.labels) {
await client.addCollection(label._class, label.space, _id, tracker.class.Issue, 'labels', {
await operations.addCollection(label._class, label.space, _id, tracker.class.Issue, 'labels', {
title: label.title,
color: label.color,
tag: label.tag
@ -418,11 +408,13 @@
}
await descriptionBox.createAttachments(_id)
await operations.commit()
if (relatedTo !== undefined) {
const doc = await client.findOne(tracker.class.Issue, { _id })
if (doc !== undefined) {
if (client.getHierarchy().isDerived(relatedTo._class, tracker.class.Issue)) {
await updateIssueRelation(client, relatedTo as Issue, doc, 'relations', '$push')
await updateIssueRelation(operations, relatedTo as Issue, doc, 'relations', '$push')
} else {
const update = await getResource(chunter.backreference.Update)
await update(doc, 'relations', [relatedTo], tracker.string.AddedReference)
@ -585,6 +577,17 @@
return client.findOne(tracker.class.Project, { _id: targetRef })
}
}
$: extraProps = {
status: object.status,
priority: object.priority,
assignee: object.assignee,
component: object.component,
milestone: object.milestone,
relatedTo,
parentIssue,
originalIssue
}
</script>
<FocusHandler {manager} />
@ -629,15 +632,7 @@
docProps={{ disabled: true, noUnderline: true }}
focusIndex={20000}
/>
{#if targetSettings?.headerComponent && currentProject}
<Component
is={targetSettings.headerComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
<DocCreateExtComponent manager={docCreateManager} kind={'header'} space={currentProject} props={extraProps} />
</svelte:fragment>
<svelte:fragment slot="title" let:label>
<div class="flex-row-center gap-1">
@ -660,6 +655,7 @@
}}
/>
{/if}
<DocCreateExtComponent manager={docCreateManager} kind={'title'} space={currentProject} props={extraProps} />
</div>
</svelte:fragment>
<svelte:fragment slot="subheader">
@ -720,15 +716,7 @@
bind:subIssues={object.subIssues}
/>
{/if}
{#if targetSettings?.bodyComponent && currentProject}
<Component
is={targetSettings.bodyComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
<DocCreateExtComponent manager={docCreateManager} kind={'body'} space={currentProject} props={extraProps} />
<svelte:fragment slot="pool">
<div id="status-editor">
<StatusEditor
@ -804,7 +792,6 @@
isEditable={true}
kind={'regular'}
size={'large'}
short
/>
<div id="estimation-editor" class="new-line">
<EstimationEditor focusIndex={7} kind={'regular'} size={'large'} value={object} />
@ -839,15 +826,7 @@
on:click={object.parentIssue ? clearParentIssue : setParentIssue}
/>
</div>
{#if targetSettings?.poolComponent && currentProject}
<Component
is={targetSettings.poolComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
<DocCreateExtComponent manager={docCreateManager} kind={'pool'} space={currentProject} props={extraProps} />
</svelte:fragment>
<svelte:fragment slot="attachments">
{#if attachments.size > 0}
@ -874,14 +853,9 @@
descriptionBox.handleAttach()
}}
/>
{#if targetSettings?.footerComponent && currentProject}
<Component
is={targetSettings.footerComponent}
props={{ targetSettingOptions, project: currentProject }}
on:change={(evt) => {
targetSettingOptions = evt.detail
}}
/>
{/if}
<DocCreateExtComponent manager={docCreateManager} kind={'footer'} space={currentProject} props={extraProps} />
</svelte:fragment>
<svelte:fragment slot="buttons">
<DocCreateExtComponent manager={docCreateManager} kind={'buttons'} space={currentProject} props={extraProps} />
</svelte:fragment>
</Card>

View File

@ -27,9 +27,10 @@
} from '@hcengineering/view-resources'
import { onDestroy } from 'svelte'
import tracker from '../../plugin'
import { ComponentsFilterMode, componentsTitleMap } from '../../utils'
import { ComponentsFilterMode, activeProjects, componentsTitleMap } from '../../utils'
import ComponentsContent from './ComponentsContent.svelte'
import NewComponent from './NewComponent.svelte'
import { isCreateAllowed } from '@hcengineering/presentation'
export let label: IntlString
export let query: DocumentQuery<Component> = {}
@ -39,6 +40,8 @@
const space = typeof query.space === 'string' ? query.space : tracker.project.DefaultProject
$: project = $activeProjects.get(space)
let viewlet: WithLookup<Viewlet> | undefined
let viewlets: WithLookup<Viewlet>[] | undefined
let viewletKey = makeViewletKey()
@ -87,7 +90,9 @@
<div class="ac-header-full medium-gap mb-1">
<ViewletSelector bind:viewlet bind:viewlets viewletQuery={{ attachTo: tracker.class.Component }} />
<Button icon={IconAdd} label={tracker.string.Component} kind="primary" on:click={showCreateDialog} />
{#if project !== undefined && isCreateAllowed(tracker.class.Component, project)}
<Button icon={IconAdd} label={tracker.string.Component} kind="primary" on:click={showCreateDialog} />
{/if}
</div>
</div>
<div class="ac-header full divide search-start">

View File

@ -13,29 +13,30 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachedData, Ref } from '@hcengineering/core'
import { AttachedData, DocumentQuery, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { RuleApplyResult, createQuery, getClient, getDocRules } from '@hcengineering/presentation'
import { Component, Issue, IssueTemplate, Project } from '@hcengineering/tracker'
import { ButtonKind, ButtonShape, ButtonSize, tooltip } from '@hcengineering/ui'
import { ButtonKind, ButtonShape, ButtonSize, deviceOptionsStore as deviceInfo } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { activeComponent } from '../../issues'
import tracker from '../../plugin'
import ComponentSelector from '../ComponentSelector.svelte'
import ComponentSelector from './ComponentSelector.svelte'
export let value: Issue | IssueTemplate | AttachedData<Issue>
export let value: Issue | Issue[] | IssueTemplate | AttachedData<Issue>
export let isEditable: boolean = true
export let shouldShowLabel: boolean = true
export let popupPlaceholder: IntlString = tracker.string.MoveToComponent
export let shouldShowPlaceholder = true
export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'large'
export let kind: ButtonKind = 'link'
export let shape: ButtonShape = undefined
export let justify: 'left' | 'center' = 'left'
export let width: string | undefined = '100%'
export let onlyIcon: boolean = false
export let isAction: boolean = false
export let groupBy: string | undefined = undefined
export let enlargedText = false
export let enlargedText: boolean = false
export let compression: boolean = false
export let shrink: number = 0
export let space: Ref<Project> | undefined = undefined
@ -45,46 +46,129 @@
const dispatch = createEventDispatcher()
const handleComponentIdChanged = async (newComponentId: Ref<Component> | null | undefined) => {
if (!isEditable || newComponentId === undefined || value.component === newComponentId) {
if (!isEditable || newComponentId === undefined || (!Array.isArray(value) && value.component === newComponentId)) {
return
}
dispatch('change', newComponentId)
if ('_class' in value) {
await client.update(value, { component: newComponentId })
if (Array.isArray(value)) {
await Promise.all(
value.map(async (p) => {
if ('_class' in value) {
await client.update(p, { component: newComponentId })
}
})
)
} else {
if ('_class' in value) {
await client.update(value, { component: newComponentId })
}
}
dispatch('change', newComponentId)
if (isAction) dispatch('close')
}
$: _space = space ?? ('space' in value ? value.space : undefined)
const milestoneQuery = createQuery()
let component: Component | undefined
$: if (!Array.isArray(value) && value.component) {
milestoneQuery.query(tracker.class.Component, { _id: value.component }, (res) => {
component = res.shift()
})
}
$: _space =
space ??
(Array.isArray(value)
? { $in: Array.from(new Set(value.map((it) => it.space))) }
: 'space' in value
? value.space
: undefined)
$: twoRows = $deviceInfo.twoRows
let rulesQuery: RuleApplyResult<Component> | undefined
let query: DocumentQuery<Component>
$: if (Array.isArray(value) || '_id' in value) {
rulesQuery = getDocRules<Component>(value, 'component')
if (rulesQuery !== undefined) {
query = { ...(rulesQuery?.fieldQuery ?? {}) }
} else {
query = { _id: 'none' as Ref<Component> }
rulesQuery = {
disableEdit: true,
disableUnset: true,
fieldQuery: {}
}
}
}
</script>
{#if (value.component && value.component !== $activeComponent && groupBy !== 'component') || shouldShowPlaceholder}
{#if kind === 'list'}
{#if !Array.isArray(value) && value.component}
<div class={compression ? 'label-wrapper' : 'clear-mins'}>
<ComponentSelector
{kind}
{size}
{shape}
{justify}
isEditable={isEditable && !rulesQuery?.disableEdit}
isAllowUnset={!rulesQuery?.disableUnset}
{shouldShowLabel}
{popupPlaceholder}
{onlyIcon}
{query}
space={_space}
{enlargedText}
short={compression}
showTooltip={{ label: value.component ? tracker.string.MoveToComponent : tracker.string.AddToComponent }}
value={value.component}
onChange={handleComponentIdChanged}
{isAction}
/>
</div>
{/if}
{:else}
<div
class={compression ? 'label-wrapper' : 'clear-mins'}
class="flex flex-wrap clear-mins"
class:minus-margin={kind === 'list-header'}
use:tooltip={{ label: value.component ? tracker.string.MoveToComponent : tracker.string.AddToComponent }}
class:label-wrapper={compression}
style:flex-direction={twoRows ? 'column' : 'row'}
>
<ComponentSelector
{kind}
{size}
{shape}
{width}
{justify}
{isEditable}
{shouldShowLabel}
{popupPlaceholder}
{onlyIcon}
{enlargedText}
{shrink}
space={_space}
value={value.component}
short={compression}
onChange={handleComponentIdChanged}
/>
{#if (!Array.isArray(value) && value.component && value.component !== $activeComponent && groupBy !== 'component') || shouldShowPlaceholder}
<div class="flex-row-center" class:minus-margin-vSpace={kind === 'list-header'} class:compression style:width>
<ComponentSelector
{kind}
{size}
{shape}
{width}
{justify}
isEditable={isEditable && !rulesQuery?.disableEdit}
isAllowUnset={!rulesQuery?.disableUnset}
{shouldShowLabel}
{popupPlaceholder}
{onlyIcon}
{enlargedText}
{query}
space={_space}
showTooltip={{
label:
!Array.isArray(value) && value.component ? tracker.string.MoveToComponent : tracker.string.AddToComponent
}}
value={!Array.isArray(value) ? value.component : undefined}
onChange={handleComponentIdChanged}
{isAction}
/>
</div>
{/if}
</div>
{/if}
<style lang="scss">
.minus-margin {
margin-left: -0.5rem;
&-vSpace {
margin: -0.25rem 0;
}
&-space {
margin: -0.25rem 0 -0.25rem 0.5rem;
}
}
</style>

View File

@ -14,12 +14,13 @@
-->
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { Component } from '@hcengineering/tracker'
import { Icon, tooltip, themeStore } from '@hcengineering/ui'
import tracker from '../../plugin'
import { Icon, Component as UIComponent, themeStore, tooltip } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { DocNavLink } from '@hcengineering/view-resources'
import { translate } from '@hcengineering/platform'
import tracker from '../../plugin'
export let value: WithLookup<Component> | undefined
export let shouldShowAvatar = true
@ -44,21 +45,46 @@
})
}
$: disabled = disabled || value === undefined
$: presenters =
value !== undefined ? getClient().getHierarchy().findMixinMixins(value, view.mixin.ObjectPresenter) : []
$: icon = tracker.icon.Component
</script>
<DocNavLink object={value} {onClick} {disabled} {noUnderline} {inline} {accent} component={view.component.EditDoc}>
{#if inline}
<span class="antiMention" use:tooltip={{ label: tracker.string.Component }}>@{label}</span>
{:else}
<span class="flex-presenter" class:list={kind === 'list'} use:tooltip={{ label: tracker.string.Component }}>
{#if shouldShowAvatar}
<div class="icon">
<Icon icon={tracker.icon.Component} size={'small'} />
<div class="flex-row-center">
<DocNavLink object={value} {onClick} {disabled} {noUnderline} {inline} {accent} component={view.component.EditDoc}>
{#if inline}
<span class="antiMention" use:tooltip={{ label: tracker.string.Component }}>@{label}</span>
{:else}
<span class="flex-presenter flex-row-center" class:list={kind === 'list'}>
<div class="flex-row-center">
{#if shouldShowAvatar}
<div class="icon">
<Icon icon={presenters.length === 0 ? tracker.icon.Component : icon} size={'small'} />
</div>
{/if}
<span title={label} class="label nowrap" class:no-underline={disabled || noUnderline} class:fs-bold={accent}>
{label}
</span>
</div>
{/if}
<span title={label} class="label nowrap" class:no-underline={disabled || noUnderline} class:fs-bold={accent}>
{label}
</span>
</span>
{/if}
</DocNavLink>
{#if presenters.length > 0}
<div class="flex-row-center">
{#each presenters as mixinPresenter}
<UIComponent
is={mixinPresenter.presenter}
props={{ value }}
on:open={(evt) => {
if (evt.detail.icon !== undefined) {
icon = evt.detail.icon
}
}}
/>
{/each}
</div>
{/if}
</DocNavLink>
</div>

View File

@ -13,17 +13,20 @@
// limitations under the License.
-->
<script lang="ts">
import { Ref, SortingOrder } from '@hcengineering/core'
import { IntlString, getEmbeddedLabel, translate } from '@hcengineering/platform'
import { DocumentQuery, Ref, SortingOrder } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import { Component, Project } from '@hcengineering/tracker'
import type { ButtonKind, ButtonSize } from '@hcengineering/ui'
import { Button, ButtonShape, Label, SelectPopup, eventToHTMLElement, showPopup, themeStore } from '@hcengineering/ui'
import tracker from '../plugin'
import { Component } from '@hcengineering/tracker'
import type { ButtonKind, ButtonSize, LabelAndProps, SelectPopupValueType } from '@hcengineering/ui'
import { Button, ButtonShape, SelectPopup, eventToHTMLElement, showPopup, themeStore } from '@hcengineering/ui'
import tracker from '../../plugin'
import ComponentPresenter from './ComponentPresenter.svelte'
export let value: Ref<Component> | null | undefined
export let space: DocumentQuery<Component>['space'] | undefined = undefined
export let query: DocumentQuery<Component> = {}
export let shouldShowLabel: boolean = true
export let isEditable: boolean = false
export let isEditable: boolean = true
export let onChange: ((newComponentId: Ref<Component> | undefined) => void) | undefined = undefined
export let popupPlaceholder: IntlString = tracker.string.AddToComponent
export let kind: ButtonKind = 'no-border'
@ -34,22 +37,21 @@
export let onlyIcon: boolean = false
export let enlargedText: boolean = false
export let short: boolean = false
export let shrink: number = 0
export let focusIndex: number | undefined = undefined
export let space: Ref<Project> | undefined = undefined
export let isAction: boolean = false
export let isAllowUnset = true
export let showTooltip: LabelAndProps | undefined = undefined
let selectedComponent: Component | undefined
let defaultComponentLabel = ''
const query = createQuery()
const queryQuery = createQuery()
let rawComponents: Component[] = []
let loading = true
$: query.query(
$: queryQuery.query(
tracker.class.Component,
space !== undefined ? { space } : {},
{ ...query, ...(space ? { space } : {}) },
(res) => {
rawComponents = res
loading = false
},
{
sort: { modifiedOn: SortingOrder.Ascending }
@ -59,7 +61,6 @@
$: handleSelectedComponentIdUpdated(value, rawComponents)
$: translate(tracker.string.NoComponent, {}, $themeStore.language).then((result) => (defaultComponentLabel = result))
$: componentText = shouldShowLabel ? selectedComponent?.label ?? defaultComponentLabel : undefined
const handleSelectedComponentIdUpdated = async (
newComponentId: Ref<Component> | null | undefined,
@ -74,45 +75,57 @@
selectedComponent = components.find((it) => it._id === newComponentId)
}
function getComponentInfo (rawComponents: Component[], sp: Component | undefined): SelectPopupValueType[] {
return [
...(isAllowUnset
? [
{
id: null,
icon: tracker.icon.Component,
label: tracker.string.NoComponent,
isSelected: sp === undefined
}
]
: []),
...rawComponents.map((p) => ({
id: p._id,
icon: tracker.icon.Component,
text: p.label,
isSelected: sp ? p._id === sp._id : false,
component: ComponentPresenter,
props: {
value: p
}
}))
]
}
let components: SelectPopupValueType[] = []
$: components = getComponentInfo(rawComponents, selectedComponent)
const handleComponentEditorOpened = async (event: MouseEvent): Promise<void> => {
event.stopPropagation()
if (!isEditable) {
return
}
const componentsInfo = [
{ id: null, icon: tracker.icon.Components, label: tracker.string.NoComponent, isSelected: !selectedComponent },
...rawComponents.map((p) => ({
id: p._id,
icon: tracker.icon.Components,
text: p.label,
isSelected: selectedComponent ? p._id === selectedComponent._id : false
}))
]
showPopup(
SelectPopup,
{ value: componentsInfo, placeholder: popupPlaceholder, searchable: true },
{ value: components, placeholder: popupPlaceholder, searchable: true },
eventToHTMLElement(event),
onChange
)
}
</script>
{#if onlyIcon || componentText === undefined}
<Button
{focusIndex}
{kind}
{size}
{shape}
{width}
{justify}
icon={tracker.icon.Components}
disabled={!isEditable}
{loading}
{short}
{shrink}
on:click={handleComponentEditorOpened}
{#if isAction}
<SelectPopup
value={components}
placeholder={popupPlaceholder}
searchable
on:close={(evt) => {
if (onChange !== undefined) onChange(evt.detail)
}}
/>
{:else}
<Button
@ -122,17 +135,15 @@
{shape}
{width}
{justify}
icon={tracker.icon.Components}
{showTooltip}
disabled={!isEditable}
{loading}
notSelected={!value}
{short}
{shrink}
on:click={handleComponentEditorOpened}
>
<svelte:fragment slot="content">
<span class="label {enlargedText ? 'ml-1 text-base' : 'text-md'} overflow-label pointer-events-none">
<Label label={getEmbeddedLabel(componentText)} />
<span class="label {enlargedText ? 'text-base' : 'text-md'} overflow-label pointer-events-none">
<svelte:component this={ComponentPresenter} value={selectedComponent} />
</span>
</svelte:fragment>
</Button>

View File

@ -13,15 +13,15 @@
// limitations under the License.
-->
<script lang="ts">
import { EmployeeBox } from '@hcengineering/contact-resources'
import { Data, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { Card, getClient, SpaceSelector } from '@hcengineering/presentation'
import { EmployeeBox } from '@hcengineering/contact-resources'
import { Card, SpaceSelector, getClient } from '@hcengineering/presentation'
import { StyledTextArea } from '@hcengineering/text-editor'
import { Component, Project } from '@hcengineering/tracker'
import { EditBox } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import tracker from '../../plugin'
import { StyledTextArea } from '@hcengineering/text-editor'
import ProjectPresenter from '../projects/ProjectPresenter.svelte'
export let space: Ref<Project>
@ -36,8 +36,10 @@
attachments: 0
}
let _space = space
async function onSave () {
await client.createDoc(tracker.class.Component, space, object)
await client.createDoc(tracker.class.Component, _space, object)
}
</script>
@ -54,7 +56,7 @@
<SpaceSelector
_class={tracker.class.Project}
label={tracker.string.Project}
bind:space
bind:space={_space}
kind={'regular'}
size={'large'}
component={ProjectPresenter}

View File

@ -13,21 +13,22 @@
// limitations under the License.
-->
<script lang="ts">
import { Employee, Person, PersonAccount } from '@hcengineering/contact'
import { AssigneeBox, personAccountByIdStore } from '@hcengineering/contact-resources'
import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact'
import { AssigneeBox, AssigneePopup, personAccountByIdStore } from '@hcengineering/contact-resources'
import { AssigneeCategory } from '@hcengineering/contact-resources/src/assignee'
import { Doc, DocumentQuery, Ref } from '@hcengineering/core'
import { Account, Doc, DocumentQuery, Ref, Space } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Issue } from '@hcengineering/tracker'
import { Component, Issue } from '@hcengineering/tracker'
import { ButtonKind, ButtonSize, IconSize, TooltipAlignment } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { get } from 'svelte/store'
import tracker from '../../plugin'
import { getPreviousAssignees } from '../../utils'
import { get } from 'svelte/store'
type Object = (Doc | {}) & Pick<Issue, 'space' | 'component' | 'assignee'>
export let object: Object
export let object: Object | Object[] | undefined = undefined
export let value: Object | Object[] | undefined = undefined
export let kind: ButtonKind = 'link'
export let size: ButtonSize = 'large'
export let avatarSize: IconSize = 'card'
@ -37,57 +38,83 @@
export let short: boolean = false
export let shouldShowName = true
export let shrink: number = 0
export let isAction: boolean = false
$: _object = object ?? value ?? []
const client = getClient()
const dispatch = createEventDispatcher()
const docQuery: DocumentQuery<Employee> = { active: true }
const handleAssigneeChanged = async (newAssignee: Ref<Person> | undefined) => {
if (newAssignee === undefined || object.assignee === newAssignee) {
const handleAssigneeChanged = async (newAssignee: Ref<Person> | undefined | null) => {
if (newAssignee === undefined || (!Array.isArray(_object) && _object.assignee === newAssignee)) {
return
}
dispatch('change', newAssignee)
if ('_class' in object) {
await client.update(object, { assignee: newAssignee })
if (Array.isArray(_object)) {
await Promise.all(
_object.map(async (p) => {
if ('_class' in p) {
await client.update(p, { assignee: newAssignee })
}
})
)
} else {
if ('_class' in _object) {
await client.update(_object as any, { assignee: newAssignee })
}
}
dispatch('change', newAssignee)
if (isAction) dispatch('close')
}
let categories: AssigneeCategory[] = []
function getCategories (object: Object): void {
function getCategories (object: Object | Object[]): void {
categories = []
if ('_class' in object) {
const _id = object._id
const docs = Array.isArray(object) ? object : [object]
const cdocs = docs.filter((d) => '_class' in d) as Doc[]
if (cdocs.length > 0) {
categories.push({
label: tracker.string.PreviousAssigned,
func: async () => await getPreviousAssignees(_id)
func: async () => {
const r: Ref<Person>[] = []
for (const d of cdocs) {
r.push(...(await getPreviousAssignees(d._id)))
}
return r
}
})
}
categories.push({
label: tracker.string.ComponentLead,
func: async () => {
if (!object.component) {
const components = Array.from(docs.map((it) => it.component).filter((it) => it)) as Ref<Component>[]
if (components.length === 0) {
return []
}
const component = await client.findOne(tracker.class.Component, { _id: object.component })
return component?.lead ? [component.lead] : []
const component = await client.findAll(tracker.class.Component, { _id: { $in: components } })
return component.map((it) => it.lead).filter((it) => it) as Ref<Person>[]
}
})
categories.push({
label: tracker.string.Members,
func: async () => {
if (!object.space) {
const spaces = Array.from(docs.map((it) => it.space).filter((it) => it)) as Ref<Space>[]
if (spaces.length === 0) {
return []
}
const project = await client.findOne(tracker.class.Project, { _id: object.space })
if (project === undefined) {
const projects = await client.findAll(tracker.class.Project, {
_id: !Array.isArray(object) ? object.space : { $in: Array.from(object.map((it) => it.space)) }
})
if (projects === undefined) {
return []
}
const store = get(personAccountByIdStore)
const accounts = project.members
const allMembers = projects.reduce((arr, p) => arr.concat(p.members), [] as Ref<Account>[])
const accounts = allMembers
.map((p) => store.get(p as Ref<PersonAccount>))
.filter((p) => p !== undefined) as PersonAccount[]
return accounts.map((p) => p.person as Ref<Employee>)
@ -95,33 +122,59 @@
})
}
$: getCategories(object)
$: getCategories(_object)
$: sel =
(!Array.isArray(_object)
? _object.assignee
: _object.reduce((v, it) => (v != null && v === it.assignee ? it.assignee : null), _object[0]?.assignee) ??
undefined) ?? undefined
</script>
{#if object}
<AssigneeBox
{docQuery}
{focusIndex}
label={tracker.string.Assignee}
placeholder={tracker.string.Assignee}
value={object.assignee}
{categories}
titleDeselect={tracker.string.Unassigned}
{size}
{kind}
{avatarSize}
{width}
{short}
{shrink}
{shouldShowName}
showNavigate={false}
justify={'left'}
showTooltip={{
label: tracker.string.AssignTo,
personLabel: tracker.string.AssignedTo,
placeholderLabel: tracker.string.Unassigned,
direction: tooltipAlignment
}}
on:change={({ detail }) => handleAssigneeChanged(detail)}
/>
{#if _object}
{#if isAction}
<AssigneePopup
{docQuery}
{categories}
icon={contact.icon.Person}
selected={sel}
allowDeselect={true}
titleDeselect={undefined}
on:close={(evt) => {
const result = evt.detail
if (result === null) {
handleAssigneeChanged(null)
} else if (result !== undefined && result._id !== value) {
value = result._id
handleAssigneeChanged(result._id)
}
}}
/>
{:else}
<AssigneeBox
{docQuery}
{focusIndex}
label={tracker.string.Assignee}
placeholder={tracker.string.Assignee}
value={sel}
{categories}
titleDeselect={tracker.string.Unassigned}
{size}
{kind}
{avatarSize}
{width}
{short}
{shrink}
{shouldShowName}
showNavigate={false}
justify={'left'}
showTooltip={{
label: tracker.string.AssignTo,
personLabel: tracker.string.AssignedTo,
placeholderLabel: tracker.string.Unassigned,
direction: tooltipAlignment
}}
on:change={({ detail }) => handleAssigneeChanged(detail)}
/>
{/if}
{/if}

View File

@ -15,8 +15,10 @@
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { Asset } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import type { Issue, Project } from '@hcengineering/tracker'
import { AnySvelteComponent, Icon, tooltip } from '@hcengineering/ui'
import { AnySvelteComponent, Component, Icon, tooltip } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { DocNavLink } from '@hcengineering/view-resources'
import tracker from '../../plugin'
import { activeProjects } from '../../utils'
@ -37,34 +39,47 @@
}
$: title = currentProject ? `${currentProject.identifier}-${value?.number}` : `${value?.number}`
$: presenters =
value !== undefined ? getClient().getHierarchy().findMixinMixins(value, view.mixin.ObjectPresenter) : []
</script>
{#if value}
<DocNavLink
object={value}
{onClick}
{disabled}
{noUnderline}
{inline}
component={tracker.component.EditIssue}
shrink={0}
>
{#if inline}
<span class="antiMention" use:tooltip={{ label: tracker.string.Issue }}>@{title}</span>
{:else}
<span class="issuePresenterRoot" class:list={kind === 'list'} class:cursor-pointer={!disabled}>
{#if shouldShowAvatar}
<div class="icon" use:tooltip={{ label: tracker.string.Issue }}>
<Icon icon={icon ?? tracker.icon.Issues} size={'small'} />
</div>
{/if}
<span class="overflow-label select-text" title={value?.title}>
{title}
<slot name="details" />
<div class="flex-row-center flex-between">
<DocNavLink
object={value}
{onClick}
{disabled}
{noUnderline}
{inline}
component={tracker.component.EditIssue}
shrink={0}
>
{#if inline}
<span class="antiMention" use:tooltip={{ label: tracker.string.Issue }}>@{title}</span>
{:else}
<span class="issuePresenterRoot" class:list={kind === 'list'} class:cursor-pointer={!disabled}>
{#if shouldShowAvatar}
<div class="icon" use:tooltip={{ label: tracker.string.Issue }}>
<Icon icon={icon ?? tracker.icon.Issues} size={'small'} />
</div>
{/if}
<span class="overflow-label select-text" title={value?.title}>
{title}
<slot name="details" />
</span>
</span>
</span>
{/if}
</DocNavLink>
{#if presenters.length > 0}
<div class="flex-row-center">
{#each presenters as mixinPresenter}
{mixinPresenter.presenter}
<Component is={mixinPresenter.presenter} props={{ value }} />
{/each}
</div>
{/if}
</DocNavLink>
</div>
{/if}
<style lang="scss">

View File

@ -102,11 +102,9 @@
return
}
const milestoneInfo = milestones
showPopup(
SelectPopup,
{ value: milestoneInfo, placeholder: popupPlaceholder, searchable: true },
{ value: milestones, placeholder: popupPlaceholder, searchable: true },
eventToHTMLElement(event),
onChange
)

View File

@ -23,7 +23,7 @@
import { createEventDispatcher } from 'svelte'
import { activeComponent, activeMilestone } from '../../issues'
import tracker from '../../plugin'
import ComponentSelector from '../ComponentSelector.svelte'
import ComponentSelector from '../components/ComponentSelector.svelte'
import AssigneeEditor from '../issues/AssigneeEditor.svelte'
import PriorityEditor from '../issues/PriorityEditor.svelte'
import MilestoneSelector from '../milestones/MilestoneSelector.svelte'

View File

@ -104,7 +104,7 @@ import TimeSpendReport from './components/issues/timereport/TimeSpendReport.svel
import RelatedIssues from './components/issues/related/RelatedIssues.svelte'
import RelatedIssueTemplates from './components/issues/related/RelatedIssueTemplates.svelte'
import ComponentSelector from './components/ComponentSelector.svelte'
import ComponentSelector from './components/components/ComponentSelector.svelte'
import IssueTemplatePresenter from './components/templates/IssueTemplatePresenter.svelte'
import IssueTemplates from './components/templates/IssueTemplates.svelte'

View File

@ -19,11 +19,9 @@ import {
Attribute,
Class,
Doc,
DocData,
DocManager,
IdMap,
Markup,
Mixin,
Ref,
RelatedDocument,
Space,
@ -78,33 +76,6 @@ export interface RelatedIssueTarget extends Doc {
rule: RelatedClassRule | RelatedSpaceRule
}
/**
* @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
*/
@ -520,9 +491,6 @@ export default plugin(trackerId, {
IssueAssigneedToYou: '' as IntlString,
RelatedIssues: '' as IntlString
},
mixin: {
ProjectIssueTargetOptions: '' as Ref<Mixin<ProjectIssueTargetOptions>>
},
extensions: {
IssueListHeader: '' as ComponentExtensionId,
EditIssueHeader: '' as ComponentExtensionId

View File

@ -416,14 +416,15 @@ function AttributeSelector (
values?: Array<{ icon?: Asset, label: IntlString, id: number | string }>
isAction?: boolean
valueKey?: string
}
): void {
const client = getClient()
const hierarchy = client.getHierarchy()
const docArray = Array.isArray(doc) ? doc : [doc]
const attribute = hierarchy.getAttribute(docArray[0]._class, props.attribute)
showPopup(props.actionPopup, { ...props, value: docArray, width: 'large' }, 'top', (result) => {
console.log(result)
showPopup(props.actionPopup, { ...props, [props.valueKey ?? 'value']: docArray, width: 'large' }, 'top', (result) => {
if (result != null) {
for (const docEl of docArray) {
void updateAttribute(client, docEl, docEl._class, { key: props.attribute, attr: attribute }, result)

View File

@ -16,16 +16,17 @@
import core, {
AccountRole,
Doc,
getCurrentAccount,
WithLookup,
Class,
Client,
matchQuery,
Ref
Doc,
Ref,
WithLookup,
getCurrentAccount,
matchQuery
} from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { Action, ActionGroup, ViewAction, ViewActionInput, ViewContextType } from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { Action, ActionGroup, ActionIgnore, ViewAction, ViewActionInput, ViewContextType } from '@hcengineering/view'
import view from './plugin'
import { FocusSelection, SelectionStore } from './selection'
@ -126,6 +127,21 @@ export async function getContextActions (
return result
}
function getIgnoreActions (ignoreActions: Array<Ref<Action> | ActionIgnore>, doc: Doc): Array<Ref<Action>> {
const ignore: Array<Ref<Action>> = []
const h = getClient().getHierarchy()
for (const a of ignoreActions) {
if (typeof a === 'string') {
ignore.push(a)
} else {
if (matchQuery([doc], a.query, a._class, h).length === 1) {
ignore.push(a.action)
}
}
}
return ignore
}
/**
* @public
*/
@ -140,7 +156,7 @@ 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>> = Array.from(ignoreActions?.actions ?? [])
const ignore: Array<Ref<Action>> = getIgnoreActions(ignoreActions?.actions ?? [], doc)
// Collect ignores from parent
const ancestors = hierarchy.getAncestors(clazz._id)
@ -148,14 +164,14 @@ export function filterActions (
for (const cl of ancestors) {
const ignoreActions = hierarchy.as(hierarchy.getClassOrInterface(cl), view.mixin.IgnoreActions)
if (ignoreActions?.actions !== undefined) {
ignore.push(...ignoreActions.actions)
ignore.push(...getIgnoreActions(ignoreActions.actions, doc))
}
}
for (const cl of hierarchy.getDescendants(clazz._id)) {
if (hierarchy.isMixin(cl) && hierarchy.hasMixin(doc, cl)) {
const ignoreActions = hierarchy.as(hierarchy.getClassOrInterface(cl), view.mixin.IgnoreActions)
if (ignoreActions?.actions !== undefined) {
ignore.push(...ignoreActions.actions)
ignore.push(...getIgnoreActions(ignoreActions.actions, doc))
}
}
}

View File

@ -13,9 +13,10 @@
// limitations under the License.
-->
<script lang="ts">
import type { IntlString } from '@hcengineering/platform'
import type { ButtonSize, ButtonKind } from '@hcengineering/ui'
import { Label, showPopup, eventToHTMLElement, Button, parseURL } from '@hcengineering/ui'
import type { Asset, IntlString } from '@hcengineering/platform'
import type { AnySvelteComponent, ButtonKind, ButtonSize, IconProps } from '@hcengineering/ui'
import { Button, Label, eventToHTMLElement, parseURL, showPopup } from '@hcengineering/ui'
import { ComponentType } from 'svelte'
import HyperlinkEditorPopup from './HyperlinkEditorPopup.svelte'
export let placeholder: IntlString
@ -27,6 +28,8 @@
export let justify: 'left' | 'center' = 'center'
export let width: string | undefined = 'fit-content'
export let title: string | undefined
export let icon: Asset | AnySvelteComponent | ComponentType | undefined = undefined
export let iconProps: IconProps = {}
let shown: boolean = false
</script>
@ -36,6 +39,8 @@
{size}
{justify}
{width}
{icon}
{iconProps}
on:click={(ev) => {
if (!shown) {
showPopup(HyperlinkEditorPopup, { value, editable: !readonly }, eventToHTMLElement(ev), (res) => {

View File

@ -15,8 +15,8 @@
<script lang="ts">
import type { IntlString } from '@hcengineering/platform'
import { translate } from '@hcengineering/platform'
import { themeStore, Label } from '@hcengineering/ui'
import { Button, IconArrowRight, IconBlueCheck, IconClose } from '@hcengineering/ui'
import { copyTextToClipboard } from '@hcengineering/presentation'
import { Button, IconArrowRight, IconBlueCheck, IconClose, IconCopy, Label, themeStore } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import view from '../plugin'
@ -32,6 +32,9 @@
onMount(() => {
if (input) input.focus()
})
const copyLink = (): void => {
copyTextToClipboard(value)
}
</script>
<div class="editor-container buttons-group xsmall-gap">
@ -88,6 +91,16 @@
focusIndex={4}
kind={'ghost'}
size={'small'}
icon={IconCopy}
showTooltip={{ label: view.string.CopyToClipboard }}
on:click={() => {
copyLink()
}}
/>
<Button
focusIndex={5}
kind={'ghost'}
size={'small'}
icon={IconArrowRight}
showTooltip={{ label: view.string.Open }}
on:click={() => {

View File

@ -42,6 +42,7 @@
export let label: IntlString
export let icon: Asset | AnySvelteComponent | undefined = undefined
export let placeholder: IntlString = presentation.string.Search
export let placeholderIcon: Asset | undefined = undefined
export let value: Ref<Doc> | null | undefined
export let allowDeselect = false
export let titleDeselect: IntlString | undefined = undefined
@ -56,6 +57,7 @@
export let id: string | undefined = undefined
export let searchField: string = 'name'
export let docProps: Record<string, any> = {}
export let shouldShowAvatar = false
export let create: ObjectCreate | undefined = undefined
@ -107,8 +109,6 @@
)
}
}
$: hideIcon = size === 'x-large' || (size === 'large' && kind !== 'link')
</script>
<div {id} bind:this={container} class="min-w-0" class:w-full={width === '100%'} class:h-full={$$slots.content}>
@ -121,7 +121,7 @@
<Button
{focusIndex}
width={width ?? 'min-content'}
{icon}
icon={icon ?? value === undefined ? placeholderIcon : undefined}
iconProps={{ size: kind === 'link' || kind === 'regular' ? 'small' : size }}
{size}
{kind}
@ -142,7 +142,7 @@
objectId={selected._id}
_class={selected._class}
value={selected}
props={{ ...docProps, disabled: true, noUnderline: true, size: 'x-small', shouldShowAvatar: false }}
props={{ ...docProps, disabled: true, noUnderline: true, size: 'x-small', shouldShowAvatar }}
/>
{:else}
<Label {label} />

View File

@ -76,6 +76,7 @@
...getProjection(viewOptions.groupBy, queryNoLookup)
}
}
$: docsQuery.query(
_class,
queryNoLookup,

View File

@ -98,16 +98,18 @@
const autoFoldLimit = 20
const defaultLimit = 20
const singleCategoryLimit = 50
let loading = false
$: initialLimit = !lastLevel ? undefined : singleCat ? singleCategoryLimit : defaultLimit
$: limit = initialLimit
$: if (lastLevel) {
limiter.add(async () => {
docsQuery.query(
loading = docsQuery.query(
_class,
{ ...resultQuery, ...docKeys },
(res) => {
items = res
loading = false
},
{ ...resultOptions, limit: limit ?? 200 }
)
@ -159,25 +161,7 @@
showPopup(Menu, { object: items, baseMenuClass }, getEventPositionElement(event))
}
let limited: Doc[] = []
let loading = false
let loadingTimeout: any | undefined = undefined
function update (items: Doc[], limit: number | undefined, index: number): void {
clearTimeout(loadingTimeout)
if (limited.length > 0 || index * 2 === 0) {
limited = limitGroup(items, limit)
} else {
loading = true
loadingTimeout = setTimeout(() => {
limited = limitGroup(items, limit)
loading = false
}, index * 2)
}
}
$: update(items, limit, index)
$: limited = limitGroup(items, limit)
$: selectedObjectIdsSet = new Set<Ref<Doc>>(selectedObjectIds.map((it) => it._id))
@ -445,6 +429,7 @@
{props}
{lastCat}
{viewOptions}
{loading}
on:more={() => {
if (limit !== undefined) limit += 20
}}

View File

@ -28,6 +28,7 @@
IconCollapseArrow,
IconMoreH,
Label,
Loading,
defaultBackground,
eventToHTMLElement,
showPopup,
@ -60,6 +61,7 @@
export let newObjectProps: (doc: Doc | undefined) => Record<string, any> | undefined
export let viewOptions: ViewOptions
export let loading: boolean = false
const dispatch = createEventDispatcher()
@ -143,35 +145,41 @@
/>
{/if}
{#if selected.length > 0}
<span class="antiSection-header__counter ml-2">
<span class="caption-color">
({selected.length})
</span>
</span>
{/if}
{#if limited < itemsProj.length}
<div class="antiSection-header__counter flex-row-center mx-2">
<span class="caption-color">{limited}</span>
<span class="text-xs mx-0-5">/</span>
{itemsProj.length}
{#if loading}
<div class="p-1">
<Loading shrink size={'small'} />
</div>
<ActionIcon
size={'small'}
icon={IconMoreH}
label={ui.string.ShowMore}
action={() => {
dispatch('more')
}}
/>
{:else}
<span class="antiSection-header__counter ml-2">{itemsProj.length}</span>
{#if selected.length > 0}
<span class="antiSection-header__counter ml-2">
<span class="caption-color">
({selected.length})
</span>
</span>
{/if}
{#if limited < itemsProj.length}
<div class="antiSection-header__counter flex-row-center mx-2">
<span class="caption-color">{limited}</span>
<span class="text-xs mx-0-5">/</span>
{itemsProj.length}
</div>
<ActionIcon
size={'small'}
icon={IconMoreH}
label={ui.string.ShowMore}
action={() => {
dispatch('more')
}}
/>
{:else}
<span class="antiSection-header__counter ml-2">{itemsProj.length}</span>
{/if}
<div class="flex-row-center flex-reverse flex-grow mr-2 gap-2 reverse">
{#each extraHeaders ?? [] as extra}
<Component is={extra} props={{ ...props, value: category, category: groupByKey, docs: items }} />
{/each}
</div>
{/if}
<div class="flex-row-center flex-reverse flex-grow mr-2 gap-2 reverse">
{#each extraHeaders ?? [] as extra}
<Component is={extra} props={{ ...props, value: category, category: groupByKey, docs: items }} />
{/each}
</div>
</div>
{#if createItemDialog !== undefined && createItemLabel !== undefined}
<div class:on-hover={!mouseOver} class="flex-row-center">

View File

@ -47,7 +47,8 @@ import {
getPanelURI,
getPlatformColorForText,
locationToUrl,
navigate
navigate,
resolvedLocationStore
} from '@hcengineering/ui'
import type { BuildModelOptions, Viewlet, ViewletDescriptor } from '@hcengineering/view'
import view, { AttributeModel, BuildModelKey } from '@hcengineering/view'
@ -562,6 +563,10 @@ export type FixedWidthStore = Record<string, number>
export const fixedWidthStore = writable<FixedWidthStore>({})
resolvedLocationStore.subscribe(() => {
fixedWidthStore.set({})
})
export function groupBy<T extends Doc> (docs: T[], key: string, categories?: CategoryType[]): Record<any, T[]> {
return docs.reduce((storage: { [key: string]: T[] }, item: T) => {
let group = getObjectValue(key, item) ?? undefined

View File

@ -520,11 +520,22 @@ export interface ViewContext {
group?: ActionGroup
}
/**
* @public
*/
export interface ActionIgnore {
_class: Ref<Class<Doc>>
// Action to be ignored
action: Ref<Action>
// Document match to ignore if matching at least one document.
query: DocumentQuery<Doc>
}
/**
* @public
*/
export interface IgnoreActions extends Class<Doc> {
actions: Ref<Action>[]
actions: (Ref<Action> | ActionIgnore)[]
}
/**
@ -976,6 +987,9 @@ const view = plugin(viewId, {
// Or list of values to select from
values?: { icon?: Asset, label: IntlString, id: number | string }[]
// If defined, documents will be set into value
valueKey?: string
}>
}
})

View File

@ -406,7 +406,7 @@ export async function confirm (db: Db, productId: string, token: string): Promis
async function sendConfirmation (productId: string, account: Account): Promise<void> {
const sesURL = getMetadata(accountPlugin.metadata.SES_URL)
if (sesURL === undefined || sesURL === '') {
throw new Error('Please provide email service url')
console.info('Please provide email service url to enable email confirmations.')
}
const front = getMetadata(accountPlugin.metadata.FrontURL)
if (front === undefined || front === '') {
@ -443,19 +443,21 @@ async function sendConfirmation (productId: string, account: Account): Promise<v
subject = 'Confirm your email address to sign up for ezQMS'
}
const to = account.email
await fetch(concatLink(sesURL, '/send'), {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text,
html,
subject,
to
if (sesURL !== undefined) {
const to = account.email
await fetch(concatLink(sesURL, '/send'), {
method: 'post',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text,
html,
subject,
to
})
})
})
}
}
/**

View File

@ -274,7 +274,13 @@ export async function cloneWorkspace (
docs = await sourceConnection.loadDocs(c, needRetrieve)
if (clearTime) {
docs = docs.map((p) => {
if (sourceConnection.getHierarchy().isDerived(p._class, core.class.TxCollectionCUD)) {
let collectionCud = false
try {
collectionCud = sourceConnection.getHierarchy().isDerived(p._class, core.class.TxCollectionCUD)
} catch (err: any) {
console.log(err)
}
if (collectionCud) {
return {
...p,
createdBy: core.account.System,

View File

@ -141,7 +141,7 @@ abstract class MongoAdapterBase implements DbAdapter {
if (!classes.includes(translated._class)) {
translated._class = { $in: classes }
}
} else if (typeof translated._class === 'object') {
} else if (typeof translated._class === 'object' && translated._class !== null) {
let descendants: Ref<Class<Doc>>[] = classes
if (Array.isArray(translated._class.$in)) {
@ -149,7 +149,7 @@ abstract class MongoAdapterBase implements DbAdapter {
descendants = translated._class.$in.filter((c: Ref<Class<Doc>>) => classesIds.has(c))
}
if (Array.isArray(translated._class.$nin)) {
if (translated._class != null && Array.isArray(translated._class.$nin)) {
const excludedClassesIds = new Set<Ref<Class<Doc>>>(translated._class.$nin)
descendants = descendants.filter((c) => !excludedClassesIds.has(c))
}

View File

@ -209,10 +209,11 @@ export async function upgradeModel (
} catch (err: any) {}
}
const migrateClient = new MigrateClientImpl(db, hierarchy, modelDb)
const migrateClient = new MigrateClientImpl(db, hierarchy, modelDb, logger)
for (const op of migrateOperations) {
logger.log(`${workspaceId.name}: migrate:`, op[0])
const t = Date.now()
await op[1].migrate(migrateClient, logger)
logger.log(`${workspaceId.name}: migrate:`, op[0], Date.now() - t)
}
logger.log(`${workspaceId.name}: Apply upgrade operations`)
@ -223,8 +224,9 @@ export async function upgradeModel (
await createUpdateIndexes(connection, db, logger)
for (const op of migrateOperations) {
logger.log(`${workspaceId.name}: upgrade:`, op[0])
const t = Date.now()
await op[1].upgrade(connection, logger)
logger.log(`${workspaceId.name}: upgrade:`, op[0], Date.now() - t)
}
await connection.close()
@ -279,7 +281,7 @@ async function createUpdateIndexes (connection: CoreClient, db: Db, logger: Mode
bb.push(vv)
}
if (bb.length > 0) {
logger.log('created indexes', d, bb)
logger.log('created indexes', d, JSON.stringify(bb))
}
}
}

View File

@ -9,14 +9,14 @@ import {
Ref,
SortingOrder
} from '@hcengineering/core'
import { MigrateUpdate, MigrationClient, MigrationResult } from '@hcengineering/model'
import { MigrateUpdate, MigrationClient, MigrationResult, ModelLogger } from '@hcengineering/model'
import { Db, Document, Filter, Sort, UpdateFilter } from 'mongodb'
/**
* Upgrade client implementation.
*/
export class MigrateClientImpl implements MigrationClient {
constructor (readonly db: Db, readonly hierarchy: Hierarchy, readonly model: ModelDb) {}
constructor (readonly db: Db, readonly hierarchy: Hierarchy, readonly model: ModelDb, readonly logger: ModelLogger) {}
private translateQuery<T extends Doc>(query: DocumentQuery<T>): Filter<Document> {
const translated: any = {}
@ -65,15 +65,22 @@ export class MigrateClientImpl implements MigrationClient {
query: DocumentQuery<T>,
operations: MigrateUpdate<T>
): Promise<MigrationResult> {
if (isOperator(operations)) {
const result = await this.db
.collection(domain)
.updateMany(this.translateQuery(query), { ...operations } as unknown as UpdateFilter<Document>)
const t = Date.now()
try {
if (isOperator(operations)) {
const result = await this.db
.collection(domain)
.updateMany(this.translateQuery(query), { ...operations } as unknown as UpdateFilter<Document>)
return { matched: result.matchedCount, updated: result.modifiedCount }
} else {
const result = await this.db.collection(domain).updateMany(this.translateQuery(query), { $set: operations })
return { matched: result.matchedCount, updated: result.modifiedCount }
return { matched: result.matchedCount, updated: result.modifiedCount }
} else {
const result = await this.db.collection(domain).updateMany(this.translateQuery(query), { $set: operations })
return { matched: result.matchedCount, updated: result.modifiedCount }
}
} finally {
if (Date.now() - t > 1000) {
this.logger.log(`update${Date.now() - t > 5000 ? 'slow' : ''}`, domain, query, Date.now() - t)
}
}
}
@ -98,6 +105,7 @@ export class MigrateClientImpl implements MigrationClient {
query: DocumentQuery<T>,
targetDomain: Domain
): Promise<MigrationResult> {
this.logger.log('move', sourceDomain, query)
const q = this.translateQuery(query)
const cursor = this.db.collection(sourceDomain).find<T>(q)
const target = this.db.collection(targetDomain)

View File

@ -90,7 +90,8 @@ services:
- SERVER_SECRET=secret
- ELASTIC_URL=http://elastic:9200
- MONGO_URL=mongodb://mongodb:27018
- METRICS_CONSOLE=true
- METRICS_CONSOLE=false
- METRICS_FILE=metrics.txt
- MINIO_ENDPOINT=minio
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin