Commit fcc43e30 authored by Alexandru Croitor's avatar Alexandru Croitor

Rolldown issue and merge request templates to projects in the group

Description templates are a variation of file templates, however
description templates were only visible at the project level in which
those were defined.

This implements the ability to inherit description templates from
projects that define description templates and are set as template
repository at group level.

This will allow to display both inherited description templates
from parent group(s) and instance down to project's own own
description templates

Edit issue is using a different FE(vue app) to be redered vs new issue
form. Update templates controller to return the right templates and in
corresponding format, i.e. hash instead of array.

Fix the edge case when templtes in different groups have the same name,
so that it would be correctly resolved to the correct template.
parent 9f527b47
......@@ -66,6 +66,8 @@ export default class FileTemplateSelector {
reportSelectionName(options) {
const opts = options;
opts.query = options.selectedObj.name;
opts.data = options.selectedObj;
opts.data.source_template_project_id = options.selectedObj.project_id;
this.reportSelection(opts);
}
......
......@@ -30,6 +30,7 @@ export default class BlobLicenseSelector extends FileTemplateSelector {
const data = {
project: this.$dropdown.data('project'),
fullname: this.$dropdown.data('fullname'),
source_template_project_id: query.project_id,
};
this.reportSelection({
......
......@@ -437,6 +437,7 @@ export class GitLabDropdown {
groupName = el.data('group');
if (groupName) {
selectedIndex = el.data('index');
this.selectedIndex = selectedIndex;
selectedObject = this.renderedData[groupName][selectedIndex];
} else {
selectedIndex = el.closest('li').index();
......
......@@ -51,7 +51,7 @@ export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selec
export const TAKING_INCIDENT_ACTION_DOCS_LINK =
'/help/operations/metrics/alerts#trigger-actions-from-alerts';
export const ISSUE_TEMPLATES_DOCS_LINK =
'/help/user/project/description_templates#creating-issue-templates';
'/help/user/project/description_templates#create-an-issue-template';
/* PagerDuty integration settings constants */
......
......@@ -132,6 +132,10 @@ export default {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
projectNamespace: {
type: String,
required: true,
......@@ -303,7 +307,7 @@ export default {
});
},
updateAndShowForm(templates = []) {
updateAndShowForm(templates = {}) {
if (!this.showForm) {
this.showForm = true;
this.store.setFormState({
......@@ -419,6 +423,7 @@ export default {
:markdown-docs-path="markdownDocsPath"
:markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-id="projectId"
:project-namespace="projectNamespace"
:show-delete-button="showDeleteButton"
:can-attach-file="canAttachFile"
......
......@@ -13,14 +13,18 @@ export default {
required: true,
},
issuableTemplates: {
type: Array,
type: Object,
required: false,
default: () => [],
default: () => {},
},
projectPath: {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
projectNamespace: {
type: String,
required: true,
......@@ -48,11 +52,12 @@ export default {
</script>
<template>
<div class="dropdown js-issuable-selector-wrap" data-issuable-type="issue">
<div class="dropdown js-issuable-selector-wrap" data-issuable-type="issues">
<button
ref="toggle"
:data-namespace-path="projectNamespace"
:data-project-path="projectPath"
:data-project-id="projectId"
:data-data="issuableTemplatesJson"
class="dropdown-menu-toggle js-issuable-selector"
type="button"
......
......@@ -26,9 +26,9 @@ export default {
required: true,
},
issuableTemplates: {
type: Array,
type: Object,
required: false,
default: () => [],
default: () => {},
},
issuableType: {
type: String,
......@@ -46,6 +46,10 @@ export default {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
projectNamespace: {
type: String,
required: true,
......@@ -68,7 +72,7 @@ export default {
},
computed: {
hasIssuableTemplates() {
return this.issuableTemplates.length;
return Object.values(Object(this.issuableTemplates)).length;
},
showLockedWarning() {
return this.formState.lockedWarningVisible && !this.formState.updateLoading;
......@@ -127,6 +131,7 @@ export default {
:form-state="formState"
:issuable-templates="issuableTemplates"
:project-path="projectPath"
:project-id="projectId"
:project-namespace="projectNamespace"
/>
</div>
......
......@@ -54,6 +54,7 @@ export function initIssueHeaderActions(store) {
issueType: el.dataset.issueType,
newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath,
projectId: el.dataset.projectId,
reportAbusePath: el.dataset.reportAbusePath,
submitAsSpamPath: el.dataset.submitAsSpamPath,
},
......
......@@ -11,7 +11,7 @@ export default class Store {
lockedWarningVisible: false,
updateLoading: false,
lock_version: 0,
issuableTemplates: [],
issuableTemplates: {},
};
}
......
......@@ -9,6 +9,7 @@ export default class IssuableTemplateSelector extends TemplateSelector {
constructor(...args) {
super(...args);
this.projectId = this.dropdown.data('projectId');
this.projectPath = this.dropdown.data('projectPath');
this.namespacePath = this.dropdown.data('namespacePath');
this.issuableType = this.$dropdownContainer.data('issuableType');
......@@ -81,21 +82,21 @@ export default class IssuableTemplateSelector extends TemplateSelector {
}
requestFile(query) {
this.startLoadingSpinner();
Api.issueTemplate(
this.namespacePath,
this.projectPath,
query.name,
this.issuableType,
(err, currentTemplate) => {
const callback = (currentTemplate) => {
this.currentTemplate = currentTemplate;
this.stopLoadingSpinner();
if (err) return; // Error handled by global AJAX error handler
this.setInputValueToTemplateContent();
},
};
this.startLoadingSpinner();
Api.projectTemplate(
this.projectId,
this.issuableType,
query.name,
{ source_template_project_id: query.project_id },
callback,
);
return;
}
setInputValueToTemplateContent() {
......
# frozen_string_literal: true
class Projects::TemplatesController < Projects::ApplicationController
include IssuablesDescriptionTemplatesHelper
before_action :authenticate_user!
before_action :authorize_can_read_issuable!
before_action :get_template_class
......@@ -24,10 +26,8 @@ class Projects::TemplatesController < Projects::ApplicationController
end
def names
templates = @template_type.dropdown_names(project)
respond_to do |format|
format.json { render json: templates }
format.json { render json: issuable_templates(project, params[:template_type]) }
end
end
......
......@@ -36,6 +36,7 @@ class LicenseTemplateFinder
LicenseTemplate.new(
key: license.key,
name: license.name,
project: project,
nickname: license.nickname,
category: (license.featured? ? :Popular : :Other),
content: license.content,
......
......@@ -199,7 +199,7 @@ module BlobHelper
categories.each_with_object({}) do |category, hash|
hash[category] = grouped[category].map do |item|
{ name: item.name, id: item.key }
{ name: item.name, id: item.key, project_id: item.project_id }
end
end
end
......
# frozen_string_literal: true
module IssuablesDescriptionTemplatesHelper
include Gitlab::Utils::StrongMemoize
include GitlabRoutingHelper
def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template"
options = {
toggle_class: 'js-issuable-selector',
title: title,
filter: true,
placeholder: 'Filter',
footer_content: true,
data: {
data: issuable_templates(ref_project, issuable.to_ability_name),
field_name: 'issuable_template',
selected: selected_template(issuable),
project_id: ref_project.id,
project_path: ref_project.path,
namespace_path: ref_project.namespace.full_path
}
}
dropdown_tag(title, options: options) do
capture(&block)
end
end
def issuable_templates(project, issuable_type)
strong_memoize(:issuable_templates) do
supported_issuable_types = %w[issue merge_request]
next [] unless supported_issuable_types.include?(issuable_type)
template_dropdown_names(TemplateFinder.build(issuable_type.pluralize.to_sym, project).execute)
end
end
private
def issuable_templates_names(issuable)
issuable_templates(ref_project, issuable.to_ability_name).map { |template| template[:name] }
end
def selected_template(issuable)
params[:issuable_template] if issuable_templates(ref_project, issuable.to_ability_name).values.flatten.any? { |template| template[:name] == params[:issuable_template] }
end
def template_names_path(parent, issuable)
return '' unless parent.is_a?(Project)
project_template_names_path(parent, template_type: issuable.to_ability_name)
end
def template_dropdown_names(items)
grouped = items.group_by(&:category)
categories = grouped.keys
categories.each_with_object({}) do |category, hash|
hash[category] = grouped[category].map do |item|
{ name: item.name, id: item.key, project_id: item.try(:project_id) }
end
end
end
end
......@@ -2,6 +2,7 @@
module IssuablesHelper
include GitlabRoutingHelper
include IssuablesDescriptionTemplatesHelper
def sidebar_gutter_toggle_icon
content_tag(:span, class: 'js-sidebar-toggle-container', data: { is_expanded: !sidebar_gutter_collapsed? }) do
......@@ -75,28 +76,6 @@ module IssuablesHelper
.to_json
end
def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template"
options = {
toggle_class: 'js-issuable-selector',
title: title,
filter: true,
placeholder: 'Filter',
footer_content: true,
data: {
data: issuable_templates(issuable),
field_name: 'issuable_template',
selected: selected_template(issuable),
project_path: ref_project.path,
namespace_path: ref_project.namespace.full_path
}
}
dropdown_tag(title, options: options) do
capture(&block)
end
end
def users_dropdown_label(selected_users)
case selected_users.length
when 0
......@@ -294,6 +273,7 @@ module IssuablesHelper
{
projectPath: ref_project.path,
projectId: ref_project.id,
projectNamespace: ref_project.namespace.full_path
}
end
......@@ -369,24 +349,6 @@ module IssuablesHelper
cookies[:collapsed_gutter] == 'true'
end
def issuable_templates(issuable)
@issuable_templates ||=
case issuable
when Issue
ref_project.repository.issue_template_names
when MergeRequest
ref_project.repository.merge_request_template_names
end
end
def issuable_templates_names(issuable)
issuable_templates(issuable).map { |template| template[:name] }
end
def selected_template(issuable)
params[:issuable_template] if issuable_templates(issuable).any? { |template| template[:name] == params[:issuable_template] }
end
def issuable_todo_button_data(issuable, is_collapsed)
{
todo_text: _('Add a to do'),
......@@ -424,12 +386,6 @@ module IssuablesHelper
end
end
def template_names_path(parent, issuable)
return '' unless parent.is_a?(Project)
project_template_names_path(parent, template_type: issuable.class.name.underscore)
end
def issuable_sidebar_options(issuable)
{
endpoint: "#{issuable[:issuable_json_path]}?serializer=sidebar_extras",
......
......@@ -12,11 +12,12 @@ class LicenseTemplate
(fullname|name\sof\s(author|copyright\sowner))
[\>\}\]]}xi.freeze
attr_reader :key, :name, :category, :nickname, :url, :meta
attr_reader :key, :name, :project, :category, :nickname, :url, :meta
def initialize(key:, name:, category:, content:, nickname: nil, url: nil, meta: {})
def initialize(key:, name:, project:, category:, content:, nickname: nil, url: nil, meta: {})
@key = key
@name = name
@project = project
@category = category
@content = content
@nickname = nickname
......@@ -24,6 +25,22 @@ class LicenseTemplate
@meta = meta
end
def project_id
project&.id
end
def project_path
project&.path
end
def namespace_id
project&.namespace&.id
end
def namespace_path
project&.namespace&.full_path
end
def popular?
category == :Popular
end
......
- issuable = local_assigns.fetch(:issuable, nil)
- return unless issuable && issuable_templates(issuable).any?
- return unless issuable && issuable_templates(ref_project, issuable.class.name.underscore).any?
.issuable-form-select-holder.selectbox.form-group
.js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name } }
.js-issuable-selector-wrap{ data: { issuable_type: issuable.to_ability_name.pluralize } }
= template_dropdown_tag(issuable) do
%ul.dropdown-footer-list
%li
......
- issuable = local_assigns.fetch(:issuable)
- has_wip_commits = local_assigns.fetch(:has_wip_commits)
- form = local_assigns.fetch(:form)
- no_issuable_templates = issuable_templates(issuable).empty?
- no_issuable_templates = issuable_templates(ref_project, issuable.class.name.underscore).empty?
- div_class = no_issuable_templates ? 'col-sm-10' : 'col-sm-7 col-lg-8'
- toggle_wip_link_start = '<a href="" class="js-toggle-wip">'
- toggle_wip_link_end = '</a>'
......
......@@ -10,8 +10,8 @@ We have implemented standard features that depend on configuration files in the
When implementing new features, please refer to these existing features to avoid conflicts:
- [Custom Dashboards](../operations/metrics/dashboards/index.md#add-a-new-dashboard-to-your-project): `.gitlab/dashboards/`.
- [Issue Templates](../user/project/description_templates.md#creating-issue-templates): `.gitlab/issue_templates/`.
- [Merge Request Templates](../user/project/description_templates.md#creating-merge-request-templates): `.gitlab/merge_request_templates/`.
- [Issue Templates](../user/project/description_templates.md#create-an-issue-template): `.gitlab/issue_templates/`.
- [Merge Request Templates](../user/project/description_templates.md#create-a-merge-request-template): `.gitlab/merge_request_templates/`.
- [GitLab Kubernetes Agents](https://gitlab.com/gitlab-org/cluster-integration/gitlab-agent/-/blob/master/doc/configuration_repository.md#layout): `.gitlab/agents/`.
- [CODEOWNERS](../user/project/code_owners.md#how-to-set-up-code-owners): `.gitlab/CODEOWNERS`.
- [Route Maps](../ci/review_apps/#route-maps): `.gitlab/route-map.yml`.
......
......@@ -52,7 +52,7 @@ With Maintainer or higher [permissions](../../user/permissions.md), you can enab
1. Navigate to **Settings > Operations > Incidents** and expand **Incidents**.
1. Check the **Create an incident** checkbox.
1. To customize the incident, select an
[issue template](../../user/project/description_templates.md#creating-issue-templates).
[issue template](../../user/project/description_templates.md#create-an-issue-template).
1. To send [an email notification](alert_notifications.md#email-notifications) to users
with [Developer permissions](../../user/permissions.md), select
**Send a separate email notification to Developers**. Email notifications are
......
......@@ -753,6 +753,9 @@ To enable this feature, navigate to the group settings page, expand the
![Group file template settings](img/group_file_template_settings.png)
To learn how to create templates for issues and merge requests, visit
[Description templates](../project/description_templates.md).
#### Group-level project templates **(PREMIUM)**
Define project templates at a group level by setting a group as the template source.
......
......@@ -6,16 +6,13 @@ info: To determine the technical writer assigned to the Stage/Group associated w
# Description templates
>[Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/4981) in GitLab 8.11.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/4981) in GitLab 8.11.
We all know that a properly submitted issue is more likely to be addressed in
a timely manner by the developers of a project.
Description templates allow you to define context-specific templates for issue
and merge request description fields for your project, as well as help filter
out a lot of unnecessary noise from issues.
## Overview
With description templates, you can define context-specific templates for issue and merge request
description fields for your project, and filter out a lot of unnecessary noise from issues.
By using the description templates, users that create a new issue or merge
request can select a description template to help them communicate with other
......@@ -28,7 +25,10 @@ Description templates must be written in [Markdown](../markdown.md) and stored
in your project's repository under a directory named `.gitlab`. Only the
templates of the default branch are taken into account.
## Use-cases
To learn how to create templates for various file types in groups, visit
[Group file templates](../group/index.md#group-file-templates).
## Use cases
- Add a template to be used in every issue for a specific project,
giving instructions and guidelines, requiring for information specific to that subject.
......@@ -40,7 +40,7 @@ templates of the default branch are taken into account.
- You can also create issues and merge request templates for different
stages of your workflow, for example, feature proposal, feature improvement, or a bug report.
## Creating issue templates
## Create an issue template
Create a new Markdown (`.md`) file inside the `.gitlab/issue_templates/`
directory in your repository. Commit and push to your default branch.
......@@ -65,13 +65,13 @@ To create the `.gitlab/issue_templates` directory:
To check if this has worked correctly, [create a new issue](issues/managing_issues.md#create-a-new-issue)
and see if you can choose a description template.
## Creating merge request templates
## Create a merge request template
Similarly to issue templates, create a new Markdown (`.md`) file inside the
`.gitlab/merge_request_templates/` directory in your repository. Commit and
push to your default branch.
## Using the templates
## Use the templates
Let's take for example that you've created the file `.gitlab/issue_templates/Bug.md`.
This enables the `Bug` dropdown option when creating or editing issues. When
......@@ -80,15 +80,46 @@ to the issue description field. The **Reset template** button discards any
changes you made after picking the template and returns it to its initial status.
NOTE:
You can create short-cut links to create an issue using a designated template. For example: `https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Feature%20proposal`.
You can create shortcut links to create an issue using a designated template.
For example: `https://gitlab.com/gitlab-org/gitlab/-/issues/new?issuable_template=Feature%20proposal`.
![Description templates](img/description_templates.png)
## Setting a default template for merge requests and issues **(STARTER)**
### Set an issue and merge request description template at group level **(PREMIUM)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46222) in GitLab 13.8.
Templates are most useful, because you can create a template once and use it multiple times.
To re-use templates [you've created](../project/description_templates.md#create-an-issue-template):
1. Go to your project's `Settings > General > Templates`.
1. From the dropdown, select your template project as the template repository at group level.
![Group template settings](../group/img/group_file_template_settings.png)
### Set an issue and merge request description template at instance level **(PREMIUM ONLY)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46222) in GitLab 13.8.
Similar to group templates, issue and merge request templates can also be set up at the instance level.
This results in those templates being available in all projects within the instance.
Only instance administrators can set instance-level templates.
To set the instance-level description template repository:
1. Select the **Admin Area** icon (**{admin}**).
1. Select **Templates**.
1. From the dropdown, select your template project as the template repository at instance level.
> - This feature was introduced before [description templates](#overview) and is available in [GitLab Starter](https://about.gitlab.com/pricing/). It can be enabled in the project's settings.
> - Templates for issues were [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28) in GitLab EE 8.1.
> - Templates for merge requests were [introduced](https://gitlab.com/gitlab-org/gitlab/commit/7478ece8b48e80782b5465b96c79f85cc91d391b) in GitLab EE 6.9.
Learn more about [instance template repository](../admin_area/settings/instance_template_repository.md).
![Setting templates in the Admin Area](../admin_area/settings/img/file_template_admin_area.png)
### Set a default template for merge requests and issues **(STARTER)**
> - This feature was introduced before [description templates](#description-templates) and is available in [GitLab Starter](https://about.gitlab.com/pricing/). It can be enabled in the project's settings.
> - Templates for issues [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28) in GitLab EE 8.1.
> - Templates for merge requests [introduced](https://gitlab.com/gitlab-org/gitlab/commit/7478ece8b48e80782b5465b96c79f85cc91d391b) in GitLab EE 6.9.
The visibility of issues and/or merge requests should be set to either "Everyone
with access" or "Only Project Members" in your project's **Settings / Visibility, project features, permissions** section, otherwise the
......@@ -113,52 +144,47 @@ pre-filled with the text you entered in the template(s).
## Description template example
We make use of Description Templates for Issues and Merge Requests within the GitLab Community
Edition project. Please refer to the [`.gitlab` folder](https://gitlab.com/gitlab-org/gitlab/tree/master/.gitlab)
We make use of description templates for issues and merge requests in the GitLab project.
Please refer to the [`.gitlab` folder](https://gitlab.com/gitlab-org/gitlab/tree/master/.gitlab)
for some examples.
NOTE:
It's possible to use [quick actions](quick_actions.md) within description templates to quickly add
It's possible to use [quick actions](quick_actions.md) in description templates to quickly add
labels, assignees, and milestones. The quick actions are only executed if the user submitting
the issue or merge request has the permissions to perform the relevant actions.
Here is an example of a Bug report template:
```plaintext
Summary
```markdown
## Summary
(Summarize the bug encountered concisely)
Steps to reproduce
## Steps to reproduce
(How one can reproduce the issue - this is very important)
## Example Project
Example Project
(If possible, please create an example project here on GitLab.com that exhibits the problematic behaviour, and link to it here in the bug report)
(If possible, please create an example project here on GitLab.com that exhibits the problematic
behaviour, and link to it here in the bug report.
If you are using an older version of GitLab, this will also determine whether the bug has been fixed
in a more recent version)
(If you are using an older version of GitLab, this will also determine whether the bug has been fixed in a more recent version)
What is the current bug behavior?
## What is the current bug behavior?
(What actually happens)
What is the expected correct behavior?
## What is the expected correct behavior?
(What you should see instead)
## Relevant logs and/or screenshots
Relevant logs and/or screenshots
(Paste any relevant logs - please use code blocks (```) to format console output,
logs, and code as it's very hard to read otherwise.)
(Paste any relevant logs - please use code blocks (```) to format console output, logs, and code, as
it's very hard to read otherwise.)
Possible fixes
## Possible fixes
(If you can, link to the line of code that might be responsible for the problem)
......
......@@ -217,7 +217,7 @@ You can then see issue statuses in the [issue list](#issues-list) and the
## Other Issue actions
- [Create an issue from a template](../../project/description_templates.md#using-the-templates)
- [Create an issue from a template](../../project/description_templates.md#use-the-templates)
- [Set a due date](due_dates.md)
- [Bulk edit issues](../bulk_editing.md) - From the Issues List, select multiple issues
in order to change their status, assignee, milestone, or labels in bulk.
......
......@@ -102,7 +102,7 @@ To edit a file:
in the bottom-right corner.
1. When you're done, click **Submit changes...**.
1. (Optional) Adjust the default title and description of the merge request that will be submitted
with your changes. Alternatively, select a [merge request template](../../../user/project/description_templates.md#creating-merge-request-templates)
with your changes. Alternatively, select a [merge request template](../../../user/project/description_templates.md#create-a-merge-request-template)
from the dropdown menu and edit it accordingly.
1. Click **Submit changes**.
1. A new merge request is automatically created and you can assign a colleague for review.
......
......@@ -45,6 +45,7 @@ export default {
:endpoint="endpoint"
:update-endpoint="updateEndpoint"
:project-path="groupPath"
:project-id="0"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:can-update="canUpdate"
......
......@@ -19,7 +19,7 @@ module EE
return super unless custom_templates?
if params[:name]
custom_templates.find(params[:name]) || super
custom_templates.find(params[:name], params[:source_template_project_id]) || super
else
custom_templates.all + super
end
......
......@@ -8,7 +8,9 @@ module EE
dockerfiles: ::Gitlab::Template::CustomDockerfileTemplate,
gitignores: ::Gitlab::Template::CustomGitignoreTemplate,
gitlab_ci_ymls: ::Gitlab::Template::CustomGitlabCiYmlTemplate,
metrics_dashboard_ymls: ::Gitlab::Template::CustomMetricsDashboardYmlTemplate
metrics_dashboard_ymls: ::Gitlab::Template::CustomMetricsDashboardYmlTemplate,
issues: ::Gitlab::Template::IssueTemplate,
merge_requests: ::Gitlab::Template::MergeRequestTemplate
).freeze
attr_reader :custom_templates
......@@ -28,7 +30,7 @@ module EE
return super if custom_templates.nil? || !custom_templates.enabled?
if params[:name]
custom_templates.find(params[:name]) || super
custom_templates.find(params[:name], params[:source_template_project_id]) || super
else
custom_templates.all + super
end
......
......@@ -4,7 +4,7 @@
.settings-header
%h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Default description template for issues')
%button.gl-button.btn.btn-default.js-settings-toggle= expanded ? _('Collapse') : _('Expand')
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/description_templates', anchor: 'setting-a-default-template-for-merge-requests-and-issues') }
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/description_templates', anchor: 'set-a-default-template-for-merge-requests-and-issues') }
%p#issue-settings-default-template-label= _('Set a default description template to be used for new issues. %{link_start}What are description templates?%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }
.settings-content
......
---
title: Configure issue and merge request description templates at group level and
rolldown description templates in the group hierarchy
merge_request: 46222
author:
type: changed
......@@ -30,8 +30,10 @@ module Gitlab
by_namespace + by_instance
end
def find(name)
def find(name, source_template_project_id = nil)
namespace_template_projects_hash.each do |namespace, project|
next if source_template_project_id && project.id != source_template_project_id.to_i
found = template_for(project, name, category_for(namespace))
return found if found
end
......@@ -79,18 +81,18 @@ module Gitlab
def templates_for(project, category)
return [] unless project
finder.all(project).map { |template| translate(template, category: category) }
finder.all(project).map { |template| translate(template, project, category: category) }
end
def template_for(project, name, category)
return unless project
translate(finder.find(name, project), category: category)
translate(finder.find(name, project), project, category: category)
rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
nil
end
def translate(template, category:)
def translate(template, project, category:)
return unless template
template.category = category
......@@ -103,6 +105,7 @@ module Gitlab
LicenseTemplate.new(
key: template.key,
name: template.name,
project: project,
nickname: template.name,
category: template.category,
content: -> { template.content }
......
......@@ -7,7 +7,7 @@ RSpec.describe LicenseTemplateFinder do
let(:params) { {} }
let(:fake_template_source) { double(::Gitlab::CustomFileTemplates) }
let(:custom_template) { ::LicenseTemplate.new(key: 'foo', name: 'foo', category: nil, content: 'Template') }
let(:custom_template) { ::LicenseTemplate.new(key: 'foo', name: 'foo', project: project, category: nil, content: 'Template') }
let(:custom_templates) { [custom_template] }
subject(:finder) { described_class.new(project, params) }
......@@ -23,7 +23,7 @@ RSpec.describe LicenseTemplateFinder do
allow(fake_template_source)
.to receive(:find)
.with(custom_template.key)
.with(custom_template.key, nil)
.and_return(custom_template)
allow(fake_template_source)
......
......@@ -20,6 +20,8 @@ RSpec.describe TemplateFinder do
:dockerfiles | ::Gitlab::Template::CustomDockerfileTemplate
:gitignores | ::Gitlab::Template::CustomGitignoreTemplate
:gitlab_ci_ymls | ::Gitlab::Template::CustomGitlabCiYmlTemplate
:issues | ::Gitlab::Template::IssueTemplate
:merge_requests | ::Gitlab::Template::MergeRequestTemplate
end
with_them do
......@@ -33,7 +35,7 @@ RSpec.describe TemplateFinder do
allow(fake_template_source)
.to receive(:find)
.with(custom_template.key)
.with(custom_template.key, nil)
.and_return(custom_template)
allow(fake_template_source)
......
......@@ -32,7 +32,7 @@ RSpec.describe BlobHelper do
.and_return([OpenStruct.new(key: 'name', name: 'Name')])
expect(categories).to contain_exactly(:Popular, :Other, group_category)
expect(by_group).to contain_exactly({ id: 'name', name: 'Name' })
expect(by_group).to contain_exactly({ id: 'name', name: 'Name', project_id: project.id })
expect(by_popular).to be_present
expect(by_other).to be_present
end
......@@ -46,7 +46,7 @@ RSpec.describe BlobHelper do
.and_return([OpenStruct.new(key: 'name', name: 'Name')])
expect(categories).to contain_exactly(:Popular, :Other, 'Instance')
expect(by_instance).to contain_exactly({ id: 'name', name: 'Name' })
expect(by_instance).to contain_exactly({ id: 'name', name: 'Name', project_id: project.id })
expect(by_popular).to be_present
expect(by_other).to be_present
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IssuablesDescriptionTemplatesHelper do
include_context 'project issuable templates context'
describe '#issuable_templates' do
context 'when project parent group has a file template project' do
let_it_be(:user) { create(:user) }
let_it_be(:parent_group) { create(:group) }
let_it_be(:group) { create(:group, parent: parent_group) }
let_it_be(:project) { create(:project, :custom_repo, group: group, files: issuable_template_files) }
let_it_be(:file_template_project) { create(:project, :custom_repo, group: parent_group, files: issuable_template_files) }
let_it_be(:group_member) { create(:group_member, :developer, group: parent_group, user: user) }
let_it_be(:inherited_from) { file_template_project }
before do
stub_licensed_features(custom_file_templates_for_namespace: true)
parent_group.update_columns(file_template_project_id: file_template_project.id)
end
it_behaves_like 'project issuable templates'
end
end
end
......@@ -27,20 +27,34 @@ RSpec.describe "Custom file template classes" do
'Dockerfile/category/baz.txt' => 'CustomDockerfileTemplate category baz',
'gitignore/category/baz.txt' => 'CustomGitignoreTemplate category baz',
'gitlab-ci/category/baz.yml' => 'CustomGitlabCiYmlTemplate category baz',
'LICENSE/category/baz.txt' => 'CustomLicenseTemplate category baz'
'LICENSE/category/baz.txt' => 'CustomLicenseTemplate category baz',
'.gitlab/issue_templates/bar.md' => 'IssueTemplate Bar',
'.gitlab/issue_templates/foo.md' => 'IssueTemplate Foo',
'.gitlab/issue_templates/bad.txt' => 'IssueTemplate Bad',
'.gitlab/issue_templates/baz.xyz' => 'IssueTemplate Baz',
'.gitlab/merge_request_templates/bar.md' => 'MergeRequestTemplate Bar',
'.gitlab/merge_request_templates/foo.md' => 'MergeRequestTemplate Foo',
'.gitlab/merge_request_templates/bad.txt' => 'MergeRequestTemplate Bad',
'.gitlab/merge_request_templates/baz.xyz' => 'MergeRequestTemplate Baz'
}
let(:project) { create(:project, :custom_repo, files: files) }
[
::Gitlab::Template::CustomDockerfileTemplate,
::Gitlab::Template::CustomGitignoreTemplate,
::Gitlab::Template::CustomGitlabCiYmlTemplate,
::Gitlab::Template::CustomLicenseTemplate,
::Gitlab::Template::CustomMetricsDashboardYmlTemplate
].each do |template_class|
describe template_class do
let(:name) { template_class.name.demodulize }
custom_templates = [
{ class_name: ::Gitlab::Template::CustomDockerfileTemplate, category: 'Custom' },
{ class_name: ::Gitlab::Template::CustomGitignoreTemplate, category: 'Custom' },
{ class_name: ::Gitlab::Template::CustomGitlabCiYmlTemplate, category: 'Custom' },
{ class_name: ::Gitlab::Template::CustomLicenseTemplate, category: 'Custom' },
{ class_name: ::Gitlab::Template::CustomMetricsDashboardYmlTemplate, category: 'Custom' },
{ class_name: ::Gitlab::Template::IssueTemplate, category: 'Project Templates' },
{ class_name: ::Gitlab::Template::MergeRequestTemplate, category: 'Project Templates' }
].freeze
custom_templates.each do |template_class|
describe template_class[:class_name] do
let(:name) { template_class[:class_name].name.demodulize }
describe '.all' do
it 'returns all valid templates' do
......@@ -48,7 +62,7 @@ RSpec.describe "Custom file template classes" do
aggregate_failures do
expect(found.map(&:name)).to contain_exactly('foo', 'bar')
expect(found.map(&:category).uniq).to contain_exactly('Custom')
expect(found.map(&:category).uniq).to contain_exactly(template_class[:category])
end
end
end
......
......@@ -45,9 +45,10 @@ module API
get ':id/templates/:type/:name', requirements: TEMPLATE_NAMES_ENDPOINT_REQUIREMENTS do
begin
template = TemplateFinder
.build(params[:type], user_project, name: params[:name])
.execute
template = TemplateFinder.build(
params[:type], user_project, name: params[:name],
source_template_project_id: params[:source_template_project_id]
).execute
rescue ::Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError
not_found!('Template')
end
......
......@@ -8,6 +8,7 @@ module Gitlab
def initialize(path, project = nil, category: nil)
@path = path
@category = category
@project = project
@finder = self.class.finder(project)
end
......@@ -31,6 +32,22 @@ module Gitlab
# override with a comment to be placed at the top of the blob.
end
def project_id
@project&.id
end
def project_path
@project&.path
end
def namespace_id
@project&.namespace&.id
end
def namespace_path
@project&.namespace&.full_path
end
# Present for compatibility with license templates, which can replace text
# like `[fullname]` with a user-specified string. This is a no-op for
# other templates
......@@ -82,11 +99,11 @@ module Gitlab
raise NotImplementedError
end
def by_category(category, project = nil)
def by_category(category, project = nil, empty_category_title: nil)
directory = category_directory(category)
files = finder(project).list_files_for(directory)
files.map { |f| new(f, project, category: category) }.sort
files.map { |f| new(f, project, category: category.presence || empty_category_title) }.sort
end
def category_directory(category)
......
......@@ -15,6 +15,10 @@ module Gitlab
def finder(project)
Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories)
end
def by_category(category, project = nil, empty_category_title: nil)
super(category, project, empty_category_title: _('Project Templates'))
end
end
end
end
......
......@@ -15,6 +15,10 @@ module Gitlab
def finder(project)
Gitlab::Template::Finders::RepoTemplateFinder.new(project, self.base_dir, self.extension, self.categories)
end
def by_category(category, project = nil, empty_category_title: nil)
super(category, project, empty_category_title: _('Project Templates'))
end
end
end
end
......
......@@ -21828,6 +21828,9 @@ msgstr ""
msgid "Project ID"
msgstr ""
msgid "Project Templates"
msgstr ""
msgid "Project URL"
msgstr ""
......
......@@ -160,12 +160,12 @@ RSpec.describe Projects::TemplatesController do
end
shared_examples 'template names request' do
it 'returns the template names' do
it 'returns the template names', :aggregate_failures do
get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response.size).to eq(2)
expect(json_response).to match(expected_template_names)
expect(json_response['Project Templates'].size).to eq(2)
expect(json_response['Project Templates'].map { |x| { "name" => x['name'] } }).to match(expected_template_names)
end
it 'fails for user with no access' do
......
......@@ -35,7 +35,7 @@ exports[`Alert integration settings form default state should match the default
Incident template (optional)
<gl-link-stub
href="/help/user/project/description_templates#creating-issue-templates"
href="/help/user/project/description_templates#create-an-issue-template"
target="_blank"
>
<gl-icon-stub
......
......@@ -422,7 +422,9 @@ describe('Issuable output', () => {
});
it('shows the form if template names request is successful', () => {
const mockData = [{ name: 'Bug' }];
const mockData = {
test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
};
mock.onGet('/issuable-templates-path').reply(() => Promise.resolve([200, mockData]));
return wrapper.vm.requestTemplatesAndShowForm().then(() => {
......
......@@ -14,7 +14,10 @@ describe('Issue description template component', () => {
vm = new Component({
propsData: {
formState,
issuableTemplates: [{ name: 'test' }],
issuableTemplates: {
test: [{ name: 'test', id: 'test', project_path: '/', namespace_path: '/' }],
},
projectId: 1,
projectPath: '/',
projectNamespace: '/',
},
......@@ -23,7 +26,7 @@ describe('Issue description template component', () => {
it('renders templates as JSON array in data attribute', () => {
expect(vm.$el.querySelector('.js-issuable-selector').getAttribute('data-data')).toBe(
'[{"name":"test"}]',
'{"test":[{"name":"test","id":"test","project_path":"/","namespace_path":"/"}]}',
);
});
......
......@@ -19,6 +19,7 @@ describe('Inline edit form component', () => {
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectPath: '/',
projectId: 1,
projectNamespace: '/',
};
......@@ -42,7 +43,11 @@ describe('Inline edit form component', () => {
});
it('renders template selector when templates exists', () => {
createComponent({ issuableTemplates: ['test'] });
createComponent({
issuableTemplates: {
test: [{ name: 'test', id: 'test', project_path: 'test', namespace_path: 'test' }],
},
});
expect(vm.$el.querySelector('.js-issuable-selector-wrap')).not.toBeNull();
});
......
......@@ -52,6 +52,7 @@ export const appProps = {
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
projectId: 1,
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IssuablesDescriptionTemplatesHelper do
include_context 'project issuable templates context'
describe '#issuable_templates' do
let_it_be(:inherited_from) { nil }
let_it_be(:user) { create(:user) }
let_it_be(:parent_group) { create(:group) }
let_it_be(:project) { create(:project, :custom_repo, files: issuable_template_files) }
let_it_be(:group_member) { create(:group_member, :developer, group: parent_group, user: user) }
let_it_be(:project_member) { create(:project_member, :developer, user: user, project: project) }
context 'when project has no parent group' do
it_behaves_like 'project issuable templates'
end
context 'when project has parent group' do
before do
project.update!(group: parent_group)
end
context 'when project parent group does not have a file template project' do
it_behaves_like 'project issuable templates'
end
context 'when project parent group has a file template project' do
let_it_be(:file_template_project) { create(:project, :custom_repo, group: parent_group, files: issuable_template_files) }
let_it_be(:group) { create(:group, parent: parent_group) }
let_it_be(:project) { create(:project, :custom_repo, group: group, files: issuable_template_files) }
before do
project.update!(group: group)
parent_group.update_columns(file_template_project_id: file_template_project.id)
end
it_behaves_like 'project issuable templates'
end
end
end
end
......@@ -199,6 +199,7 @@ RSpec.describe IssuablesHelper do
markdownDocsPath: '/help/user/markdown',
lockVersion: issue.lock_version,
projectPath: @project.path,
projectId: @project.id,
projectNamespace: @project.namespace.path,
initialTitleHtml: issue.title,
initialTitleText: issue.title,
......
......@@ -57,6 +57,6 @@ RSpec.describe LicenseTemplate do
end
def build_template(content)
described_class.new(key: 'foo', name: 'foo', category: :Other, content: content)
described_class.new(key: 'foo', name: 'foo', project: nil, category: :Other, content: content)
end
end
# frozen_string_literal: true
RSpec.shared_context 'project issuable templates context' do
let_it_be(:issuable_template_files) do
{
'.gitlab/issue_templates/issue-bar.md' => 'Issue Template Bar',
'.gitlab/issue_templates/issue-foo.md' => 'Issue Template Foo',
'.gitlab/issue_templates/issue-bad.txt' => 'Issue Template Bad',
'.gitlab/issue_templates/issue-baz.xyz' => 'Issue Template Baz',
'.gitlab/merge_request_templates/merge_request-bar.md' => 'Merge Request Template Bar',
'.gitlab/merge_request_templates/merge_request-foo.md' => 'Merge Request Template Foo',
'.gitlab/merge_request_templates/merge_request-bad.txt' => 'Merge Request Template Bad',
'.gitlab/merge_request_templates/merge_request-baz.xyz' => 'Merge Request Template Baz'
}
end
end
RSpec.shared_examples 'project issuable templates' do
context 'issuable templates' do
before do
allow(helper).to receive(:current_user).and_return(user)
end
it 'returns only md files as issue templates' do
expect(helper.issuable_templates(project, 'issue')).to eq(expected_templates('issue'))
end
it 'returns only md files as merge_request templates' do
expect(helper.issuable_templates(project, 'merge_request')).to eq(expected_templates('merge_request'))
end
end
def expected_templates(issuable_type)
expectation = {}
expectation["Project Templates"] = templates(issuable_type, project)
expectation["Group #{inherited_from.namespace.full_name}"] = templates(issuable_type, inherited_from) if inherited_from.present?
expectation
end
def templates(issuable_type, inherited_from)
[
{ id: "#{issuable_type}-bar", name: "#{issuable_type}-bar", project_id: inherited_from.id },
{ id: "#{issuable_type}-foo", name: "#{issuable_type}-foo", project_id: inherited_from.id }
]
end
end
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