diff --git a/ee/app/assets/javascripts/groups/settings/work_items/custom_fields_list.vue b/ee/app/assets/javascripts/groups/settings/work_items/custom_fields_list.vue new file mode 100644 index 0000000000000000000000000000000000000000..99c94e8ccde3adf272f1eddb9b5b981851b8caab --- /dev/null +++ b/ee/app/assets/javascripts/groups/settings/work_items/custom_fields_list.vue @@ -0,0 +1,188 @@ +<script> +import { GlBadge, GlButton, GlIntersperse, GlSprintf, GlTable } from '@gitlab/ui'; +import { humanize } from '~/lib/utils/text_utility'; +import { __, n__, s__ } from '~/locale'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import groupCustomFieldsQuery from './group_custom_fields.query.graphql'; + +export default { + components: { + GlBadge, + GlButton, + GlIntersperse, + GlSprintf, + GlTable, + TimeagoTooltip, + }, + inject: ['fullPath'], + data() { + return { + customFields: [], + customFieldsForList: [], + }; + }, + apollo: { + customFields: { + query: groupCustomFieldsQuery, + variables() { + return { + fullPath: this.fullPath, + }; + }, + update(data) { + return data.group.customFields; + }, + result() { + // need a copy of the apollo query response as the table adds + // properties to it for showing the detail view + // prevents "Cannot add property _showDetails, object is not extensible" error + this.customFieldsForList = this.customFields?.nodes?.map((field) => ({ ...field })) ?? []; + }, + error(error) { + Sentry.captureException(error.message); + }, + }, + }, + methods: { + detailsToggleIcon(detailsVisible) { + return detailsVisible ? 'chevron-down' : 'chevron-right'; + }, + formattedFieldType(item) { + return humanize(item.fieldType.toLowerCase()); + }, + selectOptionsText(item) { + if (item.selectOptions.length > 0) { + return n__('%d option', '%d options', item.selectOptions.length); + } + return null; + }, + }, + fields: [ + { + key: 'show_details', + label: '', + class: 'gl-w-0 !gl-align-middle', + }, + { + key: 'name', + label: s__('WorkItem|Field'), + class: '!gl-align-middle', + }, + { + key: 'fieldType', + label: s__('WorkItem|Type'), + class: '!gl-align-middle', + }, + { + key: 'usage', + label: s__('WorkItem|Usage'), + class: '!gl-align-middle', + }, + { + key: 'lastModified', + label: __('Last modified'), + class: '!gl-align-middle', + }, + { + key: 'actions', + label: __('Actions'), + class: 'gl-w-0 gl-text-right', + }, + ], +}; +</script> + +<template> + <div> + <div class="gl-font-lg gl-border gl-rounded-t-base gl-border-b-0 gl-p-5 gl-font-bold"> + {{ s__('WorkItem|Active custom fields') }} + <gl-badge v-if="!$apollo.queries.customFields.loading"> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + {{ customFields.count }}/50 + </gl-badge> + </div> + <gl-table + :items="customFieldsForList" + :fields="$options.fields" + outlined + responsive + class="gl-rounded-b-base !gl-bg-gray-10" + > + <template #cell(show_details)="row"> + <gl-button + :aria-label="s__('WorkItem|Toggle details')" + :icon="detailsToggleIcon(row.detailsShowing)" + category="tertiary" + class="gl-align-self-flex-start" + data-testid="toggleDetailsButton" + @click="row.toggleDetails" + /> + </template> + <template #cell(name)="{ item }"> + {{ item.name }} + </template> + <template #cell(fieldType)="{ item }"> + {{ formattedFieldType(item) }} + <div class="gl-text-secondary">{{ selectOptionsText(item) }}</div> + </template> + <template #cell(usage)="{ item }"> + <gl-intersperse> + <span v-for="workItemType in item.workItemTypes" :key="workItemType.id">{{ + workItemType.name + }}</span> + </gl-intersperse> + </template> + <template #cell(lastModified)="{ item }"> + <timeago-tooltip :time="item.updatedAt" /> + </template> + <template #cell(actions)> + <gl-button :aria-label="s__('WorkItem|Edit field')" icon="pencil" category="tertiary" /> + </template> + <template #row-details="{ item }"> + <div class="gl-border gl-col-span-5 gl-mt-3 gl-rounded-lg gl-bg-default gl-p-5"> + <div class="gl-mb-3 gl-flex gl-gap-3"> + <dt>{{ s__('WorkItem|Usage:') }}</dt> + <dd> + <gl-intersperse> + <span v-for="workItemType in item.workItemTypes" :key="workItemType.id">{{ + workItemType.name + }}</span> + </gl-intersperse> + </dd> + </div> + <div v-if="item.selectOptions.length > 0" class="gl-mb-3"> + <dt>{{ s__('WorkItem|Options:') }}</dt> + <dd> + <ul> + <li v-for="option in item.selectOptions" :key="option.id"> + {{ option.value }} + </li> + </ul> + </dd> + </div> + <div class="gl-text-sm gl-text-secondary"> + <gl-sprintf :message="s__('WorkItem|Last updated %{timeago}')"> + <template #timeago> + <timeago-tooltip :time="item.updatedAt" /> + </template> + </gl-sprintf> + · + <gl-sprintf :message="s__('WorkItem|Created %{timeago}')"> + <template #timeago> + <timeago-tooltip :time="item.updatedAt" /> + </template> + </gl-sprintf> + </div> + </div> + </template> + </gl-table> + </div> +</template> + +<style> +/* remove border between row and details row */ +.gl-table tr.b-table-has-details td { + border-bottom-style: none; +} +</style> diff --git a/ee/app/assets/javascripts/groups/settings/work_items/group_custom_fields.query.graphql b/ee/app/assets/javascripts/groups/settings/work_items/group_custom_fields.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..d5fb8a4385782b2e5a5560ec165e592f4a340cde --- /dev/null +++ b/ee/app/assets/javascripts/groups/settings/work_items/group_custom_fields.query.graphql @@ -0,0 +1,24 @@ +query groupCustomFields($fullPath: ID!) { + group(fullPath: $fullPath) { + id + customFields(active: true) { + count + nodes { + id + name + fieldType + active + createdAt + updatedAt + selectOptions { + id + value + } + workItemTypes { + id + name + } + } + } + } +} diff --git a/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings.js b/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings.js new file mode 100644 index 0000000000000000000000000000000000000000..5527eb8267abddc581582e2e5890f929ceda5b87 --- /dev/null +++ b/ee/app/assets/javascripts/groups/settings/work_items/work_item_settings.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import Translate from '~/vue_shared/translate'; +import CustomFieldsList from './custom_fields_list.vue'; + +Vue.use(VueApollo); +Vue.use(Translate); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export function initWorkItemSettingsApp() { + const el = document.querySelector('#js-work-items-settings-form'); + if (!el) return; + + const { fullPath } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + apolloProvider, + provide: { + fullPath, + }, + render(createElement) { + return createElement(CustomFieldsList); + }, + }); +} diff --git a/ee/app/assets/javascripts/pages/groups/settings/work_items/show/index.js b/ee/app/assets/javascripts/pages/groups/settings/work_items/show/index.js new file mode 100644 index 0000000000000000000000000000000000000000..b0d60d70123463dfa93d91a159c01ca3bd1213e6 --- /dev/null +++ b/ee/app/assets/javascripts/pages/groups/settings/work_items/show/index.js @@ -0,0 +1,3 @@ +import { initWorkItemSettingsApp } from 'ee/groups/settings/work_items/work_item_settings'; + +initWorkItemSettingsApp(); diff --git a/ee/app/controllers/groups/settings/work_items_controller.rb b/ee/app/controllers/groups/settings/work_items_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..0e0430edea497b20f2d2c9e1c02595db14f4cf35 --- /dev/null +++ b/ee/app/controllers/groups/settings/work_items_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Groups + module Settings + class WorkItemsController < Groups::ApplicationController + layout 'group_settings' + + before_action :check_feature_availability + + feature_category :team_planning + urgency :low + + def show; end + + private + + def check_feature_availability + render_404 unless group.licensed_feature_available?(:custom_fields) && + Feature.enabled?('custom_fields_feature', group) + end + end + end +end diff --git a/ee/app/views/groups/settings/work_items/show.html.haml b/ee/app/views/groups/settings/work_items/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..1a4c75ab45faca7e53689c259c05e9d4f0da42cf --- /dev/null +++ b/ee/app/views/groups/settings/work_items/show.html.haml @@ -0,0 +1,16 @@ +- title = s_("WorkItem|Issues settings") +- breadcrumb_title title +- page_title title + +%h1.gl-heading-1.gl-mb-1.settings-title + = _('Issues') +%p.gl-text-secondary + = s_('WorkItem|Configure work items such as epics, issues, and tasks to represent how your team works.') + +%h2.gl-heading-3.gl-mb-1.settings-title + = s_('WorkItem|Custom fields') +%p.gl-text-secondary.gl-mb-3 + = s_('WorkItem|Custom fields extend work items to track additional data. Fields will appear in alphanumeric order. All fields apply to all subgroups and projects.') + = link_to s_('WorkItem|How do I use custom fields?') + +#js-work-items-settings-form{ data: { full_path: @group.full_path } } diff --git a/ee/config/routes/group.rb b/ee/config/routes/group.rb index 9e9b57caebf197d05c84b4ce1febc657268e0bc7..1f3056e953c6fa5d73b28d2dc21d5f5070b8c580 100644 --- a/ee/config/routes/group.rb +++ b/ee/config/routes/group.rb @@ -31,6 +31,8 @@ scope module: 'remote_development' do get 'workspaces', action: :show, controller: 'workspaces' end + + resource :issues, only: [:show], controller: 'work_items' end resource :early_access_opt_in, only: %i[create show], controller: 'early_access_opt_in' diff --git a/ee/spec/controllers/groups/settings/work_items_controller_spec.rb b/ee/spec/controllers/groups/settings/work_items_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..72b533ce0256bbc0d0836dc8428d1cdd340de6af --- /dev/null +++ b/ee/spec/controllers/groups/settings/work_items_controller_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Groups::Settings::WorkItemsController, type: :controller, feature_category: :team_planning do + let(:group) { create(:group) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + describe 'GET #show' do + subject(:request) { get :show, params: { group_id: group.to_param } } + + context 'when user is not authorized' do + it 'returns 404' do + request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user is authorized' do + before do + group.add_owner(user) + end + + context 'when custom_fields_feature is not available' do + before do + stub_licensed_features(custom_fields: false) + end + + it 'returns 404' do + request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when custom_fields_feature is available' do + before do + stub_licensed_features(custom_fields: true) + end + + context 'when custom_fields_feature flag is disabled' do + before do + stub_feature_flags(custom_fields_feature: false) + end + + it 'returns 404' do + request + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when custom_fields_feature flag is enabled' do + before do + stub_feature_flags(custom_fields_feature: true) + end + + it 'renders the show template' do + request + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:show) + end + + it 'uses the group_settings layout' do + request + + expect(response).to render_template('layouts/group_settings') + end + end + end + end + end +end diff --git a/ee/spec/frontend/groups/settings/work_items/custom_fields_list_spec.js b/ee/spec/frontend/groups/settings/work_items/custom_fields_list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b024f92eac3dcd9260bee9134a447df893651f63 --- /dev/null +++ b/ee/spec/frontend/groups/settings/work_items/custom_fields_list_spec.js @@ -0,0 +1,134 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { mount } from '@vue/test-utils'; +import { GlBadge } from '@gitlab/ui'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import CustomFieldsTable from 'ee/groups/settings/work_items/custom_fields_list.vue'; +import groupCustomFieldsQuery from 'ee/groups/settings/work_items/group_custom_fields.query.graphql'; + +Vue.use(VueApollo); + +describe('CustomFieldsTable', () => { + let wrapper; + + const findDetailsButton = () => wrapper.find('[data-testid="toggleDetailsButton"'); + + const selectField = { + id: '1', + name: 'Test Select Field', + fieldType: 'SELECT', + active: true, + workItemTypes: [{ id: '1', name: 'Issue' }], + selectOptions: [ + { + id: 'select-1', + value: 'value 1', + }, + { + id: 'select-2', + value: 'value 2', + }, + ], + updatedAt: '2023-01-01T00:00:00Z', + createdAt: '2023-01-01T00:00:00Z', + }; + + const stringField = { + id: '1', + name: 'Test Text Field', + fieldType: 'STRING', + active: true, + workItemTypes: [ + { id: '1', name: 'Issue' }, + { id: '2', name: 'Task' }, + ], + selectOptions: [], + updatedAt: '2023-01-01T00:00:00Z', + createdAt: '2023-01-01T00:00:00Z', + }; + + const createComponent = (fields = [selectField]) => { + const customFieldsResponse = jest.fn().mockResolvedValue({ + data: { + group: { + id: '123', + customFields: { + nodes: fields, + count: fields.length, + }, + }, + }, + }); + + wrapper = mount(CustomFieldsTable, { + provide: { + fullPath: 'group/path', + }, + apolloProvider: createMockApollo([[groupCustomFieldsQuery, customFieldsResponse]]), + stubs: { GlIntersperse: true }, + }); + }; + + it('displays correct count in badge', async () => { + createComponent(); + await waitForPromises(); + + const badge = wrapper.findComponent(GlBadge); + expect(badge.text()).toBe('1/50'); + }); + + it('detailsToggleIcon returns correct icon based on visibility', async () => { + createComponent(); + await waitForPromises(); + + expect(findDetailsButton().props().icon).toBe('chevron-right'); + + findDetailsButton().trigger('click'); + await nextTick(); + + expect(findDetailsButton().props().icon).toBe('chevron-down'); + }); + + it('humanizes field type', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.text()).toContain('Select'); + }); + + it('selectOptionsText returns correct text based on options length', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.text()).toContain('2 options'); + }); + + it('lists work item types', async () => { + createComponent([stringField]); + await waitForPromises(); + + expect(wrapper.text()).toContain('Issue'); + expect(wrapper.text()).toContain('Task'); + }); + + it('toggles details when detail button is clicked', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.text()).not.toContain('Last updated'); + await findDetailsButton().trigger('click'); + + expect(wrapper.text()).toContain('Last updated'); + }); + + it('renders TimeagoTooltip components with correct timestamps', async () => { + createComponent(); + await waitForPromises(); + + const timeagoComponents = wrapper.findAllComponents(TimeagoTooltip); + expect(timeagoComponents.exists()).toBe(true); + expect(timeagoComponents.at(0).props('time')).toBe(selectField.updatedAt); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d318cf7f47ca3f66800e9cb5ad8475108904a766..ef1aa6688d6cbfcfb4473b4791ff3735cc55845b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -397,6 +397,11 @@ msgid_plural "%d more comments" msgstr[0] "" msgstr[1] "" +msgid "%d option" +msgid_plural "%d options" +msgstr[0] "" +msgstr[1] "" + msgid "%d package" msgid_plural "%d packages" msgstr[0] "" @@ -62365,6 +62370,9 @@ msgstr[1] "" msgid "WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}." msgstr "" +msgid "WorkItem|Active custom fields" +msgstr "" + msgid "WorkItem|Activity" msgstr "" @@ -62464,6 +62472,9 @@ msgstr "" msgid "WorkItem|Comments only" msgstr "" +msgid "WorkItem|Configure work items such as epics, issues, and tasks to represent how your team works." +msgstr "" + msgid "WorkItem|Convert to child item" msgstr "" @@ -62476,6 +62487,15 @@ msgstr "" msgid "WorkItem|Create %{workItemType}" msgstr "" +msgid "WorkItem|Created %{timeago}" +msgstr "" + +msgid "WorkItem|Custom fields" +msgstr "" + +msgid "WorkItem|Custom fields extend work items to track additional data. Fields will appear in alphanumeric order. All fields apply to all subgroups and projects." +msgstr "" + msgid "WorkItem|Dark red" msgstr "" @@ -62497,6 +62517,9 @@ msgstr "" msgid "WorkItem|Due" msgstr "" +msgid "WorkItem|Edit field" +msgstr "" + msgid "WorkItem|Epic" msgstr "" @@ -62506,6 +62529,9 @@ msgstr "" msgid "WorkItem|Existing task" msgstr "" +msgid "WorkItem|Field" +msgstr "" + msgid "WorkItem|Fixed" msgstr "" @@ -62518,6 +62544,9 @@ msgstr "" msgid "WorkItem|History only" msgstr "" +msgid "WorkItem|How do I use custom fields?" +msgstr "" + msgid "WorkItem|Incident" msgstr "" @@ -62527,6 +62556,9 @@ msgstr "" msgid "WorkItem|Issue" msgstr "" +msgid "WorkItem|Issues settings" +msgstr "" + msgid "WorkItem|Iteration" msgstr "" @@ -62536,6 +62568,9 @@ msgstr "" msgid "WorkItem|Key result" msgstr "" +msgid "WorkItem|Last updated %{timeago}" +msgstr "" + msgid "WorkItem|Lavender" msgstr "" @@ -62626,6 +62661,9 @@ msgstr "" msgid "WorkItem|Open" msgstr "" +msgid "WorkItem|Options:" +msgstr "" + msgid "WorkItem|Parent" msgstr "" @@ -62815,15 +62853,27 @@ msgstr "" msgid "WorkItem|This work item is not available. It either doesn't exist or you don't have permission to view it." msgstr "" +msgid "WorkItem|Toggle details" +msgstr "" + msgid "WorkItem|Turn off confidentiality" msgstr "" msgid "WorkItem|Turn on confidentiality" msgstr "" +msgid "WorkItem|Type" +msgstr "" + msgid "WorkItem|Undo" msgstr "" +msgid "WorkItem|Usage" +msgstr "" + +msgid "WorkItem|Usage:" +msgstr "" + msgid "WorkItem|View current version" msgstr ""