UBERF-4716: Activity info message (#4241)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2023-12-22 01:13:02 +07:00 committed by GitHub
parent 02e5513dda
commit b5bb679998
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 318 additions and 78 deletions

View File

@ -15,6 +15,7 @@
import {
type ActivityAttributeUpdatesPresenter,
type ActivityInfoMessage,
type ActivityDoc,
type ActivityExtension,
type ActivityExtensionKind,
@ -28,7 +29,8 @@ import {
type DocUpdateMessageViewlet,
type DocUpdateMessageViewletAttributesConfig,
type Reaction,
type TxViewlet
type TxViewlet,
type ActivityMessageControl
} from '@hcengineering/activity'
import core, {
DOMAIN_MODEL,
@ -51,7 +53,8 @@ import {
TypeString,
Mixin,
Collection,
TypeBoolean
TypeBoolean,
TypeIntlString
} from '@hcengineering/model'
import { TAttachedDoc, TClass, TDoc } from '@hcengineering/model-core'
import type { Asset, IntlString, Resource } from '@hcengineering/platform'
@ -119,6 +122,24 @@ export class TDocUpdateMessage extends TActivityMessage implements DocUpdateMess
attributeUpdates?: DocAttributeUpdates
}
@Model(activity.class.ActivityInfoMessage, activity.class.ActivityMessage, DOMAIN_ACTIVITY)
export class TActivityInfoMessage extends TActivityMessage implements ActivityInfoMessage {
@Prop(TypeIntlString(), activity.string.Update)
message!: IntlString
props!: Record<string, any>
icon!: Asset
iconProps!: Record<string, any>
}
@Model(activity.class.ActivityMessageControl, core.class.Doc, DOMAIN_MODEL)
export class TActivityMessageControl extends TDoc implements ActivityMessageControl {
objectClass!: Ref<Class<Doc>>
// A set of rules to be skipped from generate doc update activity messages
skip!: DocumentQuery<Tx>[]
}
@Model(activity.class.DocUpdateMessageViewlet, core.class.Doc, DOMAIN_MODEL)
export class TDocUpdateMessageViewlet extends TDoc implements DocUpdateMessageViewlet {
@Prop(TypeRef(core.class.Doc), core.string.Class)
@ -184,7 +205,9 @@ export function createModel (builder: Builder): void {
TDocUpdateMessageViewlet,
TActivityExtension,
TReaction,
TActivityAttributeUpdatesPresenter
TActivityAttributeUpdatesPresenter,
TActivityInfoMessage,
TActivityMessageControl
)
builder.mixin(activity.class.DocUpdateMessage, core.class.Class, activity.mixin.ActivityDoc, {})
@ -193,6 +216,10 @@ export function createModel (builder: Builder): void {
presenter: activity.component.DocUpdateMessagePresenter
})
builder.mixin(activity.class.ActivityInfoMessage, core.class.Class, view.mixin.ObjectPresenter, {
presenter: activity.component.ActivityInfoMessagePresenter
})
builder.createDoc(activity.class.ActivityMessagesFilter, core.space.Model, {
label: activity.string.Attributes,
filter: activity.filter.AttributesFilter

View File

@ -127,7 +127,7 @@ export async function translate<P extends Record<string, any>> (
cache.set(message, translation)
return message
}
const compiled = new IntlMessageFormat(translation, locale)
const compiled = new IntlMessageFormat(translation, locale, undefined, { ignoreTag: true })
cache.set(message, compiled)
return compiled.format(params)
} catch (err) {

View File

@ -39,7 +39,7 @@
let isNewestFirst = JSON.parse(localStorage.getItem('activity-newest-first') ?? 'false')
$: client.findAll(activity.class.ActivityExtension, { ofClass: object._class }).then((res) => {
$: void client.findAll(activity.class.ActivityExtension, { ofClass: object._class }).then((res) => {
extensions = res
})
@ -64,7 +64,7 @@
}
}
$: updateActivityMessages(object._id, isNewestFirst ? SortingOrder.Descending : SortingOrder.Ascending)
$: void updateActivityMessages(object._id, isNewestFirst ? SortingOrder.Descending : SortingOrder.Ascending)
</script>
<div class="antiSection-header high mt-9" class:invisible={transparent}>

View File

@ -0,0 +1,107 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { ActivityInfoMessage } from '@hcengineering/activity'
import { Employee, PersonAccount } from '@hcengineering/contact'
import {
Avatar,
SystemAvatar,
employeeByIdStore,
personAccountByIdStore,
personByIdStore
} from '@hcengineering/contact-resources'
import ActivityMessageTemplate from './ActivityMessageTemplate.svelte'
import { Ref } from '@hcengineering/core'
import { translate } from '@hcengineering/platform'
import { MessageViewer } from '@hcengineering/presentation'
import ActivityMessageHeader from './ActivityMessageHeader.svelte'
export let value: ActivityInfoMessage
export let showNotify: boolean = false
export let isHighlighted: boolean = false
export let isSelected: boolean = false
export let shouldScroll: boolean = false
export let embedded: boolean = false
export let hasActionsMenu: boolean = true
export let onClick: (() => void) | undefined = undefined
$: personAccount = $personAccountByIdStore.get((value.createdBy ?? value.modifiedBy) as Ref<PersonAccount>)
$: person =
personAccount?.person !== undefined
? $employeeByIdStore.get(personAccount.person as Ref<Employee>) ?? $personByIdStore.get(personAccount.person)
: undefined
let content = ''
$: void translate(value.message, value.props)
.then((message) => {
content = message
})
.catch((err) => {
content = JSON.stringify(err, null, 2)
})
</script>
<ActivityMessageTemplate
message={value}
parentMessage={undefined}
{person}
{showNotify}
{isHighlighted}
{isSelected}
{shouldScroll}
{embedded}
{hasActionsMenu}
viewlet={undefined}
{onClick}
>
<svelte:fragment slot="icon">
{#if value.icon}
<SystemAvatar size="medium" icon={value.icon} iconProps={value.iconProps} />
{:else if person}
<Avatar size="medium" avatar={person.avatar} name={person.name} />
{:else}
<SystemAvatar size="medium" />
{/if}
</svelte:fragment>
<svelte:fragment slot="header">
<ActivityMessageHeader
message={value}
{person}
object={undefined}
parentObject={undefined}
isEdited={false}
label={value.title}
/>
</svelte:fragment>
<svelte:fragment slot="content">
<div class="flex-row-center">
<div class="customContent">
<MessageViewer message={content} />
</div>
</div>
</svelte:fragment>
</ActivityMessageTemplate>
<style lang="scss">
.customContent {
display: flex;
flex-wrap: wrap;
column-gap: 0.625rem;
row-gap: 0.625rem;
}
</style>

View File

@ -0,0 +1,63 @@
<!--
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { ActivityMessage } from '@hcengineering/activity'
import { Person } from '@hcengineering/contact'
import { Doc } from '@hcengineering/core'
import notification from '../../plugin'
import { Label } from '@hcengineering/ui'
import { DocNavLink } from '@hcengineering/view-resources'
import { LinkData, getLinkData } from '../../activityMessagesUtils'
import { IntlString } from '@hcengineering/platform'
export let message: ActivityMessage
export let person: Person | undefined
export let object: Doc | undefined
export let parentObject: Doc | undefined
export let label: IntlString | undefined = undefined
export let isEdited: boolean = false
let linkData: LinkData | undefined = undefined
$: void getLinkData(message, object, parentObject, person).then((data) => {
linkData = data
})
</script>
<span class="text-sm lower">
{#if label}
<Label {label} />
{/if}
</span>
{#if linkData}
<span class="text-sm lower"><Label label={linkData.preposition} /></span>
<span class="text-sm">
<DocNavLink {object} component={linkData.panelComponent} shrink={0}>
<span class="overflow-label select-text">{linkData.title}</span>
</DocNavLink>
</span>
{#if isEdited}
<span class="text-sm lower"><Label label={notification.string.Edited} /></span>
{/if}
{/if}
<style lang="scss">
span {
margin-left: 0.25rem;
font-weight: 400;
line-height: 1.25rem;
}
</style>

View File

@ -13,23 +13,22 @@
// limitations under the License.
-->
<script lang="ts">
import activity, {
ActivityMessageExtension,
ActivityMessageViewlet,
DisplayActivityMessage
} from '@hcengineering/activity'
import { Person } from '@hcengineering/contact'
import { Avatar, EmployeePresenter, SystemAvatar } from '@hcengineering/contact-resources'
import core, { getDisplayTime } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import core from '@hcengineering/core/lib/component'
import activity, {
DisplayActivityMessage,
ActivityMessageExtension,
ActivityMessageViewlet
} from '@hcengineering/activity'
import { Action, ActionIcon, IconMoreH, Label, showPopup } from '@hcengineering/ui'
import { getActions, Menu } from '@hcengineering/view-resources'
import { getDisplayTime } from '@hcengineering/core'
import { Menu, getActions } from '@hcengineering/view-resources'
import ActivityMessageExtensionComponent from './ActivityMessageExtension.svelte'
import ActivityMessagePresenter from './ActivityMessagePresenter.svelte'
import AddReactionAction from '../reactions/AddReactionAction.svelte'
import ReactionsPresenter from '../reactions/ReactionsPresenter.svelte'
import ActivityMessageExtensionComponent from './ActivityMessageExtension.svelte'
import ActivityMessagePresenter from './ActivityMessagePresenter.svelte'
import PinMessageAction from './PinMessageAction.svelte'
export let message: DisplayActivityMessage
@ -54,38 +53,38 @@
let extensions: ActivityMessageExtension[] = []
let isActionMenuOpened = false
$: getActions(client, message, activity.class.ActivityMessage).then((res) => {
$: void getActions(client, message, activity.class.ActivityMessage).then((res) => {
allActionIds = res.map(({ _id }) => _id)
})
function scrollToMessage () {
if (element && shouldScroll) {
function scrollToMessage (): void {
if (element != null && shouldScroll) {
element.scrollIntoView({ behavior: 'auto', block: 'end' })
shouldScroll = false
}
}
$: if (element && shouldScroll) {
$: if (element != null && shouldScroll) {
setTimeout(scrollToMessage, 100)
}
client
void client
.findAll(activity.class.ActivityMessageExtension, { ofMessage: message._class })
.then((res: ActivityMessageExtension[]) => {
extensions = res
})
function handleActionMenuOpened () {
function handleActionMenuOpened (): void {
isActionMenuOpened = true
}
function handleActionMenuClosed () {
function handleActionMenuClosed (): void {
isActionMenuOpened = false
}
$: key = parentMessage ? `${message._id}_${parentMessage._id}` : message._id
$: key = parentMessage != null ? `${message._id}_${parentMessage._id}` : message._id
function showMenu (ev: MouseEvent) {
function showMenu (ev: MouseEvent): void {
showPopup(
Menu,
{
@ -124,7 +123,9 @@
{/if}
{#if !embedded}
<div class="min-w-6 mt-1">
{#if person}
{#if $$slots.icon}
<slot name="icon" />
{:else if person}
<Avatar size="medium" avatar={person.avatar} name={person.name} />
{:else}
<SystemAvatar size="medium" />
@ -138,9 +139,9 @@
{#if person}
<EmployeePresenter value={person} shouldShowAvatar={false} />
{:else}
<span class="strong">
<div class="strong">
<Label label={core.string.System} />
</span>
</div>
{/if}
<slot name="header" />

View File

@ -36,7 +36,7 @@
let linkData: LinkData | undefined = undefined
$: getLinkData(message, object, parentObject, person).then((data) => {
$: void getLinkData(message, object, parentObject, person).then((data) => {
linkData = data
})

View File

@ -13,27 +13,25 @@
// limitations under the License.
-->
<script lang="ts">
import { Person, PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import { Account, AttachedDoc, Class, Collection, Doc, Ref } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import core from '@hcengineering/core/lib/component'
import { AttributeModel } from '@hcengineering/view'
import { IntlString } from '@hcengineering/platform'
import { Component, ShowMore } from '@hcengineering/ui'
import activity, {
ActivityMessage,
DisplayActivityMessage,
DisplayDocUpdateMessage,
DocUpdateAction,
DocUpdateMessage,
DocUpdateMessageViewlet
} from '@hcengineering/activity'
import { Person, PersonAccount } from '@hcengineering/contact'
import { personByIdStore } from '@hcengineering/contact-resources'
import core, { Account, AttachedDoc, Class, Collection, Doc, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Component, ShowMore } from '@hcengineering/ui'
import { AttributeModel } from '@hcengineering/view'
import ActivityMessageTemplate from '../activity-message/ActivityMessageTemplate.svelte'
import DocUpdateMessageHeader from './DocUpdateMessageHeader.svelte'
import DocUpdateMessageContent from './DocUpdateMessageContent.svelte'
import DocUpdateMessageAttributes from './DocUpdateMessageAttributes.svelte'
import DocUpdateMessageContent from './DocUpdateMessageContent.svelte'
import DocUpdateMessageHeader from './DocUpdateMessageHeader.svelte'
import { getAttributeModel, getCollectionAttribute } from '../../activityMessagesUtils'
import { buildRemovedDoc, checkIsObjectRemoved } from '@hcengineering/view-resources'
@ -50,7 +48,6 @@
const client = getClient()
const hierarchy = client.getHierarchy()
const viewletQuery = createQuery()
const userQuery = createQuery()
const objectQuery = createQuery()
const parentObjectQuery = createQuery()
@ -70,31 +67,15 @@
let object: Doc | undefined
let isObjectRemoved: boolean = false
let isViewletLoading = true
let isObjectLoading = true
$: isLoading = isViewletLoading || isObjectLoading
$: isLoading = isObjectLoading
$: loadViewlet(value.action, value.objectClass)
$: [viewlet] = client
.getModel()
.findAllSync(activity.class.DocUpdateMessageViewlet, { action: value.action, objectClass: value.objectClass })
async function loadViewlet (action: DocUpdateAction, objectClass: Ref<Class<Doc>>) {
isViewletLoading = true
const res = viewletQuery.query(
activity.class.DocUpdateMessageViewlet,
{ action, objectClass },
(result: DocUpdateMessageViewlet[]) => {
viewlet = result[0]
isViewletLoading = false
}
)
if (!res) {
isViewletLoading = false
}
}
$: getAttributeModel(client, value.attributeUpdates, value.attachedToClass).then((model) => {
$: void getAttributeModel(client, value.attributeUpdates, value.attachedToClass).then((model) => {
attributeModel = model
})
@ -104,7 +85,7 @@
}
}
$: getParentMessage(value.attachedToClass, value.attachedTo).then((res) => {
$: void getParentMessage(value.attachedToClass, value.attachedTo).then((res) => {
parentMessage = res as DisplayActivityMessage
})
@ -112,12 +93,12 @@
user = res[0] as PersonAccount
})
$: person = user?.person && $personByIdStore.get(user.person)
$: person = user?.person != null ? $personByIdStore.get(user.person) : undefined
$: loadObject(value.objectId, value.objectClass)
$: loadParentObject(value, parentMessage)
$: void loadObject(value.objectId, value.objectClass)
$: void loadParentObject(value, parentMessage)
async function loadObject (_id: Ref<Doc>, _class: Ref<Class<Doc>>) {
async function loadObject (_id: Ref<Doc>, _class: Ref<Class<Doc>>): Promise<void> {
isObjectRemoved = await checkIsObjectRemoved(client, _id, _class)
if (isObjectRemoved) {
@ -131,7 +112,7 @@
}
}
async function loadParentObject (message: DocUpdateMessage, parentMessage?: ActivityMessage) {
async function loadParentObject (message: DocUpdateMessage, parentMessage?: ActivityMessage): Promise<void> {
if (!parentMessage && message.objectId === message.attachedTo) {
return
}
@ -150,12 +131,12 @@
})
}
$: if (object && value.objectClass !== object._class) {
$: if (object != null && value.objectClass !== object._class) {
object = undefined
}
</script>
{#if !isLoading && (!viewlet?.hideIfRemoved || !isObjectRemoved) && (value.action !== 'update' || attributeModel !== undefined)}
{#if !isLoading && (!(viewlet?.hideIfRemoved ?? false) || !isObjectRemoved) && (value.action !== 'update' || attributeModel !== undefined)}
<ActivityMessageTemplate
message={value}
{parentMessage}

View File

@ -18,6 +18,7 @@ import { type Resources } from '@hcengineering/platform'
import Activity from './components/Activity.svelte'
import ActivityMessagePresenter from './components/activity-message/ActivityMessagePresenter.svelte'
import DocUpdateMessagePresenter from './components/doc-update-message/DocUpdateMessagePresenter.svelte'
import ActivityInfoMessagePresenter from './components/activity-message/ActivityInfoMessagePresenter.svelte'
import ReactionAddedMessage from './components/reactions/ReactionAddedMessage.svelte'
import { attributesFilter, pinnedFilter } from './activityMessagesUtils'
@ -36,7 +37,8 @@ export default async (): Promise<Resources> => ({
Activity,
ActivityMessagePresenter,
DocUpdateMessagePresenter,
ReactionAddedMessage
ReactionAddedMessage,
ActivityInfoMessagePresenter
},
filter: {
AttributesFilter: attributesFilter,

View File

@ -113,13 +113,37 @@ export interface ActivityMessage extends AttachedDoc {
reactions?: number
}
export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage
export type DisplayActivityMessage = DisplayDocUpdateMessage | ActivityMessage | ActivityInfoMessage
export interface DisplayDocUpdateMessage extends DocUpdateMessage {
previousMessages?: DocUpdateMessage[]
combinedMessagesIds?: Ref<DocUpdateMessage>[]
}
/**
* Designed to control and filter some of changes from being to be propagated into activity.
* @public
*/
export interface ActivityMessageControl extends Doc {
objectClass: Ref<Class<Doc>>
// A set of rules to be skipped from generate doc update activity messages
skip: DocumentQuery<Tx>[]
}
/**
*
* General information activity message.
* @public
*/
export interface ActivityInfoMessage extends ActivityMessage {
title?: IntlString
message: IntlString
props?: Record<string, any>
icon?: Asset
iconProps?: Record<string, any>
}
export type ActivityMessageExtensionKind = 'action' | 'footer'
/**
@ -252,6 +276,8 @@ export default plugin(activityId, {
TxViewlet: '' as Ref<Class<TxViewlet>>,
DocUpdateMessage: '' as Ref<Class<DocUpdateMessage>>,
ActivityMessage: '' as Ref<Class<ActivityMessage>>,
ActivityInfoMessage: '' as Ref<Class<ActivityInfoMessage>>,
ActivityMessageControl: '' as Ref<Class<ActivityMessageControl>>,
DocUpdateMessageViewlet: '' as Ref<Class<DocUpdateMessageViewlet>>,
ActivityMessageExtension: '' as Ref<Class<ActivityMessageExtension>>,
ActivityMessagesFilter: '' as Ref<Class<ActivityMessagesFilter>>,
@ -288,6 +314,7 @@ export default plugin(activityId, {
Activity: '' as AnyComponent,
ActivityMessagePresenter: '' as AnyComponent,
DocUpdateMessagePresenter: '' as AnyComponent,
ActivityInfoMessagePresenter: '' as AnyComponent,
ReactionAddedMessage: '' as AnyComponent
}
})

View File

@ -13,17 +13,24 @@
// limitations under the License.
-->
<script lang="ts">
import { IconSize, resolvedLocationStore } from '@hcengineering/ui'
import { Icon, IconSize, resolvedLocationStore } from '@hcengineering/ui'
import { Asset } from '@hcengineering/platform'
export let size: IconSize
export let variant: 'circle' | 'roundedRect' = 'circle'
export let icon: Asset | undefined = undefined
export let iconProps: Record<string, any> | undefined = undefined
$: workspace = $resolvedLocationStore.path[1]
</script>
<div class="avatar {size} {variant}">
<div class="text">
{#if icon}
<Icon {icon} {size} {iconProps} />
{:else}
{workspace?.toUpperCase()?.[0]}
{/if}
</div>
</div>
@ -32,11 +39,12 @@
display: flex;
justify-content: center;
align-items: center;
color: var(--white-color);
background-color: rgb(246, 105, 77);
color: var(--avatar-border-color);
background-color: var(--avatar-bg-color);
.text {
font-weight: 500;
color: var(--theme-accent-color);
}
&.circle {

View File

@ -13,11 +13,13 @@
// limitations under the License.
//
import activity, { DocUpdateMessage } from '@hcengineering/activity'
import {
Account,
AttachedDoc,
Data,
Doc,
matchQuery,
Ref,
Tx,
TxCollectionCUD,
@ -25,10 +27,9 @@ import {
TxCUD,
TxProcessor
} from '@hcengineering/core'
import type { TriggerControl } from '@hcengineering/server-core'
import activity, { DocUpdateMessage } from '@hcengineering/activity'
import core from '@hcengineering/core/lib/component'
import { ActivityControl, DocObjectCache } from '@hcengineering/server-activity'
import type { TriggerControl } from '@hcengineering/server-core'
import { getDocUpdateAction, getTxAttributesUpdates } from './utils'
// export async function OnReactionChanged (originTx: Tx, control: TriggerControl): Promise<Tx[]> {
@ -222,6 +223,29 @@ export async function generateDocUpdateMessages (
if (control.hierarchy.isDerived(tx.objectClass, activity.class.ActivityMessage)) {
return res
}
const etx = TxProcessor.extractTx(tx)
if (
control.hierarchy.isDerived(etx._class, core.class.TxCUD) &&
control.hierarchy.isDerived((etx as TxCUD<Doc>).objectClass, activity.class.ActivityMessage)
) {
return res
}
// Check if we have override control over transaction => activity mappings
const controlRules = control.modelDb.findAllSync(activity.class.ActivityMessageControl, {
objectClass: { $in: control.hierarchy.getDescendants(tx.objectClass) }
})
if (controlRules.length > 0) {
for (const r of controlRules) {
for (const s of r.skip) {
const otx = originTx ?? TxProcessor.extractTx(tx)
if (matchQuery(otx !== undefined ? [tx, otx] : [tx], s, r.objectClass, control.hierarchy).length > 0) {
// Match found, we need to skip
return res
}
}
}
}
switch (tx._class) {
case core.class.TxCreateDoc: {