Skip to content

Commit 64d0987

Browse files
authored
feat(condo): Experiment: allow usage of markdown in ticket details (#6716)
* feat(condo): Experiment: Use markdown inside of Ticket/id ui * feat(condo): Experiment: Use markdown inside of Ticket/id ui * feat(condo): Experiment: Use markdown inside of Ticket/id ui * chore(condo): add type to custom markdown component * chore(condo): add stories for new markdown component * chore(condo): remove console.log * chore(condo): implement fixes on review * chore(condo): add fallback on error * chore(condo): implement fixes on error * feat(condo): Experiment: Use markdown inside of Ticket/id ui * chore(condo): add type to custom markdown component * chore(condo): implement fixes on error * chore(condo): make type not required * chore(condo): make type not required * chore(condo): make type not required * chore(condo): merge incoming changes * chore(condo): Experiment: allow usage of markdown inside of Ticket[id] * refactor(ui): change markdown type to 'inline' * refactor(ui): use .less * refactor(ui): add fixes on review * refactor(ui): add some fun to markdown stories * refactor(ui): fix styles
1 parent 964c53d commit 64d0987

File tree

6 files changed

+300
-29
lines changed

6 files changed

+300
-29
lines changed

apps/callcenter

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
1-
import { Ticket } from '@app/condo/schema'
21
import React from 'react'
32

43
import { useIntl } from '@open-condo/next/intl'
5-
4+
import { Markdown } from '@open-condo/ui'
65

76
import { PageFieldRow } from '@condo/domains/common/components/PageFieldRow'
87

98
type TicketDetailsFieldProps = {
10-
ticket: Ticket
9+
ticketDetails: string
10+
updateTicketDetails?: (newTicketDetails: string) => void
1111
}
1212

13-
export const TicketDetailsField: React.FC<TicketDetailsFieldProps> = ({ ticket }) => {
13+
export const TicketDetailsField: React.FC<TicketDetailsFieldProps> = ({ ticketDetails, updateTicketDetails }) => {
1414
const intl = useIntl()
1515
const TicketDetailsMessage = intl.formatMessage({ id: 'Problem' })
1616

1717
return (
18-
<PageFieldRow title={TicketDetailsMessage} ellipsis lineWrapping='break-spaces'>
19-
{ticket?.details}
20-
</PageFieldRow>
18+
<>
19+
<PageFieldRow title={TicketDetailsMessage} ellipsis>
20+
<Markdown type='inline' onCheckboxChange={updateTicketDetails}>
21+
{ticketDetails}
22+
</Markdown>
23+
</PageFieldRow>
24+
</>
2125
)
2226
}

apps/condo/pages/ticket/[id]/index.tsx

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import {
77
useGetTicketCommentsQuery,
88
useGetTicketLastCommentsTimeQuery, useGetUserTicketCommentsReadTimeQuery,
99
useUpdateTicketCommentMutation,
10+
useUpdateTicketMutation,
1011
useCreateUserTicketCommentReadTimeMutation,
1112
useUpdateUserTicketCommentReadTimeMutation, useGetTicketInvoicesQuery, GetIncidentsQuery,
1213
} from '@app/condo/gql'
1314
import { B2BAppGlobalFeature } from '@app/condo/schema'
1415
import { Affix, Col, ColProps, notification, Row, RowProps, Space } from 'antd'
1516
import dayjs from 'dayjs'
1617
import compact from 'lodash/compact'
18+
import debounce from 'lodash/debounce'
1719
import get from 'lodash/get'
1820
import isEmpty from 'lodash/isEmpty'
1921
import map from 'lodash/map'
@@ -22,16 +24,18 @@ import Link from 'next/link'
2224
import { useRouter } from 'next/router'
2325
import { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react'
2426

27+
2528
import { useCachePersistor } from '@open-condo/apollo'
2629
import { Link as LinkIcon } from '@open-condo/icons'
30+
import { getClientSideSenderInfo } from '@open-condo/miniapp-utils/helpers/sender'
2731
import { useAuth } from '@open-condo/next/auth'
2832
import { FormattedMessage } from '@open-condo/next/intl'
2933
import { useIntl } from '@open-condo/next/intl'
3034
import { useOrganization } from '@open-condo/next/organization'
31-
import {
32-
ActionBar,
33-
Alert,
34-
Button,
35+
import {
36+
ActionBar,
37+
Alert,
38+
Button,
3539
Typography,
3640
} from '@open-condo/ui'
3741

@@ -99,7 +103,6 @@ import { prefetchTicket } from '@condo/domains/ticket/utils/next/Ticket'
99103
import { UserNameField } from '@condo/domains/user/components/UserNameField'
100104
import { RESIDENT } from '@condo/domains/user/constants/common'
101105

102-
103106
const TICKET_CONTENT_VERTICAL_GUTTER: RowProps['gutter'] = [0, 40]
104107
const BIG_VERTICAL_GUTTER: RowProps['gutter'] = [0, 40]
105108
const MEDIUM_VERTICAL_GUTTER: RowProps['gutter'] = [0, 24]
@@ -192,7 +195,7 @@ const TicketHeader = ({ ticket, handleTicketStatusChanged, organization, employe
192195
<Row>
193196
<Col span={24}>
194197
<Typography.Text type='secondary' size='small'>
195-
{TicketCreationDate}, {TicketAuthorMessage}{' '}
198+
{TicketCreationDate}, {TicketAuthorMessage}{' '}
196199
</Typography.Text>
197200
<UserNameField user={createdBy}>
198201
{({ name, postfix }) => (
@@ -351,7 +354,7 @@ const TicketHeader = ({ ticket, handleTicketStatusChanged, organization, employe
351354
)
352355
}
353356

354-
const TicketContent = ({ ticket }) => {
357+
const TicketContent = ({ ticket, ticketDetails, updateTicketDetails }) => {
355358
return (
356359
<Col span={24}>
357360
<Row gutter={[0, 16]}>
@@ -360,7 +363,7 @@ const TicketContent = ({ ticket }) => {
360363
<TicketDeadlineField ticket={ticket}/>
361364
<TicketPropertyField ticket={ticket}/>
362365
<TicketClientField ticket={ticket}/>
363-
<TicketDetailsField ticket={ticket}/>
366+
<TicketDetailsField ticketDetails={ticketDetails} updateTicketDetails={updateTicketDetails}/>
364367
<TicketFileListField ticket={ticket}/>
365368
<TicketClassifierField ticket={ticket}/>
366369
<TicketExecutorField ticket={ticket}/>
@@ -571,6 +574,12 @@ export const TicketPageContent = ({ ticket, pollCommentsQuery, refetchTicket, or
571574

572575
const id = useMemo(() => ticket?.id, [ticket?.id])
573576

577+
const [ticketDetails, setTicketDetails] = useState(ticket?.details)
578+
579+
useEffect(() => {
580+
setTicketDetails(ticket?.details)
581+
}, [ticket?.details])
582+
574583
const {
575584
data: ticketChangesData,
576585
refetch: refetchTicketChanges,
@@ -617,6 +626,30 @@ export const TicketPageContent = ({ ticket, pollCommentsQuery, refetchTicket, or
617626
}
618627
}), [comments, ticketCommentFiles])
619628

629+
const [updateTicket] = useUpdateTicketMutation()
630+
const debouncedUpdateTicket = useMemo(() => debounce(updateTicket, 500), [updateTicket])
631+
632+
const handleUpdateTicketDetails = async (newTicketDetails: string) => {
633+
setTicketDetails(newTicketDetails)
634+
635+
await debouncedUpdateTicket({
636+
variables: {
637+
id: id,
638+
data: {
639+
details: newTicketDetails,
640+
dv: 1,
641+
sender: getClientSideSenderInfo(),
642+
},
643+
},
644+
onError: () => {
645+
setTicketDetails(ticket?.details)
646+
notification.error({
647+
message: intl.formatMessage({ id: 'ServerErrorPleaseTryAgainLater' }),
648+
})
649+
},
650+
})
651+
}
652+
620653
const [updateComment] = useUpdateTicketCommentMutation({
621654
onCompleted: async () => {
622655
await refetchTicketComments()
@@ -730,7 +763,7 @@ export const TicketPageContent = ({ ticket, pollCommentsQuery, refetchTicket, or
730763
)
731764
}
732765
</Row>
733-
<TicketContent ticket={ticket}/>
766+
<TicketContent ticket={ticket} ticketDetails={ticketDetails} updateTicketDetails={handleUpdateTicketDetails}/>
734767
{
735768
isNoServiceProviderOrganization && ticket.isPayable && (
736769
<Col span={24}>

packages/ui/src/components/Markdown/markdown.tsx

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import remarkGfm from 'remark-gfm'
55

66
import { CodeWrapper } from './codeWrapper'
77

8+
import { Checkbox } from '../Checkbox'
89
import { Typography } from '../Typography'
910

1011
const REMARK_PLUGINS: Array<any> = [
@@ -13,27 +14,151 @@ const REMARK_PLUGINS: Array<any> = [
1314

1415
export type MarkdownProps = {
1516
children: string
17+
type?: 'default' | 'inline'
18+
onCheckboxChange?: (newMarkdownContent: string) => void
19+
}
20+
21+
type PositionType = {
22+
start: {
23+
offset: number
24+
line: number
25+
column: number
26+
}
27+
end: {
28+
offset: number
29+
line: number
30+
column: number
31+
}
1632
}
1733

1834
const MARKDOWN_CLASS_PREFIX = 'condo-markdown'
35+
const MARKDOWN_TASK_LIST_CLASS_PREFIX = 'condo-markdown-task-list-item'
36+
37+
type TaskListItemType = {
38+
// Node is Element from @types/hast. I decided to skip the import and just declare the type myself
39+
// https://github.com/remarkjs/react-markdown?tab=readme-ov-file#appendix-b-components
40+
node: { position: PositionType }
41+
checked?: boolean
42+
children: React.ReactNode
43+
onToggle?: (checked: { checked: boolean, position: PositionType }) => void
44+
disabled?: boolean
45+
}
46+
47+
const TaskListItem: React.FC<TaskListItemType> = ({
48+
checked = false,
49+
children,
50+
onToggle,
51+
disabled = false,
52+
node,
53+
}) => {
54+
const position = node.position
55+
56+
return (
57+
<li>
58+
<div className={MARKDOWN_TASK_LIST_CLASS_PREFIX}>
59+
<Checkbox
60+
checked={checked}
61+
onChange={(e) => onToggle?.({ checked: e.target.checked, position })}
62+
disabled={disabled}
63+
/>
64+
<Typography.Text type='secondary'>
65+
{children}
66+
</Typography.Text>
67+
</div>
68+
</li>
69+
)
70+
}
71+
72+
const MARKDOWN_COMPONENTS_BY_TYPE = {
73+
'default': {
74+
h1: (props: any) => <Typography.Title {...omit(props, 'ref')} level={1}/>,
75+
h2: (props: any) => <Typography.Title {...omit(props, 'ref')} level={2}/>,
76+
h3: (props: any) => <Typography.Title {...omit(props, 'ref')} level={3}/>,
77+
h4: (props: any) => <Typography.Title {...omit(props, 'ref')} level={4}/>,
78+
h5: (props: any) => <Typography.Title {...omit(props, 'ref')} level={5}/>,
79+
h6: (props: any) => <Typography.Title {...omit(props, 'ref')} level={6}/>,
80+
},
81+
// Inline should be used when displaying text formatted by user
82+
'inline': {
83+
h1: (props: any) => <Typography.Paragraph strong {...omit(props, 'ref')} type='primary' />,
84+
h2: (props: any) => <Typography.Paragraph strong {...omit(props, 'ref')} type='primary' />,
85+
h3: (props: any) => <Typography.Paragraph strong {...omit(props, 'ref')} type='primary' />,
86+
h4: (props: any) => <Typography.Paragraph strong {...omit(props, 'ref')} type='primary' />,
87+
h5: (props: any) => <Typography.Paragraph strong {...omit(props, 'ref')} type='primary' />,
88+
h6: (props: any) => <Typography.Paragraph strong {...omit(props, 'ref')} type='primary' />,
89+
p: (props: any) => <Typography.Paragraph {...omit(props, 'ref')} type='primary' />,
90+
},
91+
}
92+
93+
export const Markdown: React.FC<MarkdownProps> = ({ children, type = 'default', onCheckboxChange }) => {
94+
if (!MARKDOWN_COMPONENTS_BY_TYPE.hasOwnProperty(type)) {
95+
throw new Error('Unsupported markdown type')
96+
}
97+
98+
const hasInteractiveCheckboxes = onCheckboxChange && typeof onCheckboxChange === 'function'
99+
100+
const callOnCheckboxChange: TaskListItemType['onToggle'] = ({ checked, position }) => {
101+
if (hasInteractiveCheckboxes) {
102+
const checkboxChangedPositionOffset = position.start.offset + 3
103+
104+
const partBeforeCheckbox = children.slice(0, checkboxChangedPositionOffset)
105+
const partAfterCheckbox = children.slice(checkboxChangedPositionOffset + 1)
106+
const checkboxContent = checked ? 'x' : ' '
107+
108+
const newMarkdown = partBeforeCheckbox + checkboxContent + partAfterCheckbox
109+
110+
onCheckboxChange(newMarkdown)
111+
}
112+
}
19113

20-
export const Markdown: React.FC<MarkdownProps> = ({ children }) => {
21114
return (
22115
<ReactMarkdown
23116
className={MARKDOWN_CLASS_PREFIX}
24117
remarkPlugins={REMARK_PLUGINS}
25118
components={{
26-
h1: (props) => <Typography.Title {...omit(props, 'ref')} level={1}/>,
27-
h2: (props) => <Typography.Title {...omit(props, 'ref')} level={2}/>,
28-
h3: (props) => <Typography.Title {...omit(props, 'ref')} level={3}/>,
29-
h4: (props) => <Typography.Title {...omit(props, 'ref')} level={4}/>,
30-
h5: (props) => <Typography.Title {...omit(props, 'ref')} level={5}/>,
31-
h6: (props) => <Typography.Title {...omit(props, 'ref')} level={6}/>,
32-
// TODO: Try more elegant solutions if deploys succeed
33-
p: (props: any) => <Typography.Paragraph {...omit(props, 'ref')} type='secondary' />,
34-
a: (props: any) => <Typography.Link {...omit(props, 'ref')} target='_blank'/>,
35-
li: ({ children, ...restProps }) => <li {...restProps}><Typography.Text type='secondary'>{children}</Typography.Text></li>,
119+
...MARKDOWN_COMPONENTS_BY_TYPE[type],
120+
a: (props: any) => <Typography.Link {...omit(props, 'ref')} rel='noopener noreferrer' target='_blank'/>,
36121
pre: (props: any) => <CodeWrapper {...props}/>,
122+
input: ({ type, checked, disabled, ...restProps }) => {
123+
if (type !== 'checkbox') {
124+
return <input type={type} {...restProps} />
125+
}
126+
127+
return (
128+
<Checkbox
129+
checked={checked || false}
130+
disabled
131+
/>
132+
)
133+
},
134+
li: (props) => {
135+
const { children, ...restProps } = props
136+
137+
const childrenArray = React.Children.toArray(children)
138+
const checkboxChild = childrenArray.find(child =>
139+
React.isValidElement(child) && child.props?.type === 'checkbox'
140+
)
141+
142+
if (checkboxChild) {
143+
const checked = React.isValidElement(checkboxChild) && checkboxChild?.props?.checked || false
144+
145+
const contentChildren = childrenArray.filter(child =>
146+
!(React.isValidElement(child) && child.props?.type === 'checkbox')
147+
)
148+
149+
return (
150+
<TaskListItem node={props.node as any} checked={checked} disabled={!hasInteractiveCheckboxes} onToggle={callOnCheckboxChange}>
151+
{contentChildren}
152+
</TaskListItem>
153+
)
154+
}
155+
156+
return (
157+
<li {...restProps}>
158+
<Typography.Text type='secondary'>{children}</Typography.Text>
159+
</li>
160+
)
161+
},
37162
}}
38163
>
39164
{children}

packages/ui/src/components/Markdown/style.less

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
@import (reference) "@open-condo/ui/src/components/style/mixins/typography";
33
@import (reference) "@open-condo/ui/src/components/style/mixins/table";
44

5+
.condo-markdown-task-list-item {
6+
display: flex;
7+
gap: 8px;
8+
align-items: flex-start;
9+
}
10+
511
.condo-markdown {
612
color: @condo-global-color-gray-7;
713

0 commit comments

Comments
 (0)