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-20 22:10:29 +07:00
commit a72df539e2
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
57 changed files with 581 additions and 135 deletions

View File

@ -1691,6 +1691,9 @@ dependencies:
openai: openai:
specifier: ^4.56.0 specifier: ^4.56.0
version: 4.56.0(zod@3.23.8) version: 4.56.0(zod@3.23.8)
openid-client:
specifier: ~5.7.0
version: 5.7.0
otp-generator: otp-generator:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.1 version: 4.0.1
@ -17193,6 +17196,10 @@ packages:
engines: {node: '>= 0.6.0'} engines: {node: '>= 0.6.0'}
dev: false dev: false
/jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
dev: false
/jose@5.6.3: /jose@5.6.3:
resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==} resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==}
dev: false dev: false
@ -19076,6 +19083,11 @@ packages:
resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==} resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==}
dev: false dev: false
/object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
dev: false
/object-hash@3.0.0: /object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@ -19210,6 +19222,11 @@ packages:
resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==} resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==}
dev: false dev: false
/oidc-token-hash@5.0.3:
resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==}
engines: {node: ^10.13.0 || >=12.0.0}
dev: false
/omggif@1.0.10: /omggif@1.0.10:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
dev: false dev: false
@ -19298,6 +19315,15 @@ packages:
hasBin: true hasBin: true
dev: false dev: false
/openid-client@5.7.0:
resolution: {integrity: sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==}
dependencies:
jose: 4.15.9
lru-cache: 6.0.0
object-hash: 2.2.0
oidc-token-hash: 5.0.3
dev: false
/option@0.2.4: /option@0.2.4:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==} resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
dev: false dev: false
@ -25220,7 +25246,7 @@ packages:
dev: false dev: false
file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2): file:projects/auth-providers.tgz(@types/node@20.11.19)(esbuild@0.20.1)(ts-node@10.9.2):
resolution: {integrity: sha512-RDD+zkNosRxJuY+bq2skRGbRPr4saOaAvlkKv3gvvIXT5RxnmOXnznS6haxIcXw4VA96oxv08U9Mu8+3PjgpwQ==, tarball: file:projects/auth-providers.tgz} resolution: {integrity: sha512-0YbyLxnpaSZfdCfdlt5T9LI/TmahO7fbLohsHvXQVFb2J1InAIyu1WWzbS92CLYLHxQtJmSIOAbPVbRV1wPWbw==, tarball: file:projects/auth-providers.tgz}
id: file:projects/auth-providers.tgz id: file:projects/auth-providers.tgz
name: '@rush-temp/auth-providers' name: '@rush-temp/auth-providers'
version: 0.0.0 version: 0.0.0
@ -25248,6 +25274,7 @@ packages:
koa-router: 12.0.1 koa-router: 12.0.1
koa-session: 6.4.0 koa-session: 6.4.0
mongodb: 6.9.0 mongodb: 6.9.0
openid-client: 5.7.0
passport-custom: 1.1.1 passport-custom: 1.1.1
passport-github2: 0.1.12 passport-github2: 0.1.12
passport-google-oauth20: 2.0.0 passport-google-oauth20: 2.0.0

View File

@ -152,6 +152,9 @@ export class TActivityReference extends TActivityMessage implements ActivityRefe
@Prop(TypeMarkup(), activity.string.Message) @Prop(TypeMarkup(), activity.string.Message)
@Index(IndexKind.FullText) @Index(IndexKind.FullText)
message!: string message!: string
@Prop(TypeTimestamp(), activity.string.Edit)
editedOn?: Timestamp
} }
@Model(activity.class.ActivityInfoMessage, activity.class.ActivityMessage) @Model(activity.class.ActivityInfoMessage, activity.class.ActivityMessage)

View File

@ -22,7 +22,6 @@ import {
TypeMarkup, TypeMarkup,
TypeRef, TypeRef,
TypeString, TypeString,
TypeTimestamp,
UX UX
} from '@hcengineering/model' } from '@hcengineering/model'
import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core' import core, { TAttachedDoc, TClass, TDoc, TSpace } from '@hcengineering/model-core'
@ -59,6 +58,7 @@ import type { DocNotifyContext } from '@hcengineering/notification'
import chunter from './plugin' import chunter from './plugin'
export const DOMAIN_CHUNTER = 'chunter' as Domain export const DOMAIN_CHUNTER = 'chunter' as Domain
@Model(chunter.class.ChunterSpace, core.class.Space) @Model(chunter.class.ChunterSpace, core.class.Space)
export class TChunterSpace extends TSpace implements ChunterSpace { export class TChunterSpace extends TSpace implements ChunterSpace {
@Prop(PropCollection(activity.class.ActivityMessage), chunter.string.Messages) @Prop(PropCollection(activity.class.ActivityMessage), chunter.string.Messages)
@ -84,9 +84,6 @@ export class TChatMessage extends TActivityMessage implements ChatMessage {
@Index(IndexKind.FullText) @Index(IndexKind.FullText)
message!: string message!: string
@Prop(TypeTimestamp(), chunter.string.Edit)
editedOn?: Timestamp
@Prop(PropCollection(attachment.class.Attachment), attachment.string.Attachments, { @Prop(PropCollection(attachment.class.Attachment), attachment.string.Attachments, {
shortLabel: attachment.string.Files shortLabel: attachment.string.Files
}) })

View File

@ -562,6 +562,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
input: 'focus', input: 'focus',
category: tracker.category.Tracker, category: tracker.category.Tracker,
target: tracker.class.Issue, target: tracker.class.Issue,
visibilityTester: view.function.IsClipboardAvailable,
context: { context: {
mode: ['context', 'browser'], mode: ['context', 'browser'],
application: tracker.app.Tracker, application: tracker.app.Tracker,
@ -582,6 +583,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
input: 'focus', input: 'focus',
category: tracker.category.Tracker, category: tracker.category.Tracker,
target: tracker.class.Issue, target: tracker.class.Issue,
visibilityTester: view.function.IsClipboardAvailable,
context: { context: {
mode: ['context', 'browser'], mode: ['context', 'browser'],
application: tracker.app.Tracker, application: tracker.app.Tracker,
@ -602,6 +604,7 @@ export function createActions (builder: Builder, issuesId: string, componentsId:
input: 'focus', input: 'focus',
category: tracker.category.Tracker, category: tracker.category.Tracker,
target: tracker.class.Issue, target: tracker.class.Issue,
visibilityTester: view.function.IsClipboardAvailable,
context: { context: {
mode: ['context', 'browser'], mode: ['context', 'browser'],
application: tracker.app.Tracker, application: tracker.app.Tracker,

View File

@ -140,6 +140,7 @@ export default mergeIds(viewId, view, {
CanDeleteSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanDeleteSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanJoinSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanJoinSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
CanLeaveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanLeaveSpace: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
IsClipboardAvailable: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
BlobImageMetadata: '' as Resource<(file: FileOrBlob, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>, BlobImageMetadata: '' as Resource<(file: FileOrBlob, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>,
BlobVideoMetadata: '' as Resource<(file: FileOrBlob, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>> BlobVideoMetadata: '' as Resource<(file: FileOrBlob, blob: Ref<Blob>) => Promise<BlobMetadata | undefined>>
}, },

View File

@ -321,6 +321,7 @@
width: 100%; width: 100%;
min-width: 0; min-width: 0;
min-height: var(--spacing-6_5); min-height: var(--spacing-6_5);
overflow: hidden;
&.clearPadding > .hulyHeader-row { &.clearPadding > .hulyHeader-row {
padding: 0; padding: 0;
@ -577,9 +578,12 @@
} }
&.second:not(.isOpen), &.second:not(.isOpen),
&.border, &.border,
&.default:not(.nested:last-child) { &.default:not(.nested) {
border-bottom: 1px solid var(--theme-navpanel-divider); // var(--global-surface-01-BorderColor); border-bottom: 1px solid var(--theme-navpanel-divider); // var(--global-surface-01-BorderColor);
} }
&.default.nested:not(:last-child) {
border-bottom: 1px dashed var(--theme-navpanel-divider);
}
.hulyAccordionItem-header { .hulyAccordionItem-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -790,6 +794,12 @@
} }
} }
} }
&.hiddenHeader {
display: none;
visibility: hidden;
&.nested + .hulyAccordionItem-content { padding-top: var(--spacing-1); }
}
&:hover .hulyAccordionItem-header__chevron { &:hover .hulyAccordionItem-header__chevron {
color: var(--button-subtle-IconColor); color: var(--button-subtle-IconColor);
background-color: var(--global-ui-hover-BackgroundColor); background-color: var(--global-ui-hover-BackgroundColor);
@ -806,6 +816,9 @@
&.medium.bottomSpace + .hulyAccordionItem-content { &.medium.bottomSpace + .hulyAccordionItem-content {
padding-bottom: var(--spacing-2); padding-bottom: var(--spacing-2);
} }
&.medium.nested.bottomSpace + .hulyAccordionItem-content {
padding-bottom: var(--spacing-1);
}
&.large.bottomSpace + .hulyAccordionItem-content { &.large.bottomSpace + .hulyAccordionItem-content {
padding-bottom: var(--spacing-2); padding-bottom: var(--spacing-2);
} }

View File

@ -39,6 +39,7 @@
export let duration: number | boolean = false export let duration: number | boolean = false
export let fixHeader: boolean = false export let fixHeader: boolean = false
export let categoryHeader: boolean = false export let categoryHeader: boolean = false
export let hiddenHeader: boolean = false
export let background: string | undefined = undefined export let background: string | undefined = undefined
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -74,6 +75,7 @@
class:selectable class:selectable
class:scroller-header={fixHeader} class:scroller-header={fixHeader}
class:categoryHeader class:categoryHeader
class:hiddenHeader
style:background-color={background ?? 'transparent'} style:background-color={background ?? 'transparent'}
on:click|stopPropagation={handleClick} on:click|stopPropagation={handleClick}
> >

View File

@ -39,6 +39,7 @@
export let noPrint: boolean = false export let noPrint: boolean = false
export let freezeBefore: boolean = false export let freezeBefore: boolean = false
export let doubleRowWidth = 768 export let doubleRowWidth = 768
export let closeOnEscape: boolean = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -61,7 +62,7 @@
}) })
function _close (ev: KeyboardEvent): void { function _close (ev: KeyboardEvent): void {
if (closeButton && ev.key === 'Escape') { if (closeButton && ev.key === 'Escape' && closeOnEscape) {
ev.preventDefault() ev.preventDefault()
ev.stopPropagation() ev.stopPropagation()
@ -138,7 +139,9 @@
{/if} {/if}
{#if closeButton} {#if closeButton}
{#if type !== 'type-popup'}<div class="hulyHeader-divider no-print" />{/if} {#if type !== 'type-popup'}<div class="hulyHeader-divider no-print" />{/if}
<div class="hulyHotKey-item no-print">Esc</div> {#if closeOnEscape}
<div class="hulyHotKey-item no-print">Esc</div>
{/if}
<ButtonIcon icon={IconClose} kind={'tertiary'} size={'small'} noPrint on:click={() => dispatch('close')} /> <ButtonIcon icon={IconClose} kind={'tertiary'} size={'small'} noPrint on:click={() => dispatch('close')} />
{/if} {/if}
</div> </div>
@ -233,7 +236,9 @@
{/if} {/if}
{#if closeButton} {#if closeButton}
{#if type !== 'type-popup'}<div class="hulyHeader-divider no-print" />{/if} {#if type !== 'type-popup'}<div class="hulyHeader-divider no-print" />{/if}
<div class="hulyHotKey-item no-print">Esc</div> {#if closeOnEscape}
<div class="hulyHotKey-item no-print">Esc</div>
{/if}
<ButtonIcon icon={IconClose} kind={'tertiary'} size={'small'} noPrint on:click={() => dispatch('close')} /> <ButtonIcon icon={IconClose} kind={'tertiary'} size={'small'} noPrint on:click={() => dispatch('close')} />
{/if} {/if}
{/if} {/if}

View File

@ -85,6 +85,7 @@
border-radius: 0.25rem; border-radius: 0.25rem;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
color: var(--theme-content-color);
&.primary { &.primary {
background-color: var(--theme-button-pressed); background-color: var(--theme-button-pressed);

View File

@ -126,6 +126,7 @@ export interface TabItem {
} }
export interface BreadcrumbItem { export interface BreadcrumbItem {
id?: string
icon?: Asset | AnySvelteComponent | ComponentType icon?: Asset | AnySvelteComponent | ComponentType
iconProps?: any iconProps?: any
iconWidth?: string iconWidth?: string

View File

@ -26,6 +26,7 @@
import { getActions, restrictionStore, showMenu } from '@hcengineering/view-resources' import { getActions, restrictionStore, showMenu } from '@hcengineering/view-resources'
import { Asset } from '@hcengineering/platform' import { Asset } from '@hcengineering/platform'
import { Action as ViewAction } from '@hcengineering/view' import { Action as ViewAction } from '@hcengineering/view'
import notification from '@hcengineering/notification'
import ReactionsPresenter from '../reactions/ReactionsPresenter.svelte' import ReactionsPresenter from '../reactions/ReactionsPresenter.svelte'
import ActivityMessagePresenter from './ActivityMessagePresenter.svelte' import ActivityMessagePresenter from './ActivityMessagePresenter.svelte'
@ -228,6 +229,9 @@
<span class="text-sm lower"> <span class="text-sm lower">
<MessageTimestamp date={message.createdOn ?? message.modifiedOn} /> <MessageTimestamp date={message.createdOn ?? message.modifiedOn} />
</span> </span>
{#if message.editedOn}
<span class="text-sm lower">(<Label label={notification.string.Edited} />)</span>
{/if}
{#if withActions && inlineActions.length > 0 && !readonly} {#if withActions && inlineActions.length > 0 && !readonly}
<div class="flex-presenter flex-gap-2 ml-2"> <div class="flex-presenter flex-gap-2 ml-2">

View File

@ -46,6 +46,7 @@ export interface ActivityMessage extends AttachedDoc {
replies?: number replies?: number
reactions?: number reactions?: number
editedOn?: Timestamp
} }
export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage
@ -81,6 +82,7 @@ export interface ActivityInfoMessage extends ActivityMessage {
props?: Record<string, any> props?: Record<string, any>
icon?: Asset icon?: Asset
iconProps?: Record<string, any> iconProps?: Record<string, any>
editedOn?: Timestamp
// A possible set of links to some platform resources. // A possible set of links to some platform resources.
links?: { _class: Ref<Class<Doc>>, _id: Ref<Doc> }[] links?: { _class: Ref<Class<Doc>>, _id: Ref<Doc> }[]

View File

@ -42,6 +42,7 @@
hideExtra={false} hideExtra={false}
adaptive="autoExtra" adaptive="autoExtra"
doubleRowWidth={350} doubleRowWidth={350}
closeOnEscape={false}
on:close on:close
> >
<div class="title"> <div class="title">

View File

@ -30,6 +30,7 @@
export let filters: Ref<ActivityMessagesFilter>[] = [] export let filters: Ref<ActivityMessagesFilter>[] = []
export let isAsideOpened = false export let isAsideOpened = false
export let syncLocation = true export let syncLocation = true
export let freeze = false
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -111,5 +112,6 @@
provider={dataProvider} provider={dataProvider}
{isAsideOpened} {isAsideOpened}
loadMoreAllowed={!isDocChannel} loadMoreAllowed={!isDocChannel}
{freeze}
/> />
{/if} {/if}

View File

@ -36,6 +36,7 @@
export let isAsideShown: boolean = false export let isAsideShown: boolean = false
export let filters: Ref<ActivityMessagesFilter>[] = [] export let filters: Ref<ActivityMessagesFilter>[] = []
export let canOpenInSidebar: boolean = false export let canOpenInSidebar: boolean = false
export let closeOnEscape: boolean = true
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
@ -80,6 +81,7 @@
{isAsideShown} {isAsideShown}
{withSearch} {withSearch}
{canOpenInSidebar} {canOpenInSidebar}
{closeOnEscape}
on:aside-toggled on:aside-toggled
on:close on:close
> >

View File

@ -61,8 +61,9 @@
export let skipLabels = false export let skipLabels = false
export let loadMoreAllowed = true export let loadMoreAllowed = true
export let isAsideOpened = false export let isAsideOpened = false
export let initialScrollBottom = true
export let fullHeight = true export let fullHeight = true
export let fixedInput = true
export let freeze = false
const doc = object const doc = object
@ -130,17 +131,21 @@
} }
}) })
function isFreeze (): boolean {
return freeze
}
$: displayMessages = filterChatMessages(messages, filters, filterResources, doc._class, selectedFilters) $: displayMessages = filterChatMessages(messages, filters, filterResources, doc._class, selectedFilters)
const unsubscribe = inboxClient.inboxNotificationsByContext.subscribe(() => { const unsubscribe = inboxClient.inboxNotificationsByContext.subscribe(() => {
if (notifyContext !== undefined) { if (notifyContext !== undefined && !isFreeze()) {
recheckNotifications(notifyContext) recheckNotifications(notifyContext)
readViewportMessages() readViewportMessages()
} }
}) })
function scrollToBottom (afterScrollFn?: () => void): void { function scrollToBottom (afterScrollFn?: () => void): void {
if (scroller != null && scrollElement != null) { if (scroller != null && scrollElement != null && !isFreeze()) {
scroller.scrollBy(scrollElement.scrollHeight) scroller.scrollBy(scrollElement.scrollHeight)
updateSelectedDate() updateSelectedDate()
afterScrollFn?.() afterScrollFn?.()
@ -338,7 +343,7 @@
let messagesToReadAccumulatorTimer: any let messagesToReadAccumulatorTimer: any
function readViewportMessages (): void { function readViewportMessages (): void {
if (!scrollElement || !scrollContentBox) { if (!scrollElement || !scrollContentBox || isFreeze()) {
return return
} }
@ -452,22 +457,14 @@
isInitialScrolling = false isInitialScrolling = false
} else if (separatorIndex === -1) { } else if (separatorIndex === -1) {
await wait() await wait()
if (initialScrollBottom) { isScrollInitialized = true
isScrollInitialized = true shouldWaitAndRead = true
shouldWaitAndRead = true autoscroll = true
autoscroll = true shouldScrollToNew = true
shouldScrollToNew = true isInitialScrolling = false
isInitialScrolling = false waitLastMessageRenderAndRead(() => {
waitLastMessageRenderAndRead(() => {
autoscroll = false
})
} else {
isScrollInitialized = true
autoscroll = false autoscroll = false
updateShouldScrollToNew() })
isInitialScrolling = false
readViewportMessages()
}
} else if (separatorElement) { } else if (separatorElement) {
await wait() await wait()
scrollToSeparator() scrollToSeparator()
@ -519,6 +516,7 @@
function scrollToNewMessages (): void { function scrollToNewMessages (): void {
if (!scrollElement || !shouldScrollToNew) { if (!scrollElement || !shouldScrollToNew) {
readViewportMessages()
return return
} }
@ -557,14 +555,22 @@
return return
} }
if (isFreeze()) {
messagesCount = newCount
return
}
if (scrollToRestore > 0) { if (scrollToRestore > 0) {
void restoreScroll() void restoreScroll()
} else if (dateToJump !== undefined) { } else if (dateToJump !== undefined) {
await wait() await wait()
scrollToDate(dateToJump) scrollToDate(dateToJump)
} else if (messagesCount > 0 && newCount > messagesCount) { } else if (shouldScrollToNew && messagesCount > 0 && newCount > messagesCount) {
await wait() await wait()
scrollToNewMessages() scrollToNewMessages()
} else {
await wait()
readViewportMessages()
} }
messagesCount = newCount messagesCount = newCount
@ -576,7 +582,7 @@
return return
} }
if (shouldScrollToNew && initialScrollBottom) { if (shouldScrollToNew) {
scrollToBottom() scrollToBottom()
} }
@ -660,7 +666,7 @@
} else if (element != null) { } else if (element != null) {
const { scrollHeight, scrollTop, offsetHeight } = element const { scrollHeight, scrollTop, offsetHeight } = element
showScrollDownButton = scrollHeight > offsetHeight + scrollTop + 300 showScrollDownButton = scrollHeight > offsetHeight + scrollTop + 50
} else { } else {
showScrollDownButton = false showScrollDownButton = false
} }
@ -691,7 +697,7 @@
$: void forceReadContext(isScrollAtBottom, notifyContext) $: void forceReadContext(isScrollAtBottom, notifyContext)
async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise<void> { async function forceReadContext (isScrollAtBottom: boolean, context?: DocNotifyContext): Promise<void> {
if (context === undefined || !isScrollAtBottom || forceRead) return if (context === undefined || !isScrollAtBottom || forceRead || isFreeze()) return
const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context const { lastUpdateTimestamp = 0, lastViewedTimestamp = 0 } = context
if (lastViewedTimestamp >= lastUpdateTimestamp) return if (lastViewedTimestamp >= lastUpdateTimestamp) return
@ -708,6 +714,10 @@
} }
const canLoadNextForwardStore = provider.canLoadNextForwardStore const canLoadNextForwardStore = provider.canLoadNextForwardStore
$: if (!freeze) {
readViewportMessages()
}
</script> </script>
{#if isLoading} {#if isLoading}
@ -777,6 +787,16 @@
/> />
{/each} {/each}
{#if !fixedInput}
<div class="ref-input flex-col">
<ActivityExtensionComponent
kind="input"
{extensions}
props={{ object, boundary: scrollElement, collection, autofocus: true, withTypingInfo: true }}
/>
</div>
{/if}
{#if loadMoreAllowed && $canLoadNextForwardStore} {#if loadMoreAllowed && $canLoadNextForwardStore}
<HistoryLoading isLoading={$isLoadingMoreStore} /> <HistoryLoading isLoading={$isLoadingMoreStore} />
{/if} {/if}
@ -794,7 +814,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if object} {#if fixedInput && object}
<div class="ref-input flex-col"> <div class="ref-input flex-col">
<ActivityExtensionComponent <ActivityExtensionComponent
kind="input" kind="input"
@ -841,7 +861,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
bottom: -0.75rem; bottom: -0.75rem;
animation: 1s fadeIn; animation: 0.5s fadeIn;
animation-fill-mode: forwards; animation-fill-mode: forwards;
visibility: hidden; visibility: hidden;
} }

View File

@ -58,9 +58,14 @@
{ limit: 1 } { limit: 1 }
) )
} }
let renderChannel = tab.data.thread === undefined
$: if (tab.data.thread === undefined) {
renderChannel = true
}
</script> </script>
{#if object} {#if object && renderChannel}
<div class="channel" class:invisible={threadId !== undefined} style:height style:width> <div class="channel" class:invisible={threadId !== undefined} style:height style:width>
<ChannelHeader <ChannelHeader
_id={object._id} _id={object._id}
@ -71,16 +76,17 @@
canOpen={true} canOpen={true}
allowClose={true} allowClose={true}
canOpenInSidebar={false} canOpenInSidebar={false}
closeOnEscape={false}
on:close on:close
/> />
{#key object._id} {#key object._id}
<Channel {object} {context} syncLocation={false} /> <Channel {object} {context} syncLocation={false} freeze={threadId !== undefined} />
{/key} {/key}
</div> </div>
{/if} {/if}
{#if threadId} {#if threadId}
<div class="thread" style:height style:width> <div class="thread" style:height style:width>
<ThreadView _id={threadId} on:close={() => closeThreadInSidebarChannel(widget, tab)} /> <ThreadView _id={threadId} on:channel={() => closeThreadInSidebarChannel(widget, tab)} on:close />
</div> </div>
{/if} {/if}

View File

@ -93,7 +93,7 @@
} }
</script> </script>
<div class="popupPanel panel"> <div class="popupPanel">
<ChannelHeader <ChannelHeader
_id={object._id} _id={object._id}
_class={object._class} _class={object._class}

View File

@ -36,7 +36,7 @@
} }
</script> </script>
{#if widget && tab && tab.type === 'channel'} {#if widget && tab}
<ChannelSidebarView <ChannelSidebarView
{widget} {widget}
{tab} {tab}
@ -46,13 +46,4 @@
handleClose(tab?.id) handleClose(tab?.id)
}} }}
/> />
{:else if widget && tab && tab.type === 'thread'}
<ThreadSidebarView
{tab}
{height}
{width}
on:close={() => {
handleClose(tab?.id)
}}
/>
{/if} {/if}

View File

@ -50,7 +50,7 @@
let count: number = 0 let count: number = 0
$: objectId = tab.type === 'thread' ? tab.data.thread : tab.data._id $: objectId = tab.data.thread ?? tab.data._id
$: context = objectId ? $contextByDocStore.get(objectId) : undefined $: context = objectId ? $contextByDocStore.get(objectId) : undefined
const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => { const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => {

View File

@ -57,6 +57,7 @@
export let adaptive: HeaderAdaptive = 'default' export let adaptive: HeaderAdaptive = 'default'
export let hideActions: boolean = false export let hideActions: boolean = false
export let canOpenInSidebar: boolean = false export let canOpenInSidebar: boolean = false
export let closeOnEscape: boolean = true
const client = getClient() const client = getClient()
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -72,6 +73,7 @@
hideActions={!((canOpen && object) || withAside || $$slots.actions) || hideActions} hideActions={!((canOpen && object) || withAside || $$slots.actions) || hideActions}
hideDescription={!description} hideDescription={!description}
adaptive={adaptive !== 'default' ? adaptive : withFilters ? 'freezeActions' : 'disabled'} adaptive={adaptive !== 'default' ? adaptive : withFilters ? 'freezeActions' : 'disabled'}
{closeOnEscape}
on:click on:click
on:close on:close
> >

View File

@ -33,7 +33,7 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
bind:this={div} bind:this={div}
class="border-radius-4 over-underline dateSelectorButton clear-mins" class="border-radius-4 dateSelectorButton clear-mins"
on:click={() => { on:click={() => {
showPopup(DateRangePopup, {}, div, (v) => { showPopup(DateRangePopup, {}, div, (v) => {
if (v) { if (v) {
@ -68,13 +68,17 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 1px; height: 1px;
background-color: var(--theme-divider-color); background-color: var(--highlight-select-border);
} }
.dateSelectorButton { .dateSelectorButton {
cursor: pointer;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
height: max-content; height: max-content;
background-color: var(--theme-list-row-color); color: var(--theme-content-color);
border: 1px solid var(--theme-divider-color); background-color: var(--highlight-select);
border: 1px solid var(--highlight-select-border);
font-weight: 500;
z-index: 10; z-index: 10;
} }
} }

View File

@ -14,20 +14,15 @@
--> -->
<script lang="ts"> <script lang="ts">
import { Label } from '@hcengineering/ui' import { Label } from '@hcengineering/ui'
import notification from '@hcengineering/notification'
import { IntlString } from '@hcengineering/platform' import { IntlString } from '@hcengineering/platform'
import { Timestamp } from '@hcengineering/core' import { Timestamp } from '@hcengineering/core'
export let editedOn: Timestamp | undefined
export let label: IntlString | undefined export let label: IntlString | undefined
</script> </script>
{#if label} {#if label}
<span class="text-sm lower"> <Label {label} /></span> <span class="text-sm lower"> <Label {label} /></span>
{/if} {/if}
{#if editedOn}
<span class="text-sm lower"><Label label={notification.string.Edited} /></span>
{/if}
<style lang="scss"> <style lang="scss">
span { span {

View File

@ -255,7 +255,7 @@
{onClick} {onClick}
> >
<svelte:fragment slot="header"> <svelte:fragment slot="header">
<ChatMessageHeader editedOn={value.editedOn} label={skipLabel ? undefined : viewlet?.label} /> <ChatMessageHeader label={viewlet?.label} />
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="content"> <svelte:fragment slot="content">
{#if !isEditing} {#if !isEditing}

View File

@ -76,7 +76,7 @@
if (direct !== undefined && missingAccounts.length > 0) { if (direct !== undefined && missingAccounts.length > 0) {
await client.updateDoc(chunter.class.DirectMessage, direct.space, direct._id, { await client.updateDoc(chunter.class.DirectMessage, direct.space, direct._id, {
members: [...direct.members, ...missingAccounts] $push: { members: { $each: missingAccounts, $position: 0 } }
}) })
} }

View File

@ -24,7 +24,7 @@
{#if tab.data.thread} {#if tab.data.thread}
<div class="root" style:height style:width> <div class="root" style:height style:width>
<ThreadView _id={tab.data.thread} on:close /> <ThreadView _id={tab.data.thread} on:close on:channel />
</div> </div>
{/if} {/if}

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { Doc, Ref } from '@hcengineering/core' import { Doc, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { Breadcrumbs, Label, location as locationStore, Header } from '@hcengineering/ui' import { Breadcrumbs, Label, location as locationStore, Header, BreadcrumbItem } from '@hcengineering/ui'
import { createEventDispatcher, onDestroy } from 'svelte' import { createEventDispatcher, onDestroy } from 'svelte'
import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity' import activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources' import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources'
@ -104,7 +104,14 @@
channelName = res channelName = res
}) })
function getBreadcrumbsItems (channel?: Doc, message?: DisplayActivityMessage, channelName?: string) { let breadcrumbs: BreadcrumbItem[] = []
$: breadcrumbs = showHeader ? getBreadcrumbsItems(channel, message, channelName) : []
function getBreadcrumbsItems (
channel?: Doc,
message?: DisplayActivityMessage,
channelName?: string
): BreadcrumbItem[] {
if (message === undefined) { if (message === undefined) {
return [] return []
} }
@ -115,6 +122,7 @@
return [ return [
{ {
id: 'channel',
icon: getObjectIcon(message.attachedToClass), icon: getObjectIcon(message.attachedToClass),
iconProps: { value: channel }, iconProps: { value: channel },
iconWidth: isPersonAvatar ? 'auto' : undefined, iconWidth: isPersonAvatar ? 'auto' : undefined,
@ -122,14 +130,24 @@
title: channelName, title: channelName,
label: channelName ? undefined : chunter.string.Channel label: channelName ? undefined : chunter.string.Channel
}, },
{ label: chunter.string.Thread } { id: 'thread', label: chunter.string.Thread }
] ]
} }
function handleBreadcrumbSelect (event: CustomEvent<number>): void {
const index = event.detail
const breadcrumb = breadcrumbs[index]
if (breadcrumb === undefined) return
if (breadcrumb.id !== 'channel') return
dispatch('channel')
}
</script> </script>
{#if showHeader} {#if showHeader}
<Header type={'type-aside'} adaptive={'disabled'} on:close> <Header type={'type-aside'} adaptive={'disabled'} closeOnEscape={false} on:close>
<Breadcrumbs items={getBreadcrumbsItems(channel, message, channelName)} currentOnly /> <Breadcrumbs items={breadcrumbs} on:select={handleBreadcrumbSelect} selected={1} />
</Header> </Header>
{/if} {/if}
@ -141,8 +159,8 @@
skipLabels skipLabels
object={message} object={message}
provider={dataProvider} provider={dataProvider}
initialScrollBottom={false}
fullHeight={false} fullHeight={false}
fixedInput={false}
> >
<svelte:fragment slot="header"> <svelte:fragment slot="header">
<div class="mt-3"> <div class="mt-3">

View File

@ -61,6 +61,7 @@ import {
getThreadLink, getThreadLink,
openChannelInSidebar, openChannelInSidebar,
openChannelInSidebarAction, openChannelInSidebarAction,
openThreadInSidebar,
replyToThread replyToThread
} from './navigation' } from './navigation'
import { import {
@ -199,7 +200,8 @@ export default async (): Promise<Resources> => ({
GetMessageLink: getMessageLocation, GetMessageLink: getMessageLocation,
CloseChatWidgetTab: closeChatWidgetTab, CloseChatWidgetTab: closeChatWidgetTab,
OpenChannelInSidebar: openChannelInSidebar, OpenChannelInSidebar: openChannelInSidebar,
CanTranslateMessage: canTranslateMessage CanTranslateMessage: canTranslateMessage,
OpenThreadInSidebar: openThreadInSidebar
}, },
actionImpl: { actionImpl: {
ArchiveChannel, ArchiveChannel,

View File

@ -6,7 +6,7 @@ import {
type Location, type Location,
navigate navigate
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { type Ref, type Doc, type Class } from '@hcengineering/core' import { type Ref, type Doc, type Class, generateId } from '@hcengineering/core'
import activity, { type ActivityMessage } from '@hcengineering/activity' import activity, { type ActivityMessage } from '@hcengineering/activity'
import { import {
type Channel, type Channel,
@ -262,11 +262,11 @@ export async function openChannelInSidebar (
size: isDirect || isPerson ? 'tiny' : 'x-small', size: isDirect || isPerson ? 'tiny' : 'x-small',
compact: true compact: true
}, },
type: 'channel',
data: { data: {
_id, _id,
_class, _class,
thread thread,
channelName: name
} }
} }
@ -288,18 +288,26 @@ export async function openThreadInSidebarChannel (
): Promise<void> { ): Promise<void> {
const newTab: ChatWidgetTab = { const newTab: ChatWidgetTab = {
...tab, ...tab,
name: await translate(chunter.string.ThreadIn, { name: tab.data.channelName }),
data: { ...tab.data, thread: message._id } data: { ...tab.data, thread: message._id }
} }
createWidgetTab(widget, newTab) createWidgetTab(widget, newTab)
} }
export async function closeThreadInSidebarChannel (widget: Widget, tab: ChatWidgetTab): Promise<void> { export async function closeThreadInSidebarChannel (widget: Widget, tab: ChatWidgetTab): Promise<void> {
const thread = tab.allowedPath !== undefined ? tab.data.thread : undefined
const newTab: ChatWidgetTab = { const newTab: ChatWidgetTab = {
...tab, ...tab,
id: tab.id.startsWith('thread_') ? generateId() : tab.id,
name: tab.data.channelName,
allowedPath: undefined,
data: { ...tab.data, thread: undefined } data: { ...tab.data, thread: undefined }
} }
createWidgetTab(widget, newTab) createWidgetTab(widget, newTab)
setTimeout(() => {
removeThreadFromLoc(thread)
}, 100)
} }
export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: ActivityMessage, doc?: Doc): Promise<void> { export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: ActivityMessage, doc?: Doc): Promise<void> {
@ -321,9 +329,7 @@ export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: Acti
const allowedPath = loc.path.join('/') const allowedPath = loc.path.join('/')
const currentTAbs = get(sidebarStore).widgetsState.get(widget._id)?.tabs ?? [] const currentTAbs = get(sidebarStore).widgetsState.get(widget._id)?.tabs ?? []
const tabsToClose = currentTAbs const tabsToClose = currentTAbs.filter((t) => t.isPinned !== true && t.allowedPath === allowedPath).map((t) => t.id)
.filter((t) => t.isPinned !== true && t.allowedPath === allowedPath && (t as ChatWidgetTab).type === 'thread')
.map((t) => t.id)
if (tabsToClose.length > 0) { if (tabsToClose.length > 0) {
sidebarStore.update((s) => { sidebarStore.update((s) => {
@ -341,26 +347,31 @@ export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: Acti
name: tabName, name: tabName,
icon: chunter.icon.Thread, icon: chunter.icon.Thread,
allowedPath, allowedPath,
type: 'thread',
data: { data: {
_id: object?._id, _id: object?._id,
_class: object?._class, _class: object?._class,
thread: message._id thread: message._id,
channelName: name
} }
} }
createWidgetTab(widget, tab, true) createWidgetTab(widget, tab, true)
} }
export function closeChatWidgetTab (tab?: ChatWidgetTab): void { export function removeThreadFromLoc (thread?: Ref<ActivityMessage>): void {
if (tab?.type === 'thread') { if (thread === undefined) return
const loc = getCurrentLocation() const loc = getCurrentLocation()
if (loc.path[2] === chunterId || loc.path[2] === notificationId) { if (loc.path[2] === chunterId || loc.path[2] === notificationId) {
if (loc.path[4] === tab.data.thread) { if (loc.path[4] === thread) {
loc.path[4] = '' loc.path[4] = ''
loc.path.length = 4 loc.path.length = 4
navigate(loc) navigate(loc)
}
} }
} }
} }
export function closeChatWidgetTab (tab?: ChatWidgetTab): void {
if (tab?.allowedPath !== undefined) {
removeThreadFromLoc(tab.data.thread)
}
}

View File

@ -434,9 +434,13 @@ export async function readChannelMessages (
return return
} }
const inboxClient = InboxNotificationsClientImpl.getClient() if (context === undefined) {
return
}
const inboxClient = InboxNotificationsClientImpl.getClient()
const client = getClient().apply(undefined, 'readViewportMessages') const client = getClient().apply(undefined, 'readViewportMessages')
try { try {
const readMessages = get(chatReadMessagesStore) const readMessages = get(chatReadMessagesStore)
const allIds = getAllIds(messages).filter((id) => !readMessages.has(id)) const allIds = getAllIds(messages).filter((id) => !readMessages.has(id))
@ -460,12 +464,6 @@ export async function readChannelMessages (
chatReadMessagesStore.update((store) => new Set([...store, ...allIds])) chatReadMessagesStore.update((store) => new Set([...store, ...allIds]))
await inboxClient.readNotifications(client, [...notifications, ...relatedMentions])
if (context === undefined) {
return
}
const storedTimestampUpdates = get(contextsTimestampStore).get(context._id) const storedTimestampUpdates = get(contextsTimestampStore).get(context._id)
const newTimestamp = messages[messages.length - 1].createdOn ?? 0 const newTimestamp = messages[messages.length - 1].createdOn ?? 0
const prevTimestamp = Math.max(storedTimestampUpdates ?? 0, context.lastViewedTimestamp ?? 0) const prevTimestamp = Math.max(storedTimestampUpdates ?? 0, context.lastViewedTimestamp ?? 0)
@ -478,6 +476,7 @@ export async function readChannelMessages (
}) })
await client.update(context, { lastViewedTimestamp: newTimestamp }) await client.update(context, { lastViewedTimestamp: newTimestamp })
} }
await inboxClient.readNotifications(client, [...notifications, ...relatedMentions])
} finally { } finally {
await client.commit() await client.commit()
} }

View File

@ -100,8 +100,7 @@ export interface InlineButton extends AttachedDoc {
} }
export interface ChatWidgetTab extends WidgetTab { export interface ChatWidgetTab extends WidgetTab {
type: 'channel' | 'thread' data: { _id?: Ref<Doc>, _class?: Ref<Class<Doc>>, thread?: Ref<ActivityMessage>, channelName: string }
data: { _id?: Ref<Doc>, _class?: Ref<Class<Doc>>, thread?: Ref<ActivityMessage> }
} }
/** /**
@ -232,6 +231,7 @@ export default plugin(chunterId, {
}, },
function: { function: {
CanTranslateMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>, CanTranslateMessage: '' as Resource<(doc?: Doc | Doc[]) => Promise<boolean>>,
OpenThreadInSidebar: '' as Resource<(_id: Ref<ActivityMessage>, msg?: ActivityMessage, doc?: Doc) => Promise<void>>,
OpenChannelInSidebar: '' as Resource< OpenChannelInSidebar: '' as Resource<
(_id: Ref<Doc>, _class: Ref<Doc>, doc?: Doc, thread?: Ref<ActivityMessage>) => Promise<void> (_id: Ref<Doc>, _class: Ref<Doc>, doc?: Doc, thread?: Ref<ActivityMessage>) => Promise<void>
> >

View File

@ -32,13 +32,13 @@
{#if value !== undefined} {#if value !== undefined}
<div class:highlighted class="root"> <div class:highlighted class="root">
<div class="header pt-2 pb-2 pl-4 pr-4 flex-between"> <div class="header pt-2 pb-2 pl-4 pr-4 flex-between">
<div> <span class="overflow-label">
{#if value?.index} {#if value?.index}
<span>#{value.index}</span> <span data-id="commentId">#{value.index}</span>
<span></span> <span></span>
{/if} {/if}
<Label label={resolved ? documents.string.Resolved : documents.string.Pending} /> <Label label={resolved ? documents.string.Resolved : documents.string.Pending} />
</div> </span>
{#if $canAddDocumentCommentsFeedback} {#if $canAddDocumentCommentsFeedback}
<div class="tools"> <div class="tools">
<Button <Button

View File

@ -40,6 +40,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const query = createQuery() const query = createQuery()
const isSecureContext = window.isSecureContext
interface InviteParams { interface InviteParams {
expirationTime: number expirationTime: number
@ -87,6 +88,7 @@
} }
} }
function copy (): void { function copy (): void {
if (!isSecureContext) return
if (link === undefined) return if (link === undefined) return
copyTextToClipboard(link) copyTextToClipboard(link)
copied = true copied = true
@ -109,7 +111,7 @@
let loading = false let loading = false
</script> </script>
<div class="antiPopup popup"> <div class="antiPopup popup" class:secure={isSecureContext}>
<div class="flex-between fs-title mb-9"> <div class="flex-between fs-title mb-9">
<Label label={login.string.InviteDescription} /> <Label label={login.string.InviteDescription} />
<InviteWorkspace size={'large'} /> <InviteWorkspace size={'large'} />
@ -154,7 +156,9 @@
<Loading /> <Loading />
{:else if link !== undefined} {:else if link !== undefined}
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="over-underline link" on:click={copy}>{link}</div> <div class="link" class:notSecure={!isSecureContext} class:over-underline={isSecureContext} on:click={copy}>
{link}
</div>
<div class="buttons"> <div class="buttons">
<Button <Button
label={login.string.Close} label={login.string.Close}
@ -164,7 +168,9 @@
dispatch('close') dispatch('close')
}} }}
/> />
<Button label={copied ? login.string.Copied : login.string.Copy} size={'medium'} on:click={copy} /> {#if isSecureContext}
<Button label={copied ? login.string.Copied : login.string.Copy} size={'medium'} on:click={copy} />
{/if}
</div> </div>
{:else} {:else}
<div class="buttons"> <div class="buttons">
@ -195,6 +201,10 @@
.link { .link {
margin: 1.75rem 0 0; margin: 1.75rem 0 0;
overflow-wrap: break-word; overflow-wrap: break-word;
&.notSecure {
user-select: text;
}
} }
.buttons { .buttons {

View File

@ -7,6 +7,7 @@
import { getProviders } from '../utils' import { getProviders } from '../utils'
import Github from './providers/Github.svelte' import Github from './providers/Github.svelte'
import Google from './providers/Google.svelte' import Google from './providers/Google.svelte'
import OpenId from './providers/OpenId.svelte'
interface Provider { interface Provider {
name: string name: string
@ -21,6 +22,10 @@
{ {
name: 'github', name: 'github',
component: Github component: Github
},
{
name: 'openid',
component: OpenId
} }
] ]

View File

@ -0,0 +1,15 @@
<svg viewBox="0 0 512 512" width="1.5rem" height="1.5rem" xmlns="http://www.w3.org/2000/svg">
<rect
height="512"
rx="64"
ry="64"
style="fill:#f68423;fill-opacity:1;fill-rule:nonzero;stroke:none"
width="512"
x="0"
y="0"
/>
<path
d="m 416.99957,216.95084 c -39.2625,-22.18682 -78.9827,-34.64025 -117.0052,-39.52028 V 73.776502 l -60.66712,39.049158 v 63.02711 c -188.010218,14.68899 -295.068752,208.65924 0,262.37073 l 60.66712,-39.04916 V 218.28862 c 24.3468,4.22226 50.4759,12.17342 78.0094,24.69351 l -34.6758,21.70238 h 112.6719 v -73.76497 l -39.0003,26.0313 z m -177.67682,-1.66668 v 183.89018 c -182.841238,-23.72461 -140.809838,-171.94788 0,-183.89018 z"
style="fill:#ffffff;fill-opacity:1"
/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@ -0,0 +1,10 @@
<script lang="ts">
import { Label } from '@hcengineering/ui'
import OpenId from '../icons/OpenId.svelte'
import login from '../../plugin'
</script>
<div class="flex-row-center flex-gap-2">
<OpenId />
<Label label={login.string.ContinueWith} params={{ provider: 'OpenId' }} />
</div>

View File

@ -37,6 +37,7 @@
import view, { decodeObjectURI } from '@hcengineering/view' import view, { decodeObjectURI } from '@hcengineering/view'
import { parseLinkId } from '@hcengineering/view-resources' import { parseLinkId } from '@hcengineering/view-resources'
import { get } from 'svelte/store' import { get } from 'svelte/store'
import { getResource } from '@hcengineering/platform'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient' import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import notification from '../../plugin' import notification from '../../plugin'
@ -179,6 +180,12 @@
} }
const selectedMessageId = loc?.loc.query?.message as Ref<ActivityMessage> | undefined const selectedMessageId = loc?.loc.query?.message as Ref<ActivityMessage> | undefined
const thread = loc?.loc.path[4] as Ref<ActivityMessage> | undefined
if (thread !== undefined) {
const fn = await getResource(chunter.function.OpenThreadInSidebar)
void fn(thread)
}
if (selectedMessageId !== undefined) { if (selectedMessageId !== undefined) {
selectedMessage = get(inboxClient.activityInboxNotifications).find( selectedMessage = get(inboxClient.activityInboxNotifications).find(

View File

@ -588,6 +588,7 @@ export async function selectInboxContext (
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
const { objectId, objectClass } = context const { objectId, objectClass } = context
const loc = getCurrentLocation()
if (isMentionNotification(notification) && isActivityMessageClass(notification.mentionedInClass)) { if (isMentionNotification(notification) && isActivityMessageClass(notification.mentionedInClass)) {
const selectedMsg = notification.mentionedIn as Ref<ActivityMessage> const selectedMsg = notification.mentionedIn as Ref<ActivityMessage>
@ -619,17 +620,25 @@ export async function selectInboxContext (
} }
if (isReactionMessage(message)) { if (isReactionMessage(message)) {
void navigateToInboxDoc(linkProviders, objectId, objectClass, undefined, objectId as Ref<ActivityMessage>) const thread = loc.path[4] === objectId ? objectId : undefined
void navigateToInboxDoc(
linkProviders,
objectId,
objectClass,
thread as Ref<ActivityMessage>,
objectId as Ref<ActivityMessage>
)
return return
} }
const selectedMsg = (notification as ActivityInboxNotification)?.attachedTo const selectedMsg = (notification as ActivityInboxNotification)?.attachedTo
const thread = selectedMsg !== objectId ? objectId : loc.path[4] === objectId ? objectId : undefined
void navigateToInboxDoc( void navigateToInboxDoc(
linkProviders, linkProviders,
objectId, objectId,
objectClass, objectClass,
selectedMsg !== undefined ? (objectId as Ref<ActivityMessage>) : undefined, thread as Ref<ActivityMessage>,
selectedMsg ?? (objectId as Ref<ActivityMessage>) selectedMsg ?? (objectId as Ref<ActivityMessage>)
) )
return return

View File

@ -81,7 +81,7 @@
} }
</script> </script>
<AccordionItem {id} {icon} {iconProps} {title} {label} size={'medium'} nested> <AccordionItem {id} {icon} {iconProps} {title} {label} size={'medium'} nested hiddenHeader={project === false}>
{#each todos as todo, index} {#each todos as todo, index}
<ToDoDraggable {todo} {index} {groupName} {projectId} on:drop={handleDrop}> <ToDoDraggable {todo} {index} {groupName} {projectId} on:drop={handleDrop}>
<ToDoElement {todo} planned={mode !== 'unplanned'} /> <ToDoElement {todo} planned={mode !== 'unplanned'} />

View File

@ -237,9 +237,9 @@
active: WithLookup<ToDo>[] active: WithLookup<ToDo>[]
): [IntlString, WithLookup<ToDo>[]][] { ): [IntlString, WithLookup<ToDo>[]][] {
const groups = new Map<IntlString, WithLookup<ToDo>[]>([ const groups = new Map<IntlString, WithLookup<ToDo>[]>([
[time.string.Scheduled, []],
[time.string.Unplanned, unplanned], [time.string.Unplanned, unplanned],
[time.string.ToDos, []], [time.string.ToDos, []],
[time.string.Scheduled, []],
[time.string.Done, done] [time.string.Done, done]
]) ])
const now = Date.now() const now = Date.now()

View File

@ -135,7 +135,8 @@ import {
canDeleteSpace, canDeleteSpace,
canEditSpace, canEditSpace,
canJoinSpace, canJoinSpace,
canLeaveSpace canLeaveSpace,
isClipboardAvailable
} from './visibilityTester' } from './visibilityTester'
export { canArchiveSpace, canDeleteObject, canDeleteSpace, canEditSpace } from './visibilityTester' export { canArchiveSpace, canDeleteObject, canDeleteSpace, canEditSpace } from './visibilityTester'
export { getActions, getContextActions, invokeAction, showMenu } from './actions' export { getActions, getContextActions, invokeAction, showMenu } from './actions'
@ -334,6 +335,7 @@ export default async (): Promise<Resources> => ({
CanDeleteSpace: canDeleteSpace, CanDeleteSpace: canDeleteSpace,
CanJoinSpace: canJoinSpace, CanJoinSpace: canJoinSpace,
CanLeaveSpace: canLeaveSpace, CanLeaveSpace: canLeaveSpace,
IsClipboardAvailable: isClipboardAvailable,
BlobImageMetadata: blobImageMetadata, BlobImageMetadata: blobImageMetadata,
BlobVideoMetadata: blobVideoMetadata BlobVideoMetadata: blobVideoMetadata
} }

View File

@ -143,3 +143,7 @@ export async function canLeaveSpace (doc?: Doc | Doc[]): Promise<boolean> {
return space.members?.includes(getCurrentAccount()._id) return space.members?.includes(getCurrentAccount()._id)
} }
export function isClipboardAvailable (doc?: Doc | Doc[]): boolean {
return isSecureContext && navigator.clipboard !== undefined
}

View File

@ -100,6 +100,7 @@
hideActions={false} hideActions={false}
hideDescription={true} hideDescription={true}
adaptive="disabled" adaptive="disabled"
closeOnEscape={false}
on:close={() => { on:close={() => {
if (widget !== undefined) { if (widget !== undefined) {
closeWidget(widget._id) closeWidget(widget._id)
@ -168,5 +169,6 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
border-bottom: 1px solid transparent;
} }
</style> </style>

View File

@ -101,7 +101,12 @@ export function openWidget (widget: Widget, data?: Record<string, any>, active =
const { widgetsState } = state const { widgetsState } = state
const widgetState = widgetsState.get(widget._id) const widgetState = widgetsState.get(widget._id)
widgetsState.set(widget._id, { _id: widget._id, data, tab: widgetState?.tab, tabs: widgetState?.tabs ?? [] }) widgetsState.set(widget._id, {
_id: widget._id,
data: data ?? widgetState?.data,
tab: widgetState?.tab,
tabs: widgetState?.tabs ?? []
})
sidebarStore.set({ sidebarStore.set({
...state, ...state,

View File

@ -52,6 +52,7 @@
"passport-custom": "~1.1.1", "passport-custom": "~1.1.1",
"passport-google-oauth20": "~2.0.0", "passport-google-oauth20": "~2.0.0",
"passport-github2": "~0.1.12", "passport-github2": "~0.1.12",
"openid-client": "~5.7.0",
"koa-passport": "^6.0.0", "koa-passport": "^6.0.0",
"koa": "^2.15.3", "koa": "^2.15.3",
"koa-router": "^12.0.1", "koa-router": "^12.0.1",

View File

@ -12,7 +12,7 @@ export function registerGithub (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, dbPromise: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
): string | undefined { ): string | undefined {
@ -69,6 +69,7 @@ export function registerGithub (
let loginInfo: LoginInfo let loginInfo: LoginInfo
const state = safeParseAuthState(ctx.query?.state) const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding) const branding = getBranding(brandings, state?.branding)
const db = await dbPromise
if (state.inviteId != null && state.inviteId !== '') { if (state.inviteId != null && state.inviteId !== '') {
loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, { loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, {
githubId: ctx.state.user.id githubId: ctx.state.user.id

View File

@ -12,7 +12,7 @@ export function registerGoogle (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, dbPromise: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
): string | undefined { ): string | undefined {
@ -74,6 +74,7 @@ export function registerGoogle (
let loginInfo: LoginInfo let loginInfo: LoginInfo
const state = safeParseAuthState(ctx.query?.state) const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding) const branding = getBranding(brandings, state?.branding)
const db = await dbPromise
if (state.inviteId != null && state.inviteId !== '') { if (state.inviteId != null && state.inviteId !== '') {
loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any) loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any)
} else { } else {

View File

@ -5,6 +5,7 @@ import session from 'koa-session'
import { Db } from 'mongodb' import { Db } from 'mongodb'
import { registerGithub } from './github' import { registerGithub } from './github'
import { registerGoogle } from './google' import { registerGoogle } from './google'
import { registerOpenid } from './openid'
import { registerToken } from './token' import { registerToken } from './token'
import { BrandingMap, MeasureContext } from '@hcengineering/core' import { BrandingMap, MeasureContext } from '@hcengineering/core'
@ -15,7 +16,7 @@ export type AuthProvider = (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, db: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
) => string | undefined ) => string | undefined
@ -24,7 +25,7 @@ export function registerProviders (
ctx: MeasureContext, ctx: MeasureContext,
app: Koa<Koa.DefaultState, Koa.DefaultContext>, app: Koa<Koa.DefaultState, Koa.DefaultContext>,
router: Router<any, any>, router: Router<any, any>,
db: Db, db: Promise<Db>,
serverSecret: string, serverSecret: string,
frontUrl: string | undefined, frontUrl: string | undefined,
brandings: BrandingMap brandings: BrandingMap
@ -60,7 +61,7 @@ export function registerProviders (
registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings) registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings)
const res: string[] = [] const res: string[] = []
const providers: AuthProvider[] = [registerGoogle, registerGithub] const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid]
for (const provider of providers) { for (const provider of providers) {
const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings) const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings)
if (value !== undefined) res.push(value) if (value !== undefined) res.push(value)

View File

@ -0,0 +1,119 @@
//
// 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 f.
//
import { joinWithProvider, loginWithProvider, type LoginInfo } from '@hcengineering/account'
import { BrandingMap, concatLink, MeasureContext, getBranding } from '@hcengineering/core'
import Router from 'koa-router'
import { Db } from 'mongodb'
import { Issuer, Strategy } from 'openid-client'
import qs from 'querystringify'
import { Passport } from '.'
import { getHost, safeParseAuthState } from './utils'
export function registerOpenid (
measureCtx: MeasureContext,
passport: Passport,
router: Router<any, any>,
accountsUrl: string,
dbPromise: Promise<Db>,
frontUrl: string,
brandings: BrandingMap
): string | undefined {
const openidClientId = process.env.OPENID_CLIENT_ID
const openidClientSecret = process.env.OPENID_CLIENT_SECRET
const issuer = process.env.OPENID_ISSUER
const redirectURL = '/auth/openid/callback'
if (openidClientId === undefined || openidClientSecret === undefined || issuer === undefined) return
void Issuer.discover(issuer).then((issuerObj) => {
const client = new issuerObj.Client({
client_id: openidClientId,
client_secret: openidClientSecret,
redirect_uris: [concatLink(accountsUrl, redirectURL)],
response_types: ['code']
})
passport.use(
'oidc',
new Strategy({ client, passReqToCallback: true }, (req: any, tokenSet: any, userinfo: any, done: any) => {
return done(null, userinfo)
})
)
})
router.get('/auth/openid', async (ctx, next) => {
measureCtx.info('try auth via', { provider: 'openid' })
const host = getHost(ctx.request.headers)
const brandingKey = host !== undefined ? brandings[host]?.key ?? undefined : undefined
const state = encodeURIComponent(
JSON.stringify({
inviteId: ctx.query?.inviteId,
branding: brandingKey
})
)
await passport.authenticate('oidc', {
scope: 'openid profile email',
state
})(ctx, next)
})
router.get(
redirectURL,
async (ctx, next) => {
const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding)
await passport.authenticate('oidc', {
failureRedirect: concatLink(branding?.front ?? frontUrl, '/login')
})(ctx, next)
},
async (ctx, next) => {
try {
const email = ctx.state.user.email ?? `openid:${ctx.state.user.sub}`
const [first, last] = ctx.state.user.name?.split(' ') ?? [ctx.state.user.username, '']
measureCtx.info('Provider auth handler', { email, type: 'openid' })
if (email !== undefined) {
let loginInfo: LoginInfo
const state = safeParseAuthState(ctx.query?.state)
const branding = getBranding(brandings, state?.branding)
const db = await dbPromise
if (state.inviteId != null && state.inviteId !== '') {
loginInfo = await joinWithProvider(measureCtx, db, null, email, first, last, state.inviteId as any, {
openId: ctx.state.user.sub
})
} else {
loginInfo = await loginWithProvider(measureCtx, db, null, email, first, last, {
openId: ctx.state.user.sub
})
}
const origin = concatLink(branding?.front ?? frontUrl, '/login/auth')
const query = encodeURIComponent(qs.stringify({ token: loginInfo.token }))
measureCtx.info('Success auth, redirect', { email, type: 'openid', target: origin })
// Successful authentication, redirect to your application
ctx.redirect(`${origin}?${query}`)
}
} catch (err: any) {
measureCtx.error('failed to auth', { err, type: 'openid', user: ctx.state?.user })
}
await next()
}
)
return 'openid'
}

View File

@ -12,7 +12,7 @@ export function registerToken (
passport: Passport, passport: Passport,
router: Router<any, any>, router: Router<any, any>,
accountsUrl: string, accountsUrl: string,
db: Db, dbPromise: Promise<Db>,
frontUrl: string, frontUrl: string,
brandings: BrandingMap brandings: BrandingMap
): string | undefined { ): string | undefined {
@ -21,9 +21,11 @@ export function registerToken (
new CustomStrategy(function (req: any, done: any) { new CustomStrategy(function (req: any, done: any) {
const token = req.body.token ?? req.query.token const token = req.body.token ?? req.query.token
getAccountInfoByToken(measureCtx, db, null, token) void dbPromise.then((db) => {
.then((user: any) => done(null, user)) getAccountInfoByToken(measureCtx, db, null, token)
.catch((err: any) => done(err)) .then((user: any) => done(null, user))
.catch((err: any) => done(err))
})
}) })
) )

View File

@ -76,15 +76,15 @@ export class DocumentCommentsPage extends DocumentCommonPage {
reply: string reply: string
): Promise<void> { ): Promise<void> {
const comment = this.page const comment = this.page
.locator('div.popup div.root div.header > div > span:first-child', { hasText: String(commentId) }) .locator('div.text-editor-popup span[data-id="commentId"]', { hasText: `#${String(commentId)}` })
.locator('xpath=../../../..') .locator('xpath=../../../..')
// check header // check header
await expect(comment.locator('div.root > div.header > div:first-child')).toContainText(header) await expect(comment.locator('div.root > div.header > span.overflow-label')).toContainText(header)
// can be resolved // can be resolved
await comment.locator('div.header div.tools button').hover() await comment.locator('div.header div.tools button').hover()
await expect(comment.locator('div.header div.tools button')).toBeEnabled() await expect(comment.locator('div.header div.tools button')).toBeEnabled()
// check author // check author
await expect(comment.locator('div.root div.header > div.username span.ap-label').first()).toHaveText(author) await expect(comment.locator('div.activityMessage div.header a span[class*="label"]').first()).toHaveText(author)
// check message // check message
await expect( await expect(
comment.locator('div.activityMessage div.flex-col div.clear-mins div.text-markup-view > p').first() comment.locator('div.activityMessage div.flex-col div.clear-mins div.text-markup-view > p').first()
@ -103,15 +103,15 @@ export class DocumentCommentsPage extends DocumentCommonPage {
reply: string reply: string
): Promise<void> { ): Promise<void> {
const comment = this.page const comment = this.page
.locator('div.box div.root div.header > div > span:first-child', { hasText: String(commentId) }) .locator('div[data-testid="comment"] span[data-id="commentId"]', { hasText: `#${String(commentId)}` })
.locator('xpath=../../../..') .locator('xpath=../../../..')
// check header // check header
await expect(comment.locator('div.root > div.header > div:first-child')).toContainText(header) await expect(comment.locator('div.root > div.header > span.overflow-label')).toContainText(header)
// can be resolved // can be resolved
await comment.locator('div.header > div > span:last-child').hover() await comment.locator('div.root > div.header > span.overflow-label').first().hover()
await expect(comment.locator('div.header div.tools button')).toBeEnabled() await expect(comment.locator('div.header div.tools button')).toBeEnabled()
// check author // check author
await expect(comment.locator('div.root div.header > div.username span.ap-label').first()).toHaveText(author) await expect(comment.locator('div.activityMessage div.header a span[class*="label"]').first()).toHaveText(author)
// check message // check message
await expect( await expect(
comment.locator('div.activityMessage div.flex-col div.clear-mins div.text-markup-view > p').first() comment.locator('div.activityMessage div.flex-col div.clear-mins div.text-markup-view > p').first()

View File

@ -103,10 +103,11 @@ export function serveAccount (measureCtx: MeasureContext, brandings: BrandingMap
) )
app.use(bodyParser()) app.use(bodyParser())
void client.getClient().then(async (p: MongoClient) => { const mongoClientPromise = client.getClient()
const db = p.db(ACCOUNT_DB) const dbPromise = mongoClientPromise.then((c) => c.db(ACCOUNT_DB))
registerProviders(measureCtx, app, router, db, serverSecret, frontURL, brandings) registerProviders(measureCtx, app, router, dbPromise, serverSecret, frontURL, brandings)
void dbPromise.then((db) => {
setInterval( setInterval(
() => { () => {
void cleanExpiredOtp(db) void cleanExpiredOtp(db)

View File

@ -36,7 +36,7 @@ test.describe('Content in the Documents tests', () => {
let documentsSecondPage: DocumentsPage let documentsSecondPage: DocumentsPage
let documentContentSecondPage: DocumentContentPage let documentContentSecondPage: DocumentContentPage
test.beforeEach(async ({ browser, page, request }) => { test.beforeEach(async ({ page, request }) => {
leftSideMenuPage = new LeftSideMenuPage(page) leftSideMenuPage = new LeftSideMenuPage(page)
documentsPage = new DocumentsPage(page) documentsPage = new DocumentsPage(page)
documentContentPage = new DocumentContentPage(page) documentContentPage = new DocumentContentPage(page)
@ -116,8 +116,8 @@ test.describe('Content in the Documents tests', () => {
await documentContentPage.inputContentParapraph().click() await documentContentPage.inputContentParapraph().click()
await documentContentPage.leftMenu().click() await documentContentPage.leftMenu().click()
await documentContentPage.menuPopupItemButton('Table').click() await documentContentPage.clickPopupItem('Table')
await documentContentPage.menuPopupItemButton('1x2').first().click() await documentContentPage.clickPopupItem('1x2')
await documentContentPage.proseTableCell(0, 0).fill('One') await documentContentPage.proseTableCell(0, 0).fill('One')
await documentContentPage.proseTableCell(0, 1).fill('Two') await documentContentPage.proseTableCell(0, 1).fill('Two')
await documentContentPage.buttonInsertColumn().click() await documentContentPage.buttonInsertColumn().click()
@ -295,4 +295,64 @@ test.describe('Content in the Documents tests', () => {
await documentContentPage.page.keyboard.press('Escape') await documentContentPage.page.keyboard.press('Escape')
}) })
}) })
test('Checking styles in a Document', async ({ page, browser, request }) => {
const content: string = [...new Array(20).keys()].map((index) => `Line ${index + 1}`).join('\n')
const testLink: string = 'http://test/link/123456'
const testNote: string = 'Test Note'
await documentContentPage.addContentToTheNewLine(content, false)
await documentContentPage.applyToolbarCommand('Line 1', 'btnH1')
await documentContentPage.applyToolbarCommand('Line 2', 'btnH2')
await documentContentPage.applyToolbarCommand('Line 3', 'btnH3')
await documentContentPage.goToByTOC('Line 3')
await documentContentPage.goToByTOC('Line 1')
await documentContentPage.applyToolbarCommand('Line 4', 'btnBold')
await documentContentPage.applyToolbarCommand('Line 5', 'btnItalic')
await documentContentPage.applyToolbarCommand('Line 6', 'btnStrikethrough')
await documentContentPage.applyToolbarCommand('Line 7', 'btnUnderlined')
await documentContentPage.applyToolbarCommand('Line 8', 'btnLink')
await documentContentPage.inputFormLink().fill(testLink)
await documentContentPage.buttonFormLinkSave().click()
await documentContentPage.addSeparator('Line 9')
for (let i = 9; i <= 11; i++) {
await documentContentPage.applyToolbarCommand(`Line ${i}`, 'btnOrderedList')
}
await documentContentPage.addSeparator('Line 12')
for (let i = 12; i <= 14; i++) {
await documentContentPage.applyToolbarCommand(`Line ${i}`, 'btnBulletedList')
}
await documentContentPage.addSeparator('Line 15')
await documentContentPage.applyToolbarCommand('Line 15', 'btnH3')
await documentContentPage.goToByTOC('Line 15')
await documentContentPage.applyToolbarCommand('Line 16', 'btnBlockquote')
await documentContentPage.applyToolbarCommand('Line 17', 'btnCode')
await documentContentPage.applyToolbarCommand('Line 18', 'btnCodeBlock')
await documentContentPage.changeCodeBlockLanguage('Line 18', 'auto', 'css')
await documentContentPage.applyNote('Line 19', 'warning', testNote)
await documentContentPage.addImage('Line 20')
await page.keyboard.type('Cat')
newUser2 = generateUser()
await createAccount(request, newUser2)
const linkText = await getInviteLink(page)
using _secondPage = await getSecondPageByInvite(browser, linkText, newUser2)
secondPage = _secondPage.page
leftSideMenuSecondPage = new LeftSideMenuPage(secondPage)
documentsSecondPage = new DocumentsPage(secondPage)
documentContentSecondPage = new DocumentContentPage(secondPage)
await leftSideMenuSecondPage.clickDocuments()
await documentsSecondPage.openTeamspace(testDocument.space)
await documentsSecondPage.openDocument(testDocument.title)
await documentContentSecondPage.checkDocumentTitle(testDocument.title)
await documentContentSecondPage.checkLinkInTheText('Line 8', testLink)
await documentContentSecondPage.goToByTOC('Line 15')
await documentContentSecondPage.checkImage()
await documentContentSecondPage.checkNote('Line 19', 'warning', testNote)
})
}) })

View File

@ -169,7 +169,7 @@ test.describe('Documents tests', () => {
}) })
}) })
test('Add Link to the Document', async () => { test.skip('Add Link to the Document', async () => {
const contentLink = 'Lineforthelink' const contentLink = 'Lineforthelink'
const linkDocument: NewDocument = { const linkDocument: NewDocument = {
title: `Links Document Title-${generateId()}`, title: `Links Document Title-${generateId()}`,

View File

@ -206,6 +206,10 @@ export class CommonPage {
await expect(this.menuPopupItemButton(itemText)).toBeVisible() await expect(this.menuPopupItemButton(itemText)).toBeVisible()
} }
async clickPopupItem (itemText: string): Promise<void> {
await this.menuPopupItemButton(itemText).first().click()
}
async selectFilter (filter: string, filterSecondLevel?: string): Promise<void> { async selectFilter (filter: string, filterSecondLevel?: string): Promise<void> {
await this.buttonFilter().click() await this.buttonFilter().click()
await this.selectPopupMenu(filter).click() await this.selectPopupMenu(filter).click()

View File

@ -1,6 +1,7 @@
import { type Locator, type Page, expect } from '@playwright/test' import { type Locator, type Page, expect } from '@playwright/test'
import { CommonPage } from '../common-page' import { CommonPage } from '../common-page'
import { uploadFile } from '../../utils' import { uploadFile } from '../../utils'
import path from 'path'
export class DocumentContentPage extends CommonPage { export class DocumentContentPage extends CommonPage {
readonly page: Page readonly page: Page
@ -35,7 +36,9 @@ export class DocumentContentPage extends CommonPage {
readonly buttonInsertInnerRow = (row: number = 0): Locator => readonly buttonInsertInnerRow = (row: number = 0): Locator =>
this.page.locator('table.proseTable').locator('tr').nth(row).locator('div.table-row-insert button') this.page.locator('table.proseTable').locator('tr').nth(row).locator('div.table-row-insert button')
readonly buttonToolbarLink = (): Locator => this.page.locator('div.text-editor-toolbar button[data-id="btnLink"]') readonly buttonOnToolbar = (id: string): Locator =>
this.page.locator(`div.text-editor-toolbar button[data-id="${id}"]`)
readonly inputFormLink = (): Locator => this.page.locator('form[id="text-editor:string:Link"] input') readonly inputFormLink = (): Locator => this.page.locator('form[id="text-editor:string:Link"] input')
readonly buttonFormLinkSave = (): Locator => readonly buttonFormLinkSave = (): Locator =>
this.page.locator('form[id="text-editor:string:Link"] button[type="submit"]') this.page.locator('form[id="text-editor:string:Link"] button[type="submit"]')
@ -61,6 +64,18 @@ export class DocumentContentPage extends CommonPage {
readonly slashActionItemsPopup = (): Locator => this.page.locator('.selectPopup') readonly slashActionItemsPopup = (): Locator => this.page.locator('.selectPopup')
readonly codeBlock = (hasText: string): Locator => this.page.locator('pre.proseCodeBlock > code', { hasText })
readonly inputFormNote = (): Locator => this.page.locator('form[id="text-editor:string:ConfigureNote"] textarea')
readonly colorFormNote = (color: string): Locator =>
this.page.locator(`form[id="text-editor:string:ConfigureNote"] div.colorBox.${color}`)
readonly setFormNote = (): Locator =>
this.page.locator('form[id="text-editor:string:ConfigureNote"] div.antiCard-footer button[type="submit"]')
readonly inputImageFile = (): Locator => this.page.locator('input[id="imageInput"]')
readonly imageInContent = (): Locator => this.page.locator('p img[data-type="image"]')
readonly noteInContent = (hasText: string): Locator => this.page.locator('p span[data-mark="note"]', { hasText })
async checkDocumentTitle (title: string): Promise<void> { async checkDocumentTitle (title: string): Promise<void> {
await expect(this.buttonDocumentTitle()).toHaveValue(title) await expect(this.buttonDocumentTitle()).toHaveValue(title)
} }
@ -69,10 +84,10 @@ export class DocumentContentPage extends CommonPage {
await expect(this.buttonLockedInTitle()).toBeVisible({ timeout: 1000 }) await expect(this.buttonLockedInTitle()).toBeVisible({ timeout: 1000 })
} }
async addContentToTheNewLine (newContent: string): Promise<string> { async addContentToTheNewLine (newContent: string, newLine: boolean = true): Promise<string> {
await expect(this.inputContent()).toBeVisible() await expect(this.inputContent()).toBeVisible()
await expect(this.inputContent()).toHaveJSProperty('contentEditable', 'true') await expect(this.inputContent()).toHaveJSProperty('contentEditable', 'true')
await this.inputContent().pressSequentially(`\n${newContent}`) await this.inputContent().pressSequentially(`${newLine ? '\n' : ''}${newContent}`)
const endContent = await this.inputContent().textContent() const endContent = await this.inputContent().textContent()
return endContent ?? '' return endContent ?? ''
} }
@ -202,14 +217,70 @@ export class DocumentContentPage extends CommonPage {
async addLinkToText (text: string, link: string): Promise<void> { async addLinkToText (text: string, link: string): Promise<void> {
await expect(this.page.locator('p', { hasText: text })).toBeVisible() await expect(this.page.locator('p', { hasText: text })).toBeVisible()
await this.page.locator('p', { hasText: text }).click() await this.page.locator('p', { hasText: text }).click({ clickCount: 3 })
await this.page.locator('p', { hasText: text }).dblclick() await this.buttonOnToolbar('btnLink').click()
await this.buttonToolbarLink().click()
await this.inputFormLink().fill(link) await this.inputFormLink().fill(link)
await this.buttonFormLinkSave().click() await this.buttonFormLinkSave().click()
} }
async clickButtonOnTooltip (id: string): Promise<void> {
await this.buttonOnToolbar(id).click()
}
async selectLine (text: string): Promise<void> {
const loc: Locator = this.page.locator('p', { hasText: text }).first()
await expect(loc).toBeVisible()
await loc.click({ clickCount: 3 })
}
async applyToolbarCommand (text: string, btnId: string): Promise<void> {
await this.selectLine(text)
await this.clickButtonOnTooltip(btnId)
}
async addSeparator (text: string): Promise<void> {
await this.selectLine(text)
await this.clickLeftMenu()
await this.clickPopupItem('Separator line')
}
async goToByTOC (text: string): Promise<void> {
await this.tocItems().first().click()
await this.clickPopupItem(text)
}
async clickLeftMenu (): Promise<void> {
await this.leftMenu().click()
}
async changeCodeBlockLanguage (text: string, oldLang: string, lang: string): Promise<void> {
await this.codeBlock(text).locator('button.antiButton', { hasText: oldLang }).nth(1).click()
await this.selectMenuItem(this.page, lang, true)
}
async applyNote (text: string, color: string, note: string): Promise<void> {
await this.applyToolbarCommand(text, 'btnNote')
await this.inputFormNote().fill(note)
await this.colorFormNote(color).click()
await this.setFormNote().click()
}
async checkNote (text: string, color: string, note: string): Promise<void> {
await expect(this.noteInContent(text)).toBeVisible()
await expect(this.noteInContent(text)).toHaveAttribute('data-kind', color)
await expect(this.noteInContent(text)).toHaveAttribute('title', note)
}
async addImage (text: string): Promise<void> {
await this.selectLine(text)
await this.inputImageFile().setInputFiles(path.join(__dirname, '../../files/cat.jpeg'))
}
async checkImage (width: number = 215): Promise<void> {
await expect(this.imageInContent()).toHaveAttribute('width', width.toString())
}
async checkLinkInTheText (text: string, link: string): Promise<void> { async checkLinkInTheText (text: string, link: string): Promise<void> {
await expect(this.page.locator('a', { hasText: text })).toHaveAttribute('href', link) await expect(this.page.locator('a', { hasText: text })).toHaveAttribute('href', link)
} }

View File

@ -59,6 +59,10 @@ export class TalentDetailsPage extends CommonRecruitingPage {
await this.buttonFinalContact().click() await this.buttonFinalContact().click()
await this.selectMenuItem(this.page, talentName.finalContactName) await this.selectMenuItem(this.page, talentName.finalContactName)
await expect(
this.buttonMergeRow().locator('div.flex-center', { hasText: talentName.name }).locator('label.checkbox-container')
).toBeVisible()
await this.buttonMergeRow() await this.buttonMergeRow()
.locator('div.flex-center', { hasText: talentName.name }) .locator('div.flex-center', { hasText: talentName.name })
.locator('label.checkbox-container') .locator('label.checkbox-container')