Commit 5c0c6196 authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Enrique Alcántara

Add support for collapsible content in content editor

parent 664586f7
...@@ -111,6 +111,15 @@ export default { ...@@ -111,6 +111,15 @@ export default {
:label="__('Add a numbered list')" :label="__('Add a numbered list')"
@execute="trackToolbarControlExecution" @execute="trackToolbarControlExecution"
/> />
<toolbar-button
data-testid="details"
content-type="details"
icon-name="details-block"
class="gl-mx-2"
editor-command="toggleDetails"
:label="__('Add a collapsible section')"
@execute="trackToolbarControlExecution"
/>
<toolbar-button <toolbar-button
data-testid="horizontal-rule" data-testid="horizontal-rule"
content-type="horizontalRule" content-type="horizontalRule"
......
<script>
import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2';
export default {
name: 'DetailsWrapper',
components: {
NodeViewWrapper,
NodeViewContent,
},
props: {
node: {
type: Object,
required: true,
},
},
data() {
return {
open: true,
};
},
};
</script>
<template>
<node-view-wrapper class="gl-display-flex">
<div
class="details-toggle-icon"
data-testid="details-toggle-icon"
:class="{ 'is-open': open }"
@click="open = !open"
></div>
<node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" />
</node-view-wrapper>
</template>
import { Node } from '@tiptap/core';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import { wrappingInputRule } from 'prosemirror-inputrules';
import DetailsWrapper from '../components/wrappers/details.vue';
export const inputRegex = /^\s*(<details>)$/;
export default Node.create({
name: 'details',
content: 'detailsContent+',
// eslint-disable-next-line @gitlab/require-i18n-strings
group: 'block list',
parseHTML() {
return [{ tag: 'details' }];
},
renderHTML({ HTMLAttributes }) {
return ['ul', HTMLAttributes, 0];
},
addNodeView() {
return VueNodeViewRenderer(DetailsWrapper);
},
addInputRules() {
return [wrappingInputRule(inputRegex, this.type)];
},
addCommands() {
return {
setDetails: () => ({ commands }) => commands.wrapInList('details'),
toggleDetails: () => ({ commands }) => commands.toggleList('details', 'detailsContent'),
};
},
});
import { Node } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default Node.create({
name: 'detailsContent',
content: 'block+',
defining: true,
parseHTML() {
return [
{ tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST },
];
},
renderHTML({ HTMLAttributes }) {
return ['li', HTMLAttributes, 0];
},
addKeyboardShortcuts() {
return {
Enter: () => this.editor.commands.splitListItem('detailsContent'),
'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'),
};
},
});
...@@ -10,6 +10,8 @@ import Code from '../extensions/code'; ...@@ -10,6 +10,8 @@ import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight'; import CodeBlockHighlight from '../extensions/code_block_highlight';
import DescriptionItem from '../extensions/description_item'; import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list'; import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division'; import Division from '../extensions/division';
import Document from '../extensions/document'; import Document from '../extensions/document';
import Dropcursor from '../extensions/dropcursor'; import Dropcursor from '../extensions/dropcursor';
...@@ -81,6 +83,8 @@ export const createContentEditor = ({ ...@@ -81,6 +83,8 @@ export const createContentEditor = ({
CodeBlockHighlight, CodeBlockHighlight,
DescriptionItem, DescriptionItem,
DescriptionList, DescriptionList,
Details,
DetailsContent,
Document, Document,
Division, Division,
Dropcursor, Dropcursor,
......
...@@ -11,6 +11,8 @@ import Code from '../extensions/code'; ...@@ -11,6 +11,8 @@ import Code from '../extensions/code';
import CodeBlockHighlight from '../extensions/code_block_highlight'; import CodeBlockHighlight from '../extensions/code_block_highlight';
import DescriptionItem from '../extensions/description_item'; import DescriptionItem from '../extensions/description_item';
import DescriptionList from '../extensions/description_list'; import DescriptionList from '../extensions/description_list';
import Details from '../extensions/details';
import DetailsContent from '../extensions/details_content';
import Division from '../extensions/division'; import Division from '../extensions/division';
import Emoji from '../extensions/emoji'; import Emoji from '../extensions/emoji';
import Figure from '../extensions/figure'; import Figure from '../extensions/figure';
...@@ -53,6 +55,7 @@ import { ...@@ -53,6 +55,7 @@ import {
renderImage, renderImage,
renderPlayable, renderPlayable,
renderHTMLNode, renderHTMLNode,
renderContent,
} from './serialization_helpers'; } from './serialization_helpers';
const defaultSerializerConfig = { const defaultSerializerConfig = {
...@@ -133,6 +136,15 @@ const defaultSerializerConfig = { ...@@ -133,6 +136,15 @@ const defaultSerializerConfig = {
renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node); renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node);
if (index === parent.childCount - 1) state.ensureNewLine(); if (index === parent.childCount - 1) state.ensureNewLine();
}, },
[Details.name]: renderHTMLNode('details', true),
[DetailsContent.name]: (state, node, parent, index) => {
if (!index) renderHTMLNode('summary')(state, node);
else {
if (index === 1) state.ensureNewLine();
renderContent(state, node);
if (index === parent.childCount - 1) state.ensureNewLine();
}
},
[Emoji.name]: (state, node) => { [Emoji.name]: (state, node) => {
const { name } = node.attrs; const { name } = node.attrs;
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
th, th,
li, li,
dd, dd,
dt { dt,
summary {
:first-child { :first-child {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
...@@ -37,6 +38,7 @@ ...@@ -37,6 +38,7 @@
} }
} }
.dl-content { .dl-content {
width: 100%; width: 100%;
...@@ -50,6 +52,38 @@ ...@@ -50,6 +52,38 @@
} }
} }
} }
.details-toggle-icon {
cursor: pointer;
z-index: 1;
&::before {
content: '▶';
display: inline-block;
width: $gl-spacing-scale-4;
}
&.is-open::before {
content: '▼';
}
}
.details-content {
width: calc(100% - #{$gl-spacing-scale-4});
> li {
list-style-type: none;
margin-left: $gl-spacing-scale-2;
}
> :not(:first-child) {
display: none;
}
&.is-open > :not(:first-child) {
display: inherit;
}
}
} }
.table-creator-grid-item { .table-creator-grid-item {
......
...@@ -75,6 +75,15 @@ ...@@ -75,6 +75,15 @@
details { details {
margin-bottom: $gl-padding; margin-bottom: $gl-padding;
> *:not(summary) {
margin-left: $gl-spacing-scale-5;
}
}
summary > * {
display: inline-block;
margin-bottom: 0;
} }
// Single code lines should wrap // Single code lines should wrap
...@@ -478,6 +487,7 @@ ...@@ -478,6 +487,7 @@
font-size: larger; font-size: larger;
} }
figcaption,
.small { .small {
font-size: smaller; font-size: smaller;
} }
......
...@@ -31,6 +31,7 @@ describe('content_editor/components/top_toolbar', () => { ...@@ -31,6 +31,7 @@ describe('content_editor/components/top_toolbar', () => {
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }} ${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }} ${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }} ${'ordered-list'} | ${{ contentType: 'orderedList', iconName: 'list-numbered', label: 'Add a numbered list', editorCommand: 'toggleOrderedList' }}
${'details'} | ${{ contentType: 'details', iconName: 'details-block', label: 'Add a collapsible section', editorCommand: 'toggleDetails' }}
${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }} ${'horizontal-rule'} | ${{ contentType: 'horizontalRule', iconName: 'dash', label: 'Add a horizontal rule', editorCommand: 'setHorizontalRule' }}
${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }} ${'code-block'} | ${{ contentType: 'codeBlock', iconName: 'doc-code', label: 'Insert a code block', editorCommand: 'toggleCodeBlock' }}
${'text-styles'} | ${{}} ${'text-styles'} | ${{}}
......
import { NodeViewContent } from '@tiptap/vue-2';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import DetailsWrapper from '~/content_editor/components/wrappers/details.vue';
describe('content/components/wrappers/details', () => {
let wrapper;
const createWrapper = async () => {
wrapper = shallowMountExtended(DetailsWrapper, {
propsData: {
node: {},
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders a node-view-content as a ul element', () => {
createWrapper();
expect(wrapper.findComponent(NodeViewContent).props().as).toBe('ul');
});
it('is "open" by default', () => {
createWrapper();
expect(wrapper.findByTestId('details-toggle-icon').classes()).toContain('is-open');
expect(wrapper.findComponent(NodeViewContent).classes()).toContain('is-open');
});
it('closes the details block on clicking the details toggle icon', async () => {
createWrapper();
await wrapper.findByTestId('details-toggle-icon').trigger('click');
expect(wrapper.findByTestId('details-toggle-icon').classes()).not.toContain('is-open');
expect(wrapper.findComponent(NodeViewContent).classes()).not.toContain('is-open');
});
});
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/details_content', () => {
let tiptapEditor;
let doc;
let p;
let details;
let detailsContent;
beforeEach(() => {
tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] });
({
builders: { doc, p, details, detailsContent },
} = createDocBuilder({
tiptapEditor,
names: {
details: { nodeType: Details.name },
detailsContent: { nodeType: DetailsContent.name },
},
}));
});
describe('shortcut: Enter', () => {
it('splits a details content into two items', () => {
const initialDoc = doc(
details(
detailsContent(p('Summary')),
detailsContent(p('Text content')),
detailsContent(p('Text content')),
),
);
const expectedDoc = doc(
details(
detailsContent(p('Summary')),
detailsContent(p('')),
detailsContent(p('Text content')),
detailsContent(p('Text content')),
),
);
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.commands.setTextSelection(10);
tiptapEditor.commands.keyboardShortcut('Enter');
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
describe('shortcut: Shift-Tab', () => {
it('lifts a details content and creates two separate details items', () => {
const initialDoc = doc(
details(
detailsContent(p('Summary')),
detailsContent(p('Text content')),
detailsContent(p('Text content')),
),
);
const expectedDoc = doc(
details(detailsContent(p('Summary'))),
p('Text content'),
details(detailsContent(p('Text content'))),
);
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.commands.setTextSelection(20);
tiptapEditor.commands.keyboardShortcut('Shift-Tab');
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
});
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
import { createTestEditor, createDocBuilder } from '../test_utils';
describe('content_editor/extensions/details', () => {
let tiptapEditor;
let doc;
let p;
let details;
let detailsContent;
beforeEach(() => {
tiptapEditor = createTestEditor({ extensions: [Details, DetailsContent] });
({
builders: { doc, p, details, detailsContent },
} = createDocBuilder({
tiptapEditor,
names: {
details: { nodeType: Details.name },
detailsContent: { nodeType: DetailsContent.name },
},
}));
});
describe('setDetails command', () => {
describe('when current block is a paragraph', () => {
it('converts current paragraph into a details block', () => {
const initialDoc = doc(p('Text content'));
const expectedDoc = doc(details(detailsContent(p('Text content'))));
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.commands.setDetails();
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
describe('when current block is a details block', () => {
it('maintains the same document structure', () => {
const initialDoc = doc(details(detailsContent(p('Text content'))));
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.commands.setDetails();
expect(tiptapEditor.getJSON()).toEqual(initialDoc.toJSON());
});
});
});
describe('toggleDetails command', () => {
describe('when current block is a paragraph', () => {
it('converts current paragraph into a details block', () => {
const initialDoc = doc(p('Text content'));
const expectedDoc = doc(details(detailsContent(p('Text content'))));
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.commands.toggleDetails();
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
describe('when current block is a details block', () => {
it('convert details block into a paragraph', () => {
const initialDoc = doc(details(detailsContent(p('Text content'))));
const expectedDoc = doc(p('Text content'));
tiptapEditor.commands.setContent(initialDoc.toJSON());
tiptapEditor.commands.toggleDetails();
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
});
it.each`
input | insertedNode
${'<details>'} | ${(...args) => details(detailsContent(p(...args)))}
${'<details'} | ${(...args) => p(...args)}
${'details>'} | ${(...args) => p(...args)}
`('with input=$input, then should insert a $insertedNode', ({ input, insertedNode }) => {
const { view } = tiptapEditor;
const { selection } = view.state;
const expectedDoc = doc(insertedNode());
// Triggers the event handler that input rules listen to
view.someProp('handleTextInput', (f) => f(view, selection.from, selection.to, input));
expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON());
});
});
...@@ -5,6 +5,8 @@ import Code from '~/content_editor/extensions/code'; ...@@ -5,6 +5,8 @@ import Code from '~/content_editor/extensions/code';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionItem from '~/content_editor/extensions/description_item';
import DescriptionList from '~/content_editor/extensions/description_list'; import DescriptionList from '~/content_editor/extensions/description_list';
import Details from '~/content_editor/extensions/details';
import DetailsContent from '~/content_editor/extensions/details_content';
import Division from '~/content_editor/extensions/division'; import Division from '~/content_editor/extensions/division';
import Emoji from '~/content_editor/extensions/emoji'; import Emoji from '~/content_editor/extensions/emoji';
import Figure from '~/content_editor/extensions/figure'; import Figure from '~/content_editor/extensions/figure';
...@@ -45,6 +47,8 @@ const tiptapEditor = createTestEditor({ ...@@ -45,6 +47,8 @@ const tiptapEditor = createTestEditor({
CodeBlockHighlight, CodeBlockHighlight,
DescriptionItem, DescriptionItem,
DescriptionList, DescriptionList,
Details,
DetailsContent,
Division, Division,
Emoji, Emoji,
Figure, Figure,
...@@ -78,6 +82,8 @@ const { ...@@ -78,6 +82,8 @@ const {
bulletList, bulletList,
code, code,
codeBlock, codeBlock,
details,
detailsContent,
division, division,
descriptionItem, descriptionItem,
descriptionList, descriptionList,
...@@ -110,6 +116,8 @@ const { ...@@ -110,6 +116,8 @@ const {
bulletList: { nodeType: BulletList.name }, bulletList: { nodeType: BulletList.name },
code: { markType: Code.name }, code: { markType: Code.name },
codeBlock: { nodeType: CodeBlockHighlight.name }, codeBlock: { nodeType: CodeBlockHighlight.name },
details: { nodeType: Details.name },
detailsContent: { nodeType: DetailsContent.name },
division: { nodeType: Division.name }, division: { nodeType: Division.name },
descriptionItem: { nodeType: DescriptionItem.name }, descriptionItem: { nodeType: DescriptionItem.name },
descriptionList: { nodeType: DescriptionList.name }, descriptionList: { nodeType: DescriptionList.name },
...@@ -588,6 +596,105 @@ A giant _owl-like_ creature. ...@@ -588,6 +596,105 @@ A giant _owl-like_ creature.
); );
}); });
it('correctly renders a simple details/summary', () => {
expect(
serialize(
details(
detailsContent(paragraph('this is the summary')),
detailsContent(paragraph('this content will be hidden')),
),
),
).toBe(
`
<details>
<summary>this is the summary</summary>
this content will be hidden
</details>
`.trim(),
);
});
it('correctly renders details/summary with styled content', () => {
expect(
serialize(
details(
detailsContent(paragraph('this is the ', bold('summary'))),
detailsContent(
codeBlock(
{ language: 'javascript' },
'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);',
),
),
detailsContent(paragraph('this content will be ', italic('hidden'))),
),
details(detailsContent(paragraph('summary 2')), detailsContent(paragraph('content 2'))),
),
).toBe(
`
<details>
<summary>
this is the **summary**
</summary>
\`\`\`javascript
var a = 2;
var b = 3;
var c = a + d;
console.log(c);
\`\`\`
this content will be _hidden_
</details>
<details>
<summary>summary 2</summary>
content 2
</details>
`.trim(),
);
});
it('correctly renders nested details', () => {
expect(
serialize(
details(
detailsContent(paragraph('dream level 1')),
detailsContent(
details(
detailsContent(paragraph('dream level 2')),
detailsContent(
details(
detailsContent(paragraph('dream level 3')),
detailsContent(paragraph(italic('inception'))),
),
),
),
),
),
),
).toBe(
`
<details>
<summary>dream level 1</summary>
<details>
<summary>dream level 2</summary>
<details>
<summary>dream level 3</summary>
_inception_
</details>
</details>
</details>
`.trim(),
);
});
it('correctly renders div', () => { it('correctly renders div', () => {
expect( expect(
serialize( serialize(
......
...@@ -77,6 +77,35 @@ ...@@ -77,6 +77,35 @@
</dd> </dd>
</dl> </dl>
- name: details
markdown: |-
<details>
<summary>Apply this patch</summary>
```diff
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
index 8433efaf00c..69b12c59d46 100644
--- a/spec/frontend/fixtures/api_markdown.yml
+++ b/spec/frontend/fixtures/api_markdown.yml
@@ -33,6 +33,13 @@
* <ruby>漢<rt>ㄏㄢˋ</rt></ruby>
* C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O
* The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>.The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>
+- name: details
+ markdown: |-
+ <details>
+ <summary>Apply this patch</summary>
+
+ 🐶 much meta, 🐶 many patch
+ 🐶 such diff, 🐶 very meme
+ 🐶 wow!
+ </details>
- name: link
markdown: '[GitLab](https://gitlab.com)'
- name: attachment_link
```
</details>
- name: link - name: link
markdown: '[GitLab](https://gitlab.com)' markdown: '[GitLab](https://gitlab.com)'
- name: attachment_link - name: attachment_link
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment