diff --git a/app/assets/javascripts/issues/index.js b/app/assets/javascripts/issues/index.js index b7b39d0ce08102cfe607ed62f518854beffce28b..a33a6dfb6fcb8da95847ec9521b88b626d1b2e9d 100644 --- a/app/assets/javascripts/issues/index.js +++ b/app/assets/javascripts/issues/index.js @@ -49,4 +49,12 @@ export function initShow() { import(/* webpackChunkName: 'design_management' */ '~/design_management') .then((module) => module.default()) .catch(() => {}); + + if (gon.features.workItemsViewPreference) { + import(/* webpackChunkName: 'work_items_feedback' */ '~/work_items_feedback') + .then(({ initWorkItemsFeedback }) => { + initWorkItemsFeedback(); + }) + .catch({}); + } } diff --git a/app/assets/javascripts/pages/projects/work_items/index.js b/app/assets/javascripts/pages/projects/work_items/index.js index b44ca708b287e98a05ee33b7645328362ef92c4e..1b2d24edbc477aef036f5bf580f2bb11c6e345bc 100644 --- a/app/assets/javascripts/pages/projects/work_items/index.js +++ b/app/assets/javascripts/pages/projects/work_items/index.js @@ -1,3 +1,11 @@ import { initWorkItemsRoot } from '~/work_items'; initWorkItemsRoot(); + +if (gon.features.work_items_view_preference) { + import('~/work_items_feedback') + .then(({ initWorkItemsFeedback }) => { + initWorkItemsFeedback(); + }) + .catch({}); +} diff --git a/app/assets/javascripts/work_items/index.js b/app/assets/javascripts/work_items/index.js index 9bdfe6280c5b85f33827985c843348b7365554a1..426342747a1cd52e1015f3aed62891cdb79e3359 100644 --- a/app/assets/javascripts/work_items/index.js +++ b/app/assets/javascripts/work_items/index.js @@ -78,6 +78,14 @@ export const initWorkItemsRoot = ({ workItemType, workspaceType } = {}) => { }, }); + if (gon.features.workItemsViewPreference) { + import(/* webpackChunkName: 'work_items_feedback' */ '~/work_items_feedback') + .then(({ initWorkItemsFeedback }) => { + initWorkItemsFeedback(); + }) + .catch({}); + } + return new Vue({ el, name: 'WorkItemsRoot', diff --git a/app/assets/javascripts/work_items/router/index.js b/app/assets/javascripts/work_items/router/index.js index 78e2a6df6603fc0dc8292bad21a8419ffae7051e..50059732d651e78df71ae7da81dba689c6bb80c5 100644 --- a/app/assets/javascripts/work_items/router/index.js +++ b/app/assets/javascripts/work_items/router/index.js @@ -10,7 +10,6 @@ Vue.use(VueRouter); export function createRouter({ fullPath, - workItemType = 'work_items', workspaceType = WORKSPACE_PROJECT, defaultBranch, isGroup, @@ -24,6 +23,6 @@ export function createRouter({ return new VueRouter({ routes: routes(isGroup), mode: 'history', - base: joinPaths(gon?.relative_url_root, workspacePath, fullPath, '-', workItemType), + base: joinPaths(gon?.relative_url_root, workspacePath, fullPath, '-'), }); } diff --git a/app/assets/javascripts/work_items/router/routes.js b/app/assets/javascripts/work_items/router/routes.js index 14a2d44445e72594195932bc6c128ed349ef8144..08238f983d302f6885aff955883f98c3feeb29ee 100644 --- a/app/assets/javascripts/work_items/router/routes.js +++ b/app/assets/javascripts/work_items/router/routes.js @@ -5,7 +5,7 @@ import { ROUTES } from '../constants'; function getRoutes(isGroup) { const routes = [ { - path: '/:iid', + path: '/:type(issues|epics|work_items)/:iid', name: ROUTES.workItem, component: () => import('../pages/work_item_root.vue'), props: true, @@ -27,7 +27,7 @@ function getRoutes(isGroup) { if (isGroup) { routes.unshift({ - path: '/', + path: '/:type(issues|epics|work_items)', name: ROUTES.index, component: WorkItemList, }); @@ -35,7 +35,7 @@ function getRoutes(isGroup) { if (gon.features?.workItemsAlpha) { routes.unshift({ - path: '/new', + path: '/:type(issues|epics|work_items)/new', name: ROUTES.new, component: () => import('../pages/create_work_item.vue'), }); diff --git a/app/assets/javascripts/work_items_feedback/components/work_item_view_toggle.vue b/app/assets/javascripts/work_items_feedback/components/work_item_view_toggle.vue new file mode 100644 index 0000000000000000000000000000000000000000..14ea8bf0b2edd0fc0787235672e2bae70f786ee4 --- /dev/null +++ b/app/assets/javascripts/work_items_feedback/components/work_item_view_toggle.vue @@ -0,0 +1,104 @@ + + + + + {{ $options.i18n.newIssueLook }}: {{ onOff }} + + + + {{ $options.i18n.previewWorkItems }} + + {{ $options.i18n.leaveFeedback }} + + + diff --git a/app/assets/javascripts/work_items_feedback/graphql/set_use_work_items_view.mutation.graphql b/app/assets/javascripts/work_items_feedback/graphql/set_use_work_items_view.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..bbd935f86cf2b58c442ea5f2b18061fc55aef6a5 --- /dev/null +++ b/app/assets/javascripts/work_items_feedback/graphql/set_use_work_items_view.mutation.graphql @@ -0,0 +1,7 @@ +mutation setUseWorkItemsView($useWorkItemsView: Boolean) { + userPreferencesUpdate(input: { useWorkItemsView: $useWorkItemsView }) { + userPreferences { + useWorkItemsView + } + } +} diff --git a/app/assets/javascripts/work_items_feedback/graphql/user_preferences.query.graphql b/app/assets/javascripts/work_items_feedback/graphql/user_preferences.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..02f0e435f65f380bd8a51eca0571631fe0b0d329 --- /dev/null +++ b/app/assets/javascripts/work_items_feedback/graphql/user_preferences.query.graphql @@ -0,0 +1,8 @@ +query getUserPreferences { + currentUser { + id + userPreferences { + useWorkItemsView + } + } +} diff --git a/app/assets/javascripts/work_items_feedback/index.js b/app/assets/javascripts/work_items_feedback/index.js index f99097b725a3ef0764669676d41e59e7b20e1336..535dd52dbdba56a54b30720e0ab341e8f0278440 100644 --- a/app/assets/javascripts/work_items_feedback/index.js +++ b/app/assets/javascripts/work_items_feedback/index.js @@ -2,6 +2,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { apolloProvider } from '~/graphql_shared/issuable_client'; import WorkItemFeedback from './components/work_item_feedback.vue'; +import WorkItemViewToggle from './components/work_item_view_toggle.vue'; Vue.use(VueApollo); @@ -12,7 +13,7 @@ export const initWorkItemsFeedback = ({ content, expiry, featureName, -}) => { +} = {}) => { if (expiry) { const expiryDate = new Date(expiry); if (Date.now() > expiryDate) { @@ -34,7 +35,7 @@ export const initWorkItemsFeedback = ({ featureName, }, render(h) { - return h(WorkItemFeedback); + return h(feedbackIssue ? WorkItemFeedback : WorkItemViewToggle); }, }); }; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index aeaebad8f68281096696bafb9875ef635a3131b8..10fec4dd3dcfb9ea79e95b96323f1c230300bf91 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -67,6 +67,7 @@ class Projects::IssuesController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_alpha, project&.work_items_alpha_feature_flag_enabled?) push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_frontend_feature_flag(:namespace_level_work_items, project&.group) + push_frontend_feature_flag(:work_items_view_preference, current_user) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] @@ -140,6 +141,15 @@ def new respond_with(@issue) end + def show + return super unless show_work_item? && request.format.html? + + @right_sidebar = false + @work_item = issue.becomes(::WorkItem) # rubocop:disable Cop/AvoidBecomes -- We need the instance to be a work item + + render 'projects/work_items/show' + end + def edit respond_with(@issue) end @@ -390,6 +400,10 @@ def disable_query_limiting private + def show_work_item? + Feature.enabled?(:work_items_view_preference, current_user) && current_user&.user_preference&.use_work_items_view + end + def work_item_redirect_except_actions ISSUES_EXCEPT_ACTIONS end diff --git a/config/feature_flags/beta/work_items_view_preference.yml b/config/feature_flags/beta/work_items_view_preference.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd84de9555d3588e6c838990e6947341bb340ca7 --- /dev/null +++ b/config/feature_flags/beta/work_items_view_preference.yml @@ -0,0 +1,9 @@ +--- +name: work_items_view_preference +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/461855 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165085 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/482931 +milestone: '17.4' +group: group::project management +type: beta +default_enabled: false diff --git a/ee/app/assets/javascripts/pages/projects/issues/show/index.js b/ee/app/assets/javascripts/pages/projects/issues/show/index.js index db25bfaa3f795d44fc0510191c286239f0553d7e..3bcd3b4fc2c34fdb0439d1ce2064814db0e034f3 100644 --- a/ee/app/assets/javascripts/pages/projects/issues/show/index.js +++ b/ee/app/assets/javascripts/pages/projects/issues/show/index.js @@ -1,10 +1,30 @@ -import { initRelatedFeatureFlags, initUnableToLinkVulnerabilityError } from 'ee/issues'; -import { initShow } from '~/issues'; -import UserCallout from '~/user_callout'; +const initLegacyIssuePage = async () => { + const imports = [import('ee/issues'), import('~/issues'), import('~/user_callout')]; -initShow(); -initRelatedFeatureFlags(); -initUnableToLinkVulnerabilityError(); + const [ + { initRelatedFeatureFlags, initUnableToLinkVulnerabilityError }, + { initShow }, + userCalloutModule, + ] = await Promise.all(imports); -new UserCallout({ className: 'js-epics-sidebar-callout' }); // eslint-disable-line no-new -new UserCallout({ className: 'js-weight-sidebar-callout' }); // eslint-disable-line no-new + initShow(); + initRelatedFeatureFlags(); + initUnableToLinkVulnerabilityError(); + + const UserCallout = userCalloutModule.default; + + new UserCallout({ className: 'js-epics-sidebar-callout' }); // eslint-disable-line no-new + new UserCallout({ className: 'js-weight-sidebar-callout' }); // eslint-disable-line no-new +}; + +const initWorkItemPage = async () => { + const [{ initWorkItemsRoot }] = await Promise.all([import('~/work_items')]); + + initWorkItemsRoot(); +}; + +if (gon.features.workItemsViewPreference && gon.current_user_use_work_items_view) { + initWorkItemPage(); +} else { + initLegacyIssuePage(); +} diff --git a/ee/spec/features/groups/work_items/work_item_spec.rb b/ee/spec/features/groups/work_items/work_item_spec.rb index feb9e0cfbecbd5e686e2680c7e653580d6075c33..d400cdd759d2372a91e8795e60c9bd32252833fb 100644 --- a/ee/spec/features/groups/work_items/work_item_spec.rb +++ b/ee/spec/features/groups/work_items/work_item_spec.rb @@ -66,7 +66,7 @@ it 'shows the correct breadcrumbs' do within_testid('breadcrumb-links') do expect(page).to have_link(group.name, href: group_path(group)) - expect(page).to have_link('Epics', href: "#{group_epics_path(group)}/") + expect(page).to have_link('Epics', href: group_epics_path(group)) expect(find('nav:last-of-type li:last-of-type')).to have_link(work_item.to_reference, href: group_epic_path(group, work_item.iid)) end diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 233ad440b54889f2711bc87df9281426e4092bb5..3911e210b7dde87e6185ac82c58b8b788c14f6cb 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -67,6 +67,7 @@ def add_gon_variables gon.current_user_avatar_url = current_user.avatar_url gon.time_display_relative = current_user.time_display_relative gon.time_display_format = current_user.time_display_format + gon.current_user_use_work_items_view = current_user.user_preference&.use_work_items_view || false end if current_organization && Feature.enabled?(:ui_for_organizations, current_user) @@ -81,6 +82,7 @@ def add_gon_variables push_frontend_feature_flag(:organization_switching, current_user) # To be removed with https://gitlab.com/gitlab-org/gitlab/-/issues/399248 push_frontend_feature_flag(:remove_monitor_metrics) + push_frontend_feature_flag(:work_items_view_preference, current_user) end # Exposes the state of a feature flag to the frontend code. diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1220056369f28b0c71d0be3a982e48ed8e3fcfd9..a64345826a006eff5318adc910c669c7a759eae9 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -35535,6 +35535,9 @@ msgid_plural "New issues" msgstr[0] "" msgstr[1] "" +msgid "New issue look" +msgstr "" + msgid "New issue title" msgstr "" @@ -41139,6 +41142,9 @@ msgstr "" msgid "Preview payload" msgstr "" +msgid "Preview the new issues experience, with real time updates and refreshed design. Some features are not yet supported, see feedback issue for details." +msgstr "" + msgid "Previous commit" msgstr "" diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index db6b8c36a54336b4758e4bc4373f16b5dbf25622..bddf84b1886e8660a71f4367c621412f9b3c4d52 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -182,6 +182,14 @@ end end + context 'when use_work_items_view is enabled' do + it 'displays the work item view' do + user.user_preference.update!(use_work_items_view: true) + get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid } + expect(response).to render_template 'projects/work_items/show' + end + end + context 'when issue is of type task' do let(:query) { {} } diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index ab577769949f8f90022eff77263afe00fcf3221d..896275b8459c4d895758837d982184660358299f 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -4731,3 +4731,14 @@ export const mockMoveWorkItemMutationResponse = ({ error = undefined } = {}) => }, }, }); + +export const mockUserPreferences = (useWorkItemsView = true) => ({ + data: { + currentUser: { + id: '1', + userPreferences: { + useWorkItemsView, + }, + }, + }, +}); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index cf3e8a8e54c5c162ff15b20f45cd26157c30862b..0b6dd6e0c451e0dbda5e7f84d038237dd766d76b 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -87,20 +87,20 @@ describe('Work items router', () => { }); it('renders work item on `/1` route', async () => { - await createComponent('/1'); + await createComponent('/work_items/1'); expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true); }); it('does not render create work item page on `/new` route if `workItemsAlpha` feature flag is off', async () => { - await createComponent('/new'); + await createComponent('/work_items/new'); expect(wrapper.findComponent(CreateWorkItem).exists()).toBe(false); }); it('renders create work item page on `/new` route', async () => { window.gon.features.workItemsAlpha = true; - await createComponent('/new'); + await createComponent('/work_items/new'); expect(wrapper.findComponent(CreateWorkItem).exists()).toBe(true); }); @@ -109,18 +109,24 @@ describe('Work items router', () => { gon.relative_url_root = '/my-org'; const router = createRouter({ fullPath: '/work_item' }); - expect(router.options.base).toBe('/my-org/work_item/-/work_items'); + expect(router.options.base).toBe('/my-org/work_item/-'); }); it('includes groups in path for groups', () => { const router = createRouter({ fullPath: '/work_item', workspaceType: 'group' }); - expect(router.options.base).toBe('/groups/work_item/-/work_items'); + expect(router.options.base).toBe('/groups/work_item/-'); }); - it('includes workItemType if provided', () => { - const router = createRouter({ fullPath: '/work_item', workItemType: 'epics' }); + it('renders work item on `/issues/1` route', async () => { + await createComponent('/issues/1'); - expect(router.options.base).toBe('/work_item/-/epics'); + expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true); + }); + + it('renders work item on `/epics/1` route', async () => { + await createComponent('/epics/1'); + + expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true); }); }); diff --git a/spec/frontend/work_items_feedback/work_item_feedback_spec.js b/spec/frontend/work_items_feedback/components/work_item_feedback_spec.js similarity index 100% rename from spec/frontend/work_items_feedback/work_item_feedback_spec.js rename to spec/frontend/work_items_feedback/components/work_item_feedback_spec.js diff --git a/spec/frontend/work_items_feedback/components/work_item_view_toggle_spec.js b/spec/frontend/work_items_feedback/components/work_item_view_toggle_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d49ad7f5733827010506855fcdd83de3c1a3a8d4 --- /dev/null +++ b/spec/frontend/work_items_feedback/components/work_item_view_toggle_spec.js @@ -0,0 +1,106 @@ +import { GlToggle, GlBadge, GlPopover, GlLink } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import userPreferencesQuery from '~/work_items_feedback/graphql/user_preferences.query.graphql'; +import setUseWorkItemsView from '~/work_items_feedback/graphql/set_use_work_items_view.mutation.graphql'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockUserPreferences } from 'jest/work_items/mock_data'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; + +import WorkItemToggle from '~/work_items_feedback/components/work_item_view_toggle.vue'; + +describe('WorkItemToggle', () => { + Vue.use(VueApollo); + useMockLocationHelper(); + let wrapper; + + const userPreferencesViewOnQueryHandler = jest.fn().mockResolvedValue(mockUserPreferences()); + const userPreferencesViewsOffQueryHandler = jest + .fn() + .mockResolvedValue(mockUserPreferences(false)); + + const mutationHandler = jest.fn().mockResolvedValue({ + data: { + userPreferencesUpdate: { + userPreferences: { + useWorkItemsView: true, + }, + }, + }, + }); + + const createComponent = ({ + userPreferencesQueryHandler = userPreferencesViewOnQueryHandler, + mutationHandler: providedMutationHandler = mutationHandler, + } = {}) => { + const apolloProvider = createMockApollo([ + [userPreferencesQuery, userPreferencesQueryHandler], + [setUseWorkItemsView, providedMutationHandler], + ]); + wrapper = shallowMountExtended(WorkItemToggle, { + apolloProvider, + stubs: { + GlBadge, + GlPopover, + GlLink, + GlToggle, + }, + }); + }; + + const findToggle = () => wrapper.findComponent(GlToggle); + + describe('template', () => { + it('displays the toggle on if useWorkItemsView from GraphQL API is on', async () => { + createComponent(); + await waitForPromises(); + expect(findToggle().props('value')).toBe(true); + }); + + it('displays the toggle off if useWorkItemsView from GraphQL API is off', async () => { + createComponent({ userPreferencesQueryHandler: userPreferencesViewsOffQueryHandler }); + await waitForPromises(); + expect(findToggle().props('value')).toBe(false); + }); + }); + + describe('interaction', () => { + it('sends a mutation if toggled', async () => { + createComponent(); + await waitForPromises(); + + await findToggle().vm.$emit('change', false); + + expect(mutationHandler).toHaveBeenCalledWith({ + useWorkItemsView: false, + }); + }); + + it('refreshes the view if mutation is successful', async () => { + createComponent(); + await waitForPromises(); + + await findToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(true); + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('does not refresh the view if mutation fails', async () => { + const errorMutationHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + createComponent({ mutationHandler: errorMutationHandler }); + await waitForPromises(); + + await findToggle().vm.$emit('change', false); + + await waitForPromises(); + + expect(findToggle().props('value')).toBe(true); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); +});