Skip to content
Snippets Groups Projects
Commit 5c43cdd9 authored by Simon Knox's avatar Simon Knox Committed by Enrique Alcántara
Browse files

Fix checkboxes on work item descriptions

Also properly render GFM elements

Changelog: fixed
parent 1ea3e36f
No related branches found
No related tags found
1 merge request!101364Enable interactive checklists within Task descriptions
<script>
import { GlButton, GlFormGroup, GlSafeHtmlDirective } from '@gitlab/ui';
import { GlButton, GlFormGroup } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { helpPagePath } from '~/helpers/help_page_helper';
import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave';
......@@ -11,16 +11,15 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import workItemQuery from '../graphql/work_item.query.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import { i18n, TRACKING_CATEGORY_SHOW, WIDGET_TYPE_DESCRIPTION } from '../constants';
import WorkItemDescriptionRendered from './work_item_description_rendered.vue';
export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
components: {
EditedAt,
GlButton,
GlFormGroup,
MarkdownField,
WorkItemDescriptionRendered,
},
mixins: [Tracking.mixin()],
props: {
......@@ -41,6 +40,7 @@ export default {
isSubmitting: false,
isSubmittingWithKeydown: false,
descriptionText: '',
descriptionHtml: '',
};
},
apollo: {
......@@ -54,6 +54,10 @@ export default {
skip() {
return !this.workItemId;
},
result() {
this.descriptionText = this.workItemDescription?.description;
this.descriptionHtml = this.workItemDescription?.descriptionHtml;
},
error() {
this.error = i18n.fetchError;
},
......@@ -64,7 +68,7 @@ export default {
return this.workItemId;
},
canEdit() {
return this.workItem?.userPermissions?.updateWorkItem;
return this.workItem?.userPermissions?.updateWorkItem || false;
},
tracking() {
return {
......@@ -73,12 +77,6 @@ export default {
property: `type_${this.workItemType}`,
};
},
descriptionHtml() {
return this.workItemDescription?.descriptionHtml;
},
descriptionEmpty() {
return this.descriptionHtml?.trim() === '';
},
workItemDescription() {
const descriptionWidget = this.workItem?.widgets?.find(
(widget) => widget.type === WIDGET_TYPE_DESCRIPTION,
......@@ -142,8 +140,10 @@ export default {
updateDraft(this.autosaveKey, this.descriptionText);
},
async updateWorkItem(event) {
if (event.key) {
async updateWorkItem(event = {}) {
const { key } = event;
if (key) {
this.isSubmittingWithKeydown = true;
}
......@@ -179,73 +179,70 @@ export default {
this.isSubmitting = false;
},
handleDescriptionTextUpdated(newText) {
this.descriptionText = newText;
this.updateWorkItem();
},
},
};
</script>
<template>
<gl-form-group
v-if="isEditing"
class="gl-my-5 gl-border-t gl-pt-6"
:label="__('Description')"
label-for="work-item-description"
>
<markdown-field
can-attach-file
:textarea-value="descriptionText"
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
class="gl-p-3 bordered-box gl-mt-5"
<div>
<gl-form-group
v-if="isEditing"
class="gl-mb-5 gl-border-t gl-pt-6"
:label="__('Description')"
label-for="work-item-description"
>
<template #textarea>
<textarea
id="work-item-description"
ref="textarea"
v-model="descriptionText"
:disabled="isSubmitting"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
@keydown.exact.esc.stop="cancelEditing"
@input="onInput"
></textarea>
</template>
</markdown-field>
<div class="gl-display-flex">
<gl-button
category="primary"
variant="confirm"
:loading="isSubmitting"
data-testid="save-description"
@click="updateWorkItem"
>{{ __('Save') }}</gl-button
<markdown-field
can-attach-file
:textarea-value="descriptionText"
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="$options.markdownDocsPath"
class="gl-p-3 bordered-box gl-mt-5"
>
<gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing">{{
__('Cancel')
}}</gl-button>
</div>
</gl-form-group>
<div v-else class="gl-mb-5 gl-border-t">
<div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
<label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
<gl-button
v-if="canEdit"
class="gl-ml-auto"
icon="pencil"
data-testid="edit-description"
:aria-label="__('Edit description')"
@click="startEditing"
/>
</div>
<div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
<div v-else v-safe-html="descriptionHtml" class="md gl-mb-5 gl-min-h-8"></div>
<template #textarea>
<textarea
id="work-item-description"
ref="textarea"
v-model="descriptionText"
:disabled="isSubmitting"
class="note-textarea js-gfm-input js-autosize markdown-area"
dir="auto"
data-supports-quick-actions="false"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="updateWorkItem"
@keydown.ctrl.enter="updateWorkItem"
@keydown.exact.esc.stop="cancelEditing"
@input="onInput"
></textarea>
</template>
</markdown-field>
<div class="gl-display-flex">
<gl-button
category="primary"
variant="confirm"
:loading="isSubmitting"
data-testid="save-description"
@click="updateWorkItem"
>{{ __('Save') }}
</gl-button>
<gl-button category="tertiary" class="gl-ml-3" data-testid="cancel" @click="cancelEditing"
>{{ __('Cancel') }}
</gl-button>
</div>
</gl-form-group>
<work-item-description-rendered
v-else
:work-item-description="workItemDescription"
:can-edit="canEdit"
@startEditing="startEditing"
@descriptionUpdated="handleDescriptionTextUpdated"
/>
<edited-at
v-if="lastEditedAt"
:updated-at="lastEditedAt"
......
<script>
import { GlButton, GlSafeHtmlDirective } from '@gitlab/ui';
import $ from 'jquery';
import '~/behaviors/markdown/render_gfm';
const isCheckbox = (target) => target?.classList.contains('task-list-item-checkbox');
export default {
directives: {
SafeHtml: GlSafeHtmlDirective,
},
components: {
GlButton,
},
props: {
workItemDescription: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
},
computed: {
descriptionText() {
return this.workItemDescription?.description;
},
descriptionHtml() {
return this.workItemDescription?.descriptionHtml;
},
descriptionEmpty() {
return this.descriptionHtml?.trim() === '';
},
},
watch: {
descriptionHtml: {
handler() {
this.renderGFM();
},
immediate: true,
},
},
methods: {
async renderGFM() {
await this.$nextTick();
$(this.$refs['gfm-content']).renderGFM();
if (this.canEdit) {
this.checkboxes = this.$el.querySelectorAll('.task-list-item-checkbox');
// enable boxes, disabled by default in markdown
this.checkboxes.forEach((checkbox) => {
// eslint-disable-next-line no-param-reassign
checkbox.disabled = false;
});
}
},
toggleCheckboxes(event) {
const { target } = event;
if (isCheckbox(target)) {
target.disabled = true;
const { sourcepos } = target.parentElement.dataset;
if (!sourcepos) return;
const [startRange] = sourcepos.split('-');
let [startRow] = startRange.split(':');
startRow = Number(startRow) - 1;
const descriptionTextRows = this.descriptionText.split('\n');
const newDescriptionText = descriptionTextRows
.map((row, index) => {
if (startRow === index) {
if (target.checked) {
return row.replace(/\[ \]/, '[x]');
}
return row.replace(/\[[x~]\]/i, '[ ]');
}
return row;
})
.join('\n');
this.$emit('descriptionUpdated', newDescriptionText);
}
},
},
};
</script>
<template>
<div class="gl-mb-5 gl-border-t gl-pt-5">
<div class="gl-display-inline-flex gl-align-items-center gl-mb-5">
<label class="d-block col-form-label gl-mr-5">{{ __('Description') }}</label>
<gl-button
v-if="canEdit"
class="gl-ml-auto"
icon="pencil"
data-testid="edit-description"
:aria-label="__('Edit description')"
@click="$emit('startEditing')"
/>
</div>
<div v-if="descriptionEmpty" class="gl-text-secondary gl-mb-5">{{ __('None') }}</div>
<div
v-else
ref="gfm-content"
v-safe-html="descriptionHtml"
class="md gl-mb-5 gl-min-h-8"
@change="toggleCheckboxes"
></div>
</div>
</template>
import { shallowMount } from '@vue/test-utils';
import $ from 'jquery';
import { nextTick } from 'vue';
import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue';
import { descriptionTextWithCheckboxes, descriptionHtmlWithCheckboxes } from '../mock_data';
describe('WorkItemDescription', () => {
let wrapper;
const findEditButton = () => wrapper.find('[data-testid="edit-description"]');
const findCheckboxAtIndex = (index) => wrapper.findAll('input[type="checkbox"]').at(index);
const defaultWorkItemDescription = {
description: descriptionTextWithCheckboxes,
descriptionHtml: descriptionHtmlWithCheckboxes,
};
const createComponent = ({
workItemDescription = defaultWorkItemDescription,
canEdit = false,
} = {}) => {
wrapper = shallowMount(WorkItemDescriptionRendered, {
propsData: {
workItemDescription,
canEdit,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders gfm', async () => {
const renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
createComponent();
await nextTick();
expect(renderGFMSpy).toHaveBeenCalled();
});
describe('with checkboxes', () => {
beforeEach(() => {
createComponent({
canEdit: true,
workItemDescription: {
description: `- [x] todo 1\n- [ ] todo 2`,
descriptionHtml: `<ul dir="auto" class="task-list" data-sourcepos="1:1-4:0">
<li class="task-list-item" data-sourcepos="1:1-2:15">
<input checked="" class="task-list-item-checkbox" type="checkbox"> todo 1</li>
<li class="task-list-item" data-sourcepos="2:1-2:15">
<input class="task-list-item-checkbox" type="checkbox"> todo 2</li>
</ul>`,
},
});
});
it('checks unchecked checkbox', async () => {
findCheckboxAtIndex(1).setChecked();
await nextTick();
const updatedDescription = `- [x] todo 1\n- [x] todo 2`;
expect(wrapper.emitted('descriptionUpdated')).toEqual([[updatedDescription]]);
});
it('disables checkbox while updating', async () => {
findCheckboxAtIndex(1).setChecked();
await nextTick();
expect(findCheckboxAtIndex(1).attributes().disabled).toBeDefined();
});
it('unchecks checked checkbox', async () => {
findCheckboxAtIndex(0).setChecked(false);
await nextTick();
const updatedDescription = `- [ ] todo 1\n- [ ] todo 2`;
expect(wrapper.emitted('descriptionUpdated')).toEqual([[updatedDescription]]);
});
});
describe('Edit button', () => {
it('is not visible when canUpdate = false', async () => {
await createComponent({
canUpdate: false,
});
expect(findEditButton().exists()).toBe(false);
});
it('toggles edit mode', async () => {
createComponent({
canEdit: true,
});
findEditButton().vm.$emit('click');
await nextTick();
expect(wrapper.emitted('startEditing')).toEqual([[]]);
});
});
});
......@@ -9,6 +9,7 @@ import { updateDraft } from '~/lib/utils/autosave';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
......@@ -30,8 +31,8 @@ describe('WorkItemDescription', () => {
const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse);
const findEditButton = () => wrapper.find('[data-testid="edit-description"]');
const findMarkdownField = () => wrapper.findComponent(MarkdownField);
const findRenderedDescription = () => wrapper.findComponent(WorkItemDescriptionRendered);
const findEditedAt = () => wrapper.findComponent(EditedAt);
const editDescription = (newText) => wrapper.find('textarea').setValue(newText);
......@@ -65,7 +66,7 @@ describe('WorkItemDescription', () => {
await waitForPromises();
if (isEditing) {
findEditButton().vm.$emit('click');
findRenderedDescription().vm.$emit('startEditing');
await nextTick();
}
......@@ -75,28 +76,6 @@ describe('WorkItemDescription', () => {
wrapper.destroy();
});
describe('Edit button', () => {
it('is not visible when canUpdate = false', async () => {
await createComponent({
canUpdate: false,
});
expect(findEditButton().exists()).toBe(false);
});
it('toggles edit mode', async () => {
await createComponent({
canUpdate: true,
});
findEditButton().vm.$emit('click');
await nextTick();
expect(findMarkdownField().exists()).toBe(true);
});
});
describe('editing description', () => {
it('shows edited by text', async () => {
const lastEditedAt = '2022-09-21T06:18:42Z';
......
......@@ -178,6 +178,19 @@ export const mockParent = {
},
};
export const descriptionTextWithCheckboxes = `- [ ] todo 1\n- [ ] todo 2`;
export const descriptionHtmlWithCheckboxes = `
<ul dir="auto" class="task-list" data-sourcepos"1:1-2:12">
<li class="task-list-item" data-sourcepos="1:1-1:11">
<input class="task-list-item-checkbox" type="checkbox"> todo 1
</li>
<li class="task-list-item" data-sourcepos="2:1-2:12">
<input class="task-list-item-checkbox" type="checkbox"> todo 2
</li>
</ul>
`;
export const workItemResponseFactory = ({
canUpdate = false,
canDelete = false,
......@@ -193,6 +206,7 @@ export const workItemResponseFactory = ({
allowsScopedLabels = false,
lastEditedAt = null,
lastEditedBy = null,
withCheckboxes = false,
parent = mockParent.parent,
} = {}) => ({
data: {
......@@ -224,9 +238,10 @@ export const workItemResponseFactory = ({
{
__typename: 'WorkItemWidgetDescription',
type: 'DESCRIPTION',
description: 'some **great** text',
descriptionHtml:
'<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>',
description: withCheckboxes ? descriptionTextWithCheckboxes : 'some **great** text',
descriptionHtml: withCheckboxes
? descriptionHtmlWithCheckboxes
: '<p data-sourcepos="1:1-1:19" dir="auto">some <strong>great</strong> text</p>',
lastEditedAt,
lastEditedBy,
},
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment