Commit 5b79efa7 authored by Andrew Fontaine's avatar Andrew Fontaine

Display Feature Flags Related to Issues

Now that links between feature flags and issues are established, we can
add some components to allow users to see them within their issues.

Some notes:

- The `admin_feature_flags_issue_links` permission is required to view
  related feature flags. This is currently linked to the `developer`
  role, which is the same level as `read_feature_flags`.
  `admin_feature_flags_issue_links` is used as it is only available in
  EE, and so are relating issues and feature flags.
- The component is _extremely_ self-contained with no state-management
  or polling. I don't expect feature flags or their relations to change
  very often, but can add polling in later if need be. GraphQL is not an
  option here (yet) unfortunately.

Changelog: added
EE: true
parent 8d38d489
......@@ -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 ""
......@@ -32856,6 +32862,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