Merge branch 'staging' into develop

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2025-02-17 11:02:01 +07:00
commit 329d1f175b
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
35 changed files with 728 additions and 585 deletions

View File

@ -13,60 +13,60 @@
// limitations under the License.
//
import documentsPlugin, {
documentsId,
type Document,
type DocumentSpace,
DocumentState
} from '@hcengineering/controlled-documents'
import activity from '@hcengineering/activity'
import contact from '@hcengineering/contact'
import documentsPlugin, {
documentsId,
DocumentState,
type Document,
type DocumentSpace
} from '@hcengineering/controlled-documents'
import { type Builder } from '@hcengineering/model'
import chunter from '@hcengineering/model-chunter'
import core from '@hcengineering/model-core'
import { generateClassNotificationTypes } from '@hcengineering/model-notification'
import presentation from '@hcengineering/model-presentation'
import print from '@hcengineering/model-print'
import request from '@hcengineering/model-request'
import tracker from '@hcengineering/model-tracker'
import { generateClassNotificationTypes } from '@hcengineering/model-notification'
import view, { classPresenter, createAction } from '@hcengineering/model-view'
import presentation from '@hcengineering/model-presentation'
import workbench from '@hcengineering/model-workbench'
import notification from '@hcengineering/notification'
import setting from '@hcengineering/setting'
import tags from '@hcengineering/tags'
import print from '@hcengineering/model-print'
import textEditor from '@hcengineering/text-editor'
import documents from './plugin'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { type Action } from '@hcengineering/view'
import { definePermissions } from './permissions'
import documents from './plugin'
import { defineSpaceType } from './spaceType'
import {
TChangeControl,
TControlledDocument,
TControlledDocumentSnapshot,
TDocument,
TDocumentApprovalRequest,
TDocumentCategory,
TDocumentComment,
TDocumentMeta,
TDocumentRequest,
TDocumentReviewRequest,
TDocumentSnapshot,
TDocumentSpace,
TDocumentSpaceType,
TDocumentSpaceTypeDescriptor,
TExternalSpace,
TOrgSpace,
TDocumentMeta,
TProjectDocument,
TProjectMeta,
TProject,
TDocument,
TDocumentSnapshot,
TControlledDocumentSnapshot,
THierarchyDocument,
TDocumentTemplate,
TDocumentTraining,
TDocumentCategory,
TControlledDocument,
TChangeControl,
TDocumentRequest,
TDocumentReviewRequest,
TDocumentApprovalRequest,
TTypeDocumentState,
TExternalSpace,
THierarchyDocument,
TOrgSpace,
TProject,
TProjectDocument,
TProjectMeta,
TTypeControlledDocumentState,
TDocumentComment
TTypeDocumentState
} from './types'
import { defineSpaceType } from './spaceType'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { type Action } from '@hcengineering/view'
export { documentsId } from '@hcengineering/controlled-documents/src/index'
export * from './types'
@ -105,6 +105,10 @@ export function createModel (builder: Builder): void {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.DocumentMeta, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: documents.function.ControlledDocumentTitleProvider
})
builder.mixin(documents.class.DocumentApprovalRequest, core.class.Class, view.mixin.ObjectPresenter, {
presenter: documents.component.DocumentApprovalRequestPresenter
})

View File

@ -594,6 +594,13 @@ export function createModel (builder: Builder): void {
key: 'status',
props: { kind: 'list', size: 'small', shouldShowName: false }
},
{
key: 'kind',
label: task.string.TaskType,
presenter: task.component.TaskTypeListPresenter,
props: { kind: 'list', size: 'small', justify: 'center' },
displayProps: { key: 'applicant_kind' }
},
{
key: '$lookup.attachedTo',
presenter: contact.component.PersonPresenter,

View File

@ -513,11 +513,6 @@ export function createModel (builder: Builder): void {
id: 'automations',
label: setting.string.Automations,
component: task.component.ProjectTypeAutomationsSectionEditor
},
{
id: 'collections',
label: setting.string.Collections,
component: task.component.ProjectTypeCollectionsSectionEditor
}
],
subEditors: {

View File

@ -55,6 +55,7 @@ export default mergeIds(taskId, task, {
TypesView: '' as AnyComponent,
StateIconPresenter: '' as AnyComponent,
TaskTypePresenter: '' as AnyComponent,
TaskTypeListPresenter: '' as AnyComponent,
ProjectTypePresenter: '' as AnyComponent,
TaskTypeClassPresenter: '' as AnyComponent,
ProjectTypeClassPresenter: '' as AnyComponent

View File

@ -741,7 +741,8 @@ function defineSpaceType (builder: Builder): void {
description: tracker.string.Issue,
icon: tracker.icon.Issue,
name: tracker.string.Issue,
statusCategoriesFunc: tracker.function.GetIssueStatusCategories
statusCategoriesFunc: tracker.function.GetIssueStatusCategories,
openTasks: tracker.function.OpenIssuesOfTaskType
},
tracker.descriptors.Issue
)

View File

@ -105,13 +105,13 @@ export function issueConfig (
props: { kind: 'list', size: 'small', justify: 'center' },
displayProps: { key: key + 'status' }
},
// {
// key: 'kind',
// label: task.string.TaskType,
// presenter: task.component.TaskTypePresenter,
// props: { kind: 'list', size: 'small', justify: 'center' },
// displayProps: { key: key + 'kind' }
// },
{
key: 'kind',
label: task.string.TaskType,
presenter: task.component.TaskTypeListPresenter,
props: { kind: 'list', size: 'small', justify: 'center' },
displayProps: { key: key + 'kind' }
},
{
key: '',
label: tracker.string.Title,

View File

@ -21,7 +21,7 @@ describe('dsl', () => {
)
)
expect(jsonToHTML(doc)).toEqual(
'<p>Hello, <span data-type="reference" data-id="123" data-objectclass="world" data-label="World">@World</span></p><p>Check out <a target="_blank" rel="noopener noreferrer" class="cursor-pointer" href="https://example.com"><u>this link</u></a>.</p>'
'<p>Hello, <span data-type="reference" class="antiMention" data-id="123" data-objectclass="world" data-label="World">@World</span></p><p>Check out <a target="_blank" rel="noopener noreferrer" class="cursor-pointer" href="https://example.com"><u>this link</u></a>.</p>'
)
})
})

View File

@ -15,10 +15,18 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { getDataAttribute } from './utils'
import { Class, Doc, Ref } from '@hcengineering/core'
export interface ReferenceNodeProps {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
}
export interface ReferenceOptions {
renderLabel: (props: { options: ReferenceOptions, node: any }) => string
suggestion: { char: string }
renderLabel: (props: { options: ReferenceOptions, props: ReferenceNodeProps }) => string
suggestion: { char?: string }
HTMLAttributes: Record<string, any>
}
/**
@ -28,23 +36,26 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
name: 'reference',
group: 'inline',
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes () {
return {
id: getDataAttribute('id'),
objectclass: getDataAttribute('objectclass'),
label: getDataAttribute('label'),
class: { default: null }
label: getDataAttribute('label')
}
},
addOptions () {
return {
renderLabel ({ options, node }) {
renderLabel ({ options, props }) {
// eslint-disable-next-line
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
return `${options.suggestion.char}${props.label ?? props.id}`
},
suggestion: { char: '@' }
suggestion: { char: '@' },
HTMLAttributes: {}
}
},
@ -72,21 +83,25 @@ export const ReferenceNode = Node.create<ReferenceOptions>({
},
renderHTML ({ node, HTMLAttributes }) {
const options = this.options
return [
'span',
mergeAttributes(
{
'data-type': this.name
'data-type': this.name,
class: 'antiMention'
},
this.options.HTMLAttributes,
HTMLAttributes
),
this.options.renderLabel({ options, node })
this.options.renderLabel({
options: this.options,
props: node.attrs as ReferenceNodeProps
})
]
},
renderText ({ node }) {
const options = this.options
return options.renderLabel({ options, node })
return options.renderLabel({ options, props: node.attrs as ReferenceNodeProps })
}
})

View File

@ -39,6 +39,8 @@
export let labelDirection: TooltipAlignment | undefined = undefined
export let shouldUpdateUndefined: boolean = true
export let minW0 = true
export let focusIndex: number = -1
export let dataId: string | undefined = undefined
let container: HTMLElement
let opened: boolean = false
@ -78,6 +80,8 @@
<div bind:this={container} class:min-w-0={minW0}>
<Button
{focusIndex}
{dataId}
{icon}
{iconProps}
width={width ?? 'min-content'}

View File

@ -90,7 +90,7 @@
const employeeRef = (existingPerson?._id as Ref<Employee>) ?? id
await client.createMixin(id, contact.class.Person, contact.space.Contacts, contact.mixin.Employee, {
active: true
active: false
})
await client.addCollection(
@ -148,7 +148,7 @@
function changeEmail (): void {
const index = channels.findIndex((p) => p.provider === contact.channelProvider.Email)
if (index !== -1) {
channels[index].value = email.trim()
channels[index].value = email.trim().toLowerCase()
} else {
channels.push({
provider: contact.channelProvider.Email,

View File

@ -108,6 +108,7 @@ import {
getAllDocumentStates,
getControlledDocumentTitle,
getDocumentMetaLinkFragment,
getDocumentMetaTitle,
getVisibleFilters,
isFolder,
renameFolder,
@ -456,6 +457,7 @@ export default async (): Promise<Resources> => ({
CanPrintDocument: canPrintDocument,
DocumentIdentifierProvider: documentIdentifierProvider,
ControlledDocumentTitleProvider: getControlledDocumentTitle,
DocumentMetaTitleProvider: getDocumentMetaTitle,
Comment: comment,
IsCommentVisible: isCommentVisible
},

View File

@ -246,6 +246,7 @@ export default mergeIds(documentsId, documents, {
CanOpenDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanPrintDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanTransferDocument: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
ControlledDocumentTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>,
DocumentMetaTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
}
})

View File

@ -16,6 +16,7 @@
import { Ref, SpaceType, WithLookup } from '@hcengineering/core'
import { Icon, Label, IconOpenedArrow } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import setting from '../../plugin'
export let selectedTypeId: Ref<SpaceType> | undefined
export let types: WithLookup<SpaceType>[] = []
@ -31,6 +32,7 @@
{#each types as type}
{@const descriptor = type.$lookup?.descriptor}
{@const dIcon = descriptor?.icon === '' || descriptor?.icon == null ? setting.icon.Setting : descriptor.icon}
<button
class="hulyTaskNavLink-container font-regular-14"
class:selected={type._id === selectedTypeId}
@ -39,12 +41,12 @@
}}
>
<div class="hulyTaskNavLink-avatar">
{#if descriptor?.icon}
<div class="hulyTaskNavLink-icon">
<Icon icon={descriptor?.icon} size="small" fill="currentColor" />
</div>
{#if dIcon}
<Icon icon={dIcon} size="small" fill="currentColor" />
{/if}
</div>
</div>
{#if descriptor}
<div class="hulyTaskNavLink-content">
<span class="hulyTaskNavLink-content__title">{type.name}</span>

View File

@ -28,7 +28,7 @@
TextArea,
Toggle
} from '@hcengineering/ui'
import { deleteObjects } from '@hcengineering/view-resources'
import { deleteObjects, iconsLibrary } from '@hcengineering/view-resources'
import settingRes from '../../../plugin'
@ -117,10 +117,11 @@
</script>
{#if descriptor !== undefined}
{@const dIcon = descriptor.icon === '' ? settingRes.icon.Setting : descriptor.icon}
<div class="hulyComponent-content__column-group">
<div class="hulyComponent-content__header">
<div class="flex gap-1">
<ButtonIcon icon={descriptor.icon} size={'large'} kind={'secondary'} dataId={'btnSelectIcon'} />
<ButtonIcon icon={dIcon} size={'large'} kind={'secondary'} dataId={'btnSelectIcon'} />
<ModernEditbox
kind="ghost"
size="large"

View File

@ -1,31 +0,0 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { ButtonIcon, IconAdd, IconFolder, Label } from '@hcengineering/ui'
import { ProjectType, ProjectTypeDescriptor } from '@hcengineering/task'
import task from '../../plugin'
export let type: ProjectType | undefined
export let descriptor: ProjectTypeDescriptor | undefined
export let disabled: boolean = true
</script>
{#if descriptor !== undefined}
<div class="hulyTableAttr-header font-medium-12">
<IconFolder size="small" />
<span><Label label={task.string.Collections} /></span>
<ButtonIcon kind="primary" icon={IconAdd} size="small" dataId={'btnAdd'} {disabled} on:click={() => {}} />
</div>
{/if}

View File

@ -1,10 +1,13 @@
<script lang="ts">
import { Class, Doc, Ref, toIdMap } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import task, { ProjectType, TaskType } from '@hcengineering/task'
import { ButtonKind, ButtonSize, DropdownLabels } from '@hcengineering/ui'
import { ButtonKind, ButtonSize, DropdownLabelsIntl, type DropdownIntlItem } from '@hcengineering/ui'
import { createEventDispatcher, onDestroy } from 'svelte'
import { selectedTaskTypeStore, taskTypeStore } from '../..'
import TaskTypeListPresenter from './TaskTypeListPresenter.svelte'
import TaskTypeIcon from './TaskTypeIcon.svelte'
export let value: Ref<TaskType> | undefined
export let projectType: Ref<ProjectType> | undefined
@ -28,13 +31,18 @@
(taskTypeDescriptors.get(it.descriptor)?.allowCreate ?? false) &&
(baseClass === undefined || client.getHierarchy().isDerived(it.targetClass, baseClass))
)
.map((it) => ({ id: it._id, label: it.name }))
.map((it) => ({
id: it._id,
label: getEmbeddedLabel(it.name),
icon: TaskTypeIcon,
iconProps: { value: it }
})) as DropdownIntlItem[]
$: if (
(value === undefined && items.length > 0) ||
(items.length > 0 && items.find((it) => it.id === value) === undefined)
) {
value = items[0].id
value = items[0].id as Ref<TaskType>
change()
}
@ -50,16 +58,17 @@
</script>
{#if projectType !== undefined && (items.length > 1 || showAlways)}
<DropdownLabels
<DropdownLabelsIntl
{focusIndex}
{kind}
{size}
{items}
{justify}
{width}
icon={TaskTypeIcon}
iconProps={value !== undefined ? { value: $taskTypeStore.get(value) } : {}}
dataId={'btnSelectTaskType'}
bind:selected={value}
enableSearch={false}
on:selected={change}
/>
{/if}

View File

@ -14,28 +14,31 @@
// limitations under the License.
-->
<script lang="ts">
import { AttributeEditor, createQuery, getClient } from '@hcengineering/presentation'
import task, { ProjectType, TaskType, calculateStatuses, findStatusAttr } from '@hcengineering/task'
import { Ref, SortingOrder, Status } from '@hcengineering/core'
import { Asset, getEmbeddedLabel } from '@hcengineering/platform'
import {
Label,
showPopup,
ButtonIcon,
ModernButton,
IconSquareExpand,
IconAdd,
Icon,
Scroller
} from '@hcengineering/ui'
import { IconPicker, statusStore } from '@hcengineering/view-resources'
import { Asset, getEmbeddedLabel, getResource } from '@hcengineering/platform'
import { AttributeEditor, MessageBox, createQuery, getClient } from '@hcengineering/presentation'
import { ClassAttributes, settingsStore } from '@hcengineering/setting-resources'
import task, { ProjectType, TaskType, calculateStatuses, findStatusAttr } from '@hcengineering/task'
import {
ButtonIcon,
Icon,
IconAdd,
IconDelete,
IconSquareExpand,
Label,
ModernButton,
Scroller,
getCurrentLocation,
navigate,
showPopup
} from '@hcengineering/ui'
import { IconPicker, deleteObjects, statusStore } from '@hcengineering/view-resources'
import { taskTypeStore } from '../..'
import plugin from '../../plugin'
import StatesProjectEditor from '../state/StatesProjectEditor.svelte'
import TaskTypeKindEditor from '../taskTypes/TaskTypeKindEditor.svelte'
import TaskTypeRefEditor from '../taskTypes/TaskTypeRefEditor.svelte'
import TaskTypeIcon from './TaskTypeIcon.svelte'
import plugin from '../../plugin'
export let spaceType: ProjectType
export let objectId: Ref<TaskType>
@ -65,13 +68,15 @@
$: states = (taskType?.statuses.map((p) => $statusStore.byId.get(p)).filter((p) => p !== undefined) as Status[]) ?? []
let tasksCounter: number = 0
let loading: boolean = true
const tasksCounterQuery = createQuery()
$: if (taskType !== undefined) {
tasksCounterQuery.query(
loading = tasksCounterQuery.query(
task.class.Task,
{ kind: taskType._id },
(res) => {
tasksCounter = res.total
loading = false
},
{
total: true,
@ -87,6 +92,7 @@
if (readonly) {
return
}
// TODO: Be aware icon equal to descriptor one will not be shown in lists.
const icons: Asset[] = [descriptor[0].icon]
showPopup(
@ -125,6 +131,41 @@
}
}
}
$: canDelete = !loading && tasksCounter === 0
async function handleDelete (): Promise<void> {
if (!canDelete || readonly || taskType == null) {
return
}
showPopup(MessageBox, {
label: plugin.string.Delete,
message: plugin.string.Delete,
action: async () => {
if (taskType == null) {
return
}
await deleteObjects(client, [taskType])
const loc = getCurrentLocation()
loc.path.length -= 2
navigate(loc)
}
})
}
async function showIssuesOfTaskType (): Promise<void> {
if (taskType == null) return
const descriptor = client
.getModel()
.findAllSync(task.class.TaskTypeDescriptor, { _id: taskType?.descriptor })
.shift()
if (descriptor?.openTasks !== undefined) {
const f = await getResource(descriptor.openTasks)
await f?.(taskType)
}
}
</script>
{#if taskType !== undefined}
@ -157,6 +198,7 @@
/>
{/if}
</div>
<div class="flex-row">
<ModernButton
icon={IconSquareExpand}
label={plugin.string.CountTasks}
@ -165,7 +207,20 @@
kind={'tertiary'}
size={'medium'}
hasMenu
on:click={() => {
showIssuesOfTaskType()
}}
/>
{#if canDelete}
<ButtonIcon
icon={IconDelete}
size="small"
kind="secondary"
disabled={readonly}
on:click={handleDelete}
/>
{/if}
</div>
</div>
<div class="name" class:editable={!readonly}>

View File

@ -0,0 +1,35 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import type { TaskType } from '@hcengineering/task'
import view from '@hcengineering/view'
import { taskTypeStore } from '../../'
import TaskTypeIcon from './TaskTypeIcon.svelte'
import { getClient } from '@hcengineering/presentation'
import task from '@hcengineering/task'
export let value: TaskType | Ref<TaskType> | undefined
$: _value = typeof value === 'string' ? $taskTypeStore.get(value) : value
$: descriptor = getClient().getModel().findAllSync(task.class.TaskTypeDescriptor, { _id: _value?.descriptor }).shift()
$: visible = !(_value?.icon === descriptor?.icon)
</script>
{#if _value !== undefined && visible}
<span class="label flex-row-center gap-1 ml-3">
<TaskTypeIcon value={_value} size={'small'} />
</span>
{/if}

View File

@ -64,10 +64,10 @@ import ProjectTypeClassPresenter from './components/taskTypes/ProjectTypeClassPr
import TaskKindSelector from './components/taskTypes/TaskKindSelector.svelte'
import TaskTypeClassPresenter from './components/taskTypes/TaskTypeClassPresenter.svelte'
import TaskTypePresenter from './components/taskTypes/TaskTypePresenter.svelte'
import TaskTypeListPresenter from './components/taskTypes/TaskTypeListPresenter.svelte'
import CreateProjectType from './components/projectTypes/CreateProjectType.svelte'
import ProjectTypeAutomationsSectionEditor from './components/projectTypes/ProjectTypeAutomationsSectionEditor.svelte'
import ProjectTypeCollectionsSectionEditor from './components/projectTypes/ProjectTypeCollectionsSectionEditor.svelte'
import ProjectTypeGeneralSectionEditor from './components/projectTypes/ProjectTypeGeneralSectionEditor.svelte'
import ProjectTypeSelector from './components/projectTypes/ProjectTypeSelector.svelte'
import ProjectTypeTasksTypeSectionEditor from './components/projectTypes/ProjectTypeTasksTypeSectionEditor.svelte'
@ -124,6 +124,7 @@ export default async (): Promise<Resources> => ({
StateIconPresenter,
StatusFilter,
TaskTypePresenter,
TaskTypeListPresenter,
TaskTypeClassPresenter,
ProjectTypeClassPresenter,
ProjectTypePresenter,
@ -132,7 +133,6 @@ export default async (): Promise<Resources> => ({
ProjectTypeGeneralSectionEditor,
ProjectTypeTasksTypeSectionEditor,
ProjectTypeAutomationsSectionEditor,
ProjectTypeCollectionsSectionEditor,
TaskTypeEditor
},
actionImpl: {

View File

@ -104,7 +104,6 @@ export default mergeIds(taskId, task, {
ProjectTypeGeneralSectionEditor: '' as AnyComponent,
ProjectTypeTasksTypeSectionEditor: '' as AnyComponent,
ProjectTypeAutomationsSectionEditor: '' as AnyComponent,
ProjectTypeCollectionsSectionEditor: '' as AnyComponent,
TaskTypeEditor: '' as AnyComponent
},
function: {

View File

@ -94,6 +94,8 @@ export interface TaskTypeDescriptor extends Doc {
// If specified, will allow to be created by users, system type overwise
allowCreate: boolean
statusCategoriesFunc?: Resource<(project: ProjectType) => Ref<StatusCategory>[]>
openTasks?: Resource<(value: TaskType) => Promise<void>>
}
/**

View File

@ -1,179 +0,0 @@
import { Node, mergeAttributes } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { getDataAttribute } from './utils'
import Suggestion, { type SuggestionOptions } from './components/extension/suggestion'
export interface CompletionOptions {
HTMLAttributes: Record<string, any>
renderLabel: (props: { options: CompletionOptions, node: any }) => string
suggestion: Omit<SuggestionOptions, 'editor'>
showDoc?: (event: MouseEvent, _id: string, _class: string) => void
}
export function clickHandler (opt: CompletionOptions): Plugin {
return new Plugin({
key: new PluginKey('completion-handleClickLink'),
props: {
handleClick: (view, pos, event) => {
if (event.button !== 0) {
return false
}
const link = (event.target as HTMLElement)?.closest('span')
if (link != null) {
const _class = link.getAttribute('data-objectclass')
const _id = link.getAttribute('data-id')
if (_id != null && _class != null) {
opt.showDoc?.(event, _id, _class)
}
}
return false
}
}
})
}
export const Completion = Node.create<CompletionOptions>({
name: 'reference',
addOptions () {
return {
HTMLAttributes: {},
renderLabel ({ options, node }) {
// eslint-disable-next-line
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`
},
suggestion: {
char: '@',
allowSpaces: true,
// pluginKey: CompletionPluginKey,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
if (overrideSpace !== undefined && overrideSpace) {
// eslint-disable-next-line
range.to += 1
}
if (props !== null) {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props
},
{
type: 'text',
text: ' '
}
])
.run()
}
},
allow: ({ editor, range }) => {
if (range.from > editor.state.doc.content.size) return false
const $from = editor.state.doc.resolve(range.from)
const type = editor.schema.nodes[this.name]
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
return !!$from.parent.type.contentMatch.matchType(type)
}
}
}
},
group: 'inline',
inline: true,
selectable: true,
atom: true,
draggable: true,
addAttributes () {
return {
id: getDataAttribute('id'),
label: getDataAttribute('label'),
objectclass: getDataAttribute('objectclass')
}
},
parseHTML () {
return [
{
tag: `span[data-type="${this.name}"]`
}
]
},
renderHTML ({ node, HTMLAttributes }) {
return [
'span',
mergeAttributes(
{
'data-type': this.name,
class: 'antiMention'
},
this.options.HTMLAttributes,
HTMLAttributes
),
this.options.renderLabel({
options: this.options,
node
})
]
},
renderText ({ node }) {
return this.options.renderLabel({
options: this.options,
node
})
},
addKeyboardShortcuts () {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false
const { selection } = state
const { empty, anchor } = selection
if (!empty) {
return false
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true
// eslint-disable-next-line
tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize)
return false
}
})
return isMention
})
}
},
addProseMirrorPlugins () {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
}),
clickHandler(this.options)
]
}
})

View File

@ -66,7 +66,6 @@
TextEditorHandler
} from '@hcengineering/text-editor'
import { EditorKitOptions, getEditorKit } from '../../src/kits/editor-kit'
import { Completion } from '../Completion'
import { deleteAttachment } from '../command/deleteAttachment'
import { textEditorCommandHandler } from '../commands'
import { Provider } from '../provider/types'
@ -84,8 +83,9 @@
import { InlineCommentCollaborationExtension } from './extension/inlineComment'
import { LeftMenuExtension } from './extension/leftMenu'
import { mermaidOptions } from './extension/mermaid'
import { ReferenceExtension, referenceConfig } from './extension/reference'
import { type FileAttachFunction } from './extension/types'
import { completionConfig, inlineCommandsConfig } from './extensions'
import { inlineCommandsConfig } from './extensions'
export let object: Doc
export let attribute: KeyedAttribute
@ -495,8 +495,8 @@
render: renderCursor,
selectionRender: noSelectionRender
}),
Completion.configure({
...completionConfig,
ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -15,23 +15,13 @@
-->
<script lang="ts">
import { SearchResultDoc } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import presentation, {
SearchResult,
getClient,
reduceCalls,
searchFor,
type SearchItem
} from '@hcengineering/presentation'
import presentation, { SearchResult, reduceCalls, searchFor, type SearchItem } from '@hcengineering/presentation'
import { Label, ListView, resizeObserver } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { getReferenceLabel } from './extension/reference'
export let query: string = ''
const client = getClient()
const hierarchy = client.getHierarchy()
let items: SearchItem[] = []
const dispatch = createEventDispatcher()
@ -40,21 +30,11 @@
let scrollContainer: HTMLElement
let selection = 0
async function getIdentifier (item: SearchResultDoc): Promise<string | undefined> {
const identifierProvider = hierarchy.classHierarchyMixin(item.doc._class, view.mixin.ObjectIdentifier)
if (identifierProvider === undefined) {
return item.shortTitle ?? item.title
}
const resource = await getResource(identifierProvider.provider)
return await resource(client, item.id)
}
async function handleSelectItem (item: SearchResultDoc): Promise<void> {
const identifier = await getIdentifier(item)
const label = await getReferenceLabel(item.doc._class, item.doc._id)
dispatch('close', {
id: item.id,
label: identifier,
label,
objectclass: item.doc._class
})
}

View File

@ -16,28 +16,27 @@
import { Markup } from '@hcengineering/core'
import { Asset, IntlString } from '@hcengineering/platform'
import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text'
import textEditor, { RefAction, TextEditorHandler } from '@hcengineering/text-editor'
import {
AnySvelteComponent,
Button,
ButtonKind,
handler,
registerFocus,
deviceOptionsStore as deviceInfo,
checkAdaptiveMatching,
IconClose
deviceOptionsStore as deviceInfo,
handler,
IconClose,
registerFocus
} from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
import { FocusPosition } from '@tiptap/core'
import { EditorView } from '@tiptap/pm/view'
import textEditor, { RefAction, TextEditorHandler } from '@hcengineering/text-editor'
import { createEventDispatcher } from 'svelte'
import { Completion } from '../Completion'
import view from '@hcengineering/view'
import TextEditor from './TextEditor.svelte'
import { defaultRefActions, getModelRefActions } from './editor/actions'
import { completionConfig } from './extensions'
import { EmojiExtension } from './extension/emoji'
import { IsEmptyContentExtension } from './extension/isEmptyContent'
import view from '@hcengineering/view'
import { referenceConfig, ReferenceExtension } from './extension/reference'
import Send from './icons/Send.svelte'
export let content: Markup = EmptyMarkup
@ -141,8 +140,8 @@
focusManager?.setFocus(idx)
}
}
const completionPlugin = Completion.configure({
...completionConfig,
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -2,14 +2,13 @@
import { Markup } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { EmptyMarkup } from '@hcengineering/text'
import textEditor from '@hcengineering/text-editor'
import { ButtonSize, Label } from '@hcengineering/ui'
import { AnyExtension } from '@tiptap/core'
import { createEventDispatcher } from 'svelte'
import textEditor from '@hcengineering/text-editor'
import { referenceConfig, ReferenceExtension } from './extension/reference'
import StyledTextEditor from './StyledTextEditor.svelte'
import { Completion } from '../Completion'
import { completionConfig } from './extensions'
export let label: IntlString | undefined = undefined
export let content: Markup | undefined
@ -53,8 +52,8 @@
}
function configureExtensions (): AnyExtension[] {
const completionPlugin = Completion.configure({
...completionConfig,
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -21,7 +21,6 @@
import type { AnyExtension } from '@tiptap/core'
import { createEventDispatcher } from 'svelte'
import { Completion } from '../Completion'
import StyledTextEditor from './StyledTextEditor.svelte'
import { addTableHandler } from '../utils'
@ -29,8 +28,9 @@
import { FocusExtension } from './extension/focus'
import { ImageUploadExtension } from './extension/imageUploadExt'
import { InlineCommandsExtension } from './extension/inlineCommands'
import { ReferenceExtension, referenceConfig } from './extension/reference'
import { type FileAttachFunction } from './extension/types'
import { completionConfig, InlineCommandId, inlineCommandsConfig } from './extensions'
import { InlineCommandId, inlineCommandsConfig } from './extensions'
export let label: IntlString | undefined = undefined
export let content: Markup
@ -179,8 +179,8 @@
getFileUrl
})
const completionPlugin = Completion.configure({
...completionConfig,
const completionPlugin = ReferenceExtension.configure({
...referenceConfig,
showDoc (event: MouseEvent, _id: string, _class: string) {
dispatch('open-document', { event, _id, _class })
}

View File

@ -13,14 +13,8 @@
// limitations under the License.
//
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import presentation, { getClient } from '@hcengineering/presentation'
import { parseLocation, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import workbench, { type Application } from '@hcengineering/workbench'
import { type Editor, Extension } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { showPopup } from '@hcengineering/ui'
import { Extension } from '@tiptap/core'
import LinkPopup from '../LinkPopup.svelte'
export const LinkUtilsExtension = Extension.create<any>({
@ -48,153 +42,6 @@ export const LinkUtilsExtension = Extension.create<any>({
},
addProseMirrorPlugins () {
return [ResolveReferenceUrlsPlugin(this.editor)]
return []
}
})
export interface LinkToReferencePluginState {
references: Map<string, { id: Ref<Doc>, objectclass: Ref<Class<Doc>>, label: string }>
queue: Set<string>
}
const resolveReferencePluginKey = new PluginKey<LinkToReferencePluginState>('linkToReference')
interface ReferenceProps {
id: Ref<Doc>
objectclass: Ref<Class<Doc>>
label: string
}
export function ResolveReferenceUrlsPlugin (editor: Editor): Plugin<LinkToReferencePluginState> {
return new Plugin<LinkToReferencePluginState>({
key: resolveReferencePluginKey,
appendTransaction: (transactions, oldState, newState) => {
if (transactions[0]?.getMeta('linkToReference') === undefined) return undefined
if (editor.schema.nodes.reference === undefined) return
const references = resolveReferencePluginKey.getState(newState)?.references ?? new Map()
const { tr } = newState
tr.doc.descendants((node, pos) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const mapping = references.get(url)
if (mapping === undefined) return
const replacementNode = editor.schema.nodes.reference.create(mapping)
const mpos = tr.mapping.map(pos)
tr.replaceWith(mpos, mpos + node.nodeSize, replacementNode)
})
if (tr.steps.length > 0) return tr
},
state: {
init () {
return {
references: new Map(),
queue: new Set()
}
},
apply (tr, prev, oldState, newState) {
if (tr.getMeta('linkToReference') !== undefined) {
const references = tr.getMeta('linkToReference').references as LinkToReferencePluginState['references']
const urls = new Set(references.keys())
return {
queue: new Set(Array.from(prev.queue).filter((url) => !urls.has(url))),
references: new Map([...prev.references, ...references])
}
}
if (!tr.docChanged || oldState.doc.eq(newState.doc)) return prev
const urls: string[] = []
tr.doc.descendants((node) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const hasNoMapping = prev.references.has(url) && prev.references.get(url) === undefined
if (prev.queue.has(url) || hasNoMapping) return
urls.push(url)
})
const promises = urls.map(async (url) => {
try {
return [url, await getReferenceFromUrl(url)] as const
} catch {
return [url, undefined] as const
}
})
if (promises.length > 0) {
void Promise.all(promises).then((references) => {
editor.view.dispatch(editor.state.tr.setMeta('linkToReference', { references: new Map(references) }))
})
}
return {
references: prev.references,
queue: new Set([...prev.queue, ...urls])
}
}
}
})
}
async function getReferenceFromUrl (text: string): Promise<ReferenceProps | undefined> {
const client = getClient()
const hierarchy = client.getHierarchy()
const url = new URL(text)
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
if (url.origin !== frontUrl) return
const location = parseLocation(url)
const appAlias = (location.path[2] ?? '').trim()
if (!(appAlias.length > 0)) return
const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const apps: Application[] = client
.getModel()
.findAllSync<Application>(workbench.class.Application, { hidden: false, _id: { $nin: excludedApps } })
const app = apps.find((p) => p.alias === appAlias)
if (app?.locationResolver === undefined) return
const locationResolverFn = await getResource(app.locationResolver)
const resolvedLocation = await locationResolverFn(location)
const locationParts = decodeURIComponent(resolvedLocation?.loc?.fragment ?? '').split('|')
const id = locationParts[1] as Ref<Doc>
const objectclass = locationParts[2] as Ref<Class<Doc>>
if (id === undefined || objectclass === undefined) return
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const linkProvider = linkProviders.find(({ _id }) => hierarchy.isDerived(objectclass, _id))
const _id: Ref<Doc> | undefined =
linkProvider !== undefined ? (await (await getResource(linkProvider.decode))(id)) ?? id : id
let label = ''
const labelProvider = hierarchy.classHierarchyMixin(objectclass, view.mixin.ObjectIdentifier)
if (labelProvider !== undefined) {
const resource = await getResource(labelProvider.provider)
label = await resource(client, _id)
} else {
const titleMixin = hierarchy.classHierarchyMixin(objectclass, view.mixin.ObjectTitle)
if (titleMixin === undefined) return
const titleProviderFn = await getResource(titleMixin.titleProvider)
label = await titleProviderFn(client, _id)
}
return {
id: _id,
objectclass,
label
}
}

View File

@ -0,0 +1,374 @@
import { mergeAttributes, type Editor } from '@tiptap/core'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import MentionList from '../MentionList.svelte'
import { SvelteRenderer } from '../node-view'
import { ReferenceNode, type ReferenceNodeProps, type ReferenceOptions } from '@hcengineering/text'
import Suggestion, { type SuggestionKeyDownProps, type SuggestionOptions, type SuggestionProps } from './suggestion'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getMetadata, getResource } from '@hcengineering/platform'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { parseLocation } from '@hcengineering/ui'
import view from '@hcengineering/view'
import workbench, { type Application } from '@hcengineering/workbench'
export interface ReferenceExtensionOptions extends ReferenceOptions {
suggestion: Omit<SuggestionOptions, 'editor'>
showDoc?: (event: MouseEvent, _id: string, _class: string) => void
}
export const ReferenceExtension = ReferenceNode.extend<ReferenceExtensionOptions>({
addOptions () {
return {
HTMLAttributes: {},
renderLabel ({ options, props }) {
// eslint-disable-next-line
return `${options.suggestion.char}${props.label ?? props.id}`
},
suggestion: {
char: '@',
allowSpaces: true,
command: ({ editor, range, props }) => {
// increase range.to by one when the next node is of type "text"
// and starts with a space character
const nodeAfter = editor.view.state.selection.$to.nodeAfter
const overrideSpace = nodeAfter?.text?.startsWith(' ')
if (overrideSpace !== undefined && overrideSpace) {
// eslint-disable-next-line
range.to += 1
}
if (props !== null) {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: this.name,
attrs: props
},
{
type: 'text',
text: ' '
}
])
.run()
}
},
allow: ({ editor, range }) => {
if (range.from > editor.state.doc.content.size) return false
const $from = editor.state.doc.resolve(range.from)
const type = editor.schema.nodes[this.name]
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
return !!$from.parent.type.contentMatch.matchType(type)
}
}
}
},
addNodeView () {
return ({ node, HTMLAttributes }) => {
const span = document.createElement('span')
span.setAttribute('data-type', this.name)
span.className = 'antimention'
const attributes = mergeAttributes(
{
'data-type': this.name,
class: 'antiMention'
},
this.options.HTMLAttributes,
HTMLAttributes
)
span.addEventListener('click', (event) => {
if (event.button !== 0) return
const link = (event.target as HTMLElement)?.closest('span')
if (link != null) {
const _class = link.getAttribute('data-objectclass')
const _id = link.getAttribute('data-id')
if (_id != null && _class != null) {
this.options.showDoc?.(event, _id, _class)
}
}
})
Object.entries(attributes).forEach(([key, value]) => {
span.setAttribute(key, value)
})
const query = createQuery(true)
const options = this.options
const renderLabel = (props: ReferenceNodeProps): void => {
span.setAttribute('data-label', props.label)
span.innerText = options.renderLabel({ options, props: props ?? node.attrs })
}
const id = node.attrs.id
const objectclass: Ref<Class<Doc>> = node.attrs.objectclass
renderLabel({ id, objectclass, label: node.attrs.label })
if (id !== undefined && objectclass !== undefined) {
query.query(objectclass, { _id: id }, async (result) => {
const obj = result[0]
if (obj === undefined) return
const label = await getReferenceLabel(objectclass, id, obj)
if (label === '') return
renderLabel({ id, objectclass, label })
})
}
return {
dom: span,
update (node, decorations) {
renderLabel({ id, objectclass, label: node.attrs.label })
return true
},
destroy () {
query.unsubscribe()
}
}
}
},
addKeyboardShortcuts () {
return {
Backspace: () =>
this.editor.commands.command(({ tr, state }) => {
let isMention = false
const { selection } = state
const { empty, anchor } = selection
if (!empty) {
return false
}
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
if (node.type.name === this.name) {
isMention = true
// eslint-disable-next-line
tr.insertText(this.options.suggestion.char || '', pos, pos + node.nodeSize)
return false
}
})
return isMention
})
}
},
addProseMirrorPlugins () {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
}),
// ReferenceClickHandler(this.options),
ResolveReferenceUrlsPlugin(this.editor)
]
}
})
/**
* @public
*/
export const referenceConfig: Partial<ReferenceExtensionOptions> = {
HTMLAttributes: {
class: 'reference'
},
suggestion: {
items: async () => {
return []
},
render: () => {
let component: any
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(MentionList, {
element: document.body,
props: {
...props,
close: () => {
component?.destroy()
}
}
})
},
onUpdate (props: SuggestionProps) {
component?.updateProps(props)
},
onKeyDown (props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
props.event.stopPropagation()
}
return component?.onKeyDown(props)
},
onExit () {
component?.destroy()
}
}
}
}
}
export interface ResolveReferenceUrlsPluginState {
references: Map<string, { id: Ref<Doc>, objectclass: Ref<Class<Doc>>, label: string }>
queue: Set<string>
}
const resolveReferencePluginKey = new PluginKey<ResolveReferenceUrlsPluginState>('linkToReference')
export function ResolveReferenceUrlsPlugin (editor: Editor): Plugin<ResolveReferenceUrlsPluginState> {
return new Plugin<ResolveReferenceUrlsPluginState>({
key: resolveReferencePluginKey,
appendTransaction: (transactions, oldState, newState) => {
if (transactions[0]?.getMeta('linkToReference') === undefined) return undefined
if (editor.schema.nodes.reference === undefined) return
const references = resolveReferencePluginKey.getState(newState)?.references ?? new Map()
const { tr } = newState
tr.doc.descendants((node, pos) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const mapping = references.get(url)
if (mapping === undefined) return
const replacementNode = editor.schema.nodes.reference.create(mapping)
const mpos = tr.mapping.map(pos)
tr.replaceWith(mpos, mpos + node.nodeSize, replacementNode)
})
if (tr.steps.length > 0) return tr
},
state: {
init () {
return {
references: new Map(),
queue: new Set()
}
},
apply (tr, prev, oldState, newState) {
if (tr.getMeta('linkToReference') !== undefined) {
const references = tr.getMeta('linkToReference').references as ResolveReferenceUrlsPluginState['references']
const urls = new Set(references.keys())
return {
queue: new Set(Array.from(prev.queue).filter((url) => !urls.has(url))),
references: new Map([...prev.references, ...references])
}
}
if (!tr.docChanged || oldState.doc.eq(newState.doc)) return prev
const urls: string[] = []
tr.doc.descendants((node) => {
if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
const url = node.textContent
const hasNoMapping = prev.references.has(url) && prev.references.get(url) === undefined
if (prev.queue.has(url) || hasNoMapping) return
urls.push(url)
})
const promises = urls.map(async (url) => {
try {
return [url, await getReferenceFromUrl(url)] as const
} catch {
return [url, undefined] as const
}
})
if (promises.length > 0) {
void Promise.all(promises).then((references) => {
editor.view.dispatch(editor.state.tr.setMeta('linkToReference', { references: new Map(references) }))
})
}
return {
references: prev.references,
queue: new Set([...prev.queue, ...urls])
}
}
}
})
}
export async function getReferenceLabel<T extends Doc> (
objectclass: Ref<Class<T>>,
id: Ref<T>,
doc?: T
): Promise<string> {
const client = getClient()
const hierarchy = client.getHierarchy()
const labelProvider = hierarchy.classHierarchyMixin(objectclass as Ref<Class<Doc>>, view.mixin.ObjectIdentifier)
const labelProviderFn = labelProvider !== undefined ? await getResource(labelProvider.provider) : undefined
const titleMixin = hierarchy.classHierarchyMixin(objectclass as Ref<Class<Doc>>, view.mixin.ObjectTitle)
const titleProviderFn = titleMixin !== undefined ? await getResource(titleMixin.titleProvider) : undefined
const identifier = (await labelProviderFn?.(client, id, doc)) ?? ''
const title = (await titleProviderFn?.(client, id, doc)) ?? ''
const label = identifier !== '' && title !== '' && identifier !== title ? `${identifier} ${title}` : title ?? ''
return label
}
export async function getReferenceFromUrl (text: string): Promise<ReferenceNodeProps | undefined> {
const client = getClient()
const hierarchy = client.getHierarchy()
const url = new URL(text)
const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
if (url.origin !== frontUrl) return
const location = parseLocation(url)
const appAlias = (location.path[2] ?? '').trim()
if (!(appAlias.length > 0)) return
const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []
const apps: Application[] = client
.getModel()
.findAllSync<Application>(workbench.class.Application, { hidden: false, _id: { $nin: excludedApps } })
const app = apps.find((p) => p.alias === appAlias)
if (app?.locationResolver === undefined) return
const locationResolverFn = await getResource(app.locationResolver)
const resolvedLocation = await locationResolverFn(location)
const locationParts = decodeURIComponent(resolvedLocation?.loc?.fragment ?? '').split('|')
const id = locationParts[1] as Ref<Doc>
const objectclass = locationParts[2] as Ref<Class<Doc>>
if (id === undefined || objectclass === undefined) return
const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
const linkProvider = linkProviders.find(({ _id }) => hierarchy.isDerived(objectclass, _id))
const _id: Ref<Doc> | undefined =
linkProvider !== undefined ? (await (await getResource(linkProvider.decode))(id)) ?? id : id
const label = await getReferenceLabel(objectclass, id)
if (label === '') return
return {
id: _id,
objectclass,
label
}
}

View File

@ -13,16 +13,15 @@
// limitations under the License.
//
import view from '@hcengineering/view'
import { type Editor, type Range } from '@tiptap/core'
import textEditor from '@hcengineering/text-editor'
import { IconScribble } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { type Editor, type Range } from '@tiptap/core'
import { type CompletionOptions } from '../Completion'
import MentionList from './MentionList.svelte'
import { SvelteRenderer } from './node-view'
import { type InlineCommandsOptions } from './extension/inlineCommands'
import type { SuggestionKeyDownProps, SuggestionProps } from './extension/suggestion'
import InlineCommandsList from './InlineCommandsList.svelte'
import { SvelteRenderer } from './node-view'
export const mInsertTable = [
{
@ -87,49 +86,6 @@ export const mInsertTable = [
}
]
/**
* @public
*/
export const completionConfig: Partial<CompletionOptions> = {
HTMLAttributes: {
class: 'reference'
},
suggestion: {
items: async () => {
return []
},
render: () => {
let component: any
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(MentionList, {
element: document.body,
props: {
...props,
close: () => {
component?.destroy()
}
}
})
},
onUpdate (props: SuggestionProps) {
component?.updateProps(props)
},
onKeyDown (props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
props.event.stopPropagation()
}
return component?.onKeyDown(props)
},
onExit () {
component?.destroy()
}
}
}
}
}
const inlineCommandsIds = [
'image',
'table',
@ -147,7 +103,7 @@ export type InlineCommandId = (typeof inlineCommandsIds)[number]
export function inlineCommandsConfig (
handleSelect: (id: string, pos: number, targetItem?: MouseEvent | HTMLElement) => Promise<void>,
excludedCommands: InlineCommandId[] = []
): Partial<CompletionOptions> {
): Partial<InlineCommandsOptions> {
return {
suggestion: {
items: () => {

View File

@ -32,7 +32,14 @@ import { includesAny } from '@hcengineering/contact'
import { type Resources, type Status, translate } from '@hcengineering/platform'
import { getClient, MessageBox, type ObjectSearchResult } from '@hcengineering/presentation'
import { type Component, type Issue, type Milestone, type Project } from '@hcengineering/tracker'
import { closePanel, getCurrentLocation, navigate, showPopup, themeStore } from '@hcengineering/ui'
import {
closePanel,
getCurrentLocation,
getCurrentResolvedLocation,
navigate,
showPopup,
themeStore
} from '@hcengineering/ui'
import ComponentEditor from './components/components/ComponentEditor.svelte'
import ComponentFilterValuePresenter from './components/components/ComponentFilterValuePresenter.svelte'
import ComponentPresenter from './components/components/ComponentPresenter.svelte'
@ -118,7 +125,13 @@ import ComponentSelector from './components/components/ComponentSelector.svelte'
import IssueTemplatePresenter from './components/templates/IssueTemplatePresenter.svelte'
import IssueTemplates from './components/templates/IssueTemplates.svelte'
import { AggregationManager, deleteObject, deleteObjects } from '@hcengineering/view-resources'
import {
AggregationManager,
buildFilterKey,
deleteObject,
deleteObjects,
setFilters
} from '@hcengineering/view-resources'
import MoveAndDeleteMilestonePopup from './components/milestones/MoveAndDeleteMilestonePopup.svelte'
import EditIssueTemplate from './components/templates/EditIssueTemplate.svelte'
import TemplateEstimationEditor from './components/templates/EstimationEditor.svelte'
@ -160,6 +173,8 @@ import { settingId } from '@hcengineering/setting'
import { getAllStates } from '@hcengineering/task-resources'
import EstimationValueEditor from './components/issues/timereport/EstimationValueEditor.svelte'
import TimePresenter from './components/issues/timereport/TimePresenter.svelte'
import type { TaskType } from '@hcengineering/task'
import view, { type Filter } from '@hcengineering/view'
export { default as AssigneeEditor } from './components/issues/AssigneeEditor.svelte'
export { default as SubIssueList } from './components/issues/edit/SubIssueList.svelte'
@ -346,6 +361,33 @@ function setStore (manager: DocManager<Component>): void {
componentStore.set(manager)
}
async function openIssuesOfTaskType (taskType: TaskType): Promise<void> {
function setFilterTag (taskType: TaskType): void {
const client = getClient()
const hierarchy = client.getHierarchy()
const attribute = hierarchy.getAttribute(tracker.class.Issue, 'kind')
const key = buildFilterKey(hierarchy, tracker.class.Issue, 'kind', attribute)
const filter = {
key,
value: [taskType._id],
props: { level: 0 },
modes: [view.filter.FilterObjectIn, view.filter.FilterObjectNin],
mode: view.filter.FilterObjectIn,
index: 1
} as unknown as Filter
setFilters([filter])
}
if (taskType === undefined) return
const loc = getCurrentResolvedLocation()
loc.path[2] = 'tracker'
loc.path[3] = 'all-issues'
loc.path.length = 4
navigate(loc)
setTimeout(() => {
setFilterTag(taskType)
}, 200)
}
export default async (): Promise<Resources> => ({
activity: {
PriorityIcon,
@ -467,7 +509,8 @@ export default async (): Promise<Resources> => ({
IsProjectJoined: async (project: Project) => includesAny(project.members, getCurrentAccount().socialIds),
GetIssueStatusCategories: getIssueStatusCategories,
SetComponentStore: setStore,
ComponentFilterFunction: filterComponents
ComponentFilterFunction: filterComponents,
OpenIssuesOfTaskType: openIssuesOfTaskType
},
actionImpl: {
Move: move,

View File

@ -16,7 +16,7 @@ import { type StatusCategory, type Client, type Doc, type Ref, type Space } from
import type { Asset, IntlString, Metadata, Resource } from '@hcengineering/platform'
import { mergeIds } from '@hcengineering/platform'
import type { ObjectSearchCategory, ObjectSearchFactory } from '@hcengineering/presentation'
import { type ProjectType } from '@hcengineering/task'
import { type ProjectType, type TaskType } from '@hcengineering/task'
import tracker, { trackerId, type IssueDraft, type Issue } from '@hcengineering/tracker'
import { type AnyComponent, type Location } from '@hcengineering/ui'
import {
@ -398,7 +398,8 @@ export default mergeIds(trackerId, tracker, {
IsProjectJoined: '' as Resource<(space: Space) => Promise<boolean>>,
IssueChatTitleProvider: '' as Resource<(object: Doc) => string>,
GetIssueStatusCategories: '' as Resource<(project: ProjectType) => Array<Ref<StatusCategory>>>,
GetIssueIdByIdentifier: '' as Resource<(id: string) => Promise<Ref<Issue> | undefined>>
GetIssueIdByIdentifier: '' as Resource<(id: string) => Promise<Ref<Issue> | undefined>>,
OpenIssuesOfTaskType: '' as Resource<(taskType: TaskType) => Promise<void>>
},
aggregation: {
CreateComponentAggregationManager: '' as CreateAggregationManagerFunc,

View File

@ -19,7 +19,7 @@
import { AnyComponent, LabelAndProps, themeStore, tooltip } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { getDocIdentifier } from '../utils'
import { getReferenceLabel } from '@hcengineering/text-editor-resources/src/components/extension/reference'
import DocNavLink from './DocNavLink.svelte'
export let _id: Ref<Doc> | undefined = undefined
@ -91,7 +91,7 @@
}
async function updateDocTitle (doc: Doc | undefined): Promise<void> {
docTitle = doc ? await getDocIdentifier(client, doc._id, doc._class, doc) : undefined
docTitle = doc ? await getReferenceLabel(doc._class, doc._id, doc) : undefined
}
async function updateDocTooltip (doc?: Doc): Promise<void> {

View File

@ -83,7 +83,8 @@ test.describe('contact tests', () => {
await contractPage.fillEmailInput(mail)
await contractPage.clickCreateButton()
await contractPage.waitForFormAntiCardDetached()
await contractPage.kickEmployee(first, last)
// employee already inactive
// await contractPage.kickEmployee(first, last)
// In non refactored code, the last assert just checks if the employee does exist, not if it has inactive status
await contractPage.expectKickEmployeeShowsInactiveStatus(first, last)
})

View File

@ -194,7 +194,7 @@ test.describe('Documents tests', () => {
await documentContentPage.checkLinkInTheText(contentLink, 'http://test/link/123456')
})
test('Check Link to Reference conversion', async () => {
test('Check Link to Reference conversion and sync', async () => {
const sourceDocument: NewDocument = {
title: `Reference Document Title-${generateId()}`,
space: 'Default'
@ -204,6 +204,7 @@ test.describe('Documents tests', () => {
space: 'Default'
}
await test.step('Create two documents and reference one from the other', async () => {
await leftSideMenuPage.clickDocuments()
await documentsPage.clickOnButtonCreateDocument()
@ -221,6 +222,25 @@ test.describe('Documents tests', () => {
await documentContentPage.addContentToTheNewLine(targetDocumentUrl)
await documentContentPage.addRandomLines(5)
await documentContentPage.checkReferenceInTheText(targetDocument.title)
await leftSideMenuPage.clickDocuments()
})
await test.step('Rename the document and check sync', async () => {
await leftSideMenuPage.clickDocuments()
const targetDocumentSecondTitle = `Updated Reference Document Title-${generateId()}`
await documentsPage.openDocument(targetDocument.title)
await documentContentPage.checkDocumentTitle(targetDocument.title)
await documentContentPage.updateDocumentTitle(targetDocumentSecondTitle)
await documentContentPage.checkDocumentTitle(targetDocumentSecondTitle)
await documentsPage.openDocument(sourceDocument.title)
await documentContentPage.checkDocumentTitle(sourceDocument.title)
await documentContentPage.checkReferenceInTheText(targetDocumentSecondTitle)
})
})
test('Locked document and checking URL', async ({ page, context }) => {