@open-mercato/ui 0.6.4-develop.4371.1.8f3030407e → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/AGENTS.md +2 -1
- package/dist/backend/AppShell.js +6 -0
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/CrudForm.js +212 -85
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +77 -26
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/FlashMessages.js +21 -2
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +64 -41
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/PerspectiveSidebar.js +8 -1
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/RowActions.js +1 -1
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
- package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
- package/dist/backend/conflicts/index.js +32 -0
- package/dist/backend/conflicts/index.js.map +7 -0
- package/dist/backend/conflicts/store.js +50 -0
- package/dist/backend/conflicts/store.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +1 -0
- package/dist/backend/detail/ActivitiesSection.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +2 -51
- package/dist/backend/detail/InlineEditors.js.map +3 -3
- package/dist/backend/detail/RecordNotFoundState.js +10 -5
- package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
- package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
- package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
- package/dist/backend/filters/FilteredEmptyResults.js +12 -4
- package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
- package/dist/backend/filters/ListEmptyState.js +44 -0
- package/dist/backend/filters/ListEmptyState.js.map +7 -0
- package/dist/backend/filters/SearchEmptyResults.js +28 -0
- package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
- package/dist/backend/icons/lucideRegistry.generated.js +8 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js +8 -1
- package/dist/backend/injection/useGuardedMutation.js.map +2 -2
- package/dist/backend/inputs/MarkdownField.js +20 -0
- package/dist/backend/inputs/MarkdownField.js.map +7 -0
- package/dist/backend/inputs/MdxEditorImpl.js +98 -0
- package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
- package/dist/backend/markdown/markdownToPlainText.js +29 -0
- package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +212 -0
- package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
- package/dist/backend/messages/index.js +4 -0
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
- package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
- package/dist/backend/notifications/NotificationDispatcher.js +1 -0
- package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
- package/dist/backend/notifications/useNotificationsPoll.js +7 -0
- package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
- package/dist/backend/notifications/useNotificationsSse.js +8 -0
- package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
- package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
- package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
- package/dist/backend/utils/optimisticLock.js +34 -0
- package/dist/backend/utils/optimisticLock.js.map +7 -0
- package/dist/portal/hooks/useTenantContext.js +1 -1
- package/dist/portal/hooks/useTenantContext.js.map +2 -2
- package/dist/primitives/alert.js.map +1 -1
- package/dist/primitives/checkbox.js +2 -1
- package/dist/primitives/checkbox.js.map +2 -2
- package/dist/types/css.d.js +1 -0
- package/dist/types/css.d.js.map +7 -0
- package/package.json +17 -13
- package/src/backend/AppShell.tsx +10 -0
- package/src/backend/CrudForm.tsx +320 -97
- package/src/backend/DataTable.tsx +113 -34
- package/src/backend/FlashMessages.tsx +31 -2
- package/src/backend/JsonBuilder.tsx +79 -26
- package/src/backend/PerspectiveSidebar.tsx +8 -1
- package/src/backend/RowActions.tsx +1 -1
- package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
- package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
- package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
- package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
- package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
- package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
- package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
- package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
- package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
- package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
- package/src/backend/conflicts/__tests__/store.test.ts +78 -0
- package/src/backend/conflicts/index.ts +49 -0
- package/src/backend/conflicts/store.ts +94 -0
- package/src/backend/detail/ActivitiesSection.tsx +1 -0
- package/src/backend/detail/InlineEditors.tsx +14 -76
- package/src/backend/detail/RecordNotFoundState.tsx +13 -6
- package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
- package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
- package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
- package/src/backend/filters/ListEmptyState.tsx +69 -0
- package/src/backend/filters/SearchEmptyResults.tsx +42 -0
- package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
- package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
- package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
- package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
- package/src/backend/injection/useGuardedMutation.ts +16 -1
- package/src/backend/inputs/MarkdownField.tsx +34 -0
- package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
- package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
- package/src/backend/markdown/markdownToPlainText.ts +41 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
- package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
- package/src/backend/messages/index.ts +12 -0
- package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
- package/src/backend/notifications/NotificationDispatcher.ts +2 -0
- package/src/backend/notifications/useNotificationsPoll.ts +5 -0
- package/src/backend/notifications/useNotificationsSse.ts +6 -0
- package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
- package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
- package/src/backend/utils/optimisticLock.ts +70 -0
- package/src/portal/hooks/useTenantContext.ts +7 -2
- package/src/primitives/__tests__/checkbox.test.tsx +28 -0
- package/src/primitives/alert.tsx +1 -1
- package/src/primitives/checkbox.tsx +2 -1
- package/src/types/css.d.ts +5 -0
|
@@ -4,6 +4,8 @@ import * as React from 'react'
|
|
|
4
4
|
import { useInjectionSpotEvents } from './InjectionSpot'
|
|
5
5
|
import { GLOBAL_MUTATION_INJECTION_SPOT_ID, dispatchBackendMutationError } from './mutationEvents'
|
|
6
6
|
import { withScopedApiRequestHeaders } from '../utils/apiCall'
|
|
7
|
+
import { surfaceRecordConflict } from '../conflicts'
|
|
8
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
9
|
|
|
8
10
|
type GuardedMutationContext = Record<string, unknown>
|
|
9
11
|
|
|
@@ -30,6 +32,7 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
|
|
|
30
32
|
blockedMessage = 'Save blocked by validation',
|
|
31
33
|
spotId = GLOBAL_MUTATION_INJECTION_SPOT_ID,
|
|
32
34
|
}: UseGuardedMutationOptions) {
|
|
35
|
+
const t = useT()
|
|
33
36
|
const { triggerEvent } = useInjectionSpotEvents<TContext, Record<string, unknown>>(spotId)
|
|
34
37
|
const lastMutationRef = React.useRef<StoredMutation<TContext> | null>(null)
|
|
35
38
|
|
|
@@ -39,7 +42,19 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
|
|
|
39
42
|
formId: contextId,
|
|
40
43
|
error,
|
|
41
44
|
})
|
|
42
|
-
|
|
45
|
+
// Default UX for OSS optimistic-lock conflicts (spec
|
|
46
|
+
// .ai/specs/2026-05-25-oss-optimistic-locking.md §3.6): when the
|
|
47
|
+
// server returns 409 with `code: 'optimistic_lock_conflict'`,
|
|
48
|
+
// surface the conflict on the unified, persistent, error-styled
|
|
49
|
+
// RecordConflictBanner (rendered in AppShell) instead of a transient
|
|
50
|
+
// toast. Callers that have their own handler still see the dispatched
|
|
51
|
+
// event above and can suppress/override by catching the error first.
|
|
52
|
+
try {
|
|
53
|
+
surfaceRecordConflict(error, t)
|
|
54
|
+
} catch {
|
|
55
|
+
// ignore conflict-bar dispatch failures
|
|
56
|
+
}
|
|
57
|
+
}, [contextId, t])
|
|
43
58
|
|
|
44
59
|
const runMutation = React.useCallback(async <TResult,>({
|
|
45
60
|
operation,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import dynamic from 'next/dynamic'
|
|
5
|
+
import type { ComponentType } from 'react'
|
|
6
|
+
|
|
7
|
+
export type MarkdownFieldProps = {
|
|
8
|
+
value?: string
|
|
9
|
+
onChange: (markdown: string) => void
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const isTestEnv =
|
|
13
|
+
typeof process !== 'undefined' &&
|
|
14
|
+
(process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
|
|
15
|
+
|
|
16
|
+
// Lightweight stand-in for unit tests (jsdom): MDXEditor/Lexical pull in ESM + CSS that jest
|
|
17
|
+
// would have to transform, so under test we render a plain controlled textarea instead.
|
|
18
|
+
const MarkdownFieldTestStub: ComponentType<MarkdownFieldProps> = ({ value, onChange }) => (
|
|
19
|
+
<textarea
|
|
20
|
+
data-testid="markdown-field"
|
|
21
|
+
className="min-h-[160px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
22
|
+
value={value ?? ''}
|
|
23
|
+
onChange={(event) => onChange(event.target.value)}
|
|
24
|
+
/>
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const MarkdownFieldLive = dynamic(() => import('./MdxEditorImpl'), { ssr: false }) as ComponentType<MarkdownFieldProps>
|
|
28
|
+
|
|
29
|
+
// Canonical Markdown editor for the whole app: a WYSIWYG MDXEditor (Lexical) that emits Markdown.
|
|
30
|
+
// Client-only (Lexical needs the DOM), loaded via next/dynamic with ssr disabled. Use this
|
|
31
|
+
// everywhere a Markdown editing field is needed.
|
|
32
|
+
const MarkdownField: ComponentType<MarkdownFieldProps> = isTestEnv ? MarkdownFieldTestStub : MarkdownFieldLive
|
|
33
|
+
|
|
34
|
+
export default MarkdownField
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/// <reference path="../../types/css.d.ts" />
|
|
2
|
+
"use client"
|
|
3
|
+
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import {
|
|
6
|
+
MDXEditor,
|
|
7
|
+
type MDXEditorMethods,
|
|
8
|
+
headingsPlugin,
|
|
9
|
+
listsPlugin,
|
|
10
|
+
quotePlugin,
|
|
11
|
+
thematicBreakPlugin,
|
|
12
|
+
linkPlugin,
|
|
13
|
+
linkDialogPlugin,
|
|
14
|
+
imagePlugin,
|
|
15
|
+
tablePlugin,
|
|
16
|
+
markdownShortcutPlugin,
|
|
17
|
+
diffSourcePlugin,
|
|
18
|
+
toolbarPlugin,
|
|
19
|
+
UndoRedo,
|
|
20
|
+
BoldItalicUnderlineToggles,
|
|
21
|
+
CodeToggle,
|
|
22
|
+
StrikeThroughSupSubToggles,
|
|
23
|
+
ListsToggle,
|
|
24
|
+
BlockTypeSelect,
|
|
25
|
+
CreateLink,
|
|
26
|
+
InsertImage,
|
|
27
|
+
InsertTable,
|
|
28
|
+
InsertThematicBreak,
|
|
29
|
+
Separator,
|
|
30
|
+
DiffSourceToggleWrapper,
|
|
31
|
+
} from '@mdxeditor/editor'
|
|
32
|
+
import '@mdxeditor/editor/style.css'
|
|
33
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
34
|
+
import { useTheme } from '../../theme'
|
|
35
|
+
|
|
36
|
+
type MdxEditorImplProps = {
|
|
37
|
+
value?: string
|
|
38
|
+
onChange: (markdown: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// WYSIWYG Markdown editor (MDXEditor / Lexical) wired to the CrudForm value/onChange
|
|
42
|
+
// contract. Markdown is the source of truth. Edits are buffered locally and committed
|
|
43
|
+
// on blur to avoid re-rendering the whole form on every keystroke.
|
|
44
|
+
export default function MdxEditorImpl({ value = '', onChange }: MdxEditorImplProps) {
|
|
45
|
+
const editorRef = React.useRef<MDXEditorMethods>(null)
|
|
46
|
+
const latestRef = React.useRef<string>(value)
|
|
47
|
+
const typingRef = React.useRef(false)
|
|
48
|
+
const { resolvedTheme } = useTheme()
|
|
49
|
+
|
|
50
|
+
// External value changes (form reset / async initial load) are pushed into the editor,
|
|
51
|
+
// but never while the user is actively typing (would reset the caret).
|
|
52
|
+
React.useEffect(() => {
|
|
53
|
+
if (typingRef.current) return
|
|
54
|
+
if (value !== latestRef.current) {
|
|
55
|
+
latestRef.current = value
|
|
56
|
+
editorRef.current?.setMarkdown(value ?? '')
|
|
57
|
+
}
|
|
58
|
+
}, [value])
|
|
59
|
+
|
|
60
|
+
const commit = React.useCallback(() => {
|
|
61
|
+
if (!typingRef.current) return
|
|
62
|
+
typingRef.current = false
|
|
63
|
+
onChange(latestRef.current)
|
|
64
|
+
}, [onChange])
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="w-full overflow-hidden rounded-md border border-input bg-background" onBlur={commit}>
|
|
68
|
+
<MDXEditor
|
|
69
|
+
ref={editorRef}
|
|
70
|
+
markdown={value ?? ''}
|
|
71
|
+
onChange={(markdown) => {
|
|
72
|
+
typingRef.current = true
|
|
73
|
+
latestRef.current = markdown
|
|
74
|
+
}}
|
|
75
|
+
className={cn('om-mdx-editor', resolvedTheme === 'dark' && 'dark-theme')}
|
|
76
|
+
contentEditableClassName="om-mdx-prose"
|
|
77
|
+
plugins={[
|
|
78
|
+
headingsPlugin(),
|
|
79
|
+
listsPlugin(),
|
|
80
|
+
quotePlugin(),
|
|
81
|
+
thematicBreakPlugin(),
|
|
82
|
+
linkPlugin(),
|
|
83
|
+
linkDialogPlugin(),
|
|
84
|
+
imagePlugin(),
|
|
85
|
+
tablePlugin(),
|
|
86
|
+
markdownShortcutPlugin(),
|
|
87
|
+
diffSourcePlugin({ viewMode: 'rich-text' }),
|
|
88
|
+
toolbarPlugin({
|
|
89
|
+
toolbarContents: () => (
|
|
90
|
+
<DiffSourceToggleWrapper>
|
|
91
|
+
<UndoRedo />
|
|
92
|
+
<Separator />
|
|
93
|
+
<BoldItalicUnderlineToggles />
|
|
94
|
+
<CodeToggle />
|
|
95
|
+
<StrikeThroughSupSubToggles />
|
|
96
|
+
<Separator />
|
|
97
|
+
<ListsToggle />
|
|
98
|
+
<Separator />
|
|
99
|
+
<BlockTypeSelect />
|
|
100
|
+
<Separator />
|
|
101
|
+
<CreateLink />
|
|
102
|
+
<InsertImage />
|
|
103
|
+
<Separator />
|
|
104
|
+
<InsertTable />
|
|
105
|
+
<InsertThematicBreak />
|
|
106
|
+
</DiffSourceToggleWrapper>
|
|
107
|
+
),
|
|
108
|
+
}),
|
|
109
|
+
]}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -29,11 +29,8 @@
|
|
|
29
29
|
"use client"
|
|
30
30
|
|
|
31
31
|
import * as React from 'react'
|
|
32
|
-
import dynamic from 'next/dynamic'
|
|
33
32
|
import type { PluggableList } from 'unified'
|
|
34
|
-
import
|
|
35
|
-
import { useMarkdownRemarkPlugins } from '../markdown/useMarkdownRemarkPlugins'
|
|
36
|
-
import { useTheme } from '../../theme'
|
|
33
|
+
import MarkdownField from './MarkdownField'
|
|
37
34
|
|
|
38
35
|
/** @deprecated See module-level JSDoc. */
|
|
39
36
|
export type SwitchableMarkdownInputProps = {
|
|
@@ -53,75 +50,29 @@ export type SwitchableMarkdownInputProps = {
|
|
|
53
50
|
remarkPlugins?: PluggableList
|
|
54
51
|
}
|
|
55
52
|
|
|
56
|
-
type UiMarkdownEditorProps = {
|
|
57
|
-
value?: string
|
|
58
|
-
height?: number
|
|
59
|
-
onChange?: (value?: string) => void
|
|
60
|
-
previewOptions?: { remarkPlugins?: unknown[] }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const isTestEnv =
|
|
64
|
-
typeof process !== 'undefined' &&
|
|
65
|
-
(process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
|
|
66
|
-
|
|
67
|
-
const MarkdownEditorTestStub: React.ComponentType<UiMarkdownEditorProps> = ({ value, onChange }) => (
|
|
68
|
-
<textarea
|
|
69
|
-
className="min-h-[160px] w-full rounded border px-3 py-2 text-sm"
|
|
70
|
-
value={value ?? ''}
|
|
71
|
-
onChange={(event) => onChange?.(event.target.value)}
|
|
72
|
-
/>
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
const UiMarkdownEditor = isTestEnv
|
|
76
|
-
? MarkdownEditorTestStub
|
|
77
|
-
: (dynamic(() => import('@uiw/react-md-editor'), {
|
|
78
|
-
ssr: false,
|
|
79
|
-
loading: () => (
|
|
80
|
-
<LoadingMessage
|
|
81
|
-
label="Loading editor..."
|
|
82
|
-
className="min-h-[220px] justify-center"
|
|
83
|
-
/>
|
|
84
|
-
),
|
|
85
|
-
}) as unknown as React.ComponentType<UiMarkdownEditorProps>)
|
|
86
|
-
|
|
87
53
|
/** @deprecated Use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` for new code. See module JSDoc. */
|
|
88
54
|
export function SwitchableMarkdownInput({
|
|
89
55
|
value,
|
|
90
56
|
onChange,
|
|
91
57
|
isMarkdownEnabled,
|
|
92
58
|
disableMarkdown,
|
|
93
|
-
height = 220,
|
|
94
59
|
placeholder,
|
|
95
60
|
rows = 3,
|
|
96
61
|
textareaRef,
|
|
97
62
|
onTextareaInput,
|
|
98
63
|
textareaClassName,
|
|
99
|
-
editorWrapperClassName,
|
|
100
|
-
editorClassName,
|
|
101
64
|
disabled,
|
|
102
|
-
remarkPlugins,
|
|
103
65
|
}: SwitchableMarkdownInputProps) {
|
|
104
|
-
const resolvedPlugins = useMarkdownRemarkPlugins(remarkPlugins)
|
|
105
|
-
const { resolvedTheme } = useTheme()
|
|
106
|
-
const editorWrapperClasses =
|
|
107
|
-
editorWrapperClassName ?? 'w-full rounded-lg border border-muted-foreground/20 bg-background p-2'
|
|
108
|
-
const editorClasses = editorClassName ?? 'w-full'
|
|
109
66
|
const textareaClasses =
|
|
110
67
|
textareaClassName
|
|
111
68
|
?? 'w-full resize-none overflow-hidden rounded-lg border border-muted-foreground/20 bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
112
69
|
|
|
113
70
|
if (isMarkdownEnabled && !disableMarkdown) {
|
|
114
71
|
return (
|
|
115
|
-
<
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
height={height}
|
|
120
|
-
onChange={(nextValue) => onChange(typeof nextValue === 'string' ? nextValue : '')}
|
|
121
|
-
previewOptions={resolvedPlugins.length ? { remarkPlugins: resolvedPlugins } : undefined}
|
|
122
|
-
/>
|
|
123
|
-
</div>
|
|
124
|
-
</div>
|
|
72
|
+
<MarkdownField
|
|
73
|
+
value={value}
|
|
74
|
+
onChange={(nextValue) => onChange(typeof nextValue === 'string' ? nextValue : '')}
|
|
75
|
+
/>
|
|
125
76
|
)
|
|
126
77
|
}
|
|
127
78
|
|
|
@@ -26,4 +26,17 @@ describe('markdownToPlainText', () => {
|
|
|
26
26
|
const input = '> quoted text\n\n~~gone~~\n\n| a | b |\n| - | - |\n| 1 | 2 |'
|
|
27
27
|
expect(markdownToPlainText(input)).toBe('quoted text gone a b - - 1 2')
|
|
28
28
|
})
|
|
29
|
+
|
|
30
|
+
it('strips legacy HTML tags and separates block content', () => {
|
|
31
|
+
expect(markdownToPlainText('<span style="font-weight:bold">Backend</span> and frontend')).toBe(
|
|
32
|
+
'Backend and frontend',
|
|
33
|
+
)
|
|
34
|
+
expect(markdownToPlainText('<p>Hello</p><p>World</p>')).toBe('Hello World')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('decodes named, decimal, and hex HTML entities', () => {
|
|
38
|
+
expect(markdownToPlainText('Tom & Jerry')).toBe('Tom & Jerry')
|
|
39
|
+
expect(markdownToPlainText('it's — done')).toBe("it's — done")
|
|
40
|
+
expect(markdownToPlainText('a b')).toBe('a b')
|
|
41
|
+
})
|
|
29
42
|
})
|
|
@@ -1,7 +1,47 @@
|
|
|
1
|
+
const NAMED_HTML_ENTITIES: Record<string, string> = {
|
|
2
|
+
amp: '&',
|
|
3
|
+
lt: '<',
|
|
4
|
+
gt: '>',
|
|
5
|
+
quot: '"',
|
|
6
|
+
apos: "'",
|
|
7
|
+
nbsp: ' ',
|
|
8
|
+
hellip: '…',
|
|
9
|
+
mdash: '—',
|
|
10
|
+
ndash: '–',
|
|
11
|
+
rsquo: '’',
|
|
12
|
+
lsquo: '‘',
|
|
13
|
+
rdquo: '”',
|
|
14
|
+
ldquo: '“',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function decodeHtmlEntities(value: string): string {
|
|
18
|
+
return value
|
|
19
|
+
.replace(/&#(\d+);/g, (match, dec: string) => {
|
|
20
|
+
const code = Number(dec)
|
|
21
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
|
22
|
+
})
|
|
23
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (match, hex: string) => {
|
|
24
|
+
const code = parseInt(hex, 16)
|
|
25
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
|
26
|
+
})
|
|
27
|
+
.replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name: string) =>
|
|
28
|
+
Object.prototype.hasOwnProperty.call(NAMED_HTML_ENTITIES, name) ? NAMED_HTML_ENTITIES[name] : match,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Converts a Markdown (or legacy HTML) string into a plain-text preview suitable for compact,
|
|
34
|
+
* read-only contexts such as DataTable list cells. Strips Markdown syntax, strips HTML tags, and
|
|
35
|
+
* decodes HTML entities, then collapses whitespace. Handling legacy HTML keeps list previews clean
|
|
36
|
+
* for records authored before the editor moved back to Markdown — no data migration required.
|
|
37
|
+
*
|
|
38
|
+
* This is NOT a sanitizer — render rich content with the Markdown components, not this helper.
|
|
39
|
+
*/
|
|
1
40
|
export function markdownToPlainText(input: string | null | undefined): string {
|
|
2
41
|
if (!input) return ''
|
|
3
42
|
let text = String(input)
|
|
4
43
|
text = text.replace(/```[\s\S]*?```/g, ' ')
|
|
44
|
+
text = text.replace(/<[^>]+>/g, ' ')
|
|
5
45
|
text = text.replace(/`([^`]+)`/g, '$1')
|
|
6
46
|
text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
7
47
|
text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
@@ -15,6 +55,7 @@ export function markdownToPlainText(input: string | null | undefined): string {
|
|
|
15
55
|
text = text.replace(/\*([^*]+)\*/g, '$1')
|
|
16
56
|
text = text.replace(/_([^_]+)_/g, '$1')
|
|
17
57
|
text = text.replace(/\|/g, ' ')
|
|
58
|
+
text = decodeHtmlEntities(text)
|
|
18
59
|
text = text.replace(/\s+/g, ' ').trim()
|
|
19
60
|
return text
|
|
20
61
|
}
|