From d9a11e57d2009ff713b01f9413b02764e198a44a Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Wed, 27 Nov 2024 12:54:04 +0700 Subject: [PATCH 01/10] fix: add document inline editor (#7233) Signed-off-by: Alexander Onnikov --- models/document/src/index.ts | 4 ++ models/document/src/plugin.ts | 1 + .../components/DocumentInlineEditor.svelte | 46 +++++++++++++++++++ plugins/document-resources/src/index.ts | 2 + .../src/components/ObjectBox.svelte | 4 +- 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 plugins/document-resources/src/components/DocumentInlineEditor.svelte diff --git a/models/document/src/index.ts b/models/document/src/index.ts index cacdf4c235..23d8f4d997 100644 --- a/models/document/src/index.ts +++ b/models/document/src/index.ts @@ -312,6 +312,10 @@ function defineDocument (builder: Builder): void { component: document.component.DocumentIcon }) + builder.mixin(document.class.Document, core.class.Class, view.mixin.AttributeEditor, { + inlineEditor: document.component.DocumentInlineEditor + }) + // Actions createAction(builder, { diff --git a/models/document/src/plugin.ts b/models/document/src/plugin.ts index 883cfc54b8..8f22ca8384 100644 --- a/models/document/src/plugin.ts +++ b/models/document/src/plugin.ts @@ -27,6 +27,7 @@ export default mergeIds(documentId, document, { component: { Documents: '' as AnyComponent, DocumentPresenter: '' as AnyComponent, + DocumentInlineEditor: '' as AnyComponent, NotificationDocumentPresenter: '' as AnyComponent, TeamspaceSpacePresenter: '' as AnyComponent, Move: '' as AnyComponent, diff --git a/plugins/document-resources/src/components/DocumentInlineEditor.svelte b/plugins/document-resources/src/components/DocumentInlineEditor.svelte new file mode 100644 index 0000000000..e092bc2dde --- /dev/null +++ b/plugins/document-resources/src/components/DocumentInlineEditor.svelte @@ -0,0 +1,46 @@ + + + + { + onChange(e.detail) + }} + _class={document.class.Document} + label={document.string.Document} + searchField={'title'} + docQuery={{}} + {readonly} + {kind} + {size} + {justify} + {width} +/> diff --git a/plugins/document-resources/src/index.ts b/plugins/document-resources/src/index.ts index b46ea7c610..2db4b0a18c 100644 --- a/plugins/document-resources/src/index.ts +++ b/plugins/document-resources/src/index.ts @@ -32,6 +32,7 @@ import { openDoc } from '@hcengineering/view-resources' import CreateDocument from './components/CreateDocument.svelte' import DocumentIcon from './components/DocumentIcon.svelte' import DocumentItem from './components/DocumentItem.svelte' +import DocumentInlineEditor from './components/DocumentInlineEditor.svelte' import DocumentPresenter from './components/DocumentPresenter.svelte' import DocumentSearchIcon from './components/DocumentSearchIcon.svelte' import DocumentToDoPresenter from './components/DocumentToDoPresenter.svelte' @@ -167,6 +168,7 @@ export default async (): Promise => ({ component: { CreateDocument, CreateTeamspace, + DocumentInlineEditor, DocumentPresenter, Documents, EditDoc, diff --git a/plugins/view-resources/src/components/ObjectBox.svelte b/plugins/view-resources/src/components/ObjectBox.svelte index 47ab463546..158de7dbfc 100644 --- a/plugins/view-resources/src/components/ObjectBox.svelte +++ b/plugins/view-resources/src/components/ObjectBox.svelte @@ -27,13 +27,13 @@ LabelAndProps, getEventPositionElement, getFocusManager, - showPanel, showPopup } from '@hcengineering/ui' import view from '@hcengineering/view' import { createEventDispatcher } from 'svelte' import ObjectBoxPopup from './ObjectBoxPopup.svelte' import ObjectPresenter from './ObjectPresenter.svelte' + import { openDoc } from '../utils' export let _class: Ref> export let excluded: Array> | undefined = undefined @@ -181,7 +181,7 @@ size={'small'} action={() => { if (selected) { - showPanel(view.component.EditDoc, selected._id, Hierarchy.mixinOrClass(selected), 'content') + void openDoc(client.getHierarchy(), selected) } }} /> From 61f7770a02b45b76dac2707e4682a0ab70bc71e1 Mon Sep 17 00:00:00 2001 From: Alexander Platov Date: Wed, 27 Nov 2024 16:03:27 +0300 Subject: [PATCH 02/10] Updated the Test Management layout and adaptability (#7234) Signed-off-by: Alexander Platov --- dev/prod/webpack.config.js | 2 +- models/test-management/src/index.ts | 6 +- packages/theme/styles/_layouts.scss | 2 +- packages/theme/styles/common.scss | 35 ++++-- packages/theme/styles/dialogs.scss | 7 ++ packages/ui/src/components/Scroller.svelte | 13 +++ .../ui}/src/components/icons/MenuClose.svelte | 3 +- .../ui}/src/components/icons/MenuOpen.svelte | 3 +- .../ui/src/components/internal/Root.svelte | 2 +- packages/ui/src/index.ts | 4 +- packages/ui/src/types.ts | 2 +- .../src/components/AttachmentList.svelte | 2 +- .../src/components/AttachmentPreview.svelte | 1 + .../components/CalendarWidgetHeader.svelte | 3 +- .../src/components/chat/Chat.svelte | 1 + .../src/components/Floors.svelte | 1 + .../src/components/inbox/Inbox.svelte | 1 + .../src/components/DOCXViewer.svelte | 8 -- .../src/components/Settings.svelte | 1 + .../src/components/PollQuestion.svelte | 1 - .../components/test-case/EditTestCase.svelte | 11 +- .../test-suite/EditTestSuite.svelte | 11 +- .../src/components/ToDos.svelte | 6 +- .../src/components/ToDosNavigator.svelte | 3 +- .../src/components/team/TeamNavigator.svelte | 15 +-- .../src/components/list/ListItem.svelte | 4 +- .../src/components/ComponentNavigator.svelte | 101 ++++++++++++++---- .../src/components/Workbench.svelte | 45 +++++--- .../src/components/sidebar/Sidebar.svelte | 2 +- .../components/sidebar/SidebarExpanded.svelte | 4 +- plugins/workbench-resources/src/sidebar.ts | 8 +- tests/sanity/tests/chat/chat.spec.ts | 3 +- tests/sanity/tests/model/channel-page.ts | 7 +- tests/sanity/tests/model/common-page.ts | 1 + 34 files changed, 232 insertions(+), 87 deletions(-) rename {plugins/time-resources => packages/ui}/src/components/icons/MenuClose.svelte (96%) rename {plugins/time-resources => packages/ui}/src/components/icons/MenuOpen.svelte (95%) diff --git a/dev/prod/webpack.config.js b/dev/prod/webpack.config.js index c9d9529061..25774344d0 100644 --- a/dev/prod/webpack.config.js +++ b/dev/prod/webpack.config.js @@ -277,7 +277,7 @@ module.exports = [ plugins: [ new HtmlWebpackPlugin({ meta: { - viewport: 'width=device-width, initial-scale=1, maximum-scale=1, shrink-to-fit=1' + viewport: 'width=device-width, initial-scale=1.0' } }), ...(prod ? [new CompressionPlugin()] : []), diff --git a/models/test-management/src/index.ts b/models/test-management/src/index.ts index 43b9e4d93e..45e1a86ed1 100644 --- a/models/test-management/src/index.ts +++ b/models/test-management/src/index.ts @@ -350,11 +350,13 @@ function defineTestCase (builder: Builder): void { hiddenKeys: ['title'] }, config: [ - { key: '', displayProps: { fixed: 'left', key: 'lead' } }, { key: 'status', - props: { kind: 'list', size: 'small', shouldShowName: false } + props: { kind: 'list', size: 'small', shouldShowName: false }, + displayProps: { key: 'status', fixed: 'left' } }, + { key: '', displayProps: { fixed: 'left', key: 'lead' } }, + { key: '', displayProps: { grow: true } }, { key: 'modifiedOn', displayProps: { key: 'modified', fixed: 'right', dividerBefore: true } }, { key: 'assignee', diff --git a/packages/theme/styles/_layouts.scss b/packages/theme/styles/_layouts.scss index afdc52456e..794ef8d2d6 100644 --- a/packages/theme/styles/_layouts.scss +++ b/packages/theme/styles/_layouts.scss @@ -127,7 +127,7 @@ p:last-child { margin-block-end: 0; } .p-inline { overflow-wrap: break-word; word-wrap: break-word; - hyphens: auto; + // hyphens: auto; line-height: 150%; &.contrast { color: var(--global-primary-TextColor); } diff --git a/packages/theme/styles/common.scss b/packages/theme/styles/common.scss index 6ea42526e3..edaa02262a 100644 --- a/packages/theme/styles/common.scss +++ b/packages/theme/styles/common.scss @@ -141,20 +141,38 @@ } .antiPanel-navigator { position: relative; - background-color: var(--theme-navpanel-color); - + + &:not(.second) { background-color: var(--theme-navpanel-color); } &:not(.right) { min-width: 12.5rem; max-width: 22.5rem; width: 17.5rem; } - &.right { - border-radius: var(--medium-BorderRadius); - } -} + &.right { border-radius: var(--medium-BorderRadius); } + &.second.float { + background-color: var(--theme-navpanel-color); + filter: drop-shadow(2px 0 5px rgba(0, 0, 0, .2)); + z-index: 460; + + &:not(.inner) { + position: fixed; + top: calc(var(--status-bar-height) + 3.5rem + 1px); + height: calc(100% - var(--status-bar-height) - 3.5rem - 2px); + border-left: 1px solid var(--theme-divider-color); -@media (max-width: 1024px) { - .antiPanel-navigator { + &.portrait { left: 0; } + &.landscape { left: var(--app-panel-width); } + } + + &.inner { + position: absolute; + top: 3.5rem; + left: 0; + height: calc(100% - 3.5rem); + } + } + + &.fly:not(.second) { position: fixed; background-color: var(--theme-navpanel-color); z-index: 450; @@ -176,6 +194,7 @@ } } } + @media (max-width: 480px) { .mobile-theme { .mobile-wrapper, diff --git a/packages/theme/styles/dialogs.scss b/packages/theme/styles/dialogs.scss index c18964c47a..d88ee44067 100644 --- a/packages/theme/styles/dialogs.scss +++ b/packages/theme/styles/dialogs.scss @@ -326,6 +326,13 @@ height: calc(100vh - 4rem); max-width: calc(100vw - 3rem); max-height: calc(100vh - 4rem); + + .mobile-theme & { + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + } } .header { diff --git a/packages/ui/src/components/Scroller.svelte b/packages/ui/src/components/Scroller.svelte index 681ea58b32..571d452def 100644 --- a/packages/ui/src/components/Scroller.svelte +++ b/packages/ui/src/components/Scroller.svelte @@ -41,6 +41,7 @@ export let shrink: boolean = false export let divScroll: HTMLElement | undefined | null = undefined export let divBox: HTMLElement | undefined | null = undefined + export let scrollSnap: boolean = false export let checkForHeaders: boolean = false export let stickedScrollBars: boolean = false export let thinScrollBars: boolean = false @@ -571,6 +572,8 @@ class="scroll relative flex-shrink flex-col" style:flex-direction={scrollDirection === 'vertical-reverse' ? 'column-reverse' : 'column'} class:disableOverscroll + class:scrollSnapX={scrollSnap && contentDirection === 'horizontal'} + class:scrollSnapY={scrollSnap && contentDirection === 'vertical'} style:overflow-x={horizontal ? 'auto' : 'hidden'} on:scroll={() => { if (onScroll) { @@ -879,6 +882,16 @@ &.disableOverscroll { overscroll-behavior: none; } + &.scrollSnapY { + scroll-snap-type: y mandatory; + } + &.scrollSnapX { + scroll-snap-type: x mandatory; + } + &.scrollSnapX, + &.scrollSnapY { + scroll-padding-inline: var(--spacing-1); + } &::-webkit-scrollbar:vertical { display: none; width: 0; diff --git a/plugins/time-resources/src/components/icons/MenuClose.svelte b/packages/ui/src/components/icons/MenuClose.svelte similarity index 96% rename from plugins/time-resources/src/components/icons/MenuClose.svelte rename to packages/ui/src/components/icons/MenuClose.svelte index 63908e9ba6..6cc2f44167 100644 --- a/plugins/time-resources/src/components/icons/MenuClose.svelte +++ b/packages/ui/src/components/icons/MenuClose.svelte @@ -13,7 +13,8 @@ // limitations under the License. --> diff --git a/plugins/time-resources/src/components/icons/MenuOpen.svelte b/packages/ui/src/components/icons/MenuOpen.svelte similarity index 95% rename from plugins/time-resources/src/components/icons/MenuOpen.svelte rename to packages/ui/src/components/icons/MenuOpen.svelte index c100775793..89e81f4091 100644 --- a/plugins/time-resources/src/components/icons/MenuOpen.svelte +++ b/packages/ui/src/components/icons/MenuOpen.svelte @@ -13,7 +13,8 @@ // limitations under the License. --> diff --git a/packages/ui/src/components/internal/Root.svelte b/packages/ui/src/components/internal/Root.svelte index 49858d6374..ccdfc62486 100644 --- a/packages/ui/src/components/internal/Root.svelte +++ b/packages/ui/src/components/internal/Root.svelte @@ -140,7 +140,7 @@ updateDeviceSize() $: secondRow = checkAdaptiveMatching($deviceInfo.size, 'xs') - $: asideFloat = $deviceInfo.navigator.float + $: asideFloat = $deviceInfo.aside.float $: asideOpen = $deviceInfo.aside.visible $: appsMini = $deviceInfo.isMobile && diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index ec8c8b97ab..cf2253c42b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -242,6 +242,8 @@ export { default as IconCheckmark } from './components/icons/Checkmark.svelte' export { default as IconToDetails } from './components/icons/ToDetails.svelte' export { default as IconHistory } from './components/icons/History.svelte' export { default as IconScribble } from './components/icons/Scribble.svelte' +export { default as IconMenuOpen } from './components/icons/MenuOpen.svelte' +export { default as IconMenuClose } from './components/icons/MenuClose.svelte' export { default as PanelInstance } from './components/PanelInstance.svelte' export { default as Panel } from './components/Panel.svelte' @@ -317,7 +319,7 @@ export const deviceOptionsStore = writable({ isPortrait: false, isMobile: false, navigator: { visible: true, float: false, direction: 'vertical' }, - aside: { visible: true }, + aside: { visible: true, float: false }, fontSize: 0, size: null, sizes: { xs: false, sm: false, md: false, lg: false, xl: false, xxl: false }, diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 6293ffc0a7..77121dea8d 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -381,7 +381,7 @@ export interface DeviceOptions { isPortrait: boolean isMobile: boolean navigator: { visible: boolean, float: boolean, direction: 'vertical' | 'horizontal' } - aside: { visible: boolean } + aside: { visible: boolean, float: boolean } fontSize: number size: WidthType | null sizes: Record diff --git a/plugins/attachment-resources/src/components/AttachmentList.svelte b/plugins/attachment-resources/src/components/AttachmentList.svelte index d8a42581e8..c71622a2a4 100644 --- a/plugins/attachment-resources/src/components/AttachmentList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentList.svelte @@ -27,7 +27,7 @@ {#if attachments.length} - + {#each attachments as attachment} diff --git a/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte b/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte index da02fe2841..637d4ead5f 100644 --- a/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte +++ b/plugins/calendar-resources/src/components/CalendarWidgetHeader.svelte @@ -13,7 +13,7 @@ // limitations under the License. -->
diff --git a/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte b/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte index 9e21e28c4e..d4cc7ed342 100644 --- a/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte +++ b/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte @@ -18,7 +18,7 @@ import { type Class, type Ref } from '@hcengineering/core' import { TestCase } from '@hcengineering/test-management' import { Panel } from '@hcengineering/panel' - import { EditBox } from '@hcengineering/ui' + import { EditBox, Breadcrumb } from '@hcengineering/ui' import { createEventDispatcher, onMount } from 'svelte' import testManagement from '../../plugin' @@ -65,14 +65,17 @@ dispatch('close')} > + + + + dispatch('close')} > + + + +
@@ -209,7 +210,7 @@ float={$deviceInfo.navigator.float ? 'navigator' : true} index={0} disabledWhen={['panel-aside']} - color={'var(--theme-navpanel-border)'} + color={'var(--theme-divider-color)'} />
diff --git a/plugins/time-resources/src/components/team/TeamNavigator.svelte b/plugins/time-resources/src/components/team/TeamNavigator.svelte index 01f406a5bb..b5ef38cd86 100644 --- a/plugins/time-resources/src/components/team/TeamNavigator.svelte +++ b/plugins/time-resources/src/components/team/TeamNavigator.svelte @@ -16,13 +16,11 @@ import { Ref, getCurrentAccount } from '@hcengineering/core' import { createQuery } from '@hcengineering/presentation' import task, { Project } from '@hcengineering/task' - import { Label, Separator, Scroller, NavItem } from '@hcengineering/ui' + import { Label, Separator, Scroller, NavItem, deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { ObjectPresenter, TreeNode } from '@hcengineering/view-resources' import { NavFooter, NavHeader } from '@hcengineering/workbench-resources' import time from '../../plugin' - export let navFloat: boolean = false - export let appsDirection: 'vertical' | 'horizontal' = 'horizontal' export let selected: Ref | undefined = (localStorage.getItem('team_last_mode') as Ref) ?? undefined let projects: Project[] = [] @@ -41,7 +39,10 @@ $: selectedItem = projects.find((pr) => pr._id === selected) -
+
@@ -89,9 +90,9 @@
diff --git a/plugins/view-resources/src/components/list/ListItem.svelte b/plugins/view-resources/src/components/list/ListItem.svelte index c3f162fb57..fb4a0705f3 100644 --- a/plugins/view-resources/src/components/list/ListItem.svelte +++ b/plugins/view-resources/src/components/list/ListItem.svelte @@ -80,6 +80,8 @@ } $: mobile = $deviceInfo.isMobile + $: needCompact = + model.filter((m) => m.displayProps?.optional || m.displayProps?.compression || m.displayProps?.suffix).length > 0 onMount(() => { dispatch('on-mount') @@ -191,7 +193,7 @@ {/if} {/if} {/each} - {#if compactMode} + {#if compactMode && needCompact}
diff --git a/plugins/workbench-resources/src/components/ComponentNavigator.svelte b/plugins/workbench-resources/src/components/ComponentNavigator.svelte index bb381a7930..930d78c807 100644 --- a/plugins/workbench-resources/src/components/ComponentNavigator.svelte +++ b/plugins/workbench-resources/src/components/ComponentNavigator.svelte @@ -22,6 +22,9 @@ Breadcrumb, Component, IconAdd, + IconMenuOpen, + IconMenuClose, + ButtonIcon, Header, Separator, showPopup, @@ -29,7 +32,8 @@ resolvedLocationStore, deviceOptionsStore as deviceInfo, defineSeparators, - workbenchSeparators + twoPanelsSeparators, + resizeObserver } from '@hcengineering/ui' import { Doc, DocumentQuery, Ref, Space, mergeQueries } from '@hcengineering/core' import { IntlString, Asset } from '@hcengineering/platform' @@ -48,6 +52,9 @@ export let mainComponent: AnyComponent | AnySvelteComponent export let mainComponentProps = {} + const FLOAT_LIMIT = 760 + let container: HTMLDivElement + let locationQuery: DocumentQuery = {} let resultQuery: DocumentQuery = {} let spaceQuery: DocumentQuery = {} @@ -67,24 +74,60 @@ if (createComponent === undefined) return showPopup(createComponent, { ...createComponentProps, space }, 'top') } - defineSeparators('parentsNavigator', workbenchSeparators) + + defineSeparators('parentsNavigator', twoPanelsSeparators) + + const getVisibleNavigator = (): boolean => !(localStorage.getItem('componentNavigator') === 'hidden') + let visibleNavigator: boolean = getVisibleNavigator() + let floatNavigator: boolean = false + + const toggleNavigator = (): void => { + visibleNavigator = !visibleNavigator + if (visibleNavigator) { + if (!floatNavigator && container.getBoundingClientRect().width < FLOAT_LIMIT) { + floatNavigator = true + } + localStorage.removeItem('componentNavigator') + } else localStorage.setItem('componentNavigator', 'hidden') + } -
- {#if $deviceInfo.navigator.visible} +
{ + if (visibleNavigator && !floatNavigator && element.clientWidth < FLOAT_LIMIT) { + floatNavigator = true + visibleNavigator = false + } else if (floatNavigator && element.clientWidth >= FLOAT_LIMIT) { + floatNavigator = false + visibleNavigator = getVisibleNavigator() + } + }} +> + {#if visibleNavigator} + {#if floatNavigator} + + +
+ {/if}
- + {#if createComponent}
- - + {/if}
- + + + + +
+ + diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index 1ea3c97685..d813de2a85 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -77,7 +77,8 @@ setResolvedLocation, showPopup, TooltipInstance, - workbenchSeparators + workbenchSeparators, + resizeObserver } from '@hcengineering/ui' import view from '@hcengineering/view' import { @@ -631,21 +632,31 @@ let aside: HTMLElement let cover: HTMLElement + let workbenchWidth: number = $deviceInfo.docWidth - $deviceInfo.navigator.float = !($deviceInfo.docWidth < 1024) - $: if ($deviceInfo.docWidth <= 1024 && !$deviceInfo.navigator.float) { - $deviceInfo.navigator.visible = false - $deviceInfo.navigator.float = true + $deviceInfo.navigator.float = !(workbenchWidth < 1024) + const checkWorkbenchWidth = (): void => { + if (workbenchWidth <= 1024 && !$deviceInfo.navigator.float && $deviceInfo.navigator.visible) { + $deviceInfo.navigator.visible = false + $deviceInfo.navigator.float = true + } else if (workbenchWidth > 1024 && $deviceInfo.navigator.float) { + if (getMetadata(workbench.metadata.NavigationExpandedDefault) === undefined) { + $deviceInfo.navigator.float = false + $deviceInfo.navigator.visible = true + } + } + } + $: if ($deviceInfo.docWidth <= 1024 && !$deviceInfo.aside.float) { $deviceInfo.aside.visible = false - } else if ($deviceInfo.docWidth > 1024 && $deviceInfo.navigator.float) { + $deviceInfo.aside.float = true + } else if ($deviceInfo.docWidth > 1024 && $deviceInfo.aside.float) { if (getMetadata(workbench.metadata.NavigationExpandedDefault) === undefined) { - $deviceInfo.navigator.float = false - $deviceInfo.navigator.visible = true + $deviceInfo.aside.float = false $deviceInfo.aside.visible = true } } const checkOnHide = (): void => { - if ($deviceInfo.navigator.visible && $deviceInfo.docWidth <= 1024) $deviceInfo.navigator.visible = false + if ($deviceInfo.navigator.visible && workbenchWidth <= 1024) $deviceInfo.navigator.visible = false } let oldNavVisible: boolean = $deviceInfo.navigator.visible let oldASideVisible: boolean = $deviceInfo.aside.visible @@ -889,7 +900,14 @@ application: currentApplication?._id }} /> -
+
{ + workbenchWidth = element.clientWidth + checkWorkbenchWidth() + }} + > {#if mainNavigator} @@ -900,6 +918,7 @@ class="antiPanel-navigator no-print {$deviceInfo.navigator.direction === 'horizontal' ? 'portrait' : 'landscape'} border-left" + class:fly={$deviceInfo.navigator.float} >
{#if currentApplication} @@ -996,7 +1015,7 @@
{/if}
- {#if !$deviceInfo.navigator.float} + {#if !$deviceInfo.aside.float} {#if $sidebarStore.variant === SidebarVariant.EXPANDED} {/if} @@ -1004,8 +1023,7 @@ {/if}
-
- {#if $deviceInfo.navigator.float} + {#if $deviceInfo.aside.float}
diff --git a/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte b/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte index 4a03ced07f..8860b6b0cf 100644 --- a/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte +++ b/plugins/workbench-resources/src/components/sidebar/Sidebar.svelte @@ -65,7 +65,7 @@ id="sidebar" class="antiPanel-application vertical sidebar-container" class:mini - class:float={$deviceInfo.navigator.float} + class:float={$deviceInfo.aside.float} > {#if mini} diff --git a/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte b/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte index fd4f44e38c..9cefa62449 100644 --- a/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte +++ b/plugins/workbench-resources/src/components/sidebar/SidebarExpanded.svelte @@ -22,7 +22,8 @@ Location, Header, Breadcrumbs, - getCurrentLocation + getCurrentLocation, + deviceOptionsStore as deviceInfo } from '@hcengineering/ui' import { onDestroy, onMount } from 'svelte' @@ -108,6 +109,7 @@ hideDescription={true} adaptive="disabled" closeOnEscape={false} + topIndent={$deviceInfo.navigator.float && !$deviceInfo.aside.float} on:close={() => { if (widget !== undefined) { closeWidget(widget._id) diff --git a/plugins/workbench-resources/src/sidebar.ts b/plugins/workbench-resources/src/sidebar.ts index 5a7900ab35..b916b132c9 100644 --- a/plugins/workbench-resources/src/sidebar.ts +++ b/plugins/workbench-resources/src/sidebar.ts @@ -256,7 +256,9 @@ export function createWidgetTab (widget: Widget, tab: WidgetTab, newTab = false) variant: SidebarVariant.EXPANDED }) const devInfo = get(deviceInfo) - if (devInfo.navigator.float && !devInfo.aside.visible) deviceInfo.set({ ...devInfo, aside: { visible: true } }) + if (devInfo.aside.float && !devInfo.aside.visible) { + deviceInfo.set({ ...devInfo, aside: { visible: true, float: true } }) + } } export function pinWidgetTab (widget: Widget, tabId: string): void { @@ -337,7 +339,9 @@ export function minimizeSidebar (closedByUser = false): void { sidebarStore.set({ ...state, ...widgetsState, widget: undefined, variant: SidebarVariant.MINI }) const devInfo = get(deviceInfo) - if (devInfo.navigator.float && devInfo.aside.visible) deviceInfo.set({ ...devInfo, aside: { visible: false } }) + if (devInfo.navigator.float && devInfo.aside.visible) { + deviceInfo.set({ ...devInfo, aside: { visible: false, float: true } }) + } } export function updateTabData (widget: Ref, tabId: string, data: Record): void { diff --git a/tests/sanity/tests/chat/chat.spec.ts b/tests/sanity/tests/chat/chat.spec.ts index 20f97bb20c..d65cf51242 100644 --- a/tests/sanity/tests/chat/chat.spec.ts +++ b/tests/sanity/tests/chat/chat.spec.ts @@ -548,7 +548,7 @@ test.describe('Channel tests', () => { }) await test.step('Open general in sidebar too', async () => { - await channelPage.makeActionWithChannelInMenu('general', 'Open in sidebar') + await channelPage.makeActionWithChannelInMenu('general', 'Open in sidebar', true) await sidebarPage.checkIfSidebarHasVerticalTab(true, data.channelName) await sidebarPage.checkIfSidebarHasVerticalTab(true, 'general') await sidebarPage.checkIfChatSidebarTabIsOpen(true, 'general') @@ -606,6 +606,7 @@ test.describe('Channel tests', () => { }) await test.step('User go to another chat and Sidebar with tread disappears', async () => { + await channelPage.appsMenuButton().click() await channelPage.clickChannel('random') await sidebarPage.checkIfSidebarIsOpen(false) }) diff --git a/tests/sanity/tests/model/channel-page.ts b/tests/sanity/tests/model/channel-page.ts index ff7bd1d5f2..13a87c3f20 100644 --- a/tests/sanity/tests/model/channel-page.ts +++ b/tests/sanity/tests/model/channel-page.ts @@ -302,7 +302,12 @@ export class ChannelPage extends CommonPage { await expect(this.buttonBreadcrumb(channel)).toBeVisible() } - async makeActionWithChannelInMenu (channelName: string, action: string): Promise { + async makeActionWithChannelInMenu ( + channelName: string, + action: string, + openNavigator: boolean = false + ): Promise { + if (openNavigator) await this.appsMenuButton().click() await this.channelContainers().filter({ hasText: channelName }).hover() await this.channelContainers().filter({ hasText: channelName }).locator('.hulyNavItem-actions').click() await this.selectFromDropdown(this.page, action) diff --git a/tests/sanity/tests/model/common-page.ts b/tests/sanity/tests/model/common-page.ts index b5044b51b8..07b3bc95a7 100644 --- a/tests/sanity/tests/model/common-page.ts +++ b/tests/sanity/tests/model/common-page.ts @@ -91,6 +91,7 @@ export class CommonPage { submitButton = (): Locator => this.page.locator('div.date-popup-container button[type="submit"]') buttonBreadcrumb = (hasText?: string): Locator => this.page.locator('button.hulyBreadcrumb-container', { hasText }) + appsMenuButton = (): Locator => this.page.locator('[id="app-workbench\\:string\\:ShowMenu"]') async selectMenuItem (page: Page, name: string, fullWordFilter: boolean = false): Promise { if (name !== 'first') { From da85d6dd5fda22f534acd0ef6fb644e2ee00f5cb Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Fri, 29 Nov 2024 01:36:41 +0700 Subject: [PATCH 03/10] UBERF-8584: Add test runs (#7235) Signed-off-by: Artem Savchenko --- models/test-management/src/defaultTypes.ts | 51 ----- models/test-management/src/index.ts | 197 +++++++++++++---- models/test-management/src/plugin.ts | 22 +- models/test-management/src/presenters.ts | 8 + models/test-management/src/types.ts | 67 ++++-- .../breadcrumbs/BreadcrumbsElement.svelte | 5 +- packages/theme/styles/components.scss | 4 + .../test-management-assets/assets/icons.svg | 26 ++- plugins/test-management-assets/lang/cs.json | 23 +- plugins/test-management-assets/lang/en.json | 23 +- plugins/test-management-assets/lang/es.json | 85 ++++++++ plugins/test-management-assets/lang/fr.json | 21 +- plugins/test-management-assets/lang/it.json | 85 ++++++++ plugins/test-management-assets/lang/pt.json | 85 ++++++++ plugins/test-management-assets/lang/ru.json | 21 +- plugins/test-management-assets/lang/zh.json | 21 +- plugins/test-management-assets/src/index.ts | 9 +- .../test-case/AssigneeEditor.svelte | 198 ------------------ .../test-case/CreateTestCase.svelte | 7 - .../components/test-case/EditTestCase.svelte | 9 +- .../test-case/RunSelectedTestsButton.svelte | 88 ++++++++ .../components/test-case/StatusEditor.svelte | 16 +- .../test-case/TestCaseDetails.svelte | 58 +++++ .../components/test-case/TestCasePopup.svelte | 47 +++++ .../test-case/TestCasePresenter.svelte | 3 +- .../test-case/TestCaseSelector.svelte | 67 ++++++ .../test-result/EditTestResult.svelte | 105 ++++++++++ .../components/test-result/RightHeader.svelte | 24 +++ .../test-result/TestResultFooter.svelte | 31 +++ .../test-result/TestResultHeader.svelte | 25 +++ .../test-result/TestResultPresenter.svelte | 55 +++++ .../test-result/TestResultStatusEditor.svelte | 106 ++++++++++ .../TestResultStatusPresenter.svelte | 45 ++++ .../components/test-run/CreateTestRun.svelte | 127 +++++++---- .../components/test-run/EditTestRun.svelte | 93 +++++++- .../components/test-run/TestCaseList.svelte | 106 ++++++++++ .../TestCasesList.svelte | 29 +-- .../components/test-run/TestRunAside.svelte | 32 +++ .../test-run/TestRunPresenter.svelte | 24 ++- .../components/test-run/TestRunResult.svelte | 64 ++++++ .../components/test-run/TestRunStats.svelte | 55 +++++ .../components/test-run/ViewContainer.svelte | 25 +++ .../components/test-run/store/testRunStore.ts | 22 ++ .../test-suite/EditTestSuite.svelte | 5 - .../test-management-resources/src/index.ts | 28 ++- .../src/navigation.ts | 19 +- .../src/testRunUtils.ts | 65 ++++++ .../test-management-resources/src/types.ts | 30 ++- .../test-management-resources/src/utils.ts | 50 ++--- plugins/test-management/src/analytics.ts | 26 +++ plugins/test-management/src/index.ts | 1 + plugins/test-management/src/plugin.ts | 68 ++++-- plugins/test-management/src/types.ts | 18 +- .../src/__tests__/folderUtils.test.ts | 183 ++++++++++++++++ .../{ => folders}/FolderTreeLevel.svelte | 2 +- .../{ => folders}/FoldersBrowser.svelte | 88 ++++---- .../components/folders/store/folderStore.ts | 38 ++++ .../folders/store/folderUtils.ts} | 81 ++++--- plugins/view-resources/src/index.ts | 5 +- .../src/components/ComponentNavigator.svelte | 12 ++ 60 files changed, 2379 insertions(+), 554 deletions(-) delete mode 100644 models/test-management/src/defaultTypes.ts create mode 100644 plugins/test-management-assets/lang/es.json create mode 100644 plugins/test-management-assets/lang/it.json create mode 100644 plugins/test-management-assets/lang/pt.json delete mode 100644 plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte create mode 100644 plugins/test-management-resources/src/components/test-case/RunSelectedTestsButton.svelte create mode 100644 plugins/test-management-resources/src/components/test-case/TestCaseDetails.svelte create mode 100644 plugins/test-management-resources/src/components/test-case/TestCasePopup.svelte create mode 100644 plugins/test-management-resources/src/components/test-case/TestCaseSelector.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/EditTestResult.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/RightHeader.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte create mode 100644 plugins/test-management-resources/src/components/test-result/TestResultStatusPresenter.svelte create mode 100644 plugins/test-management-resources/src/components/test-run/TestCaseList.svelte rename plugins/test-management-resources/src/components/{test-suite => test-run}/TestCasesList.svelte (69%) create mode 100644 plugins/test-management-resources/src/components/test-run/TestRunAside.svelte create mode 100644 plugins/test-management-resources/src/components/test-run/TestRunResult.svelte create mode 100644 plugins/test-management-resources/src/components/test-run/TestRunStats.svelte create mode 100644 plugins/test-management-resources/src/components/test-run/ViewContainer.svelte create mode 100644 plugins/test-management-resources/src/components/test-run/store/testRunStore.ts create mode 100644 plugins/test-management-resources/src/testRunUtils.ts create mode 100644 plugins/test-management/src/analytics.ts create mode 100644 plugins/view-resources/src/__tests__/folderUtils.test.ts rename plugins/view-resources/src/components/{ => folders}/FolderTreeLevel.svelte (99%) rename plugins/view-resources/src/components/{ => folders}/FoldersBrowser.svelte (64%) create mode 100644 plugins/view-resources/src/components/folders/store/folderStore.ts rename plugins/view-resources/src/{stores/folderStore.ts => components/folders/store/folderUtils.ts} (55%) diff --git a/models/test-management/src/defaultTypes.ts b/models/test-management/src/defaultTypes.ts deleted file mode 100644 index 3495aff78a..0000000000 --- a/models/test-management/src/defaultTypes.ts +++ /dev/null @@ -1,51 +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. -// -import { type Builder } from '@hcengineering/model' -import core from '@hcengineering/model-core' - -import { TDefaultProjectTypeData } from './types' -import testManagement from './plugin' - -export function defineDefaultSpace (builder: Builder): void { - defineDefaultProject(builder) -} - -function defineDefaultProject (builder: Builder): void { - builder.createModel(TDefaultProjectTypeData) - - builder.createDoc( - core.class.SpaceTypeDescriptor, - core.space.Model, - { - name: testManagement.string.TestProject, - description: testManagement.string.FullDescription, - icon: testManagement.icon.TestProject, - baseClass: testManagement.class.TestProject, - availablePermissions: [ - core.permission.UpdateSpace, - core.permission.ArchiveSpace, - core.permission.ForbidDeleteObject - ] - }, - testManagement.descriptors.ProjectType - ) - - builder.createDoc(core.class.SpaceType, core.space.Model, { - name: 'Default project type', - descriptor: testManagement.descriptors.ProjectType, - roles: 0, - targetClass: testManagement.mixin.DefaultProjectTypeData - }) -} diff --git a/models/test-management/src/index.ts b/models/test-management/src/index.ts index 45e1a86ed1..c75cb12f7d 100644 --- a/models/test-management/src/index.ts +++ b/models/test-management/src/index.ts @@ -16,7 +16,7 @@ import activity from '@hcengineering/activity' import chunter from '@hcengineering/chunter' import core from '@hcengineering/model-core' -import { SortingOrder } from '@hcengineering/core' +import { SortingOrder, type FindOptions } from '@hcengineering/core' import { type Builder } from '@hcengineering/model' import view, { createAction } from '@hcengineering/model-view' @@ -25,7 +25,7 @@ import print from '@hcengineering/model-print' import tracker from '@hcengineering/model-tracker' import { type ViewOptionsModel } from '@hcengineering/view' -import { testManagementId } from '@hcengineering/test-management' +import { testManagementId, type TestResult } from '@hcengineering/test-management' import { DOMAIN_TEST_MANAGEMENT, @@ -37,8 +37,8 @@ import { TTestCase, TDefaultProjectTypeData, TTestRun, - TTypeTestRunResult, - TTestRunItem + TTypeTestRunStatus, + TTestResult } from './types' import testManagement from './plugin' @@ -55,7 +55,6 @@ function defineApplication (builder: Builder): void { icon: testManagement.icon.TestManagementApplication, alias: testManagementId, hidden: false, - locationResolver: testManagement.resolver.Location, navigatorModel: { spaces: [ { @@ -85,6 +84,7 @@ function defineApplication (builder: Builder): void { mainComponentLabel: testManagement.string.TestCases, mainComponentIcon: testManagement.icon.TestCases, createComponent: testManagement.component.CreateTestSuite, + mainHeaderComponent: testManagement.component.RunSelectedTestsButton, navigationComponentProps: { _class: testManagement.class.TestSuite, icon: testManagement.icon.TestSuites, @@ -100,21 +100,34 @@ function defineApplication (builder: Builder): void { }, syncWithLocationQuery: true } - } - /* TODO: UBERF-8584 + }, { - id: opt.testRunsId, + id: 'testRuns', label: testManagement.string.TestRuns, icon: testManagement.icon.TestRuns, component: workbench.component.SpecialView, componentProps: { - _class: testManagement.class.TestRun, + _class: testManagement.class.TestResult, icon: testManagement.icon.TestRuns, - title: testManagement.string.TestRuns, - createLabel: testManagement.string.NewTestRun, - createComponent: testManagement.component.CreateTestRun + label: testManagement.string.TestRuns + }, + navigationModel: { + navigationComponent: view.component.FoldersBrowser, + navigationComponentLabel: testManagement.string.TestRun, + navigationComponentIcon: testManagement.icon.TestRuns, + mainComponentLabel: testManagement.string.TestResults, + mainComponentIcon: testManagement.icon.TestResult, + navigationComponentProps: { + _class: testManagement.class.TestRun, + icon: testManagement.icon.TestRuns, + title: testManagement.string.TestSuites, + titleKey: 'name', + getFolderLink: testManagement.function.GetTestRunLink, + plainList: true + }, + syncWithLocationQuery: true } - } */ + } ] } ] @@ -135,8 +148,8 @@ export function createModel (builder: Builder): void { TTestCase, TDefaultProjectTypeData, TTestRun, - TTestRunItem, - TTypeTestRunResult + TTypeTestRunStatus, + TTestResult ) builder.mixin(testManagement.class.TestProject, core.class.Class, activity.mixin.ActivityDoc, {}) @@ -204,7 +217,7 @@ function defineSpaceType (builder: Builder): void { core.class.SpaceType, core.space.Model, { - name: 'Default project type', + name: 'Default Test Management', descriptor: testManagement.descriptors.ProjectType, roles: 0, targetClass: testManagement.mixin.DefaultProjectTypeData @@ -250,13 +263,7 @@ function defineTestSuite (builder: Builder): void { // Actions builder.mixin(testManagement.class.TestSuite, core.class.Class, view.mixin.IgnoreActions, { - actions: [ - view.action.Open, - view.action.OpenInNewTab, - print.action.Print, - tracker.action.EditRelatedTargets, - tracker.action.NewRelatedIssue - ] + actions: [print.action.Print, tracker.action.EditRelatedTargets] }) createAction( @@ -276,6 +283,24 @@ function defineTestSuite (builder: Builder): void { }, testManagement.action.CreateChildTestSuite ) + + createAction( + builder, + { + action: testManagement.actionImpl.RunSelectedTests, + label: testManagement.string.CreateTestRun, + icon: testManagement.icon.TestRuns, + category: testManagement.category.TestCase, + input: 'selection', + target: testManagement.class.TestCase, + context: { + mode: ['context'], + application: testManagement.app.TestManagement, + group: 'create' + } + }, + testManagement.action.RunSelectedTests + ) } function defineTestCase (builder: Builder): void { @@ -306,6 +331,11 @@ function defineTestCase (builder: Builder): void { presenter: testManagement.component.TestSuiteRefPresenter }) + builder.mixin(testManagement.class.TestCase, core.class.Class, view.mixin.ClassFilters, { + filters: ['priority', 'status'], + ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn'] + }) + builder.createDoc( view.class.Viewlet, core.space.Model, @@ -350,6 +380,7 @@ function defineTestCase (builder: Builder): void { hiddenKeys: ['title'] }, config: [ + { key: '', displayProps: { fixed: 'left' } }, { key: 'status', props: { kind: 'list', size: 'small', shouldShowName: false }, @@ -368,21 +399,6 @@ function defineTestCase (builder: Builder): void { }, testManagement.viewlet.ListTestCase ) - - builder.createDoc( - view.class.Viewlet, - core.space.Model, - { - attachTo: testManagement.class.TestCase, - descriptor: view.viewlet.Table, - config: ['', 'assignee', 'modifiedOn'], - configOptions: { - sortable: true - }, - variant: 'short' - }, - testManagement.viewlet.SuiteTestCases - ) } function defineTestRun (builder: Builder): void { @@ -393,10 +409,6 @@ function defineTestRun (builder: Builder): void { components: { input: { component: chunter.component.ChatMessageInput } } }) - builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.ObjectEditor, { - editor: testManagement.component.EditTestRun - }) - builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.ObjectPanel, { component: testManagement.component.EditTestRun }) @@ -405,18 +417,111 @@ function defineTestRun (builder: Builder): void { presenter: testManagement.component.TestRunPresenter }) + builder.mixin(testManagement.class.TestRun, core.class.Class, view.mixin.ObjectIcon, { + component: testManagement.component.TestResultStatusPresenter + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPresenter, { + presenter: testManagement.component.TestResultPresenter + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, activity.mixin.ActivityDoc, {}) + + builder.createDoc(activity.class.ActivityExtension, core.space.Model, { + ofClass: testManagement.class.TestResult, + components: { input: { component: chunter.component.ChatMessageInput } } + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectEditor, { + editor: testManagement.component.EditTestResult + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectEditorHeader, { + editor: testManagement.component.TestResultHeader + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPanel, { + component: testManagement.component.EditTestResult + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ObjectPanelFooter, { + editor: testManagement.component.TestResultFooter + }) + + builder.mixin(testManagement.class.TestResult, core.class.Class, view.mixin.ClassFilters, { + filters: ['assignee', 'status', 'testSuite'], + ignoreKeys: ['createdBy', 'modifiedBy', 'createdOn', 'modifiedOn'] + }) + + const viewOptions: ViewOptionsModel = { + groupBy: ['testSuite'], + orderBy: [ + ['status', SortingOrder.Ascending], + ['modifiedOn', SortingOrder.Descending], + ['createdOn', SortingOrder.Descending] + ], + other: [ + { + key: 'shouldShowAll', + type: 'toggle', + defaultValue: false, + actionTarget: 'category', + action: view.function.ShowEmptyGroups, + label: view.string.ShowEmptyGroups + } + ] + } + builder.createDoc( view.class.Viewlet, core.space.Model, { - attachTo: testManagement.class.TestRun, + attachTo: testManagement.class.TestResult, + descriptor: view.viewlet.List, + configOptions: { + strict: true, + hiddenKeys: ['title', 'status', 'modifiedOn'] + }, + config: [ + { key: '', displayProps: { fixed: 'left' } }, + { + key: 'status', + props: { kind: 'list', size: 'small', shouldShowName: false } + }, + { + key: 'assignee', + props: { kind: 'list', shouldShowName: false, avatarSize: 'x-small' }, + displayProps: { key: 'assignee', fixed: 'right' } + } + ], + viewOptions, + /* eslint-disable @typescript-eslint/consistent-type-assertions */ + options: { + lookup: { + testCase: testManagement.class.TestCase + } + } as FindOptions + }, + testManagement.viewlet.TestResultList + ) + + builder.createDoc( + view.class.Viewlet, + core.space.Model, + { + attachTo: testManagement.class.TestResult, descriptor: view.viewlet.Table, - config: [''], + config: ['', 'testSuite', 'status', 'assignee'], configOptions: { strict: true - } + }, + options: { + lookup: { + testCase: testManagement.class.TestCase + } + } as FindOptions }, - testManagement.viewlet.TableTestRun + testManagement.viewlet.TableTestResult ) } diff --git a/models/test-management/src/plugin.ts b/models/test-management/src/plugin.ts index abf37a268f..79e2e7c588 100644 --- a/models/test-management/src/plugin.ts +++ b/models/test-management/src/plugin.ts @@ -15,23 +15,15 @@ import { testManagementId } from '@hcengineering/test-management' import testManganement from '@hcengineering/test-management-resources/src/plugin' -import type { Doc, Ref } from '@hcengineering/core' +import type { Ref } from '@hcengineering/core' import { mergeIds } from '@hcengineering/platform' import { type AnyComponent } from '@hcengineering/ui/src/types' -import type { Action, ActionCategory, ViewAction } from '@hcengineering/view' +import type { ActionCategory } from '@hcengineering/view' export default mergeIds(testManagementId, testManganement, { - action: { - DeleteTestCase: '' as Ref>, - CreateChildTestSuite: '' as Ref, - EditTestSuite: '' as Ref - }, - actionImpl: { - CreateChildTestSuite: '' as ViewAction, - EditTestSuite: '' as ViewAction - }, category: { - TestSuite: '' as Ref + TestSuite: '' as Ref, + TestCase: '' as Ref }, component: { CreateTestCase: '' as AnyComponent, @@ -44,6 +36,10 @@ export default mergeIds(testManagementId, testManganement, { CreateTestRun: '' as AnyComponent, TestRunPresenter: '' as AnyComponent, EditTestRun: '' as AnyComponent, - TestSuiteRefPresenter: '' as AnyComponent + TestSuiteRefPresenter: '' as AnyComponent, + RunSelectedTestsButton: '' as AnyComponent, + TestResultPresenter: '' as AnyComponent, + EditTestResult: '' as AnyComponent, + TestResultFooter: '' as AnyComponent } }) diff --git a/models/test-management/src/presenters.ts b/models/test-management/src/presenters.ts index 0b45b6df50..8d68c5f7c9 100644 --- a/models/test-management/src/presenters.ts +++ b/models/test-management/src/presenters.ts @@ -53,4 +53,12 @@ export function definePresenters (builder: Builder): void { builder.mixin(testManagement.class.TypeTestCaseStatus, core.class.Class, view.mixin.AttributePresenter, { presenter: testManagement.component.TestCaseStatusPresenter }) + + builder.mixin(testManagement.class.TypeTestRunStatus, core.class.Class, view.mixin.AttributePresenter, { + presenter: testManagement.component.TestResultStatusPresenter + }) + + builder.mixin(testManagement.class.TypeTestRunStatus, core.class.Class, view.mixin.AttributeEditor, { + inlineEditor: testManagement.component.TestResultStatusEditor + }) } diff --git a/models/test-management/src/types.ts b/models/test-management/src/types.ts index 1fca881944..c0b98b211a 100644 --- a/models/test-management/src/types.ts +++ b/models/test-management/src/types.ts @@ -22,8 +22,8 @@ import type { TestCaseStatus, TestProject, TestRun, - TestRunResult, - TestRunItem + TestRunStatus, + TestResult } from '@hcengineering/test-management' import { type Attachment } from '@hcengineering/attachment' import contact from '@hcengineering/contact' @@ -201,32 +201,65 @@ export class TTestRun extends TDoc implements TestRun { @Prop(TypeDate(DateRangeMode.DATETIME), testManagement.string.DueDate) dueDate?: Timestamp - @Prop(Collection(testManagement.class.TestRunItem), testManagement.string.TestRunItems, { - shortLabel: testManagement.string.TestRunItem + @Prop(Collection(testManagement.class.TestResult), testManagement.string.TestResult, { + shortLabel: testManagement.string.TestResult }) - items?: CollectionSize + results?: CollectionSize } /** @public */ -export function TypeTestRunResult (): Type { - return { _class: testManagement.class.TypeTestRunResult, label: testManagement.string.TestRunResult } +export function TypeTestRunStatus (): Type { + return { _class: testManagement.class.TypeTestRunStatus, label: testManagement.string.TestRunStatus } } -@Model(testManagement.class.TypeTestRunResult, core.class.Type, DOMAIN_TEST_MANAGEMENT) -@UX(testManagement.string.TestRunResult) -export class TTypeTestRunResult extends TType {} +@Model(testManagement.class.TypeTestRunStatus, core.class.Type, DOMAIN_TEST_MANAGEMENT) +@UX(testManagement.string.TestRunStatus) +export class TTypeTestRunStatus extends TType {} -@Model(testManagement.class.TestRunItem, core.class.AttachedDoc, DOMAIN_TEST_MANAGEMENT) -@UX(testManagement.string.TestRunItem) -export class TTestRunItem extends TAttachedDoc implements TestRunItem { - @Prop(TypeRef(testManagement.class.TestRun), testManagement.string.TestRun) - testRun!: Ref +// TODO: Refactor to associations +@Model(testManagement.class.TestResult, core.class.AttachedDoc, DOMAIN_TEST_MANAGEMENT) +@UX(testManagement.string.TestResult) +export class TTestResult extends TAttachedDoc implements TestResult { + @Prop(TypeRef(testManagement.class.TestRun), core.string.AttachedTo) + @Index(IndexKind.Indexed) + declare attachedTo: Ref + + @Prop(TypeRef(testManagement.class.TestRun), core.string.AttachedToClass) + @Index(IndexKind.Indexed) + @Hidden() + declare attachedToClass: Ref> + + @Prop(TypeRef(testManagement.class.TestProject), core.string.Space) + @Index(IndexKind.Indexed) + @Hidden() + declare space: Ref + + @Prop(TypeString(), core.string.Collection) + @Hidden() + override collection: 'results' = 'results' + + @Prop(TypeString(), testManagement.string.TestRunName) + @Index(IndexKind.FullText) + name!: string + + @Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription) + @Index(IndexKind.FullText) + description!: CollaborativeDoc @Prop(TypeRef(testManagement.class.TestCase), testManagement.string.TestCase) testCase!: Ref - @Prop(TypeTestRunResult(), testManagement.string.TestRunResult) - result?: TestRunResult + @Prop(TypeRef(testManagement.class.TestSuite), testManagement.string.TestSuite) + testSuite?: Ref + + @Prop(TypeTestRunStatus(), testManagement.string.TestRunStatus) + status?: TestRunStatus + + @Prop(TypeRef(contact.mixin.Employee), testManagement.string.TestAssignee) + assignee?: Ref + + @Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files }) + attachments?: CollectionSize @Prop(Collection(chunter.class.ChatMessage), chunter.string.Comments) comments?: number diff --git a/packages/presentation/src/components/breadcrumbs/BreadcrumbsElement.svelte b/packages/presentation/src/components/breadcrumbs/BreadcrumbsElement.svelte index 9bc6d3030a..94f69cf15c 100644 --- a/packages/presentation/src/components/breadcrumbs/BreadcrumbsElement.svelte +++ b/packages/presentation/src/components/breadcrumbs/BreadcrumbsElement.svelte @@ -19,6 +19,8 @@ export let position: 'start' | 'middle' | 'end' | undefined = undefined export let selected = false export let color = 'var(--theme-bg-color)' + export let noGap = false + export let fontColor: string | undefined = undefined let clientWidth = 0 @@ -43,6 +45,7 @@
-
+
{#if $$slots.default} {:else} diff --git a/packages/theme/styles/components.scss b/packages/theme/styles/components.scss index 4ea43b9f7f..924821fcda 100644 --- a/packages/theme/styles/components.scss +++ b/packages/theme/styles/components.scss @@ -1493,6 +1493,10 @@ min-width: 0; width: auto; + &.noGap { + flex: none; + } + &__back { padding: 1px 0.5px; height: calc(1.5rem + 2px); diff --git a/plugins/test-management-assets/assets/icons.svg b/plugins/test-management-assets/assets/icons.svg index 6fb417124a..310dd6a509 100644 --- a/plugins/test-management-assets/assets/icons.svg +++ b/plugins/test-management-assets/assets/icons.svg @@ -3,12 +3,11 @@ - - + + - - - + + @@ -52,4 +51,21 @@ + + + + + + + + + + + + + + + + + diff --git a/plugins/test-management-assets/lang/cs.json b/plugins/test-management-assets/lang/cs.json index 78ab73bfd9..c3fcb8950a 100644 --- a/plugins/test-management-assets/lang/cs.json +++ b/plugins/test-management-assets/lang/cs.json @@ -61,6 +61,25 @@ "TestRunNamePlaceholder": "Název testovacího běhu", "SelectTestSuites": "Vyberte testovací sady", "SelectTestCases": "Vyberte testovací případy", - "TestLibrary": "Testovací knihovna" + "TestLibrary": "Testovací knihovna", + "TestResult": "Výsledek", + "TestRunResult": "Výsledek", + "TestRunStatus": "Stav", + "StatusNonTested": "Neprovedeno", + "StatusBlocked": "Blokováno", + "StatusPassed": "Prošlo", + "StatusFailed": "Neprošlo", + "SelectTestCase": "Vyberte testovací případ", + "Save": "Uložit", + "SaveAndNext": "Uložit a další", + "DonePercent": "% Hotovo", + "TestResults": "Výsledky", + "TestRunName": "Název", + "DueDate": "Termín", + "RunAllTestCases": "Spustit všechny testovací případy", + "RunSelectedTestCases": "Spustit vybrané testovací případy", + "RunFilteredTestCases": "Spustit testovací případy odpovídající filtru", + "TestCaseDescription": "Popis testovacího případu", + "TestResultAttributes": "Výsledek" } -} \ No newline at end of file +} diff --git a/plugins/test-management-assets/lang/en.json b/plugins/test-management-assets/lang/en.json index 135650b31c..8db61b86e5 100644 --- a/plugins/test-management-assets/lang/en.json +++ b/plugins/test-management-assets/lang/en.json @@ -57,10 +57,29 @@ "AssignedTo": "Assigneed to", "PreviousAssigned": "Previously assigned", "NoTestCases": "There are no test cases in this test suite", - "CreateTestRun": "Create test run", + "CreateTestRun": "New test run", "TestRunNamePlaceholder": "Test run name", "SelectTestSuites": "Select test suites", "SelectTestCases": "Select test cases", - "TestLibrary": "Test library" + "TestLibrary": "Test library", + "TestResult": "Result", + "TestRunResult": "Result", + "TestRunStatus": "Status", + "StatusNonTested": "Untested", + "StatusBlocked": "Blocked", + "StatusPassed": "Passed", + "StatusFailed": "Failed", + "SelectTestCase": "Select test case", + "Save": "Save", + "SaveAndNext": "Save and next", + "DonePercent": "% Done", + "TestResults": "Results", + "TestRunName": "Name", + "DueDate": "Due date", + "RunAllTestCases": "Run all test cases", + "RunSelectedTestCases": "Run selected test cases", + "RunFilteredTestCases": "Run test cases matching a filter", + "TestCaseDescription": "Test case description", + "TestResultAttributes": "Result" } } diff --git a/plugins/test-management-assets/lang/es.json b/plugins/test-management-assets/lang/es.json new file mode 100644 index 0000000000..2017de36c7 --- /dev/null +++ b/plugins/test-management-assets/lang/es.json @@ -0,0 +1,85 @@ +{ + "string": { + "ConfigLabel": "Gestión de Pruebas", + "ConfigDescription": "Extensión para gestionar casos de prueba", + "TestCaseType": "Tipo", + "TestCasePriority": "Prioridad", + "TestCaseStatus": "Estado", + "TestSuite": "Suite de Pruebas", + "SuiteName": "Nombre", + "SuiteDescription": "Descripción", + "Suite": "Suite", + "TestName": "Nombre", + "TestDescription": "Descripción", + "TestType": "Tipo", + "TestPriority": "Prioridad", + "TestStatus": "Estado", + "TestEstimatedTime": "Tiempo estimado", + "TestPreconditions": "Precondiciones", + "TestSteps": "Pasos", + "TestAssignee": "Asignado a", + "TestCase": "Caso de prueba", + "TestProject": "Proyecto", + "TestManagementApplication": "Gestión de Pruebas", + "AllTestCases": "Todos los casos de prueba", + "AllProjects": "Todos los proyectos", + "Projects": "Proyectos", + "CreateProject": "Crear proyecto", + "EditProject": "Editar proyecto", + "TestCases": "Casos de prueba", + "TestManagementDescription": "Extensión para gestionar casos de prueba", + "CreateTestCase": "Nuevo caso de prueba", + "FullDescription": "Descripción completa", + "ProjectName": "Nombre del proyecto", + "ProjectType": "Tipo de proyecto", + "Members": "Miembros", + "RoleLabel": "Rol", + "ProjectMembers": "Miembros del proyecto", + "TestSuites": "Suites de pruebas", + "CreateTestSuite": "Crear suite de pruebas", + "NamePlaceholder": "Nombre de la suite", + "DescriptionPlaceholder": "Descripción (opcional)", + "TestRuns": "Ejecuciones de prueba", + "NewTestRun": "Nueva ejecución de prueba", + "TestRun": "Ejecución de prueba", + "TestNamePlaceholder": "Título del caso de prueba", + "ChooseIcon": "Elegir ícono", + "NoTestSuite": "No hay suite de pruebas", + "StatusDraft": "Borrador", + "StatusReview": "Listo para revisión", + "StatusReviewComments": "Necesita corregir comentarios de revisión", + "StatusApproved": "Aprobado", + "StatusRejected": "Rechazado", + "SetStatus": "Establecer estado", + "Assignee": "Asignado a", + "Unassigned": "No asignado", + "AssignTo": "Asignar a", + "AssignedTo": "Asignado a", + "PreviousAssigned": "Asignado previamente", + "NoTestCases": "No hay casos de prueba en esta suite de pruebas", + "CreateTestRun": "Nueva ejecución de prueba", + "TestRunNamePlaceholder": "Nombre de la ejecución de prueba", + "SelectTestSuites": "Seleccionar suites de prueba", + "SelectTestCases": "Seleccionar casos de prueba", + "TestLibrary": "Biblioteca de pruebas", + "TestResult": "Resultado", + "TestRunResult": "Resultado", + "TestRunStatus": "Estado", + "StatusNonTested": "No probado", + "StatusBlocked": "Bloqueado", + "StatusPassed": "Aprobado", + "StatusFailed": "Fallido", + "SelectTestCase": "Seleccionar caso de prueba", + "Save": "Guardar", + "SaveAndNext": "Guardar y siguiente", + "DonePercent": "% Completado", + "TestResults": "Resultados", + "TestRunName": "Nombre", + "DueDate": "Fecha de vencimiento", + "RunAllTestCases": "Ejecutar todos los casos de prueba", + "RunSelectedTestCases": "Ejecutar casos de prueba seleccionados", + "RunFilteredTestCases": "Ejecutar casos de prueba que coincidan con un filtro", + "TestCaseDescription": "Descripción del caso de prueba", + "TestResultAttributes": "Resultado" + } +} diff --git a/plugins/test-management-assets/lang/fr.json b/plugins/test-management-assets/lang/fr.json index c2f3353047..f9b85b94f8 100644 --- a/plugins/test-management-assets/lang/fr.json +++ b/plugins/test-management-assets/lang/fr.json @@ -61,6 +61,25 @@ "TestRunNamePlaceholder": "Nom de l'exécution du test", "SelectTestSuites": "Sélectionnez les suites de tests", "SelectTestCases": "Sélectionnez des cas de test", - "TestLibrary": "Bibliothèque de tests" + "TestLibrary": "Bibliothèque de tests", + "TestResult": "Résultat", + "TestRunResult": "Résultat", + "TestRunStatus": "Statut", + "StatusNonTested": "Non testé", + "StatusBlocked": "Bloqué", + "StatusPassed": "Réussi", + "StatusFailed": "Échoué", + "SelectTestCase": "Sélectionnez le cas de test", + "Save": "Sauvegarder", + "SaveAndNext": "Sauvegarder et suivant", + "DonePercent": "% Fait", + "TestResults": "Résultats", + "TestRunName": "Nom", + "DueDate": "Date d'échéance", + "RunAllTestCases": "Exécuter tous les cas de test", + "RunSelectedTestCases": "Exécuter des scénarios de test sélectionnés", + "RunFilteredTestCases": "Exécuter des cas de test correspondant à un filtre", + "TestCaseDescription": "Description du cas de test", + "TestResultAttributes": "Résultat" } } diff --git a/plugins/test-management-assets/lang/it.json b/plugins/test-management-assets/lang/it.json new file mode 100644 index 0000000000..88acb35da4 --- /dev/null +++ b/plugins/test-management-assets/lang/it.json @@ -0,0 +1,85 @@ +{ + "string": { + "ConfigLabel": "Gestione Test", + "ConfigDescription": "Estensione per gestire i casi di test", + "TestCaseType": "Tipo", + "TestCasePriority": "Priorità", + "TestCaseStatus": "Stato", + "TestSuite": "Suite di Test", + "SuiteName": "Nome", + "SuiteDescription": "Descrizione", + "Suite": "Suite", + "TestName": "Nome", + "TestDescription": "Descrizione", + "TestType": "Tipo", + "TestPriority": "Priorità", + "TestStatus": "Stato", + "TestEstimatedTime": "Tempo stimato", + "TestPreconditions": "Precondizioni", + "TestSteps": "Passaggi", + "TestAssignee": "Assegnatario", + "TestCase": "Caso di test", + "TestProject": "Progetto", + "TestManagementApplication": "Gestione Test", + "AllTestCases": "Tutti i casi di test", + "AllProjects": "Tutti i progetti", + "Projects": "Progetti", + "CreateProject": "Crea progetto", + "EditProject": "Modifica progetto", + "TestCases": "Casi di test", + "TestManagementDescription": "Estensione per gestire i casi di test", + "CreateTestCase": "Nuovo caso di test", + "FullDescription": "Descrizione completa", + "ProjectName": "Nome", + "ProjectType": "Tipo", + "Members": "Membri", + "RoleLabel": "Ruolo", + "ProjectMembers": "Membri del progetto", + "TestSuites": "Suite di test", + "CreateTestSuite": "Crea suite di test", + "NamePlaceholder": "Nome della suite", + "DescriptionPlaceholder": "Descrizione (opzionale)", + "TestRuns": "Esecuzioni di test", + "NewTestRun": "Nuova esecuzione di test", + "TestRun": "Esecuzione di test", + "TestNamePlaceholder": "Titolo del caso di test", + "ChooseIcon": "Scegli icona", + "NoTestSuite": "Nessuna suite di test", + "StatusDraft": "Bozza", + "StatusReview": "Pronto per la revisione", + "StatusReviewComments": "Necessita di correggere i commenti della revisione", + "StatusApproved": "Approvato", + "StatusRejected": "Respinto", + "SetStatus": "Imposta stato", + "Assignee": "Assegnatario", + "Unassigned": "Non assegnato", + "AssignTo": "Assegna a", + "AssignedTo": "Assegnato a", + "PreviousAssigned": "Assegnato in precedenza", + "NoTestCases": "Non ci sono casi di test in questa suite di test", + "CreateTestRun": "Nuova esecuzione di test", + "TestRunNamePlaceholder": "Nome dell'esecuzione di test", + "SelectTestSuites": "Seleziona suite di test", + "SelectTestCases": "Seleziona casi di test", + "TestLibrary": "Libreria di test", + "TestResult": "Risultato", + "TestRunResult": "Risultato", + "TestRunStatus": "Stato", + "StatusNonTested": "Non testato", + "StatusBlocked": "Bloccato", + "StatusPassed": "Superato", + "StatusFailed": "Fallito", + "SelectTestCase": "Seleziona caso di test", + "Save": "Salva", + "SaveAndNext": "Salva e successivo", + "DonePercent": "% Completato", + "TestResults": "Risultati", + "TestRunName": "Nome", + "DueDate": "Data di scadenza", + "RunAllTestCases": "Esegui tutti i casi di test", + "RunSelectedTestCases": "Esegui i casi di test selezionati", + "RunFilteredTestCases": "Esegui i casi di test che corrispondono a un filtro", + "TestCaseDescription": "Descrizione del caso di test", + "TestResultAttributes": "Risultato" + } +} diff --git a/plugins/test-management-assets/lang/pt.json b/plugins/test-management-assets/lang/pt.json new file mode 100644 index 0000000000..2299b3cb58 --- /dev/null +++ b/plugins/test-management-assets/lang/pt.json @@ -0,0 +1,85 @@ +{ + "string": { + "ConfigLabel": "Gestão de Testes", + "ConfigDescription": "Extensão para gerenciar casos de teste", + "TestCaseType": "Tipo", + "TestCasePriority": "Prioridade", + "TestCaseStatus": "Status", + "TestSuite": "Suite de Testes", + "SuiteName": "Nome", + "SuiteDescription": "Descrição", + "Suite": "Suite", + "TestName": "Nome", + "TestDescription": "Descrição", + "TestType": "Tipo", + "TestPriority": "Prioridade", + "TestStatus": "Status", + "TestEstimatedTime": "Tempo estimado", + "TestPreconditions": "Precondições", + "TestSteps": "Passos", + "TestAssignee": "Responsável", + "TestCase": "Caso de teste", + "TestProject": "Projeto", + "TestManagementApplication": "Gestão de Testes", + "AllTestCases": "Todos os casos de teste", + "AllProjects": "Todos os projetos", + "Projects": "Projetos", + "CreateProject": "Criar projeto", + "EditProject": "Editar projeto", + "TestCases": "Casos de teste", + "TestManagementDescription": "Extensão para gerenciar casos de teste", + "CreateTestCase": "Novo caso de teste", + "FullDescription": "Descrição completa", + "ProjectName": "Nome", + "ProjectType": "Tipo", + "Members": "Membros", + "RoleLabel": "Função", + "ProjectMembers": "Membros do projeto", + "TestSuites": "Suites de teste", + "CreateTestSuite": "Criar suite de teste", + "NamePlaceholder": "Nome da suite", + "DescriptionPlaceholder": "Descrição (opcional)", + "TestRuns": "Execuções de teste", + "NewTestRun": "Nova execução de teste", + "TestRun": "Execução de teste", + "TestNamePlaceholder": "Título do caso de teste", + "ChooseIcon": "Escolher ícone", + "NoTestSuite": "Nenhuma suite de teste", + "StatusDraft": "Rascunho", + "StatusReview": "Pronto para revisão", + "StatusReviewComments": "Precisa corrigir comentários da revisão", + "StatusApproved": "Aprovado", + "StatusRejected": "Rejeitado", + "SetStatus": "Definir status", + "Assignee": "Responsável", + "Unassigned": "Não atribuído", + "AssignTo": "Atribuir a", + "AssignedTo": "Atribuído a", + "PreviousAssigned": "Atribuído anteriormente", + "NoTestCases": "Não há casos de teste nesta suite de teste", + "CreateTestRun": "Nova execução de teste", + "TestRunNamePlaceholder": "Nome da execução de teste", + "SelectTestSuites": "Selecionar suites de teste", + "SelectTestCases": "Selecionar casos de teste", + "TestLibrary": "Biblioteca de testes", + "TestResult": "Resultado", + "TestRunResult": "Resultado", + "TestRunStatus": "Status", + "StatusNonTested": "Não testado", + "StatusBlocked": "Bloqueado", + "StatusPassed": "Aprovado", + "StatusFailed": "Falhou", + "SelectTestCase": "Selecionar caso de teste", + "Save": "Salvar", + "SaveAndNext": "Salvar e próximo", + "DonePercent": "% Concluído", + "TestResults": "Resultados", + "TestRunName": "Nome", + "DueDate": "Data de vencimento", + "RunAllTestCases": "Executar todos os casos de teste", + "RunSelectedTestCases": "Executar casos de teste selecionados", + "RunFilteredTestCases": "Executar casos de teste que correspondem a um filtro", + "TestCaseDescription": "Descrição do caso de teste", + "TestResultAttributes": "Resultado" + } +} diff --git a/plugins/test-management-assets/lang/ru.json b/plugins/test-management-assets/lang/ru.json index 19a45e0d6c..b94ff588d2 100644 --- a/plugins/test-management-assets/lang/ru.json +++ b/plugins/test-management-assets/lang/ru.json @@ -61,6 +61,25 @@ "TestRunNamePlaceholder": "Название тест плана", "SelectTestSuites": "Выбрать наборы тестов", "SelectTestCases": "Выбрать тест-кейсы", - "TestLibrary": "Библиотека тестов" + "TestLibrary": "Библиотека тестов", + "TestResult": "Результат", + "TestRunResult": "Результат", + "TestRunStatus": "Статус", + "StatusNonTested": "Не протестировано", + "StatusBlocked": "Заблокировано", + "StatusPassed": "Пройдено", + "StatusFailed": "Не пройдено", + "SelectTestCase": "Выбрать тест-кейс", + "Save": "Сохранить", + "SaveAndNext": "Сохранить и перейти к следующему", + "DonePercent": "% Выполнено", + "TestResults": "Результаты", + "TestRunName": "Название", + "DueDate": "Выполнить до", + "RunAllTestCases": "Выполнить все тест-кейсы", + "RunSelectedTestCases": "Выполнить выбранные тест-кейсы", + "RunFilteredTestCases": "Выполнить тест-кейсы, соответствующие фильтру", + "TestCaseDescription": "Описание тест-кейса", + "TestResultAttributes": "Результат" } } diff --git a/plugins/test-management-assets/lang/zh.json b/plugins/test-management-assets/lang/zh.json index c7da64156d..784fe300ea 100644 --- a/plugins/test-management-assets/lang/zh.json +++ b/plugins/test-management-assets/lang/zh.json @@ -61,6 +61,25 @@ "TestRunNamePlaceholder": "測試運行名稱", "SelectTestSuites": "選擇測試套件", "SelectTestCases": "選擇測試用例", - "TestLibrary": "測試庫" + "TestLibrary": "測試庫", + "TestResult": "結果", + "TestRunResult": "結果", + "TestRunStatus": "狀態", + "StatusNonTested": "未測試", + "StatusBlocked": "被阻止", + "StatusPassed": "通過", + "StatusFailed": "失敗", + "SelectTestCase": "選擇測試用例", + "Save": "保存", + "SaveAndNext": "保存並繼續", + "DonePercent": "%完成", + "TestResults": "結果", + "TestRunName": "姓名", + "DueDate": "到期日", + "RunAllTestCases": "運行所有測試用例", + "RunSelectedTestCases": "運行選定的測試用例", + "RunFilteredTestCases": "運行與篩選器匹配的測試用例", + "TestCaseDescription": "測試用例描述", + "TestResultAttributes": "結果" } } diff --git a/plugins/test-management-assets/src/index.ts b/plugins/test-management-assets/src/index.ts index 8a50befa21..4192353617 100644 --- a/plugins/test-management-assets/src/index.ts +++ b/plugins/test-management-assets/src/index.ts @@ -36,5 +36,12 @@ loadMetadata(testManagement.icon, { StatusReviewComments: `${icons}#status-review-comments`, StatusApproved: `${icons}#status-approved`, StatusRejected: `${icons}#status-canceled`, - TestLibrary: `${icons}#test-library` + TestLibrary: `${icons}#test-library`, + TestResult: `${icons}#testResult`, + StatusNonTested: `${icons}#status-draft`, + StatusBlocked: `${icons}#status-review-comments`, + StatusPassed: `${icons}#status-approved`, + StatusFailed: `${icons}#status-canceled`, + Filter: `${icons}#filter`, + Check: `${icons}#check` }) diff --git a/plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte b/plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte deleted file mode 100644 index ea405cd046..0000000000 --- a/plugins/test-management-resources/src/components/test-case/AssigneeEditor.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - - -{#if _object} - {#if isAction} - { - const result = evt.detail - if (result === null) { - handleAssigneeChanged(null) - } else if (result !== undefined && result._id !== value) { - value = result._id - handleAssigneeChanged(result._id) - } - }} - /> - {:else} - handleAssigneeChanged(detail)} - /> - {/if} -{/if} diff --git a/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte b/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte index aee61920a7..78b0437035 100644 --- a/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte +++ b/plugins/test-management-resources/src/components/test-case/CreateTestCase.svelte @@ -24,7 +24,6 @@ import { Button, createFocusManager, EditBox, FocusHandler, IconAttachment, getLocation } from '@hcengineering/ui' import StatusEditor from './StatusEditor.svelte' - import AssigneeEditor from './AssigneeEditor.svelte' import ProjectPresenter from '../project/ProjectPresenter.svelte' import testManagement from '../../plugin' @@ -164,12 +163,6 @@ /> - (object.assignee = detail)} - /> diff --git a/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte b/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte index d4cc7ed342..dec99743de 100644 --- a/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte +++ b/plugins/test-management-resources/src/components/test-case/EditTestCase.svelte @@ -13,13 +13,16 @@ // limitations under the License. --> + + { + void handleDropdownItemSelected(ev.detail) + }} +/> diff --git a/plugins/test-management-resources/src/components/test-case/StatusEditor.svelte b/plugins/test-management-resources/src/components/test-case/StatusEditor.svelte index 76560aa9bf..959a9c8ab4 100644 --- a/plugins/test-management-resources/src/components/test-case/StatusEditor.svelte +++ b/plugins/test-management-resources/src/components/test-case/StatusEditor.svelte @@ -43,7 +43,13 @@ const dispatch = createEventDispatcher() const client = getClient() - function handlePopupOpen (event: MouseEvent) { + $: itemsInfo = defaultTestCaseStatuses.map((status) => ({ + id: status, + isSelected: value === status, + ...testCaseStatusAssets[status] + })) + + function handlePopupOpen (event: MouseEvent): void { showPopup( SelectPopup, { value: itemsInfo, placeholder: testManagement.string.SetStatus }, @@ -52,7 +58,7 @@ ) } - async function changeStatus (newStatus: TestCase['status'] | null | undefined) { + async function changeStatus (newStatus: TestCase['status'] | null | undefined): Promise { if (disabled || newStatus == null || value === newStatus) { return } @@ -65,12 +71,6 @@ } } - $: itemsInfo = defaultTestCaseStatuses.map((status) => ({ - id: status, - isSelected: value === status, - ...testCaseStatusAssets[status] - })) - $: icon = value === undefined ? testManagement.icon.StatusDraft : testCaseStatusAssets[value].icon $: label = value === undefined ? testManagement.string.StatusDraft : testCaseStatusAssets[value].label diff --git a/plugins/test-management-resources/src/components/test-case/TestCaseDetails.svelte b/plugins/test-management-resources/src/components/test-case/TestCaseDetails.svelte new file mode 100644 index 0000000000..669d741fa2 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-case/TestCaseDetails.svelte @@ -0,0 +1,58 @@ + + + +{#if object} +
+ +
+{/if} diff --git a/plugins/test-management-resources/src/components/test-case/TestCasePopup.svelte b/plugins/test-management-resources/src/components/test-case/TestCasePopup.svelte new file mode 100644 index 0000000000..d7d6626541 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-case/TestCasePopup.svelte @@ -0,0 +1,47 @@ + + + + + + + + diff --git a/plugins/test-management-resources/src/components/test-case/TestCasePresenter.svelte b/plugins/test-management-resources/src/components/test-case/TestCasePresenter.svelte index 7968cb324b..e79ec689d0 100644 --- a/plugins/test-management-resources/src/components/test-case/TestCasePresenter.svelte +++ b/plugins/test-management-resources/src/components/test-case/TestCasePresenter.svelte @@ -26,13 +26,14 @@ export let disabled: boolean = false export let accent: boolean = false export let noUnderline: boolean = false + export let onClick: ((event: MouseEvent) => void) | undefined = undefined {#if value} {#if inline} {:else} - +
{value.name} diff --git a/plugins/test-management-resources/src/components/test-case/TestCaseSelector.svelte b/plugins/test-management-resources/src/components/test-case/TestCaseSelector.svelte new file mode 100644 index 0000000000..bec36ccf89 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-case/TestCaseSelector.svelte @@ -0,0 +1,67 @@ + + + + + diff --git a/plugins/test-management-resources/src/components/test-result/EditTestResult.svelte b/plugins/test-management-resources/src/components/test-result/EditTestResult.svelte new file mode 100644 index 0000000000..41020b475f --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/EditTestResult.svelte @@ -0,0 +1,105 @@ + + + +{#if object} + + dispatch('close')} + > +
+
+ +
+ + + + + + + + + + +{/if} diff --git a/plugins/test-management-resources/src/components/test-result/RightHeader.svelte b/plugins/test-management-resources/src/components/test-result/RightHeader.svelte new file mode 100644 index 0000000000..0429820bd8 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/RightHeader.svelte @@ -0,0 +1,24 @@ + + +
+ +
+ + diff --git a/plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte b/plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte new file mode 100644 index 0000000000..2554f5ed7d --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultFooter.svelte @@ -0,0 +1,31 @@ + + + + +
+
diff --git a/plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte b/plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte new file mode 100644 index 0000000000..668c1dde60 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultHeader.svelte @@ -0,0 +1,25 @@ + + + +
+
+ +
diff --git a/plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte b/plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte new file mode 100644 index 0000000000..f2f5005c15 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultPresenter.svelte @@ -0,0 +1,55 @@ + + + + +{#if value} + {#if inline} + + {:else} + +
+ {#if shouldShowAvatar} +
+ +
+ {/if} + + {title} + +
+
+ {/if} +{/if} diff --git a/plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte b/plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte new file mode 100644 index 0000000000..dea4e87317 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-result/TestResultStatusEditor.svelte @@ -0,0 +1,106 @@ + + + +{#if kind === 'list'} + +{:else if kind === 'list-header'} +
+ {#if shouldShowAvatar} + + {/if} + +
+{:else} +
{#if testCases > 0} @@ -67,7 +60,7 @@ @@ -78,7 +71,7 @@ - + diff --git a/plugins/test-management-resources/src/components/test-run/TestRunAside.svelte b/plugins/test-management-resources/src/components/test-run/TestRunAside.svelte new file mode 100644 index 0000000000..823f876197 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/TestRunAside.svelte @@ -0,0 +1,32 @@ + + + + + +
+ +
+ diff --git a/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte b/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte index 5157718452..9b38f419d5 100644 --- a/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte +++ b/plugins/test-management-resources/src/components/test-run/TestRunPresenter.svelte @@ -13,6 +13,28 @@ // limitations under the License. --> -
+{#if value} + {#if inline} + + {:else} + +
+ + {value.name} + +
+
+ {/if} +{/if} diff --git a/plugins/test-management-resources/src/components/test-run/TestRunResult.svelte b/plugins/test-management-resources/src/components/test-run/TestRunResult.svelte new file mode 100644 index 0000000000..c43e70d539 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/TestRunResult.svelte @@ -0,0 +1,64 @@ + + + + + + + + + + diff --git a/plugins/test-management-resources/src/components/test-run/TestRunStats.svelte b/plugins/test-management-resources/src/components/test-run/TestRunStats.svelte new file mode 100644 index 0000000000..ff10978881 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/TestRunStats.svelte @@ -0,0 +1,55 @@ + + + +
+ + {#if !isLoading && stats !== undefined} + + {:else} + + {/if} + + + {#if !isLoading} +
+
+ +
+ {stats?.done ?? 0} +
+ {:else} + + {/if} +
diff --git a/plugins/test-management-resources/src/components/test-run/ViewContainer.svelte b/plugins/test-management-resources/src/components/test-run/ViewContainer.svelte new file mode 100644 index 0000000000..2dbeed8373 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/ViewContainer.svelte @@ -0,0 +1,25 @@ + + + +
+ +
+ diff --git a/plugins/test-management-resources/src/components/test-run/store/testRunStore.ts b/plugins/test-management-resources/src/components/test-run/store/testRunStore.ts new file mode 100644 index 0000000000..9f04f26ed4 --- /dev/null +++ b/plugins/test-management-resources/src/components/test-run/store/testRunStore.ts @@ -0,0 +1,22 @@ +// +// 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. +// + +import type { TestCase, TestResult } from '@hcengineering/test-management' +import { type Ref } from '@hcengineering/core' +import { type Writable, writable } from 'svelte/store' + +export const currentTestCase: Writable | undefined> = writable(undefined) + +export const selectedTestRun: Writable = writable(undefined) diff --git a/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte b/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte index e408edbfd2..13241c1b91 100644 --- a/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte +++ b/plugins/test-management-resources/src/components/test-suite/EditTestSuite.svelte @@ -21,7 +21,6 @@ import { EditBox, Breadcrumb } from '@hcengineering/ui' import { createEventDispatcher, onMount } from 'svelte' - import TestCasesList from './TestCasesList.svelte' import testManagement from '../../plugin' export let _id: Ref @@ -95,9 +94,5 @@ showButtons={false} />
- -
- -
{/if} diff --git a/plugins/test-management-resources/src/index.ts b/plugins/test-management-resources/src/index.ts index 3776daf5b4..f7f88cf904 100644 --- a/plugins/test-management-resources/src/index.ts +++ b/plugins/test-management-resources/src/index.ts @@ -28,9 +28,17 @@ import CreateTestRun from './components/test-run/CreateTestRun.svelte' import TestCaseStatusPresenter from './components/test-case/TestCaseStatusPresenter.svelte' import EditTestRun from './components/test-run/EditTestRun.svelte' import TestRunPresenter from './components/test-run/TestRunPresenter.svelte' +import RunSelectedTestsButton from './components/test-case/RunSelectedTestsButton.svelte' +import TestResultStatusPresenter from './components/test-result/TestResultStatusPresenter.svelte' +import TestResultStatusEditor from './components/test-result/TestResultStatusEditor.svelte' +import TestRunResult from './components/test-run/TestRunResult.svelte' +import TestResultPresenter from './components/test-result/TestResultPresenter.svelte' +import EditTestResult from './components/test-result/EditTestResult.svelte' +import TestResultHeader from './components/test-result/TestResultHeader.svelte' +import TestResultFooter from './components/test-result/TestResultFooter.svelte' -import { CreateChildTestSuiteAction, EditTestSuiteAction } from './utils' -import { resolveLocation, getTestSuiteLink } from './navigation' +import { CreateChildTestSuiteAction, EditTestSuiteAction, RunSelectedTestsAction } from './utils' +import { resolveLocation, getAttachedObjectLink } from './navigation' export default async (): Promise => ({ component: { @@ -47,16 +55,26 @@ export default async (): Promise => ({ TestCaseStatusPresenter, EditTestRun, TestRunPresenter, - TestSuiteRefPresenter + TestSuiteRefPresenter, + RunSelectedTestsButton, + TestResultStatusPresenter, + TestResultStatusEditor, + TestRunResult, + TestResultPresenter, + EditTestResult, + TestResultHeader, + TestResultFooter }, function: { - GetTestSuiteLink: getTestSuiteLink + GetTestSuiteLink: getAttachedObjectLink, + GetTestRunLink: getAttachedObjectLink }, resolver: { Location: resolveLocation }, actionImpl: { CreateChildTestSuite: CreateChildTestSuiteAction, - EditTestSuite: EditTestSuiteAction + EditTestSuite: EditTestSuiteAction, + RunSelectedTests: RunSelectedTestsAction } }) diff --git a/plugins/test-management-resources/src/navigation.ts b/plugins/test-management-resources/src/navigation.ts index 775011ce07..7d0ab508eb 100644 --- a/plugins/test-management-resources/src/navigation.ts +++ b/plugins/test-management-resources/src/navigation.ts @@ -61,14 +61,9 @@ async function generateProjectLocation ( } } -export function getTestSuiteLink (testSuite: Ref): Location { +export function getAttachedObjectLink (parentDoc: Ref): Location { const loc = getCurrentResolvedLocation() - loc.query = - testSuite === undefined - ? undefined - : { - [SUITE_KEY]: testSuite - } + loc.query = parentDoc === undefined ? undefined : { attachedTo: parentDoc } return loc } @@ -84,6 +79,16 @@ export function getTestSuiteIdFromLocation (): Ref { return (location?.query?.[SUITE_KEY] as Ref) ?? testManagement.ids.NoParent } +export function getTestRunsLink (parentDoc: Ref): Location { + const loc = getCurrentResolvedLocation() + loc.path.length = 4 + loc.path[3] = 'testRuns' + loc.fragment = undefined + loc.query = parentDoc === undefined ? undefined : { attachedTo: parentDoc } + + return loc +} + export async function resolveLocation (loc: Location): Promise { if (loc.path[2] !== testManagementId) { return undefined diff --git a/plugins/test-management-resources/src/testRunUtils.ts b/plugins/test-management-resources/src/testRunUtils.ts new file mode 100644 index 0000000000..4552190e95 --- /dev/null +++ b/plugins/test-management-resources/src/testRunUtils.ts @@ -0,0 +1,65 @@ +// +// 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. +// +import { type Ref } from '@hcengineering/core' +import { getClient } from '@hcengineering/presentation' +import testManagement, { type TestRun, type TestCase, TestRunStatus } from '@hcengineering/test-management' + +export async function getTestCases (objectId: Ref): Promise { + if (objectId === undefined) { + return [] + } + const client = getClient() + const testResults = await client.findAll(testManagement.class.TestResult, { attachedTo: objectId }) + const testCaseIds = testResults.map((testResult) => testResult.testCase) + return await client.findAll(testManagement.class.TestCase, { _id: { $in: testCaseIds } }) +} + +export interface TestRunStats { + readonly done: number + readonly untested: number + readonly blocked: number + readonly completed: number + readonly failed: number +} + +async function getTestResultCount (objectId: Ref, status: TestRunStatus): Promise { + const client = getClient() + const testResults = await client.findAll( + testManagement.class.TestResult, + { + attachedTo: objectId, + status + }, + { limit: 0, total: true } + ) + return testResults.total > 0 ? testResults.total : 0 +} + +export async function getTestRunStats (objectId: Ref): Promise { + const untested = await getTestResultCount(objectId, TestRunStatus.Untested) + const blocked = await getTestResultCount(objectId, TestRunStatus.Blocked) + const completed = await getTestResultCount(objectId, TestRunStatus.Passed) + const failed = await getTestResultCount(objectId, TestRunStatus.Failed) + const total = untested + blocked + completed + failed + + const done = total > 0 ? ((total - untested) * 100) / total : 0 + return { + done, + untested, + blocked, + completed, + failed + } +} diff --git a/plugins/test-management-resources/src/types.ts b/plugins/test-management-resources/src/types.ts index 2b2ffc954d..40f555855a 100644 --- a/plugins/test-management-resources/src/types.ts +++ b/plugins/test-management-resources/src/types.ts @@ -14,7 +14,7 @@ // import { type Asset, type IntlString } from '@hcengineering/platform' -import testManagement, { TestCaseStatus } from '@hcengineering/test-management' +import testManagement, { TestCaseStatus, TestRunStatus } from '@hcengineering/test-management' /** @public */ export const defaultTestCaseStatuses = [ @@ -39,3 +39,31 @@ export const testCaseStatusAssets: Record = { + [TestRunStatus.Untested]: { + icon: testManagement.icon.StatusNonTested, + label: testManagement.string.StatusNonTested + }, + [TestRunStatus.Blocked]: { + icon: testManagement.icon.StatusBlocked, + label: testManagement.string.StatusBlocked + }, + [TestRunStatus.Passed]: { + icon: testManagement.icon.StatusPassed, + label: testManagement.string.StatusPassed + }, + [TestRunStatus.Failed]: { + icon: testManagement.icon.StatusFailed, + label: testManagement.string.StatusFailed + } +} diff --git a/plugins/test-management-resources/src/utils.ts b/plugins/test-management-resources/src/utils.ts index 823692ebf8..b0472678dc 100644 --- a/plugins/test-management-resources/src/utils.ts +++ b/plugins/test-management-resources/src/utils.ts @@ -13,9 +13,7 @@ // limitations under the License. // -import { type Contact } from '@hcengineering/contact' -import core, { type Doc, type Ref, type TxCreateDoc, type TxUpdateDoc } from '@hcengineering/core' -import { getClient } from '@hcengineering/presentation' +import type { Doc, DocumentQuery, Ref } from '@hcengineering/core' import { showPopup } from '@hcengineering/ui' import { type TestProject, type TestCase, type TestSuite } from '@hcengineering/test-management' @@ -23,34 +21,7 @@ import CreateTestSuiteComponent from './components/test-suite/CreateTestSuite.sv import EditTestSuiteComponent from './components/test-suite/EditTestSuite.svelte' import CreateTestCase from './components/test-case/CreateTestCase.svelte' import CreateProject from './components/project/CreateProject.svelte' - -export async function getPreviousAssignees (objectId: Ref | undefined): Promise>> { - if (objectId === undefined) { - return [] - } - const client = getClient() - const createTx = ( - await client.findAll>(core.class.TxCreateDoc, { - objectId: objectId as Ref - }) - )[0] - const updateTxes = await client.findAll>( - core.class.TxUpdateDoc, - { objectId: objectId as Ref, 'operations.assignee': { $exists: true } }, - { sort: { modifiedOn: -1 } } - ) - const set = new Set>() - const createAssignee = createTx?.attributes?.assignee - for (const tx of updateTxes) { - const assignee = tx.operations.assignee - if (assignee == null) continue - set.add(assignee) - } - if (createAssignee != null) { - set.add(createAssignee) - } - return Array.from(set) -} +import CreateTestRun from './components/test-run/CreateTestRun.svelte' export async function showCreateTestSuitePopup ( space: Ref | undefined, @@ -71,6 +42,14 @@ export async function showCreateProjectPopup (): Promise { showPopup(CreateProject, {}, 'top') } +export async function showCreateTestRunPopup (options: { + testCases?: TestCase[] + query?: DocumentQuery + space: Ref +}): Promise { + showPopup(CreateTestRun, options, 'top') +} + export async function CreateChildTestSuiteAction (doc: TestSuite): Promise { await showCreateTestSuitePopup(doc.space, doc._id) } @@ -78,3 +57,12 @@ export async function CreateChildTestSuiteAction (doc: TestSuite): Promise export async function EditTestSuiteAction (doc: TestSuite): Promise { await showEditTestSuitePopup(doc._id) } + +export async function RunSelectedTestsAction (testCases: TestCase[]): Promise { + if (testCases?.length > 0) { + const space = testCases[0].space + await showCreateTestRunPopup({ testCases, space }) + } else { + console.error('No test cases selected') + } +} diff --git a/plugins/test-management/src/analytics.ts b/plugins/test-management/src/analytics.ts new file mode 100644 index 0000000000..b92682a729 --- /dev/null +++ b/plugins/test-management/src/analytics.ts @@ -0,0 +1,26 @@ +// +// 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. +// + +export enum TestManagementEvents { + TestCaseCreated = 'testManagement.TestCaseCreated', + TestCaseEdited = 'testManagement.TestCaseEdited', + TestCaseDeleted = 'testManagement.TestCaseDeleted', + TestSuiteCreated = 'testManagement.TestSuiteCreated', + TestSuiteEdited = 'testManagement.TestSuiteEdited', + TestSuiteDeleted = 'testManagement.TestSuiteDeleted', + TestRunCreated = 'testManagement.TestRunCreated', + TestRunEdited = 'testManagement.TestRunEdited', + TestRunDeleted = 'testManagement.TestRunDeleted' +} diff --git a/plugins/test-management/src/index.ts b/plugins/test-management/src/index.ts index 90cd70a0a9..e19ee4f581 100644 --- a/plugins/test-management/src/index.ts +++ b/plugins/test-management/src/index.ts @@ -16,6 +16,7 @@ import { testManagementId, testManagementPlugin } from './plugin' export * from './types' +export * from './analytics' export { testManagementId } export default testManagementPlugin diff --git a/plugins/test-management/src/plugin.ts b/plugins/test-management/src/plugin.ts index e5104633ed..f5b6a0363e 100644 --- a/plugins/test-management/src/plugin.ts +++ b/plugins/test-management/src/plugin.ts @@ -28,7 +28,7 @@ import type { Asset, IntlString, Plugin, Resource } from '@hcengineering/platfor import { plugin } from '@hcengineering/platform' import { type AnyComponent, type Location, type ResolvedLocation } from '@hcengineering/ui' -import { ActionCategory, Viewlet } from '@hcengineering/view' +import { Action, ActionCategory, ViewAction, Viewlet } from '@hcengineering/view' import { TestSuite, TestCase, @@ -37,8 +37,8 @@ import { TestCasePriority, TestCaseStatus, TestRun, - TestRunItem, - TestRunResult + TestRunStatus, + TestResult } from './types' /** @public */ @@ -49,6 +49,17 @@ export const testManagementPlugin = plugin(testManagementId, { app: { TestManagement: '' as Ref }, + action: { + DeleteTestCase: '' as Ref>, + CreateChildTestSuite: '' as Ref, + EditTestSuite: '' as Ref, + RunSelectedTests: '' as Ref + }, + actionImpl: { + CreateChildTestSuite: '' as ViewAction, + EditTestSuite: '' as ViewAction, + RunSelectedTests: '' as ViewAction + }, icon: { TestManagement: '' as Asset, TestManagementVersion: '' as Asset, @@ -68,7 +79,14 @@ export const testManagementPlugin = plugin(testManagementId, { StatusApproved: '' as Asset, StatusRejected: '' as Asset, Document: '' as Asset, - TestLibrary: '' as Asset + TestLibrary: '' as Asset, + TestResult: '' as Asset, + StatusNonTested: '' as Asset, + StatusBlocked: '' as Asset, + StatusPassed: '' as Asset, + StatusFailed: '' as Asset, + Filter: '' as Asset, + Check: '' as Asset }, class: { TestCase: '' as Ref>, @@ -78,8 +96,8 @@ export const testManagementPlugin = plugin(testManagementId, { TypeTestCasePriority: '' as Ref>>, TypeTestCaseStatus: '' as Ref>>, TestRun: '' as Ref>, - TestRunItem: '' as Ref>, - TypeTestRunResult: '' as Ref>> + TypeTestRunStatus: '' as Ref>>, + TestResult: '' as Ref> }, descriptors: { ProjectType: '' as Ref @@ -150,14 +168,28 @@ export const testManagementPlugin = plugin(testManagementId, { TestRunName: '' as IntlString, NoTestCases: '' as IntlString, DueDate: '' as IntlString, - TestRunItems: '' as IntlString, - TestRunResult: '' as IntlString, - TestRunItem: '' as IntlString, + TestRunStatus: '' as IntlString, TestRunNamePlaceholder: '' as IntlString, + TestRunResult: '' as IntlString, SelectTestSuites: '' as IntlString, SelectTestCases: '' as IntlString, CreateTestRun: '' as IntlString, - TestLibrary: '' as IntlString + TestLibrary: '' as IntlString, + TestResult: '' as IntlString, + StatusNonTested: '' as IntlString, + StatusBlocked: '' as IntlString, + StatusPassed: '' as IntlString, + StatusFailed: '' as IntlString, + SelectTestCase: '' as IntlString, + Save: '' as IntlString, + SaveAndNext: '' as IntlString, + DonePercent: '' as IntlString, + TestResults: '' as IntlString, + RunAllTestCases: '' as IntlString, + RunSelectedTestCases: '' as IntlString, + RunFilteredTestCases: '' as IntlString, + TestCaseDescription: '' as IntlString, + TestResultAttributes: '' as IntlString }, category: { TestManagement: '' as Ref @@ -171,7 +203,12 @@ export const testManagementPlugin = plugin(testManagementId, { PriorityIconPresenter: '' as AnyComponent, TestCaseStatusPresenter: '' as AnyComponent, TestSuites: '' as AnyComponent, - CreateTestSuite: '' as AnyComponent + CreateTestSuite: '' as AnyComponent, + TestRunFromSelection: '' as AnyComponent, + TestResultStatusPresenter: '' as AnyComponent, + TestResultStatusEditor: '' as AnyComponent, + TestRunResult: '' as AnyComponent, + TestResultHeader: '' as AnyComponent }, ids: { NoParent: '' as Ref, @@ -193,9 +230,9 @@ export const testManagementPlugin = plugin(testManagementId, { viewlet: { TableTestCase: '' as Ref, TableTestSuites: '' as Ref, - TableTestRun: '' as Ref, - SuiteTestCases: '' as Ref, - ListTestCase: '' as Ref + ListTestCase: '' as Ref, + TestResultList: '' as Ref, + TableTestResult: '' as Ref }, testCaseTypeStatus: { Draft: '' as Ref, @@ -207,7 +244,8 @@ export const testManagementPlugin = plugin(testManagementId, { TestCase: '' as Ref }, function: { - GetTestSuiteLink: '' as Resource<(doc: Ref) => Location> + GetTestSuiteLink: '' as Resource<(doc: Ref) => Location>, + GetTestRunLink: '' as Resource<(doc: Ref) => Location> }, resolver: { Location: '' as Resource<(loc: Location) => Promise> diff --git a/plugins/test-management/src/types.ts b/plugins/test-management/src/types.ts index 1e07df32cb..7a10dab70f 100644 --- a/plugins/test-management/src/types.ts +++ b/plugins/test-management/src/types.ts @@ -103,20 +103,26 @@ export interface TestRun extends Doc { name: string description: CollaborativeDoc dueDate?: Timestamp - items?: CollectionSize + results?: CollectionSize } /** @public */ -export enum TestRunResult { - Passed, +export enum TestRunStatus { + Untested, Blocked, + Passed, Failed } +// TODO: Refactor to associations /** @public */ -export interface TestRunItem extends AttachedDoc { - testRun: Ref +export interface TestResult extends AttachedDoc { + name: string testCase: Ref - result?: TestRunResult + testSuite?: Ref + status?: TestRunStatus + description: CollaborativeDoc + assignee?: Ref + attachments?: CollectionSize comments?: number } diff --git a/plugins/view-resources/src/__tests__/folderUtils.test.ts b/plugins/view-resources/src/__tests__/folderUtils.test.ts new file mode 100644 index 0000000000..1ce0c18082 --- /dev/null +++ b/plugins/view-resources/src/__tests__/folderUtils.test.ts @@ -0,0 +1,183 @@ +// 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. +import { type Doc } from '@hcengineering/core' + +import { FoldersManager, emptyFoldersState, type FoldersState } from '../components/folders/store/folderUtils' + +describe('foldersManager', () => { + test('folders tree', () => { + const docs: Doc[] = [ + { + name: 'Child 1', + description: '', + parent: '673ef344595d5369f68ced98', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732178763949, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732178763949, + docUpdateMessages: 1, + _id: '673ef34b595d5369f68ceda2', + space: '673434ed74993781b4195e02' + }, + { + name: 'Child 2', + description: '', + parent: '673ef344595d5369f68ced98', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732178770252, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732178770252, + docUpdateMessages: 1, + _id: '673ef352595d5369f68cedac', + space: '673434ed74993781b4195e02' + }, + { + _id: '673434f674993781b4195e1b', + name: 'New suite', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731495620083, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731474678014, + docUpdateMessages: 2, + testCases: 1, + space: '673434ed74993781b4195e02' + }, + { + name: 'Parent', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732178756247, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732178756247, + docUpdateMessages: 1, + _id: '673ef344595d5369f68ced98', + space: '673434ed74993781b4195e02' + }, + { + _id: '6734863403ae9eca5b01e4fd', + name: 'test', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731495529218, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731495476678, + docUpdateMessages: 4, + testCases: 3, + space: '673434ed74993781b4195e02' + } + ] as any + const foldersManager = new FoldersManager('name', 'parent', 'testManagement:ids:NoParent' as any, false, () => {}) + const foldersState = foldersManager.getNestedStructure(docs) + + expect(foldersState.folders.length).toBe(3) + expect(foldersState.folderById.size).toBe(5) + expect(foldersState.descendants.get('673ef344595d5369f68ced98' as any)?.length).toBe(2) + }) + + test('plain list', () => { + const docs: Doc[] = [ + { + _id: '6739541d04aed0096599e03a', + name: '3 items', + description: '6739541704aed0096599e032%description:HEAD:0', + _class: 'testManagement:class:TestRun', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731810333365, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731810333307, + docUpdateMessages: 4, + testCases: 3, + space: '673434ed74993781b4195e02' + }, + { + name: 'Test run 1', + description: '673ef510595d5369f68cee0a%description:HEAD:0', + _class: 'testManagement:class:TestRun', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732179220778, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732179220778, + testCases: 2, + docUpdateMessages: 3, + _id: '673ef514595d5369f68cee12', + space: '673434ed74993781b4195e02' + }, + { + name: 'Test run 2', + description: '673ef516595d5369f68cee18%description:HEAD:0', + _class: 'testManagement:class:TestRun', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1732179227645, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1732179227611, + testCases: 2, + docUpdateMessages: 3, + _id: '673ef51b595d5369f68cee20', + space: '673434ed74993781b4195e02' + } + ] as any + const foldersManager = new FoldersManager('name', 'parent', 'noParent' as any, true, () => {}) + const foldersState = foldersManager.getPlainList(docs) + + expect(foldersState.folders.length).toBe(3) + expect(foldersState.folderById.size).toBe(3) + expect(foldersState.descendants.size).toBe(0) + }) + + test('test state update', () => { + const docs: Doc[] = [ + { + _id: '673434f674993781b4195e1b', + name: 'New suite', + description: '', + parent: 'testManagement:ids:NoParent', + _class: 'testManagement:class:TestSuite', + modifiedBy: '67286f072788ebca822ea1b2', + modifiedOn: 1731495620083, + createdBy: '67286f072788ebca822ea1b2', + createdOn: 1731474678014, + docUpdateMessages: 2, + testCases: 1, + space: '673434ed74993781b4195e02' + } + ] as any + let folderState = emptyFoldersState() + + expect(folderState.folders.length).toBe(0) + expect(folderState.descendants.size).toBe(0) + expect(folderState.folderById.size).toBe(0) + + const foldersManager = new FoldersManager( + 'name', + 'parent', + 'testManagement:ids:NoParent' as any, + false, + (state: FoldersState) => { + folderState = state + } + ) + foldersManager.setFolders(docs) + + expect(folderState.folders.length).toBe(1) + expect(folderState.folderById.size).toBe(1) + }) +}) diff --git a/plugins/view-resources/src/components/FolderTreeLevel.svelte b/plugins/view-resources/src/components/folders/FolderTreeLevel.svelte similarity index 99% rename from plugins/view-resources/src/components/FolderTreeLevel.svelte rename to plugins/view-resources/src/components/folders/FolderTreeLevel.svelte index d8eb2bff9e..96f0ada0e7 100644 --- a/plugins/view-resources/src/components/FolderTreeLevel.svelte +++ b/plugins/view-resources/src/components/folders/FolderTreeLevel.svelte @@ -19,7 +19,7 @@ import { Action, IconEdit } from '@hcengineering/ui' import { getResource } from '@hcengineering/platform' - import { TreeItem, getActions as getContributedActions } from '../index' + import { TreeItem, getActions as getContributedActions } from '../../index' export let folders: Ref[] export let folderById: Map, Doc> diff --git a/plugins/view-resources/src/components/FoldersBrowser.svelte b/plugins/view-resources/src/components/folders/FoldersBrowser.svelte similarity index 64% rename from plugins/view-resources/src/components/FoldersBrowser.svelte rename to plugins/view-resources/src/components/folders/FoldersBrowser.svelte index 182a8cbc0b..9fd06fbd3e 100644 --- a/plugins/view-resources/src/components/FoldersBrowser.svelte +++ b/plugins/view-resources/src/components/folders/FoldersBrowser.svelte @@ -20,9 +20,9 @@ import { getResource, type Resource } from '@hcengineering/platform' import { IntlString, Asset } from '@hcengineering/platform' - import { FoldersManager, FoldersStore, FoldersState } from '../stores/folderStore' + import { FoldersStore, FoldersState, emptyFoldersState, getFoldersManager } from './store/folderStore' import FolderTreeLevel from './FolderTreeLevel.svelte' - import { TreeNode, TreeItem, getActions as getContributedActions } from '../index' + import { TreeNode, TreeItem, getActions as getContributedActions } from '../../index' export let _class: Ref> export let query: DocumentQuery @@ -32,14 +32,15 @@ export let getFolderLink: Resource<(doc: Ref | undefined) => Location> export let allObjectsIcon: Asset export let allObjectsLabel: IntlString + export let plainList: boolean = false export let forciblyСollapsed: boolean = false const client = getClient() - let foldersState: FoldersState = FoldersState.empty() + let foldersState: FoldersState = emptyFoldersState() - const foldersManager: FoldersManager = new FoldersManager(titleKey, parentKey, noParentId) + const foldersManager = getFoldersManager(titleKey, parentKey, noParentId, plainList) FoldersStore.subscribe((newState) => { foldersState = newState @@ -54,6 +55,9 @@ query ?? {}, (result) => { foldersManager.setFolders(result) + if (plainList && foldersState.folders?.length > 0) { + handleFolderSelected(foldersState.folders[0]) + } }, { sort: { @@ -65,10 +69,8 @@ async function handleFolderSelected (_id: Ref): Promise { selected = _id visibleItem = selected !== undefined ? foldersState.folderById.get(selected) : undefined - if (getFolderLink) { - const getFolderLinkFunction = await getResource(getFolderLink) - navigate(getFolderLinkFunction(_id)) - } + const getFolderLinkFunction = await getResource(getFolderLink) + navigate(getFolderLinkFunction(_id)) } async function handleAllItemsSelected (): Promise { @@ -100,17 +102,46 @@ - getRootActions()} - {forciblyСollapsed} - on:click={handleAllItemsSelected} - > + {#if noParentId !== undefined} + getRootActions()} + {forciblyСollapsed} + on:click={handleAllItemsSelected} + > + { + handleFolderSelected(ev.detail) + }} + /> + + {#if (selected || forciblyСollapsed) && visibleItem !== undefined} + {@const folder = visibleItem} + await getFolderActions(folder)} + shouldTooltip + forciblyСollapsed + /> + {/if} + + + {:else} - - {#if (selected || forciblyСollapsed) && visibleItem} - {@const folder = visibleItem} - await getFolderActions(folder)} - shouldTooltip - forciblyСollapsed - /> - {/if} - - + {/if} diff --git a/plugins/view-resources/src/components/folders/store/folderStore.ts b/plugins/view-resources/src/components/folders/store/folderStore.ts new file mode 100644 index 0000000000..511be91c6d --- /dev/null +++ b/plugins/view-resources/src/components/folders/store/folderStore.ts @@ -0,0 +1,38 @@ +// 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. + +import { type Doc, type Ref } from '@hcengineering/core' +import { writable, type Writable } from 'svelte/store' + +import { type FoldersState, FoldersManager, emptyFoldersState } from './folderUtils' + +export { type FoldersState, emptyFoldersState } + +export const FoldersStore: Writable = writable(emptyFoldersState()) + +export const SelectedFolderStore: Writable | undefined> = writable(undefined) + +export function setSelectedFolder (_id: Ref | undefined): void { + SelectedFolderStore.set(_id) +} + +export function getFoldersManager ( + titleKey: string, + parentKey: string, + noParentId: Ref, + plainList: boolean +): FoldersManager { + return new FoldersManager(titleKey, parentKey, noParentId, plainList, (state: FoldersState) => { + FoldersStore.set(state) + }) +} diff --git a/plugins/view-resources/src/stores/folderStore.ts b/plugins/view-resources/src/components/folders/store/folderUtils.ts similarity index 55% rename from plugins/view-resources/src/stores/folderStore.ts rename to plugins/view-resources/src/components/folders/store/folderUtils.ts index 5de7347c60..fea2297e23 100644 --- a/plugins/view-resources/src/stores/folderStore.ts +++ b/plugins/view-resources/src/components/folders/store/folderUtils.ts @@ -12,41 +12,40 @@ // limitations under the License. import { type Doc, type Ref } from '@hcengineering/core' -import { writable, type Writable } from 'svelte/store' -export class FoldersState { - folders: Array> - folderById: Map, Doc> - descendants: Map, Doc[]> - - constructor (folders: Array>, folderById: Map, Doc>, descendants: Map, Doc[]>) { - this.folders = folders - this.folderById = folderById - this.descendants = descendants - } - - static empty (): FoldersState { - return new FoldersState([], new Map, Doc>(), new Map, Doc[]>()) - } +export interface FoldersState { + readonly folders: Array> + readonly folderById: Map, Doc> + readonly descendants: Map, Doc[]> } -export const FoldersStore: Writable = writable(FoldersState.empty()) - -export const SelectedFolderStore: Writable | undefined> = writable(undefined) - -export function setSelectedFolder (_id: Ref | undefined): void { - SelectedFolderStore.set(_id) +export function emptyFoldersState (): FoldersState { + return { + folders: [], + folderById: new Map, Doc>(), + descendants: new Map, Doc[]>() + } } export class FoldersManager { titleKey: string parentKey: string noParentId: Ref + plainList: boolean + updateStore: (state: FoldersState) => void - constructor (titleKey: string, parentKey: string, noParentId: Ref) { + constructor ( + titleKey: string, + parentKey: string, + noParentId: Ref, + plainList: boolean, + updateStore: (state: FoldersState) => void + ) { this.titleKey = titleKey this.parentKey = parentKey this.noParentId = noParentId + this.plainList = plainList + this.updateStore = updateStore } public getTitle (doc: Doc): string { @@ -64,16 +63,17 @@ export class FoldersManager { } public setFolders (result: Doc[]): void { + const newState: FoldersState = this.plainList ? this.getPlainList(result) : this.getNestedStructure(result) + this.updateStore(newState) + } + + public getNestedStructure (result: Doc[]): FoldersState { let folders: Array> = [] const folderById: Map, Doc> = new Map, Doc>() const descendants: Map, Doc[]> = new Map, Doc[]>() for (const doc of result) { - const mappedDoc = { - title: this.getTitle(doc), - parent: this.getParent(doc), - ...doc - } + const mappedDoc = this.mapDoc(doc) const current = descendants.get(this.getParent(mappedDoc)) ?? [] current.push(mappedDoc) descendants.set(this.getParent(mappedDoc), current) @@ -81,6 +81,31 @@ export class FoldersManager { } folders = this.getDescendants(descendants, this.noParentId) - FoldersStore.set(new FoldersState(folders, folderById, descendants)) + return { folders, folderById, descendants } + } + + public getPlainList (result: Doc[]): FoldersState { + const folderById: Map, Doc> = new Map, Doc>() + + for (const doc of result) { + const mappedDoc = this.mapDoc(doc) + folderById.set(mappedDoc._id, mappedDoc) + } + const folders: Array> = result.map((doc) => doc._id) + + return { + folders, + folderById, + descendants: new Map, Doc[]>() + } + } + + private mapDoc (doc: Doc): Doc { + const mappedDoc = { + title: this.getTitle(doc), + parent: this.getParent(doc), + ...doc + } + return mappedDoc } } diff --git a/plugins/view-resources/src/index.ts b/plugins/view-resources/src/index.ts index bb099c3cc9..ddf8b5ce41 100644 --- a/plugins/view-resources/src/index.ts +++ b/plugins/view-resources/src/index.ts @@ -101,7 +101,7 @@ import ImageViewer from './components/viewer/ImageViewer.svelte' import VideoViewer from './components/viewer/VideoViewer.svelte' import PDFViewer from './components/viewer/PDFViewer.svelte' import TextViewer from './components/viewer/TextViewer.svelte' -import FoldersBrowser from './components/FoldersBrowser.svelte' +import FoldersBrowser from './components/folders/FoldersBrowser.svelte' import { blobImageMetadata, blobVideoMetadata } from './blob' @@ -167,6 +167,8 @@ export { default as List } from './components/list/List.svelte' export { default as NavLink } from './components/navigator/NavLink.svelte' export { default as StatusPresenter } from './components/status/StatusPresenter.svelte' export { default as StatusRefPresenter } from './components/status/StatusRefPresenter.svelte' +export { default as FoldersBrowser } from './components/folders/FoldersBrowser.svelte' +export { default as ListView } from './components/list/ListView.svelte' export * from './filter' export * from './middleware' @@ -189,6 +191,7 @@ export { } from './utils' export * from './viewOptions' export { + ArrayEditor, BooleanEditor, BooleanPresenter, ClassAttributeBar, diff --git a/plugins/workbench-resources/src/components/ComponentNavigator.svelte b/plugins/workbench-resources/src/components/ComponentNavigator.svelte index 930d78c807..84d5d3c7b8 100644 --- a/plugins/workbench-resources/src/components/ComponentNavigator.svelte +++ b/plugins/workbench-resources/src/components/ComponentNavigator.svelte @@ -47,6 +47,7 @@ export let createComponentProps: Record = {} export let mainComponentLabel: IntlString export let mainComponentIcon: Asset | undefined = undefined + export let mainHeaderComponent: AnyComponent | undefined = undefined export let query: DocumentQuery = {} export let syncWithLocationQuery: boolean = true export let mainComponent: AnyComponent | AnySvelteComponent @@ -165,6 +166,17 @@ + + {#if mainHeaderComponent} + + {/if} + Date: Thu, 28 Nov 2024 23:16:32 +0400 Subject: [PATCH 04/10] UBERF-8607: Fix inbox embedding (#7236) + Few minor cleanups Signed-off-by: Andrey Sobolev --- dev/docker-compose.yaml | 2 +- .../ui/src/components/PanelInstance.svelte | 46 +++++++++---------- packages/ui/src/panelup.ts | 11 +++-- .../src/components/Guest.svelte | 7 +-- .../src/components/inbox/Inbox.svelte | 1 + .../src/components/inbox/Inbox.svelte | 1 - plugins/tracker-resources/src/index.ts | 2 - plugins/tracker-resources/src/plugin.ts | 1 - .../src/components/Workbench.svelte | 8 ++-- 9 files changed, 40 insertions(+), 39 deletions(-) delete mode 100644 plugins/tracker-resources/src/components/inbox/Inbox.svelte diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index c83b0f3c48..d92ff909c7 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -406,7 +406,7 @@ services: restart: unless-stopped links: - elastic - - postgres + - cockroach ports: - 4702:4702 environment: diff --git a/packages/ui/src/components/PanelInstance.svelte b/packages/ui/src/components/PanelInstance.svelte index c05d85bd37..6aeefe1204 100644 --- a/packages/ui/src/components/PanelInstance.svelte +++ b/packages/ui/src/components/PanelInstance.svelte @@ -14,13 +14,13 @@ // limitations under the License. --> { - if (props && contentPanel) fitPopup(props, contentPanel) + if (props != null && contentPanel != null) fitPopup(props, contentPanel) }} on:keydown={(evt) => { - if (props) handleKeydown(evt) + if (props != null) handleKeydown(evt) }} on:beforeprint={() => { - if (props && contentPanel) fitPopup(props, contentPanel) + if (props != null && contentPanel != null) fitPopup(props, contentPanel) }} on:afterprint={() => { - if (props && contentPanel) fitPopup(props, contentPanel) + if (props != null && contentPanel != null) fitPopup(props, contentPanel) }} /> {#if props} - {#if !component} + {#if !(component != null)} {:else} diff --git a/packages/ui/src/panelup.ts b/packages/ui/src/panelup.ts index d1434a8c8b..4b06c3d655 100644 --- a/packages/ui/src/panelup.ts +++ b/packages/ui/src/panelup.ts @@ -11,7 +11,9 @@ export interface PanelProps { refit?: () => void } -export const panelstore = writable<{ panel?: PanelProps | undefined }>({ panel: undefined }) +export const panelstore = writable<{ + panel?: PanelProps | undefined +}>({ panel: undefined }) let currentLocation: string | undefined export function getPanelURI (component: AnyComponent, _id: string, _class: string, element?: PopupAlignment): string { @@ -27,17 +29,18 @@ export function showPanel ( _id: string, _class: string, element?: PopupAlignment, - rightSection?: AnyComponent + rightSection?: AnyComponent, + doNavigate: boolean = true ): void { openPanel(component, _id, _class, element, rightSection) const loc = getLocation() - if (loc.fragment !== currentLocation) { + if (doNavigate && loc.fragment !== currentLocation) { loc.fragment = currentLocation navigate(loc) } } -export function openPanel ( +function openPanel ( component: AnyComponent, _id: string, _class: string, diff --git a/plugins/guest-resources/src/components/Guest.svelte b/plugins/guest-resources/src/components/Guest.svelte index a026073cd5..5893cf5879 100644 --- a/plugins/guest-resources/src/components/Guest.svelte +++ b/plugins/guest-resources/src/components/Guest.svelte @@ -33,7 +33,7 @@ getCurrentLocation, getLocation, navigate, - openPanel, + showPanel, defineSeparators, setResolvedLocation, deviceOptionsStore as deviceInfo @@ -207,12 +207,13 @@ provider, focus: doc }) - openPanel( + showPanel( props[0] as AnyComponent, _id, _class, (props[3] ?? undefined) as PopupAlignment, - (props[4] ?? undefined) as AnyComponent + (props[4] ?? undefined) as AnyComponent, + false ) } else { closePanel(false) diff --git a/plugins/notification-resources/src/components/inbox/Inbox.svelte b/plugins/notification-resources/src/components/inbox/Inbox.svelte index be08d9e374..c2bc98cd25 100644 --- a/plugins/notification-resources/src/components/inbox/Inbox.svelte +++ b/plugins/notification-resources/src/components/inbox/Inbox.svelte @@ -452,6 +452,7 @@ ? urlObjectClass ?? selectedContext.objectClass : selectedContext.objectClass, autofocus: false, + embedded: true, context: selectedContext, activityMessage: selectedMessage, props: { context: selectedContext, autofocus: false } diff --git a/plugins/tracker-resources/src/components/inbox/Inbox.svelte b/plugins/tracker-resources/src/components/inbox/Inbox.svelte deleted file mode 100644 index a2f9cf1f91..0000000000 --- a/plugins/tracker-resources/src/components/inbox/Inbox.svelte +++ /dev/null @@ -1 +0,0 @@ -Some inbox stuff here. diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index fd1f78ec9d..086587f69e 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -51,7 +51,6 @@ import ProjectComponents from './components/components/ProjectComponents.svelte' import CreateIssue from './components/CreateIssue.svelte' import EditRelatedTargets from './components/EditRelatedTargets.svelte' import EditRelatedTargetsPopup from './components/EditRelatedTargetsPopup.svelte' -import Inbox from './components/inbox/Inbox.svelte' import AssigneeEditor from './components/issues/AssigneeEditor.svelte' import DueDatePresenter from './components/issues/DueDatePresenter.svelte' import EditIssue from './components/issues/edit/EditIssue.svelte' @@ -582,7 +581,6 @@ export default async (): Promise => ({ component: { NopeComponent, Issues, - Inbox, MyIssues, Components, IssuePresenter, diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index b7be6e83be..09330b2801 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -305,7 +305,6 @@ export default mergeIds(trackerId, tracker, { }, component: { NopeComponent: '' as AnyComponent, - Inbox: '' as AnyComponent, MyIssues: '' as AnyComponent, Views: '' as AnyComponent, Issues: '' as AnyComponent, diff --git a/plugins/workbench-resources/src/components/Workbench.svelte b/plugins/workbench-resources/src/components/Workbench.svelte index d813de2a85..26a51ac531 100644 --- a/plugins/workbench-resources/src/components/Workbench.svelte +++ b/plugins/workbench-resources/src/components/Workbench.svelte @@ -63,7 +63,7 @@ locationToUrl, mainSeparators, navigate, - openPanel, + showPanel, PanelInstance, Popup, PopupAlignment, @@ -401,7 +401,6 @@ const prevTabLoc = prevTab ? getTabLocation(prevTab) : undefined if (prevTabLoc === undefined || prevTabLoc.path[2] !== loc.path[2]) { clear(1) - clear(2) } } prevTabIdStore.set($tabIdStore) @@ -529,12 +528,13 @@ provider, focus: doc }) - openPanel( + showPanel( props[0] as AnyComponent, _id, _class, (props[3] ?? undefined) as PopupAlignment, - (props[4] ?? undefined) as AnyComponent + (props[4] ?? undefined) as AnyComponent, + false ) } else { accessDeniedStore.set(true) From a6fb3713f6acf18ca6d6ee4190b3b61f8dbf5aac Mon Sep 17 00:00:00 2001 From: Alexander Onnikov Date: Fri, 29 Nov 2024 16:58:50 +0700 Subject: [PATCH 05/10] UBERF-8557 Save collaborative content as JSON (#7039) Signed-off-by: Alexander Onnikov --- .vscode/launch.json | 2 + dev/doc-import-tool/src/import.ts | 21 +- dev/import-tool/src/importer/importer.ts | 65 ++---- dev/import-tool/src/importer/uploader.ts | 37 ++-- dev/import-tool/src/notion/notion.ts | 29 +-- dev/tool/src/benchmark.ts | 3 +- dev/tool/src/clean.ts | 11 +- dev/tool/src/index.ts | 69 +----- dev/tool/src/markup.ts | 207 ------------------ models/contact/src/index.ts | 4 +- models/controlled-documents/src/migration.ts | 25 ++- models/controlled-documents/src/types.ts | 10 +- models/core/src/core.ts | 8 +- models/core/src/index.ts | 2 - models/core/src/migration.ts | 187 ++++++++++++++-- models/document/package.json | 1 + models/document/src/index.ts | 13 +- models/document/src/migration.ts | 75 +++++-- models/lead/src/migration.ts | 47 +--- models/lead/src/types.ts | 4 +- models/love/src/index.ts | 14 +- models/love/src/migration.ts | 19 +- models/recruit/src/types.ts | 4 +- models/test-management/src/types.ts | 8 +- models/tracker/src/types.ts | 4 +- models/view/src/index.ts | 4 - packages/api-client/src/client.ts | 36 ++- packages/api-client/src/markup/client.ts | 27 ++- packages/api-client/src/markup/types.ts | 22 +- .../src/__tests__/utils.test.ts | 31 +-- packages/collaborator-client/src/client.ts | 75 +++++-- packages/collaborator-client/src/index.ts | 1 - packages/collaborator-client/src/types.ts | 20 -- packages/collaborator-client/src/utils.ts | 78 ++----- packages/core/lang/en.json | 2 +- packages/core/lang/es.json | 2 +- packages/core/lang/fr.json | 2 +- packages/core/lang/pt.json | 2 +- packages/core/lang/ru.json | 2 +- packages/core/lang/zh.json | 2 +- .../core/src/__tests__/collaboration.test.ts | 204 ----------------- packages/core/src/classes.ts | 10 +- packages/core/src/collaboration.ts | 180 ++------------- packages/core/src/component.ts | 8 +- packages/model/src/dsl.ts | 13 +- packages/presentation/src/collaborator.ts | 27 ++- packages/presentation/src/file.ts | 13 +- plugins/bitrix/src/hr.ts | 5 +- .../src/components/CreateOrganization.svelte | 12 +- plugins/contact/src/index.ts | 4 +- .../src/components/CreateDocument.svelte | 34 +-- .../src/components/EditDocPanel.svelte | 18 +- .../create-doc/QmsDocumentWizard.svelte | 10 +- .../create-doc/QmsTemplateWizard.svelte | 5 +- .../document/DocumentDiffViewer.svelte | 7 +- .../components/document/EditDocContent.svelte | 28 +-- .../src/docutils.ts | 69 +++--- plugins/controlled-documents/src/docutils.ts | 55 +---- plugins/controlled-documents/src/types.ts | 6 +- .../src/components/DocumentEditor.svelte | 18 +- plugins/document-resources/src/utils.ts | 5 +- plugins/document/src/types.ts | 6 +- .../src/components/CreateCustomer.svelte | 15 +- plugins/lead/src/index.ts | 4 +- .../src/components/AddRoomPopup.svelte | 4 +- plugins/love-resources/src/utils.ts | 3 +- plugins/love/src/index.ts | 6 +- plugins/love/src/utils.ts | 14 +- .../src/components/CreateVacancy.svelte | 24 +- plugins/recruit/src/types.ts | 4 +- .../test-case/CreateTestCase.svelte | 13 +- .../components/test-run/CreateTestRun.svelte | 19 +- plugins/test-management/src/types.ts | 8 +- .../src/components/Collaboration.svelte | 35 +-- .../CollaborativeAttributeBox.svelte | 54 ++--- .../components/CollaborativeTextEditor.svelte | 41 ++-- .../src/components/CollaboratorEditor.svelte | 22 +- .../src/provider/cloud.ts | 8 +- .../src/provider/hocuspocus.ts | 16 +- .../src/provider/utils.ts | 65 ++---- .../src/components/CreateIssue.svelte | 20 +- .../src/components/SubIssues.svelte | 14 +- .../src/components/issues/IssuePreview.svelte | 8 +- plugins/tracker/src/index.ts | 4 +- .../activity-resources/src/references.ts | 46 ++-- .../collaboration-resources/src/index.ts | 13 +- server/collaboration/package.json | 1 + .../src/history/__tests__/branch.test.ts | 133 ----------- .../src/history/__tests__/history.test.ts | 159 -------------- .../src/history/__tests__/snapshot.test.ts | 109 --------- server/collaboration/src/history/branch.ts | 54 ----- server/collaboration/src/history/history.ts | 110 ---------- server/collaboration/src/history/snapshot.ts | 36 --- server/collaboration/src/index.ts | 8 +- server/collaboration/src/storage.ts | 127 +++++++++++ .../utils/__tests__/collaborative-doc.test.ts | 61 ------ .../src/utils/__tests__/ydoc.test.ts | 87 -------- .../src/utils/collaborative-doc.ts | 168 -------------- server/collaboration/src/utils/storage.ts | 57 ----- server/collaboration/src/{utils => }/ydoc.ts | 8 +- server/collaborator/src/context.ts | 12 +- .../src/extensions/authentication.ts | 7 +- server/collaborator/src/extensions/storage.ts | 5 +- .../src/rpc/methods/createContent.ts | 49 +++++ .../src/rpc/methods/getContent.ts | 7 +- server/collaborator/src/rpc/methods/index.ts | 2 + .../src/rpc/methods/updateContent.ts | 4 +- server/collaborator/src/server.ts | 10 +- server/collaborator/src/storage/adapter.ts | 5 +- server/collaborator/src/storage/platform.ts | 126 +++++------ server/collaborator/src/types.ts | 31 --- server/indexer/src/indexer/indexer.ts | 18 +- server/tool/src/initializer.ts | 23 +- .../github/pod-github/src/sync/issueBase.ts | 21 +- services/github/pod-github/src/sync/issues.ts | 23 +- .../pod-github/src/sync/pullrequests.ts | 22 +- 116 files changed, 1194 insertions(+), 2661 deletions(-) delete mode 100644 dev/tool/src/markup.ts delete mode 100644 packages/collaborator-client/src/types.ts delete mode 100644 packages/core/src/__tests__/collaboration.test.ts delete mode 100644 server/collaboration/src/history/__tests__/branch.test.ts delete mode 100644 server/collaboration/src/history/__tests__/history.test.ts delete mode 100644 server/collaboration/src/history/__tests__/snapshot.test.ts delete mode 100644 server/collaboration/src/history/branch.ts delete mode 100644 server/collaboration/src/history/history.ts delete mode 100644 server/collaboration/src/history/snapshot.ts create mode 100644 server/collaboration/src/storage.ts delete mode 100644 server/collaboration/src/utils/__tests__/collaborative-doc.test.ts delete mode 100644 server/collaboration/src/utils/__tests__/ydoc.test.ts delete mode 100644 server/collaboration/src/utils/collaborative-doc.ts delete mode 100644 server/collaboration/src/utils/storage.ts rename server/collaboration/src/{utils => }/ydoc.ts (88%) create mode 100644 server/collaborator/src/rpc/methods/createContent.ts delete mode 100644 server/collaborator/src/types.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 47bd54f286..cb08a80418 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -286,12 +286,14 @@ "MINIO_ENDPOINT": "localhost:9000", "TRANSACTOR_URL": "ws://localhost:3333", "MONGO_URL": "mongodb://localhost:27017", + "DB_URL": "mongodb://localhost:27017", "ACCOUNTS_URL": "http://localhost:3000", "TELEGRAM_DATABASE": "telegram-service", "ELASTIC_URL": "http://localhost:9200", "REKONI_URL": "http://localhost:4004", "MODEL_VERSION": "0.6.287" }, + "runtimeVersion": "20", "runtimeArgs": ["--nolazy", "-r", "ts-node/register"], "sourceMaps": true, "outputCapture": "std", diff --git a/dev/doc-import-tool/src/import.ts b/dev/doc-import-tool/src/import.ts index f8c414d409..c76a8d0b9c 100644 --- a/dev/doc-import-tool/src/import.ts +++ b/dev/doc-import-tool/src/import.ts @@ -15,14 +15,13 @@ import documents, { import core, { AttachedData, BackupClient, - CollaborativeDoc, Client as CoreClient, Data, MeasureContext, Ref, TxOperations, generateId, - makeCollaborativeDoc, + makeDocCollabId, systemAccountEmail, type Blob } from '@hcengineering/core' @@ -101,7 +100,7 @@ async function createDocument ( abstract: '', effectiveDate: 0, reviewInterval: DEFAULT_PERIODIC_REVIEW_INTERVAL, - content: makeCollaborativeDoc(generateId()), + content: null, snapshots: 0, plannedEffectiveDate: 0 } @@ -115,11 +114,6 @@ async function createDocument ( console.log('Creating controlled doc from template') - const copyContent = async (source: CollaborativeDoc, target: CollaborativeDoc): Promise => { - // intentionally left empty - // even though the template has some content, it won't be used - } - const { success } = await createControlledDocFromTemplate( txops, templateId, @@ -128,8 +122,7 @@ async function createDocument ( space, undefined, undefined, - documents.class.ControlledDocument, - copyContent + documents.class.ControlledDocument ) if (!success) { throw new Error('Failed to create controlled document from template') @@ -184,7 +177,7 @@ async function createTemplateIfNotExist ( approvers: [], coAuthors: [], changeControl: ccRecordId, - content: makeCollaborativeDoc(generateId()), + content: null, snapshots: 0, plannedEffectiveDate: 0 } @@ -229,9 +222,7 @@ async function createSections ( console.log('Creating document content') - const collabId = doc.content - - console.log(`Collab doc ID: ${collabId}`) + const collabId = makeDocCollabId(doc, 'content') try { let content: string = '' @@ -245,7 +236,7 @@ async function createSections ( content += `

${section.title}

${section.content}` } - await collaborator.updateContent(collabId, { content }) + await collaborator.updateMarkup(collabId, content) } finally { // do nothing } diff --git a/dev/import-tool/src/importer/importer.ts b/dev/import-tool/src/importer/importer.ts index 13d2363f08..93f285ee70 100644 --- a/dev/import-tool/src/importer/importer.ts +++ b/dev/import-tool/src/importer/importer.ts @@ -14,18 +14,18 @@ // import attachment, { type Attachment } from '@hcengineering/attachment' import chunter, { type ChatMessage } from '@hcengineering/chunter' -import { yDocToBuffer } from '@hcengineering/collaboration' import { type Person } from '@hcengineering/contact' import core, { type Account, type AttachedData, type Class, + type Blob as PlatformBlob, type CollaborativeDoc, type Data, type Doc, type DocumentQuery, generateId, - makeCollaborativeDoc, + makeCollabId, type Mixin, type Ref, SortingOrder, @@ -42,7 +42,7 @@ import task, { type TaskType, type TaskTypeWithFactory } from '@hcengineering/task' -import { jsonToMarkup, jsonToYDocNoSchema, parseMessageMarkdown } from '@hcengineering/text' +import { jsonToMarkup, parseMessageMarkdown } from '@hcengineering/text' import tracker, { type Issue, type IssueParentInfo, @@ -52,7 +52,7 @@ import tracker, { TimeReportDayType } from '@hcengineering/tracker' import { type MarkdownPreprocessor, NoopMarkdownPreprocessor } from './preprocessor' -import { type FileUploader, type UploadResult } from './uploader' +import { type FileUploader } from './uploader' export interface ImportWorkspace { projectTypes?: ImportProjectType[] @@ -281,14 +281,15 @@ export class WorkspaceImporter { ): Promise> { const id = doc.id ?? generateId() const content = await doc.descrProvider() - const collabId = await this.createCollaborativeContent(id, 'content', content, teamspaceId) + const collabId = makeCollabId(document.class.Document, id, 'content') + const contentId = await this.createCollaborativeContent(id, collabId, content, teamspaceId) const lastRank = await getFirstRank(this.client, teamspaceId, parentId) const rank = makeRank(lastRank, undefined) const attachedData: Data = { title: doc.title, - content: collabId, + content: contentId, parent: parentId, attachments: 0, embeddings: 0, @@ -391,7 +392,8 @@ export class WorkspaceImporter { ): Promise<{ id: Ref, identifier: string }> { const issueId = issue.id ?? generateId() const content = await issue.descrProvider() - const collabId = await this.createCollaborativeContent(issueId, 'description', content, project._id) + const collabId = makeCollabId(tracker.class.Issue, issueId, 'description') + const contentId = await this.createCollaborativeContent(issueId, collabId, content, project._id) const { number, identifier } = issue.number !== undefined @@ -412,7 +414,7 @@ export class WorkspaceImporter { const issueData: AttachedData = { title: issue.title, - description: collabId, + description: contentId, assignee: issue.assignee ?? null, component: null, number, @@ -532,14 +534,10 @@ export class WorkspaceImporter { } const file = new File([blob], attachment.title) - const attachmentId = await this.createAttachment( - attachment.id ?? generateId(), - file, - spaceId, - parentId, - parentClass - ) - if (attachmentId === null) { + + try { + await this.createAttachment(attachment.id ?? generateId(), file, spaceId, parentId, parentClass) + } catch { console.warn('Failed to upload attachment file: ', attachment.title) } } @@ -550,22 +548,9 @@ export class WorkspaceImporter { spaceId: Ref, parentId: Ref, parentClass: Ref>> - ): Promise | null> { - const response = await this.fileUploader.uploadFile(id, id, file) - if (response.status !== 200) { - return null - } - - const responseText = await response.text() - if (responseText === undefined) { - return null - } - - const uploadResult = JSON.parse(responseText) as UploadResult[] - if (!Array.isArray(uploadResult) || uploadResult.length === 0) { - return null - } - + ): Promise> { + const attachmentId = generateId() + const blobId = await this.fileUploader.uploadFile(id, file) await this.client.addCollection( attachment.class.Attachment, spaceId, @@ -573,7 +558,7 @@ export class WorkspaceImporter { parentClass, 'attachments', { - file: uploadResult[0].id, + file: blobId, lastModified: Date.now(), name: file.name, size: file.size, @@ -581,25 +566,23 @@ export class WorkspaceImporter { }, id ) - return id + return attachmentId } // Collaborative content handling private async createCollaborativeContent ( id: Ref, - field: string, + collabId: CollaborativeDoc, content: string, spaceId: Ref - ): Promise { + ): Promise> { const json = parseMessageMarkdown(content ?? '', 'image://') const processedJson = this.preprocessor.process(json, id, spaceId) - const collabId = makeCollaborativeDoc(id, 'description') - const yDoc = jsonToYDocNoSchema(processedJson, field) - const buffer = yDocToBuffer(yDoc) + const markup = jsonToMarkup(processedJson) + const buffer = Buffer.from(markup) - await this.fileUploader.uploadCollaborativeDoc(id, collabId, buffer) - return collabId + return await this.fileUploader.uploadCollaborativeDoc(collabId, buffer) } async findIssueStatusByName (name: string): Promise> { diff --git a/dev/import-tool/src/importer/uploader.ts b/dev/import-tool/src/importer/uploader.ts index 64269f1176..d2ae4fb7d9 100644 --- a/dev/import-tool/src/importer/uploader.ts +++ b/dev/import-tool/src/importer/uploader.ts @@ -13,25 +13,19 @@ // limitations under the License. // import { - concatLink, type Ref, type Blob as PlatformBlob, - type Doc, type CollaborativeDoc, - collaborativeDocParse + concatLink, + makeCollabJsonId } from '@hcengineering/core' export interface FileUploader { - uploadFile: (id: Ref, name: string, file: File, contentType?: string) => Promise - uploadCollaborativeDoc: (id: Ref, collabId: CollaborativeDoc, data: Buffer) => Promise + uploadFile: (name: string, file: Blob) => Promise> + uploadCollaborativeDoc: (collabId: CollaborativeDoc, data: Buffer) => Promise> getFileUrl: (id: string) => string } -export interface UploadResult { - key: 'file' - id: Ref -} - export class FrontFileUploader implements FileUploader { constructor ( private readonly frontUrl: string, @@ -41,31 +35,32 @@ export class FrontFileUploader implements FileUploader { this.getFileUrl = this.getFileUrl.bind(this) } - public async uploadFile (id: Ref, name: string, file: File, contentType?: string): Promise { + public async uploadFile (name: string, file: Blob): Promise> { const form = new FormData() form.append('file', file, name) - form.append('type', contentType ?? file.type) - form.append('size', file.size.toString()) - form.append('name', file.name) - form.append('id', id) - form.append('data', new Blob([file])) - return await fetch(concatLink(this.frontUrl, '/files'), { + const res = await fetch(concatLink(this.frontUrl, '/files'), { method: 'POST', headers: { Authorization: 'Bearer ' + this.token }, body: form }) + + if (res.ok && res.status === 200) { + return name as Ref + } + + throw new Error('Failed to upload file') } public getFileUrl (id: string): string { return concatLink(this.frontUrl, `/files/${this.workspaceId}/${id}?file=${id}&workspace=${this.workspaceId}`) } - public async uploadCollaborativeDoc (id: Ref, collabId: CollaborativeDoc, data: Buffer): Promise { - const file = new File([data], collabId) - const { documentId } = collaborativeDocParse(collabId) - return await this.uploadFile(id, documentId, file, 'application/ydoc') + public async uploadCollaborativeDoc (collabId: CollaborativeDoc, data: Buffer): Promise> { + const blobId = makeCollabJsonId(collabId) + const blob = new Blob([data], { type: 'application/json' }) + return await this.uploadFile(blobId, blob) } } diff --git a/dev/import-tool/src/notion/notion.ts b/dev/import-tool/src/notion/notion.ts index 7af6e37082..aff260f759 100644 --- a/dev/import-tool/src/notion/notion.ts +++ b/dev/import-tool/src/notion/notion.ts @@ -12,21 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. // -import { yDocToBuffer } from '@hcengineering/collaboration' import { type AttachedData, type Blob, type Data, - type Doc, - generateId, - makeCollaborativeDoc, type Ref, - type TxOperations + type TxOperations, + generateId, + makeCollabId } from '@hcengineering/core' -import document, { type Document, getFirstRank, type Teamspace } from '@hcengineering/document' +import document, { type Document, type Teamspace, getFirstRank } from '@hcengineering/document' import { makeRank } from '@hcengineering/rank' import { - jsonToYDocNoSchema, + jsonToMarkup, MarkupMarkType, type MarkupNode, MarkupNodeType, @@ -325,8 +323,6 @@ async function createDBPageWithAttachments ( documentMetaMap?: Map ): Promise { const pageId = docMeta.id as Ref - const collabId = makeCollaborativeDoc(pageId, 'content') - const parentId = parentMeta !== undefined ? (parentMeta.id as Ref) : document.ids.NoParent const lastRank = await getFirstRank(client, space, parentId) @@ -334,7 +330,7 @@ async function createDBPageWithAttachments ( const object: Data = { title: docMeta.name, - content: collabId, + content: null, parent: parentId, attachments: 0, embeddings: 0, @@ -409,7 +405,7 @@ async function importAttachment ( } const file = new File([data], docMeta.name) - await fileUploader.uploadFile(docMeta.id as Ref, docMeta.id, file) + await fileUploader.uploadFile(docMeta.id, file) const attachedData: AttachedData = { file: docMeta.id as Ref, @@ -444,13 +440,12 @@ async function importPageDocument ( if (documentMetaMap !== undefined) { preProcessMarkdown(json, documentMetaMap, fileUploader) } - const yDoc = jsonToYDocNoSchema(json, 'content') - const buffer = yDocToBuffer(yDoc) + const markup = jsonToMarkup(json) + const buffer = Buffer.from(markup) const id = docMeta.id as Ref - const collabId = makeCollaborativeDoc(id, 'description') - - await fileUploader.uploadCollaborativeDoc(id, collabId, buffer) + const collabId = makeCollabId(document.class.Document, id, 'content') + const blobId = await fileUploader.uploadCollaborativeDoc(collabId, buffer) const parent = (parentMeta?.id as Ref) ?? document.ids.NoParent @@ -459,7 +454,7 @@ async function importPageDocument ( const attachedData: Data = { title: docMeta.name, - content: collabId, + content: blobId, parent, attachments: 0, embeddings: 0, diff --git a/dev/tool/src/benchmark.ts b/dev/tool/src/benchmark.ts index fcae5fd39f..7143746c0e 100644 --- a/dev/tool/src/benchmark.ts +++ b/dev/tool/src/benchmark.ts @@ -21,7 +21,6 @@ import core, { concatLink, generateId, getWorkspaceId, - makeCollaborativeDoc, metricsToString, newMetrics, systemAccountEmail, @@ -594,7 +593,7 @@ async function generateVacancy (client: TxOperations, members: Ref { try { - const ydoc = await loadCollaborativeDoc(ctx, storage, workspaceId, _id) + const ydoc = await loadCollabYdoc(ctx, storage, workspaceId, _id) if (ydoc === undefined) { ctx.error('document content not found', { document: contentDoc._id }) return @@ -1264,7 +1265,7 @@ async function updateYDoc ( }) if (updatedYDoc !== undefined) { - await saveCollaborativeDoc(ctx, storage, workspaceId, _id, updatedYDoc) + await saveCollabYdoc(ctx, storage, workspaceId, _id, updatedYDoc) } } catch { // do nothing, the collaborative doc does not sem to exist yet diff --git a/dev/tool/src/index.ts b/dev/tool/src/index.ts index d49d0380ad..829c9eca48 100644 --- a/dev/tool/src/index.ts +++ b/dev/tool/src/index.ts @@ -58,13 +58,7 @@ import serverClientPlugin, { listAccountWorkspaces, updateBackupInfo } from '@hcengineering/server-client' -import { - createBackupPipeline, - getConfig, - getServerPipeline, - registerServerPlugins, - registerStringLoaders -} from '@hcengineering/server-pipeline' +import { createBackupPipeline, getConfig } from '@hcengineering/server-pipeline' import serverToken, { decodeToken, generateToken } from '@hcengineering/server-token' import toolPlugin, { FileModelLogger } from '@hcengineering/server-tool' import { createWorkspace, upgradeWorkspace } from '@hcengineering/workspace-service' @@ -89,8 +83,7 @@ import core, { type Ref, type Tx, type Version, - type WorkspaceId, - type WorkspaceIdWithUrl + type WorkspaceId } from '@hcengineering/core' import { consoleModelLogger, type MigrateOperation } from '@hcengineering/model' import contact from '@hcengineering/model-contact' @@ -123,7 +116,6 @@ import { } from './clean' import { changeConfiguration } from './configuration' import { moveAccountDbFromMongoToPG, moveFromMongoToPG, moveWorkspaceFromMongoToPG } from './db' -import { fixJsonMarkup, migrateMarkup } from './markup' import { fixMixinForeignAttributes, showMixinForeignAttributes } from './mixin' import { fixAccountEmails, renameAccount } from './renameAccount' import { moveFiles, showLostFiles } from './storage' @@ -1723,63 +1715,6 @@ export function devTool ( }) }) - program - .command('fix-json-markup-mongo ') - .description('fixes double converted json markup') - .action(async (workspace: string) => { - const mongodbUri = getMongoDBUrl() - await withStorage(async (adapter) => { - const wsid = getWorkspaceId(workspace) - const endpoint = await getTransactorEndpoint(generateToken(systemAccountEmail, wsid), 'external') - await fixJsonMarkup(toolCtx, mongodbUri, adapter, wsid, endpoint) - }) - }) - - program - .command('migrate-markup-mongo') - .description('migrates collaborative markup to storage') - .option('-w, --workspace ', 'Selected workspace only', '') - .option('-c, --concurrency ', 'Number of documents being processed concurrently', '10') - .action(async (cmd: { workspace: string, concurrency: string }) => { - const { dbUrl, txes } = prepareTools() - const mongodbUri = getMongoDBUrl() - await withDatabase(dbUrl, async (db) => { - await withStorage(async (adapter) => { - const workspaces = await listWorkspacesPure(db) - const client = getMongoClient(mongodbUri) - const _client = await client.getClient() - let index = 0 - try { - for (const workspace of workspaces) { - if (cmd.workspace !== '' && workspace.workspace !== cmd.workspace) { - continue - } - - const wsId = getWorkspaceId(workspace.workspace) - console.log('processing workspace', workspace.workspace, index, workspaces.length) - const wsUrl: WorkspaceIdWithUrl = { - name: workspace.workspace, - workspaceName: workspace.workspaceName ?? '', - workspaceUrl: workspace.workspaceUrl ?? '' - } - - registerServerPlugins() - registerStringLoaders() - - const { pipeline } = await getServerPipeline(toolCtx, txes, dbUrl, wsUrl) - - await migrateMarkup(toolCtx, adapter, wsId, _client, pipeline, parseInt(cmd.concurrency)) - - console.log('...done', workspace.workspace) - index++ - } - } finally { - client.close() - } - }) - }) - }) - program .command('remove-duplicates-ids-mongo ') .description('remove duplicates ids for futue migration') diff --git a/dev/tool/src/markup.ts b/dev/tool/src/markup.ts deleted file mode 100644 index 649d78e959..0000000000 --- a/dev/tool/src/markup.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { saveCollaborativeDoc } from '@hcengineering/collaboration' -import core, { - type AnyAttribute, - type Class, - type Client as CoreClient, - type Doc, - type Domain, - type Hierarchy, - type MeasureContext, - type Ref, - type WorkspaceId, - collaborativeDocParse, - makeCollaborativeDoc, - RateLimiter -} from '@hcengineering/core' -import { getMongoClient, getWorkspaceMongoDB } from '@hcengineering/mongo' -import { type Pipeline, type StorageAdapter } from '@hcengineering/server-core' -import { connect } from '@hcengineering/server-tool' -import { jsonToText, markupToYDoc } from '@hcengineering/text' -import { type Db, type FindCursor, type MongoClient } from 'mongodb' - -export async function fixJsonMarkup ( - ctx: MeasureContext, - mongoUrl: string, - storageAdapter: StorageAdapter, - workspaceId: WorkspaceId, - transactorUrl: string -): Promise { - const connection = (await connect(transactorUrl, workspaceId, undefined, { - mode: 'backup' - })) as unknown as CoreClient - const hierarchy = connection.getHierarchy() - - const client = getMongoClient(mongoUrl) - const _client = await client.getClient() - const db = getWorkspaceMongoDB(_client, workspaceId) - - try { - const classes = hierarchy.getDescendants(core.class.Doc) - for (const _class of classes) { - const domain = hierarchy.findDomain(_class) - if (domain === undefined) continue - - const attributes = hierarchy.getAllAttributes(_class) - const filtered = Array.from(attributes.values()).filter((attribute) => { - return hierarchy.isDerived(attribute.type._class, core.class.TypeMarkup) - }) - if (filtered.length === 0) continue - - await processFixJsonMarkupFor(ctx, domain, _class, filtered, workspaceId, db, storageAdapter) - } - } finally { - client.close() - await connection.close() - } -} - -async function processFixJsonMarkupFor ( - ctx: MeasureContext, - domain: Domain, - _class: Ref>, - attributes: AnyAttribute[], - workspaceId: WorkspaceId, - db: Db, - storageAdapter: StorageAdapter -): Promise { - const collection = db.collection(domain) - const docs = await collection.find({ _class }).toArray() - for (const doc of docs) { - const update: Record = {} - const remove = [] - - for (const attribute of attributes) { - try { - const value = (doc as any)[attribute.name] - if (value != null) { - let res = value - while (true) { - try { - const json = JSON.parse(res) - const text = jsonToText(json) - JSON.parse(text) - res = text - } catch { - break - } - } - if (res !== value) { - update[attribute.name] = res - remove.push(makeCollaborativeDoc(doc._id, attribute.name)) - } - } - } catch {} - } - - if (Object.keys(update).length > 0) { - try { - await collection.updateOne({ _id: doc._id }, { $set: update }) - } catch (err) { - console.error('failed to update document', doc._class, doc._id, err) - } - } - - if (remove.length > 0) { - try { - await storageAdapter.remove(ctx, workspaceId, remove) - } catch (err) { - console.error('failed to remove objects from storage', doc._class, doc._id, remove, err) - } - } - } -} - -export async function migrateMarkup ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspaceId: WorkspaceId, - client: MongoClient, - pipeline: Pipeline, - concurrency: number -): Promise { - const hierarchy = pipeline.context.hierarchy - - const workspaceDb = client.db(workspaceId.name) - - const classes = hierarchy.getDescendants(core.class.Doc) - for (const _class of classes) { - const domain = hierarchy.findDomain(_class) - if (domain === undefined) continue - - const allAttributes = hierarchy.getAllAttributes(_class) - const attributes = Array.from(allAttributes.values()).filter((attribute) => { - return hierarchy.isDerived(attribute.type._class, 'core:class:TypeCollaborativeMarkup' as Ref>) - }) - - if (attributes.length === 0) continue - if (hierarchy.isMixin(_class) && attributes.every((p) => p.attributeOf !== _class)) continue - - const collection = workspaceDb.collection(domain) - - const filter = hierarchy.isMixin(_class) ? { [_class]: { $exists: true } } : { _class } - const iterator = collection.find(filter) - - try { - await processMigrateMarkupFor(ctx, hierarchy, storageAdapter, workspaceId, attributes, iterator, concurrency) - } finally { - await iterator.close() - } - } -} - -async function processMigrateMarkupFor ( - ctx: MeasureContext, - hierarchy: Hierarchy, - storageAdapter: StorageAdapter, - workspaceId: WorkspaceId, - attributes: AnyAttribute[], - iterator: FindCursor, - concurrency: number -): Promise { - const rateLimiter = new RateLimiter(concurrency) - - let processed = 0 - - while (true) { - const doc = await iterator.next() - if (doc === null) break - - const timestamp = Date.now() - const revisionId = `${timestamp}` - - await rateLimiter.exec(async () => { - for (const attribute of attributes) { - const collaborativeDoc = makeCollaborativeDoc(doc._id, attribute.name, revisionId) - const { documentId } = collaborativeDocParse(collaborativeDoc) - - const value = hierarchy.isMixin(attribute.attributeOf) - ? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string) - : ((doc as any)[attribute.name] as string) - - if (value != null && value.startsWith('{')) { - const blob = await storageAdapter.stat(ctx, workspaceId, documentId) - // only for documents not in storage - if (blob === undefined) { - try { - const ydoc = markupToYDoc(value, attribute.name) - await saveCollaborativeDoc(ctx, storageAdapter, workspaceId, collaborativeDoc, ydoc) - } catch (err) { - console.error('failed to process document', doc._class, doc._id, err) - } - } - } - } - }) - - processed += 1 - - if (processed % 100 === 0) { - await rateLimiter.waitProcessing() - console.log('...processing', processed) - } - } - - await rateLimiter.waitProcessing() - - console.log('processed', processed) -} diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index e7a597a31a..6bcaa00a87 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -39,7 +39,7 @@ import { IndexKind, type Blob, type Class, - type CollaborativeDoc, + type MarkupBlobRef, type Domain, type Ref, type Timestamp @@ -175,7 +175,7 @@ export class TMember extends TAttachedDoc implements Member { export class TOrganization extends TContact implements Organization { @Prop(TypeCollaborativeDoc(), core.string.Description) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null @Prop(Collection(contact.class.Member), contact.string.Members) members!: number diff --git a/models/controlled-documents/src/migration.ts b/models/controlled-documents/src/migration.ts index a609ba304f..c199e79fb0 100644 --- a/models/controlled-documents/src/migration.ts +++ b/models/controlled-documents/src/migration.ts @@ -4,12 +4,12 @@ import attachment, { type Attachment } from '@hcengineering/attachment' import { - clone, - loadCollaborativeDoc, - saveCollaborativeDoc, - YAbstractType, YXmlElement, - YXmlText + YXmlText, + YAbstractType, + yXmlElementClone, + loadCollabYdoc, + saveCollabYdoc } from '@hcengineering/collaboration' import { type ChangeControl, @@ -26,7 +26,7 @@ import { type Doc, DOMAIN_TX, generateId, - makeCollaborativeDoc, + makeDocCollabId, MeasureMetricsContext, type Ref, SortingOrder, @@ -144,7 +144,7 @@ async function createProductChangeControlTemplate (tx: TxOperations): Promise { // Migrate sections headers + content try { - const ydoc = await loadCollaborativeDoc(ctx, storage, client.workspaceId, document.content) + const collabId = makeDocCollabId(document, 'content') + const ydoc = await loadCollabYdoc(ctx, storage, client.workspaceId, collabId) if (ydoc === undefined) { // no content, ignore continue @@ -331,13 +332,17 @@ async function migrateDocSections (client: MigrationClient): Promise { ...(sectionContent .toArray() .map((item) => - item instanceof YAbstractType ? (item instanceof YXmlElement ? clone(item) : item.clone()) : item + item instanceof YAbstractType + ? item instanceof YXmlElement + ? yXmlElementClone(item) + : item.clone() + : item ) as any) ]) } }) - await saveCollaborativeDoc(ctx, storage, client.workspaceId, document.content, ydoc) + await saveCollabYdoc(ctx, storage, client.workspaceId, collabId, ydoc) } catch (err) { ctx.error('error collaborative document content migration', { error: err, document: document.title }) } diff --git a/models/controlled-documents/src/types.ts b/models/controlled-documents/src/types.ts index 6fbe902438..4f8dc62038 100644 --- a/models/controlled-documents/src/types.ts +++ b/models/controlled-documents/src/types.ts @@ -48,17 +48,17 @@ import { DateRangeMode, IndexKind, type Class, + type MarkupBlobRef, type Doc, + type Domain, type Ref, type Timestamp, type Type, type CollectionSize, - type CollaborativeDoc, type Role, type TypedSpace, type Account, - type RolesAssignment, - type Domain + type RolesAssignment } from '@hcengineering/core' import { ArrOf, @@ -259,7 +259,7 @@ export class TDocument extends TDoc implements Document { state!: DocumentState @Prop(TypeCollaborativeDoc(), documents.string.CollaborativeDocument) - content!: CollaborativeDoc + content!: MarkupBlobRef | null @Prop(Collection(tags.class.TagReference), documents.string.Labels) labels?: CollectionSize @@ -425,7 +425,7 @@ export class TDocumentSnapshot extends TAttachedDoc implements DocumentSnapshot @Prop(TypeCollaborativeDoc(), documents.string.CollaborativeDocument) @Hidden() - content!: CollaborativeDoc + content!: MarkupBlobRef | null @Prop(TypeDocumentState(), documents.string.Status) state?: DocumentState diff --git a/models/core/src/core.ts b/models/core/src/core.ts index 60512e0cf2..2907480e95 100644 --- a/models/core/src/core.ts +++ b/models/core/src/core.ts @@ -28,7 +28,7 @@ import { type Card, type Class, type ClassifierKind, - type CollaborativeDoc, + type MarkupBlobRef, type Collection, type Configuration, type ConfigurationElement, @@ -121,7 +121,7 @@ export class TCard extends TDoc implements Card { title!: string @Prop(TypeCollaborativeDoc(), core.string.Description) - description!: CollaborativeDoc | null + description!: MarkupBlobRef | null @Prop(TypeString(), core.string.Id) identifier?: string | undefined @@ -373,10 +373,6 @@ export class TDomainIndexConfiguration extends TDoc implements DomainIndexConfig @Model(core.class.TypeCollaborativeDoc, core.class.Type) export class TTypeCollaborativeDoc extends TType {} -@UX(core.string.CollaborativeDocVersion) -@Model(core.class.TypeCollaborativeDocVersion, core.class.Type) -export class TTypeCollaborativeDocVersion extends TType {} - @UX(core.string.Rank) @Model(core.class.TypeRank, core.class.Type) export class TTypeRank extends TType {} diff --git a/models/core/src/index.ts b/models/core/src/index.ts index 26445f9f9d..d741be243c 100644 --- a/models/core/src/index.ts +++ b/models/core/src/index.ts @@ -58,7 +58,6 @@ import { TTypeBlob, TTypeBoolean, TTypeCollaborativeDoc, - TTypeCollaborativeDocVersion, TTypeDate, TTypeFileSize, TTypeHyperlink, @@ -125,7 +124,6 @@ export function createModel (builder: Builder): void { TEnumOf, TTypeMarkup, TTypeCollaborativeDoc, - TTypeCollaborativeDocVersion, TArrOf, TRefTo, TTypeDate, diff --git a/models/core/src/migration.ts b/models/core/src/migration.ts index 36a96bafd0..e5dfc1e3bc 100644 --- a/models/core/src/migration.ts +++ b/models/core/src/migration.ts @@ -13,19 +13,21 @@ // limitations under the License. // -import { saveCollaborativeDoc } from '@hcengineering/collaboration' +import { saveCollabJson, saveCollabYdoc, yDocFromBuffer } from '@hcengineering/collaboration' import core, { - collaborativeDocParse, coreId, DOMAIN_MODEL_TX, DOMAIN_SPACE, DOMAIN_STATUS, DOMAIN_TX, generateId, - makeCollaborativeDoc, + makeDocCollabId, + makeCollabJsonId, + makeCollabYdocId, MeasureMetricsContext, RateLimiter, type AnyAttribute, + type Blob, type Class, type Doc, type Domain, @@ -48,7 +50,6 @@ import { type MigrationUpgradeClient } from '@hcengineering/model' import { type StorageAdapter } from '@hcengineering/storage' -import { markupToYDoc } from '@hcengineering/text' async function migrateStatusesToModel (client: MigrationClient): Promise { // Move statuses to model: @@ -176,7 +177,7 @@ async function migrateCollaborativeContentToStorage (client: MigrationClient): P const iterator = await client.traverse(domain, query) try { - console.log('processing', _class) + ctx.info('processing', { _class }) await processMigrateContentFor(ctx, domain, attributes, client, storageAdapter, iterator) } finally { await iterator.close() @@ -204,9 +205,6 @@ async function processMigrateContentFor ( break } - const timestamp = Date.now() - const revisionId = `${timestamp}` - const operations: { filter: MigrationDocumentQuery, update: MigrateUpdate }[] = [] for (const doc of docs) { @@ -214,8 +212,6 @@ async function processMigrateContentFor ( const update: MigrateUpdate = {} for (const attribute of attributes) { - const collaborativeDoc = makeCollaborativeDoc(doc._id, attribute.name, revisionId) - const value = hierarchy.isMixin(attribute.attributeOf) ? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string) : ((doc as any)[attribute.name] as string) @@ -224,22 +220,20 @@ async function processMigrateContentFor ( ? `${attribute.attributeOf}.${attribute.name}` : attribute.name + const collabId = makeDocCollabId(doc, attribute.name) + const blobId = makeCollabJsonId(collabId) + if (value != null && value.startsWith('{')) { - const { documentId } = collaborativeDocParse(collaborativeDoc) - const blob = await storageAdapter.stat(ctx, client.workspaceId, documentId) - // only for documents not in storage - if (blob === undefined) { - try { - const ydoc = markupToYDoc(value, attribute.name) - await saveCollaborativeDoc(ctx, storageAdapter, client.workspaceId, collaborativeDoc, ydoc) - } catch (err) { - console.error('failed to process document', doc._class, doc._id, err) - } + try { + const buffer = Buffer.from(value) + await storageAdapter.put(ctx, client.workspaceId, blobId, buffer, 'application/json', buffer.length) + } catch (err) { + ctx.error('failed to process document', { _class: doc._class, _id: doc._id, err }) } - update[attributeName] = collaborativeDoc + update[attributeName] = blobId } else if (value == null || value === '') { - update[attributeName] = collaborativeDoc + update[attributeName] = null } } @@ -256,10 +250,153 @@ async function processMigrateContentFor ( } processed += docs.length - console.log('...processed', processed) + ctx.info('...processed', { count: processed }) } } +async function migrateCollaborativeDocsToJson (client: MigrationClient): Promise { + const ctx = new MeasureMetricsContext('migrateCollaborativeDocsToJson', {}) + const storageAdapter = client.storageAdapter + + const hierarchy = client.hierarchy + const classes = hierarchy.getDescendants(core.class.Doc) + for (const _class of classes) { + const domain = hierarchy.findDomain(_class) + if (domain === undefined) continue + + const allAttributes = hierarchy.getAllAttributes(_class) + const attributes = Array.from(allAttributes.values()).filter((attribute) => { + return hierarchy.isDerived(attribute.type._class, core.class.TypeCollaborativeDoc) + }) + + if (attributes.length === 0) continue + if (hierarchy.isMixin(_class) && attributes.every((p) => p.attributeOf !== _class)) continue + + const query = hierarchy.isMixin(_class) ? { [_class]: { $exists: true } } : { _class } + + const iterator = await client.traverse(domain, query) + try { + ctx.info('processing', { _class }) + await processMigrateJsonForDomain(ctx, domain, attributes, client, storageAdapter, iterator) + } finally { + await iterator.close() + } + } +} + +async function processMigrateJsonForDomain ( + ctx: MeasureContext, + domain: Domain, + attributes: AnyAttribute[], + client: MigrationClient, + storageAdapter: StorageAdapter, + iterator: MigrationIterator +): Promise { + const rateLimiter = new RateLimiter(10) + + let processed = 0 + + while (true) { + const docs = await iterator.next(100) + if (docs === null || docs.length === 0) { + break + } + + const operations: { filter: MigrationDocumentQuery, update: MigrateUpdate }[] = [] + + for (const doc of docs) { + await rateLimiter.exec(async () => { + const update = await processMigrateJsonForDoc(ctx, doc, attributes, client, storageAdapter) + if (Object.keys(update).length > 0) { + operations.push({ filter: { _id: doc._id }, update }) + } + }) + } + + await rateLimiter.waitProcessing() + + if (operations.length > 0) { + await client.bulk(domain, operations) + } + + processed += docs.length + ctx.info('...processed', { count: processed }) + } +} + +async function processMigrateJsonForDoc ( + ctx: MeasureContext, + doc: Doc, + attributes: AnyAttribute[], + client: MigrationClient, + storageAdapter: StorageAdapter +): Promise> { + const { hierarchy, workspaceId } = client + + const update: MigrateUpdate = {} + + for (const attribute of attributes) { + const value = hierarchy.isMixin(attribute.attributeOf) + ? ((doc as any)[attribute.attributeOf]?.[attribute.name] as string) + : ((doc as any)[attribute.name] as string) + + if (value == null || value === '') { + continue + } + + const attributeName = hierarchy.isMixin(attribute.attributeOf) + ? `${attribute.attributeOf}.${attribute.name}` + : attribute.name + + // Name of existing ydoc document + // original value here looks like '65b7f82f4d422b89d4cbdd6f:HEAD:0' + // where the first part is the blob id + const currentYdocId = value.split(':')[0] as Ref + const collabId = makeDocCollabId(doc, attribute.name) + + if (value.startsWith('{')) { + // For some reason we have documents that are already markups + const jsonId = await saveCollabJson(ctx, storageAdapter, workspaceId, collabId, value) + update[attributeName] = jsonId + continue + } + + try { + const stat = await storageAdapter.stat(ctx, workspaceId, currentYdocId) + if (stat !== undefined) { + if (stat.contentType.includes('application/ydoc')) { + const buffer = await storageAdapter.read(ctx, workspaceId, currentYdocId) + const ydoc = yDocFromBuffer(Buffer.concat(buffer as any)) + + // If document id has changed, save it with new name to ensure we will be able to load it later + const ydocId = makeCollabYdocId(collabId) + if (ydocId !== currentYdocId) { + ctx.info('saving collaborative doc with new name', { collabId, ydocId, currentYdocId }) + await saveCollabYdoc(ctx, storageAdapter, workspaceId, collabId, ydoc) + // do not bother with deletion so we can restore content is something goes wrong + // await storageAdapter.remove(ctx, client.workspaceId, [currentYdocId]) + } + + // Save document as JSON and save blob Id + const jsonId = await saveCollabJson(ctx, storageAdapter, workspaceId, collabId, ydoc) + update[attributeName] = jsonId + } else { + // it is not ydoc, do nothing + continue + } + } else { + // document is empty, unset + const unset = update.$unset ?? {} + update.$unset = { ...unset, [attribute.name]: 1 } + } + } catch (err) { + ctx.warn('failed to process collaborative doc', { workspaceId, collabId, currentYdocId, err }) + } + } + + return update +} + export const coreOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise { await tryMigrate(client, coreId, [ @@ -351,6 +488,10 @@ export const coreOperation: MigrateOperation = { DOMAIN_MODEL_TX ) } + }, + { + state: 'collaborative-docs-to-json', + func: migrateCollaborativeDocsToJson } ]) }, diff --git a/models/document/package.json b/models/document/package.json index 55c1b687ad..f671066983 100644 --- a/models/document/package.json +++ b/models/document/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@hcengineering/model-core": "^0.6.0", + "@hcengineering/model-activity": "^0.6.0", "@hcengineering/model-chunter": "^0.6.0", "@hcengineering/model-workbench": "^0.6.1", "@hcengineering/model-attachment": "^0.6.0", diff --git a/models/document/src/index.ts b/models/document/src/index.ts index 23d8f4d997..1e17d90d36 100644 --- a/models/document/src/index.ts +++ b/models/document/src/index.ts @@ -1,5 +1,5 @@ // -// Copyright © 2022, 2023 Hardcore Engineering Inc. +// Copyright © 2022, 2023, 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 @@ -14,7 +14,7 @@ // import activity from '@hcengineering/activity' -import type { Class, CollaborativeDoc, CollectionSize, Domain, Rank, Role, RolesAssignment } from '@hcengineering/core' +import type { Class, CollectionSize, MarkupBlobRef, Domain, Rank, Role, RolesAssignment } from '@hcengineering/core' import { Account, AccountRole, IndexKind, Ref } from '@hcengineering/core' import { type Document, @@ -32,8 +32,8 @@ import { Mixin, Model, Prop, + ReadOnly, TypeCollaborativeDoc, - TypeCollaborativeDocVersion, TypeNumber, TypeRef, TypeString, @@ -77,7 +77,7 @@ export class TDocument extends TDoc implements Document, Todoable { title!: string @Prop(TypeCollaborativeDoc(), document.string.Document) - content!: CollaborativeDoc + content!: MarkupBlobRef | null @Prop(TypeRef(document.class.Document), document.string.ParentDocument) parent!: Ref @@ -137,8 +137,9 @@ export class TDocumentSnapshot extends TDoc implements DocumentSnapshot { @Index(IndexKind.FullText) title!: string - @Prop(TypeCollaborativeDocVersion(), document.string.Document) - content!: CollaborativeDoc + @Prop(TypeCollaborativeDoc(), document.string.Document) + @ReadOnly() + content!: MarkupBlobRef @Prop(TypeRef(document.class.Document), document.string.ParentDocument) parent!: Ref diff --git a/models/document/src/migration.ts b/models/document/src/migration.ts index e05caea6f9..6bc4efb4a4 100644 --- a/models/document/src/migration.ts +++ b/models/document/src/migration.ts @@ -13,8 +13,18 @@ // limitations under the License. // -import { DOMAIN_MODEL_TX, MeasureMetricsContext, SortingOrder, type CollaborativeDoc } from '@hcengineering/core' -import { type Document, type DocumentSnapshot, type Teamspace } from '@hcengineering/document' +import { + type Class, + type CollaborativeDoc, + type Doc, + type Ref, + DOMAIN_TX, + DOMAIN_MODEL_TX, + MeasureMetricsContext, + SortingOrder, + makeDocCollabId +} from '@hcengineering/core' +import { type DocumentSnapshot, type Document, type Teamspace } from '@hcengineering/document' import { migrateSpaceRanks, tryMigrate, @@ -24,11 +34,13 @@ import { type MigrationDocumentQuery, type MigrationUpgradeClient } from '@hcengineering/model' +import { DOMAIN_ACTIVITY } from '@hcengineering/model-activity' import core, { DOMAIN_SPACE } from '@hcengineering/model-core' +import { DOMAIN_NOTIFICATION } from '@hcengineering/notification' import { type Asset } from '@hcengineering/platform' import { makeRank } from '@hcengineering/rank' -import { loadCollaborativeDoc, saveCollaborativeDoc, yDocCopyXmlField } from '@hcengineering/collaboration' +import { loadCollabYdoc, saveCollabYdoc, yDocCopyXmlField } from '@hcengineering/collaboration' import document, { documentId, DOMAIN_DOCUMENT } from './index' async function migrateDocumentIcons (client: MigrationClient): Promise { @@ -197,23 +209,23 @@ async function renameFieldsRevert (client: MigrationClient): Promise { } ) - if (document.description.includes('%description:')) { - try { - const ydoc = await loadCollaborativeDoc(ctx, storage, client.workspaceId, document.description) - if (ydoc === undefined) { - continue - } + try { + const collabId = makeDocCollabId(document, 'content') - if (!ydoc.share.has('description') || ydoc.share.has('content')) { - continue - } - - yDocCopyXmlField(ydoc, 'description', 'content') - - await saveCollaborativeDoc(ctx, storage, client.workspaceId, document.description, ydoc) - } catch (err) { - ctx.error('error document content migration', { error: err, document: document.title }) + const ydoc = await loadCollabYdoc(ctx, storage, client.workspaceId, collabId) + if (ydoc === undefined) { + continue } + + if (!ydoc.share.has('description') || ydoc.share.has('content')) { + continue + } + + yDocCopyXmlField(ydoc, 'description', 'content') + + await saveCollabYdoc(ctx, storage, client.workspaceId, collabId, ydoc) + } catch (err) { + ctx.error('error document content migration', { error: err, document: document.title }) } } @@ -246,7 +258,9 @@ async function restoreContentField (client: MigrationClient): Promise { for (const document of documents) { try { - const ydoc = await loadCollaborativeDoc(ctx, storage, client.workspaceId, document.content) + const collabId = makeDocCollabId(document, 'content') + + const ydoc = await loadCollabYdoc(ctx, storage, client.workspaceId, collabId) if (ydoc === undefined) { ctx.error('document content not found', { document: document.title }) continue @@ -260,7 +274,7 @@ async function restoreContentField (client: MigrationClient): Promise { if (ydoc.share.has('')) { yDocCopyXmlField(ydoc, '', 'content') if (ydoc.share.has('content')) { - await saveCollaborativeDoc(ctx, storage, client.workspaceId, document.content, ydoc) + await saveCollabYdoc(ctx, storage, client.workspaceId, collabId, ydoc) } else { ctx.error('document content still not found', { document: document.title }) } @@ -281,6 +295,23 @@ async function migrateRanks (client: MigrationClient): Promise { } } +async function removeOldClasses (client: MigrationClient): Promise { + const classes = [ + 'document:class:DocumentContent', + 'document:class:DocumentSnapshot', + 'document:class:DocumentVersion', + 'document:class:DocumentRequest' + ] as Ref>[] + + for (const _class of classes) { + await client.deleteMany(DOMAIN_DOCUMENT, { _class }) + await client.deleteMany(DOMAIN_ACTIVITY, { attachedToClass: _class }) + await client.deleteMany(DOMAIN_ACTIVITY, { objectClass: _class }) + await client.deleteMany(DOMAIN_NOTIFICATION, { attachedToClass: _class }) + await client.deleteMany(DOMAIN_TX, { objectClass: _class }) + await client.deleteMany(DOMAIN_TX, { 'tx.objectClass': _class }) + } +} export const documentOperation: MigrateOperation = { async migrate (client: MigrationClient): Promise { await tryMigrate(client, documentId, [ @@ -321,6 +352,10 @@ export const documentOperation: MigrateOperation = { { state: 'migrateRanks', func: migrateRanks + }, + { + state: 'removeOldClasses', + func: removeOldClasses } ]) }, diff --git a/models/lead/src/migration.ts b/models/lead/src/migration.ts index 7f77fd49da..df581ebabf 100644 --- a/models/lead/src/migration.ts +++ b/models/lead/src/migration.ts @@ -13,14 +13,7 @@ // limitations under the License. // -import { - AccountRole, - DOMAIN_MODEL_TX, - makeCollaborativeDoc, - TxOperations, - type Ref, - type Status -} from '@hcengineering/core' +import { AccountRole, DOMAIN_MODEL_TX, TxOperations, type Ref, type Status } from '@hcengineering/core' import { leadId, type Lead } from '@hcengineering/lead' import { tryMigrate, @@ -213,44 +206,6 @@ export const leadOperation: MigrateOperation = { } } ) - const it = await client.traverse(DOMAIN_CONTACT, { - _class: contact.class.Organization, - description: { $exists: false } - }) - while (true) { - const docs = await it.next(50) - if (docs == null || docs.length === 0) { - break - } - await client.bulk( - DOMAIN_CONTACT, - docs.map((doc) => ({ - filter: { _id: doc._id }, - update: { $set: { description: makeCollaborativeDoc(doc._id, 'description') } } - })) - ) - } - const it2 = await client.traverse(DOMAIN_CONTACT, { [lead.mixin.Customer + '.customerDescription']: null }) - while (true) { - const docs = await it2.next(50) - if (docs == null || docs.length === 0) { - break - } - await client.bulk( - DOMAIN_CONTACT, - docs.map((doc) => ({ - filter: { _id: doc._id }, - update: { - $set: { - [lead.mixin.Customer + '.customerDescription']: makeCollaborativeDoc( - docs[0]._id, - 'customerDescription' - ) - } - } - })) - ) - } } } ]) diff --git a/models/lead/src/types.ts b/models/lead/src/types.ts index 7a32aeebbf..25b807ec0e 100644 --- a/models/lead/src/types.ts +++ b/models/lead/src/types.ts @@ -17,7 +17,7 @@ import type { Employee } from '@hcengineering/contact' import { Account, IndexKind, - type CollaborativeDoc, + type MarkupBlobRef, type Role, type RolesAssignment, type Ref, @@ -97,7 +97,7 @@ export class TCustomer extends TContact implements Customer { @Prop(TypeCollaborativeDoc(), lead.string.Description) @Index(IndexKind.FullText) - customerDescription!: CollaborativeDoc + customerDescription!: MarkupBlobRef | null } @Mixin(lead.mixin.DefaultFunnelTypeData, lead.class.Funnel) diff --git a/models/love/src/index.ts b/models/love/src/index.ts index 0467e74658..4b5183244c 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -16,15 +16,15 @@ import contact, { type Employee, type Person } from '@hcengineering/contact' import { AccountRole, - type CollaborativeDoc, type CollectionSize, - DateRangeMode, type Doc, type Domain, - DOMAIN_TRANSIENT, - IndexKind, + type MarkupBlobRef, type Ref, - type Timestamp + type Timestamp, + DOMAIN_TRANSIENT, + DateRangeMode, + IndexKind } from '@hcengineering/core' import { type DevicesPreference, @@ -91,7 +91,7 @@ export class TRoom extends TDoc implements Room { @Prop(TypeCollaborativeDoc(), core.string.Description) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null type!: RoomType @@ -212,7 +212,7 @@ export class TMeetingMinutes extends TAttachedDoc implements MeetingMinutes, Tod @Prop(TypeCollaborativeDoc(), core.string.Description) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null @Prop(TypeAny(love.component.MeetingMinutesStatusPresenter, love.string.Status), love.string.Status, { editor: love.component.MeetingMinutesStatusPresenter diff --git a/models/love/src/migration.ts b/models/love/src/migration.ts index be65d9912d..db983c7aa4 100644 --- a/models/love/src/migration.ts +++ b/models/love/src/migration.ts @@ -14,7 +14,7 @@ // import contact from '@hcengineering/contact' -import { type Space, TxOperations, type Ref, makeCollaborativeDoc } from '@hcengineering/core' +import { type Space, TxOperations, type Ref } from '@hcengineering/core' import drive from '@hcengineering/drive' import { MeetingStatus, @@ -23,8 +23,7 @@ import { createDefaultRooms, isOffice, loveId, - type Floor, - type Room + type Floor } from '@hcengineering/love' import { createDefaultSpace, @@ -90,7 +89,7 @@ async function createReception (client: MigrationUpgradeClient): Promise { language: 'en', startWithTranscription: false, startWithRecording: false, - description: makeCollaborativeDoc(love.ids.Reception, 'description') + description: null }, love.ids.Reception ) @@ -156,18 +155,6 @@ export const loveOperation: MigrateOperation = { await client.move(DOMAIN_LOVE, { _class: love.class.MeetingMinutes }, DOMAIN_MEETING_MINUTES) } }, - { - state: 'create-description-collaborative', - func: async (client) => { - const rooms = await client.find(DOMAIN_LOVE, { _class: { $in: [love.class.Room, love.class.Office] } }) - for (const room of rooms) { - const description = room.description - if (description == null) { - await client.update(DOMAIN_LOVE, room, { description: makeCollaborativeDoc(room._id, 'description') }) - } - } - } - }, { state: 'default-meeting-minutes-status', func: async (client) => { diff --git a/models/recruit/src/types.ts b/models/recruit/src/types.ts index c6d390be15..8d1c097744 100644 --- a/models/recruit/src/types.ts +++ b/models/recruit/src/types.ts @@ -17,8 +17,8 @@ import type { Employee, Organization } from '@hcengineering/contact' import { Account, IndexKind, - type CollaborativeDoc, type Collection, + type MarkupBlobRef, type Domain, type Markup, type Ref, @@ -68,7 +68,7 @@ import recruit from './plugin' export class TVacancy extends TProject implements Vacancy { @Prop(TypeCollaborativeDoc(), recruit.string.FullDescription) @Index(IndexKind.FullText) - fullDescription!: CollaborativeDoc + fullDescription!: MarkupBlobRef | null @Prop(TypeCollection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files diff --git a/models/test-management/src/types.ts b/models/test-management/src/types.ts index c0b98b211a..fc1ffcd1ef 100644 --- a/models/test-management/src/types.ts +++ b/models/test-management/src/types.ts @@ -40,7 +40,7 @@ import { type Timestamp, type Type, type CollectionSize, - type CollaborativeDoc, + type MarkupBlobRef, type Class } from '@hcengineering/core' import { @@ -163,7 +163,7 @@ export class TTestCase extends TAttachedDoc implements TestCase { @Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null @Prop(TypeTestCaseType(), testManagement.string.TestType) @ReadOnly() @@ -196,7 +196,7 @@ export class TTestRun extends TDoc implements TestRun { @Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null @Prop(TypeDate(DateRangeMode.DATETIME), testManagement.string.DueDate) dueDate?: Timestamp @@ -244,7 +244,7 @@ export class TTestResult extends TAttachedDoc implements TestResult { @Prop(TypeCollaborativeDoc(), testManagement.string.FullDescription) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null @Prop(TypeRef(testManagement.class.TestCase), testManagement.string.TestCase) testCase!: Ref diff --git a/models/tracker/src/types.ts b/models/tracker/src/types.ts index 70cadb279e..92d8ba2d13 100644 --- a/models/tracker/src/types.ts +++ b/models/tracker/src/types.ts @@ -19,7 +19,7 @@ import { DOMAIN_MODEL, DateRangeMode, IndexKind, - type CollaborativeDoc, + type MarkupBlobRef, type Domain, type Markup, type Ref, @@ -185,7 +185,7 @@ export class TIssue extends TTask implements Issue { @Prop(TypeCollaborativeDoc(), tracker.string.Description) @Index(IndexKind.FullText) - description!: CollaborativeDoc + description!: MarkupBlobRef | null @Prop(TypeRef(tracker.class.IssueStatus), tracker.string.Status, { _id: tracker.attribute.IssueStatus, diff --git a/models/view/src/index.ts b/models/view/src/index.ts index 7d21f3db1c..968100c190 100644 --- a/models/view/src/index.ts +++ b/models/view/src/index.ts @@ -514,10 +514,6 @@ export function createModel (builder: Builder): void { presenter: view.component.MarkupDiffPresenter }) - builder.mixin(core.class.TypeCollaborativeDocVersion, core.class.Class, view.mixin.InlineAttributEditor, { - editor: view.component.CollaborativeDocEditor - }) - classPresenter(builder, core.class.TypeBoolean, view.component.BooleanPresenter, view.component.BooleanEditor) classPresenter( builder, diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index a39c0bc8df..5ad7fe45b9 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -128,12 +128,12 @@ class PlatformClientImpl implements PlatformClient { await this.connection.close() } - private async processMarkup(id: Ref, data: WithMarkup): Promise { + private async processMarkup(_class: Ref>, id: Ref, data: WithMarkup): Promise { const result: any = {} for (const [key, value] of Object.entries(data)) { if (value instanceof MarkupContent) { - result[key] = this.markup.uploadMarkup(id, key, value.content, value.kind) + result[key] = this.markup.uploadMarkup(_class, id, key, value.content, value.kind) } else { result[key] = value } @@ -151,7 +151,7 @@ class PlatformClientImpl implements PlatformClient { id?: Ref ): Promise> { id ??= generateId() - const data = await this.processMarkup>(id, attributes) + const data = await this.processMarkup>(_class, id, attributes) return await this.client.createDoc(_class, space, data, id) } @@ -162,7 +162,7 @@ class PlatformClientImpl implements PlatformClient { operations: WithMarkup>, retrieve?: boolean ): Promise { - const update = await this.processMarkup>(objectId, operations) + const update = await this.processMarkup>(_class, objectId, operations) return await this.client.updateDoc(_class, space, objectId, update, retrieve) } @@ -182,7 +182,7 @@ class PlatformClientImpl implements PlatformClient { id?: Ref

): Promise> { id ??= generateId() - const data = await this.processMarkup>(id, attributes) + const data = await this.processMarkup>(_class, id, attributes) return await this.client.addCollection(_class, space, attachedTo, attachedToClass, collection, data, id) } @@ -196,7 +196,7 @@ class PlatformClientImpl implements PlatformClient { operations: WithMarkup>, retrieve?: boolean ): Promise> { - const update = await this.processMarkup>(objectId, operations) + const update = await this.processMarkup>(_class, objectId, operations) return await this.client.updateCollection( _class, space, @@ -229,7 +229,7 @@ class PlatformClientImpl implements PlatformClient { mixin: Ref>, attributes: WithMarkup> ): Promise { - const data = await this.processMarkup>(objectId, attributes) + const data = await this.processMarkup>(objectClass, objectId, attributes) return await this.client.createMixin(objectId, objectClass, objectSpace, mixin, data) } @@ -240,18 +240,30 @@ class PlatformClientImpl implements PlatformClient { mixin: Ref>, attributes: WithMarkup> ): Promise { - const update = await this.processMarkup>(objectId, attributes) + const update = await this.processMarkup>(objectClass, objectId, attributes) return await this.client.updateMixin(objectId, objectClass, objectSpace, mixin, update) } // Markup - async fetchMarkup (objectId: Ref, objectAttr: string, markup: MarkupRef, format: MarkupFormat): Promise { - return await this.markup.fetchMarkup(objectId, objectAttr, markup, format) + async fetchMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: MarkupRef, + format: MarkupFormat + ): Promise { + return await this.markup.fetchMarkup(objectClass, objectId, objectAttr, markup, format) } - async uploadMarkup (objectId: Ref, objectAttr: string, markup: string, format: MarkupFormat): Promise { - return await this.markup.uploadMarkup(objectId, objectAttr, markup, format) + async uploadMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: string, + format: MarkupFormat + ): Promise { + return await this.markup.uploadMarkup(objectClass, objectId, objectAttr, markup, format) } // AsyncDisposable diff --git a/packages/api-client/src/markup/client.ts b/packages/api-client/src/markup/client.ts index 7bc255899a..180e6fdfdf 100644 --- a/packages/api-client/src/markup/client.ts +++ b/packages/api-client/src/markup/client.ts @@ -13,7 +13,7 @@ // limitations under the License. // -import { type Doc, Markup, type Ref, concatLink, makeCollaborativeDoc } from '@hcengineering/core' +import { type Class, type Doc, type Markup, type Ref, concatLink, makeCollabId } from '@hcengineering/core' import { type CollaboratorClient, getClient } from '@hcengineering/collaborator-client' import { parseMessageMarkdown, jsonToMarkup, markupToHTML, markupToMarkdown, htmlToMarkup } from '@hcengineering/text' @@ -45,9 +45,15 @@ class MarkupOperationsImpl implements MarkupOperations { this.collaborator = getClient({ name: workspace }, token, config.COLLABORATOR_URL) } - async fetchMarkup (objectId: Ref, objectAttr: string, doc: MarkupRef, format: MarkupFormat): Promise { - const content = await this.collaborator.getContent(doc) - const markup = content[objectAttr] ?? '' + async fetchMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + doc: MarkupRef, + format: MarkupFormat + ): Promise { + const collabId = makeCollabId(objectClass, objectId, objectAttr) + const markup = await this.collaborator.getMarkup(collabId, doc) switch (format) { case 'markup': @@ -61,7 +67,13 @@ class MarkupOperationsImpl implements MarkupOperations { } } - async uploadMarkup (objectId: Ref, objectAttr: string, value: string, format: MarkupFormat): Promise { + async uploadMarkup ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + value: string, + format: MarkupFormat + ): Promise { let markup: Markup = '' switch (format) { @@ -78,8 +90,7 @@ class MarkupOperationsImpl implements MarkupOperations { throw new Error('Unknown content format') } - const doc = makeCollaborativeDoc(objectId, objectAttr) - await this.collaborator.updateContent(doc, { [objectAttr]: markup }) - return doc + const collabId = makeCollabId(objectClass, objectId, objectAttr) + return await this.collaborator.createMarkup(collabId, markup) } } diff --git a/packages/api-client/src/markup/types.ts b/packages/api-client/src/markup/types.ts index a641f9fd06..a4f6765689 100644 --- a/packages/api-client/src/markup/types.ts +++ b/packages/api-client/src/markup/types.ts @@ -13,10 +13,10 @@ // limitations under the License. // -import { type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core' +import { Class, type Blob, type Doc, type Ref } from '@hcengineering/core' /** @public */ -export type MarkupRef = CollaborativeDoc +export type MarkupRef = Ref /** @public */ export type MarkupFormat = 'markup' | 'html' | 'markdown' @@ -45,21 +45,35 @@ export function markdown (content: string): MarkupContent { export interface MarkupOperations { /** * Retrieves markup content for a specified document object + * * @param objectClass - Reference to the class of the document containing the markup * @param objectId - Reference to the document containing the markup * @param objectAttr - The attribute/field name where the markup is stored * @param id - Unique reference identifying the specific markup content * @param format - The format of the markup (e.g., HTML, Markdown, etc.) * @returns Promise containing the markup content as a string */ - fetchMarkup: (objectId: Ref, objectAttr: string, id: MarkupRef, format: MarkupFormat) => Promise + fetchMarkup: ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + id: MarkupRef, + format: MarkupFormat + ) => Promise /** * Saves markup content for a document object + * @param objectClass - Reference to the class of the document where markup should be stored * @param objectId - Reference to the document where markup should be stored * @param objectAttr - The attribute/field name where markup should be saved * @param markup - The actual markup content to be uploaded * @param format - The format of the provided markup (e.g., HTML, Markdown, etc.) * @returns Promise containing a reference to the newly saved markup */ - uploadMarkup: (objectId: Ref, objectAttr: string, markup: string, format: MarkupFormat) => Promise + uploadMarkup: ( + objectClass: Ref>, + objectId: Ref, + objectAttr: string, + markup: string, + format: MarkupFormat + ) => Promise } diff --git a/packages/collaborator-client/src/__tests__/utils.test.ts b/packages/collaborator-client/src/__tests__/utils.test.ts index 00b407d3d6..992e39c2bd 100644 --- a/packages/collaborator-client/src/__tests__/utils.test.ts +++ b/packages/collaborator-client/src/__tests__/utils.test.ts @@ -13,26 +13,27 @@ // limitations under the License. // -import { CollaborativeDoc } from '@hcengineering/core' -import { DocumentId } from '../types' -import { formatDocumentId, parseDocumentId } from '../utils' +import core, { CollaborativeDoc, Doc, Ref } from '@hcengineering/core' +import { encodeDocumentId, decodeDocumentId } from '../utils' describe('utils', () => { - it('formatDocumentId', () => { - expect(formatDocumentId('ws1', 'doc1:HEAD:v1' as CollaborativeDoc)).toEqual('ws1://doc1:HEAD' as DocumentId) - expect(formatDocumentId('ws1', 'doc1:HEAD:v1#doc2:v2:v2' as CollaborativeDoc)).toEqual( - 'ws1://doc1:HEAD/doc2:v2' as DocumentId - ) + it('encodeDocumentId', () => { + const doc: CollaborativeDoc = { + objectClass: core.class.Card, + objectId: 'doc1' as Ref, + objectAttr: 'description' + } + expect(encodeDocumentId('ws1', doc)).toEqual('ws1|core:class:Card|doc1|description') }) - describe('parseDocumentId', () => { - expect(parseDocumentId('ws1://doc1:HEAD' as DocumentId)).toEqual({ + describe('decodeDocumentId', () => { + expect(decodeDocumentId('ws1|core:class:Card|doc1|description')).toEqual({ workspaceId: 'ws1', - collaborativeDoc: 'doc1:HEAD:HEAD' as CollaborativeDoc - }) - expect(parseDocumentId('ws1://doc1:HEAD/doc2:v2' as DocumentId)).toEqual({ - workspaceId: 'ws1', - collaborativeDoc: 'doc1:HEAD:HEAD#doc2:v2:v2' as CollaborativeDoc + documentId: { + objectClass: core.class.Card, + objectId: 'doc1' as Ref, + objectAttr: 'description' + } }) }) }) diff --git a/packages/collaborator-client/src/client.ts b/packages/collaborator-client/src/client.ts index c7ffa716a4..b7d34156a4 100644 --- a/packages/collaborator-client/src/client.ts +++ b/packages/collaborator-client/src/client.ts @@ -13,18 +13,29 @@ // limitations under the License. // -import { CollaborativeDoc, Markup, WorkspaceId, concatLink } from '@hcengineering/core' -import { formatDocumentId } from './utils' +import { Blob, CollaborativeDoc, Markup, MarkupBlobRef, Ref, WorkspaceId, concatLink } from '@hcengineering/core' +import { encodeDocumentId } from './utils' /** @public */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface GetContentRequest {} +export interface GetContentRequest { + source?: Ref +} /** @public */ export interface GetContentResponse { content: Record } +/** @public */ +export interface CreateContentRequest { + content: Record +} + +/** @public */ +export interface CreateContentResponse { + content: Record +} + /** @public */ export interface UpdateContentRequest { content: Record @@ -36,8 +47,9 @@ export interface UpdateContentResponse {} /** @public */ export interface CollaboratorClient { - getContent: (document: CollaborativeDoc) => Promise> - updateContent: (document: CollaborativeDoc, content: Record) => Promise + getMarkup: (document: CollaborativeDoc, source?: Ref | null) => Promise + createMarkup: (document: CollaborativeDoc, markup: Markup) => Promise + updateMarkup: (document: CollaborativeDoc, markup: Markup) => Promise copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise } @@ -54,11 +66,11 @@ class CollaboratorClientImpl implements CollaboratorClient { private readonly collaboratorUrl: string ) {} - private async rpc (document: CollaborativeDoc, method: string, payload: any): Promise { + private async rpc(document: CollaborativeDoc, method: string, payload: P): Promise { const workspace = this.workspace.name - const documentId = formatDocumentId(workspace, document) + const documentId = encodeDocumentId(workspace, document) - const url = concatLink(this.collaboratorUrl, '/rpc') + const url = concatLink(this.collaboratorUrl, `/rpc/${encodeURIComponent(documentId)}`) const res = await fetch(url, { method: 'POST', @@ -66,7 +78,7 @@ class CollaboratorClientImpl implements CollaboratorClient { Authorization: 'Bearer ' + this.token, 'Content-Type': 'application/json' }, - body: JSON.stringify({ method, documentId, payload }) + body: JSON.stringify({ method, payload }) }) if (!res.ok) { @@ -79,33 +91,58 @@ class CollaboratorClientImpl implements CollaboratorClient { throw new Error(result.error) } - return result + return result as R } - async getContent (document: CollaborativeDoc): Promise> { + async getMarkup (document: CollaborativeDoc, source?: Ref | null): Promise { + const payload: GetContentRequest = { + source: source !== null ? source : undefined + } + const res = await retry( 3, async () => { - return (await this.rpc(document, 'getContent', {})) as GetContentResponse + return await this.rpc(document, 'getContent', payload) }, 50 ) - return res.content ?? {} + + return res.content[document.objectAttr] ?? '' } - async updateContent (document: CollaborativeDoc, content: Record): Promise { + async createMarkup (document: CollaborativeDoc, markup: Markup): Promise { + const content = { + [document.objectAttr]: markup + } + + const res = await retry( + 3, + async () => { + return await this.rpc(document, 'createContent', { content }) + }, + 50 + ) + + return res.content[document.objectAttr] + } + + async updateMarkup (document: CollaborativeDoc, markup: Markup): Promise { + const content = { + [document.objectAttr]: markup + } + await retry( 3, async () => { - await this.rpc(document, 'updateContent', { content }) + await this.rpc(document, 'updateContent', { content }) }, 50 ) } - async copyContent (source: CollaborativeDoc, target: CollaborativeDoc): Promise { - const content = await this.getContent(source) - await this.updateContent(target, content) + async copyContent (source: CollaborativeDoc, target: CollaborativeDoc, content?: Ref): Promise { + const markup = await this.getMarkup(source, content) + await this.updateMarkup(target, markup) } } diff --git a/packages/collaborator-client/src/index.ts b/packages/collaborator-client/src/index.ts index b487cf8270..70baf83124 100644 --- a/packages/collaborator-client/src/index.ts +++ b/packages/collaborator-client/src/index.ts @@ -14,5 +14,4 @@ // export * from './client' -export * from './types' export * from './utils' diff --git a/packages/collaborator-client/src/types.ts b/packages/collaborator-client/src/types.ts deleted file mode 100644 index 9169a5eadc..0000000000 --- a/packages/collaborator-client/src/types.ts +++ /dev/null @@ -1,20 +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. -// - -/** @public */ -export type DocumentId = string & { __documentId: true } - -/** @public */ -export type PlatformDocumentId = string & { __platformDocId: true } diff --git a/packages/collaborator-client/src/utils.ts b/packages/collaborator-client/src/utils.ts index 216744b62e..f6225ba0aa 100644 --- a/packages/collaborator-client/src/utils.ts +++ b/packages/collaborator-client/src/utils.ts @@ -13,78 +13,26 @@ // limitations under the License. // -import { - Class, - CollaborativeDoc, - Doc, - Ref, - collaborativeDocChain, - collaborativeDocFormat, - collaborativeDocParse, - collaborativeDocUnchain -} from '@hcengineering/core' -import { DocumentId, PlatformDocumentId } from './types' +import { Class, CollaborativeDoc, Doc, Ref } from '@hcengineering/core' -/** - * Formats collaborative document as Hocuspocus document name. - * - * The document name is used for document identification on the server so should remain the same even - * when document is updated. Hence, we remove lastVersionId component from CollaborativeDoc. - * - * Example: - * workspace1://doc1:HEAD/doc2:v1 - * - * @public - */ -export function formatDocumentId (workspaceId: string, collaborativeDoc: CollaborativeDoc): DocumentId { - const path = collaborativeDocUnchain(collaborativeDoc) - .map((p) => { - const { documentId, versionId } = collaborativeDocParse(p) - return `${documentId}:${versionId}` - }) - .join('/') - - return `${workspaceId}://${path}` as DocumentId +/** @public */ +export function encodeDocumentId (workspaceId: string, documentId: CollaborativeDoc): string { + const { objectClass, objectId, objectAttr } = documentId + return [workspaceId, objectClass, objectId, objectAttr].join('|') } /** @public */ -export function parseDocumentId (documentId: DocumentId): { +export function decodeDocumentId (documentId: string): { workspaceId: string - collaborativeDoc: CollaborativeDoc + documentId: CollaborativeDoc } { - const [workspaceId, path] = documentId.split('://') - const segments = path.split('/') - - const collaborativeDocs = segments.map((p) => { - const [documentId, versionId] = p.split(':') - return collaborativeDocFormat({ documentId, versionId, lastVersionId: versionId }) - }) - + const [workspaceId, objectClass, objectId, objectAttr] = documentId.split('|') return { workspaceId, - collaborativeDoc: collaborativeDocChain(...collaborativeDocs) - } -} - -/** @public */ -export function formatPlatformDocumentId ( - objectClass: Ref>, - objectId: Ref, - objectAttr: string -): PlatformDocumentId { - return `${objectClass}/${objectId}/${objectAttr}` as PlatformDocumentId -} - -/** @public */ -export function parsePlatformDocumentId (platformDocumentId: PlatformDocumentId): { - objectClass: Ref> - objectId: Ref - objectAttr: string -} { - const [objectClass, objectId, objectAttr] = platformDocumentId.split('/') - return { - objectClass: objectClass as Ref>, - objectId: objectId as Ref, - objectAttr + documentId: { + objectClass: objectClass as Ref>, + objectId: objectId as Ref, + objectAttr + } } } diff --git a/packages/core/lang/en.json b/packages/core/lang/en.json index fa7f241872..f0a11de8cd 100644 --- a/packages/core/lang/en.json +++ b/packages/core/lang/en.json @@ -36,7 +36,7 @@ "Enum": "Enum", "Members": "Members", "Hyperlink": "URL", - "Collaborative": "Collaborative", + "MarkupBlobRef": "Collaborative", "Object": "Object", "System": "System", "CreatedBy": "Created by", diff --git a/packages/core/lang/es.json b/packages/core/lang/es.json index 563325862c..67acda9558 100644 --- a/packages/core/lang/es.json +++ b/packages/core/lang/es.json @@ -29,7 +29,7 @@ "Enum": "Enum.", "Members": "Miembros", "Hyperlink": "Enlace", - "Collaborative": "Colaborativo", + "MarkupBlobRef": "Colaborativo", "Object": "Objeto", "System": "Sistema", "CreatedBy": "Creado por", diff --git a/packages/core/lang/fr.json b/packages/core/lang/fr.json index 0e33492919..9fea9a9952 100644 --- a/packages/core/lang/fr.json +++ b/packages/core/lang/fr.json @@ -36,7 +36,7 @@ "Enum": "Énumération", "Members": "Membres", "Hyperlink": "URL", - "Collaborative": "Collaboratif", + "MarkupBlobRef": "Collaboratif", "Object": "Objet", "System": "Système", "CreatedBy": "Créé par", diff --git a/packages/core/lang/pt.json b/packages/core/lang/pt.json index 171e524cb6..d654320cf1 100644 --- a/packages/core/lang/pt.json +++ b/packages/core/lang/pt.json @@ -29,7 +29,7 @@ "Enum": "Enumeração", "Members": "Membros", "Hyperlink": "URL", - "Collaborative": "Colaborativo", + "MarkupBlobRef": "Colaborativo", "Object": "Objeto", "System": "Sistema", "CreatedBy": "Criado por", diff --git a/packages/core/lang/ru.json b/packages/core/lang/ru.json index c31dd56013..b887a212fa 100644 --- a/packages/core/lang/ru.json +++ b/packages/core/lang/ru.json @@ -36,7 +36,7 @@ "Enum": "Справочник", "Members": "Участники", "Hyperlink": "URL", - "Collaborative": "Коллаборативный", + "MarkupBlobRef": "Коллаборативный", "Object": "Объект", "System": "Система", "CreatedBy": "Создан", diff --git a/packages/core/lang/zh.json b/packages/core/lang/zh.json index d7370b90d2..11a95ea773 100644 --- a/packages/core/lang/zh.json +++ b/packages/core/lang/zh.json @@ -36,7 +36,7 @@ "Enum": "枚举", "Members": "成员", "Hyperlink": "URL", - "Collaborative": "协作", + "MarkupBlobRef": "协作", "Object": "对象", "System": "系统", "CreatedBy": "创建者", diff --git a/packages/core/src/__tests__/collaboration.test.ts b/packages/core/src/__tests__/collaboration.test.ts deleted file mode 100644 index ffc376a30a..0000000000 --- a/packages/core/src/__tests__/collaboration.test.ts +++ /dev/null @@ -1,204 +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. -// - -import { - CollaborativeDoc, - collaborativeDocChain, - collaborativeDocUnchain, - collaborativeDocFormat, - collaborativeDocParse, - collaborativeDocFromCollaborativeDoc, - collaborativeDocFromLastVersion, - collaborativeDocWithVersion, - collaborativeDocWithLastVersion, - collaborativeDocWithSource -} from '../collaboration' - -describe('collaborative-doc', () => { - describe('collaborativeDocChain', () => { - it('chains one collaborative doc', async () => { - expect(collaborativeDocChain('doc1:v1:v1' as CollaborativeDoc)).toEqual('doc1:v1:v1' as CollaborativeDoc) - }) - it('chains multiple collaborative docs', async () => { - expect(collaborativeDocChain('doc1:v1:v1' as CollaborativeDoc, 'doc2:v2:v2' as CollaborativeDoc)).toEqual( - 'doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc - ) - }) - it('chains multiple chained collaborative docs', async () => { - expect( - collaborativeDocChain('doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc, 'doc3:v3:v3' as CollaborativeDoc) - ).toEqual('doc1:v1:v1#doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc) - }) - }) - - describe('collaborativeDocUnchain', () => { - it('unchains one collaborative doc', async () => { - expect(collaborativeDocUnchain('doc1:v1:v1' as CollaborativeDoc)).toEqual(['doc1:v1:v1'] as CollaborativeDoc[]) - }) - it('unchains multiple collaborative docs', async () => { - expect(collaborativeDocUnchain('doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc)).toEqual([ - 'doc1:v1:v1', - 'doc2:v2:v2' - ] as CollaborativeDoc[]) - }) - }) - - describe('collaborativeDocParse', () => { - it('parses collaborative doc id', async () => { - expect(collaborativeDocParse('documentId' as CollaborativeDoc)).toEqual({ - documentId: 'documentId', - versionId: 'HEAD', - lastVersionId: 'HEAD', - source: [] - }) - }) - it('parses collaborative doc id with versionId', async () => { - expect(collaborativeDocParse('documentId:main' as CollaborativeDoc)).toEqual({ - documentId: 'documentId', - versionId: 'main', - lastVersionId: 'main', - source: [] - }) - }) - it('parses collaborative doc id with versionId and lastVersionId', async () => { - expect(collaborativeDocParse('documentId:HEAD:0' as CollaborativeDoc)).toEqual({ - documentId: 'documentId', - versionId: 'HEAD', - lastVersionId: '0', - source: [] - }) - }) - it('parses collaborative doc id with versionId, lastVersionId, and source', async () => { - expect(collaborativeDocParse('documentId:HEAD:0#documentId1:main#documentId2:HEAD' as CollaborativeDoc)).toEqual({ - documentId: 'documentId', - versionId: 'HEAD', - lastVersionId: '0', - source: ['documentId1:main' as CollaborativeDoc, 'documentId2:HEAD' as CollaborativeDoc] - }) - }) - }) - - describe('collaborativeDocFormat', () => { - it('formats collaborative doc id', async () => { - expect( - collaborativeDocFormat({ - documentId: 'documentId', - versionId: 'HEAD', - lastVersionId: '0' - }) - ).toEqual('documentId:HEAD:0') - }) - it('formats collaborative doc id with sources', async () => { - expect( - collaborativeDocFormat({ - documentId: 'documentId', - versionId: 'HEAD', - lastVersionId: '0', - source: ['documentId1:main' as CollaborativeDoc, 'documentId2:HEAD' as CollaborativeDoc] - }) - ).toEqual('documentId:HEAD:0#documentId1:main#documentId2:HEAD') - }) - it('formats collaborative doc id with invalid characters', async () => { - expect( - collaborativeDocFormat({ - documentId: 'doc:id', - versionId: 'version#id', - lastVersionId: 'last:version#id' - }) - ).toEqual('doc%id:version%id:last%version%id') - }) - }) - - describe('collaborativeDocWithVersion', () => { - it('updates collaborative doc version id', async () => { - expect(collaborativeDocWithVersion('doc1:HEAD:HEAD' as CollaborativeDoc, 'v1')).toEqual('doc1:v1:v1') - expect(collaborativeDocWithVersion('doc1:HEAD:v1' as CollaborativeDoc, 'v2')).toEqual('doc1:v2:v2') - expect(collaborativeDocWithVersion('doc1:HEAD:v1#doc2:v1:v1' as CollaborativeDoc, 'v2')).toEqual( - 'doc1:v2:v2#doc2:v1:v1' - ) - }) - }) - - describe('collaborativeDocWithLastVersion', () => { - it('updates collaborative doc version id', async () => { - expect(collaborativeDocWithLastVersion('doc1:HEAD:HEAD' as CollaborativeDoc, 'v1')).toEqual('doc1:HEAD:v1') - expect(collaborativeDocWithLastVersion('doc1:HEAD:v1' as CollaborativeDoc, 'v2')).toEqual('doc1:HEAD:v2') - expect(collaborativeDocWithLastVersion('doc1:HEAD:v1#doc2:v1:v1' as CollaborativeDoc, 'v2')).toEqual( - 'doc1:HEAD:v2#doc2:v1:v1' - ) - expect(collaborativeDocWithLastVersion('doc1:v1:v1' as CollaborativeDoc, 'v2')).toEqual( - // cannot update last version for non HEAD - 'doc1:v1:v1' - ) - }) - }) - - describe('collaborativeDocWithSource', () => { - it('updates collaborative doc version id', async () => { - expect( - collaborativeDocWithSource('doc1:HEAD:HEAD' as CollaborativeDoc, 'doc2:v1:v1' as CollaborativeDoc) - ).toEqual('doc1:HEAD:HEAD#doc2:v1:v1' as CollaborativeDoc) - expect(collaborativeDocWithSource('doc1:v1:v1' as CollaborativeDoc, 'doc2:v2:v2' as CollaborativeDoc)).toEqual( - 'doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc - ) - expect( - collaborativeDocWithSource('doc1:v1:v1' as CollaborativeDoc, 'doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc) - ).toEqual('doc1:v1:v1#doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc) - expect( - collaborativeDocWithSource('doc1:v1:v1#doc2:v2:v2' as CollaborativeDoc, 'doc3:v3:v3' as CollaborativeDoc) - ).toEqual('doc1:v1:v1#doc3:v3:v3' as CollaborativeDoc) - }) - }) - - describe('collaborativeDocFromLastVersion', () => { - it('returns valid collaborative doc id', async () => { - expect(collaborativeDocFromLastVersion('doc1:HEAD:HEAD#doc2:main:v2#doc3:main:v3' as CollaborativeDoc)).toEqual( - 'doc1:HEAD:HEAD#doc2:main:v2#doc3:main:v3' - ) - expect(collaborativeDocFromLastVersion('doc1:HEAD:v1#doc2:main:v2#doc3:main:v3' as CollaborativeDoc)).toEqual( - 'doc1:v1:v1#doc2:main:v2#doc3:main:v3' - ) - expect(collaborativeDocFromLastVersion('doc1:v1:v1#doc2:main:v2#doc3:main:v3' as CollaborativeDoc)).toEqual( - 'doc1:v1:v1#doc2:main:v2#doc3:main:v3' - ) - expect(collaborativeDocFromLastVersion('doc1:HEAD:v1' as CollaborativeDoc)).toEqual('doc1:v1:v1') - }) - }) - - describe('collaborativeDocFromCollaborativeDoc', () => { - it('returns valid collaborative doc id', async () => { - expect( - collaborativeDocFromCollaborativeDoc( - 'doc1:HEAD:HEAD' as CollaborativeDoc, - 'doc2:HEAD:v2#doc3:v3:v3' as CollaborativeDoc - ) - ).toEqual('doc1:HEAD:HEAD#doc2:v2:v2#doc3:v3:v3') - - expect( - collaborativeDocFromCollaborativeDoc( - 'doc1:HEAD:HEAD' as CollaborativeDoc, - 'doc2:v2:v2#doc3:v3:v3' as CollaborativeDoc - ) - ).toEqual('doc1:HEAD:HEAD#doc2:v2:v2#doc3:v3:v3') - - expect( - collaborativeDocFromCollaborativeDoc( - 'doc1:HEAD:HEAD' as CollaborativeDoc, - 'doc2:HEAD:HEAD#doc3:v3:v3' as CollaborativeDoc - ) - ).toEqual('doc1:HEAD:HEAD#doc2:HEAD:HEAD#doc3:v3:v3') - }) - }) -}) diff --git a/packages/core/src/classes.ts b/packages/core/src/classes.ts index 739ecebc11..e3f8043059 100644 --- a/packages/core/src/classes.ts +++ b/packages/core/src/classes.ts @@ -15,7 +15,6 @@ // import type { Asset, IntlString, Plugin } from '@hcengineering/platform' -import { CollaborativeDoc } from './collaboration' import type { DocumentQuery } from './storage' /** @@ -55,6 +54,13 @@ export type CollectionSize = T[]['length'] */ export type Rank = string +/** + * @public + * + * Reference to blob containing snapshot of collaborative doc. + */ +export type MarkupBlobRef = Ref + /** * @public */ @@ -76,7 +82,7 @@ export interface Doc extends Obj { export interface Card extends Doc { title: string - description?: CollaborativeDoc | null + description?: MarkupBlobRef | null identifier?: string parent?: Ref | null } diff --git a/packages/core/src/collaboration.ts b/packages/core/src/collaboration.ts index 77177ba966..1042ef561c 100644 --- a/packages/core/src/collaboration.ts +++ b/packages/core/src/collaboration.ts @@ -13,175 +13,41 @@ // limitations under the License. // -import { Doc, Ref } from './classes' - -/** - * Identifier of the collaborative document holding collaborative content. - * - * Format: - * {documentId}:{versionId}:{lastVersionId} - * {documentId}:{versionId} - * - * Where: - * - documentId is an identifier of the document in storage - * - versionId is an identifier of the document version, HEAD for latest editable version - * - lastVersionId is an identifier of the latest available version - * - * The collaborative document may contain one or more such sections chained with # (hash): - * collaborativeDocId#collaborativeDocId#collaborativeDocId#... - * - * When collaborative document does not exist, it will be initialized from the first existing - * document in the list. - * - * @public - * */ -export type CollaborativeDoc = string & { __collaborativeDoc: true } +import type { Class, MarkupBlobRef, Doc, Ref } from './classes' /** @public */ -export type CollaborativeDocVersion = string | typeof CollaborativeDocVersionHead +export interface CollaborativeDoc { + objectClass: Ref> + objectId: Ref + objectAttr: string +} /** @public */ -export const CollaborativeDocVersionHead = 'HEAD' - -/** @public */ -export function makeCollaborativeDoc ( - objectId: Ref, - objectAttr?: string | undefined, - versionId?: string | undefined +export function makeCollabId ( + objectClass: Ref>, + objectId: Ref, + objectAttr: Extract | string ): CollaborativeDoc { - const storageDocumentId = objectAttr !== undefined && objectAttr !== '' ? `${objectId}%${objectAttr}` : `${objectId}` - return collaborativeDocFormat({ - documentId: storageDocumentId, - versionId: CollaborativeDocVersionHead, - lastVersionId: versionId ?? '0' - }) + return { objectClass, objectId, objectAttr } } /** @public */ -export interface CollaborativeDocData { - // Id of the document in object storage - documentId: string - // Id of the document version - // HEAD version represents the editable last document version - // Otherwise, it is a readonly version - versionId: CollaborativeDocVersion - // For HEAD versionId it is the latest available document version - // Otherwise, it is the same value as versionId - lastVersionId: string - - source?: CollaborativeDoc[] -} - -/** - * Merge several collaborative docs into single collaborative doc train. - * - * @public - */ -export function collaborativeDocChain (...docs: CollaborativeDoc[]): CollaborativeDoc { - return docs.join('#') as CollaborativeDoc -} - -/** - * Split collaborative doc train into separate collaborative docs. - * - * @public - */ -export function collaborativeDocUnchain (doc: CollaborativeDoc): CollaborativeDoc[] { - return doc.split('#') as CollaborativeDoc[] +export function makeDocCollabId ( + doc: T, + objectAttr: Extract | string +): CollaborativeDoc { + return makeCollabId(doc._class, doc._id, objectAttr) } /** @public */ -export function collaborativeDocParse (doc: CollaborativeDoc): CollaborativeDocData { - const [first, ...other] = collaborativeDocUnchain(doc) - const [documentId, versionId, lastVersionId] = first.split(':') - return { - documentId, - versionId: versionId ?? CollaborativeDocVersionHead, - lastVersionId: lastVersionId ?? versionId ?? CollaborativeDocVersionHead, - source: other - } +export function makeCollabYdocId (doc: CollaborativeDoc): MarkupBlobRef { + const { objectId, objectAttr } = doc + return `${objectId}%${objectAttr}` as MarkupBlobRef } -const sanitize = (value: string): string => value.replace(/[:#]/g, '%') - /** @public */ -export function collaborativeDocFormat ({ - documentId, - versionId, - lastVersionId, - source -}: CollaborativeDocData): CollaborativeDoc { - const parts = [sanitize(documentId), sanitize(versionId), sanitize(lastVersionId)] - const collaborativeDoc = parts.join(':') as CollaborativeDoc - return collaborativeDocChain(collaborativeDoc, ...(source ?? [])) -} - -/** - * Updates versionId component in the collaborative document. - * Both versionId and lastVersionId will refer to the same collaborative document version. - * - * When versionId is not HEAD, the document will represent a readonly document version (snapshot). - * - * @public - */ -export function collaborativeDocWithVersion (collaborativeDoc: CollaborativeDoc, versionId: string): CollaborativeDoc { - const { documentId, source } = collaborativeDocParse(collaborativeDoc) - return collaborativeDocFormat({ documentId, versionId, lastVersionId: versionId, source }) -} - -/** - * Updates lastVersionId component in the collaborative document. - * - * When document versionId is HEAD, the function is no-op. - * - * @public - */ -export function collaborativeDocWithLastVersion ( - collaborativeDoc: CollaborativeDoc, - lastVersionId: string -): CollaborativeDoc { - const { documentId, versionId, source } = collaborativeDocParse(collaborativeDoc) - return versionId === CollaborativeDocVersionHead - ? collaborativeDocFormat({ documentId, versionId, lastVersionId, source }) - : collaborativeDoc -} - -/** - * Replaces source component in the collaborative document. - * - * @public - */ -export function collaborativeDocWithSource ( - collaborativeDoc: CollaborativeDoc, - source: CollaborativeDoc -): CollaborativeDoc { - const { documentId, versionId, lastVersionId } = collaborativeDocParse(collaborativeDoc) - return collaborativeDocFormat({ documentId, versionId, lastVersionId, source: [source] }) -} - -/** - * Creates collaborative document that refers to the last version from the source collaborative document. - * - * @public - */ -export function collaborativeDocFromLastVersion (collaborativeDoc: CollaborativeDoc): CollaborativeDoc { - const { documentId, lastVersionId, source } = collaborativeDocParse(collaborativeDoc) - return collaborativeDocFormat({ - documentId, - versionId: lastVersionId, - lastVersionId, - source - }) -} - -/** - * Creates collaborative document that refers to the last version from the source collaborative document. - * - * @public - */ -export function collaborativeDocFromCollaborativeDoc ( - collaborativeDoc: CollaborativeDoc, - sourceCollaborativeDoc: CollaborativeDoc -): CollaborativeDoc { - return collaborativeDocWithSource(collaborativeDoc, collaborativeDocFromLastVersion(sourceCollaborativeDoc)) +export function makeCollabJsonId (doc: CollaborativeDoc): MarkupBlobRef { + const timestamp = Date.now() + const { objectId, objectAttr } = doc + return [objectId, objectAttr, timestamp].join('-') as MarkupBlobRef } diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 4ed7db7dff..398c937502 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -23,6 +23,7 @@ import type { Blob, Card, Class, + MarkupBlobRef, Collection, Configuration, ConfigurationElement, @@ -54,7 +55,6 @@ import type { TypedSpace, UserStatus } from './classes' -import { CollaborativeDoc } from './collaboration' import { Status, StatusCategory } from './status' import type { Tx, @@ -120,8 +120,7 @@ export default plugin(coreId, { TypeBoolean: '' as Ref>>, TypeTimestamp: '' as Ref>>, TypeDate: '' as Ref>>, - TypeCollaborativeDoc: '' as Ref>>, - TypeCollaborativeDocVersion: '' as Ref>>, + TypeCollaborativeDoc: '' as Ref>>, RefTo: '' as Ref>>, ArrOf: '' as Ref>>, Enum: '' as Ref>, @@ -185,9 +184,8 @@ export default plugin(coreId, { String: '' as IntlString, Record: '' as IntlString, Markup: '' as IntlString, - Collaborative: '' as IntlString, CollaborativeDoc: '' as IntlString, - CollaborativeDocVersion: '' as IntlString, + MarkupBlobRef: '' as IntlString, Number: '' as IntlString, Boolean: '' as IntlString, Timestamp: '' as IntlString, diff --git a/packages/model/src/dsl.ts b/packages/model/src/dsl.ts index 430aeaea72..5741db1fb9 100644 --- a/packages/model/src/dsl.ts +++ b/packages/model/src/dsl.ts @@ -20,7 +20,7 @@ import core, { Class, Classifier, ClassifierKind, - CollaborativeDoc, + MarkupBlobRef, Data, DateRangeMode, Doc, @@ -500,15 +500,8 @@ export function ArrOf> (type: Type): TypeAr /** * @public */ -export function TypeCollaborativeDoc (): Type { - return { _class: core.class.TypeCollaborativeDoc, label: core.string.CollaborativeDoc } -} - -/** - * @public - */ -export function TypeCollaborativeDocVersion (): Type { - return { _class: core.class.TypeCollaborativeDocVersion, label: core.string.CollaborativeDocVersion } +export function TypeCollaborativeDoc (): Type { + return { _class: core.class.TypeCollaborativeDoc, label: core.string.MarkupBlobRef } } /** diff --git a/packages/presentation/src/collaborator.ts b/packages/presentation/src/collaborator.ts index 712a90d0b0..8b3ad682e2 100644 --- a/packages/presentation/src/collaborator.ts +++ b/packages/presentation/src/collaborator.ts @@ -14,13 +14,12 @@ // import { type CollaboratorClient, getClient as getCollaborator } from '@hcengineering/collaborator-client' -import { type CollaborativeDoc, type Markup, getWorkspaceId } from '@hcengineering/core' +import { type Blob, type CollaborativeDoc, type Markup, type Ref, getWorkspaceId } from '@hcengineering/core' import { getMetadata } from '@hcengineering/platform' import presentation from './plugin' -/** @public */ -export function getCollaboratorClient (): CollaboratorClient { +function getClient (): CollaboratorClient { const workspaceId = getWorkspaceId(getMetadata(presentation.metadata.WorkspaceId) ?? '') const token = getMetadata(presentation.metadata.Token) ?? '' const collaboratorURL = getMetadata(presentation.metadata.CollaboratorUrl) ?? '' @@ -29,19 +28,25 @@ export function getCollaboratorClient (): CollaboratorClient { } /** @public */ -export async function getMarkup (collaborativeDoc: CollaborativeDoc): Promise> { - const client = getCollaboratorClient() - return await client.getContent(collaborativeDoc) +export async function getMarkup (doc: CollaborativeDoc, source: Ref | null | undefined): Promise { + const client = getClient() + return await client.getMarkup(doc, source) } /** @public */ -export async function updateMarkup (collaborativeDoc: CollaborativeDoc, content: Record): Promise { - const client = getCollaboratorClient() - await client.updateContent(collaborativeDoc, content) +export async function createMarkup (doc: CollaborativeDoc, markup: Markup): Promise> { + const client = getClient() + return await client.createMarkup(doc, markup) } /** @public */ -export async function copyDocument (source: CollaborativeDoc, target: CollaborativeDoc): Promise { - const client = getCollaboratorClient() +export async function updateMarkup (doc: CollaborativeDoc, markup: Markup): Promise { + const client = getClient() + await client.updateMarkup(doc, markup) +} + +/** @public */ +export async function copyMarkup (source: CollaborativeDoc, target: CollaborativeDoc): Promise { + const client = getClient() await client.copyContent(source, target) } diff --git a/packages/presentation/src/file.ts b/packages/presentation/src/file.ts index 5d1fa55756..a49b011d4a 100644 --- a/packages/presentation/src/file.ts +++ b/packages/presentation/src/file.ts @@ -173,17 +173,18 @@ export function getFileUrl (file: string, filename?: string): string { /** * @public */ -export async function uploadFile (file: File): Promise> { - const id = generateFileId() - const params = getFileUploadParams(id, file) +export async function uploadFile (file: File, uuid?: Ref): Promise> { + uuid ??= generateFileId() as Ref + + const params = getFileUploadParams(uuid, file) if (params.method === 'signed-url') { - await uploadFileWithSignedUrl(file, id, params.url) + await uploadFileWithSignedUrl(file, uuid, params.url) } else { - await uploadFileWithFormData(file, id, params.url) + await uploadFileWithFormData(file, uuid, params.url) } - return id as Ref + return uuid } /** diff --git a/plugins/bitrix/src/hr.ts b/plugins/bitrix/src/hr.ts index 1ed9d9faa7..f36c7605dc 100644 --- a/plugins/bitrix/src/hr.ts +++ b/plugins/bitrix/src/hr.ts @@ -8,8 +8,7 @@ import core, { SortingOrder, Status, TxOperations, - generateId, - makeCollaborativeDoc + generateId } from '@hcengineering/core' import recruit, { Applicant, Vacancy } from '@hcengineering/recruit' import task, { ProjectType, makeRank } from '@hcengineering/task' @@ -41,7 +40,7 @@ export async function createVacancy ( { name, description: type.shortDescription ?? '', - fullDescription: makeCollaborativeDoc(id, 'fullDescription'), + fullDescription: null, private: false, archived: false, company, diff --git a/plugins/contact-resources/src/components/CreateOrganization.svelte b/plugins/contact-resources/src/components/CreateOrganization.svelte index 36f8e07f88..4818633fb9 100644 --- a/plugins/contact-resources/src/components/CreateOrganization.svelte +++ b/plugins/contact-resources/src/components/CreateOrganization.svelte @@ -20,13 +20,13 @@ AttachedData, fillDefaults, generateId, - makeCollaborativeDoc, + makeCollabId, Ref, TxOperations, WithLookup } from '@hcengineering/core' - import { Card, getClient, InlineAttributeBar, updateMarkup } from '@hcengineering/presentation' - import { EmptyMarkup } from '@hcengineering/text' + import { Card, createMarkup, getClient, InlineAttributeBar } from '@hcengineering/presentation' + import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { Button, createFocusManager, EditBox, FocusHandler, IconAttachment, IconInfo, Label } from '@hcengineering/ui' import { createEventDispatcher } from 'svelte' @@ -46,7 +46,6 @@ const object: Organization = { name: '', - description: makeCollaborativeDoc(id, 'description'), attachments: 0 } as unknown as Organization @@ -59,8 +58,11 @@ fillDefaults(hierarchy, object, contact.class.Organization) async function createOrganization (): Promise { + if (!isEmptyMarkup(description)) { + const target = makeCollabId(contact.class.Organization, id, 'description') + object.description = await createMarkup(target, description) + } const op = client.apply() - await updateMarkup(object.description, { description }) await op.createDoc(contact.class.Organization, contact.space.Contacts, object, id) await descriptionBox.createAttachments(id, op) diff --git a/plugins/contact/src/index.ts b/plugins/contact/src/index.ts index bb6d5f077b..b19b6ff83d 100644 --- a/plugins/contact/src/index.ts +++ b/plugins/contact/src/index.ts @@ -18,13 +18,13 @@ import { Account, AttachedDoc, Class, - CollaborativeDoc, Doc, Ref, Space, Timestamp, UXObject, type Blob, + type MarkupBlobRef, type Data, type WithLookup } from '@hcengineering/core' @@ -137,7 +137,7 @@ export interface Member extends AttachedDoc { */ export interface Organization extends Contact { members: number - description: CollaborativeDoc + description: MarkupBlobRef | null } /** diff --git a/plugins/controlled-documents-resources/src/components/CreateDocument.svelte b/plugins/controlled-documents-resources/src/components/CreateDocument.svelte index 310275ad53..7b60de7287 100644 --- a/plugins/controlled-documents-resources/src/components/CreateDocument.svelte +++ b/plugins/controlled-documents-resources/src/components/CreateDocument.svelte @@ -17,17 +17,8 @@ -{#if $controlledDocument !== null && collaborativeDoc !== undefined} +{#if $controlledDocument !== null && attribute !== undefined} - + {#if $editorMode === 'comparing'} {:else} diff --git a/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte b/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte index eebd20c597..ae43692bd9 100644 --- a/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte +++ b/plugins/controlled-documents-resources/src/components/create-doc/QmsDocumentWizard.svelte @@ -18,13 +18,12 @@ import { generateId, getCurrentAccount, - makeCollaborativeDoc, type AttachedData, type Class, type Data, type Ref } from '@hcengineering/core' - import { MessageBox, copyDocument, getClient } from '@hcengineering/presentation' + import { MessageBox, getClient } from '@hcengineering/presentation' import { AnySvelteComponent, addNotification, @@ -43,10 +42,10 @@ type DocumentTemplate, DocumentState, createChangeControl, - createControlledDocFromTemplate, DEFAULT_PERIODIC_REVIEW_INTERVAL } from '@hcengineering/controlled-documents' + import { createControlledDocFromTemplate } from '../../docutils' import documents from '../../plugin' import { getProjectDocumentLink } from '../../navigation' import InfoStep from './steps/InfoStep.svelte' @@ -122,7 +121,7 @@ state: DocumentState.Draft, snapshots: 0, changeControl: ccRecordId, - content: makeCollaborativeDoc(generateId()), + content: null, requests: 0, reviewers: [], @@ -161,8 +160,7 @@ _space, $locationStep.project, $locationStep.parent, - documents.class.ControlledDocument, - copyDocument + documents.class.ControlledDocument ) if (!success) { diff --git a/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte b/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte index 32b89c7aa8..1c7a2d6560 100644 --- a/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte +++ b/plugins/controlled-documents-resources/src/components/create-doc/QmsTemplateWizard.svelte @@ -32,8 +32,7 @@ type Ref, type Mixin, generateId, - getCurrentAccount, - makeCollaborativeDoc + getCurrentAccount } from '@hcengineering/core' import { MessageBox, getClient } from '@hcengineering/presentation' import { @@ -118,7 +117,7 @@ state: DocumentState.Draft, snapshots: 0, changeControl: ccRecordId, - content: makeCollaborativeDoc(generateId()), + content: null, requests: 0, reviewers: [], diff --git a/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte b/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte index 802da8264e..af125cfc9f 100644 --- a/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte +++ b/plugins/controlled-documents-resources/src/components/document/DocumentDiffViewer.svelte @@ -1,7 +1,7 @@ -{#if $controlledDocument && collaborativeDoc} +{#if $controlledDocument && attribute} {#if headings.length > 0} @@ -270,14 +264,10 @@ | undefined, + documentId: Ref, + spec: AttachedData, + space: Ref, + project: Ref | undefined, + parent: Ref | undefined, + docClass: Ref> = documents.class.ControlledDocument +): Promise<{ seqNumber: number, success: boolean }> { + const result = await controlledDocFromTemplate(client, templateId, documentId, spec, space, project, parent, docClass) + + if (result.success && templateId !== undefined) { + const source = makeCollabId(documents.mixin.DocumentTemplate, templateId, 'content') + const target = makeCollabId(docClass, documentId, 'content') + try { + await copyMarkup(source, target) + } catch (err) { + await setPlatformStatus(unknownError(err)) + return { ...result, success: false } + } + } + + return result +} + export async function createNewDraftForControlledDoc ( client: TxOperations, document: ControlledDocument, @@ -65,18 +94,6 @@ export async function createNewDraftForControlledDoc ( ops.notMatch(documents.class.Document, notMatchQuery) - const collaborativeDoc = getCollaborativeDocForDocument( - `DOC-${document.prefix}`, - document.seqNumber, - document.major, - document.minor, - true - ) - - if (document.content !== undefined) { - await copyDocument(document.content, collaborativeDoc) - } - // Create new change control for new version const newCCId = generateId() const newCCSpec: Data = { @@ -111,7 +128,7 @@ export async function createNewDraftForControlledDoc ( labels: 0, state: DocumentState.Draft, plannedEffectiveDate: 0, - content: collaborativeDoc + content: document.content } const meta = await client.findOne(documents.class.ProjectMeta, { @@ -170,16 +187,6 @@ export async function createNewDraftForControlledDoc ( } export async function createDocumentSnapshotAndEdit (client: TxOperations, document: ControlledDocument): Promise { - const collaborativeDoc = getCollaborativeDocForDocument( - `DOC-${document.prefix}`, - document.seqNumber, - document.major, - document.minor, - true - ) - - await copyDocument(document.content, collaborativeDoc) - const language = get(themeStore).language const namePrefix = await translate(documents.string.DraftRevision, {}, language) const name = `${namePrefix} ${(document.snapshots ?? 0) + 1}` @@ -197,7 +204,7 @@ export async function createDocumentSnapshotAndEdit (client: TxOperations, docum name, state: document.state, controlledState: document.controlledState, - content: collaborativeDoc + content: document.content }, newSnapshotId ) @@ -205,6 +212,10 @@ export async function createDocumentSnapshotAndEdit (client: TxOperations, docum await op.commit() await client.update(document, { controlledState: undefined }) + + const source = makeDocCollabId(document, 'content') + const target = makeCollabId(documents.class.ControlledDocumentSnapshot, newSnapshotId, 'content') + await copyMarkup(source, target) } export function getDocumentTrainingClass (hierarchy: Hierarchy): Class { diff --git a/plugins/controlled-documents/src/docutils.ts b/plugins/controlled-documents/src/docutils.ts index 29e2815475..1e7afaad44 100644 --- a/plugins/controlled-documents/src/docutils.ts +++ b/plugins/controlled-documents/src/docutils.ts @@ -14,17 +14,7 @@ // import { type Employee } from '@hcengineering/contact' -import { - type AttachedData, - type Class, - type CollaborativeDoc, - type Doc, - type Ref, - type TxOperations, - Mixin, - generateId, - makeCollaborativeDoc -} from '@hcengineering/core' +import { type AttachedData, type Class, type Ref, type TxOperations, Blob, Mixin } from '@hcengineering/core' import { type Document, type DocumentTemplate, @@ -40,7 +30,6 @@ import { import documents from './plugin' import { TEMPLATE_PREFIX } from './utils' -import { setPlatformStatus, unknownError } from '@hcengineering/platform' async function getParentPath (client: TxOperations, parent: Ref): Promise>> { const parentDocObj = await client.findOne(documents.class.ProjectDocument, { @@ -72,8 +61,7 @@ export async function createControlledDocFromTemplate ( space: Ref, project: Ref | undefined, parent: Ref | undefined, - docClass: Ref> = documents.class.ControlledDocument, - copyContent: (source: CollaborativeDoc, target: CollaborativeDoc) => Promise = async () => {} + docClass: Ref> = documents.class.ControlledDocument ): Promise<{ seqNumber: number, success: boolean }> { if (templateId == null) { return { seqNumber: -1, success: false } @@ -101,8 +89,6 @@ export async function createControlledDocFromTemplate ( const seqNumber = template.sequence + 1 const prefix = template.docPrefix - const _copyContent = (doc: CollaborativeDoc): Promise => copyContent(template.content, doc) - return await createControlledDoc( client, templateId, @@ -114,7 +100,7 @@ export async function createControlledDocFromTemplate ( seqNumber, path, docClass, - _copyContent + template.content ) } @@ -129,12 +115,10 @@ async function createControlledDoc ( seqNumber: number, path: Ref[] = [], docClass: Ref> = documents.class.ControlledDocument, - copyContent: (doc: CollaborativeDoc) => Promise + content: Ref | null ): Promise<{ seqNumber: number, success: boolean }> { const projectId = project ?? documents.ids.NoProject - const collaborativeDoc = getCollaborativeDocForDocument(`DOC-${prefix}`, seqNumber, 0, 1) - const ops = client.apply() ops.notMatch(documents.class.Document, { @@ -184,22 +168,12 @@ async function createControlledDoc ( seqNumber, prefix, state: DocumentState.Draft, - content: collaborativeDoc + content }, documentId ) const success = await ops.commit() - - if (success.result) { - try { - await copyContent(collaborativeDoc) - } catch (err) { - await setPlatformStatus(unknownError(err)) - return { seqNumber, success: false } - } - } - return { seqNumber, success: success.result } } @@ -228,7 +202,6 @@ export async function createDocumentTemplate ( true ) const seqNumber = (incResult as any).object.sequence as number - const collaborativeDocId = getCollaborativeDocForDocument('TPL-DOC', seqNumber, 0, 1) const code = spec.code === '' ? `${TEMPLATE_PREFIX}-${seqNumber}` : spec.code let path: Array> = [] @@ -292,7 +265,7 @@ export async function createDocumentTemplate ( prefix: TEMPLATE_PREFIX, author, owner: author, - content: collaborativeDocId + content: null }, templateId ) @@ -306,19 +279,3 @@ export async function createDocumentTemplate ( return { seqNumber, success: success.result } } - -export function getCollaborativeDocForDocument ( - prefix: string, - seqNumber: number, - major: number, - minor: number, - next: boolean = false -): CollaborativeDoc { - if (prefix.endsWith('-')) { - prefix = prefix.substring(0, prefix.length - 1) - } - - return makeCollaborativeDoc( - (`${prefix}-${seqNumber}-${major}.${minor}${next ? '.next' : ''}-` + generateId()) as Ref - ) -} diff --git a/plugins/controlled-documents/src/types.ts b/plugins/controlled-documents/src/types.ts index 31119266a6..4ec4a756ac 100644 --- a/plugins/controlled-documents/src/types.ts +++ b/plugins/controlled-documents/src/types.ts @@ -5,10 +5,10 @@ import { Attachment } from '@hcengineering/attachment' import { ChatMessage } from '@hcengineering/chunter' import { Employee } from '@hcengineering/contact' import { - CollaborativeDoc, type AttachedDoc, type Class, type CollectionSize, + type MarkupBlobRef, type Doc, type Markup, type Ref, @@ -122,7 +122,7 @@ export interface Document extends Doc { author?: Ref // Employee who created/released the document owner?: Ref // Employee responsible for working on the document state: DocumentState - content: CollaborativeDoc + content: MarkupBlobRef | null labels?: CollectionSize // A collection of attached tags(labels) abstract?: string commentSequence: number // Used to enumerate the comments across revisions of the working copy of the document @@ -138,7 +138,7 @@ export interface Document extends Doc { */ export interface DocumentSnapshot extends AttachedDoc { name?: string - content: CollaborativeDoc + content: MarkupBlobRef | null state?: DocumentState } diff --git a/plugins/document-resources/src/components/DocumentEditor.svelte b/plugins/document-resources/src/components/DocumentEditor.svelte index afc26cc971..547023115e 100644 --- a/plugins/document-resources/src/components/DocumentEditor.svelte +++ b/plugins/document-resources/src/components/DocumentEditor.svelte @@ -16,8 +16,9 @@ --> = { title, - content: makeCollaborativeDoc(id, 'content'), + content: null, attachments: 0, embeddings: 0, labels: 0, diff --git a/plugins/document/src/types.ts b/plugins/document/src/types.ts index 8b650bcc49..767afdbb45 100644 --- a/plugins/document/src/types.ts +++ b/plugins/document/src/types.ts @@ -14,7 +14,7 @@ // import { Attachment } from '@hcengineering/attachment' -import { Account, Class, CollaborativeDoc, Doc, Rank, Ref, TypedSpace } from '@hcengineering/core' +import { Account, Class, MarkupBlobRef, Doc, Rank, Ref, TypedSpace } from '@hcengineering/core' import { Preference } from '@hcengineering/preference' import { IconProps } from '@hcengineering/view' @@ -24,7 +24,7 @@ export interface Teamspace extends TypedSpace, IconProps {} /** @public */ export interface Document extends Doc, IconProps { title: string - content: CollaborativeDoc + content: MarkupBlobRef | null parent: Ref space: Ref @@ -43,7 +43,7 @@ export interface Document extends Doc, IconProps { /** @public */ export interface DocumentSnapshot extends Doc { title: string - content: CollaborativeDoc + content: MarkupBlobRef parent: Ref } diff --git a/plugins/lead-resources/src/components/CreateCustomer.svelte b/plugins/lead-resources/src/components/CreateCustomer.svelte index b218fc30be..f2ef95ccdd 100644 --- a/plugins/lead-resources/src/components/CreateCustomer.svelte +++ b/plugins/lead-resources/src/components/CreateCustomer.svelte @@ -25,12 +25,12 @@ Ref, WithLookup, generateId, - makeCollaborativeDoc + makeCollabId } from '@hcengineering/core' import { Customer, LeadEvents } from '@hcengineering/lead' - import { Card, getClient, InlineAttributeBar, updateMarkup } from '@hcengineering/presentation' + import { Card, createMarkup, getClient, InlineAttributeBar } from '@hcengineering/presentation' import { StyledTextBox } from '@hcengineering/text-editor-resources' - import { EmptyMarkup } from '@hcengineering/text' + import { EmptyMarkup, isEmptyMarkup } from '@hcengineering/text' import { Button, createFocusManager, @@ -85,14 +85,17 @@ } if (client.getHierarchy().isDerived(targetClass._id, contact.class.Organization)) { - ;(candidate as Organization).description = makeCollaborativeDoc(customerId, 'description') + ;(candidate as Organization).description = null } const candidateData: MixinData = { - customerDescription: makeCollaborativeDoc(customerId, 'customerDescription') + customerDescription: null } - await updateMarkup(candidateData.customerDescription, { customerDescription: description }) + if (!isEmptyMarkup(description)) { + const collabId = makeCollabId(lead.mixin.Customer, customerId, 'customerDescription') + candidateData.customerDescription = await createMarkup(collabId, description) + } const id = await client.createDoc(targetClass._id, contact.space.Contacts, { ...candidate, ...object }, customerId) await client.createMixin( diff --git a/plugins/lead/src/index.ts b/plugins/lead/src/index.ts index d9fc1f0fee..f30be8eb82 100644 --- a/plugins/lead/src/index.ts +++ b/plugins/lead/src/index.ts @@ -15,7 +15,7 @@ // import type { Contact } from '@hcengineering/contact' -import type { Attribute, Class, CollaborativeDoc, Doc, Markup, Ref, Status, Timestamp } from '@hcengineering/core' +import type { Attribute, Class, MarkupBlobRef, Doc, Markup, Ref, Status, Timestamp } from '@hcengineering/core' import { Mixin } from '@hcengineering/core' import type { Asset, IntlString, Plugin } from '@hcengineering/platform' import { plugin } from '@hcengineering/platform' @@ -36,7 +36,7 @@ export interface Funnel extends Project { export interface Customer extends Contact { leads?: number - customerDescription: CollaborativeDoc + customerDescription: MarkupBlobRef | null } /** diff --git a/plugins/love-resources/src/components/AddRoomPopup.svelte b/plugins/love-resources/src/components/AddRoomPopup.svelte index 4d61e0b1fd..719eacc0c3 100644 --- a/plugins/love-resources/src/components/AddRoomPopup.svelte +++ b/plugins/love-resources/src/components/AddRoomPopup.svelte @@ -1,5 +1,5 @@ -{#key _documentId} +{#key documentId} {/key} diff --git a/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte b/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte index 18cd027ba9..3742b98e3a 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeAttributeBox.svelte @@ -15,7 +15,7 @@ -{#if collaborativeDoc != null} - -{/if} + diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index aaa4a66110..dfce9ff788 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -16,9 +16,16 @@ --> {#if issue} @@ -92,11 +96,11 @@

- {#if issue.description} + {#if descriptionKey}
limit} style:max-height={`${limit}px`}>
(cHeight = element.clientHeight)}> {#key issue._id} - + {/key}
diff --git a/plugins/tracker/src/index.ts b/plugins/tracker/src/index.ts index 3ba06ba655..bec155e700 100644 --- a/plugins/tracker/src/index.ts +++ b/plugins/tracker/src/index.ts @@ -18,7 +18,7 @@ import { AttachedDoc, Attribute, Class, - CollaborativeDoc, + MarkupBlobRef, CollectionSize, Data, Doc, @@ -183,7 +183,7 @@ export interface Milestone extends Doc { export interface Issue extends Task { attachedTo: Ref title: string - description: CollaborativeDoc + description: MarkupBlobRef | null status: Ref priority: IssuePriority diff --git a/server-plugins/activity-resources/src/references.ts b/server-plugins/activity-resources/src/references.ts index 3c4d12fa45..f97426cb8a 100644 --- a/server-plugins/activity-resources/src/references.ts +++ b/server-plugins/activity-resources/src/references.ts @@ -14,12 +14,12 @@ // import activity, { ActivityMessage, ActivityReference, UserMentionInfo } from '@hcengineering/activity' -import { loadCollaborativeDoc, yDocToBuffer } from '@hcengineering/collaboration' +import { loadCollabJson } from '@hcengineering/collaboration' import contact, { Employee, Person, PersonAccount } from '@hcengineering/contact' import core, { Account, + Blob, Class, - CollaborativeDoc, Data, Doc, generateId, @@ -416,21 +416,17 @@ async function getCreateReferencesTxes ( refs.push(...attrReferences) } else if (attr.type._class === core.class.TypeCollaborativeDoc) { - const collaborativeDoc = (createdDoc as any)[attr.name] as CollaborativeDoc - try { - const ydoc = await loadCollaborativeDoc(ctx, storage, control.workspace, collaborativeDoc) - if (ydoc !== undefined) { - const attrReferences = getReferencesData( - srcDocId, - srcDocClass, - attachedDocId, - attachedDocClass, - yDocToBuffer(ydoc) - ) - refs.push(...attrReferences) + const blobId = (createdDoc as any)[attr.name] as Ref + if (blobId != null) { + try { + const markup = await loadCollabJson(ctx, storage, control.workspace, blobId) + if (markup !== undefined) { + const attrReferences = getReferencesData(srcDocId, srcDocClass, attachedDocId, attachedDocClass, markup) + refs.push(...attrReferences) + } + } catch { + // do nothing, the collaborative doc does not sem to exist yet } - } catch { - // do nothing, the collaborative doc does not sem to exist yet } } } @@ -469,17 +465,13 @@ async function getUpdateReferencesTxes ( } else if (attr.type._class === core.class.TypeCollaborativeDoc) { hasReferenceAttrs = true try { - const collaborativeDoc = (updatedDoc as any)[attr.name] as CollaborativeDoc - const ydoc = await loadCollaborativeDoc(ctx, storage, control.workspace, collaborativeDoc) - if (ydoc !== undefined) { - const attrReferences = getReferencesData( - srcDocId, - srcDocClass, - attachedDocId, - attachedDocClass, - yDocToBuffer(ydoc) - ) - references.push(...attrReferences) + const blobId = (updatedDoc as any)[attr.name] as Ref + if (blobId != null) { + const markup = await loadCollabJson(ctx, storage, control.workspace, blobId) + if (markup !== undefined) { + const attrReferences = getReferencesData(srcDocId, srcDocClass, attachedDocId, attachedDocClass, markup) + references.push(...attrReferences) + } } } catch { // do nothing, the collaborative doc does not sem to exist yet diff --git a/server-plugins/collaboration-resources/src/index.ts b/server-plugins/collaboration-resources/src/index.ts index bcf19cbfdd..18cc1c7ce0 100644 --- a/server-plugins/collaboration-resources/src/index.ts +++ b/server-plugins/collaboration-resources/src/index.ts @@ -13,9 +13,9 @@ // limitations under the License. // -import { removeCollaborativeDoc } from '@hcengineering/collaboration' import type { CollaborativeDoc, Doc, Tx, TxRemoveDoc } from '@hcengineering/core' -import core from '@hcengineering/core' +import core, { makeCollabId } from '@hcengineering/core' +import { removeCollabYdoc } from '@hcengineering/collaboration' import { type TriggerControl } from '@hcengineering/server-core' /** @@ -39,17 +39,16 @@ export async function OnDelete ( const attributes = hierarchy.getAllAttributes(rmTx.objectClass) for (const attribute of attributes.values()) { if (hierarchy.isDerived(attribute.type._class, core.class.TypeCollaborativeDoc)) { - const value = (doc as any)[attribute.name] as CollaborativeDoc - if (value !== undefined) { - toDelete.push(value) - } + toDelete.push(makeCollabId(doc._class, doc._id, attribute.name)) } } // TODO This is not accurate way to delete collaborative document // Even though we are deleting it here, the document can be currently in use by someone else // and when editing session ends, the collborator service will recreate the document again - await removeCollaborativeDoc(storageAdapter, workspace, toDelete, ctx) + if (toDelete.length > 0) { + await removeCollabYdoc(storageAdapter, workspace, toDelete, ctx) + } } return [] } diff --git a/server/collaboration/package.json b/server/collaboration/package.json index e2a6c20abf..b591ce67a3 100644 --- a/server/collaboration/package.json +++ b/server/collaboration/package.json @@ -40,6 +40,7 @@ "dependencies": { "@hcengineering/core": "^0.6.32", "@hcengineering/server-core": "^0.6.1", + "@hcengineering/text": "^0.6.5", "base64-js": "^1.5.1", "yjs": "^13.6.19" } diff --git a/server/collaboration/src/history/__tests__/branch.test.ts b/server/collaboration/src/history/__tests__/branch.test.ts deleted file mode 100644 index ee92d9bb1a..0000000000 --- a/server/collaboration/src/history/__tests__/branch.test.ts +++ /dev/null @@ -1,133 +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. -// - -import { Doc as YDoc, encodeStateAsUpdate, encodeStateVector } from 'yjs' - -import { yDocBranch, yDocBranchWithGC } from '../branch' -import { generateId } from '@hcengineering/core' - -describe('branch', () => { - describe('yDocBranch', () => { - it('branches document without gc', async () => { - const source = new YDoc({ guid: generateId(), gc: false }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranch(source) - - expect(target.gc).toBeFalsy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates the same as source regardless of gc - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).toEqual(sourceUpdate) - }) - - it('branches document state with gc', async () => { - const source = new YDoc({ guid: generateId(), gc: true }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranch(source) - - expect(target.gc).toBeTruthy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates the same as source regardless of gc - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).toEqual(sourceUpdate) - }) - }) - - describe('yDocBranchWithGC', () => { - it('branches document state without gc', async () => { - const source = new YDoc({ guid: generateId(), gc: false }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranchWithGC(source) - - expect(target.gc).toBeFalsy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates different because source is not gc-ed - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).not.toEqual(sourceUpdate) - }) - - it('branches document state with gc', async () => { - const source = new YDoc({ guid: generateId(), gc: true }) - - applyGarbageCollectableChanges(source) - - const target = yDocBranchWithGC(source) - - expect(target.gc).toBeTruthy() - - // ensure target data - const sourceData = source.getArray('data') - const targetData = target.getArray('data') - expect(targetData.toArray()).toEqual(expect.arrayContaining(sourceData.toArray())) - - // ensure target state - const sourceState = encodeStateVector(source) - const targetState = encodeStateVector(target) - expect(targetState).toEqual(sourceState) - - // ensure target updates the same because source is gc-ed - const sourceUpdate = encodeStateAsUpdate(source) - const targetUpdate = encodeStateAsUpdate(target) - expect(targetUpdate).toEqual(sourceUpdate) - }) - }) - - function applyGarbageCollectableChanges (ydoc: YDoc): void { - const sourceData = ydoc.getArray('data') - sourceData.insert(0, ['a']) - sourceData.insert(1, [1, 2]) - sourceData.delete(0, 1) - sourceData.insert(2, [3]) - } -}) diff --git a/server/collaboration/src/history/__tests__/history.test.ts b/server/collaboration/src/history/__tests__/history.test.ts deleted file mode 100644 index 957b048e78..0000000000 --- a/server/collaboration/src/history/__tests__/history.test.ts +++ /dev/null @@ -1,159 +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. -// - -import { Account, Ref, generateId } from '@hcengineering/core' -import { Doc as YDoc, encodeStateAsUpdate } from 'yjs' - -import { YDocVersion, addVersion, deleteVersion, getVersion, getVersionData, listVersions } from '../history' - -const HISTORY = 'history' -const UPDATES = 'updates' - -describe('history', () => { - let ydoc: YDoc - - beforeEach(() => { - ydoc = new YDoc({ guid: generateId() }) - }) - - it('addVersion should append new version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, version, update) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(1) - expect(updates.size).toEqual(1) - - expect(history.get(0)).toEqual(version) - expect(updates.get(versionId)).toBeDefined() - }) - - it('addVersion should raise an error when a version already exists', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, version, update) - expect(() => { - addVersion(ydoc, version, update) - }).toThrow() - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(1) - expect(updates.size).toEqual(1) - - expect(history.get(0)).toEqual(version) - expect(updates.get(versionId)).toBeDefined() - }) - - it('getVersion should get existing version data', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - addVersion(ydoc, version, update) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(3) - expect(updates.size).toEqual(3) - - expect(getVersion(ydoc, versionId)).toEqual(version) - }) - - it('getVersion should return undefined for unknown version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - addVersion(ydoc, version, encodeStateAsUpdate(ydoc)) - - expect(getVersion(ydoc, generateId())).toBeUndefined() - }) - - it('listVersions should return existing versions', async () => { - const version1 = yDocVersion(generateId()) - const version2 = yDocVersion(generateId()) - - addVersion(ydoc, version1, encodeStateAsUpdate(ydoc)) - addVersion(ydoc, version2, encodeStateAsUpdate(ydoc)) - - expect(listVersions(ydoc)).toEqual(expect.arrayContaining([version1, version2])) - }) - - it('listVersions should return empty list when no versions', async () => { - expect(listVersions(ydoc)).toEqual([]) - }) - - it('getVersionData should get existing version data', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - const update = encodeStateAsUpdate(ydoc) - - addVersion(ydoc, version, update) - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - addVersion(ydoc, yDocVersion(generateId()), encodeStateAsUpdate(ydoc)) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(3) - expect(updates.size).toEqual(3) - - expect(getVersionData(ydoc, versionId)).toEqual(update) - }) - - it('getVersionData should return undefined for unknown version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - addVersion(ydoc, version, encodeStateAsUpdate(ydoc)) - - expect(getVersionData(ydoc, generateId())).toBeUndefined() - }) - - it('deleteVersion should delete existing version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - addVersion(ydoc, version, encodeStateAsUpdate(ydoc)) - - deleteVersion(ydoc, versionId) - - const history = ydoc.getArray(HISTORY) - const updates = ydoc.getMap(UPDATES) - - expect(history.length).toEqual(0) - expect(updates.size).toEqual(0) - - expect(getVersion(ydoc, versionId)).toEqual(undefined) - expect(getVersionData(ydoc, versionId)).toEqual(undefined) - }) -}) - -function yDocVersion (versionId: string): YDocVersion { - return { - versionId, - name: versionId, - createdBy: 'unit test' as Ref, - createdOn: Date.now() - } -} diff --git a/server/collaboration/src/history/__tests__/snapshot.test.ts b/server/collaboration/src/history/__tests__/snapshot.test.ts deleted file mode 100644 index 6c36917352..0000000000 --- a/server/collaboration/src/history/__tests__/snapshot.test.ts +++ /dev/null @@ -1,109 +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. -// - -import { Account, Ref, generateId } from '@hcengineering/core' -import { Doc as YDoc } from 'yjs' - -import { YDocVersion } from '../history' -import { createYdocSnapshot, restoreYdocSnapshot } from '../snapshot' - -const HISTORY = 'history' -const UPDATES = 'updates' - -describe('snapshot', () => { - let yContent: YDoc - let yHistory: YDoc - - beforeEach(() => { - yContent = new YDoc({ guid: generateId(), gc: false }) - yHistory = new YDoc({ guid: generateId() }) - }) - - it('createYdocSnapshot appends new version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - createYdocSnapshot(yContent, yHistory, version) - - const history = yHistory.getArray(HISTORY) - const updates = yHistory.getMap(UPDATES) - - expect(history.length).toEqual(1) - expect(updates.size).toEqual(1) - - expect(history.get(0)).toEqual(version) - expect(updates.get(versionId)).toBeDefined() - }) - - it('restoreYdocSnapshot restores existing version', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - const data = yContent.getArray('data') - data.insert(0, [1, 2, 3]) - expect(data.toArray()).toEqual(expect.arrayContaining([1, 2, 3])) - - createYdocSnapshot(yContent, yHistory, version) - - data.delete(1, 1) - expect(data.toArray()).toEqual(expect.arrayContaining([1, 3])) - - const yRestore = restoreYdocSnapshot(yContent, yHistory, versionId) - - // assert the restored doc has not been changed - expect(yRestore).toBeDefined() - expect(yRestore?.getArray('data').toArray()).toEqual(expect.arrayContaining([1, 2, 3])) - - // assert the original doc has not been changed - expect(yContent.getArray('data').toArray()).toEqual(expect.arrayContaining([1, 3])) - }) - - it('restoreYdocSnapshot throws an error when gc is enabled', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - yContent = new YDoc({ guid: generateId(), gc: true }) - createYdocSnapshot(yContent, yHistory, version) - expect(() => restoreYdocSnapshot(yContent, yHistory, versionId)).toThrow() - }) - - it('restoreYdocSnapshot does not restore version that does not exist', async () => { - const versionId = generateId() - - const yRestore = restoreYdocSnapshot(yContent, yHistory, versionId) - expect(yRestore).toBeUndefined() - }) - - it('restoreYdocSnapshot restored document has gc enabled', async () => { - const versionId = generateId() - const version = yDocVersion(versionId) - - createYdocSnapshot(yContent, yHistory, version) - const yRestore = restoreYdocSnapshot(yContent, yHistory, versionId) - - // so far we don't care whether gc is enabled or not in the restore - // but we need to ensure we understand that it is enabled - expect(yRestore?.gc).toEqual(true) - }) -}) - -function yDocVersion (versionId: string): YDocVersion { - return { - versionId, - name: versionId, - createdBy: 'unit test' as Ref, - createdOn: Date.now() - } -} diff --git a/server/collaboration/src/history/branch.ts b/server/collaboration/src/history/branch.ts deleted file mode 100644 index 275cf4a31d..0000000000 --- a/server/collaboration/src/history/branch.ts +++ /dev/null @@ -1,54 +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. -// - -import { generateId } from '@hcengineering/core' -import { Doc as YDoc, applyUpdate, encodeStateAsUpdate } from 'yjs' - -/** - * Branch (copy) document content as is. - * - * If the source document has gc parameter enabled, then garbage - * collection will be performed. The result document will have the same - * gc parameter value as the source document. - * - * @public - * */ -export function yDocBranch (source: YDoc): YDoc { - const target = new YDoc({ guid: generateId(), gc: source.gc }) - - const update = encodeStateAsUpdate(source) - applyUpdate(target, update) - - return target -} - -/** - * Branch (copy) document content with garbage collecting while applying update. - * - * Garbage collection will be performed regardless of the gc parameter - * in the source document. The result document will have the same gc - * parameter value as the source document. - * - * @public - * */ -export function yDocBranchWithGC (source: YDoc): YDoc { - const target = new YDoc({ guid: generateId(), gc: source.gc }) - - const gc = new YDoc({ guid: generateId(), gc: true }) - applyUpdate(gc, encodeStateAsUpdate(source)) - applyUpdate(target, encodeStateAsUpdate(gc)) - - return target -} diff --git a/server/collaboration/src/history/history.ts b/server/collaboration/src/history/history.ts deleted file mode 100644 index 7feaebb3ed..0000000000 --- a/server/collaboration/src/history/history.ts +++ /dev/null @@ -1,110 +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. -// - -import { Account, Ref, Timestamp } from '@hcengineering/core' -import { fromByteArray, toByteArray } from 'base64-js' -import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs' - -/** - * This module provides utils for document version storage based on YDoc - * - * At the top level the history document contains two fields: - * 1. history - * An array containing version ids in creation order - * 2. updates - * A map containing version data keyed by version id - * - * { - * "history": [ - * { "versionId": "version1", ... }, - * { "versionId": "version2", ... }, - * ... - * ], - * "updates": { - * "version1": ... version data as ydoc update base64 encoded ..., - * "version2": ... version data as ydoc update base64 encoded ..., - * ... - * } - * } - */ - -/** @public */ -export interface YDocVersion { - versionId: string - name: string - - createdBy: Ref - createdOn: Timestamp -} - -const HISTORY = 'history' -const UPDATES = 'updates' - -function getHistory (ydoc: YDoc): YArray { - return ydoc.getArray(HISTORY) -} - -function getUpdates (ydoc: YDoc): YMap { - return ydoc.getMap(UPDATES) -} - -/** @public */ -export function addVersion (ydoc: YDoc, version: YDocVersion, update: Uint8Array): void { - const history = getHistory(ydoc) - const updates = getUpdates(ydoc) - - const { versionId } = version - - if (updates.has(versionId)) { - throw Error('history item already exists') - } - - ydoc.transact((tr) => { - history.push([version]) - updates.set(versionId, fromByteArray(update)) - }) -} - -/** @public */ -export function getVersion (ydoc: YDoc, versionId: string): YDocVersion | undefined { - const history = getHistory(ydoc) - return history.toArray().find((p) => p.versionId === versionId) -} - -/** @public */ -export function listVersions (ydoc: YDoc): YDocVersion[] { - return getHistory(ydoc).toArray() -} - -/** @public */ -export function getVersionData (ydoc: YDoc, versionId: string): Uint8Array | undefined { - const updates = getUpdates(ydoc) - const update = updates.get(versionId) - return update !== undefined ? toByteArray(update) : undefined -} - -/** @public */ -export function deleteVersion (ydoc: YDoc, versionId: string): void { - const history = getHistory(ydoc) - const updates = getUpdates(ydoc) - - ydoc.transact((tr) => { - const index = history.toArray().findIndex((p) => p.versionId === versionId) - if (index !== -1) { - history.delete(index, 1) - } - updates.delete(versionId) - }) -} diff --git a/server/collaboration/src/history/snapshot.ts b/server/collaboration/src/history/snapshot.ts deleted file mode 100644 index ac0d273af3..0000000000 --- a/server/collaboration/src/history/snapshot.ts +++ /dev/null @@ -1,36 +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. -// - -import { Doc as YDoc } from 'yjs' -import * as Y from 'yjs' - -import { YDocVersion, addVersion, getVersionData } from './history' - -/** @public */ -export function createYdocSnapshot (yContent: YDoc, yHistory: YDoc, version: YDocVersion): void { - const snapshot = Y.snapshot(yContent) - const update = Y.encodeSnapshot(snapshot) - - addVersion(yHistory, version, update) -} - -/** @public */ -export function restoreYdocSnapshot (yContent: YDoc, yHistory: YDoc, versionId: string): YDoc | undefined { - const update = getVersionData(yHistory, versionId) - if (update !== undefined) { - const snapshot = Y.decodeSnapshot(update) - return Y.createDocFromSnapshot(yContent, snapshot) - } -} diff --git a/server/collaboration/src/index.ts b/server/collaboration/src/index.ts index 223512db04..88cac91502 100644 --- a/server/collaboration/src/index.ts +++ b/server/collaboration/src/index.ts @@ -13,9 +13,5 @@ // limitations under the License. // -export * from './history/branch' -export * from './history/history' -export * from './history/snapshot' -export * from './utils/collaborative-doc' -export * from './utils/storage' -export * from './utils/ydoc' +export * from './storage' +export * from './ydoc' diff --git a/server/collaboration/src/storage.ts b/server/collaboration/src/storage.ts new file mode 100644 index 0000000000..2d195ef3c8 --- /dev/null +++ b/server/collaboration/src/storage.ts @@ -0,0 +1,127 @@ +// +// 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. +// + +import { + type Blob, + type CollaborativeDoc, + type Ref, + type WorkspaceId, + Markup, + MeasureContext, + generateId, + makeCollabJsonId, + makeCollabYdocId +} from '@hcengineering/core' +import { StorageAdapter } from '@hcengineering/server-core' +import { yDocToMarkup } from '@hcengineering/text' +import { Doc as YDoc } from 'yjs' + +import { yDocFromBuffer, yDocToBuffer } from './ydoc' + +/** @public */ +export async function loadCollabYdoc ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + doc: CollaborativeDoc +): Promise { + const blobId = makeCollabYdocId(doc) + + const blob = await storageAdapter.stat(ctx, workspace, blobId) + if (blob === undefined) { + return undefined + } + + if (!blob.contentType.includes('application/ydoc')) { + ctx.error('invalid content type', { contentType: blob.contentType }) + return undefined + } + + // no need to apply gc because we load existing document + // it is either already gc-ed, or gc not needed and it is disabled + const ydoc = new YDoc({ guid: generateId(), gc: false }) + + const buffer = await storageAdapter.read(ctx, workspace, blobId) + return yDocFromBuffer(Buffer.concat(buffer as any), ydoc) +} + +/** @public */ +export async function saveCollabYdoc ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + doc: CollaborativeDoc, + ydoc: YDoc +): Promise> { + const blobId = makeCollabYdocId(doc) + + const buffer = yDocToBuffer(ydoc) + await storageAdapter.put(ctx, workspace, blobId, buffer, 'application/ydoc', buffer.length) + + return blobId +} + +/** @public */ +export async function removeCollabYdoc ( + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + collaborativeDocs: CollaborativeDoc[], + ctx: MeasureContext +): Promise { + const toRemove: string[] = collaborativeDocs.map(makeCollabYdocId) + if (toRemove.length > 0) { + await ctx.with('remove', {}, async () => { + await storageAdapter.remove(ctx, workspace, toRemove) + }) + } +} + +/** @public */ +export async function loadCollabJson ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + blobId: Ref +): Promise { + const blob = await storageAdapter.stat(ctx, workspace, blobId) + if (blob === undefined) { + return undefined + } + + if (!blob.contentType.includes('application/json')) { + ctx.error('invalid content type', { contentType: blob.contentType }) + return undefined + } + + const buffer = await storageAdapter.read(ctx, workspace, blobId) + return Buffer.concat(buffer as any).toString() +} + +/** @public */ +export async function saveCollabJson ( + ctx: MeasureContext, + storageAdapter: StorageAdapter, + workspace: WorkspaceId, + doc: CollaborativeDoc, + content: Markup | YDoc +): Promise> { + const blobId = makeCollabJsonId(doc) + + const markup = typeof content === 'string' ? content : yDocToMarkup(content, doc.objectAttr) + const buffer = Buffer.from(markup) + await storageAdapter.put(ctx, workspace, blobId, buffer, 'application/json', buffer.length) + + return blobId +} diff --git a/server/collaboration/src/utils/__tests__/collaborative-doc.test.ts b/server/collaboration/src/utils/__tests__/collaborative-doc.test.ts deleted file mode 100644 index 87e8e542c7..0000000000 --- a/server/collaboration/src/utils/__tests__/collaborative-doc.test.ts +++ /dev/null @@ -1,61 +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. -// - -import { collaborativeDocFormat } from '@hcengineering/core' -import { collaborativeHistoryDocId, isEditableDoc, isEditableDocVersion } from '../collaborative-doc' - -describe('collaborative-doc', () => { - describe('collaborativeHistoryDocId', () => { - it('returns valid history doc id', async () => { - expect(collaborativeHistoryDocId('documentId')).toEqual('documentId#history') - }) - - it('returns valid history doc id for history doc id', async () => { - expect(collaborativeHistoryDocId('documentId#history')).toEqual('documentId#history') - }) - }) - - describe('isEditableDoc', () => { - it('returns true for HEAD version', async () => { - const doc = collaborativeDocFormat({ - documentId: 'example', - versionId: 'HEAD', - lastVersionId: '0' - }) - expect(isEditableDoc(doc)).toBeTruthy() - }) - - it('returns false for other versions', async () => { - const doc = collaborativeDocFormat({ - documentId: 'example', - versionId: 'main', - lastVersionId: '0' - }) - expect(isEditableDoc(doc)).toBeFalsy() - }) - }) - - describe('isEditableDocVersion', () => { - it('returns true for HEAD version', async () => { - expect(isEditableDocVersion('HEAD')).toBeTruthy() - }) - - it('returns false for other versions', async () => { - expect(isEditableDocVersion('')).toBeFalsy() - expect(isEditableDocVersion('main')).toBeFalsy() - expect(isEditableDocVersion('head')).toBeFalsy() - }) - }) -}) diff --git a/server/collaboration/src/utils/__tests__/ydoc.test.ts b/server/collaboration/src/utils/__tests__/ydoc.test.ts deleted file mode 100644 index 6dc0acaeb4..0000000000 --- a/server/collaboration/src/utils/__tests__/ydoc.test.ts +++ /dev/null @@ -1,87 +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. -// - -import { Doc as YDoc, XmlElement as YXmlElement, XmlText as YXmlText, encodeStateVector } from 'yjs' - -import { clone, yDocCopyXmlField, yDocFromBuffer, yDocToBuffer } from '../ydoc' -import { generateId } from '@hcengineering/core' - -describe('ydoc', () => { - it('yDocFromBuffer converts ydoc to a buffer', async () => { - const ydoc = new YDoc({ guid: generateId() }) - const buffer = yDocToBuffer(ydoc) - - expect(buffer).toBeDefined() - }) - - it('yDocFromBuffer converts buffer to a ydoc', async () => { - const source = new YDoc({ guid: generateId() }) - source.getArray('data').insert(0, [1, 2]) - - const buffer = yDocToBuffer(source) - - const target = yDocFromBuffer(buffer, new YDoc({ guid: generateId() })) - expect(target).toBeDefined() - expect(encodeStateVector(target)).toEqual(encodeStateVector(source)) - }) - - describe('yDocCopyXmlField', () => { - it('copies into new field', async () => { - const ydoc = new YDoc() - - const source = ydoc.getXmlFragment('source') - source.insertAfter(null, [new YXmlElement('p'), new YXmlText('foo'), new YXmlElement('p')]) - expect(ydoc.share.has('target')).toBeFalsy() - - yDocCopyXmlField(ydoc, 'source', 'target') - const target = ydoc.getXmlFragment('target') - - expect(ydoc.share.has('target')).toBeTruthy() - expect(target.toJSON()).toEqual(source.toJSON()) - }) - - it('copies into existing field', async () => { - const ydoc = new YDoc() - - const source = ydoc.getXmlFragment('source') - const target = ydoc.getXmlFragment('target') - - source.insertAfter(null, [new YXmlElement('p'), new YXmlText('foo'), new YXmlElement('p')]) - target.insertAfter(null, [new YXmlText('bar')]) - expect(target.toJSON()).not.toEqual(source.toJSON()) - - yDocCopyXmlField(ydoc, 'source', 'target') - expect(ydoc.share.has('target')).toBeTruthy() - expect(target.toJSON()).toEqual(source.toJSON()) - }) - }) - - it('clones YXmlElement', () => { - const ydoc = new YDoc() - const source = ydoc.getXmlElement('source') - const target = ydoc.getXmlFragment('target') - - const src = new YXmlElement('paragraph') - src.setAttribute('class', 'text') - src.setAttribute('size', 1024 as any) - src.insert(0, [new YXmlText('foo')]) - source.insert(0, [src]) - - const dst = clone(src) - target.insert(0, [dst]) - - expect(src.toJSON()).toEqual(dst.toJSON()) - }) -}) diff --git a/server/collaboration/src/utils/collaborative-doc.ts b/server/collaboration/src/utils/collaborative-doc.ts deleted file mode 100644 index f6d6f9cd29..0000000000 --- a/server/collaboration/src/utils/collaborative-doc.ts +++ /dev/null @@ -1,168 +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. -// - -import { - CollaborativeDoc, - CollaborativeDocVersion, - CollaborativeDocVersionHead, - MeasureContext, - WorkspaceId, - collaborativeDocParse, - collaborativeDocUnchain, - generateId -} from '@hcengineering/core' -import { Doc as YDoc } from 'yjs' - -import { StorageAdapter } from '@hcengineering/server-core' -import { restoreYdocSnapshot } from '../history/snapshot' -import { yDocFromStorage, yDocToStorage } from './storage' - -/** @public */ -export function collaborativeHistoryDocId (id: string): string { - const suffix = '#history' - return id.endsWith(suffix) ? id : id + suffix -} - -async function loadCollaborativeDocVersion ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - versionId: string -): Promise { - const yContent = await ctx.with('yDocFromStorage', { type: 'content' }, (ctx) => { - return yDocFromStorage(ctx, storageAdapter, workspace, documentId, new YDoc({ guid: generateId(), gc: false })) - }) - - // the document does not exist - if (yContent === undefined) { - return undefined - } - - if (versionId === 'HEAD') { - return yContent - } - - const historyDocumentId = collaborativeHistoryDocId(documentId) - const yHistory = await ctx.with('yDocFromStorage', { type: 'history' }, (ctx) => { - return yDocFromStorage(ctx, storageAdapter, workspace, historyDocumentId, new YDoc({ guid: generateId() })) - }) - - // the history document does not exist - if (yHistory === undefined) { - return undefined - } - - return await ctx.with('restoreYdocSnapshot', {}, () => { - return restoreYdocSnapshot(yContent, yHistory, versionId) - }) -} - -/** @public */ -export async function loadCollaborativeDoc ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - collaborativeDoc: CollaborativeDoc -): Promise { - const sources = collaborativeDocUnchain(collaborativeDoc) - - return await ctx.with('loadCollaborativeDoc', { type: 'content' }, async (ctx) => { - for (const source of sources) { - const { documentId, versionId } = collaborativeDocParse(source) - - const ydoc = await loadCollaborativeDocVersion(ctx, storageAdapter, workspace, documentId, versionId) - - if (ydoc !== undefined) { - return ydoc - } - } - return undefined - }) -} - -/** @public */ -export async function saveCollaborativeDoc ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - collaborativeDoc: CollaborativeDoc, - ydoc: YDoc -): Promise { - const { documentId, versionId } = collaborativeDocParse(collaborativeDoc) - await saveCollaborativeDocVersion(ctx, storageAdapter, workspace, documentId, versionId, ydoc) -} - -/** @public */ -export async function saveCollaborativeDocVersion ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - versionId: CollaborativeDocVersion, - ydoc: YDoc -): Promise { - await ctx.with('saveCollaborativeDoc', {}, async (ctx) => { - if (versionId === 'HEAD') { - await ctx.with('yDocToStorage', {}, () => yDocToStorage(ctx, storageAdapter, workspace, documentId, ydoc)) - } else { - console.warn('Cannot save non HEAD document version', documentId, versionId) - } - }) -} - -/** @public */ -export async function removeCollaborativeDoc ( - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - collaborativeDocs: CollaborativeDoc[], - ctx: MeasureContext -): Promise { - await ctx.with('removeollaborativeDoc', {}, async (ctx) => { - const toRemove: string[] = [] - for (const collaborativeDoc of collaborativeDocs) { - const { documentId, versionId } = collaborativeDocParse(collaborativeDoc) - if (versionId === CollaborativeDocVersionHead) { - toRemove.push(documentId, collaborativeHistoryDocId(documentId)) - } else { - console.warn('Cannot remove non HEAD document version', documentId, versionId) - } - } - if (toRemove.length > 0) { - await ctx.with('remove', {}, () => storageAdapter.remove(ctx, workspace, toRemove)) - } - }) -} - -/** @public */ -export function isEditableDoc (id: CollaborativeDoc): boolean { - const { versionId } = collaborativeDocParse(id) - return isEditableDocVersion(versionId) -} - -/** @public */ -export function isReadonlyDoc (id: CollaborativeDoc): boolean { - return !isEditableDoc(id) -} - -/** @public */ -export function isEditableDocVersion (version: CollaborativeDocVersion): boolean { - return version === CollaborativeDocVersionHead -} - -/** @public */ -export function isReadonlyDocVersion (version: CollaborativeDocVersion): boolean { - return !isEditableDocVersion(version) -} diff --git a/server/collaboration/src/utils/storage.ts b/server/collaboration/src/utils/storage.ts deleted file mode 100644 index be2cb7976d..0000000000 --- a/server/collaboration/src/utils/storage.ts +++ /dev/null @@ -1,57 +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. -// - -import { generateId, MeasureContext, WorkspaceId } from '@hcengineering/core' -import { StorageAdapter } from '@hcengineering/server-core' -import { Doc as YDoc } from 'yjs' - -import { yDocFromBuffer, yDocToBuffer } from './ydoc' - -/** @public */ -export async function yDocFromStorage ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - ydoc?: YDoc -): Promise { - // stat the object to ensure it exists, because read will throw an error in this case - try { - const buffer = await storageAdapter.read(ctx, workspace, documentId) - - // no need to apply gc because we load existing document - // it is either already gc-ed, or gc not needed and it is disabled - ydoc ??= new YDoc({ guid: generateId(), gc: false }) - - return yDocFromBuffer(Buffer.concat(buffer as any), ydoc) - } catch (err: any) { - if (err.code === 'NoSuchKey') { - return undefined - } - throw err - } -} - -/** @public */ -export async function yDocToStorage ( - ctx: MeasureContext, - storageAdapter: StorageAdapter, - workspace: WorkspaceId, - documentId: string, - ydoc: YDoc -): Promise { - const buffer = yDocToBuffer(ydoc) - await storageAdapter.put(ctx, workspace, documentId, buffer, 'application/ydoc', buffer.length) -} diff --git a/server/collaboration/src/utils/ydoc.ts b/server/collaboration/src/ydoc.ts similarity index 88% rename from server/collaboration/src/utils/ydoc.ts rename to server/collaboration/src/ydoc.ts index c6824fb4fb..b20cd907e8 100644 --- a/server/collaboration/src/utils/ydoc.ts +++ b/server/collaboration/src/ydoc.ts @@ -13,6 +13,7 @@ // limitations under the License. // +import { generateId } from '@hcengineering/core' import { AbstractType as YAbstractType, Doc as YDoc, @@ -24,7 +25,8 @@ import { export { XmlElement as YXmlElement, XmlText as YXmlText, AbstractType as YAbstractType } from 'yjs' /** @public */ -export function yDocFromBuffer (buffer: Buffer, ydoc: YDoc): YDoc { +export function yDocFromBuffer (buffer: Buffer, ydoc?: YDoc): YDoc { + ydoc ??= new YDoc({ guid: generateId(), gc: false }) try { const uint8arr = new Uint8Array(buffer) applyUpdate(ydoc, uint8arr) @@ -59,7 +61,7 @@ export function yDocCopyXmlField (ydoc: YDoc, source: string, target: string): v * @param src YXmlElement * @returns YXmlElement */ -export function clone (src: YXmlElement): YXmlElement { +export function yXmlElementClone (src: YXmlElement): YXmlElement { const el = new YXmlElement(src.nodeName) const attrs = src.getAttributes() @@ -72,7 +74,7 @@ export function clone (src: YXmlElement): YXmlElement { src .toArray() .map((item) => - item instanceof YAbstractType ? (item instanceof YXmlElement ? clone(item) : item.clone()) : item + item instanceof YAbstractType ? (item instanceof YXmlElement ? yXmlElementClone(item) : item.clone()) : item ) as any ) diff --git a/server/collaborator/src/context.ts b/server/collaborator/src/context.ts index b0de5441ef..c1775f9ba4 100644 --- a/server/collaborator/src/context.ts +++ b/server/collaborator/src/context.ts @@ -13,8 +13,7 @@ // limitations under the License. // -import { type DocumentId, type PlatformDocumentId } from '@hcengineering/collaborator-client' -import { WorkspaceId, generateId } from '@hcengineering/core' +import { Blob, Ref, WorkspaceId, generateId } from '@hcengineering/core' import { decodeToken } from '@hcengineering/server-token' import { onAuthenticatePayload } from '@hocuspocus/server' import { ClientFactory, simpleClientFactory } from './platform' @@ -24,8 +23,7 @@ export interface Context { workspaceId: WorkspaceId clientFactory: ClientFactory - initialContentId?: DocumentId - platformDocumentId?: PlatformDocumentId + content?: Ref } interface WithContext { @@ -42,14 +40,12 @@ export function buildContext (data: onAuthenticatePayload): Context { const connectionId = context.connectionId ?? generateId() const decodedToken = decodeToken(data.token) - const initialContentId = (data.requestParameters.get('initialContentId') as DocumentId) ?? undefined - const platformDocumentId = (data.requestParameters.get('platformDocumentId') as PlatformDocumentId) ?? undefined + const content = (data.requestParameters.get('content') as Ref) ?? undefined return { connectionId, workspaceId: decodedToken.workspace, clientFactory: simpleClientFactory(decodedToken), - initialContentId, - platformDocumentId + content } } diff --git a/server/collaborator/src/extensions/authentication.ts b/server/collaborator/src/extensions/authentication.ts index 2d7081133b..757b0d4422 100644 --- a/server/collaborator/src/extensions/authentication.ts +++ b/server/collaborator/src/extensions/authentication.ts @@ -13,8 +13,7 @@ // limitations under the License. // -import { DocumentId, parseDocumentId } from '@hcengineering/collaborator-client' -import { isReadonlyDoc } from '@hcengineering/collaboration' +import { decodeDocumentId } from '@hcengineering/collaborator-client' import { MeasureContext } from '@hcengineering/core' import { decodeToken } from '@hcengineering/server-token' import { Extension, onAuthenticatePayload } from '@hocuspocus/server' @@ -35,7 +34,7 @@ export class AuthenticationExtension implements Extension { async onAuthenticate (data: onAuthenticatePayload): Promise { const ctx = this.configuration.ctx - const { workspaceId, collaborativeDoc } = parseDocumentId(data.documentName as DocumentId) + const { workspaceId } = decodeDocumentId(data.documentName) return await ctx.with('authenticate', { workspaceId }, async () => { const token = decodeToken(data.token) @@ -50,8 +49,6 @@ export class AuthenticationExtension implements Extension { throw new Error('documentName must include workspace id') } - data.connection.readOnly = isReadonlyDoc(collaborativeDoc) - return buildContext(data) }) } diff --git a/server/collaborator/src/extensions/storage.ts b/server/collaborator/src/extensions/storage.ts index db49e51148..279e4568b3 100644 --- a/server/collaborator/src/extensions/storage.ts +++ b/server/collaborator/src/extensions/storage.ts @@ -13,7 +13,6 @@ // limitations under the License. // -import { DocumentId } from '@hcengineering/collaborator-client' import { type Markup, MeasureContext } from '@hcengineering/core' import { Document, @@ -130,7 +129,7 @@ export class StorageExtension implements Extension { try { return await ctx.with('load-document', {}, (ctx) => { - return adapter.loadDocument(ctx, documentName as DocumentId, context) + return adapter.loadDocument(ctx, documentName, context) }) } catch (err) { ctx.error('failed to load document', { documentName, error: err }) @@ -146,7 +145,7 @@ export class StorageExtension implements Extension { const currMarkup = this.configuration.transformer.fromYdoc(document) await ctx.with('save-document', {}, (ctx) => - adapter.saveDocument(ctx, documentName as DocumentId, document, context, { + adapter.saveDocument(ctx, documentName, document, context, { prev: prevMarkup, curr: currMarkup }) diff --git a/server/collaborator/src/rpc/methods/createContent.ts b/server/collaborator/src/rpc/methods/createContent.ts new file mode 100644 index 0000000000..97ad6e4ce7 --- /dev/null +++ b/server/collaborator/src/rpc/methods/createContent.ts @@ -0,0 +1,49 @@ +// +// 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. +// + +import { + type CreateContentRequest, + type CreateContentResponse, + decodeDocumentId +} from '@hcengineering/collaborator-client' +import { saveCollabJson } from '@hcengineering/collaboration' +import { type Blob, type Ref, MeasureContext } from '@hcengineering/core' +import { Context } from '../../context' +import { RpcMethodParams } from '../rpc' + +export async function createContent ( + ctx: MeasureContext, + context: Context, + documentName: string, + payload: CreateContentRequest, + params: RpcMethodParams +): Promise { + const { content } = payload + const { hocuspocus, storageAdapter } = params + + if (hocuspocus.documents.has(documentName) || hocuspocus.loadingDocuments.has(documentName)) { + throw new Error(`Document ${documentName} already exists`) + } + + const { documentId, workspaceId } = decodeDocumentId(documentName) + + const result: Record> = {} + for (const [field, markup] of Object.entries(content)) { + const blob = await saveCollabJson(ctx, storageAdapter, { name: workspaceId }, documentId, markup) + result[field] = blob + } + + return { content: result } +} diff --git a/server/collaborator/src/rpc/methods/getContent.ts b/server/collaborator/src/rpc/methods/getContent.ts index 90aeb92cdf..8899141834 100644 --- a/server/collaborator/src/rpc/methods/getContent.ts +++ b/server/collaborator/src/rpc/methods/getContent.ts @@ -21,14 +21,17 @@ import { RpcMethodParams } from '../rpc' export async function getContent ( ctx: MeasureContext, context: Context, - documentId: string, + documentName: string, payload: GetContentRequest, params: RpcMethodParams ): Promise { const { hocuspocus, transformer } = params + const { source } = payload + + context = { ...context, content: source } const connection = await ctx.with('connect', {}, () => { - return hocuspocus.openDirectConnection(documentId, context) + return hocuspocus.openDirectConnection(documentName, context) }) try { diff --git a/server/collaborator/src/rpc/methods/index.ts b/server/collaborator/src/rpc/methods/index.ts index 6c69a0a2f3..2a01f16280 100644 --- a/server/collaborator/src/rpc/methods/index.ts +++ b/server/collaborator/src/rpc/methods/index.ts @@ -14,10 +14,12 @@ // import { getContent } from './getContent' +import { createContent } from './createContent' import { updateContent } from './updateContent' import { RpcMethod } from '../rpc' export const methods: Record = { getContent, + createContent, updateContent } diff --git a/server/collaborator/src/rpc/methods/updateContent.ts b/server/collaborator/src/rpc/methods/updateContent.ts index 1334f0265c..87dc350e78 100644 --- a/server/collaborator/src/rpc/methods/updateContent.ts +++ b/server/collaborator/src/rpc/methods/updateContent.ts @@ -22,7 +22,7 @@ import { RpcMethodParams } from '../rpc' export async function updateContent ( ctx: MeasureContext, context: Context, - documentId: string, + documentName: string, payload: UpdateContentRequest, params: RpcMethodParams ): Promise { @@ -41,7 +41,7 @@ export async function updateContent ( }) const connection = await ctx.with('connect', {}, () => { - return hocuspocus.openDirectConnection(documentId, context) + return hocuspocus.openDirectConnection(documentName, context) }) try { diff --git a/server/collaborator/src/server.ts b/server/collaborator/src/server.ts index 8e7422ebc0..e3afeefbc2 100644 --- a/server/collaborator/src/server.ts +++ b/server/collaborator/src/server.ts @@ -136,24 +136,24 @@ export async function start (ctx: MeasureContext, config: Config, storageAdapter }) // eslint-disable-next-line @typescript-eslint/no-misused-promises - app.post('/rpc', async (req, res) => { + app.post('/rpc/:id', async (req, res) => { const authHeader = req.headers.authorization if (authHeader === undefined) { res.status(403).send({ error: 'Unauthorized' }) return } - const request = req.body as RpcRequest - - const documentId = request.documentId + const documentId = req.params.id if (documentId === undefined || documentId === '') { const response: RpcErrorResponse = { - error: 'Missing documentId' + error: 'Missing document id' } res.status(400).send(response) return } + const request = req.body as RpcRequest + const method = methods[request.method] if (method === undefined) { const response: RpcErrorResponse = { diff --git a/server/collaborator/src/storage/adapter.ts b/server/collaborator/src/storage/adapter.ts index 04573e7296..8b5fb44a7d 100644 --- a/server/collaborator/src/storage/adapter.ts +++ b/server/collaborator/src/storage/adapter.ts @@ -13,16 +13,15 @@ // limitations under the License. // -import { DocumentId } from '@hcengineering/collaborator-client' import { MeasureContext } from '@hcengineering/core' import { Doc as YDoc } from 'yjs' import { Context } from '../context' export interface CollabStorageAdapter { - loadDocument: (ctx: MeasureContext, documentId: DocumentId, context: Context) => Promise + loadDocument: (ctx: MeasureContext, documentId: string, context: Context) => Promise saveDocument: ( ctx: MeasureContext, - documentId: DocumentId, + documentId: string, document: YDoc, context: Context, markup: { diff --git a/server/collaborator/src/storage/platform.ts b/server/collaborator/src/storage/platform.ts index 3179ae5e5d..d9d7884479 100644 --- a/server/collaborator/src/storage/platform.ts +++ b/server/collaborator/src/storage/platform.ts @@ -14,61 +14,63 @@ // import activity, { DocUpdateMessage } from '@hcengineering/activity' -import { loadCollaborativeDoc, saveCollaborativeDoc } from '@hcengineering/collaboration' -import { - DocumentId, - PlatformDocumentId, - parseDocumentId, - parsePlatformDocumentId -} from '@hcengineering/collaborator-client' -import core, { - AttachedData, - CollaborativeDoc, - MeasureContext, - TxOperations, - collaborativeDocWithLastVersion -} from '@hcengineering/core' +import { loadCollabJson, loadCollabYdoc, saveCollabJson, saveCollabYdoc } from '@hcengineering/collaboration' +import { decodeDocumentId } from '@hcengineering/collaborator-client' +import core, { AttachedData, MeasureContext, TxOperations } from '@hcengineering/core' import { StorageAdapter } from '@hcengineering/server-core' +import { markupToYDocNoSchema, areEqualMarkups } from '@hcengineering/text' import { Doc as YDoc } from 'yjs' import { Context } from '../context' -import { areEqualMarkups } from '@hcengineering/text' import { CollabStorageAdapter } from './adapter' export class PlatformStorageAdapter implements CollabStorageAdapter { constructor (private readonly storage: StorageAdapter) {} - async loadDocument (ctx: MeasureContext, documentId: DocumentId, context: Context): Promise { + async loadDocument (ctx: MeasureContext, documentName: string, context: Context): Promise { + const { content, workspaceId } = context + const { documentId } = decodeDocumentId(documentName) + // try to load document content try { - ctx.info('load document content', { documentId }) - const ydoc = await this.loadDocumentFromStorage(ctx, documentId, context) + ctx.info('load document content', { documentName }) + + const ydoc = await ctx.with('loadCollabYdoc', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return loadCollabYdoc(ctx, this.storage, context.workspaceId, documentId) + }) + }) if (ydoc !== undefined) { return ydoc } } catch (err) { - ctx.error('failed to load document content', { documentId, error: err }) + ctx.error('failed to load document content', { documentName, error: err }) throw err } // then try to load from inital content - const { initialContentId } = context - if (initialContentId !== undefined && initialContentId.length > 0) { + if (content !== undefined) { try { - ctx.info('load document initial content', { documentId, initialContentId }) - const ydoc = await this.loadDocumentFromStorage(ctx, initialContentId, context) + ctx.info('load document initial content', { documentName, content }) + + const markup = await ctx.with('loadCollabJson', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return loadCollabJson(ctx, this.storage, workspaceId, content) + }) + }) + if (markup !== undefined) { + const ydoc = markupToYDocNoSchema(markup, documentId.objectAttr) + + // if document was loaded from the initial content or storage we need to save + // it to ensure the next time we load it from the ydoc document + await saveCollabYdoc(ctx, this.storage, workspaceId, documentId, ydoc) - // if document was loaded from the initial content or storage we need to save - // it to ensure the next time we load it from the ydoc document - if (ydoc !== undefined) { - ctx.info('save document content', { documentId, initialContentId }) - await this.saveDocumentToStorage(ctx, documentId, ydoc, context) return ydoc } } catch (err) { - ctx.error('failed to load initial document content', { documentId, initialContentId, error: err }) + ctx.error('failed to load initial document content', { documentName, content, error: err }) throw err } } @@ -79,7 +81,7 @@ export class PlatformStorageAdapter implements CollabStorageAdapter { async saveDocument ( ctx: MeasureContext, - documentId: DocumentId, + documentName: string, document: YDoc, context: Context, markup: { @@ -88,72 +90,45 @@ export class PlatformStorageAdapter implements CollabStorageAdapter { } ): Promise { const { clientFactory } = context + const { documentId } = decodeDocumentId(documentName) const client = await ctx.with('connect', {}, () => clientFactory()) try { try { - ctx.info('save document content', { documentId }) - await this.saveDocumentToStorage(ctx, documentId, document, context) + ctx.info('save document ydoc content', { documentName }) + await ctx.with('saveCollabYdoc', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return saveCollabYdoc(ctx, this.storage, context.workspaceId, documentId, document) + }) + }) } catch (err) { - ctx.error('failed to save document', { documentId, error: err }) + ctx.error('failed to save document ydoc content', { documentName, error: err }) // raise an error if failed to save document to storage // this will prevent document from being unloaded from memory throw err } - const { platformDocumentId } = context - if (platformDocumentId !== undefined) { - ctx.info('save document content to platform', { documentId, platformDocumentId }) - await ctx.with('save-to-platform', {}, (ctx) => - this.saveDocumentToPlatform(ctx, client, documentId, platformDocumentId, markup) - ) - } + ctx.info('save document content to platform', { documentName }) + await ctx.with('save-to-platform', {}, (ctx) => { + return this.saveDocumentToPlatform(ctx, client, documentName, markup) + }) } finally { await client.close() } } - async loadDocumentFromStorage ( - ctx: MeasureContext, - documentId: DocumentId, - context: Context - ): Promise { - const { collaborativeDoc } = parseDocumentId(documentId) - - return await ctx.with('load-document', {}, (ctx) => - withRetry(ctx, 5, async () => { - return await loadCollaborativeDoc(ctx, this.storage, context.workspaceId, collaborativeDoc) - }) - ) - } - - async saveDocumentToStorage ( - ctx: MeasureContext, - documentId: DocumentId, - document: YDoc, - context: Context - ): Promise { - const { collaborativeDoc } = parseDocumentId(documentId) - - await ctx.with('save-document', {}, (ctx) => - withRetry(ctx, 5, async () => { - await saveCollaborativeDoc(ctx, this.storage, context.workspaceId, collaborativeDoc, document) - }) - ) - } - async saveDocumentToPlatform ( ctx: MeasureContext, client: Omit, documentName: string, - platformDocumentId: PlatformDocumentId, markup: { prev: Record curr: Record } ): Promise { - const { objectClass, objectId, objectAttr } = parsePlatformDocumentId(platformDocumentId) + const { documentId, workspaceId } = decodeDocumentId(documentName) + const { objectAttr, objectClass, objectId } = documentId const currMarkup = markup.curr[objectAttr] const prevMarkup = markup.prev[objectAttr] @@ -184,10 +159,13 @@ export class PlatformStorageAdapter implements CollabStorageAdapter { return } - const collaborativeDoc = (current as any)[objectAttr] as CollaborativeDoc - const newCollaborativeDoc = collaborativeDocWithLastVersion(collaborativeDoc, `${Date.now()}`) + const blobId = await ctx.with('saveCollabJson', {}, (ctx) => { + return withRetry(ctx, 5, () => { + return saveCollabJson(ctx, this.storage, { name: workspaceId }, documentId, markup.curr[objectAttr]) + }) + }) - await ctx.with('update', {}, () => client.diffUpdate(current, { [objectAttr]: newCollaborativeDoc })) + await ctx.with('update', {}, () => client.diffUpdate(current, { [objectAttr]: blobId })) await ctx.with('activity', {}, () => { const data: AttachedData = { diff --git a/server/collaborator/src/types.ts b/server/collaborator/src/types.ts deleted file mode 100644 index 6171defa96..0000000000 --- a/server/collaborator/src/types.ts +++ /dev/null @@ -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. -// - -import type { Class, Doc, Domain, Ref } from '@hcengineering/core' - -/** @public */ -export interface DocumentId { - workspaceId: string - documentId: string - versionId: string -} - -/** @public */ -export interface PlatformDocumentId { - objectDomain: Domain - objectClass: Ref> - objectId: Ref - objectAttr: string -} diff --git a/server/indexer/src/indexer/indexer.ts b/server/indexer/src/indexer/indexer.ts index cb576d29f3..041b35b807 100644 --- a/server/indexer/src/indexer/indexer.ts +++ b/server/indexer/src/indexer/indexer.ts @@ -21,7 +21,6 @@ import core, { type AttachedDoc, type Blob, type Class, - type CollaborativeDoc, DOMAIN_DOC_INDEX_STATE, DOMAIN_MIGRATION, type Doc, @@ -41,7 +40,6 @@ import core, { TxFactory, type WithLookup, type WorkspaceIdWithUrl, - collaborativeDocParse, coreId, docKey, generateId, @@ -63,7 +61,7 @@ import type { StorageAdapter } from '@hcengineering/server-core' import { RateLimiter, SessionDataImpl } from '@hcengineering/server-core' -import { jsonToText, markupToJSON, pmNodeToText, yDocContentToNodes } from '@hcengineering/text' +import { jsonToText, markupToJSON, markupToText } from '@hcengineering/text' import { findSearchPresenter, updateDocWithPresenter } from '../mapper' import { type FullTextPipeline } from './types' import { createIndexedDoc, createStateDoc, getContent } from './utils' @@ -780,14 +778,12 @@ export class FullTextIndexPipeline implements FullTextPipeline { v: { value: any, attr: AnyAttribute }, indexedDoc: IndexedDoc ): Promise { - const collaborativeDoc = v.value as CollaborativeDoc - if (collaborativeDoc !== undefined && collaborativeDoc !== '') { - const { documentId } = collaborativeDocParse(collaborativeDoc) - + const value = v.value as Ref + if (value !== undefined && value !== '') { try { - const readable = await this.storageAdapter?.read(ctx, this.workspace, documentId) - const nodes = yDocContentToNodes(Buffer.concat(readable as any)) - let textContent = nodes.map(pmNodeToText).join('\n') + const readable = await this.storageAdapter?.read(ctx, this.workspace, value) + const markup = Buffer.concat(readable as any).toString() + let textContent = markupToText(markup) textContent = textContent .split(/ +|\t+|\f+/) .filter((it) => it) @@ -798,7 +794,7 @@ export class FullTextIndexPipeline implements FullTextPipeline { indexedDoc.fulltextSummary += '\n' + textContent } catch (err: any) { Analytics.handleError(err) - ctx.error('failed to handle blob', { _id: documentId, workspace: this.workspace.name }) + ctx.error('failed to handle blob', { _id: value, workspace: this.workspace.name }) } } } diff --git a/server/tool/src/initializer.ts b/server/tool/src/initializer.ts index e2782444aa..250115c633 100644 --- a/server/tool/src/initializer.ts +++ b/server/tool/src/initializer.ts @@ -1,11 +1,11 @@ -import { saveCollaborativeDoc } from '@hcengineering/collaboration' +import { saveCollabJson } from '@hcengineering/collaboration' import core, { AttachedDoc, Class, - CollaborativeDoc, Data, Doc, generateId, + makeCollabId, MeasureContext, Mixin, Ref, @@ -16,7 +16,7 @@ import core, { import { ModelLogger } from '@hcengineering/model' import { makeRank } from '@hcengineering/rank' import type { StorageAdapter } from '@hcengineering/server-core' -import { jsonToYDocNoSchema, parseMessageMarkdown } from '@hcengineering/text' +import { jsonToMarkup, parseMessageMarkdown } from '@hcengineering/text' import { v4 as uuid } from 'uuid' const fieldRegexp = /\${\S+?}/ @@ -199,7 +199,7 @@ export class WorkspaceInitializer { if (step.collabFields !== undefined) { for (const field of step.collabFields) { if ((data as any)[field] !== undefined) { - const res = await this.createCollab((data as any)[field], field, _id) + const res = await this.createCollab((data as any)[field], step._class, _id, field) ;(data as any)[field] = res } } @@ -265,15 +265,18 @@ export class WorkspaceInitializer { return data } - private async createCollab (data: string, field: string, _id: Ref): Promise { - const id = `${_id}%${field}` - const collabId = `${id}:HEAD:0` as CollaborativeDoc + private async createCollab ( + data: string, + objectClass: Ref>, + objectId: Ref, + objectAttr: string + ): Promise { + const doc = makeCollabId(objectClass, objectId, objectAttr) const json = parseMessageMarkdown(data ?? '', this.imageUrl) - const yDoc = jsonToYDocNoSchema(json, field) + const markup = jsonToMarkup(json) - await saveCollaborativeDoc(this.ctx, this.storageAdapter, this.wsUrl, collabId, yDoc) - return collabId + return await saveCollabJson(this.ctx, this.storageAdapter, this.wsUrl, doc, markup) } private async fillProps | Props>( diff --git a/services/github/pod-github/src/sync/issueBase.ts b/services/github/pod-github/src/sync/issueBase.ts index 08ba502e24..c767ad9a9c 100644 --- a/services/github/pod-github/src/sync/issueBase.ts +++ b/services/github/pod-github/src/sync/issueBase.ts @@ -14,7 +14,6 @@ import core, { Account, AttachedDoc, Class, - CollaborativeDoc, Doc, DocumentUpdate, Markup, @@ -22,7 +21,8 @@ import core, { Ref, Space, Status, - TxOperations + TxOperations, + makeDocCollabId } from '@hcengineering/core' import github, { DocSyncInfo, @@ -74,8 +74,8 @@ import { /** * @public */ -export type WithMarkup = { - [P in keyof T]: T[P] extends CollaborativeDoc ? Markup : T[P] +export type WithMarkup = Omit & { + description: Markup } /** @@ -259,7 +259,7 @@ export abstract class IssueSyncManagerBase { } let needProjectRefresh = false - const update: DocumentUpdate & Record = {} + const update: DocumentUpdate> & Record = {} let structure = integration.projectStructure.get(target.target._id) @@ -372,7 +372,8 @@ export abstract class IssueSyncManagerBase { !areEqualMarkups(update.description, syncData.current?.description ?? '') ) { try { - await this.collaborator.updateContent(doc.description, { description: update.description }) + const collabId = makeDocCollabId(doc, 'description') + await this.collaborator.updateMarkup(collabId, update.description) } catch (err: any) { Analytics.handleError(err) this.ctx.error(err) @@ -772,7 +773,7 @@ export abstract class IssueSyncManagerBase { } if (update.description !== undefined) { - if (areEqualMarkups(update.description, existingIssue.description)) { + if (update.description === existingIssue.description) { delete update.description } } @@ -924,8 +925,10 @@ export abstract class IssueSyncManagerBase { workspace: this.provider.getWorkspaceId().name }) try { - issueData.description = update.description - await this.collaborator.updateContent(existingIssue.description, { description: update.description }) + const description = update.description as Markup + issueData.description = description + const collabId = makeDocCollabId(existingIssue, 'description') + await this.collaborator.updateMarkup(collabId, description) } catch (err: any) { Analytics.handleError(err) this.ctx.error('error during description update', err) diff --git a/services/github/pod-github/src/sync/issues.ts b/services/github/pod-github/src/sync/issues.ts index bc389a98a3..a2bc87b74c 100644 --- a/services/github/pod-github/src/sync/issues.ts +++ b/services/github/pod-github/src/sync/issues.ts @@ -19,7 +19,9 @@ import core, { TxOperations, cutObjectArray, generateId, - makeCollaborativeDoc + makeDocCollabId, + makeCollabJsonId, + makeCollabId } from '@hcengineering/core' import github, { DocSyncInfo, @@ -239,7 +241,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan case 'assigned': case 'unassigned': { const assignees = await this.getAssigneesI(event.issue) - const update: DocumentUpdate = { + const update: IssueUpdate = { assignee: assignees?.[0]?.person ?? null } await this.handleUpdate(externalData as IssueExternalData, derivedClient, update, account, prj, false) @@ -255,7 +257,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan } const type = await this.provider.getTaskTypeOf(prj.type, tracker.class.Issue) const statuses = await this.provider.getStatuses(type?._id) - const update: DocumentUpdate = { + const update: IssueUpdate = { status: ( await guessStatus( { @@ -380,8 +382,8 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan } const description = await this.ctx.withLog('query collaborative description', {}, async () => { - const content = await this.collaborator.getContent((existing as Issue).description) - return content.description ?? '' + const collabId = makeDocCollabId(existing, 'description') + return await this.collaborator.getMarkup(collabId, (existing as Issue).description) }) this.ctx.info('create github issue', { @@ -624,8 +626,8 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan 'query collaborative description', {}, async () => { - const content = await this.collaborator.getContent((existing as Issue).description) - return content.description ?? '' + const collabId = makeDocCollabId(existing, 'description') + return await this.collaborator.getMarkup(collabId, (existing as Issue).description) }, { url: issueExternal.url } ) @@ -908,9 +910,12 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan const { description, ...update } = issueData + const collabId = makeCollabId(tracker.class.Issue, issueId, 'description') + const contentId = makeCollabJsonId(collabId) + const value: AttachedData = { ...update, - description: makeCollaborativeDoc(issueId, 'description'), + description: contentId, kind: taskType, component: null, milestone: null, @@ -930,7 +935,7 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan identifier: `${prj.identifier}-${number}` } - await this.collaborator.updateContent(value.description, { description }) + await this.collaborator.updateMarkup(collabId, description) await this.client.addCollection( tracker.class.Issue, diff --git a/services/github/pod-github/src/sync/pullrequests.ts b/services/github/pod-github/src/sync/pullrequests.ts index 731dc79403..33b8b77749 100644 --- a/services/github/pod-github/src/sync/pullrequests.ts +++ b/services/github/pod-github/src/sync/pullrequests.ts @@ -15,7 +15,8 @@ import core, { WithLookup, cutObjectArray, generateId, - makeCollaborativeDoc + makeCollabId, + makeDocCollabId } from '@hcengineering/core' import github, { DocSyncInfo, @@ -217,12 +218,12 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS break } case 'review_requested': { - const update: DocumentUpdate = {} + const update: GithubPullRequestUpdate = {} await this.handleUpdate(externalData, derivedClient, update, account, prj, true) break } case 'review_request_removed': { - const update: DocumentUpdate = {} + const update: GithubPullRequestUpdate = {} await this.handleUpdate(externalData, derivedClient, update, account, prj, true) break } @@ -234,7 +235,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS case 'assigned': case 'unassigned': { const assignees = await this.getAssignees(externalData) - const update: DocumentUpdate = { + const update: GithubPullRequestUpdate = { assignee: assignees?.[0]?.person ?? null } await this.handleUpdate(externalData, derivedClient, update, account, prj, true) @@ -247,7 +248,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS const isMerged = event.pull_request?.merged_at !== null - const update: DocumentUpdate = { + const update: GithubPullRequestUpdate = { draft: externalData.isDraft, head: externalData.headRef, base: externalData.baseRef, @@ -594,8 +595,8 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS 'query collaborative pull request description', {}, async () => { - const content = await this.collaborator.getContent((existing as any).description) - return content.description + const collabId = makeDocCollabId(existing, 'description') + return await this.collaborator.getMarkup(collabId, (existing as GithubPullRequest).description) }, { url: pullRequestExternal.url } ) @@ -998,7 +999,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS async performIssueFieldsUpdate ( info: DocSyncInfo, - existing: Issue, + existing: WithMarkup, platformUpdate: DocumentUpdate, issueData: Pick, 'title' | 'description' | 'assignee' | 'status' | 'remainingTime' | 'component'>, container: ContainerFocus, @@ -1180,7 +1181,7 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS const number = project.sequence const value: AttachedData = { ...data, - description: makeCollaborativeDoc(prId, 'description'), + description: null, kind: taskType, component: null, milestone: null, @@ -1203,7 +1204,8 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS reviews: 0 } - await this.collaborator.updateContent(value.description, { description }) + const collabId = makeCollabId(github.class.GithubPullRequest, prId, 'description') + await this.collaborator.updateMarkup(collabId, description) await client.addCollection( github.class.GithubPullRequest, From f9dff9573d3d135dc7a258a9df99a8eb5aaaf8fc Mon Sep 17 00:00:00 2001 From: Kristina Date: Fri, 29 Nov 2024 19:07:46 +0400 Subject: [PATCH 06/10] Fix meetings transcription (#7240) Signed-off-by: Kristina Fefelova --- models/love/src/index.ts | 4 -- plugins/ai-bot-resources/src/requests.ts | 3 +- plugins/ai-bot/src/rest.ts | 2 - .../src/components/RoomPreview.svelte | 4 +- .../components/widget/MeetingWidget.svelte | 18 ++---- plugins/love-resources/src/utils.ts | 21 +++--- plugins/love/src/index.ts | 2 - services/ai-bot/love-agent/esbuild.config.js | 2 +- services/ai-bot/love-agent/package.json | 4 +- services/ai-bot/love-agent/pnpm-lock.yaml | 64 ++++++++++--------- services/ai-bot/love-agent/src/agent.ts | 2 +- .../love-agent/src/{ => deepgram}/stt.ts | 11 ++-- services/ai-bot/pod-ai-bot/src/controller.ts | 2 +- .../ai-bot/pod-ai-bot/src/workspace/love.ts | 40 ++++++------ .../src/workspace/workspaceClient.ts | 4 +- services/love/src/main.ts | 3 +- services/love/src/workspaceClient.ts | 17 +++-- 17 files changed, 98 insertions(+), 105 deletions(-) rename services/ai-bot/love-agent/src/{ => deepgram}/stt.ts (95%) diff --git a/models/love/src/index.ts b/models/love/src/index.ts index 4b5183244c..b25c90400b 100644 --- a/models/love/src/index.ts +++ b/models/love/src/index.ts @@ -48,7 +48,6 @@ import { type Builder, Collection, Collection as PropCollection, - Hidden, Index, Mixin, Model, @@ -203,9 +202,6 @@ export class TMeetingMinutes extends TAttachedDoc implements MeetingMinutes, Tod @ReadOnly() declare attachedTo: Ref - @Hidden() - sid!: string - @Prop(TypeString(), view.string.Title) @Index(IndexKind.FullText) title!: string diff --git a/plugins/ai-bot-resources/src/requests.ts b/plugins/ai-bot-resources/src/requests.ts index 9e44c86184..3bc8387761 100644 --- a/plugins/ai-bot-resources/src/requests.ts +++ b/plugins/ai-bot-resources/src/requests.ts @@ -56,7 +56,6 @@ export async function translate (text: Markup, lang: string): Promise, - sid: string, language: RoomLanguage, options: Partial ): Promise { @@ -68,7 +67,7 @@ export async function connectMeeting ( } try { - const req: ConnectMeetingRequest = { roomId, roomSid: sid, transcription: options.transcription ?? false, language } + const req: ConnectMeetingRequest = { roomId, transcription: options.transcription ?? false, language } await fetch(concatLink(url, 'love/connect'), { method: 'POST', headers: { diff --git a/plugins/ai-bot/src/rest.ts b/plugins/ai-bot/src/rest.ts index d6a71da8a7..226ccafe3c 100644 --- a/plugins/ai-bot/src/rest.ts +++ b/plugins/ai-bot/src/rest.ts @@ -61,7 +61,6 @@ export interface TranslateResponse { export interface ConnectMeetingRequest { roomId: Ref - roomSid: string language: RoomLanguage transcription: boolean } @@ -74,7 +73,6 @@ export interface PostTranscriptRequest { transcript: string participant: Ref roomName: string - final: boolean } export interface IdentityResponse { diff --git a/plugins/love-resources/src/components/RoomPreview.svelte b/plugins/love-resources/src/components/RoomPreview.svelte index 4a5fd4297e..350d01a77a 100644 --- a/plugins/love-resources/src/components/RoomPreview.svelte +++ b/plugins/love-resources/src/components/RoomPreview.svelte @@ -67,11 +67,9 @@ const client = getClient() const hierarchy = client.getHierarchy() if ($isConnected && $currentRoom?._id === room._id) { - const sid = await lk.getSid() let meeting = $currentMeetingMinutes - if (meeting?.sid !== sid || meeting?.attachedTo !== room._id || meeting?.status !== MeetingStatus.Active) { + if (meeting?.attachedTo !== room._id || meeting?.status !== MeetingStatus.Active) { meeting = await client.findOne(love.class.MeetingMinutes, { - sid, attachedTo: room._id, status: MeetingStatus.Active }) diff --git a/plugins/love-resources/src/components/widget/MeetingWidget.svelte b/plugins/love-resources/src/components/widget/MeetingWidget.svelte index efb67682a8..36bb107edb 100644 --- a/plugins/love-resources/src/components/widget/MeetingWidget.svelte +++ b/plugins/love-resources/src/components/widget/MeetingWidget.svelte @@ -20,7 +20,7 @@ import love from '../../plugin' import VideoTab from './VideoTab.svelte' - import { isCurrentInstanceConnected, lk } from '../../utils' + import { isCurrentInstanceConnected } from '../../utils' import { currentMeetingMinutes, currentRoom } from '../../stores' import ChatTab from './ChatTab.svelte' import TranscriptionTab from './TranscriptionTab.svelte' @@ -35,14 +35,9 @@ let isMeetingMinutesLoaded = false let room: Room | undefined = undefined - let sid: string | undefined = undefined $: room = $currentRoom - void lk.getSid().then((res) => { - sid = res - }) - $: if ( !$isCurrentInstanceConnected || widgetState?.data?.room === undefined || @@ -52,15 +47,10 @@ closeWidget(love.ids.MeetingWidget) } - $: if (meetingMinutes?.sid !== sid) { - meetingMinutes = undefined - isMeetingMinutesLoaded = false - } - - $: if (sid != null && room !== undefined) { + $: if (room !== undefined) { meetingQuery.query( love.class.MeetingMinutes, - { sid, attachedTo: room._id, status: MeetingStatus.Active }, + { attachedTo: room._id, status: MeetingStatus.Active }, async (res) => { meetingMinutes = res[0] if (meetingMinutes) { @@ -72,7 +62,7 @@ } else { meetingQuery.unsubscribe() meetingMinutes = undefined - isMeetingMinutesLoaded = sid !== undefined + isMeetingMinutesLoaded = false } function handleClose (): void { diff --git a/plugins/love-resources/src/utils.ts b/plugins/love-resources/src/utils.ts index dd02b19c42..1c3681f244 100644 --- a/plugins/love-resources/src/utils.ts +++ b/plugins/love-resources/src/utils.ts @@ -414,6 +414,13 @@ async function initRoomMetadata (metadata: string | undefined): Promise { isTranscription.set(data.transcription === TranscriptionStatus.InProgress) const room = get(currentRoom) + const meetingMinutes = get(currentMeetingMinutes) + const isValidMeeting = + meetingMinutes != null && meetingMinutes.attachedTo === room?._id && meetingMinutes.status === MeetingStatus.Active + + if (room != null && !isValidMeeting) { + await initMeetingMinutes(room) + } if ( (data.transcription == null || data.transcription === TranscriptionStatus.Idle) && @@ -422,7 +429,7 @@ async function initRoomMetadata (metadata: string | undefined): Promise { await startTranscription(room) } - if (data.recording == null && room?.startWithRecording === true) { + if (get(isRecordingAvailable) && data.recording == null && room?.startWithRecording === true) { await record(room) } } @@ -657,11 +664,9 @@ async function navigateToOfficeDoc (hierarchy: Hierarchy, object: Doc): Promise< navigate(loc) } -async function openMeetingMinutes (room: Room): Promise { +async function initMeetingMinutes (room: Room): Promise { const client = getClient() - const sid = await lk.getSid() const doc = await client.findOne(love.class.MeetingMinutes, { - sid, attachedTo: room._id, status: MeetingStatus.Active }) @@ -682,7 +687,6 @@ async function openMeetingMinutes (room: Room): Promise { const newDoc: MeetingMinutes = { _id, _class: love.class.MeetingMinutes, - sid, attachedTo: room._id, attachedToClass: room._class, collection: 'meetings', @@ -699,7 +703,7 @@ async function openMeetingMinutes (room: Room): Promise { room._id, room._class, 'meetings', - { sid, title: newDoc.title, description: newDoc.description, status: newDoc.status }, + { title: newDoc.title, description: newDoc.description, status: newDoc.status }, _id ) currentMeetingMinutes.set(newDoc) @@ -734,7 +738,7 @@ export async function connectRoom ( 3, 1000 ) - await openMeetingMinutes(room) + await initMeetingMinutes(room) } catch (err) { console.error(err) await leaveRoom(currentInfo, get(myOffice)) @@ -993,8 +997,7 @@ export async function startTranscription (room: Room): Promise { const current = get(currentRoom) if (current === undefined || room._id !== current._id) return - const sid = await lk.getSid() - await connectMeeting(room._id, sid, room.language, { transcription: true }) + await connectMeeting(room._id, room.language, { transcription: true }) } export async function stopTranscription (room: Room): Promise { diff --git a/plugins/love/src/index.ts b/plugins/love/src/index.ts index b873db4e22..33c4e1790b 100644 --- a/plugins/love/src/index.ts +++ b/plugins/love/src/index.ts @@ -166,8 +166,6 @@ export enum MeetingStatus { } export interface MeetingMinutes extends AttachedDoc { - sid: string - title: string description: MarkupBlobRef | null diff --git a/services/ai-bot/love-agent/esbuild.config.js b/services/ai-bot/love-agent/esbuild.config.js index da1b7778ab..bd1cd8d505 100644 --- a/services/ai-bot/love-agent/esbuild.config.js +++ b/services/ai-bot/love-agent/esbuild.config.js @@ -1,7 +1,7 @@ import esbuild from 'esbuild'; await esbuild.build({ - entryPoints: ['src/index.ts', 'src/start.ts', 'src/config.ts', 'src/agent.ts', 'src/stt.ts', 'src/type.ts'], + entryPoints: ['src/**/*.ts'], platform: 'node', bundle: false, minify: false, diff --git a/services/ai-bot/love-agent/package.json b/services/ai-bot/love-agent/package.json index 8e681df53e..a0e1704677 100644 --- a/services/ai-bot/love-agent/package.json +++ b/services/ai-bot/love-agent/package.json @@ -35,8 +35,8 @@ }, "dependencies": { "@deepgram/sdk": "^3.9.0", - "@livekit/agents": "^0.4.1", - "@livekit/rtc-node": "^0.11.1", + "@livekit/agents": "^0.4.6", + "@livekit/rtc-node": "^0.12.1", "dotenv": "^16.4.5" } } \ No newline at end of file diff --git a/services/ai-bot/love-agent/pnpm-lock.yaml b/services/ai-bot/love-agent/pnpm-lock.yaml index 26f981af21..22b867f125 100644 --- a/services/ai-bot/love-agent/pnpm-lock.yaml +++ b/services/ai-bot/love-agent/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: ^3.9.0 version: 3.9.0 '@livekit/agents': - specifier: ^0.4.1 - version: 0.4.1 + specifier: ^0.4.6 + version: 0.4.6(@livekit/rtc-node@0.12.1) '@livekit/rtc-node': - specifier: ^0.11.1 - version: 0.11.1 + specifier: ^0.12.1 + version: 0.12.1 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -243,8 +243,10 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@livekit/agents@0.4.1': - resolution: {integrity: sha512-zZnd19CWvm1i6PKzAgUw6gLdZOJ/QKzbIVSLNAZPQphCEEg3cPoe3fEf9XE7o9+n6e5sZ6FfmSUh/c7jxWmujw==} + '@livekit/agents@0.4.6': + resolution: {integrity: sha512-SVA6tW3o1ZVdQ0fGWwie+mMB4jkcNZzI0iYrOb0L9lDs9vW4C/4YlVtp8bOQSCxisG8HsCy+beVKbMTd0Mx9Fg==} + peerDependencies: + '@livekit/rtc-node': ^0.11.1 '@livekit/mutex@1.1.0': resolution: {integrity: sha512-XRLG+z/0uoyDioupjUiskjI06Y51U/IXVPJn7qJ+R3J75XX01irYVBM9MpxeJahpVoe9QhU4moIEolX+HO9U9g==} @@ -252,38 +254,38 @@ packages: '@livekit/protocol@1.27.1': resolution: {integrity: sha512-ISEp7uWdV82mtCR1eyHFTzdRZTVbe2+ZztjmjiMPzR/KPrI1Ma/u5kLh87NNuY3Rn8wv1VlEvGHHsFjQ+dKVUw==} - '@livekit/rtc-node-darwin-arm64@0.11.1': - resolution: {integrity: sha512-M+Ui87H06ae19GGI7r937dS6hI84MBBTQAkkNlL7qd+pvdCAk25u0FYa8r4SOElKJ0VR3AbzeDoXTihLgpvjMg==} + '@livekit/rtc-node-darwin-arm64@0.12.1': + resolution: {integrity: sha512-GF+QnRp7yBK4AyeG3eZiuAzbQOH4+bkLSyTgb5A1CzGEca2XD5CSgKUeGtIgIglq0XmYzN6bvl260xUWPl0WCQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@livekit/rtc-node-darwin-x64@0.11.1': - resolution: {integrity: sha512-7G92fyuK2p+jdTH2cUJTNeAmtknTGsXuy0xbI727V7VzQvHFDXULCExRlgwn4t9TxvNlIjUpiltiQ6RCSai6zw==} + '@livekit/rtc-node-darwin-x64@0.12.1': + resolution: {integrity: sha512-uORO+/W5jvCfuFcmzQ4q5WCgJX5EW7eUxgfUHSaIuzcwgxBxG4yagWDl9WkK6MY9V1j6fhAiNlJ3hPl8cYGUjA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@livekit/rtc-node-linux-arm64-gnu@0.11.1': - resolution: {integrity: sha512-vqZN9+87Pvxit7auYVW69M+GvUPnf+EwipIJ92GgCJA3Ir1Tcceu5ud5/Ic+0FzSoV0cotVVlQNm74F0tQvyCg==} + '@livekit/rtc-node-linux-arm64-gnu@0.12.1': + resolution: {integrity: sha512-8LIHq0nSwCjUMVPNfrohupH2ynhEFcYmidYa1KmHLkVlW+gyXBfy+QE7voAbXhTSbLZLDX58N5KGCQa61LSVAA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@livekit/rtc-node-linux-x64-gnu@0.11.1': - resolution: {integrity: sha512-smHZUMfgILQh6/eoauYNe/VlKwQCp4/4jWxiIADHY+mtDtVSvQ9zB6y4GP8FrpohRwFWesKCUpvPBypU0Icrng==} + '@livekit/rtc-node-linux-x64-gnu@0.12.1': + resolution: {integrity: sha512-8x5IKzcTmwGyy0I0CU5vzealCl7c0Fp4KMBakq/uUOavbh/Rs1nAsjT8flrXC09F4njXD1E9uYN6CMTN9uZeKw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@livekit/rtc-node-win32-x64-msvc@0.11.1': - resolution: {integrity: sha512-bTWVtb+UiRPFjiuhrqq40gt5vs5mMPTa1e+kd2jGQPTOlKZPLArQ0WgFcep2TAy1zmcpOgfeM1XRPVFhZl7G1A==} + '@livekit/rtc-node-win32-x64-msvc@0.12.1': + resolution: {integrity: sha512-zPbvUnpHdDlkEW3AN8wIqa3Wwae26MV/gQmqmP+3AjZRog8hMZy9Fe48lJgZR6VobjFjw99wSgIytYZCdIOUqQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@livekit/rtc-node@0.11.1': - resolution: {integrity: sha512-EFw+giPll12fcXATZpN2zKkE3umYJAdHvfjW+Yu0aBjwfxbUBXu8rz6le2CzDNvGmRwR888DSZXFZfYikwZgiw==} + '@livekit/rtc-node@0.12.1': + resolution: {integrity: sha512-VNRobdI/CmZ8mVUV105uqFOH7BMDmK8VsNq4GrpWAdXKnQMVKoAjLmvFU2LVQMKP4qE9BG3aTi59BGYt/PCLiA==} engines: {node: '>= 18'} '@livekit/typed-emitter@3.0.0': @@ -1556,11 +1558,11 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} - '@livekit/agents@0.4.1': + '@livekit/agents@0.4.6(@livekit/rtc-node@0.12.1)': dependencies: '@livekit/mutex': 1.1.0 '@livekit/protocol': 1.27.1 - '@livekit/rtc-node': 0.11.1 + '@livekit/rtc-node': 0.12.1 '@livekit/typed-emitter': 3.0.0 commander: 12.1.0 livekit-server-sdk: 2.8.1 @@ -1578,32 +1580,32 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.0 - '@livekit/rtc-node-darwin-arm64@0.11.1': + '@livekit/rtc-node-darwin-arm64@0.12.1': optional: true - '@livekit/rtc-node-darwin-x64@0.11.1': + '@livekit/rtc-node-darwin-x64@0.12.1': optional: true - '@livekit/rtc-node-linux-arm64-gnu@0.11.1': + '@livekit/rtc-node-linux-arm64-gnu@0.12.1': optional: true - '@livekit/rtc-node-linux-x64-gnu@0.11.1': + '@livekit/rtc-node-linux-x64-gnu@0.12.1': optional: true - '@livekit/rtc-node-win32-x64-msvc@0.11.1': + '@livekit/rtc-node-win32-x64-msvc@0.12.1': optional: true - '@livekit/rtc-node@0.11.1': + '@livekit/rtc-node@0.12.1': dependencies: '@bufbuild/protobuf': 2.2.2 '@livekit/mutex': 1.1.0 '@livekit/typed-emitter': 3.0.0 optionalDependencies: - '@livekit/rtc-node-darwin-arm64': 0.11.1 - '@livekit/rtc-node-darwin-x64': 0.11.1 - '@livekit/rtc-node-linux-arm64-gnu': 0.11.1 - '@livekit/rtc-node-linux-x64-gnu': 0.11.1 - '@livekit/rtc-node-win32-x64-msvc': 0.11.1 + '@livekit/rtc-node-darwin-arm64': 0.12.1 + '@livekit/rtc-node-darwin-x64': 0.12.1 + '@livekit/rtc-node-linux-arm64-gnu': 0.12.1 + '@livekit/rtc-node-linux-x64-gnu': 0.12.1 + '@livekit/rtc-node-win32-x64-msvc': 0.12.1 '@livekit/typed-emitter@3.0.0': {} diff --git a/services/ai-bot/love-agent/src/agent.ts b/services/ai-bot/love-agent/src/agent.ts index e442c2bddb..b5192b694b 100644 --- a/services/ai-bot/love-agent/src/agent.ts +++ b/services/ai-bot/love-agent/src/agent.ts @@ -17,7 +17,7 @@ import { cli, defineAgent, type JobContext, JobRequest, WorkerOptions } from '@l import { fileURLToPath } from 'node:url' import { RemoteParticipant, RemoteTrack, RemoteTrackPublication, RoomEvent, TrackKind } from '@livekit/rtc-node' -import { STT } from './stt.js' +import { STT } from './deepgram/stt.js' import { Metadata, TranscriptionStatus } from './type.js' import config from './config.js' diff --git a/services/ai-bot/love-agent/src/stt.ts b/services/ai-bot/love-agent/src/deepgram/stt.ts similarity index 95% rename from services/ai-bot/love-agent/src/stt.ts rename to services/ai-bot/love-agent/src/deepgram/stt.ts index 02ee5aae86..576a48644d 100644 --- a/services/ai-bot/love-agent/src/stt.ts +++ b/services/ai-bot/love-agent/src/deepgram/stt.ts @@ -24,7 +24,7 @@ import { SOCKET_STATES } from '@deepgram/sdk' -import config from './config.js' +import config from '../config.js' const KEEP_ALIVE_INTERVAL = 10 * 1000 @@ -165,9 +165,9 @@ export class STT { } if (data.speech_final === true) { - void this.sendToPlatform(transcript, sid, true) + void this.sendToPlatform(transcript, sid) } else if (data.is_final === true) { - void this.sendToPlatform(transcript, sid, false) + void this.sendToPlatform(transcript, sid) } }) @@ -199,12 +199,11 @@ export class STT { } } - async sendToPlatform (transcript: string, sid: string, isFinal = false): Promise { + async sendToPlatform (transcript: string, sid: string): Promise { const request = { transcript, participant: this.participantBySid.get(sid)?.identity, - roomName: this.name, - final: isFinal + roomName: this.name } try { diff --git a/services/ai-bot/pod-ai-bot/src/controller.ts b/services/ai-bot/pod-ai-bot/src/controller.ts index 90b2a0b73c..a8f4ecf310 100644 --- a/services/ai-bot/pod-ai-bot/src/controller.ts +++ b/services/ai-bot/pod-ai-bot/src/controller.ts @@ -308,6 +308,6 @@ export class AIControl { const wsClient = await this.getWorkspaceClient(workspace) if (wsClient === undefined) return - await wsClient.processLoveTranscript(request.transcript, request.participant, roomId as Ref, request.final) + await wsClient.processLoveTranscript(request.transcript, request.participant, roomId as Ref) } } diff --git a/services/ai-bot/pod-ai-bot/src/workspace/love.ts b/services/ai-bot/pod-ai-bot/src/workspace/love.ts index 78c414ab79..8ec68e30d8 100644 --- a/services/ai-bot/pod-ai-bot/src/workspace/love.ts +++ b/services/ai-bot/pod-ai-bot/src/workspace/love.ts @@ -1,3 +1,18 @@ +// +// 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. +// + import { ConnectMeetingRequest } from '@hcengineering/ai-bot' import chunter from '@hcengineering/chunter' import { Person } from '@hcengineering/contact' @@ -27,7 +42,6 @@ import { jsonToMarkup, MarkupNodeType } from '@hcengineering/text' import config from '../config' export class LoveController { - private readonly roomSidById = new Map, string>() private readonly connectedRooms = new Set>() private participantsInfo: ParticipantInfo[] = [] @@ -44,7 +58,7 @@ export class LoveController { void this.initData() setInterval(() => { void this.checkConnection() - }, 5000) + }, 10 * 1000) } getIdentity (): { identity: Ref, name: string } { @@ -119,12 +133,10 @@ export class LoveController { if (room === undefined) { this.ctx.error('Room not found', request) - this.roomSidById.delete(request.roomId) this.connectedRooms.delete(request.roomId) return } - this.roomSidById.set(request.roomId, request.roomSid) this.connectedRooms.add(request.roomId) this.ctx.info('Connecting', { room: room.name, roomId: room._id }) @@ -155,11 +167,10 @@ export class LoveController { await stopTranscription(this.token, getTokenRoomName(this.workspace, room.name, room._id), room.name) } - this.roomSidById.delete(roomId) this.connectedRooms.delete(roomId) } - async processTranscript (text: string, person: Ref, roomId: Ref, final: boolean): Promise { + async processTranscript (text: string, person: Ref, roomId: Ref): Promise { const room = await this.getRoom(roomId) const participant = await this.getRoomParticipant(roomId, person) @@ -167,14 +178,8 @@ export class LoveController { return } - const sid = this.roomSidById.get(roomId) - - if (sid === undefined) { - return - } - const personAccount = this.client.getModel().getAccountByPersonId(participant.person)[0] - const doc = await this.getMeetingMinutes(room, sid) + const doc = await this.getMeetingMinutes(room) if (doc === undefined) return const op = this.client.apply(undefined, undefined, true) @@ -210,13 +215,10 @@ export class LoveController { ) } - async getMeetingMinutes (room: Room, sid: string): Promise { - if (sid === '') return undefined - + async getMeetingMinutes (room: Room): Promise { const doc = - this.meetingMinutes.find( - (m) => m.sid === sid && m.attachedTo === room._id && m.status === MeetingStatus.Active - ) ?? (await this.client.findOne(love.class.MeetingMinutes, { sid, room: room._id, status: MeetingStatus.Active })) + this.meetingMinutes.find((m) => m.attachedTo === room._id && m.status === MeetingStatus.Active) ?? + (await this.client.findOne(love.class.MeetingMinutes, { attachedTo: room._id, status: MeetingStatus.Active })) if (doc === undefined) { return undefined diff --git a/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts b/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts index ee8fe6ee61..78f7bf21d0 100644 --- a/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts +++ b/services/ai-bot/pod-ai-bot/src/workspace/workspaceClient.ts @@ -709,7 +709,7 @@ export class WorkspaceClient { await this.love.disconnect(request.roomId) } - async processLoveTranscript (text: string, participant: Ref, room: Ref, final: boolean): Promise { + async processLoveTranscript (text: string, participant: Ref, room: Ref): Promise { // Just wait initialization await this.opClient @@ -718,7 +718,7 @@ export class WorkspaceClient { return } - await this.love.processTranscript(text, participant, room, final) + await this.love.processTranscript(text, participant, room) } async getLoveIdentity (): Promise { diff --git a/services/love/src/main.ts b/services/love/src/main.ts index 67d17603c9..a2d36dca80 100644 --- a/services/love/src/main.ts +++ b/services/love/src/main.ts @@ -86,7 +86,7 @@ export const main = async (): Promise => { const filename = stripPrefix(prefix, res.filename) const storedBlob = await storageAdapter.stat(ctx, data.workspaceId, filename) if (storedBlob !== undefined) { - const client = await WorkspaceClient.create(data.workspace) + const client = await WorkspaceClient.create(data.workspace, ctx) await client.saveFile(filename, data.name, storedBlob, data.meetingMinutes) await client.close() } @@ -137,6 +137,7 @@ export const main = async (): Promise => { const name = `${room}_${dateStr}.mp4` const id = await startRecord(storageConfig, egressClient, roomClient, roomName, workspace) dataByUUID.set(id, { name, workspace: workspace.name, workspaceId: workspace, meetingMinutes }) + ctx.info('Start recording', { workspace: workspace.name, roomName, meetingMinutes }) res.send() } catch (e) { console.error(e) diff --git a/services/love/src/workspaceClient.ts b/services/love/src/workspaceClient.ts index f8293d7735..4daf1bc42b 100644 --- a/services/love/src/workspaceClient.ts +++ b/services/love/src/workspaceClient.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import core, { Client, Ref, TxOperations, type Blob, Data } from '@hcengineering/core' +import core, { Client, Ref, TxOperations, type Blob, Data, MeasureContext } from '@hcengineering/core' import drive, { createFile } from '@hcengineering/drive' import love, { MeetingMinutes } from '@hcengineering/love' import { generateToken } from '@hcengineering/server-token' @@ -23,10 +23,13 @@ import config from './config' export class WorkspaceClient { private client!: TxOperations - private constructor (private readonly workspace: string) {} + private constructor ( + private readonly workspace: string, + private readonly ctx: MeasureContext + ) {} - static async create (workspace: string): Promise { - const instance = new WorkspaceClient(workspace) + static async create (workspace: string, ctx: MeasureContext): Promise { + const instance = new WorkspaceClient(workspace, ctx) await instance.initClient(workspace) return instance } @@ -43,6 +46,7 @@ export class WorkspaceClient { } async saveFile (uuid: string, name: string, blob: Blob, meetingMinutes?: Ref): Promise { + this.ctx.info('Save recording', { workspace: this.workspace, meetingMinutes }) const current = await this.client.findOne(drive.class.Drive, { _id: love.space.Drive }) if (current === undefined) { await this.client.createDoc( @@ -83,7 +87,10 @@ export class WorkspaceClient { if (ref === undefined) return const meeting = await this.client.findOne(love.class.MeetingMinutes, { _id: ref }) - if (meeting === undefined) return + if (meeting === undefined) { + this.ctx.error('Meeting not found', { _id: ref }) + return + } await this.client.addCollection( attachment.class.Attachment, From 8b0fa35e81268bb36f62586a505996c4763706cd Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Fri, 29 Nov 2024 23:16:14 +0700 Subject: [PATCH 07/10] Redesign test run button (#7241) Signed-off-by: Artem Savchenko --- models/test-management/src/index.ts | 10 +-- models/test-management/src/plugin.ts | 2 +- .../test-management-assets/assets/icons.svg | 8 +- plugins/test-management-assets/lang/cs.json | 5 +- plugins/test-management-assets/lang/en.json | 5 +- plugins/test-management-assets/lang/es.json | 5 +- plugins/test-management-assets/lang/fr.json | 5 +- plugins/test-management-assets/lang/it.json | 5 +- plugins/test-management-assets/lang/pt.json | 5 +- plugins/test-management-assets/lang/ru.json | 5 +- plugins/test-management-assets/lang/zh.json | 5 +- plugins/test-management-assets/src/index.ts | 3 +- .../src/components/test-case/RunButton.svelte | 46 ++++++++++ .../test-case/RunSelectedTestsButton.svelte | 88 ------------------- .../test-management-resources/src/index.ts | 4 +- .../test-management-resources/src/utils.ts | 3 +- plugins/test-management/src/plugin.ts | 8 +- 17 files changed, 68 insertions(+), 144 deletions(-) create mode 100644 plugins/test-management-resources/src/components/test-case/RunButton.svelte delete mode 100644 plugins/test-management-resources/src/components/test-case/RunSelectedTestsButton.svelte diff --git a/models/test-management/src/index.ts b/models/test-management/src/index.ts index c75cb12f7d..f461dd558d 100644 --- a/models/test-management/src/index.ts +++ b/models/test-management/src/index.ts @@ -73,9 +73,7 @@ function defineApplication (builder: Builder): void { componentProps: { _class: testManagement.class.TestCase, icon: testManagement.icon.TestLibrary, - label: testManagement.string.TestLibrary, - createLabel: testManagement.string.CreateTestCase, - createComponent: testManagement.component.CreateTestCase + label: testManagement.string.TestLibrary }, navigationModel: { navigationComponent: view.component.FoldersBrowser, @@ -84,7 +82,7 @@ function defineApplication (builder: Builder): void { mainComponentLabel: testManagement.string.TestCases, mainComponentIcon: testManagement.icon.TestCases, createComponent: testManagement.component.CreateTestSuite, - mainHeaderComponent: testManagement.component.RunSelectedTestsButton, + mainHeaderComponent: testManagement.component.RunButton, navigationComponentProps: { _class: testManagement.class.TestSuite, icon: testManagement.icon.TestSuites, @@ -288,8 +286,8 @@ function defineTestSuite (builder: Builder): void { builder, { action: testManagement.actionImpl.RunSelectedTests, - label: testManagement.string.CreateTestRun, - icon: testManagement.icon.TestRuns, + label: testManagement.string.RunTestCases, + icon: testManagement.icon.Run, category: testManagement.category.TestCase, input: 'selection', target: testManagement.class.TestCase, diff --git a/models/test-management/src/plugin.ts b/models/test-management/src/plugin.ts index 79e2e7c588..0bf91802e5 100644 --- a/models/test-management/src/plugin.ts +++ b/models/test-management/src/plugin.ts @@ -37,7 +37,7 @@ export default mergeIds(testManagementId, testManganement, { TestRunPresenter: '' as AnyComponent, EditTestRun: '' as AnyComponent, TestSuiteRefPresenter: '' as AnyComponent, - RunSelectedTestsButton: '' as AnyComponent, + RunButton: '' as AnyComponent, TestResultPresenter: '' as AnyComponent, EditTestResult: '' as AnyComponent, TestResultFooter: '' as AnyComponent diff --git a/plugins/test-management-assets/assets/icons.svg b/plugins/test-management-assets/assets/icons.svg index 310dd6a509..7a4cde62a4 100644 --- a/plugins/test-management-assets/assets/icons.svg +++ b/plugins/test-management-assets/assets/icons.svg @@ -61,11 +61,7 @@ - - - - - - + + diff --git a/plugins/test-management-assets/lang/cs.json b/plugins/test-management-assets/lang/cs.json index c3fcb8950a..4cc8c2d40d 100644 --- a/plugins/test-management-assets/lang/cs.json +++ b/plugins/test-management-assets/lang/cs.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Název sady", "DescriptionPlaceholder": "Popis (volitelné)", "TestRuns": "Testovací běhy", - "NewTestRun": "Nový testovací běh", "TestRun": "Testovací běh", "TestNamePlaceholder": "Název testovacího případu", "ChooseIcon": "Vyberte ikonu", @@ -76,9 +75,7 @@ "TestResults": "Výsledky", "TestRunName": "Název", "DueDate": "Termín", - "RunAllTestCases": "Spustit všechny testovací případy", - "RunSelectedTestCases": "Spustit vybrané testovací případy", - "RunFilteredTestCases": "Spustit testovací případy odpovídající filtru", + "RunTestCases": "Spustit", "TestCaseDescription": "Popis testovacího případu", "TestResultAttributes": "Výsledek" } diff --git a/plugins/test-management-assets/lang/en.json b/plugins/test-management-assets/lang/en.json index 8db61b86e5..11a3feb1a3 100644 --- a/plugins/test-management-assets/lang/en.json +++ b/plugins/test-management-assets/lang/en.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Suite name", "DescriptionPlaceholder": "Description (optional)", "TestRuns": "Test runs", - "NewTestRun": "New test run", "TestRun": "Test run", "TestNamePlaceholder": "Test case title", "ChooseIcon": "Choose icon", @@ -76,9 +75,7 @@ "TestResults": "Results", "TestRunName": "Name", "DueDate": "Due date", - "RunAllTestCases": "Run all test cases", - "RunSelectedTestCases": "Run selected test cases", - "RunFilteredTestCases": "Run test cases matching a filter", + "RunTestCases": "Run", "TestCaseDescription": "Test case description", "TestResultAttributes": "Result" } diff --git a/plugins/test-management-assets/lang/es.json b/plugins/test-management-assets/lang/es.json index 2017de36c7..0bbdac565e 100644 --- a/plugins/test-management-assets/lang/es.json +++ b/plugins/test-management-assets/lang/es.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Nombre de la suite", "DescriptionPlaceholder": "Descripción (opcional)", "TestRuns": "Ejecuciones de prueba", - "NewTestRun": "Nueva ejecución de prueba", "TestRun": "Ejecución de prueba", "TestNamePlaceholder": "Título del caso de prueba", "ChooseIcon": "Elegir ícono", @@ -76,9 +75,7 @@ "TestResults": "Resultados", "TestRunName": "Nombre", "DueDate": "Fecha de vencimiento", - "RunAllTestCases": "Ejecutar todos los casos de prueba", - "RunSelectedTestCases": "Ejecutar casos de prueba seleccionados", - "RunFilteredTestCases": "Ejecutar casos de prueba que coincidan con un filtro", + "RunTestCases": "Ejecutar", "TestCaseDescription": "Descripción del caso de prueba", "TestResultAttributes": "Resultado" } diff --git a/plugins/test-management-assets/lang/fr.json b/plugins/test-management-assets/lang/fr.json index f9b85b94f8..3fdb3a50fb 100644 --- a/plugins/test-management-assets/lang/fr.json +++ b/plugins/test-management-assets/lang/fr.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Nom de la suite", "DescriptionPlaceholder": "Description (facultatif)", "TestRuns": "Exécutions de tests", - "NewTestRun": "Nouveau test", "TestRun": "Exécution d'essai", "TestNamePlaceholder": "Titre du scénario de test", "ChooseIcon": "Choisir l'icône", @@ -76,9 +75,7 @@ "TestResults": "Résultats", "TestRunName": "Nom", "DueDate": "Date d'échéance", - "RunAllTestCases": "Exécuter tous les cas de test", - "RunSelectedTestCases": "Exécuter des scénarios de test sélectionnés", - "RunFilteredTestCases": "Exécuter des cas de test correspondant à un filtre", + "RunTestCases": "Exécuter", "TestCaseDescription": "Description du cas de test", "TestResultAttributes": "Résultat" } diff --git a/plugins/test-management-assets/lang/it.json b/plugins/test-management-assets/lang/it.json index 88acb35da4..8751272028 100644 --- a/plugins/test-management-assets/lang/it.json +++ b/plugins/test-management-assets/lang/it.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Nome della suite", "DescriptionPlaceholder": "Descrizione (opzionale)", "TestRuns": "Esecuzioni di test", - "NewTestRun": "Nuova esecuzione di test", "TestRun": "Esecuzione di test", "TestNamePlaceholder": "Titolo del caso di test", "ChooseIcon": "Scegli icona", @@ -76,9 +75,7 @@ "TestResults": "Risultati", "TestRunName": "Nome", "DueDate": "Data di scadenza", - "RunAllTestCases": "Esegui tutti i casi di test", - "RunSelectedTestCases": "Esegui i casi di test selezionati", - "RunFilteredTestCases": "Esegui i casi di test che corrispondono a un filtro", + "RunTestCases": "Esegui", "TestCaseDescription": "Descrizione del caso di test", "TestResultAttributes": "Risultato" } diff --git a/plugins/test-management-assets/lang/pt.json b/plugins/test-management-assets/lang/pt.json index 2299b3cb58..5f34b98868 100644 --- a/plugins/test-management-assets/lang/pt.json +++ b/plugins/test-management-assets/lang/pt.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Nome da suite", "DescriptionPlaceholder": "Descrição (opcional)", "TestRuns": "Execuções de teste", - "NewTestRun": "Nova execução de teste", "TestRun": "Execução de teste", "TestNamePlaceholder": "Título do caso de teste", "ChooseIcon": "Escolher ícone", @@ -76,9 +75,7 @@ "TestResults": "Resultados", "TestRunName": "Nome", "DueDate": "Data de vencimento", - "RunAllTestCases": "Executar todos os casos de teste", - "RunSelectedTestCases": "Executar casos de teste selecionados", - "RunFilteredTestCases": "Executar casos de teste que correspondem a um filtro", + "RunTestCases": "Executar", "TestCaseDescription": "Descrição do caso de teste", "TestResultAttributes": "Resultado" } diff --git a/plugins/test-management-assets/lang/ru.json b/plugins/test-management-assets/lang/ru.json index b94ff588d2..9917ba97ca 100644 --- a/plugins/test-management-assets/lang/ru.json +++ b/plugins/test-management-assets/lang/ru.json @@ -40,7 +40,6 @@ "NamePlaceholder": "Имя тестового набора", "DescriptionPlaceholder": "Описание (опционально)", "TestRuns": "Выполнение тестов", - "NewTestRun": "Новый тест план", "TestRun": "Тест план", "TestNamePlaceholder": "Имя тест кейса", "ChooseIcon": "Выберите иконку", @@ -76,9 +75,7 @@ "TestResults": "Результаты", "TestRunName": "Название", "DueDate": "Выполнить до", - "RunAllTestCases": "Выполнить все тест-кейсы", - "RunSelectedTestCases": "Выполнить выбранные тест-кейсы", - "RunFilteredTestCases": "Выполнить тест-кейсы, соответствующие фильтру", + "RunTestCases": "Выполнить", "TestCaseDescription": "Описание тест-кейса", "TestResultAttributes": "Результат" } diff --git a/plugins/test-management-assets/lang/zh.json b/plugins/test-management-assets/lang/zh.json index 784fe300ea..4d1558a7e6 100644 --- a/plugins/test-management-assets/lang/zh.json +++ b/plugins/test-management-assets/lang/zh.json @@ -40,7 +40,6 @@ "NamePlaceholder": "套房名稱", "DescriptionPlaceholder": "說明(可選)", "TestRuns": "試運行", - "NewTestRun": "新試運行", "TestRun": "試運行", "TestNamePlaceholder": "測試用例標題", "ChooseIcon": "選擇圖示", @@ -76,9 +75,7 @@ "TestResults": "結果", "TestRunName": "姓名", "DueDate": "到期日", - "RunAllTestCases": "運行所有測試用例", - "RunSelectedTestCases": "運行選定的測試用例", - "RunFilteredTestCases": "運行與篩選器匹配的測試用例", + "RunTestCases": "运行", "TestCaseDescription": "測試用例描述", "TestResultAttributes": "結果" } diff --git a/plugins/test-management-assets/src/index.ts b/plugins/test-management-assets/src/index.ts index 4192353617..d88f48d5e0 100644 --- a/plugins/test-management-assets/src/index.ts +++ b/plugins/test-management-assets/src/index.ts @@ -42,6 +42,5 @@ loadMetadata(testManagement.icon, { StatusBlocked: `${icons}#status-review-comments`, StatusPassed: `${icons}#status-approved`, StatusFailed: `${icons}#status-canceled`, - Filter: `${icons}#filter`, - Check: `${icons}#check` + Run: `${icons}#run` }) diff --git a/plugins/test-management-resources/src/components/test-case/RunButton.svelte b/plugins/test-management-resources/src/components/test-case/RunButton.svelte new file mode 100644 index 0000000000..5ca698510a --- /dev/null +++ b/plugins/test-management-resources/src/components/test-case/RunButton.svelte @@ -0,0 +1,46 @@ + + + + - {/each} -
+ { + commands = [] + modified = true + }} + /> {/if} {:else} {/if} - - diff --git a/packages/presentation/src/components/DrawingBoardToolbar.svelte b/packages/presentation/src/components/DrawingBoardToolbar.svelte new file mode 100644 index 0000000000..1d5f793615 --- /dev/null +++ b/packages/presentation/src/components/DrawingBoardToolbar.svelte @@ -0,0 +1,250 @@ + + + +
+ + {/each} +
+ +
+
+ + diff --git a/packages/presentation/src/components/FilePreviewPopup.svelte b/packages/presentation/src/components/FilePreviewPopup.svelte index 29015db6c7..de3028c17c 100644 --- a/packages/presentation/src/components/FilePreviewPopup.svelte +++ b/packages/presentation/src/components/FilePreviewPopup.svelte @@ -13,6 +13,7 @@ // limitations under the License. --> + + + + diff --git a/packages/presentation/src/drawing.ts b/packages/presentation/src/drawing.ts index 0332c3e3c5..cc9a97b402 100644 --- a/packages/presentation/src/drawing.ts +++ b/packages/presentation/src/drawing.ts @@ -19,15 +19,22 @@ export interface DrawingData { export interface DrawingProps { readonly: boolean + autoSize?: boolean imageWidth?: number imageHeight?: number - drawingData: DrawingData - drawingTool?: DrawingTool + commandCount?: number + commands: DrawingCmd[] + offset?: Point + tool?: DrawingTool penColor?: string - changed?: (content: string) => void + penWidth?: number + eraserWidth?: number + defaultCursor?: string + cmdAdded?: (cmd: DrawingCmd) => void + panned?: (offset: Point) => void } -interface DrawCmd { +export interface DrawingCmd { lineWidth: number erasing: boolean penColor: string @@ -52,6 +59,7 @@ class DrawState { penWidth = 4 eraserWidth = 30 minLineLength = 6 + center: Point = { x: 0, y: 0 } offset: Point = { x: 0, y: 0 } points: Point[] = [] scale: Point = { x: 1, y: 1 } @@ -71,8 +79,8 @@ class DrawState { addPoint = (mouseX: number, mouseY: number): void => { this.points.push({ - x: mouseX * this.scale.x - this.offset.x, - y: mouseY * this.scale.y - this.offset.y + x: mouseX * this.scale.x - this.offset.x - this.center.x, + y: mouseY * this.scale.y - this.offset.y - this.center.y }) } @@ -86,6 +94,8 @@ class DrawState { this.addPoint(x, y) } const erasing = this.tool === 'erase' + this.ctx.save() + this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y) this.ctx.beginPath() this.ctx.lineCap = 'round' this.ctx.strokeStyle = this.penColor @@ -97,10 +107,13 @@ class DrawState { this.drawSmoothSegment(this.points, this.points.length - 1, lastPoint) this.ctx.stroke() } + this.ctx.restore() }) } - drawCommand = (cmd: DrawCmd): void => { + drawCommand = (cmd: DrawingCmd): void => { + this.ctx.save() + this.ctx.translate(this.offset.x + this.center.x, this.offset.y + this.center.y) this.ctx.beginPath() this.ctx.lineCap = 'round' this.ctx.strokeStyle = cmd.penColor @@ -114,6 +127,7 @@ class DrawState { } this.ctx.stroke() } + this.ctx.restore() } drawPoint = (p: Point, erasing: boolean): void => { @@ -121,7 +135,7 @@ class DrawState { if (!erasing) { // Single point looks too small compared to a line of the same width // So make it a bit biggers - r *= 1.5 + r *= 1 / r + 1 } this.ctx.lineWidth = 0 this.ctx.fillStyle = this.ctx.strokeStyle @@ -152,15 +166,13 @@ class DrawState { } } -export function drawing (node: HTMLElement, props: DrawingProps): any { - if ( - props.imageWidth === undefined || - props.imageHeight === undefined || - node.clientWidth === undefined || - node.clientHeight === undefined - ) { +export function drawing ( + node: HTMLElement, + props: DrawingProps +): { canvas?: HTMLCanvasElement, update?: (props: DrawingProps) => void } { + if (props.autoSize !== true && (props.imageWidth === undefined || props.imageHeight === undefined)) { console.error('Failed to create drawing: image size is not specified') - return + return {} } const canvas = document.createElement('canvas') @@ -169,43 +181,62 @@ export function drawing (node: HTMLElement, props: DrawingProps): any { canvas.style.top = '0' canvas.style.width = '100%' canvas.style.height = '100%' - canvas.width = props.imageWidth - canvas.height = props.imageHeight + canvas.width = props.imageWidth ?? 0 + canvas.height = props.imageHeight ?? 0 node.appendChild(canvas) const ctx = canvas.getContext('2d') if (ctx === null) { console.error('Failed to create drawing: unable to get 2d canvas context') node.removeChild(canvas) - return + return {} } const canvasCursor = document.createElement('div') canvasCursor.style.visibility = 'hidden' canvasCursor.style.position = 'absolute' canvasCursor.style.borderRadius = '50%' + canvasCursor.style.border = 'none' canvasCursor.style.cursor = 'none' canvasCursor.style.pointerEvents = 'none' + canvasCursor.style.left = '50%' + canvasCursor.style.top = '50%' node.appendChild(canvasCursor) let readonly = props.readonly ?? false let prevPos: Point = { x: 0, y: 0 } const draw = new DrawState(ctx) - draw.tool = props.drawingTool ?? 'pan' - draw.penColor = props.penColor ?? 'blue' + draw.tool = props.tool ?? draw.tool + draw.penColor = props.penColor ?? draw.penColor + draw.penWidth = props.penWidth ?? draw.penWidth + draw.eraserWidth = props.eraserWidth ?? draw.eraserWidth updateCanvasCursor() - let commands: DrawCmd[] = [] - let drawingData = props.drawingData - parseData() + let commands = props.commands + let commandCount = props.commandCount ?? 0 + replayCommands({ + offset: { + x: props.offset?.x ?? 0, + y: props.offset?.y ?? 0 + } + }) const resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.target === canvas) { - draw.scale = { - x: canvas.width / entry.contentRect.width, - y: canvas.height / entry.contentRect.height + if (props.autoSize === true) { + draw.scale = { x: 1, y: 1 } + canvas.width = Math.floor(entry.contentRect.width) + canvas.height = Math.floor(entry.contentRect.height) + draw.center.x = canvas.width / 2 + draw.center.y = canvas.height / 2 + replayCommands({ offset: draw.offset }) + } else { + draw.scale = { + x: canvas.width / entry.contentRect.width, + y: canvas.height / entry.contentRect.height + } } } } @@ -255,9 +286,18 @@ export function drawing (node: HTMLElement, props: DrawingProps): any { } } - // TODO: if (draw.tool === 'pan') - // Currently we only show drawing over attached images - // Their sizes are fixed and no pan required + if (draw.on && draw.tool === 'pan') { + requestAnimationFrame(() => { + replayCommands({ + offset: { + x: draw.offset.x + x - prevPos.x, + y: draw.offset.y + y - prevPos.y + } + }) + prevPos = { x, y } + props.panned?.(draw.offset) + }) + } } canvas.onpointerup = (e) => { @@ -288,30 +328,31 @@ export function drawing (node: HTMLElement, props: DrawingProps): any { } function storeCommand (): void { - if (draw.points.length > 1) { + if (draw.points.length > 0) { const erasing = draw.tool === 'erase' - const cmd: DrawCmd = { + const cmd: DrawingCmd = { lineWidth: (erasing ? draw.eraserWidth : draw.penWidth) * draw.lineScale(), erasing, penColor: draw.penColor, points: draw.points } commands.push(cmd) - props.changed?.(JSON.stringify(commands)) + props.cmdAdded?.(cmd) } } function updateCanvasCursor (): void { if (readonly) { canvasCursor.style.visibility = 'hidden' - canvas.style.cursor = 'default' + canvas.style.cursor = props.defaultCursor ?? 'default' } else if (draw.isDrawingTool()) { canvas.style.cursor = 'none' const erasing = draw.tool === 'erase' const w = draw.cursorWidth() canvasCursor.style.background = erasing ? 'none' : draw.penColor - canvasCursor.style.border = erasing ? '1px solid #333' : 'none' - canvasCursor.style.boxShadow = erasing ? '0px 0px 0px 1px #eee inset' : 'none' + canvasCursor.style.boxShadow = erasing + ? '0px 0px 1px 1px white inset, 0px 0px 2px 1px black' + : '0px 0px 3px 0px var(--theme-button-contrast-enabled)' canvasCursor.style.width = `${w}px` canvasCursor.style.height = `${w}px` } else if (draw.tool === 'pan') { @@ -328,43 +369,54 @@ export function drawing (node: HTMLElement, props: DrawingProps): any { draw.offset = { x: 0, y: 0 } } - function replayCommands (): void { - draw.ctx.reset() - for (const cmd of commands) { - draw.drawCommand(cmd) + function replayCommands ({ offset, startIndex }: { offset?: Point, startIndex?: number } = {}): void { + if (startIndex === undefined || startIndex === 0) { + clearCanvas() } - } - - function parseData (): void { - clearCanvas() - if (drawingData.content !== undefined && drawingData.content !== null) { - try { - commands = JSON.parse(drawingData.content) - replayCommands() - } catch (error) { - commands = [] - console.error('Failed to parse drawing content', error) - } - } else { - commands = [] + if (offset !== undefined) { + draw.offset = offset } + for (let i = startIndex ?? 0; i < commands.length; i++) { + draw.drawCommand(commands[i]) + } + commandCount = commands.length } return { + canvas, + update (props: DrawingProps) { - if (drawingData !== props.drawingData) { - drawingData = props.drawingData - parseData() + let replay = false + let offset: Point | undefined + let startIndex: number | undefined + if (props.offset?.x !== draw.offset.x || props.offset?.y !== draw.offset.y) { + offset = props.offset + replay = true + } + if (commands !== props.commands) { + commands = props.commands + replay = true + } else if (props.commandCount !== undefined && props.commandCount !== commandCount) { + startIndex = commandCount + replay = true } let updateCursor = false - if (draw.tool !== props.drawingTool) { - draw.tool = props.drawingTool ?? 'pen' + if (draw.tool !== props.tool) { + draw.tool = props.tool ?? 'pen' updateCursor = true } if (draw.penColor !== props.penColor) { draw.penColor = props.penColor ?? 'blue' updateCursor = true } + if (draw.penWidth !== props.penWidth) { + draw.penWidth = props.penWidth ?? 5 + updateCursor = true + } + if (draw.eraserWidth !== props.eraserWidth) { + draw.eraserWidth = props.eraserWidth ?? 5 + updateCursor = true + } if (props.readonly !== readonly) { readonly = props.readonly ?? false updateCursor = true @@ -372,6 +424,9 @@ export function drawing (node: HTMLElement, props: DrawingProps): any { if (updateCursor) { updateCanvasCursor() } + if (replay) { + replayCommands({ offset, startIndex }) + } } } } diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 547d178bf0..62a913052d 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -49,6 +49,7 @@ export { default as LiteMessageViewer } from './components/LiteMessageViewer.sve export { default as DownloadFileButton } from './components/DownloadFileButton.svelte' export { default as FileTypeIcon } from './components/FileTypeIcon.svelte' export { default as DrawingBoard } from './components/DrawingBoard.svelte' +export { default as DrawingBoardToolbar } from './components/DrawingBoardToolbar.svelte' export { default } from './plugin' export * from './types' export * from './utils' diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 2033fc4067..84c592159e 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -123,7 +123,10 @@ export default plugin(presentationId, { ContentType: '' as IntlString, ContentTypeNotSupported: '' as IntlString, StartDrawing: '' as IntlString, - DrawingHistory: '' as IntlString + DrawingHistory: '' as IntlString, + ColorAdd: '' as IntlString, + ColorRemove: '' as IntlString, + ColorReset: '' as IntlString }, extension: { FilePreviewExtension: '' as ComponentExtensionId, diff --git a/plugins/attachment-resources/src/utils.ts b/plugins/attachment-resources/src/utils.ts index 51feeba355..d48476e0ac 100644 --- a/plugins/attachment-resources/src/utils.ts +++ b/plugins/attachment-resources/src/utils.ts @@ -183,12 +183,10 @@ export function showAttachmentPreviewPopup (value: WithLookup): Popu content: data.content }) const newDrawing = await client.findOne(attachment.class.Drawing, { _id: newId }) - if (newDrawing !== undefined) { - return newDrawing - } else { - console.error('Unable to find just created drawing') - return data + if (newDrawing === undefined) { + throw new Error('Unable to find just created drawing') } + return newDrawing } } diff --git a/plugins/text-editor-assets/lang/cs.json b/plugins/text-editor-assets/lang/cs.json index 56cafd8935..fc482580e8 100644 --- a/plugins/text-editor-assets/lang/cs.json +++ b/plugins/text-editor-assets/lang/cs.json @@ -59,6 +59,7 @@ "Unset": "Zrušit nastavení", "Image": "Obrázek", "SeparatorLine": "Oddělovací čára", - "TodoList": "Akční položka" + "TodoList": "Akční položka", + "DrawingBoard": "Kreslení" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/en.json b/plugins/text-editor-assets/lang/en.json index cbc870e082..2c63a5982d 100644 --- a/plugins/text-editor-assets/lang/en.json +++ b/plugins/text-editor-assets/lang/en.json @@ -59,6 +59,7 @@ "Unset": "Unset", "Image": "Image", "SeparatorLine": "Separator line", - "TodoList": "Action item" + "TodoList": "Action item", + "DrawingBoard": "Drawing board" } } diff --git a/plugins/text-editor-assets/lang/es.json b/plugins/text-editor-assets/lang/es.json index d10e9c46cb..dd2f5d37b7 100644 --- a/plugins/text-editor-assets/lang/es.json +++ b/plugins/text-editor-assets/lang/es.json @@ -57,6 +57,7 @@ "Unset": "Anular", "Image": "Imagen", "SeparatorLine": "Línea de separación", - "TodoList": "Tarea pendiente" + "TodoList": "Tarea pendiente", + "DrawingBoard": "Tablero de dibujos" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/fr.json b/plugins/text-editor-assets/lang/fr.json index 7193686f05..3ae6e1d7f2 100644 --- a/plugins/text-editor-assets/lang/fr.json +++ b/plugins/text-editor-assets/lang/fr.json @@ -57,6 +57,7 @@ "Unset": "Non défini", "Image": "Image", "SeparatorLine": "Ligne de séparation", - "TodoList": "À faire" + "TodoList": "À faire", + "DrawingBoard": "Tableau de dessin" } } \ No newline at end of file diff --git a/plugins/text-editor-assets/lang/pt.json b/plugins/text-editor-assets/lang/pt.json index b6f0b47125..d043b750a3 100644 --- a/plugins/text-editor-assets/lang/pt.json +++ b/plugins/text-editor-assets/lang/pt.json @@ -57,6 +57,7 @@ "Unset": "Anular", "Image": "Imagem", "SeparatorLine": "linha separadora", - "TodoList": "Tarefa pendente" + "TodoList": "Tarefa pendente", + "DrawingBoard": "Quadro de desenho" } } diff --git a/plugins/text-editor-assets/lang/ru.json b/plugins/text-editor-assets/lang/ru.json index f197e3abe5..41aaf9e4c2 100644 --- a/plugins/text-editor-assets/lang/ru.json +++ b/plugins/text-editor-assets/lang/ru.json @@ -59,6 +59,7 @@ "Unset": "Убрать", "Image": "Изображение", "SeparatorLine": "Разделительная линия", - "TodoList": "Действие" + "TodoList": "Действие", + "DrawingBoard": "Доска" } } diff --git a/plugins/text-editor-assets/lang/zh.json b/plugins/text-editor-assets/lang/zh.json index c3def4da63..f2bbe1938e 100644 --- a/plugins/text-editor-assets/lang/zh.json +++ b/plugins/text-editor-assets/lang/zh.json @@ -59,6 +59,7 @@ "Unset": "未设置", "Image": "图片", "SeparatorLine": "分隔线", - "TodoList": "待办" + "TodoList": "待办", + "DrawingBoard": "画板" } } diff --git a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte index dfce9ff788..004afa615d 100644 --- a/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte +++ b/plugins/text-editor-resources/src/components/CollaborativeTextEditor.svelte @@ -30,6 +30,7 @@ import { AnySvelteComponent, Button, + IconScribble, IconSize, Loading, PopupAlignment, @@ -44,7 +45,7 @@ import CollaborationCursor from '@tiptap/extension-collaboration-cursor' import Placeholder from '@tiptap/extension-placeholder' import { createEventDispatcher, getContext, onDestroy, onMount } from 'svelte' - import { Doc as YDoc } from 'yjs' + import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs' import { Completion } from '../Completion' import { deleteAttachment } from '../command/deleteAttachment' @@ -65,6 +66,7 @@ import TextEditorToolbar from './TextEditorToolbar.svelte' import { noSelectionRender, renderCursor } from './editor/collaboration' import { defaultEditorAttributes } from './editor/editorProps' + import { DrawingBoardExtension } from './extension/drawingBoard' import { EmojiExtension } from './extension/emoji' import { FileUploadExtension } from './extension/fileUploadExt' import { ImageUploadExtension } from './extension/imageUploadExt' @@ -265,7 +267,8 @@ { id: 'table', label: textEditor.string.Table, icon: view.icon.Table2 }, { id: 'code-block', label: textEditor.string.CodeBlock, icon: view.icon.CodeBlock }, { id: 'separator-line', label: textEditor.string.SeparatorLine, icon: view.icon.SeparatorLine }, - { id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList } + { id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList }, + { id: 'drawing-board', label: textEditor.string.DrawingBoard, icon: IconScribble as any } ], handleSelect: handleLeftMenuClick }) @@ -358,6 +361,9 @@ case 'separator-line': editor.commands.setHorizontalRule() break + case 'drawing-board': + makeNewDrawingBoard(pos) + break } } @@ -366,6 +372,16 @@ remoteProvider.awareness?.setLocalStateField('lastUpdate', Date.now()) } + function makeNewDrawingBoard (pos: number): void { + const id = generateId() + ydoc.getArray('drawing-board-registry').push([id]) + const drawing = ydoc.getMap(`drawing-board-${id}`) + drawing.set('commands', new YArray()) + drawing.set('props', new YMap()) + editor.commands.insertContentAt(pos, { type: 'drawingBoard', attrs: { id } }) + editor.commands.showDrawingBoardPopup() + } + onMount(async () => { await ph @@ -414,6 +430,7 @@ } }), EmojiExtension, + DrawingBoardExtension.configure({ ydoc }), ...extensions ], parseOptions: { diff --git a/plugins/text-editor-resources/src/components/DrawingBoardPopup.svelte b/plugins/text-editor-resources/src/components/DrawingBoardPopup.svelte new file mode 100644 index 0000000000..75348ad5e2 --- /dev/null +++ b/plugins/text-editor-resources/src/components/DrawingBoardPopup.svelte @@ -0,0 +1,121 @@ + + + +{#if savedCmds !== undefined && savedProps !== undefined} + + { + dispatch('close') + }} + > +
{ + savedCmds.push([cmd]) + }, + panned: (offset) => { + savedProps.set('offset', offset) + } + }} + > + + + + + { + savedCmds.delete(0, savedCmds.length) + savedProps.set('offset', { x: 0, y: 0 }) + }} + /> +
+
+{/if} diff --git a/plugins/text-editor-resources/src/components/extension/drawingBoard.ts b/plugins/text-editor-resources/src/components/extension/drawingBoard.ts new file mode 100644 index 0000000000..c1476599cb --- /dev/null +++ b/plugins/text-editor-resources/src/components/extension/drawingBoard.ts @@ -0,0 +1,276 @@ +// +// 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. +// + +import { type DrawingCmd, type DrawingProps, drawing } from '@hcengineering/presentation' +import { showPopup } from '@hcengineering/ui' +import { type Editor, mergeAttributes, Node } from '@tiptap/core' +import { NodeSelection } from '@tiptap/pm/state' +import type { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs' +import DrawingBoardPopup from '../DrawingBoardPopup.svelte' + +const defaultHeight = 500 +const maxHeight = 1000 +const minHeight = 100 + +export interface DrawingBoardOptions { + ydoc?: YDoc +} + +const scribbleSvg = ` + +` +const chevronSvg = ` + +` + +interface SavedBoard { + commands: YArray + props: YMap +} + +function getSavedBoard (ydoc: YDoc | undefined, id: string): SavedBoard { + const board = ydoc?.getMap(`drawing-board-${id}`) + const commands = board?.get('commands') as YArray + const props = board?.get('props') as YMap + return { commands, props } +} + +function showBoardPopup (board: SavedBoard, editor: Editor): void { + if (board.commands !== undefined && board.props !== undefined) { + showPopup( + DrawingBoardPopup, + { + savedCmds: board.commands, + savedProps: board.props + }, + 'centered', + () => { + editor.commands.focus() + } + ) + } +} + +declare module '@tiptap/core' { + interface Commands { + drawingBoard: { + showDrawingBoardPopup: () => ReturnType + } + } +} + +export const DrawingBoardExtension = Node.create({ + name: 'drawingBoard', + + group: 'block', + + renderHTML ({ HTMLAttributes }) { + return ['div', mergeAttributes({ 'data-type': this.name }, HTMLAttributes)] + }, + + addOptions () { + return { + ydoc: undefined + } + }, + + addAttributes () { + return { + id: { + renderHTML: (attrs) => ({ 'data-id': attrs.id }) + }, + height: { + renderHTML: (attrs) => ({ 'data-height': attrs.height }) + } + } + }, + + addCommands () { + return { + showDrawingBoardPopup: + () => + ({ state }) => { + if (state.selection instanceof NodeSelection) { + const node = state.selection.node + if (node?.type.name === this.name) { + const board = getSavedBoard(this.options.ydoc, node.attrs.id) + showBoardPopup(board, this.editor) + return true + } + } + return false + } + } + }, + + addKeyboardShortcuts () { + return { + Enter: () => this.editor.commands.showDrawingBoardPopup() + } + }, + + addNodeView () { + return ({ node, getPos }) => { + const board = getSavedBoard(this.options.ydoc, node.attrs.id) + if (board.commands === undefined || board.props === undefined) { + return {} + } + + const normalBorder = '1px solid var(--theme-navpanel-border)' + const selectedBorder = '1px solid var(--theme-editbox-focus-border)' + + const dom = document.createElement('div') + dom.id = node.attrs.id + dom.contentEditable = 'false' + dom.style.width = '100%' + dom.style.position = 'relative' + dom.style.height = `${node.attrs.height ?? defaultHeight}px` + dom.style.border = normalBorder + dom.style.borderRadius = 'var(--small-BorderRadius)' + dom.style.backgroundColor = 'var(--theme-navpanel-color)' + dom.ondblclick = () => { + showBoardPopup(board, this.editor) + } + + const drawingProps: DrawingProps = { + readonly: true, + autoSize: true, + commands: board.commands.toArray(), + commandCount: board.commands.length, + offset: board.props.get('offset') + } + + const { canvas, update: updateDrawing } = drawing(dom, drawingProps) + if (canvas === undefined || updateDrawing === undefined) { + return {} + } + dom.appendChild(canvas) + + const listenSavedCommands = (): void => { + let update = false + if (board.commands.length === 0) { + update = true + drawingProps.commands = [] + } else if (board.commands.length > drawingProps.commands.length) { + update = true + for (let i = drawingProps.commands.length; i < board.commands.length; i++) { + drawingProps.commands.push(board.commands.get(i)) + } + } + if (update) { + drawingProps.commandCount = board.commands.length + updateDrawing(drawingProps) + } + } + const listenSavedProps = (): void => { + drawingProps.offset = board.props.get('offset') + updateDrawing(drawingProps) + } + board.commands.observe(listenSavedCommands) + board.props.observe(listenSavedProps) + + const button = document.createElement('div') + button.style.visibility = 'hidden' + button.style.cursor = 'pointer' + button.style.position = 'absolute' + button.style.top = '0.3rem' + button.style.right = '0.3rem' + button.style.width = '2.2rem' + button.style.height = '2.2rem' + button.style.color = 'var(--primary-button-color)' + button.style.backgroundColor = 'var(--primary-button-default)' + button.style.borderRadius = 'var(--extra-small-BorderRadius)' + button.style.border = normalBorder + button.style.display = 'flex' + button.style.alignItems = 'center' + button.style.justifyContent = 'center' + button.innerHTML = scribbleSvg + button.onclick = () => { + showBoardPopup(board, this.editor) + } + dom.appendChild(button) + + const resizer = document.createElement('div') + resizer.style.position = 'absolute' + resizer.style.bottom = '0' + resizer.style.left = 'calc(50% - 4rem)' + resizer.style.width = '8rem' + resizer.style.height = '0.6rem' + resizer.style.cursor = 'row-resize' + resizer.style.color = 'var(--global-on-accent-TextColor)' + resizer.style.backgroundColor = 'var(--global-accent-IconColor)' + resizer.style.border = selectedBorder + resizer.style.display = 'flex' + resizer.style.alignItems = 'center' + resizer.style.justifyContent = 'center' + resizer.style.visibility = 'hidden' + resizer.style.borderTopLeftRadius = 'var(--small-BorderRadius)' + resizer.style.borderTopRightRadius = 'var(--small-BorderRadius)' + resizer.style.borderBottom = 'none' + resizer.style.opacity = '0.5' + resizer.innerHTML = chevronSvg + resizer.onpointerenter = () => { + resizer.style.opacity = '1' + } + resizer.onpointerleave = () => { + resizer.style.opacity = '0.5' + } + resizer.onpointerdown = (e: PointerEvent): void => { + e.preventDefault() + resizer.setPointerCapture(e.pointerId) + const { offsetHeight } = dom + const startY = e.clientY - offsetHeight + let height = node.attrs.height ?? defaultHeight + const onPointerMove = (e: PointerEvent): void => { + e.preventDefault() + height = Math.max(minHeight, e.clientY - startY) + height = Math.min(maxHeight, height) + dom.style.height = `${height}px` + } + const onPointerUp = (e: PointerEvent): void => { + e.preventDefault() + resizer.releasePointerCapture(e.pointerId) + if (typeof getPos === 'function') { + const tr = this.editor.state.tr.setNodeMarkup(getPos(), undefined, { ...node.attrs, height }) + this.editor.view.dispatch(tr) + } + dom.removeEventListener('pointermove', onPointerMove) + dom.removeEventListener('pointerup', onPointerUp) + } + dom.addEventListener('pointermove', onPointerMove) + dom.addEventListener('pointerup', onPointerUp) + } + dom.appendChild(resizer) + + return { + dom, + selectNode: () => { + dom.style.border = selectedBorder + button.style.visibility = 'visible' + resizer.style.visibility = 'visible' + }, + deselectNode: () => { + dom.style.border = normalBorder + button.style.visibility = 'hidden' + resizer.style.visibility = 'hidden' + }, + destroy: () => { + board.commands.unobserve(listenSavedCommands) + board.props.unobserve(listenSavedProps) + } + } + } + } +}) diff --git a/plugins/text-editor-resources/src/components/extensions.ts b/plugins/text-editor-resources/src/components/extensions.ts index 8eada21dbf..c69ab8ef56 100644 --- a/plugins/text-editor-resources/src/components/extensions.ts +++ b/plugins/text-editor-resources/src/components/extensions.ts @@ -16,6 +16,7 @@ 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 { type CompletionOptions } from '../Completion' import MentionList from './MentionList.svelte' @@ -147,7 +148,8 @@ export function inlineCommandsConfig ( { id: 'table', label: textEditor.string.Table, icon: view.icon.Table2 }, { id: 'code-block', label: textEditor.string.CodeBlock, icon: view.icon.CodeBlock }, { id: 'separator-line', label: textEditor.string.SeparatorLine, icon: view.icon.SeparatorLine }, - { id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList } + { id: 'todo-list', label: textEditor.string.TodoList, icon: view.icon.TodoList }, + { id: 'drawing-board', label: textEditor.string.DrawingBoard, icon: IconScribble as any } ].filter(({ id }) => !excludedCommands.includes(id as InlineCommandId)) }, command: ({ editor, range, props }: { editor: Editor, range: Range, props: any }) => { diff --git a/plugins/text-editor/src/plugin.ts b/plugins/text-editor/src/plugin.ts index 66fc794368..93e52d3bcf 100644 --- a/plugins/text-editor/src/plugin.ts +++ b/plugins/text-editor/src/plugin.ts @@ -93,7 +93,8 @@ export default plugin(textEditorId, { Unset: '' as IntlString, Image: '' as IntlString, SeparatorLine: '' as IntlString, - TodoList: '' as IntlString + TodoList: '' as IntlString, + DrawingBoard: '' as IntlString }, icon: { Header1: '' as Asset, From e0f2c87ed36221384406c4d356f5e415c4e920ee Mon Sep 17 00:00:00 2001 From: Artyom Savchenko Date: Mon, 2 Dec 2024 14:04:16 +0700 Subject: [PATCH 09/10] Fix test suite and test run default selection (#7250) Signed-off-by: Artem Savchenko --- .../components/test-run/CreateTestRun.svelte | 2 +- .../src/navigation.ts | 8 +++-- .../components/folders/FoldersBrowser.svelte | 35 +++++++++++-------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/plugins/test-management-resources/src/components/test-run/CreateTestRun.svelte b/plugins/test-management-resources/src/components/test-run/CreateTestRun.svelte index 6b9990529a..4748e32721 100644 --- a/plugins/test-management-resources/src/components/test-run/CreateTestRun.svelte +++ b/plugins/test-management-resources/src/components/test-run/CreateTestRun.svelte @@ -107,7 +107,7 @@ throw new Error('Failed to create test run') } else { Analytics.handleEvent(TestManagementEvents.TestRunCreated, { id }) - navigate(getTestRunsLink(id)) + navigate(getTestRunsLink(space, id)) } } catch (err: any) { console.error(err) diff --git a/plugins/test-management-resources/src/navigation.ts b/plugins/test-management-resources/src/navigation.ts index 7d0ab508eb..00ba0f82b0 100644 --- a/plugins/test-management-resources/src/navigation.ts +++ b/plugins/test-management-resources/src/navigation.ts @@ -79,10 +79,12 @@ export function getTestSuiteIdFromLocation (): Ref { return (location?.query?.[SUITE_KEY] as Ref) ?? testManagement.ids.NoParent } -export function getTestRunsLink (parentDoc: Ref): Location { +export function getTestRunsLink (space: Ref, parentDoc: Ref): Location { const loc = getCurrentResolvedLocation() - loc.path.length = 4 - loc.path[3] = 'testRuns' + loc.path.length = 5 + loc.path[2] = testManagementId + loc.path[3] = space + loc.path[4] = 'testRuns' loc.fragment = undefined loc.query = parentDoc === undefined ? undefined : { attachedTo: parentDoc } diff --git a/plugins/view-resources/src/components/folders/FoldersBrowser.svelte b/plugins/view-resources/src/components/folders/FoldersBrowser.svelte index 9fd06fbd3e..0995e1f6fc 100644 --- a/plugins/view-resources/src/components/folders/FoldersBrowser.svelte +++ b/plugins/view-resources/src/components/folders/FoldersBrowser.svelte @@ -16,7 +16,7 @@