Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
850fff2
48108 fix(tasks): correct output field order and stale draft handling
aicarma-artyom-maslov Jul 13, 2026
2f199c0
48108 fix(tasks): avoid output sync races and preserve file attachments
aicarma-artyom-maslov Jul 13, 2026
1a14d92
48108 fix(fields): normalize empty radio values
aicarma-artyom-maslov Jul 15, 2026
fb5bf00
48108 fix(tasks): harden output synchronization and cleanup
aicarma-artyom-maslov Jul 15, 2026
4574567
48108 fix(tasks): prevent stale output drafts during sync
aicarma-artyom-maslov Jul 15, 2026
4cf8905
48108 refactor(tasks): align output components with project rules
aicarma-artyom-maslov Jul 15, 2026
7f269fa
48108 fix(tasks): prevent stale resync state
aicarma-artyom-maslov Jul 15, 2026
d728403
48108 fix(frontend): stabilize output and async updates
aicarma-artyom-maslov Jul 15, 2026
9713e44
48108 fix(tasks): preserve pending output drafts
aicarma-artyom-maslov Jul 15, 2026
205e22c
Merge remote-tracking branch 'origin/master' into frontend/tasks/4810…
aicarma-artyom-maslov Jul 17, 2026
2c2ef12
48108 fix(tasks): clear fieldset drafts on restart
aicarma-artyom-maslov Jul 20, 2026
935479a
48108 fix(tasks): preserve valid output drafts
aicarma-artyom-maslov Jul 20, 2026
f0d1f22
Merge remote-tracking branch 'origin/master' into frontend/tasks/4810…
aicarma-artyom-maslov Jul 21, 2026
50a54f4
48108 fix(tasks): merge fieldset drafts with server fields
aicarma-artyom-maslov Jul 21, 2026
def1cb7
48108 fix(tasks): flush drafts and restore user selections
aicarma-artyom-maslov Jul 21, 2026
077cadb
48108 fix(tasks): preserve drafts across output updates
aicarma-artyom-maslov Jul 21, 2026
7e28fc6
48108 fix(tasks): preserve field drafts during sync
aicarma-artyom-maslov Jul 21, 2026
4bcc8e6
48108 fix(tasks): validate stored drafts on mount
aicarma-artyom-maslov Jul 21, 2026
41217f0
48108 fix(tasks): sync output metadata safely
aicarma-artyom-maslov Jul 21, 2026
d9e70f6
48108 fix(tasks): normalize empty drafts on first render
aicarma-artyom-maslov Jul 21, 2026
4f0d57b
48108 fix(tasks): avoid invalidating draft fingerprints
aicarma-artyom-maslov Jul 21, 2026
f518e61
Merge remote-tracking branch 'origin/master' into frontend/tasks/4810…
aicarma-artyom-maslov Aug 3, 2026
397c0f3
Merge remote-tracking branch 'origin/master' into frontend/tasks/4810…
aicarma-artyom-maslov Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions frontend/src/public/components/Highlights/FeedItemComment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useEffect, useRef, useState } from 'react';
import classnames from 'classnames';
import { useIntl } from 'react-intl';

import { EWorkflowLogEvent } from '../../types/workflow';
import { isArrayWithItems } from '../../utils/helpers';
import { Attachments } from '../Attachments';
import { RichText } from '../RichText';

import { Ellipsis } from './Ellipsis';
import { IFeedItemCommentProps } from './types';
import { TruncatedContent } from './utils/TruncatedContent';

import styles from './FeedItem.css';

const MAX_TRUNCATED_COMMENT_HEIGHT = 20 * 5;

export function FeedItemComment({
attachments,
isTextExpanded,
onExpand,
task,
text,
type,
}: IFeedItemCommentProps) {
const { formatMessage, messages } = useIntl();
const commentTextRef = useRef<HTMLSpanElement>(null);
const [isCommentExpandable, setIsCommentExpandable] = useState(true);
const hasAttachments = Boolean(text && attachments && isArrayWithItems(attachments));

useEffect(() => {
const commentTextHeight = commentTextRef.current?.scrollHeight || 0;

setIsCommentExpandable(commentTextHeight > MAX_TRUNCATED_COMMENT_HEIGHT || hasAttachments);
}, [attachments, hasAttachments, text]);

if (!text) {
return null;
}

const isTruncated = !isTextExpanded && isCommentExpandable;

return (
<>
<div className={styles['header__comment']}>
<span className={styles['comment__title']}>
{type === EWorkflowLogEvent.TaskRevert
? formatMessage({ id: 'task.log-returned' }, { taskName: task?.name })
: messages['general.comment']}
</span>
<span
className={classnames(styles['comment__text'], isTextExpanded && styles['comment__text_expanded'])}
ref={commentTextRef}
>
<TruncatedContent isTruncated={isTruncated} maxHeight={MAX_TRUNCATED_COMMENT_HEIGHT}>
<RichText text={text} />
</TruncatedContent>
</span>
</div>
{hasAttachments && !isTruncated && <Attachments attachments={attachments} />}
{isTruncated && <Ellipsis expand={onExpand} />}
</>
);
}
307 changes: 55 additions & 252 deletions frontend/src/public/components/Highlights/FeedItemHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,13 @@
import * as React from 'react';
import { useState, useEffect, useRef } from 'react';
import classnames from 'classnames';
import React, { useState } from 'react';
import { useIntl } from 'react-intl';

import { RichText } from '../RichText';
import { Attachments } from '../Attachments';
import { EWorkflowLogEvent } from '../../types/workflow';
import { IFieldsetRuntime } from '../../types/fieldset';
import { EExtraFieldType, ETemplateOwnerType, IExtraField } from '../../types/template';
import { IHighlightsItem } from '../../types/highlights';
import { isArrayWithItems } from '../../utils/helpers';
import { EKickoffOutputsViewModes, KickoffOutputs } from '../KickoffOutputs';
import { UserData } from '../UserData';
import { getUserFullName } from '../../utils/users';
import UserDataWithGroup from '../UserDataWithGroup';
import { getSnoozedUntilDate } from '../../utils/dateTime';

import { Ellipsis } from './Ellipsis';
import { TruncatedContent } from './utils/TruncatedContent';

import styles from './FeedItem.css';

interface IFeedItemHeaderProps extends IHighlightsItem {}
import { FeedItemComment } from './FeedItemComment';
import { FeedItemOutputs } from './FeedItemOutputs';
import { PerformerChange } from './PerformerChange';
import { IFeedItemHeaderProps } from './types';

export function FeedItemHeader({
attachments,
Expand All @@ -33,243 +19,60 @@ export function FeedItemHeader({
targetUserId,
targetGroupId,
}: IFeedItemHeaderProps) {
const { messages, formatMessage } = useIntl();
const commentTextRef = useRef<HTMLSpanElement>(null);

const { formatMessage } = useIntl();
const [isTextExpanded, setIsTextExpanded] = useState(false);
const [isCommentExpandable, setIsCommentExpandable] = useState(true);

useEffect(() => {
const commentTextHeigh = commentTextRef.current?.offsetHeight || 0;

setIsCommentExpandable(commentTextHeigh > MAX_TRUNCATED_COMMENT_HEIGHT || hasAttachments);
}, [commentTextRef.current]);

const hasAttachments = Boolean(text && attachments && isArrayWithItems(attachments));
const hasCommentText = Boolean(text);
const MAX_TRUNCATED_COMMENT_HEIGHT = 20 * 5;

const renderElipsis = () => {
return <Ellipsis expand={() => setIsTextExpanded(true)} />;
};

const renderOutputsContents = () => {
const { output: taskOutput, fieldsets: taskFieldsets } = task || {};
const { output: kickoffOutput, fieldsets: kickoffFieldsets } = kickoff || {};

if (
!isArrayWithItems(taskOutput) &&
!isArrayWithItems(kickoffOutput) &&
!isArrayWithItems(taskFieldsets) &&
!isArrayWithItems(kickoffFieldsets)
) {
return null;
}

const OUTPUTS_MAP: { [key in EWorkflowLogEvent]?: IExtraField[] } = {
[EWorkflowLogEvent.WorkflowRun]: kickoffOutput,
[EWorkflowLogEvent.WorkflowComplete]: taskOutput,
[EWorkflowLogEvent.TaskComplete]: taskOutput,
[EWorkflowLogEvent.WorkflowsReturned]: taskOutput,
[EWorkflowLogEvent.TaskRevert]: taskOutput,
};

const FIELDSETS_MAP: { [key in EWorkflowLogEvent]?: IFieldsetRuntime[] } = {
[EWorkflowLogEvent.WorkflowRun]: kickoffFieldsets,
[EWorkflowLogEvent.WorkflowComplete]: taskFieldsets,
[EWorkflowLogEvent.TaskComplete]: taskFieldsets,
[EWorkflowLogEvent.WorkflowsReturned]: taskFieldsets,
[EWorkflowLogEvent.TaskRevert]: taskFieldsets,
};

const outputs = OUTPUTS_MAP[type];
const fieldsets = FIELDSETS_MAP[type];

if (!isArrayWithItems(outputs) && !isArrayWithItems(fieldsets)) {
return null;
}

const filterField = (output: IExtraField) => {
const value = output.type === EExtraFieldType.User ? output.userId || output.groupId : output.value;
return value || output.attachments?.length;
};

const filteredOutputs = (outputs || []).filter(filterField);

const filteredFieldsets = (fieldsets || [])
.map((fs) => ({
...fs,
fields: fs.fields.filter(filterField),
}))
.filter((fs) => fs.fields.length > 0);

return (
<>
<KickoffOutputs
outputs={filteredOutputs}
fieldsets={filteredFieldsets}
viewMode={EKickoffOutputsViewModes.Short}
isTruncated={!isTextExpanded}
const handleExpand = () => setIsTextExpanded(true);

switch (type) {
case EWorkflowLogEvent.TaskComplete:
case EWorkflowLogEvent.WorkflowRun:
case EWorkflowLogEvent.WorkflowComplete:
case EWorkflowLogEvent.WorkflowsReturned:
return (
<FeedItemOutputs
kickoff={kickoff}
isTextExpanded={isTextExpanded}
onExpand={handleExpand}
task={task}
type={type}
/>
{filteredOutputs.length + filteredFieldsets.flatMap((fs) => fs.fields).length > 1 &&
!isTextExpanded &&
renderElipsis()}
</>
);
};

const renderAttachments = () => {
if (!text || !hasAttachments) {
return null;
}

return (
<>
<Attachments attachments={attachments} isTruncated={!isTextExpanded} />
{attachments.length > 1 && renderElipsis()}
</>
);
};

const renderCommentContent = (params: { isTruncated: boolean }) => {
if (!text) {
return null;
}

const { isTruncated } = params;

return (
<>
{hasCommentText && (
<div className={styles['header__comment']}>
<span className={styles['comment__title']}>
{type === EWorkflowLogEvent.TaskRevert
? formatMessage({ id: 'task.log-returned' }, { taskName: task?.name })
: messages['general.comment']}
</span>
<span
className={classnames(styles['comment__text'], isTextExpanded && styles['comment__text_expanded'])}
ref={commentTextRef}
>
<TruncatedContent isTruncated={isTruncated} maxHeight={MAX_TRUNCATED_COMMENT_HEIGHT}>
<RichText text={text} />
</TruncatedContent>
</span>
</div>
)}
{hasAttachments && !isTruncated && <Attachments attachments={attachments} />}
{isTruncated && renderElipsis()}
</>
);
};

const renderTaskCommentContent = () => {
if (!text) {
return null;
}

const hasOnlyAttachments = hasAttachments && !text;

if (hasOnlyAttachments) {
return renderAttachments();
}

const shoudShowTruncatedContent = !isTextExpanded && isCommentExpandable;

return renderCommentContent({ isTruncated: shoudShowTruncatedContent });
};

const renderAddedPerformer = () => {
if (!targetUserId) {
return null;
}

return (
<div className={styles['changed-performer']}>
{formatMessage({ id: 'task.log-added-performer' })}
<UserData userId={targetUserId}>
{(user) => {
if (!user) {
return null;
}

return <span className={styles['username']}>{getUserFullName(user, { withAtSign: true })}</span>;
}}
</UserData>
</div>
);
};

const renderRemovedPerformer = () => {
if (!targetUserId) {
return null;
}
);

case EWorkflowLogEvent.TaskComment:
case EWorkflowLogEvent.WorkflowFinished:
case EWorkflowLogEvent.TaskRevert:
return (
<FeedItemComment
attachments={attachments}
isTextExpanded={isTextExpanded}
onExpand={handleExpand}
task={task}
text={text}
type={type}
/>
);

case EWorkflowLogEvent.AddedPerformer:
case EWorkflowLogEvent.RemovedPerformer:
case EWorkflowLogEvent.AddedPerformerGroup:
case EWorkflowLogEvent.RemovedPerformerGroup:
return (
<PerformerChange
targetGroupId={targetGroupId}
targetUserId={targetUserId}
type={type}
/>
);

return (
<div className={styles['changed-performer']}>
{formatMessage({ id: 'task.log-removed-performer' })}
<UserData userId={targetUserId}>
{(user) => {
if (!user) {
return null;
}
case EWorkflowLogEvent.WorkflowSnoozedManually:
return (
<>{formatMessage({ id: 'workflows.event-snoozed-until' }, { date: getSnoozedUntilDate(delay || null) })}</>
);

return <span className={styles['username']}>{getUserFullName(user, { withAtSign: true })}</span>;
}}
</UserData>
</div>
);
};
case EWorkflowLogEvent.WorkflowResumed:
return <>{formatMessage({ id: 'workflows.event-resumed' })}</>;

const renderAddedPerformerGroup = () => {
if (!targetGroupId) {
default:
return null;
}

return (
<div className={styles['changed-performer']}>
{formatMessage({ id: 'task.log-added-performer-group' })}
<UserDataWithGroup type={ETemplateOwnerType.UserGroup} idItem={targetGroupId}>
{(group) => <span className={styles['username']}>{group.firstName}</span>}
</UserDataWithGroup>
</div>
);
};

const renderRemovedPerformerGroup = () => {
if (!targetGroupId) {
return null;
}

return (
<div className={styles['changed-performer']}>
{formatMessage({ id: 'task.log-removed-performer-group' })}
<UserDataWithGroup type={ETemplateOwnerType.UserGroup} idItem={targetGroupId}>
{(group) => <span className={styles['username']}>{group.firstName}</span>}
</UserDataWithGroup>
</div>
);
};

const EVENT_CONTENT_MAP: { [key in EWorkflowLogEvent]?: JSX.Element | null } = {
[EWorkflowLogEvent.TaskComplete]: renderOutputsContents(),
[EWorkflowLogEvent.TaskComment]: renderTaskCommentContent(),
[EWorkflowLogEvent.WorkflowRun]: renderOutputsContents(),
[EWorkflowLogEvent.WorkflowFinished]: renderTaskCommentContent(),
[EWorkflowLogEvent.WorkflowComplete]: renderOutputsContents(),
[EWorkflowLogEvent.WorkflowsReturned]: renderOutputsContents(),
[EWorkflowLogEvent.TaskRevert]: renderTaskCommentContent(),
[EWorkflowLogEvent.AddedPerformer]: renderAddedPerformer(),
[EWorkflowLogEvent.RemovedPerformer]: renderRemovedPerformer(),
[EWorkflowLogEvent.AddedPerformerGroup]: renderAddedPerformerGroup(),
[EWorkflowLogEvent.RemovedPerformerGroup]: renderRemovedPerformerGroup(),
[EWorkflowLogEvent.WorkflowSnoozedManually]: (
<>{formatMessage({ id: 'workflows.event-snoozed-until' }, { date: getSnoozedUntilDate(delay || null) })}</>
),
[EWorkflowLogEvent.WorkflowResumed]: <>{formatMessage({ id: 'workflows.event-resumed' })}</>,
};

const FeedItemHeaderComponent = EVENT_CONTENT_MAP[type] ?? null;

return FeedItemHeaderComponent;
}
}
Loading