diff --git a/app/assets/javascripts/projects/your_work/components/app.vue b/app/assets/javascripts/projects/your_work/components/app.vue index 2ff12e61d5e725965f72d1b70e46eaccf32ca0f7..4ad6fa994ca1d413ad12365303772308b50ce76e 100644 --- a/app/assets/javascripts/projects/your_work/components/app.vue +++ b/app/assets/javascripts/projects/your_work/components/app.vue @@ -1,16 +1,78 @@ <script> +import { GlTabs, GlTab, GlBadge, GlSprintf } from '@gitlab/ui'; import { __ } from '~/locale'; +import { joinPaths, updateHistory, pathSegments } from '~/lib/utils/url_utility'; +import { PROJECT_DASHBOARD_TABS, CONTRIBUTED_TAB } from 'ee_else_ce/projects/your_work/constants'; export default { name: 'YourWorkProjectsApp', i18n: { - listText: __('Projects list'), + heading: __('Projects'), + activeTab: __('Active tab: %{tab}'), + }, + components: { + GlTabs, + GlTab, + GlBadge, + GlSprintf, + }, + data() { + return { + activeTabIndex: 0, + }; + }, + computed: { + formattedTabs() { + return PROJECT_DASHBOARD_TABS.map((tab) => ({ ...tab, count: 0 })); + }, + }, + created() { + this.getTabFromUrl(); + }, + methods: { + getTabFromUrl() { + const tab = pathSegments(window.location)?.pop(); + const tabIndex = PROJECT_DASHBOARD_TABS.findIndex(({ value }) => value === tab); + + this.activeTabIndex = tabIndex > 0 ? tabIndex : 0; + }, + setTabInUrl() { + const tab = PROJECT_DASHBOARD_TABS[this.activeTabIndex] || CONTRIBUTED_TAB; + const url = joinPaths(gon.relative_url_root || '/', `/dashboard/projects/${tab.value}`); + + updateHistory({ url, replace: true }); + }, + onTabUpdate(index) { + // This return will prevent us overwriting the root `/` and `/dashboard/projects` paths + // when we don't need to. + if (index === this.activeTabIndex) return; + + this.activeTabIndex = index; + this.setTabInUrl(); + }, }, }; </script> <template> <div> - <p>{{ $options.i18n.listText }}</p> + <h1 class="page-title gl-font-size-h-display gl-mt-5">{{ $options.i18n.heading }}</h1> + + <gl-tabs :value="activeTabIndex" @input="onTabUpdate"> + <gl-tab v-for="tab in formattedTabs" :key="tab.text"> + <template #title> + <span data-testid="projects-dashboard-tab-title"> + <span>{{ tab.text }}</span> + <gl-badge size="sm" class="gl-tab-counter-badge">{{ tab.count }}</gl-badge> + </span> + </template> + + <gl-sprintf :message="$options.i18n.activeTab"> + <template #tab> + {{ tab.text }} + </template> + </gl-sprintf> + </gl-tab> + </gl-tabs> </div> </template> diff --git a/app/assets/javascripts/projects/your_work/constants.js b/app/assets/javascripts/projects/your_work/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..1bb0b5d502fe1dbde1135595bb709a2c5c14f2c9 --- /dev/null +++ b/app/assets/javascripts/projects/your_work/constants.js @@ -0,0 +1,23 @@ +import { __ } from '~/locale'; + +export const CONTRIBUTED_TAB = { + text: __('Contributed'), + value: 'contributed', +}; + +export const STARRED_TAB = { + text: __('Starred'), + value: 'starred', +}; + +export const PERSONAL_TAB = { + text: __('Personal'), + value: 'personal', +}; + +export const MEMBER_TAB = { + text: __('Member'), + value: 'member', +}; + +export const PROJECT_DASHBOARD_TABS = [CONTRIBUTED_TAB, STARRED_TAB, PERSONAL_TAB, MEMBER_TAB]; diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 3e303963f8092230e72955b75536dc84553a589e..2780ba121a23497d300e91ba996eb992018d6f2f 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -8,12 +8,13 @@ - add_page_specific_style 'page_bundles/projects' = render "projects/last_push" -- if show_projects?(@projects, params) - = render 'dashboard/projects_head' - = render 'nav' - - if Feature.enabled?(:your_work_projects_vue, current_user) - #js-your-work-projects-app - - else - = render 'projects' + +- if Feature.enabled?(:your_work_projects_vue, current_user) + #js-your-work-projects-app - else - = render "zero_authorized_projects" + - if show_projects?(@projects, params) + = render 'dashboard/projects_head' + = render 'nav' + = render 'projects' + - else + = render "zero_authorized_projects" diff --git a/app/views/dashboard/projects/shared/_common.html.haml b/app/views/dashboard/projects/shared/_common.html.haml index d07d55ca7c4d592cbd47a96a0f8ea024f848d78a..e91c430dc98d9ee141289b8c259e3399134ff76a 100644 --- a/app/views/dashboard/projects/shared/_common.html.haml +++ b/app/views/dashboard/projects/shared/_common.html.haml @@ -3,12 +3,13 @@ = render_dashboard_ultimate_trial(current_user) = render "projects/last_push" -= render 'dashboard/projects_head', project_tab_filter: :starred -- if params[:filter_projects] || any_projects?(@projects) - - if Feature.enabled?(:your_work_projects_vue, current_user) - #js-your-work-projects-app - - else - = render 'projects' +- if Feature.enabled?(:your_work_projects_vue, current_user) + #js-your-work-projects-app - else - = render empty_page + = render 'dashboard/projects_head', project_tab_filter: :starred + + - if params[:filter_projects] || any_projects?(@projects) + = render 'projects' + - else + = render empty_page diff --git a/config/routes/dashboard.rb b/config/routes/dashboard.rb index 6a3aa5ff0c1fbb9b9845898f0b558b9621535932..5c5f063b2af86a4c35a7116d82e845a8d4878a04 100644 --- a/config/routes/dashboard.rb +++ b/config/routes/dashboard.rb @@ -25,7 +25,12 @@ resources :projects, only: [:index] do collection do + ## TODO: Migrate this over to to: 'projects#index' as part of `:your_work_projects_vue` FF rollout + ## https://gitlab.com/gitlab-org/gitlab/-/issues/465889 get :starred + get :contributed, to: 'projects#index' + get :personal, to: 'projects#index' + get :member, to: 'projects#index' end end end diff --git a/ee/app/assets/javascripts/projects/your_work/constants.js b/ee/app/assets/javascripts/projects/your_work/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..ed924e531355d9e543023a32e28f43e11dce805f --- /dev/null +++ b/ee/app/assets/javascripts/projects/your_work/constants.js @@ -0,0 +1,16 @@ +import { PROJECT_DASHBOARD_TABS as PROJECT_DASHBOARD_TABS_CE } from '~/projects/your_work/constants'; + +import { __ } from '~/locale'; + +// Exports override for EE +// eslint-disable-next-line import/export +export * from '~/projects/your_work/constants'; + +export const INACTIVE_TAB = { + text: __('Inactive'), + value: 'removed', +}; + +// Exports override for EE +// eslint-disable-next-line import/export +export const PROJECT_DASHBOARD_TABS = [...PROJECT_DASHBOARD_TABS_CE, INACTIVE_TAB]; diff --git a/ee/config/routes/dashboard.rb b/ee/config/routes/dashboard.rb index a66cf2385f2444c28d02ee7a8b338d54c56b32ea..145d19f4fb780454d0bca31636be593272fff671 100644 --- a/ee/config/routes/dashboard.rb +++ b/ee/config/routes/dashboard.rb @@ -4,6 +4,8 @@ scope module: :dashboard do resources :projects, only: [:index] do collection do + ## TODO: Migrate this over to to: 'projects#index' as part of `:your_work_projects_vue` FF rollout + ## https://gitlab.com/gitlab-org/gitlab/-/issues/465889 get :removed end end diff --git a/ee/spec/frontend/projects/your_work/components/app_spec.js b/ee/spec/frontend/projects/your_work/components/app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fd7bf258b11ede798d7df8220c6f43dab05e1225 --- /dev/null +++ b/ee/spec/frontend/projects/your_work/components/app_spec.js @@ -0,0 +1,39 @@ +import { GlTab, GlSprintf } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { TEST_HOST } from 'helpers/test_constants'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import YourWorkProjectsApp from '~/projects/your_work/components/app.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; + +jest.mock('~/alert'); + +describe('YourWorkProjectsApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMountExtended(YourWorkProjectsApp, { + stubs: { + GlSprintf, + GlTab, + }, + }); + }; + + describe.each` + path | expectedIndex + ${'/dashboard/projects/removed'} | ${4} + `('onMount when path is $path', ({ path, expectedIndex }) => { + useMockLocationHelper(); + beforeEach(async () => { + delete window.location; + window.location = new URL(`${TEST_HOST}/${path}`); + + createComponent(); + await nextTick(); + }); + + it('initializes to the correct tab', () => { + expect(wrapper.vm.activeTabIndex).toBe(expectedIndex); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 651d5b8d3147b8922b8522ce8ef15934cb163705..68f3b411b0b72f1029cbf6e9a52e261c41fd1920 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2976,6 +2976,9 @@ msgstr "" msgid "Active project access tokens" msgstr "" +msgid "Active tab: %{tab}" +msgstr "" + msgid "Activity" msgstr "" @@ -14681,6 +14684,9 @@ msgstr "" msgid "Contribute to GitLab" msgstr "" +msgid "Contributed" +msgstr "" + msgid "Contribution" msgstr "" @@ -38261,6 +38267,9 @@ msgstr "" msgid "Permissions and project features" msgstr "" +msgid "Personal" +msgstr "" + msgid "Personal Access Token" msgstr "" @@ -42224,9 +42233,6 @@ msgstr "" msgid "Projects in this group can use Git LFS" msgstr "" -msgid "Projects list" -msgstr "" - msgid "Projects shared with %{group_name}" msgstr "" @@ -51236,6 +51242,9 @@ msgstr "" msgid "Star labels to start sorting by priority." msgstr "" +msgid "Starred" +msgstr "" + msgid "Starred Projects" msgstr "" diff --git a/spec/features/dashboard/projects_spec.rb b/spec/features/dashboard/projects_spec.rb index d8364ec09442fe390e01323a05c52ecf338d2375..0af3199fd32f368cfd709aa285bde8406b08e730 100644 --- a/spec/features/dashboard/projects_spec.rb +++ b/spec/features/dashboard/projects_spec.rb @@ -17,11 +17,11 @@ stub_feature_flags(your_work_projects_vue: true) end - it 'mounts JS app' do + it 'mounts JS app and defaults to contributed tab' do visit dashboard_projects_path expect(page).to have_content('Projects') - expect(page).to have_content('Projects list') + expect(page).to have_content('Active tab: Contributed') end end @@ -34,7 +34,7 @@ visit dashboard_projects_path expect(page).to have_content('Projects') - expect(page).not_to have_content('Projects list') + expect(page).not_to have_content('Active tab') end it_behaves_like "an autodiscoverable RSS feed with current_user's feed token" do diff --git a/spec/frontend/projects/your_work/components/app_spec.js b/spec/frontend/projects/your_work/components/app_spec.js index 30b36938afee3d177fa86bc8e3dc42ed1187b304..a60b9971087348f0676dd4be658a5730807bed83 100644 --- a/spec/frontend/projects/your_work/components/app_spec.js +++ b/spec/frontend/projects/your_work/components/app_spec.js @@ -1,24 +1,145 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { nextTick } from 'vue'; +import { GlTabs } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { updateHistory } from '~/lib/utils/url_utility'; import YourWorkProjectsApp from '~/projects/your_work/components/app.vue'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { + PROJECT_DASHBOARD_TABS, + CONTRIBUTED_TAB, + STARRED_TAB, + PERSONAL_TAB, + MEMBER_TAB, +} from 'ee_else_ce/projects/your_work/constants'; -jest.mock('~/alert'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); describe('YourWorkProjectsApp', () => { let wrapper; const createComponent = () => { - wrapper = shallowMountExtended(YourWorkProjectsApp); + wrapper = mountExtended(YourWorkProjectsApp); }; - const findPageText = () => wrapper.find('p'); + const findPageTitle = () => wrapper.find('h1'); + const findGlTabs = () => wrapper.findComponent(GlTabs); + const findAllTabTitles = () => wrapper.findAllByTestId('projects-dashboard-tab-title'); + const findActiveTab = () => wrapper.find('.tab-pane.active'); describe('template', () => { beforeEach(() => { createComponent(); }); - it('renders Vue app with Projects list p tag', () => { - expect(findPageText().text()).toBe('Projects list'); + it('renders Vue app with Projects h1 tag', () => { + expect(findPageTitle().text()).toBe('Projects'); + }); + + it('renders all expected tabs with counts', () => { + const wrapperTabTitles = findAllTabTitles().wrappers.map((w) => w.text().replace(/ /g, '')); + const expectedTabTitles = PROJECT_DASHBOARD_TABS.map(({ text }) => `${text}0`); + + expect(wrapperTabTitles).toStrictEqual(expectedTabTitles); + }); + + it('defaults to Contributed tab as active', () => { + expect(findActiveTab().text()).toContain('Contributed'); + }); + }); + + describe.each` + path | expectedTab + ${'/'} | ${CONTRIBUTED_TAB} + ${'/dashboard'} | ${CONTRIBUTED_TAB} + ${'/dashboard/projects'} | ${CONTRIBUTED_TAB} + ${'/dashboard/projects/contributed'} | ${CONTRIBUTED_TAB} + ${'/dashboard/projects/starred'} | ${STARRED_TAB} + ${'/dashboard/projects/personal'} | ${PERSONAL_TAB} + ${'/dashboard/projects/member'} | ${MEMBER_TAB} + ${'/dashboard/projects/fake'} | ${CONTRIBUTED_TAB} + `('onMount when path is $path', ({ path, expectedTab }) => { + useMockLocationHelper(); + beforeEach(() => { + delete window.location; + window.location = new URL(`${TEST_HOST}/${path}`); + + createComponent(); + }); + + it('initializes to the correct tab', () => { + expect(findActiveTab().text()).toContain(expectedTab.text); + }); + }); + + describe('onTabUpdate', () => { + describe('when tab is already active', () => { + beforeEach(() => { + createComponent(); + }); + + it('does not update the url path', async () => { + findGlTabs().vm.$emit('input', 0); + + await nextTick(); + + expect(updateHistory).not.toHaveBeenCalled(); + }); + }); + + describe('when tab is a valid tab', () => { + beforeEach(() => { + createComponent(); + }); + + it('updates the url path correctly', async () => { + findGlTabs().vm.$emit('input', 2); + + await nextTick(); + + expect(updateHistory).toHaveBeenCalledWith({ + url: `/dashboard/projects/${PROJECT_DASHBOARD_TABS[2].value}`, + replace: true, + }); + }); + }); + + describe('when tab is an invalid tab', () => { + beforeEach(() => { + createComponent(); + }); + + it('update the url path with the default Contributed tab', async () => { + findGlTabs().vm.$emit('input', 100); + + await nextTick(); + + expect(updateHistory).toHaveBeenCalledWith({ + url: `/dashboard/projects/${CONTRIBUTED_TAB.value}`, + replace: true, + }); + }); + }); + + describe('when gon.relative_url_root is set', () => { + beforeEach(() => { + gon.relative_url_root = '/gitlab'; + createComponent(); + }); + + it('update the url path correctly with relative url', async () => { + findGlTabs().vm.$emit('input', 3); + + await nextTick(); + + expect(updateHistory).toHaveBeenCalledWith({ + url: `/gitlab/dashboard/projects/${PROJECT_DASHBOARD_TABS[3].value}`, + replace: true, + }); + }); }); }); }); diff --git a/spec/views/dashboard/projects/index.html.haml_spec.rb b/spec/views/dashboard/projects/index.html.haml_spec.rb index 3d9f988c4d2c1089bf9673818dfacad1fe403b9c..112766cb5c07da1120a394ffeb536d7b6193d449 100644 --- a/spec/views/dashboard/projects/index.html.haml_spec.rb +++ b/spec/views/dashboard/projects/index.html.haml_spec.rb @@ -37,11 +37,11 @@ render end - it 'does not render #js-your-work-projects-app and renders empty state' do + it 'renders #js-your-work-projects-app and does not render HAML empty state' do render - expect(rendered).not_to have_selector('#js-your-work-projects-app') - expect(rendered).to render_template('dashboard/projects/_zero_authorized_projects') + expect(rendered).to have_selector('#js-your-work-projects-app') + expect(rendered).not_to render_template('dashboard/projects/_zero_authorized_projects') end end end diff --git a/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb b/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb index 9885f4aa80215ef70dd058066c0a79267fe4ec7c..24d12621479464c9964b4c2ec9213207add478f3 100644 --- a/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb +++ b/spec/views/dashboard/projects/shared/_common.html.haml_spec.rb @@ -40,11 +40,11 @@ render end - it 'does not render #js-your-work-projects-app and renders empty state' do + it 'renders #js-your-work-projects-app and does not render HAML empty state' do render - expect(rendered).not_to have_selector('#js-your-work-projects-app') - expect(rendered).to render_template('dashboard/projects/_starred_empty_state') + expect(rendered).to have_selector('#js-your-work-projects-app') + expect(rendered).not_to render_template('dashboard/projects/_zero_authorized_projects') end end end