diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 4c867b656a3aef18b8a44499ff76cf9fa29b48b7..493ef4b18f5c3900aa086e7fd5df0704f544a661 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -18,6 +18,7 @@ import isExpandedHierarchyTreeChildQuery from '~/work_items/graphql/client/is_ex import activeBoardItemQuery from 'ee_else_ce/boards/graphql/client/active_board_item.query.graphql'; import activeDiscussionQuery from '~/work_items/components/design_management/graphql/client/active_design_discussion.query.graphql'; import { updateNewWorkItemCache, workItemBulkEdit } from '~/work_items/graphql/resolvers'; +import { preserveDetailsState } from '~/work_items/utils'; export const config = { typeDefs, @@ -65,6 +66,21 @@ export const config = { merge: true, }, }, + WorkItemWidgetDescription: { + fields: { + descriptionHtml: { + merge(_, incoming) { + const el = document.querySelector('.work-item-description'); + if (!el) { + return incoming; + } + + const descriptionHtml = preserveDetailsState(el, incoming); + return descriptionHtml || incoming; + }, + }, + }, + }, WorkItemWidgetNotes: { fields: { // If we add any key args, the discussions field becomes discussions({"filter":"ONLY_ACTIVITY","first":10}) and diff --git a/app/assets/javascripts/work_items/utils.js b/app/assets/javascripts/work_items/utils.js index 74d72e89e7550961e8a2c4a8d544306f782f4a9f..f0ece9e9dbd972f2b1e1bdd00abc15215da3d107 100644 --- a/app/assets/javascripts/work_items/utils.js +++ b/app/assets/javascripts/work_items/utils.js @@ -8,6 +8,7 @@ import { TYPE_EPIC, TYPE_ISSUE } from '~/issues/constants'; import { NEW_WORK_ITEM_IID, WIDGET_TYPE_ASSIGNEES, + WIDGET_TYPE_DESCRIPTION, WIDGET_TYPE_DESIGNS, WIDGET_TYPE_HEALTH_STATUS, WIDGET_TYPE_HIERARCHY, @@ -45,6 +46,9 @@ export const isWeightWidget = (widget) => widget.type === WIDGET_TYPE_WEIGHT; export const findHierarchyWidgets = (widgets) => widgets?.find((widget) => widget.type === WIDGET_TYPE_HIERARCHY); +export const findDescriptionWidget = (workItem) => + workItem?.widgets?.find((widget) => widget.type === WIDGET_TYPE_DESCRIPTION); + export const findLinkedItemsWidget = (workItem) => workItem.widgets?.find((widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS); @@ -351,3 +355,35 @@ export const formatSelectOptionForCustomField = ({ id, value }) => ({ text: value, value: id, }); + +/** + * This function takes the `descriptionHtml` property of a work item and updates any `<details>` + * elements within it with an `open=true` attribute to match the current state in the DOM. + * + * This is necessary for scenarios such as toggling a checkbox with an opened `<details>` element, + * which causes the `<details>` element to close when the frontend receives the backend response. + * + * @param {HTMLElement} element DOM element containing <details> elements + * @param {string} descriptionHtml The incoming HTML description + * @returns {string|null} The updated HTML for the incoming description that preserves the state of the <details> elements + */ +export const preserveDetailsState = (element, descriptionHtml) => { + const previousDetails = Array.from(element.getElementsByTagName('details')); + if (!previousDetails.some((details) => details.open)) { + return null; + } + + const nextTemplate = document.createElement('div'); + nextTemplate.innerHTML = descriptionHtml; // eslint-disable-line no-unsanitized/property + const nextDetails = nextTemplate.getElementsByTagName('details'); + if (previousDetails.length !== nextDetails.length) { + return null; + } + + Array.from(nextDetails).forEach((details, i) => { + if (previousDetails[i].open) { + details.setAttribute('open', 'true'); + } + }); + return nextTemplate.innerHTML; +}; diff --git a/spec/frontend/work_items/utils_spec.js b/spec/frontend/work_items/utils_spec.js index 56f4b35df8aaac88ee8d6aa3d83a047050d460ce..dc8b0fd7c5174b55e0bf6df0453a6acca13108ed 100644 --- a/spec/frontend/work_items/utils_spec.js +++ b/spec/frontend/work_items/utils_spec.js @@ -1,10 +1,10 @@ import { NEW_WORK_ITEM_IID, - WORK_ITEM_TYPE_ENUM_ISSUE, - WORK_ITEM_TYPE_ENUM_EPIC, - STATE_OPEN, STATE_CLOSED, + STATE_OPEN, + WORK_ITEM_TYPE_ENUM_EPIC, WORK_ITEM_TYPE_ENUM_INCIDENT, + WORK_ITEM_TYPE_ENUM_ISSUE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_REQUIREMENTS, @@ -36,6 +36,7 @@ import { getItems, canRouterNav, formatSelectOptionForCustomField, + preserveDetailsState, } from '~/work_items/utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { TYPE_EPIC } from '~/issues/constants'; @@ -402,3 +403,50 @@ describe('formatSelectOptionForCustomField', () => { expect(formatSelectOptionForCustomField(data)).toEqual(result); }); }); + +describe('preserveDetailsState', () => { + const descriptionHtml = '<details><summary>Test</summary><p>Content</p></details>'; + let element; + + beforeEach(() => { + element = document.createElement('div'); + }); + + it('returns null when there are no open details elements', () => { + element.innerHTML = '<details><summary>Test</summary><p>Content</p></details>'; + + expect(preserveDetailsState(element, descriptionHtml)).toBe(null); + }); + + it('returns null when number of details elements does not match', () => { + element.innerHTML = '<details open><summary>Test</summary><p>Content</p></details>'; + const newDescriptionHtml = + '<details><summary>Test</summary><p>Content</p></details><details><summary>Test 2</summary><p>Content 2</p></details>'; + + expect(preserveDetailsState(element, newDescriptionHtml)).toBe(null); + }); + + it('preserves open state of details elements', () => { + element.innerHTML = '<details open><summary>Test</summary><p>Content</p></details>'; + + expect(preserveDetailsState(element, descriptionHtml)).toBe( + '<details open="true"><summary>Test</summary><p>Content</p></details>', + ); + }); + + it('handles multiple details elements', () => { + element.innerHTML = ` + <details open><summary>Test 1</summary><p>Content 1</p></details> + <details><summary>Test 2</summary><p>Content 2</p></details> + `; + const newDescriptionHtml = ` + <details><summary>Test 1</summary><p>Content 1</p></details> + <details><summary>Test 2</summary><p>Content 2</p></details> + `; + + expect(preserveDetailsState(element, newDescriptionHtml)).toBe(` + <details open="true"><summary>Test 1</summary><p>Content 1</p></details> + <details><summary>Test 2</summary><p>Content 2</p></details> + `); + }); +});