Commit c920712f authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 0ff031c7
<script>
/* eslint-disable @gitlab/vue-i18n/no-bare-strings */
import _ from 'underscore';
import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import { GlTooltipDirective, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
......@@ -9,19 +9,21 @@ import { __, n__, sprintf } from '~/locale';
import { slugify } from '~/lib/utils/text_utility';
import { getLocationHash } from '~/lib/utils/url_utility';
import { scrollToElement } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
name: 'ReleaseBlock',
components: {
GlLink,
GlBadge,
GlButton,
Icon,
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
mixins: [timeagoMixin, glFeatureFlagsMixin()],
props: {
release: {
type: Object,
......@@ -72,6 +74,11 @@ export default {
labelText() {
return n__('Milestone', 'Milestones', this.release.milestones.length);
},
shouldShowEditButton() {
return Boolean(
this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit,
);
},
},
mounted() {
const hash = getLocationHash();
......@@ -89,12 +96,23 @@ export default {
<template>
<div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block">
<div class="card-body">
<h2 class="card-title mt-0">
<div class="d-flex align-items-start">
<h2 class="card-title mt-0 mr-auto">
{{ release.name }}
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{
__('Upcoming Release')
}}</gl-badge>
</h2>
<gl-link
v-if="shouldShowEditButton"
v-gl-tooltip
class="btn btn-default js-edit-button ml-2"
:title="__('Edit this release')"
:href="release._links.edit"
>
<icon name="pencil" />
</gl-link>
</div>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
......
......@@ -4,6 +4,9 @@ class Projects::ReleasesController < Projects::ApplicationController
# Authorize
before_action :require_non_empty_project
before_action :authorize_read_release!
before_action do
push_frontend_feature_flag(:release_edit_page, project)
end
def index
end
......
......@@ -28,8 +28,8 @@ module Issuable
TITLE_LENGTH_MAX = 255
TITLE_HTML_LENGTH_MAX = 800
DESCRIPTION_LENGTH_MAX = 16000
DESCRIPTION_HTML_LENGTH_MAX = 48000
DESCRIPTION_LENGTH_MAX = 1.megabyte
DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
......
---
title: Add edit button to release blocks on Releases page
merge_request: 18411
author:
type: added
......@@ -119,7 +119,8 @@ Use:
- The `project` keyword to specify the full path to a downstream project.
- The `branch` keyword to specify the name of a branch in the project specified by `project`.
Variable expansion is supported.
[From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/10126), variable expansion is
supported.
GitLab will use a commit that is currently on the HEAD of the branch when
creating a downstream pipeline.
......
......@@ -1086,13 +1086,51 @@ Manual actions are considered to be write actions, so permissions for
[protected branches](../../user/project/protected_branches.md) are used when
a user wants to trigger an action. In other words, in order to trigger a manual
action assigned to a branch that the pipeline is running for, the user needs to
have the ability to merge to this branch.
have the ability to merge to this branch. It is possible to use protected environments
to more strictly [protect manual deployments](#protecting-manual-jobs) from being
run by unauthorized users.
NOTE: **Note:**
Using `when:manual` and `trigger` together results in the error `jobs:#{job-name} when
should be on_success, on_failure or always`, because `when:manual` prevents triggers
being used.
##### Protecting manual jobs
It's possible to use [protected environments](../environments/protected_environments.md)
to define a precise list of users authorized to run a manual job. By allowing only
users associated with a protected environment to trigger manual jobs, it is possible
to implement some special use cases, such as:
- more precisely limiting who can deploy to an environment.
- enabling a pipeline to be blocked until an approved user "approves" it.
To do this, you must add an environment to the job. For example:
```yaml
deploy_prod:
stage: deploy
script:
- echo "Deploy to production server"
environment:
name: production
url: https://example.com
when: manual
only:
- master
```
Then, in the [protected environments settings](../environments/protected_environments.md#protecting-environments),
select the environment (`production` in the example above) and add the users, roles or groups
that are authorized to trigger the manual job to the **Allowed to Deploy** list. Only those in
this list will be able to trigger this manual job, as well as GitLab admins who are always able
to use protected environments.
Additionally, if a manual job is defined as blocking by adding `allow_failure: false`,
the next stages of the pipeline will not run until the manual job is triggered. This
can be used as a way to have a defined list of users allowed to "approve" later pipeline
stages by triggering the blocking manual job.
#### `when:delayed`
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/21767) in GitLab 11.4.
......
......@@ -539,3 +539,8 @@ it 'returns a successful response' do
expect(graphql_mutation_response(:merge_request_set_wip)['errors']).to be_empty
end
```
## Documentation
For information on generating GraphQL documentation, see
[Rake tasks related to GraphQL](rake_tasks.md#update-graphql-documentation).
......@@ -11,8 +11,8 @@ There are max length constraints for the most important text fields for `Issuabl
- `title`: 255 chars
- `title_html`: 800 chars
- `description`: 16000 chars
- `description_html`: 48000 chars
- `description`: 1 megabyte
- `description_html`: 5 megabytes
[Issue]: https://docs.gitlab.com/ee/user/project/issues
[Merge Requests]: https://docs.gitlab.com/ee/user/project/merge_requests
......
......@@ -220,3 +220,26 @@ bundle exec rake db:obsolete_ignored_columns
```
Feel free to remove their definitions from their `ignored_columns` definitions.
## Update GraphQL Documentation
To generate GraphQL documentation based on the GitLab schema, run:
```shell
bundle exec rake gitlab:graphql:compile_docs
```
In its current state, the rake task:
- Generates output for GraphQL objects.
- Places the output at `docs/api/graphql/reference/index.md`.
This uses some features from `graphql-docs` gem like its schema parser and helper methods.
The docs generator code comes from our side giving us more flexibility, like using Haml templates and generating Markdown files.
To edit the template used, please take a look at `lib/gitlab/graphql/docs/templates/default.md.haml`.
The actual renderer is at `Gitlab::Graphql::Docs::Renderer`.
`@parsed_schema` is an instance variable that the `graphql-docs` gem expects to have available.
`Gitlab::Graphql::Docs::Helper` defines the `object` method we currently use. This is also where you should implement any
new methods for new types you'd like to display.
......@@ -5805,6 +5805,9 @@ msgstr ""
msgid "Edit stage"
msgstr ""
msgid "Edit this release"
msgstr ""
msgid "Edit wiki page"
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Release block with default props matches the snapshot 1`] = `
<div
class="card release-block"
id="v0.3"
>
<div
class="card-body"
>
<div
class="d-flex align-items-start"
>
<h2
class="card-title mt-0 mr-auto"
>
New release
<!---->
</h2>
<a
class="btn btn-default js-edit-button ml-2"
data-original-title="Edit this release"
href="http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit"
title=""
>
<svg
aria-hidden="true"
class="s16 ic-pencil"
>
<use
xlink:href="#pencil"
/>
</svg>
</a>
</div>
<div
class="card-subtitle d-flex flex-wrap text-secondary"
>
<div
class="append-right-8"
>
<svg
aria-hidden="true"
class="align-middle s16 ic-commit"
>
<use
xlink:href="#commit"
/>
</svg>
<span
data-original-title="Initial commit"
title=""
>
c22b0728
</span>
</div>
<div
class="append-right-8"
>
<svg
aria-hidden="true"
class="align-middle s16 ic-tag"
>
<use
xlink:href="#tag"
/>
</svg>
<span
data-original-title="Tag"
title=""
>
v0.3
</span>
</div>
<div
class="js-milestone-list-label"
>
<svg
aria-hidden="true"
class="align-middle s16 ic-flag"
>
<use
xlink:href="#flag"
/>
</svg>
<span
class="js-label-text"
>
Milestones
</span>
</div>
<a
class="append-right-4 prepend-left-4 js-milestone-link"
data-original-title="The 13.6 milestone!"
href="http://0.0.0.0:3001/root/release-test/-/milestones/2"
title=""
>
13.6
</a>
<a
class="append-right-4 prepend-left-4 js-milestone-link"
data-original-title="The 13.5 milestone!"
href="http://0.0.0.0:3001/root/release-test/-/milestones/1"
title=""
>
13.5
</a>
<!---->
<div
class="append-right-4"
>
<span
data-original-title="Aug 26, 2019 5:54pm GMT+0000"
title=""
>
released 1 month ago
</span>
</div>
<div
class="d-flex"
>
by
<a
class="user-avatar-link prepend-left-4"
href=""
>
<span>
<img
alt="root's avatar"
class="avatar s20 "
data-original-title=""
data-src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
height="20"
src="https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
title=""
width="20"
/>
<div
aria-hidden="true"
class="js-user-avatar-image-toolip d-none"
style="display: none;"
>
<div>
root
</div>
</div>
</span>
<!---->
</a>
</div>
</div>
<div
class="card-text prepend-top-default"
>
<b>
Assets
<span
class="js-assets-count badge badge-pill"
>
5
</span>
</b>
<ul
class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list"
>
<li
class="append-bottom-8"
>
<a
class=""
data-original-title="Download asset"
href="https://google.com"
title=""
>
<svg
aria-hidden="true"
class="align-middle append-right-4 align-text-bottom s16 ic-package"
>
<use
xlink:href="#package"
/>
</svg>
my link
<span>
(external source)
</span>
</a>
</li>
<li
class="append-bottom-8"
>
<a
class=""
data-original-title="Download asset"
href="https://gitlab.com/gitlab-org/gitlab-foss/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50"
title=""
>
<svg
aria-hidden="true"
class="align-middle append-right-4 align-text-bottom s16 ic-package"
>
<use
xlink:href="#package"
/>
</svg>
my second link
<!---->
</a>
</li>
</ul>
<div
class="dropdown"
>
<button
aria-expanded="false"
aria-haspopup="true"
class="btn btn-link"
data-toggle="dropdown"
type="button"
>
<svg
aria-hidden="true"
class="align-top append-right-4 s16 ic-doc-code"
>
<use
xlink:href="#doc-code"
/>
</svg>
Source code
<svg
aria-hidden="true"
class="s16 ic-arrow-down"
>
<use
xlink:href="#arrow-down"
/>
</svg>
</button>
<div
class="js-sources-dropdown dropdown-menu"
>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip"
>
Download zip
</a>
</li>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz"
>
Download tar.gz
</a>
</li>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2"
>
Download tar.bz2
</a>
</li>
<li>
<a
class=""
href="http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar"
>
Download tar
</a>
</li>
</div>
</div>
</div>
<div
class="card-text prepend-top-default"
>
<div>
<p
data-sourcepos="1:1-1:21"
dir="auto"
>
A super nice release!
</p>
</div>
</div>
</div>
</div>
`;
......@@ -19,46 +19,53 @@ jest.mock('~/lib/utils/common_utils', () => ({
describe('Release block', () => {
let wrapper;
let releaseClone;
const factory = releaseProp => {
const factory = (releaseProp, releaseEditPageFeatureFlag = true) => {
wrapper = mount(ReleaseBlock, {
propsData: {
release: releaseProp,
},
provide: {
glFeatures: {
releaseEditPage: releaseEditPageFeatureFlag,
},
},
sync: false,
});
return wrapper.vm.$nextTick();
};
const milestoneListLabel = () => wrapper.find('.js-milestone-list-label');
const editButton = () => wrapper.find('.js-edit-button');
beforeEach(() => {
releaseClone = JSON.parse(JSON.stringify(release));
});
afterEach(() => {
wrapper.destroy();
});
describe('with default props', () => {
beforeEach(() => {
factory(release);
beforeEach(() => factory(release));
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it("renders the block with an id equal to the release's tag name", () => {
expect(wrapper.attributes().id).toBe('v0.3');
});
it('renders release name', () => {
expect(wrapper.text()).toContain(release.name);
it('renders an edit button that links to the "Edit release" page', () => {
expect(editButton().exists()).toBe(true);
expect(editButton().attributes('href')).toBe(release._links.edit);
});
it('renders commit sha', () => {
expect(wrapper.text()).toContain(release.commit.short_id);
wrapper.setProps({ release: { ...release, commit_path: '/commit/example' } });
expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true);
});
it('renders tag name', () => {
expect(wrapper.text()).toContain(release.tag_name);
wrapper.setProps({ release: { ...release, tag_path: '/tag/example' } });
expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true);
it('renders release name', () => {
expect(wrapper.text()).toContain(release.name);
});
it('renders release date', () => {
......@@ -141,45 +148,74 @@ describe('Release block', () => {
});
});
it('renders commit sha', () => {
releaseClone.commit_path = '/commit/example';
return factory(releaseClone).then(() => {
expect(wrapper.text()).toContain(release.commit.short_id);
expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true);
});
});
it('renders tag name', () => {
releaseClone.tag_path = '/tag/example';
return factory(releaseClone).then(() => {
expect(wrapper.text()).toContain(release.tag_name);
expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true);
});
});
it("does not render an edit button if release._links.edit isn't a string", () => {
delete releaseClone._links;
return factory(releaseClone).then(() => {
expect(editButton().exists()).toBe(false);
});
});
it('does not render an edit button if the releaseEditPage feature flag is disabled', () =>
factory(releaseClone, false).then(() => {
expect(editButton().exists()).toBe(false);
}));
it('does not render the milestone list if no milestones are associated to the release', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
delete releaseClone.milestones;
factory(releaseClone);
return factory(releaseClone).then(() => {
expect(milestoneListLabel().exists()).toBe(false);
});
});
it('renders the label as "Milestone" if only a single milestone is passed in', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.milestones = releaseClone.milestones.slice(0, 1);
factory(releaseClone);
return factory(releaseClone).then(() => {
expect(
milestoneListLabel()
.find('.js-label-text')
.text(),
).toEqual('Milestone');
});
});
it('renders upcoming release badge', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.upcoming_release = true;
factory(releaseClone);
return factory(releaseClone).then(() => {
expect(wrapper.text()).toContain('Upcoming Release');
});
});
it('slugifies the tag_name before setting it as the elements ID', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>';
factory(releaseClone);
return factory(releaseClone).then(() => {
expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script-');
});
});
describe('anchor scrolling', () => {
beforeEach(() => {
......@@ -190,40 +226,39 @@ describe('Release block', () => {
it('does not attempt to scroll the page if no anchor tag is included in the URL', () => {
mockLocationHash = '';
factory(release);
return factory(release).then(() => {
expect(scrollToElement).not.toHaveBeenCalled();
});
});
it("does not attempt to scroll the page if the anchor tag doesn't match the release's tag name", () => {
mockLocationHash = 'v0.4';
factory(release);
return factory(release).then(() => {
expect(scrollToElement).not.toHaveBeenCalled();
});
});
it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => {
mockLocationHash = release.tag_name;
factory(release);
return factory(release).then(() => {
expect(scrollToElement).toHaveBeenCalledTimes(1);
expect(scrollToElement).toHaveBeenCalledWith(wrapper.element);
});
});
it('renders with a light blue background if it is the target of the anchor', () => {
mockLocationHash = release.tag_name;
factory(release);
return wrapper.vm.$nextTick().then(() => {
return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(true);
});
});
it('does not render with a light blue background if it is not the target of the anchor', () => {
mockLocationHash = '';
factory(release);
return wrapper.vm.$nextTick().then(() => {
return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(false);
});
});
......
......@@ -94,4 +94,7 @@ export const release = {
},
],
},
_links: {
edit: 'http://0.0.0.0:3001/root/release-test/-/releases/v0.3/edit',
},
};
......@@ -45,8 +45,8 @@ describe Issuable do
it { is_expected.to validate_presence_of(:iid) }
it { is_expected.to validate_presence_of(:author) }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_length_of(:title).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(16_000).on(:create) }
it { is_expected.to validate_length_of(:title).is_at_most(described_class::TITLE_LENGTH_MAX) }
it { is_expected.to validate_length_of(:description).is_at_most(described_class::DESCRIPTION_LENGTH_MAX).on(:create) }
it_behaves_like 'validates description length with custom validation'
it_behaves_like 'truncates the description to its allowed maximum length on import'
......
......@@ -10,7 +10,7 @@ shared_examples_for 'matches_cross_reference_regex? fails fast' do
end
shared_examples_for 'validates description length with custom validation' do
let(:issuable) { build(:issue, description: 'x' * 16_001) }
let(:issuable) { build(:issue, description: 'x' * (::Issuable::DESCRIPTION_LENGTH_MAX + 1)) }
let(:context) { :update }
subject { issuable.validate(context) }
......@@ -18,7 +18,7 @@ shared_examples_for 'validates description length with custom validation' do
context 'when Issuable is a new record' do
it 'validates the maximum description length' do
subject
expect(issuable.errors[:description]).to eq(["is too long (maximum is 16000 characters)"])
expect(issuable.errors[:description]).to eq(["is too long (maximum is #{::Issuable::DESCRIPTION_LENGTH_MAX} characters)"])
end
context 'on create' do
......@@ -53,14 +53,14 @@ shared_examples_for 'truncates the description to its allowed maximum length on
allow(issuable).to receive(:importing?).and_return(true)
end
let(:issuable) { build(:issue, description: 'x' * 16_001) }
let(:issuable) { build(:issue, description: 'x' * (::Issuable::DESCRIPTION_LENGTH_MAX + 1)) }
subject { issuable.validate(:create) }
it 'truncates the description to its allowed maximum length' do
subject
expect(issuable.description).to eq('x' * 16_000)
expect(issuable.description).to eq('x' * ::Issuable::DESCRIPTION_LENGTH_MAX)
expect(issuable.errors[:description]).to be_empty
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