Merge remote-tracking branch 'origin/develop' into staging

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2024-09-24 14:35:12 +07:00
commit 143de3c85e
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
32 changed files with 604 additions and 146 deletions

View File

@ -1139,7 +1139,8 @@ export function devTool (
.option('-m, --move <move>', 'When set to true, the files will be moved, otherwise copied', 'false')
.option('-bl, --blobLimit <blobLimit>', 'A blob size limit in megabytes (default 50mb)', '50')
.option('-c, --concurrency <concurrency>', 'Number of files being processed concurrently', '10')
.action(async (cmd: { workspace: string, move: string, blobLimit: string, concurrency: string }) => {
.option('--disabled', 'Include disabled workspaces', false)
.action(async (cmd: { workspace: string, move: string, blobLimit: string, concurrency: string, disabled: boolean }) => {
const params = {
blobSizeLimitMb: parseInt(cmd.blobLimit),
concurrency: parseInt(cmd.concurrency),
@ -1165,6 +1166,10 @@ export function devTool (
if (cmd.workspace !== '' && workspace.workspace !== cmd.workspace) {
continue
}
if (workspace.disabled === true && !cmd.disabled) {
console.log('ignore disabled workspace', workspace.workspace)
continue
}
console.log('start', workspace.workspace, index, '/', workspaces.length)
await moveFiles(toolCtx, getWorkspaceId(workspace.workspace), exAdapter, params)
@ -1182,7 +1187,8 @@ export function devTool (
program
.command('sync-files')
.option('-w, --workspace <workspace>', 'Selected workspace only', '')
.action(async (cmd: { workspace: string }) => {
.option('--disabled', 'Include disabled workspaces', false)
.action(async (cmd: { workspace: string, disabled: boolean }) => {
const { mongodbUri } = prepareTools()
await withDatabase(mongodbUri, async (db) => {
await withStorage(mongodbUri, async (adapter) => {
@ -1196,13 +1202,22 @@ export function devTool (
workspaces.sort((a, b) => b.lastVisit - a.lastVisit)
for (const workspace of workspaces) {
if (workspace.disabled === true && !cmd.disabled) {
console.log('ignore disabled workspace', workspace.workspace)
continue
}
if (cmd.workspace !== '' && workspace.workspace !== cmd.workspace) {
continue
}
console.log('start', workspace.workspace, index, '/', workspaces.length)
await syncFiles(toolCtx, getWorkspaceId(workspace.workspace), exAdapter)
console.log('done', workspace.workspace)
try {
console.log('start', workspace.workspace, index, '/', workspaces.length)
await syncFiles(toolCtx, getWorkspaceId(workspace.workspace), exAdapter)
console.log('done', workspace.workspace)
} catch (err) {
console.warn('failed to sync files', err)
}
index += 1
}

View File

@ -0,0 +1,45 @@
<!--
// Copyright © 2024 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { type AnySvelteComponent, ButtonIcon, IconSize } from '@hcengineering/ui'
import { Asset, IntlString } from '@hcengineering/platform'
import { ComponentType } from 'svelte'
export let label: IntlString
export let icon: Asset | AnySvelteComponent | ComponentType
export let iconProps: any | undefined = undefined
export let size: IconSize = 'small'
export let action: (ev: MouseEvent) => Promise<void> | void = async () => {}
export let opened: boolean = false
export let dataId: string | undefined = undefined
function onClick (ev: MouseEvent): void {
ev.stopPropagation()
ev.preventDefault()
void action(ev)
}
</script>
<ButtonIcon
{icon}
{iconProps}
iconSize={size}
size="small"
kind="tertiary"
pressed={opened}
{dataId}
tooltip={{ label }}
on:click={onClick}
/>

View File

@ -14,29 +14,21 @@
-->
<script lang="ts">
import { type Attachment } from '@hcengineering/attachment'
import { getEmbeddedLabel, getResource } from '@hcengineering/platform'
import {
import type { WithLookup } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import presentation, {
FilePreviewPopup,
canPreviewFile,
getFileUrl,
getPreviewAlignment,
previewTypes
} from '@hcengineering/presentation'
import {
ActionIcon,
IconMoreH,
IconOpen,
Menu,
Action as UIAction,
closeTooltip,
showPopup,
tooltip
} from '@hcengineering/ui'
import { IconMoreH, IconOpen, Menu, Action as UIAction, closeTooltip, showPopup, tooltip } from '@hcengineering/ui'
import view, { Action } from '@hcengineering/view'
import type { WithLookup } from '@hcengineering/core'
import attachmentPlugin from '../plugin'
import AttachmentAction from './AttachmentAction.svelte'
import FileDownload from './icons/FileDownload.svelte'
import attachmentPlugin from '../plugin'
export let attachment: WithLookup<Attachment>
export let isSaved = false
@ -131,30 +123,37 @@
</script>
<div class="flex">
{#if canPreview}
<AttachmentAction
label={view.string.Open}
icon={IconOpen}
size="small"
dataId="open-in-sidebar"
action={showPreview}
/>
{/if}
<a
class="mr-1 flex-row-center gap-2 p-1"
href={getFileUrl(attachment.file, attachment.name)}
download={attachment.name}
bind:this={download}
use:tooltip={{ label: getEmbeddedLabel(attachment.name) }}
use:tooltip={{ label: presentation.string.Download }}
on:click|stopPropagation
>
{#if canPreview}
<ActionIcon
icon={IconOpen}
size={'medium'}
action={(evt) => {
showPreview(evt)
}}
/>
{/if}
<ActionIcon
<AttachmentAction
label={presentation.string.Download}
icon={FileDownload}
size={'medium'}
size="small"
dataId="open-in-sidebar"
action={() => {
download.click()
}}
/>
</a>
<ActionIcon icon={IconMoreH} size={'medium'} action={showMenu} />
<AttachmentAction
label={view.string.MoreActions}
icon={IconMoreH}
size="small"
dataId="open-in-sidebar"
action={showMenu}
/>
</div>

View File

@ -26,6 +26,7 @@
interface Dimensions {
width: 'auto' | number
height: 'auto' | number
fit: 'cover' | 'contain'
}
const minSizeRem = 4
@ -45,7 +46,8 @@
if (size === 'auto') {
return {
width: 'auto',
height: 'auto'
height: 'auto',
fit: 'contain'
}
}
@ -55,42 +57,41 @@
if (!metadata) {
return {
width: preferredWidth,
height: preferredWidth
height: preferredWidth,
fit: 'contain'
}
}
const { originalWidth, originalHeight } = metadata
const maxSize = maxSizeRem * parseFloat(getComputedStyle(document.documentElement).fontSize)
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
const maxSize = maxSizeRem * fontSize
const minSize = minSizeRem * fontSize
const width = Math.min(originalWidth, preferredWidth)
const ratio = originalHeight / originalWidth
const height = width * ratio
const fit = width < minSize || height < minSize ? 'cover' : 'contain'
if (height > maxSize) {
return {
width: maxSize / ratio,
height: maxSize
height: maxSize,
fit
}
} else if (height < minSize) {
return {
width,
height: minSize,
fit
}
} else {
return {
width,
height,
fit
}
}
return {
width,
height
}
}
function getObjectFit (size: Dimensions): 'contain' | 'cover' {
if (size.width === 'auto' || size.height === 'auto') {
return 'contain'
}
const minSize = minSizeRem * parseFloat(getComputedStyle(document.documentElement).fontSize)
if (size.width < minSize || size.height < minSize) {
return 'cover'
}
return 'contain'
}
function getUrlSize (size: AttachmentImageSize): IconSize {
@ -110,9 +111,9 @@
{#await getBlobRef(value.file, value.name, sizeToWidth(urlSize)) then blobSrc}
<img
src={blobSrc.src}
style:object-fit={getObjectFit(dimensions)}
width={dimensions.width}
height={dimensions.height}
style:object-fit={dimensions.fit}
width="100%"
height="100%"
srcset={blobSrc.srcset}
alt={value.name}
/>
@ -124,7 +125,6 @@
max-width: 20rem;
max-height: 20rem;
border-radius: 0.75rem;
object-fit: contain;
min-height: 4rem;
min-width: 4rem;
}

View File

@ -1,6 +1,6 @@
<!--
// Copyright © 2020, 2021 Anticrm Platform Contributors.
// Copyright © 2021 Hardcore Engineering Inc.
// Copyright © 2021, 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
@ -58,28 +58,28 @@
}}
>
<AttachmentImagePreview {value} size={imageSize} />
<div class="actions conner">
<div class="actions">
<AttachmentActions attachment={value} {isSaved} {removable} />
</div>
</div>
{:else if type === 'audio'}
<div class="buttonContainer">
<AudioPlayer {value} />
<div class="actions conner" style:padding={'0.125rem 0.25rem'}>
<div class="actions" style:padding={'0.125rem 0.25rem'}>
<AttachmentActions attachment={value} {isSaved} {removable} />
</div>
</div>
{:else if type === 'video'}
<div class="content buttonContainer flex-center">
<AttachmentVideoPreview {value} preload={videoPreload} />
<div class="actions conner">
<div class="actions">
<AttachmentActions attachment={value} {isSaved} {removable} />
</div>
</div>
{:else}
<div class="flex buttonContainer extraWidth">
<AttachmentPresenter {value} />
<div class="actions conner">
<div class="actions">
<AttachmentActions attachment={value} {isSaved} {removable} />
</div>
</div>
@ -100,17 +100,10 @@
position: absolute;
top: 0.25rem;
right: 0.25rem;
padding: 0.25rem;
padding: 0.125rem;
background-color: var(--theme-comp-header-color);
border: 1px solid var(--theme-divider-color);
border-radius: 0.75rem;
&.conner {
top: 0;
right: 0;
padding: 0.25rem;
border-radius: 0 0.4rem 0 0.25rem;
}
border-radius: 0.375rem;
}
}

View File

@ -500,9 +500,9 @@
updateSelectedDate()
if (selectedMessageId !== undefined && messages.some(({ _id }) => _id === selectedMessageId)) {
isScrollInitialized = true
await wait()
scrollToMessage()
isScrollInitialized = true
isInitialScrolling = false
} else if (separatorIndex === -1) {
await wait()
@ -800,6 +800,7 @@
bind:divBox={scrollContentBox}
noStretch={false}
disableOverscroll
horizontal={false}
onScroll={handleScroll}
onResize={handleResize}
>

View File

@ -143,6 +143,8 @@
dispatch('channel')
}
$: messagesStore = dataProvider?.messagesStore
</script>
{#if showHeader}
@ -167,10 +169,13 @@
<ThreadParentMessage {message} />
</div>
{#if message.replies && message.replies > 0}
{#if (message.replies ?? $messagesStore?.length ?? 0) > 0}
<div class="separator">
<div class="label lower">
<Label label={activity.string.RepliesCount} params={{ replies: message.replies }} />
<Label
label={activity.string.RepliesCount}
params={{ replies: message.replies ?? $messagesStore?.length ?? 1 }}
/>
</div>
<div class="line" />
</div>

View File

@ -150,6 +150,7 @@ export async function buildThreadLink (
loc.path[2] = chunterId
}
loc.query = { message: '' }
loc.path[3] = objectURI
loc.path[4] = threadParent
loc.fragment = undefined
@ -177,7 +178,7 @@ export async function replyToThread (message: ActivityMessage, e: Event): Promis
}
}
void openThreadInSidebar(message._id, message)
await openThreadInSidebar(message._id, message)
if (loc.path[2] !== chunterId && loc.path[2] !== notificationId) {
return
}
@ -326,10 +327,14 @@ export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: Acti
const name = (await getChannelName(object._id, object._class, object)) ?? (await translate(titleIntl, {}))
const tabName = await translate(chunter.string.ThreadIn, { name })
const loc = getCurrentLocation()
const allowedPath = loc.path.join('/')
const currentTAbs = get(sidebarStore).widgetsState.get(widget._id)?.tabs ?? []
const tabsToClose = currentTAbs.filter((t) => t.isPinned !== true && t.allowedPath === allowedPath).map((t) => t.id)
if (loc.path[2] === chunterId || loc.path[2] === notificationId) {
loc.path[4] = message._id
}
const allowedPath = loc.path.join('/')
const currentTabs = get(sidebarStore).widgetsState.get(widget._id)?.tabs ?? []
const tabsToClose = currentTabs.filter((t) => t.isPinned !== true && t.allowedPath === allowedPath).map((t) => t.id)
if (tabsToClose.length > 0) {
sidebarStore.update((s) => {

View File

@ -11,7 +11,7 @@
"Remove": "Удалить нотификацию",
"RemoveAll": "Удалить все нотификации",
"MarkAllAsRead": "Отметить все нотификации как прочитанные",
"MarkAsRead": "Отметить нотификация прочитанной",
"MarkAsRead": "Отметить прочитанным",
"MarkAsUnread": "Отметить непрочитанным",
"Archive": "Архивировать",
"Inbox": "Входящие",

View File

@ -198,7 +198,7 @@
<div
class="card"
on:click={() => {
dispatch('click', { context: value })
dispatch('click', { context: value, object })
}}
>
{#if isLoading}
@ -266,7 +266,7 @@
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
dispatch('click', { context: value, notification: group[0] })
dispatch('click', { context: value, notification: group[0], object })
}}
/>
</div>

View File

@ -256,7 +256,7 @@
const selectedNotification: InboxNotification | undefined = event?.detail?.notification
void selectInboxContext(linkProviders, selectedContext, selectedNotification)
void selectInboxContext(linkProviders, selectedContext, selectedNotification, event?.detail.object)
}
async function updateSelectedPanel (selectedContext?: DocNotifyContext): Promise<void> {
@ -373,6 +373,7 @@
}
]
$: $deviceInfo.replacedPanel = replacedPanel
onDestroy(() => {
$deviceInfo.replacedPanel = undefined
unsubscribeLoc()

View File

@ -583,7 +583,8 @@ export function resetInboxContext (): void {
export async function selectInboxContext (
linkProviders: LinkIdProvider[],
context: DocNotifyContext,
notification?: WithLookup<InboxNotification>
notification?: WithLookup<InboxNotification>,
object?: Doc
): Promise<void> {
const client = getClient()
const hierarchy = client.getHierarchy()
@ -607,24 +608,42 @@ export async function selectInboxContext (
const message = (notification as WithLookup<ActivityInboxNotification>)?.$lookup?.attachedTo
if (objectClass === chunter.class.ThreadMessage) {
const thread = await client.findOne(
chunter.class.ThreadMessage,
{
_id: objectId as Ref<ThreadMessage>
},
{ projection: { _id: 1, attachedTo: 1 } }
)
const thread =
object?._id === objectId
? (object as ThreadMessage)
: await client.findOne(
chunter.class.ThreadMessage,
{
_id: objectId as Ref<ThreadMessage>
},
{ projection: { _id: 1, attachedTo: 1 } }
)
void navigateToInboxDoc(linkProviders, objectId, objectClass, thread?.attachedTo, thread?._id)
void navigateToInboxDoc(
linkProviders,
thread?.objectId ?? objectId,
thread?.objectClass ?? objectClass,
thread?.attachedTo,
thread?._id
)
return
}
if (isReactionMessage(message)) {
const thread = loc.path[4] === objectId ? objectId : undefined
const reactedTo =
(object as ActivityMessage) ??
(await client.findOne(activity.class.ActivityMessage, { _id: message.attachedTo as Ref<ActivityMessage> }))
const isThread = hierarchy.isDerived(reactedTo._class, chunter.class.ThreadMessage)
const channelId = isThread ? (reactedTo as ThreadMessage)?.objectId : reactedTo?.attachedTo ?? objectId
const channelClass = isThread
? (reactedTo as ThreadMessage)?.objectClass
: reactedTo?.attachedToClass ?? objectClass
void navigateToInboxDoc(
linkProviders,
objectId,
objectClass,
channelId,
channelClass,
thread as Ref<ActivityMessage>,
objectId as Ref<ActivityMessage>
)
@ -633,11 +652,13 @@ export async function selectInboxContext (
const selectedMsg = (notification as ActivityInboxNotification)?.attachedTo
const thread = selectedMsg !== objectId ? objectId : loc.path[4] === objectId ? objectId : undefined
const channelId = (object as ActivityMessage)?.attachedTo ?? message?.attachedTo ?? objectId
const channelClass = (object as ActivityMessage)?.attachedToClass ?? message?.attachedToClass ?? objectClass
void navigateToInboxDoc(
linkProviders,
objectId,
objectClass,
channelId,
channelClass,
thread as Ref<ActivityMessage>,
selectedMsg ?? (objectId as Ref<ActivityMessage>)
)

View File

@ -42,6 +42,7 @@
"@hcengineering/platform": "^0.6.11",
"svelte": "^4.2.12",
"@hcengineering/ui": "^0.6.15",
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/presentation": "^0.6.3",
"@hcengineering/core": "^0.6.32",
"@hcengineering/view": "^0.6.13",

View File

@ -15,6 +15,7 @@
//
-->
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import { type Space, type Class, type CollaborativeDoc, type Doc, type Ref } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { getFileUrl, getImageSize, imageSizeToRatio } from '@hcengineering/presentation'
@ -116,11 +117,12 @@
objectAttr
})
let contentError = false
let localSynced = false
let remoteSynced = false
$: loading = !localSynced && !remoteSynced
$: editable = !readonly && remoteSynced
$: editable = !readonly && !contentError && remoteSynced
void localProvider.loaded.then(() => (localSynced = true))
void remoteProvider.loaded.then(() => (remoteSynced = true))
@ -218,7 +220,10 @@
}
$: if (editor !== undefined) {
editor.setEditable(editable, true)
// When the content is invalid, we don't want to emit an update
// Preventing synchronization of the invalid content
const emitUpdate = !contentError
editor.setEditable(editable, emitUpdate)
}
// TODO: should be inside the editor
@ -364,6 +369,7 @@
await ph
editor = new Editor({
enableContentCheck: true,
element,
editorProps: { attributes: mergeAttributes(defaultEditorAttributes, editorAttributes, { class: 'flex-grow' }) },
extensions: [
@ -432,6 +438,11 @@
throttle.call(updateLastUpdateTime)
dispatch('update')
},
onContentError: ({ error, disableCollaboration }) => {
disableCollaboration()
contentError = true
Analytics.handleError(error)
}
})
})

View File

@ -14,6 +14,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import { Markup } from '@hcengineering/core'
import { IntlString, translate } from '@hcengineering/platform'
import { EmptyMarkup, getMarkup, markupToJSON } from '@hcengineering/text'
@ -150,6 +151,7 @@
onMount(() => {
void ph.then(async () => {
editor = new Editor({
enableContentCheck: true,
element,
editorProps: {
attributes: mergeAttributes(defaultEditorAttributes, editorAttributes),
@ -197,6 +199,9 @@
content = getContent()
dispatch('value', content)
dispatch('update', content)
},
onContentError: ({ error }) => {
Analytics.handleError(error)
}
})
})

View File

@ -23,6 +23,7 @@
} from '@hcengineering/text-editor'
import { createQuery } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
import { onDestroy, onMount } from 'svelte'
import { inlineToolbarKey } from './extension/inlineToolbar'
import TextActionButton from './TextActionButton.svelte'
@ -93,11 +94,40 @@
$: categories.forEach((category) => {
category.sort((a, b) => a[0] - b[0])
})
let selecting = false
function handleMouseDown (): void {
function handleMouseMove (): void {
if (!editor.state.selection.empty) {
selecting = true
document.removeEventListener('mousemove', handleMouseMove)
}
}
function handleMouseUp (): void {
selecting = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}
onMount(() => {
document.addEventListener('mousedown', handleMouseDown)
})
onDestroy(() => {
document.removeEventListener('mousedown', handleMouseDown)
})
</script>
<div bind:this={toolbar} style="visibility: hidden;">
{#if editor && visible && visibleActions.length > 0}
<div class="text-editor-toolbar buttons-group xsmall-gap mb-4">
<div bind:this={toolbar} class="p-2" style="visibility: hidden;">
{#if editor && visible && !selecting && visibleActions.length > 0}
<div class="text-editor-toolbar buttons-group xsmall-gap">
{#each Object.values(categories) as category, index}
{#if index > 0}
<div class="buttons-divider" />
@ -113,8 +143,7 @@
<style lang="scss">
.text-editor-toolbar {
margin: -0.5rem -0.25rem 0.5rem;
padding: 0.375rem;
padding: 0.25rem;
background-color: var(--theme-comp-header-color);
border-radius: 0.5rem;
box-shadow: var(--button-shadow);

View File

@ -21,6 +21,7 @@ import { type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
import { common, createLowlight } from 'lowlight'
import { isChangeEditable } from './editable'
type Lowlight = ReturnType<typeof createLowlight>
@ -117,7 +118,7 @@ export function LanguageSelector (options: CodeBlockLowlightOptions): Plugin {
return createDecorations(state.doc, options)
},
apply (tr, prev) {
if (tr.docChanged) {
if (tr.docChanged || isChangeEditable(tr)) {
return createDecorations(tr.doc, options)
}
@ -133,7 +134,7 @@ function createDecorations (doc: ProseMirrorNode, options: CodeBlockLowlightOpti
doc.descendants((node, pos) => {
if (node.type.name === CodeBlockLowlight.name) {
decorations.push(
Decoration.widget(pos + node.nodeSize - 1, (view) => {
Decoration.widget(pos + 1, (view) => {
const button = createLangButton(node.attrs.language)
if (view.editable) {

View File

@ -0,0 +1,33 @@
import { Extension } from '@tiptap/core'
import { type Transaction } from '@tiptap/pm/state'
const metaKey = '$editable'
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface EditableOptions {}
export interface EditableStorage {
isEditable: boolean | undefined
}
export function isChangeEditable (tr: Transaction): boolean {
return tr.getMeta(metaKey) !== undefined
}
export const EditableExtension = Extension.create<EditableOptions, EditableStorage>({
name: 'editable',
addStorage () {
return { isEditable: undefined }
},
onUpdate () {
if (this.editor.isEditable !== this.storage.isEditable) {
const { state, view } = this.editor
this.storage.isEditable = this.editor.isEditable
const tr = state.tr.setMeta(metaKey, this.storage.isEditable)
view.dispatch(tr)
}
}
})

View File

@ -22,6 +22,7 @@ export interface FocusStorage {
}
export const FocusExtension = Extension.create<FocusOptions, FocusStorage>({
name: 'focus',
addStorage () {
return { canBlur: true }
},

View File

@ -6,6 +6,7 @@ export interface SubmitOptions {
}
export const SubmitExtension = Extension.create<SubmitOptions>({
name: 'submit',
addKeyboardShortcuts () {
const shortcuts: Record<string, KeyboardShortcutCommand> = {
Space: () => {

View File

@ -23,6 +23,7 @@ import ListKeymap from '@tiptap/extension-list-keymap'
import TableHeader from '@tiptap/extension-table-header'
import 'prosemirror-codemark/dist/codemark.css'
import { EditableExtension } from '../components/extension/editable'
import { CodeBlockHighlighExtension, codeBlockHighlightOptions } from '../components/extension/codeblock'
import { NoteExtension, type NoteOptions } from '../components/extension/note'
import { FileExtension, type FileOptions } from '../components/extension/fileExt'
@ -172,6 +173,7 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
}
})
],
[110, EditableExtension],
[200, CodeBlockHighlighExtension.configure(codeBlockHighlightOptions)],
[210, CodeExtension.configure(codeOptions)],
[220, HardBreakExtension.configure({ shortcuts: mode })]

View File

@ -12,8 +12,6 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { type Doc as YDoc } from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
@ -26,11 +24,7 @@ export class IndexeddbProvider extends IndexeddbPersistence implements Provider
readonly awareness: Awareness | null = null
constructor (documentId: string, doc: YDoc) {
const workspaceId: string = getMetadata(presentation.metadata.WorkspaceId) ?? ''
const name = `${workspaceId}/${documentId}`
super(name, doc)
super(documentId, doc)
this.loaded = new Promise((resolve) => {
this.on('synced', resolve)

View File

@ -28,7 +28,7 @@
$: if (query) updateSearchQuery(search)
let resultQuery: DocumentQuery<Issue> = { ...searchQuery }
$: if (!label && title) {
$: if (title) {
void translate(title, {}, $themeStore.language).then((res) => {
label = res
})

View File

@ -24,7 +24,7 @@
Breadcrumbs,
getCurrentLocation
} from '@hcengineering/ui'
import { onDestroy } from 'svelte'
import { onDestroy, onMount } from 'svelte'
import { closeWidgetTab, sidebarStore, SidebarVariant, WidgetState, openWidgetTab, closeWidget } from '../../sidebar'
import WidgetsBar from './widgets/WidgetsBar.svelte'
@ -46,36 +46,15 @@
$: widgetState = widget !== undefined ? $sidebarStore.widgetsState.get(widget._id) : undefined
$: tabId = widgetState?.tab
$: tabs = getTabs(widget, widgetState)
$: tabs = widgetState?.tabs ?? []
$: tab = tabId !== undefined ? tabs.find((it) => it.id === tabId) ?? tabs[0] : tabs[0]
$: if ($sidebarStore.widget === undefined) {
sidebarStore.update((s) => ({ ...s, variant: SidebarVariant.MINI }))
}
function getTabs (widget?: Widget, state?: WidgetState): WidgetTab[] {
if (widget === undefined || !state?.tabs) return []
const loc = getCurrentLocation()
const result: WidgetTab[] = []
for (const tab of state.tabs) {
if (tab.allowedPath !== undefined && !tab.isPinned) {
const path = loc.path.join('/')
if (!path.startsWith(tab.allowedPath)) {
void handleTabClose(tab.id, widget)
continue
}
}
result.push(tab)
}
return result
}
const unsubscribe = locationStore.subscribe((loc: Location) => {
function closeWrongTabs (loc: Location): void {
if (widget === undefined) return
for (const tab of tabs) {
if (tab.allowedPath !== undefined && !tab.isPinned) {
const path = loc.path.join('/')
@ -84,6 +63,14 @@
}
}
}
}
const unsubscribe = locationStore.subscribe((loc: Location) => {
closeWrongTabs(loc)
})
onMount(() => {
closeWrongTabs(getCurrentLocation())
})
onDestroy(() => {

View File

@ -203,7 +203,6 @@ export function openWidgetTab (widget: Ref<Widget>, tab: string): void {
}
export function createWidgetTab (widget: Widget, tab: WidgetTab, newTab = false): void {
openWidget(widget)
const state = get(sidebarStore)
const { widgetsState } = state
const widgetState = widgetsState.get(widget._id)
@ -225,6 +224,7 @@ export function createWidgetTab (widget: Widget, tab: WidgetTab, newTab = false)
}
widgetsState.set(widget._id, {
...widgetState,
_id: widget._id,
tabs: newTabs,
tab: tab.id
@ -232,7 +232,9 @@ export function createWidgetTab (widget: Widget, tab: WidgetTab, newTab = false)
sidebarStore.set({
...state,
widgetsState
widget: widget._id,
widgetsState,
variant: SidebarVariant.EXPANDED
})
}

View File

@ -596,9 +596,6 @@ async function changeIssueDataHandler (control: TriggerControl, issueId: Ref<Iss
if (data.title !== todo.title) {
update.title = data.title
}
if (data.description !== todo.description) {
update.description = data.description
}
if (data.attachedSpace !== todo.attachedSpace) {
update.attachedSpace = data.attachedSpace
}
@ -633,8 +630,8 @@ async function updateIssueHandler (tx: TxUpdateDoc<Issue>, control: TriggerContr
res.push(...(await changeIssueStatusHandler(control, newStatus, tx.objectId)))
}
const name = tx.operations.title
const number = tx.operations.number
if (number !== undefined || name !== undefined) {
const space = tx.operations.space
if (space !== undefined || name !== undefined) {
res.push(...(await changeIssueDataHandler(control, tx.objectId)))
}
return res

View File

@ -0,0 +1,81 @@
import { test } from '@playwright/test'
import { PlatformSetting, PlatformURI } from '../utils'
import { Drive } from '../model/drive/types'
import { faker } from '@faker-js/faker'
import { DriveLeftMenu } from '../model/drive/drive-left-menu'
import { DriveCreateEditPopup } from '../model/drive/drive-create-edit-popup'
import { DrivesPage } from '../model/drive/drive-drives-page'
import { DriveFilesPage } from '../model/drive/drive-files-page'
test.use({
storageState: PlatformSetting
})
test.describe('Drive tests', () => {
let leftMenu: DriveLeftMenu
let popupDrive: DriveCreateEditPopup
let drivesPage: DrivesPage
let filesPage: DriveFilesPage
let drive: Drive
test.beforeEach(async ({ page }) => {
popupDrive = new DriveCreateEditPopup(page)
leftMenu = new DriveLeftMenu(page)
drivesPage = new DrivesPage(page)
filesPage = new DriveFilesPage(page)
drive = {
name: faker.word.noun()
}
await (await page.goto(`${PlatformURI}/workbench/sanity-ws/drive`))?.finished()
await leftMenu.clickCreateNewDrive()
await popupDrive.createOrEditDrive(drive)
await leftMenu.clickDrives()
})
test('Check if drive exists at drives page', async () => {
await drivesPage.checkDriveExists(drive)
})
test('Create new folder, check if folder exists in drive tree', async () => {
const folder = faker.word.noun()
await drivesPage.createFolder(drive, folder)
await leftMenu.checkFolderExists(drive, folder)
})
test('Edit drive, rename drive, check if it was renamed', async () => {
const newDrive: Drive = {
name: faker.word.noun()
}
await drivesPage.clickEditDrive(drive)
await popupDrive.createOrEditDrive(newDrive)
await drivesPage.checkDriveExists(newDrive)
await drivesPage.checkDriveNotExists(drive)
})
test('Archive/unarchive drive, check drive archive status', async () => {
await drivesPage.archiveDrive(drive)
await drivesPage.checkIsArchived(drive)
await drivesPage.unarchiveDrive(drive)
await drivesPage.checkIsNotArchived(drive)
})
test('Leave/Join drive, check if user leaved/joined drive', async () => {
await drivesPage.leaveDrive(drive)
await drivesPage.checkUserNotJoinedDrive(drive)
await drivesPage.joinDrive(drive)
await drivesPage.checkUserJoinedDrive(drive)
})
test('Upload/rename/delete file, check file was uploaded, renamed and deleted', async () => {
await drivesPage.clickOnDrive(drive)
await leftMenu.clickUploadFiles()
const fileName = 'cat.jpeg'
await filesPage.uploadFile(fileName)
await filesPage.checkFileExists(fileName)
const newFileName = 'dog.jpeg'
await filesPage.renameFile(fileName, newFileName)
await filesPage.checkFileExists(newFileName)
await filesPage.deleteFile(newFileName)
await filesPage.checkFileNotExists(newFileName)
})
})

View File

@ -0,0 +1,27 @@
import { type Locator, type Page } from '@playwright/test'
import { Drive } from './types'
export class DriveCreateEditPopup {
readonly page: Page
constructor (page: Page) {
this.page = page
}
readonly popup = (): Locator => this.page.locator('div.popup')
readonly form = (): Locator => this.popup().locator('form[id^="drive:string:"]')
readonly buttonSelectSpaceType = (): Locator => this.form().locator('div.antiGrid-row:has-text("Space type") button')
readonly inputName = (): Locator => this.form().locator('div.antiGrid-row:has-text("Name") input')
readonly inputDescription = (): Locator => this.form().locator('div.antiGrid-row:has-text("Description") input')
readonly buttonSelectOwners = (): Locator => this.form().locator('div.antiGrid-row:has-text("Owners") button')
readonly togglePrivate = (): Locator =>
this.form().locator('div.antiGrid-row:has-text("Make private") .toggle-switch')
readonly buttonSelectMembers = (): Locator => this.form().locator('div.antiGrid-row:has-text("Members") button')
readonly buttonSubmit = (): Locator => this.form().locator('button[type="submit"]')
async createOrEditDrive (drive: Drive): Promise<void> {
await this.inputName().fill(drive.name)
await this.buttonSubmit().click()
}
}

View File

@ -0,0 +1,95 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { ButtonDrivesContextMenu, Drive } from './types'
import { CommonPage } from '../common-page'
export class DrivesPage extends CommonPage {
page: Page
constructor (page: Page) {
super(page)
this.page = page
}
readonly cellDriveName = (driveName: string): Locator => this.page.getByRole('cell', { name: driveName }).first()
readonly buttonContextMenu = (buttonText: ButtonDrivesContextMenu): Locator =>
this.page.locator('div.antiPopup').getByRole('button', { name: buttonText })
readonly popupCreateFolder = (): Locator => this.page.locator('div.popup form[id="drive:string:CreateFolder"]')
readonly rowDrive = (driveName: string): Locator =>
this.page.locator('tbody tr').filter({
has: this.cellDriveName(driveName)
})
readonly buttonJoinDriveRow = (driveName: string): Locator =>
this.rowDrive(driveName).locator('td').nth(2).getByRole('button', { name: 'Join' })
readonly popupArchive = (): Locator => this.page.locator('.popup.endShow:text("archive")').first()
readonly cellArchiveStatusYes = (driveName: string): Locator =>
this.rowDrive(driveName).locator('td').last().locator(':text("Yes")')
async checkDriveExists (drive: Drive): Promise<void> {
await expect(this.cellDriveName(drive.name)).toBeVisible()
}
async checkDriveNotExists (drive: Drive): Promise<void> {
await expect(this.cellDriveName(drive.name)).not.toBeVisible()
}
async clickButtonDriveContextMenu (drive: Drive, buttonText: ButtonDrivesContextMenu): Promise<void> {
await this.cellDriveName(drive.name).click({ button: 'right' })
await this.buttonContextMenu(buttonText).click()
}
async createFolder (drive: Drive, folderName: string): Promise<void> {
await this.clickButtonDriveContextMenu(drive, 'Create folder')
await this.popupCreateFolder().locator('input').fill(folderName)
await this.popupCreateFolder().getByRole('button', { name: 'Create' }).last().click()
}
async clickEditDrive (drive: Drive): Promise<void> {
await this.clickButtonDriveContextMenu(drive, 'Edit drive')
}
async archiveDrive (drive: Drive): Promise<void> {
await this.clickButtonDriveContextMenu(drive, 'Archive')
await this.popupSubmitButton().click()
await this.popupArchive().waitFor({ state: 'detached' })
}
async unarchiveDrive (drive: Drive): Promise<void> {
await this.clickButtonDriveContextMenu(drive, 'Unarchive')
await this.popupSubmitButton().click()
await this.popupArchive().waitFor({ state: 'detached' })
}
async checkIsArchived (drive: Drive): Promise<void> {
await expect(this.cellArchiveStatusYes(drive.name)).toBeVisible()
}
async checkIsNotArchived (drive: Drive): Promise<void> {
await expect(this.cellArchiveStatusYes(drive.name)).not.toBeVisible()
}
async leaveDrive (drive: Drive): Promise<void> {
await this.clickButtonDriveContextMenu(drive, 'Leave')
}
async joinDrive (drive: Drive): Promise<void> {
await this.clickButtonDriveContextMenu(drive, 'Join')
}
async checkUserJoinedDrive (drive: Drive): Promise<void> {
await expect(this.buttonJoinDriveRow(drive.name)).not.toBeVisible()
}
async checkUserNotJoinedDrive (drive: Drive): Promise<void> {
await this.buttonJoinDriveRow(drive.name).waitFor({ state: 'visible' })
}
async clickOnDrive (drive: Drive): Promise<void> {
await this.cellDriveName(drive.name).locator('a').click()
}
}

View File

@ -0,0 +1,48 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { ButtonFilesContextMenu } from './types'
import { uploadFile } from '../../utils'
import { CommonPage } from '../common-page'
export class DriveFilesPage extends CommonPage {
page: Page
constructor (page: Page) {
super(page)
this.page = page
}
readonly leftMenu = (): Locator => this.page.locator('.antiPanel-wrap__content.hulyNavPanel-container')
readonly buttonConfirmUpload = (): Locator => this.page.locator('.popup button').last()
readonly linkFile = (fileName: string): Locator => this.page.locator(`td:has(span:text-is("${fileName}")) a`).first()
readonly buttonContextMenu = (buttonText: ButtonFilesContextMenu): Locator =>
this.page.locator('div.antiPopup').getByRole('button', { name: buttonText })
async uploadFile (fileName: string): Promise<void> {
await uploadFile(this.page, fileName, 'My device')
await this.buttonConfirmUpload().click()
}
async checkFileExists (fileName: string): Promise<void> {
await expect(this.linkFile(fileName)).toBeVisible()
}
async checkFileNotExists (fileName: string): Promise<void> {
await expect(this.linkFile(fileName)).not.toBeVisible()
}
async clickFilesContextMenu (fileName: string, buttonText: ButtonFilesContextMenu): Promise<void> {
await this.linkFile(fileName).click({ button: 'right' })
await this.buttonContextMenu(buttonText).click()
}
async renameFile (fileName: string, newFileName: string): Promise<void> {
await this.clickFilesContextMenu(fileName, 'Rename')
await this.page.locator('.popup input').fill(newFileName)
await this.pressYesForPopup(this.page)
}
async deleteFile (fileName: string): Promise<void> {
await this.clickFilesContextMenu(fileName, 'Delete')
await this.pressYesForPopup(this.page)
}
}

View File

@ -0,0 +1,46 @@
import { expect, type Locator, type Page } from '@playwright/test'
import { Drive } from './types'
export class DriveLeftMenu {
page: Page
constructor (page: Page) {
this.page = page
}
readonly leftMenu = (): Locator => this.page.locator('.antiPanel-wrap__content.hulyNavPanel-container')
readonly buttonCreateDrive = (): Locator => this.page.locator('button#tree-drives')
readonly buttonUploadFiles = (): Locator => this.leftMenu().getByRole('button', { name: 'Upload files' })
readonly buttonDrives = (): Locator => this.page.getByRole('link', { name: 'Drives' }).getByRole('button')
readonly groupHeaderDrive = (): Locator => this.page.getByRole('button', { name: 'Drive', exact: true })
readonly treeDrives = (): Locator => this.leftMenu().locator('#navGroup-tree-drives')
readonly buttonDriveInTree = (drive: Drive): Locator =>
this.treeDrives().locator('.hulyNavGroup-container').getByRole('button', { name: drive.name })
readonly driveContainer = (drive: Drive): Locator =>
this.treeDrives()
.locator('.hulyNavGroup-container')
.filter({
has: this.page.locator(':scope').getByRole('button', { name: drive.name })
})
readonly buttonFolderInDrive = (drive: Drive, folder: string): Locator =>
this.driveContainer(drive).getByRole('button', { name: folder })
async clickCreateNewDrive (): Promise<void> {
await this.groupHeaderDrive().hover()
await this.buttonCreateDrive().click()
}
async clickDrives (): Promise<void> {
await this.buttonDrives().click()
}
async checkFolderExists (drive: Drive, folder: string): Promise<void> {
await expect(this.buttonFolderInDrive(drive, folder)).toBeVisible()
}
async clickUploadFiles (): Promise<void> {
await this.buttonUploadFiles().click()
}
}

View File

@ -0,0 +1,12 @@
export interface Drive {
spaceType?: string
name: string
description?: string
owners?: string[]
private?: boolean
members?: string[]
}
export type ButtonDrivesContextMenu = 'Create folder' | 'Edit drive' | 'Archive' | 'Unarchive' | 'Leave' | 'Join'
export type ButtonFilesContextMenu = 'Rename' | 'Download' | 'Move' | 'Delete'