diff --git a/.github/playwright/impact-map.generated.json b/.github/playwright/impact-map.generated.json
index a5c2053bdd35..8b2d5e9053e8 100644
--- a/.github/playwright/impact-map.generated.json
+++ b/.github/playwright/impact-map.generated.json
@@ -3328,6 +3328,7 @@
"playwright/e2e/Features/AdvancedSearch.spec.ts",
"playwright/e2e/Features/ContextCenterDashboard.spec.ts",
"playwright/e2e/Features/DataQuality/IncidentManagerSqlQuery.spec.ts",
+ "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts",
"playwright/e2e/Features/DataQuality/TableTestCaseExportFilters.spec.ts",
"playwright/e2e/Features/DomainFilterQueryFilter.spec.ts",
"playwright/e2e/Features/LandingPageWidgets/LandingPageContextCenter.spec.ts",
@@ -8668,6 +8669,7 @@
"openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddIngestion/AddIngestion.component.tsx"
],
"specs": [
+ "playwright/e2e/Features/DataQuality/ProfilerIngestionForm.spec.ts",
"playwright/e2e/Features/StorageMetadataAgentForm.spec.ts"
]
},
@@ -9260,6 +9262,21 @@
"playwright/e2e/Pages/Users.spec.ts"
]
},
+ {
+ "sources": [
+ "openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.tsx"
+ ],
+ "specs": [
+ "playwright/e2e/Features/AIMode/InboxActivity.spec.ts",
+ "playwright/e2e/Features/AIMode/InboxTasks.spec.ts",
+ "playwright/e2e/Features/ActivityAPI.spec.ts",
+ "playwright/e2e/Features/ActivityFeed.spec.ts",
+ "playwright/e2e/Features/ContextCenterArticles.spec.ts",
+ "playwright/e2e/Features/ContextCenterPermission.spec.ts",
+ "playwright/e2e/Features/IncidentManager.spec.ts",
+ "playwright/e2e/Features/Tasks/TaskComments.spec.ts"
+ ]
+ },
{
"sources": [
"openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/CustomPropertiesWidget/CustomPropertiesWidgetEditor.tsx"
@@ -10350,21 +10367,6 @@
"playwright/e2e/Features/AIMode/InboxTasks.spec.ts"
]
},
- {
- "sources": [
- "openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxCommentComposer.tsx"
- ],
- "specs": [
- "playwright/e2e/Features/AIMode/InboxActivity.spec.ts",
- "playwright/e2e/Features/AIMode/InboxTasks.spec.ts",
- "playwright/e2e/Features/ActivityAPI.spec.ts",
- "playwright/e2e/Features/ActivityFeed.spec.ts",
- "playwright/e2e/Features/ContextCenterArticles.spec.ts",
- "playwright/e2e/Features/ContextCenterPermission.spec.ts",
- "playwright/e2e/Features/IncidentManager.spec.ts",
- "playwright/e2e/Features/Tasks/TaskComments.spec.ts"
- ]
- },
{
"sources": [
"openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxTaskListToolbar.tsx"
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-blockquote.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-blockquote.svg
new file mode 100644
index 000000000000..73f0eecd98fe
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-blockquote.svg
@@ -0,0 +1,6 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-bold.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-bold.svg
new file mode 100644
index 000000000000..e384a9d21b60
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-bold.svg
@@ -0,0 +1,3 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-bulleted-list.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-bulleted-list.svg
new file mode 100644
index 000000000000..ccc280bf9eac
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-bulleted-list.svg
@@ -0,0 +1,8 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-code.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-code.svg
new file mode 100644
index 000000000000..583a914ddd8a
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-code.svg
@@ -0,0 +1,5 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-emoji.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-emoji.svg
new file mode 100644
index 000000000000..b0ad7248182b
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-emoji.svg
@@ -0,0 +1,5 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-hashtag.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-hashtag.svg
new file mode 100644
index 000000000000..49c516fa6b93
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-hashtag.svg
@@ -0,0 +1,6 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-italic.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-italic.svg
new file mode 100644
index 000000000000..e76fdf2f9c49
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-italic.svg
@@ -0,0 +1,3 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-link.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-link.svg
new file mode 100644
index 000000000000..c0d31a578d3d
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-link.svg
@@ -0,0 +1,3 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-mention.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-mention.svg
new file mode 100644
index 000000000000..8a276a293721
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-mention.svg
@@ -0,0 +1,3 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-numbered-list.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-numbered-list.svg
new file mode 100644
index 000000000000..b3175c92655e
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-numbered-list.svg
@@ -0,0 +1,7 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-strikethrough.svg b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-strikethrough.svg
new file mode 100644
index 000000000000..891f3fff6770
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/icons/text-editor-strikethrough.svg
@@ -0,0 +1,3 @@
+
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBlockquote.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBlockquote.tsx
new file mode 100644
index 000000000000..607d44de8568
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBlockquote.tsx
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorBlockquote: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorBlockquote.displayName = 'TextEditorBlockquote';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBold.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBold.tsx
new file mode 100644
index 000000000000..5b1983f99a28
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBold.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorBold: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorBold.displayName = 'TextEditorBold';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBulletedList.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBulletedList.tsx
new file mode 100644
index 000000000000..a003b14513e4
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorBulletedList.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorBulletedList: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorBulletedList.displayName = 'TextEditorBulletedList';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorCode.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorCode.tsx
new file mode 100644
index 000000000000..042f0ecd24f8
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorCode.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorCode: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorCode.displayName = 'TextEditorCode';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorEmoji.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorEmoji.tsx
new file mode 100644
index 000000000000..08eba43224b7
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorEmoji.tsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorEmoji: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorEmoji.displayName = 'TextEditorEmoji';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorHashtag.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorHashtag.tsx
new file mode 100644
index 000000000000..ca6172a589e2
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorHashtag.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorHashtag: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorHashtag.displayName = 'TextEditorHashtag';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorItalic.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorItalic.tsx
new file mode 100644
index 000000000000..f4bc3d921d1e
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorItalic.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorItalic: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorItalic.displayName = 'TextEditorItalic';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorLink.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorLink.tsx
new file mode 100644
index 000000000000..14056c2ccb70
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorLink.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorLink: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorLink.displayName = 'TextEditorLink';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorMention.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorMention.tsx
new file mode 100644
index 000000000000..59de7694f307
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorMention.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorMention: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorMention.displayName = 'TextEditorMention';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorNumberedList.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorNumberedList.tsx
new file mode 100644
index 000000000000..3dd7c5c8a40f
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorNumberedList.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorNumberedList: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorNumberedList.displayName = 'TextEditorNumberedList';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorStrikethrough.tsx b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorStrikethrough.tsx
new file mode 100644
index 000000000000..e6afc6910f65
--- /dev/null
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/TextEditorStrikethrough.tsx
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2025 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+import * as React from 'react';
+import type { SVGProps, FC } from 'react';
+interface Props extends SVGProps {
+ color?: string;
+ size?: number;
+}
+
+export const TextEditorStrikethrough: FC = ({
+ size = 24,
+ color = 'currentColor',
+ ...props
+}) => (
+
+);
+TextEditorStrikethrough.displayName = 'TextEditorStrikethrough';
diff --git a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/index.ts b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/index.ts
index 6c401952a556..d2cf2338e4c8 100644
--- a/openmetadata-ui-core-components/src/main/resources/ui/src/icons/index.ts
+++ b/openmetadata-ui-core-components/src/main/resources/ui/src/icons/index.ts
@@ -424,6 +424,17 @@ export { TestCase2 } from './TestCase2';
export { TestCase } from './TestCase';
export { TestDefinition } from './TestDefinition';
export { TestSuite } from './TestSuite';
+export { TextEditorBlockquote } from './TextEditorBlockquote';
+export { TextEditorBold } from './TextEditorBold';
+export { TextEditorBulletedList } from './TextEditorBulletedList';
+export { TextEditorCode } from './TextEditorCode';
+export { TextEditorEmoji } from './TextEditorEmoji';
+export { TextEditorHashtag } from './TextEditorHashtag';
+export { TextEditorItalic } from './TextEditorItalic';
+export { TextEditorLink } from './TextEditorLink';
+export { TextEditorMention } from './TextEditorMention';
+export { TextEditorNumberedList } from './TextEditorNumberedList';
+export { TextEditorStrikethrough } from './TextEditorStrikethrough';
export { ThumbsDown } from './ThumbsDown';
export { ThumbsUp } from './ThumbsUp';
export { Time } from './Time';
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxCommentComposer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.test.tsx
similarity index 59%
rename from openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxCommentComposer.test.tsx
rename to openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.test.tsx
index ad6760047149..ad24fe07ffd9 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/discovery/personal-space/InboxPage/components/InboxCommentComposer.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.test.tsx
@@ -59,11 +59,15 @@ jest.mock(
onTextChange,
placeHolder,
editAction,
+ defaultValue,
+ className,
}: {
onSave?: (m: string) => void;
onTextChange?: (m: string) => void;
placeHolder?: string;
editAction?: React.ReactNode;
+ defaultValue?: string;
+ className?: string;
},
ref: React.Ref
) => {
@@ -74,9 +78,22 @@ jest.mock(
}));
return (
- <>
+
{/* quill's format bar, where quill-emoji adds its button. */}
,
Button: ({
onClick,
+ onPress,
isDisabled,
+ children,
...rest
}: {
onClick?: () => void;
+ onPress?: () => void;
isDisabled?: boolean;
+ children?: React.ReactNode;
'aria-label'?: string;
'data-testid'?: string;
}) => (
@@ -125,12 +153,31 @@ jest.mock('@openmetadata/ui-core-components', () => ({
aria-label={rest['aria-label']}
data-testid={rest['data-testid']}
disabled={isDisabled}
+ onClick={onClick ?? onPress}>
+ {children}
+
+ ),
+ ButtonUtility: ({
+ onClick,
+ tooltip,
+ ...rest
+ }: {
+ onClick?: () => void;
+ tooltip?: string;
+ 'data-testid'?: string;
+ }) => (
+
),
+ Typography: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
}));
-import InboxCommentComposer from './InboxCommentComposer';
+import CommentComposer, { CommentEditForm } from './CommentComposer';
// Stands in for typing: the editor reports its markdown through onTextChange.
const type = (text: string) =>
@@ -143,9 +190,9 @@ beforeEach(() => {
mockCurrentUser = { name: 'alice', displayName: 'Alice Johnson' };
});
-describe('InboxCommentComposer', () => {
+describe('CommentComposer', () => {
it("renders the current user's avatar", () => {
- render();
+ render();
const avatar = screen.getByTestId('avatar');
@@ -156,7 +203,7 @@ describe('InboxCommentComposer', () => {
// The design's arrow button replaces the editor's own send button.
it('posts and clears the draft from the send button', () => {
mockEditorContent = 'Looks right';
- render();
+ render();
type('Looks right');
fireEvent.click(screen.getByTestId('send-button'));
@@ -166,7 +213,7 @@ describe('InboxCommentComposer', () => {
});
it('keeps the send button disabled until something is typed', () => {
- render();
+ render();
expect(screen.getByTestId('send-button')).toBeDisabled();
@@ -185,7 +232,7 @@ describe('InboxCommentComposer', () => {
it('disables the send button again once the comment is sent', () => {
mockEditorContent = 'Looks right';
- render();
+ render();
type('Looks right');
fireEvent.click(screen.getByTestId('send-button'));
@@ -194,7 +241,7 @@ describe('InboxCommentComposer', () => {
});
it('disables the send button again after Enter sends the comment', () => {
- render();
+ render();
type('hello');
fireEvent.click(screen.getByTestId('feed-editor'));
@@ -204,7 +251,7 @@ describe('InboxCommentComposer', () => {
});
it('forwards the editor save to onSave', () => {
- render();
+ render();
fireEvent.click(screen.getByTestId('feed-editor'));
@@ -214,7 +261,7 @@ describe('InboxCommentComposer', () => {
// A refused comment comes back to the editor rather than vanishing with it.
it('puts the draft back when the save fails', async () => {
mockOnSave.mockRejectedValueOnce(new Error('refused'));
- render();
+ render();
await act(async () => {
fireEvent.click(screen.getByTestId('feed-editor'));
@@ -228,7 +275,7 @@ describe('InboxCommentComposer', () => {
it('leaves the editor empty when the save succeeds', async () => {
mockOnSave.mockResolvedValueOnce(undefined);
- render();
+ render();
await act(async () => {
fireEvent.click(screen.getByTestId('feed-editor'));
@@ -238,7 +285,7 @@ describe('InboxCommentComposer', () => {
});
it('passes the default placeholder to the editor', () => {
- render();
+ render();
expect(screen.getByTestId('feed-editor')).toHaveAttribute(
'data-placeholder',
@@ -247,9 +294,7 @@ describe('InboxCommentComposer', () => {
});
it('respects a custom placeholder', () => {
- render(
-
- );
+ render();
expect(screen.getByTestId('feed-editor')).toHaveAttribute(
'data-placeholder',
@@ -258,18 +303,80 @@ describe('InboxCommentComposer', () => {
});
it('exposes the placeholder to the editor chrome via a CSS variable', () => {
- render(
-
- );
+ render();
const holder = document.querySelector(
- '[style*="--inbox-composer-placeholder"]'
+ '[style*="--comment-composer-placeholder"]'
);
expect(holder).not.toBeNull();
expect(holder?.getAttribute('style')).toContain('Reply here');
});
+ // The format bar starts hidden; the toggle beside send shows and hides it.
+ describe('formatting toggle', () => {
+ // The class that hides Quill's format bar while the toggle is off.
+ const COLLAPSED_CLASS = 'tw:[&_.ql-toolbar.ql-snow]:hidden!';
+ const editorRoot = () => screen.getByTestId('editor-root');
+
+ it('starts with the format bar hidden', () => {
+ render();
+
+ expect(editorRoot()).toHaveClass(COLLAPSED_CLASS);
+ expect(screen.getByTestId('formatting-toggle')).toHaveAttribute(
+ 'aria-label',
+ 'label.show-formatting'
+ );
+ });
+
+ it('shows the format bar, then hides it again', () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('formatting-toggle'));
+
+ expect(editorRoot()).not.toHaveClass(COLLAPSED_CLASS);
+ expect(screen.getByTestId('formatting-toggle')).toHaveAttribute(
+ 'aria-label',
+ 'label.hide-formatting'
+ );
+
+ fireEvent.click(screen.getByTestId('formatting-toggle'));
+
+ expect(editorRoot()).toHaveClass(COLLAPSED_CLASS);
+ });
+ });
+
+ // Quill draws its icons from one app-wide registry; the composer draws the
+ // design's into its own buttons, Quill's and quill-emoji's alike.
+ describe('format bar icons', () => {
+ const iconIn = (button: Element | null) =>
+ button?.querySelector('svg.comment-composer-icon');
+
+ it("draws the design's icon into each format button", async () => {
+ render();
+
+ await waitFor(() =>
+ expect(iconIn(screen.getByTestId('bold-control'))).toBeTruthy()
+ );
+
+ expect(iconIn(screen.getByTestId('ordered-list-control'))).toBeTruthy();
+ expect(iconIn(screen.getByTestId('emoji-control'))).toBeTruthy();
+ });
+
+ it('draws the icons into a format bar rebuilt after mount', async () => {
+ render();
+ const newBar = document.createElement('div');
+ newBar.className = 'ql-toolbar';
+ newBar.innerHTML =
+ '';
+ screen.getByTestId('format-bar').replaceWith(newBar);
+
+ await waitFor(() =>
+ expect(iconIn(newBar.querySelector('.ql-italic'))).toBeTruthy()
+ );
+ });
+ });
+
// quill-emoji adds its picker to the format bar with no horizontal position
// and removes it on close.
describe('emoji picker', () => {
@@ -285,7 +392,7 @@ describe('InboxCommentComposer', () => {
};
it('anchors the picker above its button and marks the button open', async () => {
- render();
+ render();
const button = screen.getByTestId('emoji-control');
const picker = openPicker('-250px');
@@ -306,7 +413,7 @@ describe('InboxCommentComposer', () => {
// The editor loads lazily and quill rebuilds its bar on re-initialising,
// so the bar can be a new one by the time the picker opens.
it('anchors the picker in a format bar rebuilt after mount', async () => {
- render();
+ render();
const oldBar = screen.getByTestId('format-bar');
const newBar = document.createElement('div');
newBar.className = 'ql-toolbar';
@@ -328,7 +435,7 @@ describe('InboxCommentComposer', () => {
});
it('opens below the bar when quill-emoji chose below', async () => {
- render();
+ render();
const picker = openPicker();
await waitFor(() => expect(picker.style.top).toBe('100%'));
@@ -337,3 +444,55 @@ describe('InboxCommentComposer', () => {
});
});
});
+
+// An edited comment opens in the same box a new one is written in.
+describe('CommentEditForm', () => {
+ const renderEdit = (onSave = jest.fn(), onCancel = jest.fn()) =>
+ render(
+
+ );
+
+ it("opens the comment in the composer's editor, ready to send", async () => {
+ renderEdit();
+
+ expect(screen.getByTestId('edit-comment-editor')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('feed-editor').getAttribute('data-default-value')
+ ).toContain('Looking');
+ expect(screen.getByTestId('send-button')).toBeEnabled();
+
+ await waitFor(() =>
+ expect(
+ screen
+ .getByTestId('bold-control')
+ .querySelector('svg.comment-composer-icon')
+ ).toBeTruthy()
+ );
+ });
+
+ it('shows the new-line hint beside Cancel', () => {
+ const onCancel = jest.fn();
+ renderEdit(jest.fn(), onCancel);
+
+ expect(screen.getByText(/message.to-add-new-line/)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId('cancel-edit-comment'));
+
+ expect(onCancel).toHaveBeenCalled();
+ });
+
+ it('saves the edited text from the send button', () => {
+ const onSave = jest.fn().mockResolvedValue(undefined);
+ mockEditorContent = 'Looking at this tomorrow.';
+ renderEdit(onSave);
+
+ fireEvent.click(screen.getByTestId('send-button'));
+
+ expect(onSave).toHaveBeenCalledWith('Looking at this tomorrow.');
+ });
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.tsx
new file mode 100644
index 000000000000..dfb51b75d222
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CommentComposer/CommentComposer.tsx
@@ -0,0 +1,482 @@
+/*
+ * Copyright 2026 Collate.
+ * Licensed under the Apache 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
+ * http://www.apache.org/licenses/LICENSE-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.
+ */
+
+import {
+ Box,
+ Button,
+ ButtonUtility,
+ Typography,
+} from '@openmetadata/ui-core-components';
+import {
+ ArrowRight,
+ TextEditorBlockquote,
+ TextEditorBold,
+ TextEditorBulletedList,
+ TextEditorCode,
+ TextEditorEmoji,
+ TextEditorHashtag,
+ TextEditorItalic,
+ TextEditorLink,
+ TextEditorMention,
+ TextEditorNumberedList,
+ TextEditorStrikethrough,
+ Type01,
+} from '@openmetadata/ui-core-components/icons';
+import classNames from 'classnames';
+import React, {
+ FC,
+ RefObject,
+ SVGProps,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { useTranslation } from 'react-i18next';
+import { useApplicationStore } from '../../../hooks/useApplicationStore';
+import {
+ getBackendFormat,
+ getFrontEndFormat,
+ MarkdownToHTMLConverter,
+} from '../../../utils/FeedUtilsPure';
+import ActivityFeedEditorNew from '../../ActivityFeed/ActivityFeedEditor/ActivityFeedEditorNew';
+import ProfilePicture from '../ProfilePicture/ProfilePicture';
+import { EditorContentRef } from '../RichTextEditor/RichTextEditor.interface';
+
+// The design's restyle of the OSS ActivityFeedEditorNew, scoped to this
+// editor: one input line with the formatting toggle and send button inside it,
+// under a format bar of the design's icons. Quill builds this markup itself, so
+// these reach it from the editor's root (Tailwind reads `_` in those selectors
+// as a space, so no class here has one). `!`: Quill's snow theme and the
+// FeedEditor's own styles are unlayered CSS, which beats Tailwind's utilities
+// whatever their selector.
+const ICON_CLASS = 'comment-composer-icon';
+
+// The design's gray-blue border; in dark that scale turns navy, so it stays
+// neutral. It also holds over FeedEditor's inline focus border, which all but
+// vanishes in dark — and an edit box opens focused.
+const EDITOR_BOX_CLASS = [
+ 'tw:[&_.editor-container]:border! tw:[&_.editor-container]:border-solid!',
+ 'tw:[&_.editor-container]:border-utility-gray-blue-100! tw:dark:[&_.editor-container]:border-primary!',
+ 'tw:[&_.editor-container]:rounded-[10px]! tw:[&_.editor-container]:bg-primary!',
+ // Quill sets 13px Helvetica; comments read in the page's font at 14px.
+ 'tw:[&_.ql-container.ql-snow]:border-none! tw:[&_.ql-container.ql-snow]:bg-transparent!',
+ 'tw:[&_.ql-container.ql-snow]:font-[inherit]! tw:[&_.ql-container.ql-snow]:text-[14px]!',
+];
+
+// The format bar sits in the same box as the text, split by a hairline. It
+// anchors the emoji picker, which the composer places by its button. The
+// design's icons, drawn into Quill's buttons, replace Quill's and
+// quill-emoji's icons and the @ / # glyphs, at 16px in Quill's own button box.
+const FORMAT_BAR_CLASS = [
+ 'tw:[&_.ql-toolbar.ql-snow]:relative! tw:[&_.ql-toolbar.ql-snow]:px-2! tw:[&_.ql-toolbar.ql-snow]:py-1!',
+ 'tw:[&_.ql-toolbar.ql-snow]:border-0! tw:[&_.ql-toolbar.ql-snow]:border-b!',
+ 'tw:[&_.ql-toolbar.ql-snow]:border-solid! tw:[&_.ql-toolbar.ql-snow]:border-b-secondary!',
+ 'tw:[&_.ql-toolbar_.ql-formats_button]:text-quaternary!',
+ 'tw:[&_.ql-toolbar_.ql-formats_button>svg:not(.comment-composer-icon)]:hidden!',
+ 'tw:[&_.ql-toolbar_.ql-formats_button]:after:content-none!',
+ // The editor zeroes the @ / # buttons' padding for their glyphs.
+ 'tw:[&_.ql-toolbar_button.ql-insertMention]:px-[5px]! tw:[&_.ql-toolbar_button.ql-insertMention]:py-[3px]!',
+ 'tw:[&_.ql-toolbar_button.ql-insertRef]:px-[5px]! tw:[&_.ql-toolbar_button.ql-insertRef]:py-[3px]!',
+ 'tw:[&_.ql-toolbar_.ql-formats_button:hover]:text-fg-brand-primary!',
+ 'tw:[&_.ql-toolbar_.ql-formats_button:focus-visible]:text-fg-brand-primary!',
+ 'tw:[&_.ql-toolbar_.ql-formats_button.ql-active]:text-fg-brand-primary!',
+ 'tw:[&_.ql-toolbar_.comment-composer-icon]:block!',
+ 'tw:[&_.ql-toolbar_.comment-composer-icon]:float-none!',
+ 'tw:[&_.ql-toolbar_.comment-composer-icon]:size-4!',
+ 'tw:[&_.ql-toolbar_.comment-composer-icon]:mx-auto!',
+ 'tw:[&_.ql-toolbar_.comment-composer-icon]:my-px!',
+];
+
+// One line of text, clear of the toggle and send button on the right.
+// `.editor-container` outranks the FeedEditor's own 100px minimum.
+const TEXT_CLASS = [
+ 'tw:[&_.ql-editor]:min-h-11! tw:[&_.ql-editor]:pt-3! tw:[&_.ql-editor]:pb-3!',
+ 'tw:[&_.ql-editor]:pl-3.5! tw:[&_.ql-editor]:pr-25!',
+ 'tw:[&_.ql-editor]:bg-transparent! tw:[&_.ql-editor]:rounded-[10px]!',
+ // A list starts at the text's edge: its items' padding holds the marker.
+ 'tw:[&_.ql-editor_ol]:pl-0!',
+ // FeedEditor hard-codes the "Use @mention…" placeholder: the design's copy
+ // comes from the component as a CSS variable, so it stays translated.
+ // Quill's own placeholder color is invisible on a dark surface.
+ 'tw:[&_.ql-editor.ql-blank]:before:content-(--comment-composer-placeholder)!',
+ 'tw:[&_.ql-editor.ql-blank]:before:not-italic! tw:[&_.ql-editor.ql-blank]:before:left-3.5!',
+ 'tw:dark:[&_.ql-editor.ql-blank]:before:text-placeholder!',
+];
+
+// The @ / # suggestion list, in the core Dropdown's style: a padded overlay
+// with inset rounded rows.
+const MENTION_LIST_CLASS = [
+ 'tw:[&_.ql-mention-list-container]:w-62! tw:[&_.ql-mention-list-container]:py-1!',
+ 'tw:[&_.ql-mention-list-container]:border-none!',
+ 'tw:[&_.ql-mention-list-container]:rounded-lg! tw:[&_.ql-mention-list-container]:outline-1!',
+ 'tw:[&_.ql-mention-list-container]:outline-solid!',
+ 'tw:[&_.ql-mention-list-container]:-outline-offset-1!',
+ 'tw:[&_.ql-mention-list-container]:outline-subtle!',
+ 'tw:[&_.ql-mention-list-container]:bg-overlay-surface!',
+ // The theme's `shadow-lg` is empty here; the token carries the values.
+ 'tw:[&_.ql-mention-list-container]:[box-shadow:var(--om-shadow-lg)]!',
+ 'tw:[&_.ql-mention-list-item]:mx-1.5! tw:[&_.ql-mention-list-item]:my-px!',
+ 'tw:[&_.ql-mention-list-item]:px-2.5! tw:[&_.ql-mention-list-item]:py-2!',
+ 'tw:[&_.ql-mention-list-item]:rounded-md! tw:[&_.ql-mention-list-item]:text-[14px]!',
+ 'tw:[&_.ql-mention-list-item]:text-secondary!',
+ 'tw:[&_.ql-mention-list-item.selected]:bg-primary_hover!',
+ 'tw:[&_.ql-mention-list-item.disabled]:cursor-default! tw:[&_.ql-mention-list-item.disabled]:text-tertiary!',
+];
+
+// The link bubble is a fixed 300px with nowrap content, so its chips overflow
+// here: a flex row bounded by the editor's width instead. `:not(.ql-hidden)`
+// keeps Quill's hide rule winning. Quill's editing mode zeroes the Save chip's
+// right padding, leaving its label flush with the chip's edge. Its ::before and
+// ::after are reached in the selector, not with `before:`/`after:`, which would
+// also reset their `content` ("Enter Url:", "Save").
+const LINK_TOOLTIP_CLASS = [
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)]:flex!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)]:items-center!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)]:gap-2!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)]:w-[min(420px,calc(100%-16px))]!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)::before]:shrink-0!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_input[type=text]]:flex-auto!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_input[type=text]]:min-w-0!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_input[type=text]]:w-auto!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_a.ql-preview]:flex-auto!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_a.ql-preview]:min-w-0!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_a.ql-preview]:w-auto!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_a.ql-action]:shrink-0!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden)_a.ql-remove]:shrink-0!',
+ 'tw:[&_.ql-snow_.ql-tooltip:not(.ql-hidden).ql-editing_a.ql-action::after]:pr-2!',
+];
+
+const EDITOR_CLASS = classNames(
+ EDITOR_BOX_CLASS,
+ FORMAT_BAR_CLASS,
+ TEXT_CLASS,
+ MENTION_LIST_CLASS,
+ LINK_TOOLTIP_CLASS
+);
+
+// The format bar shows only while the formatting toggle beside send is on.
+const COLLAPSED_CLASS = 'tw:[&_.ql-toolbar.ql-snow]:hidden!';
+
+// quill-emoji opens its picker above the editor when the editor sits in the
+// lower half of the window, by this inline top.
+const EMOJI_PICKER_ABOVE_TOP = '-250px';
+
+/**
+ * The format bar's buttons, by the class Quill or quill-emoji gives each, and
+ * the design's icon for it. Quill draws its icons from one app-wide registry,
+ * so the composer draws these into its own buttons instead and hides Quill's.
+ */
+const FORMAT_BAR_ICONS: ReadonlyArray<
+ readonly [string, FC>]
+> = [
+ ['.ql-bold', TextEditorBold],
+ ['.ql-italic', TextEditorItalic],
+ ['.ql-strike', TextEditorStrikethrough],
+ ['.ql-blockquote', TextEditorBlockquote],
+ ['.ql-code-block', TextEditorCode],
+ ['.ql-list[value="ordered"]', TextEditorNumberedList],
+ ['.ql-list[value="bullet"]', TextEditorBulletedList],
+ ['.ql-link', TextEditorLink],
+ ['.ql-insertMention', TextEditorMention],
+ ['.ql-insertRef', TextEditorHashtag],
+ ['.textarea-emoji-control', TextEditorEmoji],
+];
+
+const isSameButtons = (a: Array, b: Array) =>
+ a.length === b.length && a.every((button, index) => button === b[index]);
+
+/**
+ * Watches the editor's format bar, which loads lazily, is rebuilt when Quill
+ * re-initialises and gains the emoji button after the rest. Returns its
+ * buttons in {@link FORMAT_BAR_ICONS} order, and anchors the emoji picker:
+ * quill-emoji drops it into the bar with no horizontal position, so it lands
+ * at the composer's far edge. It is pinned to its button, above or below as
+ * the module chose, and the button is marked open while it shows.
+ */
+const useFormatBar = (hostRef: RefObject) => {
+ const [buttons, setButtons] = useState>([]);
+
+ useEffect(() => {
+ const host = hostRef.current;
+ if (!host) {
+ return;
+ }
+ // Read afresh each time: the editor loads lazily, and quill rebuilds its
+ // bar when it re-initialises.
+ const sync = () => {
+ const toolbar = host.querySelector('.ql-toolbar');
+ const next = FORMAT_BAR_ICONS.map(
+ ([selector]) => toolbar?.querySelector(selector) ?? null
+ );
+ setButtons((current) => (isSameButtons(current, next) ? current : next));
+ const button = toolbar?.querySelector(
+ '.textarea-emoji-control'
+ );
+ const picker = toolbar?.querySelector('#textarea-emoji');
+ button?.classList.toggle('ql-active', Boolean(picker));
+ button?.setAttribute('aria-expanded', String(Boolean(picker)));
+ if (toolbar && button && picker) {
+ // Read the module's choice once: the first pass clears that top, and
+ // any later node change (an icon drawn, a palette tab) passes again.
+ picker.dataset.above ??= String(
+ picker.style.top === EMOJI_PICKER_ABOVE_TOP
+ );
+ const isAbove = picker.dataset.above === 'true';
+ // Kept inside the bar where the button sits near its far edge.
+ const left = Math.min(
+ button.offsetLeft,
+ toolbar.clientWidth - picker.offsetWidth
+ );
+ // `''` drops the module's inline top; quill-emoji's CSS pins `right`.
+ Object.assign(picker.style, {
+ left: `${Math.max(left, 0)}px`,
+ right: 'auto',
+ top: isAbove ? '' : '100%',
+ bottom: isAbove ? '100%' : '',
+ });
+ }
+ };
+ // Node changes only: the styles set above never re-trigger it.
+ const observer = new MutationObserver(sync);
+ observer.observe(host, { childList: true, subtree: true });
+ sync();
+
+ return () => observer.disconnect();
+ }, [hostRef]);
+
+ return buttons;
+};
+
+export interface CommentEditorProps {
+ // A rejected promise puts the draft back in the editor.
+ onSave: (message: string) => void | Promise;
+ placeHolder?: string;
+ focused?: boolean;
+ // The message being edited, as HTML; the send button starts enabled.
+ defaultValue?: string;
+}
+
+/**
+ * A comment editor: the OSS {@link ActivityFeedEditorNew} verbatim —
+ * so mention (@), hashtag (#), markdown and Enter-to-send all keep working —
+ * restyled through the scoped classes above (EDITOR_CLASS), with the
+ * design's format-bar icons and its arrow send button, disabled until there is
+ * something to send. The format bar starts hidden, one line of text; the
+ * formatting toggle beside send shows it, and stays highlighted while it shows.
+ * A new comment and an edited one use the same box.
+ */
+export const CommentEditor: React.FC = ({
+ onSave,
+ placeHolder,
+ focused,
+ defaultValue,
+}) => {
+ const { t } = useTranslation();
+ const placeholderText = placeHolder ?? t('message.leave-a-comment');
+ const editorRef = useRef(null);
+ const editorHostRef = useRef(null);
+ const [hasText, setHasText] = useState(Boolean(defaultValue));
+ const [isFormatting, setIsFormatting] = useState(false);
+ const formatBarButtons = useFormatBar(editorHostRef);
+
+ // The editor reports its markdown on every change; whitespace alone is not a
+ // comment, so it keeps the send button disabled.
+ const handleTextChange = useCallback((message: string) => {
+ setHasText(message.trim().length > 0);
+ }, []);
+
+ // The editor is already empty when this runs, so a save that fails writes
+ // the draft back rather than losing it.
+ const submit = useCallback(
+ (message: string) => {
+ setHasText(false);
+ Promise.resolve(onSave(message)).catch(() => {
+ editorRef.current?.setEditorContent(
+ MarkdownToHTMLConverter.makeHtml(getFrontEndFormat(message))
+ );
+ setHasText(true);
+ });
+ },
+ [onSave]
+ );
+
+ // Mirrors the editor's own Enter-to-send, which clears itself first.
+ const handleSend = () => {
+ const content = editorRef.current?.getEditorContent();
+ if (content) {
+ editorRef.current?.clearEditorContent();
+ submit(getBackendFormat(content));
+ }
+ };
+
+ return (
+ // FeedEditor hard-codes its Quill placeholder, so feed it the figma copy
+ // through a CSS variable the scoped style reads.
+