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:
specifier: ^4.56.0
version: 4.56.0(zod@3.23.8)
openid-client:
specifier: ~5.7.0
version: 5.7.0
otp-generator:
specifier: ^4.0.1
version: 4.0.1
@ -17193,6 +17196,10 @@ packages:
engines: {node: '>= 0.6.0'}
dev: false
/jose@4.15.9:
resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==}
dev: false
/jose@5.6.3:
resolution: {integrity: sha512-1Jh//hEEwMhNYPDDLwXHa2ePWgWiFNNUadVmguAAw2IJ6sj9mNxV5tGXJNqlMkJAybF6Lgw1mISDxTePP/187g==}
dev: false
@ -19076,6 +19083,11 @@ packages:
resolution: {integrity: sha512-CsubGNxhIEChNY4cXYuA6KXafztzHqzLLZ/y3Kasf3A+sa3lL9thq3z+7o0pZqzEinjXT6lXDPAfVWI59dUyzQ==}
dev: false
/object-hash@2.2.0:
resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==}
engines: {node: '>= 6'}
dev: false
/object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
@ -19210,6 +19222,11 @@ packages:
resolution: {integrity: sha512-zuHHiGTYTA1sYJ/wZN+t5HKZaH23i4yI1HMwbuXm24Nid7Dv0KcuRlKoNKS9UNfAVSBlnGLcuQrnOKWOZoEGaw==}
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:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
dev: false
@ -19298,6 +19315,15 @@ packages:
hasBin: true
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:
resolution: {integrity: sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==}
dev: false
@ -25220,7 +25246,7 @@ packages:
dev: false
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
name: '@rush-temp/auth-providers'
version: 0.0.0
@ -25248,6 +25274,7 @@ packages:
koa-router: 12.0.1
koa-session: 6.4.0
mongodb: 6.9.0
openid-client: 5.7.0
passport-custom: 1.1.1
passport-github2: 0.1.12
passport-google-oauth20: 2.0.0

View File

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

View File

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

View File

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

View File

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

View File

@ -321,6 +321,7 @@
width: 100%;
min-width: 0;
min-height: var(--spacing-6_5);
overflow: hidden;
&.clearPadding > .hulyHeader-row {
padding: 0;
@ -577,9 +578,12 @@
}
&.second:not(.isOpen),
&.border,
&.default:not(.nested:last-child) {
&.default:not(.nested) {
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 {
display: flex;
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 {
color: var(--button-subtle-IconColor);
background-color: var(--global-ui-hover-BackgroundColor);
@ -806,6 +816,9 @@
&.medium.bottomSpace + .hulyAccordionItem-content {
padding-bottom: var(--spacing-2);
}
&.medium.nested.bottomSpace + .hulyAccordionItem-content {
padding-bottom: var(--spacing-1);
}
&.large.bottomSpace + .hulyAccordionItem-content {
padding-bottom: var(--spacing-2);
}

View File

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

View File

@ -39,6 +39,7 @@
export let noPrint: boolean = false
export let freezeBefore: boolean = false
export let doubleRowWidth = 768
export let closeOnEscape: boolean = true
const dispatch = createEventDispatcher()
@ -61,7 +62,7 @@
})
function _close (ev: KeyboardEvent): void {
if (closeButton && ev.key === 'Escape') {
if (closeButton && ev.key === 'Escape' && closeOnEscape) {
ev.preventDefault()
ev.stopPropagation()
@ -138,7 +139,9 @@
{/if}
{#if closeButton}
{#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')} />
{/if}
</div>
@ -233,7 +236,9 @@
{/if}
{#if closeButton}
{#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')} />
{/if}
{/if}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -50,7 +50,7 @@
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
const unsubscribe = notificationClient.inboxNotificationsByContext.subscribe((res) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -76,7 +76,7 @@
if (direct !== undefined && missingAccounts.length > 0) {
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}
<div class="root" style:height style:width>
<ThreadView _id={tab.data.thread} on:close />
<ThreadView _id={tab.data.thread} on:close on:channel />
</div>
{/if}

View File

@ -15,7 +15,7 @@
<script lang="ts">
import { Doc, Ref } from '@hcengineering/core'
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 activity, { ActivityMessage, DisplayActivityMessage } from '@hcengineering/activity'
import { getMessageFromLoc, messageInFocus } from '@hcengineering/activity-resources'
@ -104,7 +104,14 @@
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) {
return []
}
@ -115,6 +122,7 @@
return [
{
id: 'channel',
icon: getObjectIcon(message.attachedToClass),
iconProps: { value: channel },
iconWidth: isPersonAvatar ? 'auto' : undefined,
@ -122,14 +130,24 @@
title: channelName,
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>
{#if showHeader}
<Header type={'type-aside'} adaptive={'disabled'} on:close>
<Breadcrumbs items={getBreadcrumbsItems(channel, message, channelName)} currentOnly />
<Header type={'type-aside'} adaptive={'disabled'} closeOnEscape={false} on:close>
<Breadcrumbs items={breadcrumbs} on:select={handleBreadcrumbSelect} selected={1} />
</Header>
{/if}
@ -141,8 +159,8 @@
skipLabels
object={message}
provider={dataProvider}
initialScrollBottom={false}
fullHeight={false}
fixedInput={false}
>
<svelte:fragment slot="header">
<div class="mt-3">

View File

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

View File

@ -6,7 +6,7 @@ import {
type Location,
navigate
} 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 {
type Channel,
@ -262,11 +262,11 @@ export async function openChannelInSidebar (
size: isDirect || isPerson ? 'tiny' : 'x-small',
compact: true
},
type: 'channel',
data: {
_id,
_class,
thread
thread,
channelName: name
}
}
@ -288,18 +288,26 @@ export async function openThreadInSidebarChannel (
): Promise<void> {
const newTab: ChatWidgetTab = {
...tab,
name: await translate(chunter.string.ThreadIn, { name: tab.data.channelName }),
data: { ...tab.data, thread: message._id }
}
createWidgetTab(widget, newTab)
}
export async function closeThreadInSidebarChannel (widget: Widget, tab: ChatWidgetTab): Promise<void> {
const thread = tab.allowedPath !== undefined ? tab.data.thread : undefined
const newTab: ChatWidgetTab = {
...tab,
id: tab.id.startsWith('thread_') ? generateId() : tab.id,
name: tab.data.channelName,
allowedPath: undefined,
data: { ...tab.data, thread: undefined }
}
createWidgetTab(widget, newTab)
setTimeout(() => {
removeThreadFromLoc(thread)
}, 100)
}
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 currentTAbs = get(sidebarStore).widgetsState.get(widget._id)?.tabs ?? []
const tabsToClose = currentTAbs
.filter((t) => t.isPinned !== true && t.allowedPath === allowedPath && (t as ChatWidgetTab).type === 'thread')
.map((t) => t.id)
const tabsToClose = currentTAbs.filter((t) => t.isPinned !== true && t.allowedPath === allowedPath).map((t) => t.id)
if (tabsToClose.length > 0) {
sidebarStore.update((s) => {
@ -341,26 +347,31 @@ export async function openThreadInSidebar (_id: Ref<ActivityMessage>, msg?: Acti
name: tabName,
icon: chunter.icon.Thread,
allowedPath,
type: 'thread',
data: {
_id: object?._id,
_class: object?._class,
thread: message._id
thread: message._id,
channelName: name
}
}
createWidgetTab(widget, tab, true)
}
export function closeChatWidgetTab (tab?: ChatWidgetTab): void {
if (tab?.type === 'thread') {
const loc = getCurrentLocation()
export function removeThreadFromLoc (thread?: Ref<ActivityMessage>): void {
if (thread === undefined) return
const loc = getCurrentLocation()
if (loc.path[2] === chunterId || loc.path[2] === notificationId) {
if (loc.path[4] === tab.data.thread) {
loc.path[4] = ''
loc.path.length = 4
navigate(loc)
}
if (loc.path[2] === chunterId || loc.path[2] === notificationId) {
if (loc.path[4] === thread) {
loc.path[4] = ''
loc.path.length = 4
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
}
const inboxClient = InboxNotificationsClientImpl.getClient()
if (context === undefined) {
return
}
const inboxClient = InboxNotificationsClientImpl.getClient()
const client = getClient().apply(undefined, 'readViewportMessages')
try {
const readMessages = get(chatReadMessagesStore)
const allIds = getAllIds(messages).filter((id) => !readMessages.has(id))
@ -460,12 +464,6 @@ export async function readChannelMessages (
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 newTimestamp = messages[messages.length - 1].createdOn ?? 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 inboxClient.readNotifications(client, [...notifications, ...relatedMentions])
} finally {
await client.commit()
}

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@
import { getProviders } from '../utils'
import Github from './providers/Github.svelte'
import Google from './providers/Google.svelte'
import OpenId from './providers/OpenId.svelte'
interface Provider {
name: string
@ -21,6 +22,10 @@
{
name: '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 { parseLinkId } from '@hcengineering/view-resources'
import { get } from 'svelte/store'
import { getResource } from '@hcengineering/platform'
import { InboxNotificationsClientImpl } from '../../inboxNotificationsClient'
import notification from '../../plugin'
@ -179,6 +180,12 @@
}
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) {
selectedMessage = get(inboxClient.activityInboxNotifications).find(

View File

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

View File

@ -81,7 +81,7 @@
}
</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}
<ToDoDraggable {todo} {index} {groupName} {projectId} on:drop={handleDrop}>
<ToDoElement {todo} planned={mode !== 'unplanned'} />

View File

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

View File

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

View File

@ -143,3 +143,7 @@ export async function canLeaveSpace (doc?: Doc | Doc[]): Promise<boolean> {
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}
hideDescription={true}
adaptive="disabled"
closeOnEscape={false}
on:close={() => {
if (widget !== undefined) {
closeWidget(widget._id)
@ -168,5 +169,6 @@
flex-direction: column;
flex: 1;
overflow: hidden;
border-bottom: 1px solid transparent;
}
</style>

View File

@ -101,7 +101,12 @@ export function openWidget (widget: Widget, data?: Record<string, any>, active =
const { widgetsState } = state
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({
...state,

View File

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

View File

@ -12,7 +12,7 @@ export function registerGithub (
passport: Passport,
router: Router<any, any>,
accountsUrl: string,
db: Db,
dbPromise: Promise<Db>,
frontUrl: string,
brandings: BrandingMap
): string | undefined {
@ -69,6 +69,7 @@ export function registerGithub (
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, {
githubId: ctx.state.user.id

View File

@ -12,7 +12,7 @@ export function registerGoogle (
passport: Passport,
router: Router<any, any>,
accountsUrl: string,
db: Db,
dbPromise: Promise<Db>,
frontUrl: string,
brandings: BrandingMap
): string | undefined {
@ -74,6 +74,7 @@ export function registerGoogle (
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)
} else {

View File

@ -5,6 +5,7 @@ import session from 'koa-session'
import { Db } from 'mongodb'
import { registerGithub } from './github'
import { registerGoogle } from './google'
import { registerOpenid } from './openid'
import { registerToken } from './token'
import { BrandingMap, MeasureContext } from '@hcengineering/core'
@ -15,7 +16,7 @@ export type AuthProvider = (
passport: Passport,
router: Router<any, any>,
accountsUrl: string,
db: Db,
db: Promise<Db>,
frontUrl: string,
brandings: BrandingMap
) => string | undefined
@ -24,7 +25,7 @@ export function registerProviders (
ctx: MeasureContext,
app: Koa<Koa.DefaultState, Koa.DefaultContext>,
router: Router<any, any>,
db: Db,
db: Promise<Db>,
serverSecret: string,
frontUrl: string | undefined,
brandings: BrandingMap
@ -60,7 +61,7 @@ export function registerProviders (
registerToken(ctx, passport, router, accountsUrl, db, frontUrl, brandings)
const res: string[] = []
const providers: AuthProvider[] = [registerGoogle, registerGithub]
const providers: AuthProvider[] = [registerGoogle, registerGithub, registerOpenid]
for (const provider of providers) {
const value = provider(ctx, passport, router, accountsUrl, db, frontUrl, brandings)
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,
router: Router<any, any>,
accountsUrl: string,
db: Db,
dbPromise: Promise<Db>,
frontUrl: string,
brandings: BrandingMap
): string | undefined {
@ -21,9 +21,11 @@ export function registerToken (
new CustomStrategy(function (req: any, done: any) {
const token = req.body.token ?? req.query.token
getAccountInfoByToken(measureCtx, db, null, token)
.then((user: any) => done(null, user))
.catch((err: any) => done(err))
void dbPromise.then((db) => {
getAccountInfoByToken(measureCtx, db, null, token)
.then((user: any) => done(null, user))
.catch((err: any) => done(err))
})
})
)

View File

@ -76,15 +76,15 @@ export class DocumentCommentsPage extends DocumentCommonPage {
reply: string
): Promise<void> {
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=../../../..')
// 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
await comment.locator('div.header div.tools button').hover()
await expect(comment.locator('div.header div.tools button')).toBeEnabled()
// 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
await expect(
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
): Promise<void> {
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=../../../..')
// 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
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()
// 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
await expect(
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())
void client.getClient().then(async (p: MongoClient) => {
const db = p.db(ACCOUNT_DB)
registerProviders(measureCtx, app, router, db, serverSecret, frontURL, brandings)
const mongoClientPromise = client.getClient()
const dbPromise = mongoClientPromise.then((c) => c.db(ACCOUNT_DB))
registerProviders(measureCtx, app, router, dbPromise, serverSecret, frontURL, brandings)
void dbPromise.then((db) => {
setInterval(
() => {
void cleanExpiredOtp(db)

View File

@ -36,7 +36,7 @@ test.describe('Content in the Documents tests', () => {
let documentsSecondPage: DocumentsPage
let documentContentSecondPage: DocumentContentPage
test.beforeEach(async ({ browser, page, request }) => {
test.beforeEach(async ({ page, request }) => {
leftSideMenuPage = new LeftSideMenuPage(page)
documentsPage = new DocumentsPage(page)
documentContentPage = new DocumentContentPage(page)
@ -116,8 +116,8 @@ test.describe('Content in the Documents tests', () => {
await documentContentPage.inputContentParapraph().click()
await documentContentPage.leftMenu().click()
await documentContentPage.menuPopupItemButton('Table').click()
await documentContentPage.menuPopupItemButton('1x2').first().click()
await documentContentPage.clickPopupItem('Table')
await documentContentPage.clickPopupItem('1x2')
await documentContentPage.proseTableCell(0, 0).fill('One')
await documentContentPage.proseTableCell(0, 1).fill('Two')
await documentContentPage.buttonInsertColumn().click()
@ -295,4 +295,64 @@ test.describe('Content in the Documents tests', () => {
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 linkDocument: NewDocument = {
title: `Links Document Title-${generateId()}`,

View File

@ -206,6 +206,10 @@ export class CommonPage {
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> {
await this.buttonFilter().click()
await this.selectPopupMenu(filter).click()

View File

@ -1,6 +1,7 @@
import { type Locator, type Page, expect } from '@playwright/test'
import { CommonPage } from '../common-page'
import { uploadFile } from '../../utils'
import path from 'path'
export class DocumentContentPage extends CommonPage {
readonly page: Page
@ -35,7 +36,9 @@ export class DocumentContentPage extends CommonPage {
readonly buttonInsertInnerRow = (row: number = 0): Locator =>
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 buttonFormLinkSave = (): Locator =>
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 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> {
await expect(this.buttonDocumentTitle()).toHaveValue(title)
}
@ -69,10 +84,10 @@ export class DocumentContentPage extends CommonPage {
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()).toHaveJSProperty('contentEditable', 'true')
await this.inputContent().pressSequentially(`\n${newContent}`)
await this.inputContent().pressSequentially(`${newLine ? '\n' : ''}${newContent}`)
const endContent = await this.inputContent().textContent()
return endContent ?? ''
}
@ -202,14 +217,70 @@ export class DocumentContentPage extends CommonPage {
async addLinkToText (text: string, link: string): Promise<void> {
await expect(this.page.locator('p', { hasText: text })).toBeVisible()
await this.page.locator('p', { hasText: text }).click()
await this.page.locator('p', { hasText: text }).dblclick()
await this.buttonToolbarLink().click()
await this.page.locator('p', { hasText: text }).click({ clickCount: 3 })
await this.buttonOnToolbar('btnLink').click()
await this.inputFormLink().fill(link)
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> {
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.selectMenuItem(this.page, talentName.finalContactName)
await expect(
this.buttonMergeRow().locator('div.flex-center', { hasText: talentName.name }).locator('label.checkbox-container')
).toBeVisible()
await this.buttonMergeRow()
.locator('div.flex-center', { hasText: talentName.name })
.locator('label.checkbox-container')