summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorEzekiel Kigbo <ekigbo@gitlab.com>2019-05-05 16:35:31 +0100
committerEzekiel Kigbo <ekigbo@gitlab.com>2019-05-16 15:43:19 -0500
commit4e5503f35a114f4d23c1bfc750cb1eaf193c9eba (patch)
tree8a71d6062f5491bad14131da200f262607eb3a1e
parentcb45b7193df2916e881dfb3a8efc29dc7c2b9006 (diff)
downloadgitlab-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.js23
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/index.vue72
-rw-r--r--app/controllers/dashboard/projects_controller.rb4
-rw-r--r--app/helpers/projects_helper.rb22
-rw-r--r--app/views/shared/projects/_list.html.haml33
-rw-r--r--spec/javascripts/vue_shared/components/projects_list/project_list_item_spec.js148
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 &nbsp;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 &nbsp;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');
+ });
+ });
+ });
+ });
+});