diff --git a/src/components/GoalActivity.tsx b/src/components/GoalActivity.tsx index d6be08fb3..b7519ee42 100644 --- a/src/components/GoalActivity.tsx +++ b/src/components/GoalActivity.tsx @@ -1,24 +1,12 @@ -import React, { forwardRef } from 'react'; +import React, { forwardRef, useMemo } from 'react'; import { nullable } from '@taskany/bricks'; import { GoalByIdReturnType } from '../../trpc/inferredTypes'; import { GoalComment } from '../types/comment'; +import { safeUserData } from '../utils/getUserName'; import { ActivityFeed } from './ActivityFeed'; -import { - HistoryRecord, - HistoryRecordDependency, - HistoryRecordTags, - HistoryRecordTextChange, - HistoryRecordEstimate, - HistoryRecordPriority, - HistoryRecordState, - HistoryRecordParticipant, - HistoryRecordProject, - HistoryRecordLongTextChange, - HistoryRecordCriteria, - HistoryRecordPartnerProject, -} from './HistoryRecord/HistoryRecord'; +import { HistoryRecordGroup } from './HistoryRecord/HistoryRecord'; interface GoalActivityProps { feed: NonNullable['_activityFeed']; @@ -30,67 +18,63 @@ interface GoalActivityProps { export const GoalActivity = forwardRef( ({ feed, header, footer, renderCommentItem }, ref) => { + const unionFeed = useMemo(() => { + const res = []; + let tempRecords = []; + + for (let i = 0; i < feed.length; i += 1) { + const current = feed[i]; + const next = feed[i + 1]; + + if (current.type === 'history') { + if (current.type === next?.type && current.value.subject === next.value.subject) { + tempRecords.push(current.value); + } else { + res.push({ + type: current.type, + value: tempRecords.concat([current.value]), + }); + + tempRecords = []; + } + } else { + // only comments + res.push(current); + } + } + + return res; + }, [feed]); + return ( {header} - {feed.map((item) => - nullable(item, ({ type, value }) => ( - - {type === 'comment' && renderCommentItem(value)} - - {type === 'history' && ( - - {value.subject === 'dependencies' && ( - - )} - {value.subject === 'tags' && ( - - )} - {value.subject === 'description' && ( - - )} - {value.subject === 'title' && ( - + {unionFeed.map((item) => + nullable(item, ({ type, value }) => { + if (type === 'history') { + return ( + 1} + values={value.map( + ({ id, action, subject, nextValue, previousValue, activity, createdAt }) => ({ + author: safeUserData(activity), + from: previousValue, + to: nextValue, + action, + id, + subject, + createdAt, + }), )} - {value.subject === 'estimate' && ( - - )} - {value.subject === 'priority' && ( - - )} - {value.subject === 'state' && ( - - )} - {(value.subject === 'participants' || value.subject === 'owner') && ( - - )} - {value.subject === 'project' && ( - - )} - {value.subject === 'partnerProject' && ( - - )} - {value.subject === 'criteria' && ( - - )} - - )} - - )), + /> + ); + } + + return {renderCommentItem(value)}; + }), )} {footer} diff --git a/src/components/HistoryRecord/HistoryRecord.i18n/en.json b/src/components/HistoryRecord/HistoryRecord.i18n/en.json index d3c1ded4e..0b430196c 100644 --- a/src/components/HistoryRecord/HistoryRecord.i18n/en.json +++ b/src/components/HistoryRecord/HistoryRecord.i18n/en.json @@ -25,5 +25,8 @@ "as criteria": "", "goal complete": "completed goal", "goal in progress": "returns goal to work", - "partner project": "" + "partner project": "", + "author and more made changes in": "{author} and {count} more made changes in {subject}", + "author and the other author made changes in": "{author} and {oneMoreAuthor} made changes in {subject}", + "author made changes in": "{author} made changes in {subject}" } diff --git a/src/components/HistoryRecord/HistoryRecord.i18n/ru.json b/src/components/HistoryRecord/HistoryRecord.i18n/ru.json index 08f4d3199..53d771480 100644 --- a/src/components/HistoryRecord/HistoryRecord.i18n/ru.json +++ b/src/components/HistoryRecord/HistoryRecord.i18n/ru.json @@ -25,5 +25,8 @@ "as criteria": "как критерий", "goal complete": "выполнил(а) цель", "goal in progress": "вернул(а) цель в работу", - "partner project": "партнерский проект" + "partner project": "партнерский проект", + "author and more made changes in": "{author} и еще {count} внесли изменения в {subject}", + "author and the other author made changes in": "{author} и {oneMoreAuthor} внесли изменения в {subject}", + "author made changes in": "{author} внес(ла) изменения в {subject}" } diff --git a/src/components/HistoryRecord/HistoryRecord.tsx b/src/components/HistoryRecord/HistoryRecord.tsx index 38e85205f..d4729bc48 100644 --- a/src/components/HistoryRecord/HistoryRecord.tsx +++ b/src/components/HistoryRecord/HistoryRecord.tsx @@ -1,4 +1,13 @@ -import { createContext, useContext, useState, SetStateAction, useMemo, useEffect } from 'react'; +import { + createContext, + useContext, + useState, + SetStateAction, + useMemo, + useEffect, + useCallback, + forwardRef, +} from 'react'; import { User, Tag as TagData, @@ -11,9 +20,14 @@ import { Priority, } from '@prisma/client'; import styled, { css } from 'styled-components'; -import { UserPic, Text, Tag, nullable, Button } from '@taskany/bricks'; -import { IconDoubleCaretRightCircleSolid, IconDividerLineOutline } from '@taskany/icons'; -import { backgroundColor, gray7 } from '@taskany/colors'; +import { UserPic, Text, Tag, nullable, Button, Badge } from '@taskany/bricks'; +import { + IconDoubleCaretRightCircleSolid, + IconDividerLineOutline, + IconRightSmallOutline, + IconDownSmallOutline, +} from '@taskany/icons'; +import { backgroundColor, gapS, gapXs, gray7, radiusXl } from '@taskany/colors'; import { ActivityFeedItem } from '../ActivityFeed'; import { IssueListItem } from '../IssueListItem'; @@ -21,11 +35,11 @@ import { RelativeTime } from '../RelativeTime/RelativeTime'; import { decodeHistoryEstimate, formateEstimate } from '../../utils/dateTime'; import { getPriorityText } from '../PriorityText/PriorityText'; import { StateDot } from '../StateDot'; -import { HistoryRecordAction, HistoryRecordSubject } from '../../types/history'; +import { HistoryRecordAction, HistoryRecordSubject, HistoryRecordWithActivity } from '../../types/history'; import { calculateDiffBetweenArrays } from '../../utils/calculateDiffBetweenArrays'; import { Circle } from '../Circle'; import { useLocale } from '../../hooks/useLocale'; -import { getUserName, prepareUserDataFromActivity } from '../../utils/getUserName'; +import { getUserName, prepareUserDataFromActivity, safeGetUserName, safeUserData } from '../../utils/getUserName'; import { tr } from './HistoryRecord.i18n'; @@ -40,9 +54,9 @@ type WholeSubject = | 'goalInProgress' | keyof HistoryRecordSubject; -interface HistoryRecordProps { +interface HistoryRecordInnerProps { id: string; - author: User | null; + author?: ReturnType; subject: WholeSubject; action: HistoryRecordAction; children?: React.ReactNode; @@ -72,7 +86,7 @@ const StyledHistoryRecordWrapper = styled.div` gap: 0.25rem; flex: 1; line-height: 1.5; - align-items: start; + align-items: flex-start; flex-wrap: nowrap; `; @@ -112,6 +126,65 @@ const StyledFlexReset = styled.div` width: 100%; `; +const StyledGroupHeaderWrapper = styled.div` + display: flex; + width: fit-content; + align-items: center; + gap: ${gapS}; + position: relative; + background-color: ${backgroundColor}; + cursor: pointer; + + :after { + content: ''; + position: absolute; + top: 100%; + height: 100%; + left: 15px; + border-left: 1px solid var(--gray5); + z-index: 0; + } +`; + +const StyledIcon = styled( + forwardRef< + HTMLSpanElement, + Omit, 'size'> & { + collapsed?: boolean; + } + >(({ collapsed, ...props }, ref) => { + return nullable( + collapsed, + () => , + , + ); + }), +)` + /* no-magic: this negative margin needs for align icon by center of first line in content */ + margin-top: -3px; +`; + +const StyledGroupHeader = styled(Text)` + display: flex; + width: fit-content; + align-items: center; + border: 1px solid ${gray7}; + border-radius: ${radiusXl}; + gap: ${gapXs}; + padding: ${gapXs} ${gapS}; + padding-right: ${gapXs}; + user-select: none; +`; + +const StyledBadge = styled(Badge)` + display: inline-flex; + min-width: 20px; + height: 20px; + padding: 0; + align-items: center; + justify-content: center; +`; + interface HistoryRecordContext { setActionText: (value: SetStateAction) => void; setSubjectText: (value: SetStateAction) => void; @@ -122,7 +195,7 @@ const RecordCtx = createContext({ setSubjectText: () => {}, }); -export const HistorySimplifyRecord: React.FC<{ withPretext?: boolean } & HistoryChangeProps> = ({ +const HistorySimplifyRecord: React.FC<{ withPretext?: boolean } & HistoryChangeProps> = ({ from, to, withPretext = true, @@ -147,7 +220,7 @@ export const HistorySimplifyRecord: React.FC<{ withPretext?: boolean } & History ); -export const HistoryMultilineRecord: React.FC<{ withPretext?: boolean } & HistoryChangeProps> = ({ +const HistoryMultilineRecord: React.FC<{ withPretext?: boolean } & HistoryChangeProps> = ({ from, to, withPretext = true, @@ -176,8 +249,8 @@ export const HistoryMultilineRecord: React.FC<{ withPretext?: boolean } & Histor ); -export const HistoryRecord: React.FC = ({ author, subject, action, createdAt, children }) => { - const translates = useMemo>(() => { +const useHistoryTranslates = () => { + return useMemo>(() => { return { change: tr('change'), edit: tr('edit'), @@ -196,18 +269,22 @@ export const HistoryRecord: React.FC = ({ author, subject, a dependencies: tr('dependencies'), priority: tr('priority'), criteria: tr('criteria'), + partnerProject: tr('partner project'), goalAsCriteria: tr('goal as criteria'), criteriaState: tr('marked criteria'), goalComplete: tr('goal complete'), goalInProgress: tr('goal in progress'), complete: '', uncomplete: '', - partnerProject: tr('partner project'), }; }, []); +}; - const [actionText, setActionText] = useState(action); - const [subjectText, setSubjectText] = useState(subject); +const HistoryRecordInner: React.FC = ({ author, subject, action, createdAt, children }) => { + const translates = useHistoryTranslates(); + + const [actionText, setActionText] = useState(() => action); + const [subjectText, setSubjectText] = useState(() => subject); return ( @@ -241,13 +318,12 @@ export const HistoryRecord: React.FC = ({ author, subject, a ); }; -export const HistoryRecordDependency: React.FC<{ - issues?: Array['issue']>; - strike?: boolean; -}> = ({ issues = [], strike = false }) => { +const HistoryRecordDependency: React.FC< + HistoryChangeProps['issue']>> & { strike?: boolean } +> = ({ from, to, strike = false }) => { return ( <> - {issues.map((issue) => ( + {(from || to || []).map((issue) => ( ))} @@ -266,7 +342,7 @@ const HistoryTags: React.FC<{ tags: { title: string; id: string }[] }> = ({ tags ); }; -export const HistoryRecordTags: React.FC> = ({ from, to }) => { +const HistoryRecordTags: React.FC> = ({ from, to }) => { const recordCtx = useContext(RecordCtx); const added = calculateDiffBetweenArrays(to, from); @@ -299,7 +375,7 @@ export const HistoryRecordTags: React.FC> = ({ fro ); }; -export const HistoryRecordEstimate: React.FC> = ({ from, to }) => { +const HistoryRecordEstimate: React.FC> = ({ from, to }) => { const locale = useLocale(); return ( @@ -316,7 +392,7 @@ export const HistoryRecordEstimate: React.FC> = ({ fr ); }; -export const HistoryRecordProject: React.FC> = ({ from, to }) => ( +const HistoryRecordProject: React.FC> = ({ from, to }) => ( // FIXME: it must be ProjectBadge ( @@ -328,7 +404,7 @@ export const HistoryRecordProject: React.FC> = ({ fr /> ); -export const HistoryRecordPartnerProject: React.FC> = ({ from, to }) => ( +const HistoryRecordPartnerProject: React.FC> = ({ from, to }) => ( // FIXME: it must be ProjectBadge > /> ); -export const HistoryRecordLongTextChange: React.FC> = ({ from, to }) => { +const HistoryRecordLongTextChange: React.FC> = ({ from, to }) => { const [viewDestiption, setViewDescription] = useState(false); const handlerViewDescription = () => { @@ -375,7 +451,7 @@ export const HistoryRecordLongTextChange: React.FC> = ); }; -export const HistoryRecordTextChange: React.FC> = ({ from, to }) => { +const HistoryRecordTextChange: React.FC> = ({ from, to }) => { return ( ( @@ -390,7 +466,7 @@ export const HistoryRecordTextChange: React.FC> = ({ ); }; -export const HistoryRecordPriority: React.FC> = ({ from, to }) => ( +const HistoryRecordPriority: React.FC> = ({ from, to }) => ( = ({ title, h ); -export const HistoryRecordState: React.FC> = ({ from, to }) => ( +const HistoryRecordState: React.FC> = ({ from, to }) => ( ( @@ -440,9 +516,10 @@ const HistoryParticipant: React.FC<{ name?: string | null; pic?: string | null; ); -export const HistoryRecordParticipant: React.FC< - HistoryChangeProps -> = ({ from, to }) => ( +const HistoryRecordParticipant: React.FC> = ({ + from, + to, +}) => ( ( @@ -481,7 +558,7 @@ const HistoryRecordCriteriaItem: React.FC = ({ criteriaGoal, title ); }; -export const HistoryRecordCriteria: React.FC< +const HistoryRecordCriteria: React.FC< HistoryChangeProps & { action: HistoryRecordAction; strike?: boolean; @@ -530,3 +607,129 @@ export const HistoryRecordCriteria: React.FC< /> ); }; + +type OuterSubjects = Exclude; + +/** + * let it be like this, with `any` props annotation + * because a facade component `HistoryRecord` has correct typings for usage + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mapSubjectToComponent: Record> = { + dependencies: HistoryRecordDependency, + criteria: HistoryRecordCriteria, + participants: HistoryRecordParticipant, + owner: HistoryRecordParticipant, + project: HistoryRecordProject, + partnerProject: HistoryRecordPartnerProject, + state: HistoryRecordState, + priority: HistoryRecordPriority, + tags: HistoryRecordTags, + title: HistoryRecordTextChange, + estimate: HistoryRecordEstimate, + description: HistoryRecordLongTextChange, +}; + +interface HistoryRecordProps extends Omit { + subject: OuterSubjects; + from?: HistoryRecordWithActivity['previousValue']; + to?: HistoryRecordWithActivity['nextValue']; +} + +export const HistoryRecord: React.FC = ({ subject, author, action, createdAt, id, ...rest }) => { + const Component = mapSubjectToComponent[subject]; + + return ( + + + + ); +}; + +const HisroryRecordGroupHeader: React.FC< + React.PropsWithChildren<{ + createdAt: Date; + collapsed: boolean; + onClick: () => void; + }> +> = ({ createdAt, onClick, collapsed, children }) => ( + + + + {children} + + + + + +); + +export const HistoryRecordGroup: React.FC<{ + subject: OuterSubjects; + groupped?: boolean; + values: HistoryRecordProps[]; +}> = ({ values, subject }) => { + const [collapsed, setCollapsed] = useState(() => values.length > 1); + const showGroupHeader = values.length > 1; + + const translates = useHistoryTranslates(); + + const heading = useMemo(() => { + const authorsSet = new Set(); + + for (const { author } of values) { + const name = safeGetUserName({ user: author }); + + if (name) { + authorsSet.add(name); + } + } + + const [first, ...rest] = Array.from(authorsSet); + + if (rest.length > 1) { + return tr.raw('author and more made changes in', { + author: {first}, + count: rest.length, + subject: translates[subject], + }); + } + + if (rest.length) { + return tr.raw('author and the other author made changes in', { + author: {first}, + oneMoreAuthor: {rest[0]}, + subject: translates[subject], + }); + } + + return tr.raw('author made changes in', { subject: translates[subject], author: {first} }); + }, [values, translates, subject]); + + const handleToggleCollapse = useCallback(() => setCollapsed((prev) => !prev), []); + + const lastRecord = values[values.length - 1]; + + return ( + <> + {nullable(showGroupHeader, () => ( + + {heading} + {values.length} + + ))} + + {nullable(!collapsed, () => ( + <> + {values.map(({ ...item }) => ( + + ))} + + ))} + + ); +};