Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-08-19 21:10:32 +00:00
parent 21db5294d4
commit acb2f0ab94
14 changed files with 776 additions and 72 deletions

View File

@ -38,7 +38,8 @@ AllCops:
- 'workhorse/**/*'
- 'spec/support/*.git/**/*' # e.g. spec/support/gitlab-git-test.git
- 'db/ci_migrate/*.rb' # since the `db/ci_migrate` is a symlinked to `db/migrate`
CacheRootDirectory: tmp
# Use absolute path to avoid orphan directories with changed workspace root.
CacheRootDirectory: <%= Dir.getwd %>/tmp
MaxFilesInCache: 25000
Cop/AvoidKeywordArgumentsInSidekiqWorkers:

View File

@ -1,5 +1,6 @@
import { TableCell } from '@tiptap/extension-table-cell';
import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableCell.extend({
content: 'inline*',
content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
});

View File

@ -1,5 +1,6 @@
import { TableHeader } from '@tiptap/extension-table-header';
import { isBlockTablesFeatureEnabled } from '../services/feature_flags';
export default TableHeader.extend({
content: 'inline*',
content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*',
});

View File

@ -0,0 +1,3 @@
export function isBlockTablesFeatureEnabled() {
return gon.features?.contentEditorBlockTables;
}

View File

@ -30,6 +30,12 @@ import TableRow from '../extensions/table_row';
import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import {
renderHardBreak,
renderTable,
renderTableCell,
renderTableRow,
} from './serialization_helpers';
const defaultSerializerConfig = {
marks: {
@ -65,6 +71,7 @@ const defaultSerializerConfig = {
expelEnclosingWhitespace: true,
},
},
nodes: {
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
@ -80,7 +87,7 @@ const defaultSerializerConfig = {
state.write(`:${name}:`);
},
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
[HardBreak.name]: renderHardBreak,
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
[Image.name]: (state, node) => {
@ -95,60 +102,10 @@ const defaultSerializerConfig = {
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
},
[Table.name]: (state, node) => {
state.renderContent(node);
},
[TableCell.name]: (state, node) => {
state.renderInline(node);
},
[TableHeader.name]: (state, node) => {
state.renderInline(node);
},
[TableRow.name]: (state, node) => {
const isHeaderRow = node.child(0).type.name === 'tableHeader';
const renderRow = () => {
const cellWidths = [];
state.flushClose(1);
state.write('| ');
node.forEach((cell, _, i) => {
if (i) state.write(' | ');
const { length } = state.out;
state.render(cell, node, i);
cellWidths.push(state.out.length - length);
});
state.write(' |');
state.closeBlock(node);
return cellWidths;
};
const renderHeaderRow = (cellWidths) => {
state.flushClose(1);
state.write('|');
node.forEach((cell, _, i) => {
if (i) state.write('|');
state.write(cell.attrs.align === 'center' ? ':' : '-');
state.write(state.repeat('-', cellWidths[i]));
state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
});
state.write('|');
state.closeBlock(node);
};
if (isHeaderRow) {
renderHeaderRow(renderRow());
} else {
renderRow();
}
},
[Table.name]: renderTable,
[TableCell.name]: renderTableCell,
[TableHeader.name]: renderTableCell,
[TableRow.name]: renderTableRow,
[TaskItem.name]: (state, node) => {
state.write(`[${node.attrs.checked ? 'x' : ' '}] `);
state.renderContent(node);
@ -175,7 +132,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
* that parses the Markdown and converts it into HTML.
* @returns a markdown serializer
*/
export default ({ render = () => null, serializerConfig }) => ({
export default ({ render = () => null, serializerConfig = {} } = {}) => ({
/**
* Converts a Markdown string into a ProseMirror JSONDocument based
* on a ProseMirror schema.

View File

@ -0,0 +1,250 @@
import { uniq } from 'lodash';
import { isBlockTablesFeatureEnabled } from './feature_flags';
const defaultAttrs = {
td: { colspan: 1, rowspan: 1, colwidth: null },
th: { colspan: 1, rowspan: 1, colwidth: null },
};
const tableMap = new WeakMap();
function shouldRenderCellInline(cell) {
if (cell.childCount === 1) {
const parent = cell.child(0);
if (parent.type.name === 'paragraph' && parent.childCount === 1) {
const child = parent.child(0);
return child.isText && child.marks.length === 0;
}
}
return false;
}
function getRowsAndCells(table) {
const cells = [];
const rows = [];
table.descendants((n) => {
if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') {
cells.push(n);
return false;
}
if (n.type.name === 'tableRow') {
rows.push(n);
}
return true;
});
return { rows, cells };
}
function getChildren(node) {
const children = [];
for (let i = 0; i < node.childCount; i += 1) {
children.push(node.child(i));
}
return children;
}
function shouldRenderHTMLTable(table) {
const { rows, cells } = getRowsAndCells(table);
const cellChildCount = Math.max(...cells.map((cell) => cell.childCount));
const maxColspan = Math.max(...cells.map((cell) => cell.attrs.colspan));
const maxRowspan = Math.max(...cells.map((cell) => cell.attrs.rowspan));
const rowChildren = rows.map((row) => uniq(getChildren(row).map((cell) => cell.type.name)));
const cellTypeInFirstRow = rowChildren[0];
const cellTypesInOtherRows = uniq(rowChildren.slice(1).map(([type]) => type));
// if the first row has headers, and there are no headers anywhere else, render markdown table
if (
!(
cellTypeInFirstRow.length === 1 &&
cellTypeInFirstRow[0] === 'tableHeader' &&
cellTypesInOtherRows.length === 1 &&
cellTypesInOtherRows[0] === 'tableCell'
)
) {
return true;
}
if (cellChildCount === 1 && maxColspan === 1 && maxRowspan === 1) {
// if all rows contain only one paragraph each and no rowspan/colspan, render markdown table
const children = uniq(cells.map((cell) => cell.child(0).type.name));
if (children.length === 1 && children[0] === 'paragraph') {
return false;
}
}
return true;
}
function openTag(state, tagName, attrs) {
let str = `<${tagName}`;
str += Object.entries(attrs || {})
.map(([key, value]) => {
if (defaultAttrs[tagName]?.[key] === value) return '';
return ` ${key}=${state.quote(value?.toString() || '')}`;
})
.join('');
return `${str}>`;
}
function closeTag(state, tagName) {
return `</${tagName}>`;
}
function isInBlockTable(node) {
return tableMap.get(node);
}
function isInTable(node) {
return tableMap.has(node);
}
function setIsInBlockTable(table, value) {
tableMap.set(table, value);
const { rows, cells } = getRowsAndCells(table);
rows.forEach((row) => tableMap.set(row, value));
cells.forEach((cell) => {
tableMap.set(cell, value);
if (cell.childCount && cell.child(0).type.name === 'paragraph')
tableMap.set(cell.child(0), value);
});
}
function unsetIsInBlockTable(table) {
tableMap.delete(table);
const { rows, cells } = getRowsAndCells(table);
rows.forEach((row) => tableMap.delete(row));
cells.forEach((cell) => {
tableMap.delete(cell);
if (cell.childCount) tableMap.delete(cell.child(0));
});
}
function renderTagOpen(state, tagName, attrs) {
state.ensureNewLine();
state.write(openTag(state, tagName, attrs));
}
function renderTagClose(state, tagName, insertNewline = true) {
state.write(closeTag(state, tagName));
if (insertNewline) state.ensureNewLine();
}
function renderTableHeaderRowAsMarkdown(state, node, cellWidths) {
state.flushClose(1);
state.write('|');
node.forEach((cell, _, i) => {
if (i) state.write('|');
state.write(cell.attrs.align === 'center' ? ':' : '-');
state.write(state.repeat('-', cellWidths[i]));
state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-');
});
state.write('|');
state.closeBlock(node);
}
function renderTableRowAsMarkdown(state, node, isHeaderRow = false) {
const cellWidths = [];
state.flushClose(1);
state.write('| ');
node.forEach((cell, _, i) => {
if (i) state.write(' | ');
const { length } = state.out;
state.render(cell, node, i);
cellWidths.push(state.out.length - length);
});
state.write(' |');
state.closeBlock(node);
if (isHeaderRow) renderTableHeaderRowAsMarkdown(state, node, cellWidths);
}
function renderTableRowAsHTML(state, node) {
renderTagOpen(state, 'tr');
node.forEach((cell, _, i) => {
const tag = cell.type.name === 'tableHeader' ? 'th' : 'td';
renderTagOpen(state, tag, cell.attrs);
if (!shouldRenderCellInline(cell)) {
state.closeBlock(node);
state.flushClose();
}
state.render(cell, node, i);
state.flushClose(1);
renderTagClose(state, tag);
});
renderTagClose(state, 'tr');
}
export function renderTableCell(state, node) {
if (!isBlockTablesFeatureEnabled()) {
state.renderInline(node);
return;
}
if (!isInBlockTable(node) || shouldRenderCellInline(node)) {
state.renderInline(node.child(0));
} else {
state.renderContent(node);
}
}
export function renderTableRow(state, node) {
if (isInBlockTable(node)) {
renderTableRowAsHTML(state, node);
} else {
renderTableRowAsMarkdown(state, node, node.child(0).type.name === 'tableHeader');
}
}
export function renderTable(state, node) {
if (isBlockTablesFeatureEnabled()) {
setIsInBlockTable(node, shouldRenderHTMLTable(node));
}
if (isInBlockTable(node)) renderTagOpen(state, 'table');
state.renderContent(node);
if (isInBlockTable(node)) renderTagClose(state, 'table');
// ensure at least one blank line after any table
state.closeBlock(node);
state.flushClose();
if (isBlockTablesFeatureEnabled()) {
unsetIsInBlockTable(node);
}
}
export function renderHardBreak(state, node, parent, index) {
const br = isInTable(parent) ? '<br>' : '\\\n';
for (let i = index + 1; i < parent.childCount; i += 1) {
if (parent.child(i).type !== node.type) {
state.write(br);
return;
}
}
}

View File

@ -133,7 +133,10 @@ export default {
<template>
<div
:class="[$options.userColorScheme, { inline, 'with-codequality': hasCodequalityChanges }]"
:class="[
$options.userColorScheme,
{ 'inline-diff-view': inline, 'with-codequality': hasCodequalityChanges },
]"
:data-commit-id="commitId"
class="diff-grid diff-table code diff-wrap-lines js-syntax-highlight text-file"
@mousedown="handleParallelLineMouseDown"

View File

@ -613,7 +613,7 @@ table.code {
grid-template-columns: 1fr 1fr;
}
&.inline {
&.inline-diff-view {
.diff-grid-comments {
display: grid;
grid-template-columns: 1fr;

View File

@ -5,5 +5,9 @@ class Projects::WikisController < Projects::ApplicationController
alias_method :container, :project
before_action do
push_frontend_feature_flag(:content_editor_block_tables, @project, default_enabled: :yaml)
end
feature_category :wiki
end

View File

@ -0,0 +1,8 @@
---
name: content_editor_block_tables
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66187
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/338937
milestone: '14.3'
type: development
group: group::editor
default_enabled: false

View File

@ -188,7 +188,7 @@ module Gitlab
merge_requests = MergeRequestsFinder.new(current_user, issuable_params).execute
unless default_project_filter
merge_requests = merge_requests.in_projects(project_ids_relation)
merge_requests = merge_requests.of_projects(project_ids_relation)
end
apply_sort(merge_requests, scope: 'merge_requests')

View File

@ -0,0 +1,480 @@
import Blockquote from '~/content_editor/extensions/blockquote';
import Bold from '~/content_editor/extensions/bold';
import BulletList from '~/content_editor/extensions/bullet_list';
import Code from '~/content_editor/extensions/code';
import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight';
import Emoji from '~/content_editor/extensions/emoji';
import HardBreak from '~/content_editor/extensions/hard_break';
import Heading from '~/content_editor/extensions/heading';
import HorizontalRule from '~/content_editor/extensions/horizontal_rule';
import Image from '~/content_editor/extensions/image';
import Italic from '~/content_editor/extensions/italic';
import Link from '~/content_editor/extensions/link';
import ListItem from '~/content_editor/extensions/list_item';
import OrderedList from '~/content_editor/extensions/ordered_list';
import Paragraph from '~/content_editor/extensions/paragraph';
import Strike from '~/content_editor/extensions/strike';
import Table from '~/content_editor/extensions/table';
import TableCell from '~/content_editor/extensions/table_cell';
import TableHeader from '~/content_editor/extensions/table_header';
import TableRow from '~/content_editor/extensions/table_row';
import Text from '~/content_editor/extensions/text';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
import { createTestEditor, createDocBuilder } from '../test_utils';
jest.mock('~/emoji');
jest.mock('~/content_editor/services/feature_flags', () => ({
isBlockTablesFeatureEnabled: jest.fn().mockReturnValue(true),
}));
const tiptapEditor = createTestEditor({
extensions: [
Blockquote,
Bold,
BulletList,
Code,
CodeBlockHighlight,
Emoji,
HardBreak,
Heading,
HorizontalRule,
Image,
Italic,
Link,
ListItem,
OrderedList,
Paragraph,
Strike,
Table,
TableCell,
TableHeader,
TableRow,
Text,
],
});
const {
builders: {
doc,
blockquote,
bold,
bulletList,
code,
codeBlock,
emoji,
heading,
hardBreak,
horizontalRule,
image,
italic,
link,
listItem,
orderedList,
paragraph,
strike,
table,
tableCell,
tableHeader,
tableRow,
},
} = createDocBuilder({
tiptapEditor,
names: {
blockquote: { nodeType: Blockquote.name },
bold: { markType: Bold.name },
bulletList: { nodeType: BulletList.name },
code: { markType: Code.name },
codeBlock: { nodeType: CodeBlockHighlight.name },
emoji: { markType: Emoji.name },
hardBreak: { nodeType: HardBreak.name },
heading: { nodeType: Heading.name },
horizontalRule: { nodeType: HorizontalRule.name },
image: { nodeType: Image.name },
italic: { nodeType: Italic.name },
link: { markType: Link.name },
listItem: { nodeType: ListItem.name },
orderedList: { nodeType: OrderedList.name },
paragraph: { nodeType: Paragraph.name },
strike: { markType: Strike.name },
table: { nodeType: Table.name },
tableCell: { nodeType: TableCell.name },
tableHeader: { nodeType: TableHeader.name },
tableRow: { nodeType: TableRow.name },
},
});
const serialize = (...content) =>
markdownSerializer({}).serialize({
schema: tiptapEditor.schema,
content: doc(...content).toJSON(),
});
describe('markdownSerializer', () => {
it('correctly serializes a line break', () => {
expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld');
});
it('correctly serializes a table with inline content', () => {
expect(
serialize(
table(
// each table cell must contain at least one paragraph
tableRow(
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
),
tableRow(
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
),
tableRow(
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
tableCell(paragraph('cell')),
),
),
).trim(),
).toBe(
`
| header | header | header |
|--------|--------|--------|
| cell | cell | cell |
| cell | cell | cell |
`.trim(),
);
});
it('correctly serializes a table with line breaks', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
tableRow(
tableCell(paragraph('cell with', hardBreak(), 'line', hardBreak(), 'breaks')),
tableCell(paragraph('cell')),
),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
).trim(),
).toBe(
`
| header | header |
|--------|--------|
| cell with<br>line<br>breaks | cell |
| cell | cell |
`.trim(),
);
});
it('correctly serializes two consecutive tables', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
table(
tableRow(tableHeader(paragraph('header')), tableHeader(paragraph('header'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
).trim(),
).toBe(
`
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
`.trim(),
);
});
it('correctly serializes a table with block content', () => {
expect(
serialize(
table(
tableRow(
tableHeader(paragraph('examples of')),
tableHeader(paragraph('block content')),
tableHeader(paragraph('in tables')),
tableHeader(paragraph('in content editor')),
),
tableRow(
tableCell(heading({ level: 1 }, 'heading 1')),
tableCell(heading({ level: 2 }, 'heading 2')),
tableCell(paragraph(bold('just bold'))),
tableCell(paragraph(bold('bold'), ' ', italic('italic'), ' ', code('code'))),
),
tableRow(
tableCell(
paragraph('all marks in three paragraphs:'),
paragraph('the ', bold('quick'), ' ', italic('brown'), ' ', code('fox')),
paragraph(
link({ href: '/home' }, 'jumps'),
' over the ',
strike('lazy'),
' ',
emoji({ name: 'dog' }),
),
),
tableCell(
paragraph(image({ src: 'img.jpg', alt: 'some image' }), hardBreak(), 'image content'),
),
tableCell(
blockquote('some text', hardBreak(), hardBreak(), 'in a multiline blockquote'),
),
tableCell(
codeBlock(
{ language: 'javascript' },
'var a = 2;\nvar b = 3;\nvar c = a + d;\n\nconsole.log(c);',
),
),
),
tableRow(
tableCell(bulletList(listItem('item 1'), listItem('item 2'), listItem('item 2'))),
tableCell(orderedList(listItem('item 1'), listItem('item 2'), listItem('item 2'))),
tableCell(
paragraph('paragraphs separated by'),
horizontalRule(),
paragraph('a horizontal rule'),
),
tableCell(
table(
tableRow(tableHeader(paragraph('table')), tableHeader(paragraph('inside'))),
tableRow(tableCell(paragraph('another')), tableCell(paragraph('table'))),
),
),
),
),
).trim(),
).toBe(
`
<table>
<tr>
<th>examples of</th>
<th>block content</th>
<th>in tables</th>
<th>in content editor</th>
</tr>
<tr>
<td>
# heading 1
</td>
<td>
## heading 2
</td>
<td>
**just bold**
</td>
<td>
**bold** _italic_ \`code\`
</td>
</tr>
<tr>
<td>
all marks in three paragraphs:
the **quick** _brown_ \`fox\`
[jumps](/home) over the ~~lazy~~ :dog:
</td>
<td>
![some image](img.jpg)<br>image content
</td>
<td>
> some text\\
> \\
> in a multiline blockquote
</td>
<td>
\`\`\`javascript
var a = 2;
var b = 3;
var c = a + d;
console.log(c);
\`\`\`
</td>
</tr>
<tr>
<td>
* item 1
* item 2
* item 2
</td>
<td>
1. item 1
2. item 2
3. item 2
</td>
<td>
paragraphs separated by
---
a horizontal rule
</td>
<td>
| table | inside |
|-------|--------|
| another | table |
</td>
</tr>
</table>
`.trim(),
);
});
it('correctly renders content after a markdown table', () => {
expect(
serialize(
table(tableRow(tableHeader(paragraph('header'))), tableRow(tableCell(paragraph('cell')))),
heading({ level: 1 }, 'this is a heading'),
).trim(),
).toBe(
`
| header |
|--------|
| cell |
# this is a heading
`.trim(),
);
});
it('correctly renders content after an html table', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('header'))),
tableRow(tableCell(blockquote('hi'), paragraph('there'))),
),
heading({ level: 1 }, 'this is a heading'),
).trim(),
).toBe(
`
<table>
<tr>
<th>header</th>
</tr>
<tr>
<td>
> hi
there
</td>
</tr>
</table>
# this is a heading
`.trim(),
);
});
it('correctly serializes tables with misplaced header cells', () => {
expect(
serialize(
table(
tableRow(tableHeader(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableHeader(paragraph('cell'))),
),
).trim(),
).toBe(
`
<table>
<tr>
<th>cell</th>
<td>cell</td>
</tr>
<tr>
<td>cell</td>
<th>cell</th>
</tr>
</table>
`.trim(),
);
});
it('correctly serializes table without any headers', () => {
expect(
serialize(
table(
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
tableRow(tableCell(paragraph('cell')), tableCell(paragraph('cell'))),
),
).trim(),
).toBe(
`
<table>
<tr>
<td>cell</td>
<td>cell</td>
</tr>
<tr>
<td>cell</td>
<td>cell</td>
</tr>
</table>
`.trim(),
);
});
it('correctly serializes table with rowspan and colspan', () => {
expect(
serialize(
table(
tableRow(
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
tableHeader(paragraph('header')),
),
tableRow(
tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2')),
tableCell({ rowspan: 2 }, paragraph('cell')),
),
tableRow(tableCell({ colspan: 2 }, paragraph('cell with rowspan: 2'))),
),
).trim(),
).toBe(
`
<table>
<tr>
<th>header</th>
<th>header</th>
<th>header</th>
</tr>
<tr>
<td colspan="2">cell with rowspan: 2</td>
<td rowspan="2">cell</td>
</tr>
<tr>
<td colspan="2">cell with rowspan: 2</td>
</tr>
</table>
`.trim(),
);
});
});

View File

@ -102,14 +102,10 @@
markdown: |-
| header | header |
|--------|--------|
| cell | cell |
| cell | cell |
- name: table_with_alignment
markdown: |-
| header | : header : | header : |
|--------|------------|----------|
| cell | cell | cell |
| cell | cell | cell |
| `code` | cell with **bold** |
| ~~strike~~ | cell with _italic_ |
# content after table
- name: emoji
markdown: ':sparkles: :heart: :100:'
- name: reference

View File

@ -148,13 +148,13 @@ RSpec.describe Gitlab::SearchResults do
end
end
it 'includes merge requests from source and target projects' do
it 'does not include merge requests from source projects' do
forked_project = fork_project(project, user)
merge_request_2 = create(:merge_request, target_project: project, source_project: forked_project, title: 'foo')
results = described_class.new(user, 'foo', Project.where(id: forked_project.id))
expect(results.objects('merge_requests')).to include merge_request_2
expect(results.objects('merge_requests')).not_to include merge_request_2
end
describe '#merge_requests' do