From 84afa8be85e511b7eabb9546a42df9fdbca930e7 Mon Sep 17 00:00:00 2001 From: Marc Vilanova Date: Thu, 5 Jun 2025 13:28:10 -0700 Subject: [PATCH 1/2] fix(case): markdown in case resolution --- .../plugins/dispatch_slack/case/messages.py | 71 ++++++-- src/dispatch/static/dispatch/components.d.ts | 93 +--------- .../src/case/CaseAttributesDrawer.vue | 1 + .../static/dispatch/src/case/ClosedDialog.vue | 22 ++- .../static/dispatch/src/case/DetailsTab.vue | 39 ++-- .../dispatch/src/components/HtmlRenderer.vue | 166 ++++++++++++++++++ .../dispatch/src/components/RichEditor.vue | 23 ++- 7 files changed, 279 insertions(+), 136 deletions(-) create mode 100644 src/dispatch/static/dispatch/src/components/HtmlRenderer.vue diff --git a/src/dispatch/plugins/dispatch_slack/case/messages.py b/src/dispatch/plugins/dispatch_slack/case/messages.py index 12d5cbdb0f93..898ed679e056 100644 --- a/src/dispatch/plugins/dispatch_slack/case/messages.py +++ b/src/dispatch/plugins/dispatch_slack/case/messages.py @@ -1,5 +1,7 @@ import logging from typing import NamedTuple +import html +import re from blockkit import ( @@ -28,6 +30,7 @@ ) from dispatch.plugins.dispatch_slack.config import ( MAX_SECTION_TEXT_LENGTH, + SlackConversationConfiguration, ) from dispatch.plugins.dispatch_slack.models import ( CaseSubjects, @@ -46,21 +49,61 @@ def map_priority_color(color: str) -> str: - """Maps a priority color to its corresponding emoji symbol.""" - if not color: - return "" + """ + Returns the first slack-compatible priority color for the given color. - # TODO we should probably restrict the possible colors to make this work - priority_color_mapping = { - "#9e9e9e": "βšͺ", - "#8bc34a": "🟒", - "#ffeb3b": "🟑", - "#ff9800": "🟠", - "#f44336": "πŸ”΄", - "#9c27b0": "🟣", + Args: + color (str): RGB Hex color string. + + Returns: + str: Slack Color string. + """ + color_mappings = { + "red": ":red_circle:", + "orange": ":orange_circle:", + "amber": ":yellow_circle:", + "green": ":green_circle:", + "blue": ":blue_circle:", + "purple": ":purple_circle:", + "grey": ":grey_circle:", + "gray": ":grey_circle:", } - return priority_color_mapping.get(color.lower(), "") + for mapping_color in color_mappings: + if mapping_color in color.lower(): + return color_mappings[mapping_color] + + return ":blue_circle:" + + +def html_to_plain_text(html_content: str) -> str: + """ + Convert HTML content to plain text for Slack messages. + + Args: + html_content (str): HTML content to convert + + Returns: + str: Plain text content + """ + if not html_content: + return "" + + # First decode any HTML entities + text = html.unescape(html_content) + + # Remove HTML tags but preserve line breaks + text = re.sub(r'', '\n', text) + text = re.sub(r'

', '\n\n', text) + text = re.sub(r']*>', '', text) + text = re.sub(r']+>', '', text) + + # Clean up extra whitespace + text = re.sub(r'\n\s*\n\s*\n', '\n\n', text) # Multiple newlines to double + text = re.sub(r'[ \t]+', ' ', text) # Multiple spaces/tabs to single space + text = text.strip() + + return text def create_case_message(case: Case, channel_id: str) -> list[Block]: @@ -142,11 +185,13 @@ def create_case_message(case: Case, channel_id: str) -> list[Block]: ] ) elif case.status == CaseStatus.closed: + # Convert HTML resolution to plain text for Slack display + resolution_text = html_to_plain_text(case.resolution) if case.resolution else "" blocks.extend( [ Section(text=f"*Resolution reason* \n {case.resolution_reason}"), Section( - text=f"*Resolution description* \n {case.resolution}"[:MAX_SECTION_TEXT_LENGTH] + text=f"*Resolution description* \n {resolution_text}"[:MAX_SECTION_TEXT_LENGTH] ), Actions( elements=[ diff --git a/src/dispatch/static/dispatch/components.d.ts b/src/dispatch/static/dispatch/components.d.ts index cd9ce5015d8e..b4e4bf790339 100644 --- a/src/dispatch/static/dispatch/components.d.ts +++ b/src/dispatch/static/dispatch/components.d.ts @@ -8,11 +8,9 @@ export {} declare module '@vue/runtime-core' { export interface GlobalComponents { AdminLayout: typeof import('./src/components/layouts/AdminLayout.vue')['default'] - AnimatedNumber: typeof import("./src/components/AnimatedNumber.vue")["default"] AppDrawer: typeof import('./src/components/AppDrawer.vue')['default'] AppToolbar: typeof import('./src/components/AppToolbar.vue')['default'] AutoComplete: typeof import('./src/components/AutoComplete.vue')['default'] - Avatar: typeof import("./src/components/Avatar.vue")["default"] BaseCombobox: typeof import('./src/components/BaseCombobox.vue')['default'] BasicLayout: typeof import('./src/components/layouts/BasicLayout.vue')['default'] ColorPickerInput: typeof import('./src/components/ColorPickerInput.vue')['default'] @@ -25,17 +23,15 @@ declare module '@vue/runtime-core' { DMenu: typeof import('./src/components/DMenu.vue')['default'] DTooltip: typeof import('./src/components/DTooltip.vue')['default'] GenaiAnalysisDisplay: typeof import('./src/components/GenaiAnalysisDisplay.vue')['default'] + HtmlRenderer: typeof import('./src/components/HtmlRenderer.vue')['default'] IconPickerInput: typeof import('./src/components/IconPickerInput.vue')['default'] InfoWidget: typeof import('./src/components/InfoWidget.vue')['default'] Loading: typeof import('./src/components/Loading.vue')['default'] LockButton: typeof import('./src/components/LockButton.vue')['default'] MonacoEditor: typeof import('./src/components/MonacoEditor.vue')['default'] NotificationSnackbarsWrapper: typeof import('./src/components/NotificationSnackbarsWrapper.vue')['default'] - PageHeader: typeof import("./src/components/PageHeader.vue")["default"] - ParticipantAutoComplete: typeof import("./src/components/ParticipantAutoComplete.vue")["default"] ParticipantSelect: typeof import('./src/components/ParticipantSelect.vue')['default'] PreciseDateTimePicker: typeof import('./src/components/PreciseDateTimePicker.vue')['default'] - ProjectAutoComplete: typeof import("./src/components/ProjectAutoComplete.vue")["default"] Refresh: typeof import('./src/components/Refresh.vue')['default'] RichEditor: typeof import('./src/components/RichEditor.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] @@ -43,93 +39,6 @@ declare module '@vue/runtime-core' { SavingState: typeof import('./src/components/SavingState.vue')['default'] SearchPopover: typeof import('./src/components/SearchPopover.vue')['default'] SettingsBreadcrumbs: typeof import('./src/components/SettingsBreadcrumbs.vue')['default'] - ShepherdStep: typeof import("./src/components/ShepherdStep.vue")["default"] - ShpherdStep: typeof import("./src/components/ShpherdStep.vue")["default"] StatWidget: typeof import('./src/components/StatWidget.vue')['default'] - SubjectLastUpdated: typeof import("./src/components/SubjectLastUpdated.vue")["default"] - TimePicker: typeof import("./src/components/TimePicker.vue")["default"] - VAlert: typeof import("vuetify/lib")["VAlert"] - VApp: typeof import("vuetify/lib")["VApp"] - VAppBar: typeof import("vuetify/lib")["VAppBar"] - VAutocomplete: typeof import("vuetify/lib")["VAutocomplete"] - VAvatar: typeof import("vuetify/lib")["VAvatar"] - VBadge: typeof import("vuetify/lib")["VBadge"] - VBottomSheet: typeof import("vuetify/lib")["VBottomSheet"] - VBreadcrumbs: typeof import("vuetify/lib")["VBreadcrumbs"] - VBreadcrumbsItem: typeof import("vuetify/lib")["VBreadcrumbsItem"] - VBtn: typeof import("vuetify/lib")["VBtn"] - VCard: typeof import("vuetify/lib")["VCard"] - VCardActions: typeof import("vuetify/lib")["VCardActions"] - VCardSubtitle: typeof import("vuetify/lib")["VCardSubtitle"] - VCardText: typeof import("vuetify/lib")["VCardText"] - VCardTitle: typeof import("vuetify/lib")["VCardTitle"] - VCheckbox: typeof import("vuetify/lib")["VCheckbox"] - VChip: typeof import("vuetify/lib")["VChip"] - VChipGroup: typeof import("vuetify/lib")["VChipGroup"] - VCol: typeof import("vuetify/lib")["VCol"] - VColorPicker: typeof import("vuetify/lib")["VColorPicker"] - VCombobox: typeof import("vuetify/lib")["VCombobox"] - VContainer: typeof import("vuetify/lib")["VContainer"] - VDataTable: typeof import("vuetify/lib")["VDataTable"] - VDatePicker: typeof import("vuetify/lib")["VDatePicker"] - VDialog: typeof import("vuetify/lib")["VDialog"] - VDivider: typeof import("vuetify/lib")["VDivider"] - VExpandTransition: typeof import("vuetify/lib")["VExpandTransition"] - VExpansionPanel: typeof import("vuetify/lib")["VExpansionPanel"] - VExpansionPanelContent: typeof import("vuetify/lib")["VExpansionPanelContent"] - VExpansionPanelHeader: typeof import("vuetify/lib")["VExpansionPanelHeader"] - VExpansionPanels: typeof import("vuetify/lib")["VExpansionPanels"] - VFlex: typeof import("vuetify/lib")["VFlex"] - VForm: typeof import("vuetify/lib")["VForm"] - VHover: typeof import("vuetify/lib")["VHover"] - VIcon: typeof import("vuetify/lib")["VIcon"] - VItem: typeof import("vuetify/lib")["VItem"] - VLayout: typeof import("vuetify/lib")["VLayout"] - VLazy: typeof import("vuetify/lib")["VLazy"] - VList: typeof import("vuetify/lib")["VList"] - VListGroup: typeof import("vuetify/lib")["VListGroup"] - VListItem: typeof import("vuetify/lib")["VListItem"] - VListItemAction: typeof import("vuetify/lib")["VListItemAction"] - VListItemAvatar: typeof import("vuetify/lib")["VListItemAvatar"] - VListItemContent: typeof import("vuetify/lib")["VListItemContent"] - VListItemGroup: typeof import("vuetify/lib")["VListItemGroup"] - VListItemIcon: typeof import("vuetify/lib")["VListItemIcon"] - VListItemSubtitle: typeof import("vuetify/lib")["VListItemSubtitle"] - VListItemTitle: typeof import("vuetify/lib")["VListItemTitle"] - VMain: typeof import("vuetify/lib")["VMain"] - VMenu: typeof import("vuetify/lib")["VMenu"] - VNavigationDrawer: typeof import("vuetify/lib")["VNavigationDrawer"] - VProgressLinear: typeof import("vuetify/lib")["VProgressLinear"] - VRadio: typeof import("vuetify/lib")["VRadio"] - VRadioGroup: typeof import("vuetify/lib")["VRadioGroup"] - VRow: typeof import("vuetify/lib")["VRow"] - VSelect: typeof import("vuetify/lib")["VSelect"] - VSheet: typeof import("vuetify/lib")["VSheet"] - VSimpleCheckbox: typeof import("vuetify/lib")["VSimpleCheckbox"] - VSnackbar: typeof import("vuetify/lib")["VSnackbar"] - VSpacer: typeof import("vuetify/lib")["VSpacer"] - VStepper: typeof import("vuetify/lib")["VStepper"] - VStepperContent: typeof import("vuetify/lib")["VStepperContent"] - VStepperHeader: typeof import("vuetify/lib")["VStepperHeader"] - VStepperItems: typeof import("vuetify/lib")["VStepperItems"] - VStepperStep: typeof import("vuetify/lib")["VStepperStep"] - VSubheader: typeof import("vuetify/lib")["VSubheader"] - VSwitch: typeof import("vuetify/lib")["VSwitch"] - VSystemBar: typeof import("vuetify/lib")["VSystemBar"] - VTab: typeof import("vuetify/lib")["VTab"] - VTabItem: typeof import("vuetify/lib")["VTabItem"] - VTabs: typeof import("vuetify/lib")["VTabs"] - VTabsItems: typeof import("vuetify/lib")["VTabsItems"] - VTextarea: typeof import("vuetify/lib")["VTextarea"] - VTextArea: typeof import("vuetify/lib")["VTextArea"] - VTextField: typeof import("vuetify/lib")["VTextField"] - VTimeline: typeof import("vuetify/lib")["VTimeline"] - VTimelineItem: typeof import("vuetify/lib")["VTimelineItem"] - VTimePicker: typeof import("vuetify/lib")["VTimePicker"] - VToolbarItems: typeof import("vuetify/lib")["VToolbarItems"] - VToolbarTitle: typeof import("vuetify/lib")["VToolbarTitle"] - VTooltip: typeof import("vuetify/lib")["VTooltip"] - VWindow: typeof import("vuetify/lib")["VWindow"] - VWindowItem: typeof import("vuetify/lib")["VWindowItem"] } } diff --git a/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue b/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue index 3ce037f86e5b..631dc467bb9e 100644 --- a/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue +++ b/src/dispatch/static/dispatch/src/case/CaseAttributesDrawer.vue @@ -11,6 +11,7 @@ import DTooltip from "@/components/DTooltip.vue" import ParticipantSearchPopover from "@/participant/ParticipantSearchPopover.vue" import ProjectSearchPopover from "@/project/ProjectSearchPopover.vue" import TagSearchPopover from "@/tag/TagSearchPopover.vue" +import RichEditor from "@/components/RichEditor.vue" import { useSavingState } from "@/composables/useSavingState" // Define the props diff --git a/src/dispatch/static/dispatch/src/case/ClosedDialog.vue b/src/dispatch/static/dispatch/src/case/ClosedDialog.vue index 660765180fd1..4e89e9ddc2ef 100644 --- a/src/dispatch/static/dispatch/src/case/ClosedDialog.vue +++ b/src/dispatch/static/dispatch/src/case/ClosedDialog.vue @@ -19,11 +19,18 @@ /> - Resolution + + + + Cancel @@ -51,10 +58,15 @@ + + diff --git a/src/dispatch/static/dispatch/src/components/RichEditor.vue b/src/dispatch/static/dispatch/src/components/RichEditor.vue index 40ec57b99f28..d6e45e60191a 100644 --- a/src/dispatch/static/dispatch/src/components/RichEditor.vue +++ b/src/dispatch/static/dispatch/src/components/RichEditor.vue @@ -22,6 +22,7 @@ const props = defineProps({ }) const editor = ref(null) +const htmlValue = ref("") const plainTextValue = ref("") const emit = defineEmits(["update:modelValue"]) @@ -36,6 +37,12 @@ const handleBlur = () => { userIsTyping.value = false } +// Utility function to convert HTML to plain text +const htmlToPlainText = (html) => { + if (!html) return "" + return html.replace(/<\/?[^>]+(>|$)/g, "") +} + watch( () => props.content, (value) => { @@ -63,7 +70,7 @@ onMounted(() => { // Use different placeholders depending on the node type: // placeholder: ({ node }) => { // if (node.type.name === 'heading') { - // return 'What’s the title?' + // return 'What's the title?' // } // return 'Can you add some further context?' @@ -73,10 +80,12 @@ onMounted(() => { content: props.content, onUpdate: () => { let content = editor.value?.getHTML() - // remove the HTML tags - plainTextValue.value = content.replace(/<\/?[^>]+(>|$)/g, "") - // Emitting the updated plain text - emit("update:modelValue", plainTextValue.value) + // Preserve the HTML content to maintain formatting + htmlValue.value = content + // Also maintain plain text version for backwards compatibility + plainTextValue.value = htmlToPlainText(content) + // Emit the HTML content instead of stripped plain text + emit("update:modelValue", htmlValue.value) }, keyboardShortcuts: { Enter: () => {}, // Override Enter key to do nothing @@ -92,8 +101,8 @@ onBeforeUnmount(() => { editor.value?.destroy() }) -// Expose plainTextValue for parent component to use -defineExpose({ plainTextValue }) +// Expose both HTML and plain text values for parent component to use +defineExpose({ htmlValue, plainTextValue, htmlToPlainText })