Commit 53bc903b authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'reference-feature-flags-from-issues-fe' into 'master'

Display Feature Flags Related to Issues

See merge request gitlab-org/gitlab!64538
parents 5261002c 5b79efa7
......@@ -20,6 +20,9 @@
#js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: issuable.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
- if can?(current_user, :admin_feature_flags_issue_links, @project)
= render_if_exists 'projects/issues/related_feature_flags'
- if can?(current_user, :download_code, @project)
- add_page_startup_api_call related_branches_path
#related-branches{ data: { url: related_branches_path } }
......
......@@ -392,8 +392,10 @@ end
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36617) in GitLab 13.2.
> - [Feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/251234) in GitLab 13.5.
> - [Updated](https://gitlab.com/gitlab-org/gitlab/-/issues/220333) in GitLab 14.1
You can link related issues to a feature flag. In the **Linked issues** section,
click the `+` button and input the issue reference number or the full URL of the issue.
The issue(s) will then appear in the related feautre flag and vice versa.
This feature is similar to the [linked issues](../user/project/issues/related_issues.md) feature.
import initRelatedFeatureFlags from 'ee/related_feature_flags';
import initSidebarBundle from 'ee/sidebar/sidebar_bundle';
import initShow from '~/pages/projects/issues/show';
......@@ -7,6 +8,7 @@ import UserCallout from '~/user_callout';
initShow();
initSidebarBundle();
initRelatedIssues();
initRelatedFeatureFlags();
// eslint-disable-next-line no-new
new UserCallout({ className: 'js-epics-sidebar-callout' });
......
<script>
import {
GlIcon,
GlLink,
GlLoadingIcon,
GlTruncate,
GlTooltipDirective as GlTooltip,
} from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
export default {
components: { GlIcon, GlLink, GlLoadingIcon, GlTruncate },
directives: {
GlTooltip,
},
inject: {
endpoint: { default: '' },
},
data() {
return {
featureFlags: [],
loading: true,
};
},
i18n: {
title: __('Related feature flags'),
error: __('There was an error loading related feature flags'),
active: __('Active'),
inactive: __('Inactive'),
},
computed: {
shouldShowRelatedFeatureFlags() {
return this.loading || this.numberOfFeatureFlags > 0;
},
cardHeaderClass() {
return { 'gl-border-b-0': this.numberOfFeatureFlags === 0 };
},
numberOfFeatureFlags() {
return this.featureFlags?.length ?? 0;
},
},
mounted() {
if (this.endpoint) {
axios
.get(this.endpoint)
.then(({ data }) => {
this.featureFlags = data;
})
.catch((error) =>
createFlash({
message: this.$options.i18n.error,
error,
}),
)
.finally(() => {
this.loading = false;
});
} else {
this.loading = false;
}
},
methods: {
icon({ active }) {
return active ? 'feature-flag' : 'feature-flag-disabled';
},
iconTooltip({ active }) {
return active ? this.$options.i18n.active : this.$options.i18n.inactive;
},
},
};
</script>
<template>
<div
v-if="shouldShowRelatedFeatureFlags"
id="related-feature-flags"
class="card card-slim gl-overflow-hidden"
>
<div
:class="cardHeaderClass"
class="card-header gl-display-flex gl-justify-content-start gl-align-items-center"
>
<h3 class="card-title gl-my-0 gl-display-flex gl-align-items-center gl-w-full gl-relative h5">
<gl-link
id="user-content-related-feature-flags"
class="anchor gl-text-decoration-none gl-absolute gl-mr-2"
href="#related-feature-flags"
aria-hidden="true"
/>
{{ $options.i18n.title }}
<gl-icon class="text-secondary gl-mr-2" name="feature-flag" />
<span class="h5">{{ numberOfFeatureFlags }}</span>
</h3>
</div>
<gl-loading-icon v-if="loading" class="gl-my-3" />
<ul v-else class="content-list related-items-list">
<li
v-for="flag in featureFlags"
:key="flag.id"
class="gl-display-flex"
data-testid="feature-flag-details"
>
<gl-icon
v-gl-tooltip
:name="icon(flag)"
:title="iconTooltip(flag)"
class="gl-mr-3"
data-testid="feature-flag-details-icon"
/>
<gl-link
v-gl-tooltip
:title="flag.name"
:href="flag.path"
class="gl-str-truncated"
data-testid="feature-flag-details-link"
>
<gl-truncate :text="flag.name" />
</gl-link>
<span
v-gl-tooltip
:title="flag.reference"
class="text-secondary gl-mt-3 gl-lg-mt-0 gl-lg-ml-3 gl-white-space-nowrap"
data-testid="feature-flag-details-reference"
>
<gl-truncate :text="flag.reference" />
</span>
</li>
</ul>
</div>
</template>
import Vue from 'vue';
import RelatedFeatureFlags from './components/related_feature_flags.vue';
export default function initRelatedFeatureFlags() {
const el = document.querySelector('#js-related-feature-flags-root');
if (el) {
/* eslint-disable-next-line no-new */
new Vue({
el,
provide: { endpoint: el.dataset.endpoint },
render(h) {
return h(RelatedFeatureFlags);
},
});
}
}
#js-related-feature-flags-root{ data: { endpoint: project_issue_feature_flags_path(@project, @issue) } }
import { GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import RelatedFeatureFlags from 'ee/related_feature_flags/components/related_feature_flags.vue';
import { TEST_HOST } from 'helpers/test_constants';
import { trimText } from 'helpers/text_helper';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
jest.mock('~/flash');
const ENDPOINT = `${TEST_HOST}/endpoint`;
const DEFAULT_PROVIDE = {
endpoint: ENDPOINT,
};
const MOCK_DATA = [
{
id: 5,
name: 'foo',
iid: 2,
active: true,
path: '/gitlab-org/gitlab-test/-/feature_flags/2',
reference: '[feature_flag:2]',
link_type: 'relates_to',
},
{
id: 2,
name: 'bar',
iid: 1,
active: false,
path: '/h5bp/html5-boilerplate/-/feature_flags/1',
reference: '[feature_flag:h5bp/html5-boilerplate/1]',
link_type: 'relates_to',
},
];
describe('ee/related_feature_flags/components/related_feature_flags.vue', () => {
let mock;
let wrapper;
const createWrapper = (provide = {}) => {
wrapper = mountExtended(RelatedFeatureFlags, {
provide: {
...DEFAULT_PROVIDE,
...provide,
},
});
};
afterEach(() => {
mock.restore();
wrapper.destroy();
});
beforeEach(() => {
mock = new MockAdapter(axios);
});
describe('with endpoint', () => {
it('displays a loading icon while feature flags load', () => {
mock.onGet(ENDPOINT).reply(() => new Promise(() => {}));
createWrapper();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('displays nothing if there are no feature flags loaded', async () => {
mock.onGet(ENDPOINT).reply(200, []);
createWrapper();
await waitForPromises();
await nextTick();
expect(wrapper.find('#related-feature-flags').exists()).toBe(false);
});
it('displays nothing if the request fails', async () => {
mock.onGet(ENDPOINT).reply(500);
createWrapper();
await waitForPromises();
await nextTick();
expect(createFlash).toHaveBeenCalledWith(
expect.objectContaining({
message: 'There was an error loading related feature flags',
}),
);
expect(wrapper.find('#related-feature-flags').exists()).toBe(false);
});
describe('with loaded feature flags', () => {
beforeEach(async () => {
mock.onGet(ENDPOINT).reply(200, MOCK_DATA);
createWrapper();
await waitForPromises();
await nextTick();
});
it('displays the number of referenced feature flags', () => {
const header = wrapper.findByRole('heading', `Related feature flags ${MOCK_DATA.length}`);
expect(trimText(header.text())).toBe(`Related feature flags ${MOCK_DATA.length}`);
});
it.each(MOCK_DATA.map((data, index) => [data.name, data, index]))(
'displays information for feature flag %s',
(_, flag, index) => {
const flagRow = extendedWrapper(
wrapper.findAllByTestId('feature-flag-details').at(index),
);
const icon = flagRow.findByTestId('feature-flag-details-icon');
expect(icon.props('name')).toBe(flag.active ? 'feature-flag' : 'feature-flag-disabled');
expect(icon.attributes('title')).toBe(flag.active ? 'Active' : 'Inactive');
const link = flagRow.findByRole('link', flag.name);
expect(link.attributes('href')).toBe(flag.path);
expect(link.attributes('title')).toBe(flag.name);
const reference = flagRow.findByTestId('feature-flag-details-reference');
expect(reference.text()).toBe(flag.reference);
expect(reference.attributes('title')).toBe(flag.reference);
},
);
});
});
describe('without endoint', () => {
it('renders nothing', async () => {
createWrapper({ endpoint: '' });
await nextTick();
expect(wrapper.find('#related-feature-flags').exists()).toBe(false);
});
});
});
......@@ -17027,6 +17027,9 @@ msgstr ""
msgid "InProductMarketing|you may %{unsubscribe_link} at any time."
msgstr ""
msgid "Inactive"
msgstr ""
msgid "Incident"
msgstr ""
......@@ -26890,6 +26893,9 @@ msgstr ""
msgid "Related Issues"
msgstr ""
msgid "Related feature flags"
msgstr ""
msgid "Related issues"
msgstr ""
......@@ -32859,6 +32865,9 @@ msgstr ""
msgid "There was an error loading merge request approval settings."
msgstr ""
msgid "There was an error loading related feature flags"
msgstr ""
msgid "There was an error loading users activity calendar."
msgstr ""
......
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