diff options
author | Ezekiel Kigbo <ekigbo@gitlab.com> | 2019-05-05 16:35:31 +0100 |
---|---|---|
committer | Ezekiel Kigbo <ekigbo@gitlab.com> | 2019-05-16 15:43:19 -0500 |
commit | 4e5503f35a114f4d23c1bfc750cb1eaf193c9eba (patch) | |
tree | 8a71d6062f5491bad14131da200f262607eb3a1e | |
parent | cb45b7193df2916e881dfb3a8efc29dc7c2b9006 (diff) | |
download | gitlab-ce-56992-mount-project-lists-vue-component.tar.gz |
Mount projects list vue app56992-mount-project-lists-vue-component
Bootstrap data from the backend
-rw-r--r-- | app/assets/javascripts/projects_list.js | 23 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/projects_list/index.vue | 72 | ||||
-rw-r--r-- | app/controllers/dashboard/projects_controller.rb | 4 | ||||
-rw-r--r-- | app/helpers/projects_helper.rb | 22 | ||||
-rw-r--r-- | app/views/shared/projects/_list.html.haml | 33 | ||||
-rw-r--r-- | spec/javascripts/vue_shared/components/projects_list/project_list_item_spec.js | 148 |
6 files changed, 289 insertions, 13 deletions
diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js index c67d59d2be5..6a08eecc48a 100644 --- a/app/assets/javascripts/projects_list.js +++ b/app/assets/javascripts/projects_list.js @@ -1,9 +1,27 @@ +import Vue from 'vue'; +import VueProjectsList from '~/vue_shared/components/projects_list/index.vue'; import FilterableList from './filterable_list'; /** * Makes search request for projects when user types a value in the search input. * Updates the html content of the page with the received one. */ + +function fetchData(mountPoint) { + const { projects = [] } = mountPoint.dataset; + return JSON.parse(projects); +} + +export function initProjectsList() { + const mountPoint = document.querySelector('.vjs-projects-list'); + return new Vue({ + el: mountPoint, + render: createElement => + createElement(VueProjectsList, { + props: { projects: fetchData(mountPoint) }, + }), + }); +} export default class ProjectsList { constructor() { const form = document.querySelector('form#project-filter-form'); @@ -14,5 +32,10 @@ export default class ProjectsList { const list = new FilterableList(form, filter, holder); list.initSearch(); } + + const { features = {} } = window.gon; + if (features.vueProjectsList) { + initProjectsList(); + } } } diff --git a/app/assets/javascripts/vue_shared/components/projects_list/index.vue b/app/assets/javascripts/vue_shared/components/projects_list/index.vue new file mode 100644 index 00000000000..47d4835d950 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/projects_list/index.vue @@ -0,0 +1,72 @@ +<script> +import Api from '~/api'; + +/** + * Renders a list of projects + */ +export const PROJECT_TABS = { + default: '', + explore: 'EXPLORE', + starred: 'STARRED', +}; + +const basename = url => + url + .split('/') + .filter(i => i.length) + .reverse()[0]; + +const currentTab = () => { + const tab = basename(window.location.pathname); + return PROJECT_TABS[tab] || PROJECT_TABS.default; +}; + +// TODO: not sure if theres a better way +const isExploreProjectsTab = () => { + return currentTab() === PROJECT_TABS.EXPLORE; +}; + +export default { + components: { + // ProjectListItem, + }, + props: { + projects: { + type: Array, + default: [], + }, + size: 48, // size of what? + hideArchived: { + type: Boolean, + default: false, + }, + }, + created() { + console.log('ProjecstListApp::loaded'); + console.log('ProjecstListApp::projects', this.projects); + }, + computed: { + isExploreProjectsTab, + hasProjects: function() { + const { projects } = this; + return projects && projects.length; + }, + }, +}; +</script> +<template> + <div> + <!-- TODO: loading spinner --> + <!-- TODO: empty / no projects state --> + <ul class="projects-list"> + <template v-for="project in projects"> + <!-- <project-list-item + :key="project.id" + :project="project" + :is-explore="isExploreProjectsTab" + :hide-archived="hideArchived" + />--> + </template> + </ul> + </div> +</template> diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index 70811f5ea59..200b1cc679f 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -9,6 +9,10 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController before_action :default_sorting skip_cross_project_access_check :index, :starred + before_action do + push_frontend_feature_flag(:vue_projects_list) + end + def index @projects = load_projects(params.merge(non_public: true)) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 91d15e0e4ea..a8acba98bdb 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -631,4 +631,26 @@ module ProjectsHelper project.builds_enabled? && !project.repository.gitlab_ci_yml end + + def project_additional_fields(project) + additional_fields = { + # TODO: namespace is not always correct + # for cases where we are the owner we should have the 'owner' object available + namespace: project.namespace, + open_merge_requests_count: project.open_merge_requests_count, + open_issues_count: project.open_issues_count, + visibility: { + level: project.visibility_level, + description: visibility_icon_description(project) + } + # owner: project.owner + } + project.as_json.merge(additional_fields) + end + + def projects_data_json(projects) + projects = projects.to_a.map { |project| project_additional_fields(project) } + + projects.to_json.html_safe + end end diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 13847cd9be1..294aee9822b 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -29,21 +29,28 @@ .js-projects-list-holder - if any_projects?(projects) - load_pipeline_status(projects) - %ul.projects-list{ class: css_classes } - - projects.each_with_index do |project, i| - - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil - = render "shared/projects/project", project: project, skip_namespace: skip_namespace, - avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar, - forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests, - issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode + -# maybe load merge request count here + - if Feature.enabled?(:vue_projects_list) + -# Mount vue app + %ul.vjs-projects-list{ class: css_classes, data: { projects: projects_data_json(projects) } } + = paginate_collection(projects, remote: remote) unless skip_pagination + - else + %ul.projects-list{ class: css_classes } + - projects.each_with_index do |project, i| + - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil + = render "shared/projects/project", project: project, skip_namespace: skip_namespace, + avatar: avatar, stars: stars, css_class: css_class, ci: ci, use_creator_avatar: use_creator_avatar, + forks: forks, show_last_commit_as_description: show_last_commit_as_description, user: user, merge_requests: merge_requests, + issues: issues, pipeline_status: pipeline_status, compact_mode: compact_mode - - if @private_forks_count && @private_forks_count > 0 - %li.project-row.private-forks-notice - = icon('lock fw', base: 'circle', class: 'fa-lg private-fork-icon') - %strong= pluralize(@private_forks_count, 'private fork') - %span you have no access to. - = paginate_collection(projects, remote: remote) unless skip_pagination + - if @private_forks_count && @private_forks_count > 0 + %li.project-row.private-forks-notice + = icon('lock fw', base: 'circle', class: 'fa-lg private-fork-icon') + %strong= pluralize(@private_forks_count, 'private fork') + %span you have no access to. + = paginate_collection(projects, remote: remote) unless skip_pagination - else + -# TODO: check these states - if @contributed_projects = render partial: 'shared/empty_states/profile_tabs', locals: { illustration_path: contributed_projects_illustration_path, current_user_empty_message_header: contributed_projects_current_user_empty_message_header, diff --git a/spec/javascripts/vue_shared/components/projects_list/project_list_item_spec.js b/spec/javascripts/vue_shared/components/projects_list/project_list_item_spec.js new file mode 100644 index 00000000000..57f21d904b5 --- /dev/null +++ b/spec/javascripts/vue_shared/components/projects_list/project_list_item_spec.js @@ -0,0 +1,148 @@ +import Vue from 'vue'; +import ProjectListItem from '~/vue_shared/components/projects_list/project_list_item.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +// TODO: move to shallow mount / vue test utils ?? + +loadJSONFixtures('projects.json'); +const projects = getJSONFixture('projects.json'); +const ownedProject = projects[0]; +const selectedProject = projects[1]; + +const createComponent = (props, defaultComponent = ProjectListItem) => { + const Component = Vue.extend(defaultComponent); + + return mountComponent(Component, props); +}; + +describe('ProjectListItem', () => { + let vm; + + beforeEach(() => { + // const pathname = '/dashboard/projects'; + // spyOn(window.location, 'pathname', 'get').and.returnValue(pathname); + vm = createComponent({ project: selectedProject }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + // TODO: Possible BE changes needed for missing fields: + // - Populate namespace with correct data: + // * no group set => should default to the user's name + // * subgroups?? + // * pipeline-status + // * last_activity_at (api) != last_activity_date (haml) + // * open_merge_requests_count + // * open_issues_count + // * project visibility + // - description field: should show the last commit as a description if available otherwise just the project description + + // TODO: additional cases + // - project.archived? + + describe('data', () => { + it('returns default data props', () => { + const projectFields = [ + 'id', + 'name', + 'description', + 'path', + 'path_with_namespace', + 'created_at', + 'tag_list', + 'star_count', + 'forks_count', + 'open_issues_count', + 'last_activity_at', + ]; + projectFields.forEach(field => { + expect(vm.project[field]).toBe(selectedProject[field]); + }); + + const namespaceFields = ['id', 'name', 'path', 'kind', 'full_path', 'parent_id']; + namespaceFields.forEach(field => { + expect(vm.project.namespace[field]).toBe(selectedProject.namespace[field]); + }); + }); + }); + + describe('template', () => { + describe('User owns project', () => { + beforeEach(() => { + vm = createComponent({ project: ownedProject }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it(`renders the owner name for the namespace`, () => { + expect(vm.$el.querySelector('.namespace-name').innerText).toEqual( + `${ownedProject.owner.name} /`, + ); + }); + + it(`renders the project name`, () => { + expect(vm.$el.querySelector('.project-name').innerText).toEqual(`${ownedProject.name}`); + }); + }); + + describe('User does not own the project', () => { + beforeEach(() => { + vm = createComponent({ project: selectedProject }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it(`renders the project name for the namespace`, () => { + expect(vm.$el.querySelector('.namespace-name').innerText).toEqual( + `${selectedProject.namespace.name} /`, + ); + }); + + it(`renders the project name`, () => { + expect(vm.$el.querySelector('.project-name').innerText).toEqual(`${selectedProject.name}`); + }); + }); + + describe('Viewing explore projects', () => { + beforeEach(() => { + vm = createComponent({ project: selectedProject, isExploreTab: true }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('does not render project access', () => {}); + }); + + describe('Project meta', () => { + it('renders the correct project name', () => { + expect(vm.$el.querySelector('.project-name').innerText).toBe(selectedProject.name); + }); + + it('renders the project avatar as a link', () => { + expect(vm.$el.querySelector('.avatar-container a').href).toContain(selectedProject.path); + }); + + it('renders the correct project description', () => { + expect(vm.$el.querySelector('.description').innerText).toBe(selectedProject.description); + expect(vm.$el.querySelector('.description').classList).not.toContain('no-description'); + }); + + it('does not render the description if it is missing', () => { + ['', null].forEach(description => { + const noDescVm = createComponent({ project: { ...selectedProject, description } }); + + expect(noDescVm.$el.querySelector('.description')).toBeNull(); + expect(noDescVm.$el.classList).toContain('no-description'); + }); + }); + }); + }); +}); |