From 03434d9c476c48413fdb974cb8c86738fc9540c7 Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Thu, 25 Sep 2025 15:55:28 +0200 Subject: [PATCH 1/4] Add disable and enable list item option to task context menu Changelog: added --- .../components/task_list_item_actions.vue | 31 +++++++- app/assets/javascripts/issues/show/utils.js | 38 +++++++++ .../work_item_description_rendered.vue | 31 +++++++- locale/gitlab.pot | 6 ++ .../components/task_list_item_actions_spec.js | 47 ++++++++++- spec/frontend/issues/show/utils_spec.js | 78 +++++++++++++++++++ 6 files changed, 224 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue index 34c99db435a5d2..f76ef4a58019c1 100644 --- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -19,11 +19,14 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - inject: ['id', 'issuableType'], + inject: ['id', 'issuableType', 'enabled'], computed: { showConvertToTaskItem() { return allowedTypes.includes(this.issuableType); }, + isEnabledTaskListItem() { + return this.enabled; + }, }, methods: { convertToTask() { @@ -32,6 +35,12 @@ export default { deleteTaskListItem() { eventHub.$emit('delete-task-list-item', this.eventPayload()); }, + disableTaskListItem() { + eventHub.$emit('disable-task-list-item', this.eventPayload()); + }, + enableTaskListItem() { + eventHub.$emit('enable-task-list-item', this.eventPayload()); + }, eventPayload() { return { id: this.id, @@ -64,6 +73,26 @@ export default { {{ s__('WorkItem|Convert to child item') }} + + + + + + { }; }; +export const disableTaskListItem = (description, sourcepos) => { + const descriptionLines = description.split(NEWLINE); + const startIndex = getSourceposRows(sourcepos)[0]; + + if (descriptionLines[startIndex].includes('[ ]')) { + descriptionLines[startIndex] = descriptionLines[startIndex].replace('[ ]', '[~]'); + } else if (descriptionLines[startIndex].includes('[x]')) { + descriptionLines[startIndex] = descriptionLines[startIndex].replace('[x]', '[~]'); + } + + return { + newDescription: descriptionLines.join(NEWLINE), + }; +}; + +export const enableTaskListItem = (description, sourcepos) => { + const descriptionLines = description.split(NEWLINE); + const startIndex = getSourceposRows(sourcepos)[0]; + + if (descriptionLines[startIndex].includes('[~]')) { + descriptionLines[startIndex] = descriptionLines[startIndex].replace('[~]', '[ ]'); + } + + return { + newDescription: descriptionLines.join(NEWLINE), + }; +}; + +export const isDisabledTaskListItem = (description, sourcepos) => { + const descriptionLines = description.split(NEWLINE); + const startIndex = getSourceposRows(sourcepos)[0]; + + if (descriptionLines[startIndex].includes('[~]')) { + return true; + } + return false; +}; + /** * Given a title and description for a task: * diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index 6663d7e20aa5c0..c565836ecbe53f 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -11,6 +11,8 @@ import { InternalEvents } from '~/tracking'; import { convertDescriptionWithNewSort, deleteTaskListItem, + disableTaskListItem, + enableTaskListItem, extractTaskTitleAndDescription, insertNextToTaskListItemText, } from '~/issues/show/utils'; @@ -135,6 +137,8 @@ export default { async mounted() { eventHub.$on('convert-task-list-item', this.convertTaskListItem); eventHub.$on('delete-task-list-item', this.deleteTaskListItem); + eventHub.$on('disable-task-list-item', this.disableTaskListItem); + eventHub.$on('enable-task-list-item', this.enableTaskListItem); window.addEventListener('hashchange', (e) => this.truncateOrScrollToAnchor(e)); await this.$nextTick(); @@ -143,6 +147,8 @@ export default { beforeDestroy() { eventHub.$off('convert-task-list-item', this.convertTaskListItem); eventHub.$off('delete-task-list-item', this.deleteTaskListItem); + eventHub.$off('disable-task-list-item', this.disableTaskListItem); + eventHub.$off('enable-task-list-item', this.enableTaskListItem); window.removeEventListener('hashchange', this.truncateOrScrollToAnchor); this.removeAllPointerEventListeners(); @@ -242,20 +248,23 @@ export default { }, renderTaskListItemActions() { const taskListItems = this.$el.querySelectorAll?.( - '.task-list-item:not(.inapplicable, table .task-list-item)', + '.task-list-item:not(table .task-list-item', ); taskListItems?.forEach((listItem) => { - const dropdown = this.createTaskListItemActions(); + const enabled = + listItem.querySelectorAll('.task-list-item-checkbox[disabled]').length === 0; + + const dropdown = this.createTaskListItemActions(enabled); insertNextToTaskListItemText(dropdown, listItem); this.addPointerEventListeners(listItem, '.task-list-item-actions'); this.hasTaskListItemActions = true; }); }, - createTaskListItemActions() { + createTaskListItemActions(enabled) { const app = new Vue({ el: document.createElement('div'), - provide: { id: this.workItemId, issuableType: this.workItemType }, + provide: { id: this.workItemId, issuableType: this.workItemType, enabled }, render: (createElement) => createElement(TaskListItemActions), }); return app.$el; @@ -322,6 +331,20 @@ export default { const { newDescription } = deleteTaskListItem(this.descriptionText, sourcepos); this.$emit('descriptionUpdated', newDescription); }, + disableTaskListItem({ id, sourcepos }) { + if (this.workItemId !== id) { + return; + } + const { newDescription } = disableTaskListItem(this.descriptionText, sourcepos); + this.$emit('descriptionUpdated', newDescription); + }, + enableTaskListItem({ id, sourcepos }) { + if (this.workItemId !== id) { + return; + } + const { newDescription } = enableTaskListItem(this.descriptionText, sourcepos); + this.$emit('descriptionUpdated', newDescription); + }, handleWorkItemCreated() { this.$emit('descriptionUpdated', this.newDescription); }, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a3c97604174f04..10f9ed6c42287d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -76103,6 +76103,12 @@ msgstr "" msgid "WorkItems|Your preferences" msgstr "" +msgid "WorkItem| Disable list item" +msgstr "" + +msgid "WorkItem| Enable list item" +msgstr "" + msgid "WorkItem|%d item" msgid_plural "WorkItem|%d items" msgstr[0] "" diff --git a/spec/frontend/issues/show/components/task_list_item_actions_spec.js b/spec/frontend/issues/show/components/task_list_item_actions_spec.js index a85d1cb009d423..09f4505421525f 100644 --- a/spec/frontend/issues/show/components/task_list_item_actions_spec.js +++ b/spec/frontend/issues/show/components/task_list_item_actions_spec.js @@ -14,8 +14,10 @@ describe('TaskListItemActions component', () => { const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findConvertToChildItemItem = () => wrapper.findByTestId('convert'); const findDeleteItem = () => wrapper.findByTestId('delete'); + const findDisableItem = () => wrapper.findByTestId('disable'); + const findEnableItem = () => wrapper.findByTestId('enable'); - const mountComponent = ({ issuableType = TYPE_ISSUE } = {}) => { + const mountComponent = ({ issuableType = TYPE_ISSUE, enabled = true } = {}) => { setHTMLFixture(`
  • @@ -26,6 +28,7 @@ describe('TaskListItemActions component', () => { provide: { id: 'gid://gitlab/WorkItem/818', issuableType, + enabled, }, attachTo: 'div', }); @@ -65,7 +68,7 @@ describe('TaskListItemActions component', () => { }); }); - describe('events', () => { + describe('events for enabled items', () => { beforeEach(() => { mountComponent(); }); @@ -87,5 +90,45 @@ describe('TaskListItemActions component', () => { sourcepos: '3:1-3:10', }); }); + it('emits event when `Disable` dropdown item is clicked', () => { + findDisableItem().vm.$emit('action'); + + expect(eventHub.$emit).toHaveBeenCalledWith('disable-task-list-item', { + id: 'gid://gitlab/WorkItem/818', + sourcepos: '3:1-3:10', + }); + }); + }); + + describe('events for disabled items', () => { + beforeEach(() => { + mountComponent({ enabled: false }); + }); + + it('emits event when `Convert to child item` dropdown item is clicked', () => { + findConvertToChildItemItem().vm.$emit('action'); + + expect(eventHub.$emit).toHaveBeenCalledWith('convert-task-list-item', { + id: 'gid://gitlab/WorkItem/818', + sourcepos: '3:1-3:10', + }); + }); + + it('emits event when `Delete` dropdown item is clicked', () => { + findDeleteItem().vm.$emit('action'); + + expect(eventHub.$emit).toHaveBeenCalledWith('delete-task-list-item', { + id: 'gid://gitlab/WorkItem/818', + sourcepos: '3:1-3:10', + }); + }); + it('emits event when `Enable` dropdown item is clicked', () => { + findEnableItem().vm.$emit('action'); + + expect(eventHub.$emit).toHaveBeenCalledWith('enable-task-list-item', { + id: 'gid://gitlab/WorkItem/818', + sourcepos: '3:1-3:10', + }); + }); }); }); diff --git a/spec/frontend/issues/show/utils_spec.js b/spec/frontend/issues/show/utils_spec.js index dc1abd1cd65fd0..dfc3da03080fe7 100644 --- a/spec/frontend/issues/show/utils_spec.js +++ b/spec/frontend/issues/show/utils_spec.js @@ -2,6 +2,8 @@ import { setHTMLFixture } from 'helpers/fixtures'; import { convertDescriptionWithNewSort, deleteTaskListItem, + disableTaskListItem, + enableTaskListItem, extractTaskTitleAndDescription, insertNextToTaskListItemText, } from '~/issues/show/utils'; @@ -354,6 +356,82 @@ paragraph text }); }); + describe('disableTaskListItem', () => { + it('disable unchecked item', () => { + const description = `Tasks + +1. [ ] item 1 + 1. [ ] item 2 + 1. [ ] item 3 + 1. [ ] item 4 + 1. [ ] item 5 + 1. [ ] item 6`; + const sourcepos = '4:4-4:14'; + const newDescription = `Tasks + +1. [ ] item 1 + 1. [~] item 2 + 1. [ ] item 3 + 1. [ ] item 4 + 1. [ ] item 5 + 1. [ ] item 6`; + + expect(disableTaskListItem(description, sourcepos)).toEqual({ + newDescription, + }); + }); + + it('disable checked item', () => { + const description = `Tasks + +1. [ ] item 1 + 1. [x] item 2 + 1. [ ] item 3 + 1. [ ] item 4 + 1. [ ] item 5 + 1. [ ] item 6`; + const sourcepos = '4:4-4:14'; + const newDescription = `Tasks + +1. [ ] item 1 + 1. [~] item 2 + 1. [ ] item 3 + 1. [ ] item 4 + 1. [ ] item 5 + 1. [ ] item 6`; + + expect(disableTaskListItem(description, sourcepos)).toEqual({ + newDescription, + }); + }); + }); + + describe('enableTaskListItem', () => { + it('enable item', () => { + const description = `Tasks + +1. [ ] item 1 + 1. [~] item 2 + 1. [ ] item 3 + 1. [ ] item 4 + 1. [ ] item 5 + 1. [ ] item 6`; + const sourcepos = '4:4-4:14'; + const newDescription = `Tasks + +1. [ ] item 1 + 1. [ ] item 2 + 1. [ ] item 3 + 1. [ ] item 4 + 1. [ ] item 5 + 1. [ ] item 6`; + + expect(enableTaskListItem(description, sourcepos)).toEqual({ + newDescription, + }); + }); + }); + describe('extractTaskTitleAndDescription', () => { const description = `A multi-line description`; -- GitLab From c8e0aabca501161b60f5e5f744bfcf7823557537 Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Fri, 5 Dec 2025 09:03:18 +0100 Subject: [PATCH 2/4] Fix bug when the list item is disabled, the menu actions show with strikethrough Suggested by Nick Leonard & Joseph Fletcher --- app/assets/stylesheets/framework/typography.scss | 3 ++- app/assets/stylesheets/page_bundles/issues_show.scss | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 66382c517adffd..107f8465fdf214 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -609,7 +609,8 @@ } // Strikethrough block-level items in non-tight lists as a whole; we don't do sublists. - > p, div { + > p, + > div:not(.gl-disclosure-dropdown) { text-decoration: line-through; @apply gl-text-disabled; } diff --git a/app/assets/stylesheets/page_bundles/issues_show.scss b/app/assets/stylesheets/page_bundles/issues_show.scss index 84877bb26e3d51..bf4b0fc344c9bc 100644 --- a/app/assets/stylesheets/page_bundles/issues_show.scss +++ b/app/assets/stylesheets/page_bundles/issues_show.scss @@ -25,7 +25,7 @@ } } - .has-task-list-item-actions > :is(ul, ol) > li { + .has-task-list-item-actions :is(ul, ol) li { margin-inline-end: 2rem; } -- GitLab From 7b77a1701db4a3c6bbc9c152061eea20ff28eb3c Mon Sep 17 00:00:00 2001 From: Roger Meier Date: Fri, 5 Dec 2025 09:04:43 +0100 Subject: [PATCH 3/4] Disable "Convert to Child Item" for Disabled Tasks Suggested by Joseph Fletcher --- .../issues/show/components/task_list_item_actions.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue index f76ef4a58019c1..a96c81494c1295 100644 --- a/app/assets/javascripts/issues/show/components/task_list_item_actions.vue +++ b/app/assets/javascripts/issues/show/components/task_list_item_actions.vue @@ -67,6 +67,7 @@ export default { v-if="showConvertToTaskItem" class="!gl-ml-2" data-testid="convert" + :disabled="!isEnabledTaskListItem" @action="convertToTask" >