diff options
Diffstat (limited to 'app/assets/javascripts/organizations')
21 files changed, 877 insertions, 112 deletions
diff --git a/app/assets/javascripts/organizations/constants.js b/app/assets/javascripts/organizations/constants.js new file mode 100644 index 00000000000..8ade37b169e --- /dev/null +++ b/app/assets/javascripts/organizations/constants.js @@ -0,0 +1,4 @@ +export const RESOURCE_TYPE_GROUPS = 'groups'; +export const RESOURCE_TYPE_PROJECTS = 'projects'; + +export const ORGANIZATION_ROOT_ROUTE_NAME = 'root'; diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue index 10471cc1fdd..dba738de5e1 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/components/app.vue +++ b/app/assets/javascripts/organizations/groups_and_projects/components/app.vue @@ -13,9 +13,10 @@ import { FILTERED_SEARCH_TERM, TOKEN_EMPTY_SEARCH_TERM, } from '~/vue_shared/components/filtered_search_bar/constants'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; +import GroupsView from '../../shared/components/groups_view.vue'; +import ProjectsView from '../../shared/components/projects_view.vue'; import { - DISPLAY_QUERY_GROUPS, - DISPLAY_QUERY_PROJECTS, DISPLAY_LISTBOX_ITEMS, SORT_DIRECTION_ASC, SORT_DIRECTION_DESC, @@ -23,8 +24,6 @@ import { SORT_ITEM_CREATED, FILTERED_SEARCH_TERM_KEY, } from '../constants'; -import GroupsPage from './groups_page.vue'; -import ProjectsPage from './projects_page.vue'; export default { i18n: { @@ -45,14 +44,14 @@ export default { const { display } = this.$route.query; switch (display) { - case DISPLAY_QUERY_GROUPS: - return GroupsPage; + case RESOURCE_TYPE_GROUPS: + return GroupsView; - case DISPLAY_QUERY_PROJECTS: - return ProjectsPage; + case RESOURCE_TYPE_PROJECTS: + return ProjectsView; default: - return GroupsPage; + return GroupsView; } }, activeSortItem() { @@ -80,9 +79,9 @@ export default { displayListboxSelected() { const { display } = this.$route.query; - return [DISPLAY_QUERY_GROUPS, DISPLAY_QUERY_PROJECTS].includes(display) + return [RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS].includes(display) ? display - : DISPLAY_QUERY_GROUPS; + : RESOURCE_TYPE_GROUPS; }, }, methods: { diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue b/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue deleted file mode 100644 index 20db38403f7..00000000000 --- a/app/assets/javascripts/organizations/groups_and_projects/components/groups_page.vue +++ /dev/null @@ -1,43 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; -import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; -import groupsQuery from '../graphql/queries/groups.query.graphql'; -import { formatGroups } from '../utils'; - -export default { - i18n: { - errorMessage: s__( - 'Organization|An error occurred loading the groups. Please refresh the page to try again.', - ), - }, - components: { GlLoadingIcon, GroupsList }, - data() { - return { - groups: [], - }; - }, - apollo: { - groups: { - query: groupsQuery, - update(data) { - return formatGroups(data.organization.groups.nodes); - }, - error(error) { - createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); - }, - }, - }, - computed: { - isLoading() { - return this.$apollo.queries.groups.loading; - }, - }, -}; -</script> - -<template> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <groups-list v-else :groups="groups" show-group-icon /> -</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue b/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue deleted file mode 100644 index d6958ee996e..00000000000 --- a/app/assets/javascripts/organizations/groups_and_projects/components/projects_page.vue +++ /dev/null @@ -1,46 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; -import { createAlert } from '~/alert'; -import projectsQuery from '../graphql/queries/projects.query.graphql'; -import { formatProjects } from '../utils'; - -export default { - i18n: { - errorMessage: s__( - 'Organization|An error occurred loading the projects. Please refresh the page to try again.', - ), - }, - components: { - ProjectsList, - GlLoadingIcon, - }, - data() { - return { - projects: [], - }; - }, - apollo: { - projects: { - query: projectsQuery, - update(data) { - return formatProjects(data.organization.projects.nodes); - }, - error(error) { - createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); - }, - }, - }, - computed: { - isLoading() { - return this.$apollo.queries.projects.loading; - }, - }, -}; -</script> - -<template> - <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> - <projects-list v-else :projects="projects" show-project-icon /> -</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/constants.js b/app/assets/javascripts/organizations/groups_and_projects/constants.js index 529caa666a0..d79b632f6fb 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/constants.js +++ b/app/assets/javascripts/organizations/groups_and_projects/constants.js @@ -3,8 +3,6 @@ import { __ } from '~/locale'; export const DISPLAY_QUERY_GROUPS = 'groups'; export const DISPLAY_QUERY_PROJECTS = 'projects'; -export const ORGANIZATION_ROOT_ROUTE_NAME = 'root'; - export const FILTERED_SEARCH_TERM_KEY = 'search'; export const DISPLAY_LISTBOX_ITEMS = [ diff --git a/app/assets/javascripts/organizations/groups_and_projects/index.js b/app/assets/javascripts/organizations/groups_and_projects/index.js index f3f15c635f1..3e05e4d0a4c 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/index.js +++ b/app/assets/javascripts/organizations/groups_and_projects/index.js @@ -2,9 +2,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import VueRouter from 'vue-router'; import createDefaultClient from '~/lib/graphql'; -import resolvers from './graphql/resolvers'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { ORGANIZATION_ROOT_ROUTE_NAME } from '../constants'; +import resolvers from '../shared/graphql/resolvers'; import App from './components/app.vue'; -import { ORGANIZATION_ROOT_ROUTE_NAME } from './constants'; export const createRouter = () => { const routes = [{ path: '/', name: ORGANIZATION_ROOT_ROUTE_NAME }]; @@ -23,6 +24,16 @@ export const initOrganizationsGroupsAndProjects = () => { if (!el) return false; + const { + dataset: { appData }, + } = el; + const { + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + } = convertObjectPropsToCamelCase(JSON.parse(appData)); + Vue.use(VueRouter); const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(resolvers), @@ -34,6 +45,12 @@ export const initOrganizationsGroupsAndProjects = () => { name: 'OrganizationsGroupsAndProjects', apolloProvider, router, + provide: { + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + }, render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js new file mode 100644 index 00000000000..17ab7bd1d34 --- /dev/null +++ b/app/assets/javascripts/organizations/mock_data.js @@ -0,0 +1,258 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +// This is temporary mock data that will be removed when completing the following: +// https://gitlab.com/gitlab-org/gitlab/-/issues/420777 +// https://gitlab.com/gitlab-org/gitlab/-/issues/421441 + +export const organization = { + id: 'gid://gitlab/Organization/1', + __typename: 'Organization', +}; + +export const organizationProjects = { + nodes: [ + { + id: 'gid://gitlab/Project/8', + nameWithNamespace: 'Twitter / Typeahead.Js', + webUrl: 'http://127.0.0.1:3000/twitter/Typeahead.Js', + topics: ['JavaScript', 'Vue.js'], + forksCount: 4, + avatarUrl: null, + starCount: 0, + visibility: 'public', + openIssuesCount: 48, + descriptionHtml: + '<p data-sourcepos="1:1-1:59" dir="auto">Optio et reprehenderit enim doloremque deserunt et commodi.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: true, + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Project/7', + nameWithNamespace: 'Flightjs / Flight', + webUrl: 'http://127.0.0.1:3000/flightjs/Flight', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'private', + openIssuesCount: 37, + descriptionHtml: + '<p data-sourcepos="1:1-1:49" dir="auto">Dolor dicta rerum et ut eius voluptate earum qui.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 20, + }, + }, + { + id: 'gid://gitlab/Project/6', + nameWithNamespace: 'Jashkenas / Underscore', + webUrl: 'http://127.0.0.1:3000/jashkenas/Underscore', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'private', + openIssuesCount: 34, + descriptionHtml: + '<p data-sourcepos="1:1-1:52" dir="auto">Incidunt est aliquam autem nihil eveniet quis autem.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Project/5', + nameWithNamespace: 'Commit451 / Lab Coat', + webUrl: 'http://127.0.0.1:3000/Commit451/lab-coat', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'internal', + openIssuesCount: 49, + descriptionHtml: + '<p data-sourcepos="1:1-1:34" dir="auto">Sint eos dolorem impedit rerum et.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Project/1', + nameWithNamespace: 'Toolbox / Gitlab Smoke Tests', + webUrl: 'http://127.0.0.1:3000/toolbox/gitlab-smoke-tests', + topics: [], + forksCount: 0, + avatarUrl: null, + starCount: 0, + visibility: 'internal', + openIssuesCount: 34, + descriptionHtml: + '<p data-sourcepos="1:1-1:40" dir="auto">Veritatis error laboriosam libero autem.</p>', + issuesAccessLevel: 'enabled', + forkingAccessLevel: 'enabled', + isForked: false, + accessLevel: { + integerValue: 30, + }, + }, + ], +}; + +export const organizationGroups = { + nodes: [ + { + id: 'gid://gitlab/Group/29', + fullName: 'Commit451', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/Commit451', + descriptionHtml: + '<p data-sourcepos="1:1-1:52" dir="auto">Autem praesentium vel ut ratione itaque ullam culpa.</p>', + avatarUrl: null, + descendantGroupsCount: 0, + projectsCount: 3, + groupMembersCount: 2, + visibility: 'public', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Group/33', + fullName: 'Flightjs', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/flightjs', + descriptionHtml: + '<p data-sourcepos="1:1-1:60" dir="auto">Ipsa reiciendis deleniti officiis illum nostrum quo aliquam.</p>', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 3, + groupMembersCount: 1, + visibility: 'private', + accessLevel: { + integerValue: 20, + }, + }, + { + id: 'gid://gitlab/Group/24', + fullName: 'Gitlab Org', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/gitlab-org', + descriptionHtml: + '<p data-sourcepos="1:1-1:64" dir="auto">Dolorem dolorem omnis impedit cupiditate pariatur officia velit.</p>', + avatarUrl: null, + descendantGroupsCount: 1, + projectsCount: 1, + groupMembersCount: 2, + visibility: 'internal', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Group/27', + fullName: 'Gnuwget', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/gnuwgetf', + descriptionHtml: + '<p data-sourcepos="1:1-1:47" dir="auto">Culpa soluta aut eius dolores est vel sapiente.</p>', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 2, + groupMembersCount: 3, + visibility: 'public', + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Group/31', + fullName: 'Jashkenas', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/jashkenas', + descriptionHtml: '<p data-sourcepos="1:1-1:25" dir="auto">Ut ut id aliquid nostrum.</p>', + avatarUrl: null, + descendantGroupsCount: 3, + projectsCount: 3, + groupMembersCount: 10, + visibility: 'private', + accessLevel: { + integerValue: 10, + }, + }, + { + id: 'gid://gitlab/Group/22', + fullName: 'Toolbox', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/toolbox', + descriptionHtml: + '<p data-sourcepos="1:1-1:46" dir="auto">Quo voluptatem magnam facere voluptates alias.</p>', + avatarUrl: null, + descendantGroupsCount: 2, + projectsCount: 3, + groupMembersCount: 40, + visibility: 'internal', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Group/35', + fullName: 'Twitter', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/twitter', + descriptionHtml: + '<p data-sourcepos="1:1-1:40" dir="auto">Quae nulla consequatur assumenda id quo.</p>', + avatarUrl: null, + descendantGroupsCount: 20, + projectsCount: 30, + groupMembersCount: 100, + visibility: 'public', + accessLevel: { + integerValue: 40, + }, + }, + { + id: 'gid://gitlab/Group/73', + fullName: 'test', + parent: null, + webUrl: 'http://127.0.0.1:3000/groups/test', + descriptionHtml: '', + avatarUrl: null, + descendantGroupsCount: 1, + projectsCount: 1, + groupMembersCount: 1, + visibility: 'private', + accessLevel: { + integerValue: 30, + }, + }, + { + id: 'gid://gitlab/Group/74', + fullName: 'Twitter / test subgroup', + parent: { + id: 'gid://gitlab/Group/35', + }, + webUrl: 'http://127.0.0.1:3000/groups/twitter/test-subgroup', + descriptionHtml: '', + avatarUrl: null, + descendantGroupsCount: 4, + projectsCount: 4, + groupMembersCount: 4, + visibility: 'internal', + accessLevel: { + integerValue: 20, + }, + }, + ], +}; diff --git a/app/assets/javascripts/organizations/shared/components/groups_view.vue b/app/assets/javascripts/organizations/shared/components/groups_view.vue new file mode 100644 index 00000000000..eaa3017ef97 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/groups_view.vue @@ -0,0 +1,82 @@ +<script> +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { s__, __ } from '~/locale'; +import GroupsList from '~/vue_shared/components/groups_list/groups_list.vue'; +import groupsQuery from '../graphql/queries/groups.query.graphql'; +import { formatGroups } from '../utils'; + +export default { + i18n: { + errorMessage: s__( + 'Organization|An error occurred loading the groups. Please refresh the page to try again.', + ), + emptyState: { + title: s__("Organization|You don't have any groups yet."), + description: s__( + 'Organization|A group is a collection of several projects. If you organize your projects under a group, it works like a folder.', + ), + primaryButtonText: __('New group'), + }, + }, + components: { GlLoadingIcon, GlEmptyState, GroupsList }, + inject: { + groupsEmptyStateSvgPath: {}, + newGroupPath: { + default: null, + }, + }, + props: { + shouldShowEmptyStateButtons: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + groups: [], + }; + }, + apollo: { + groups: { + query: groupsQuery, + update(data) { + return formatGroups(data.organization.groups.nodes); + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.groups.loading; + }, + emptyStateProps() { + const baseProps = { + svgHeight: 144, + svgPath: this.groupsEmptyStateSvgPath, + title: this.$options.i18n.emptyState.title, + description: this.$options.i18n.emptyState.description, + }; + + if (this.shouldShowEmptyStateButtons && this.newGroupPath) { + return { + ...baseProps, + primaryButtonLink: this.newGroupPath, + primaryButtonText: this.$options.i18n.emptyState.primaryButtonText, + }; + } + + return baseProps; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> + <groups-list v-else-if="groups.length" :groups="groups" show-group-icon /> + <gl-empty-state v-else v-bind="emptyStateProps" /> +</template> diff --git a/app/assets/javascripts/organizations/shared/components/projects_view.vue b/app/assets/javascripts/organizations/shared/components/projects_view.vue new file mode 100644 index 00000000000..9bf4e597884 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/components/projects_view.vue @@ -0,0 +1,86 @@ +<script> +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue'; +import { createAlert } from '~/alert'; +import projectsQuery from '../graphql/queries/projects.query.graphql'; +import { formatProjects } from '../utils'; + +export default { + i18n: { + errorMessage: s__( + 'Organization|An error occurred loading the projects. Please refresh the page to try again.', + ), + emptyState: { + title: s__("Organization|You don't have any projects yet."), + description: s__( + 'GroupsEmptyState|Projects are where you can store your code, access issues, wiki, and other features of Gitlab.', + ), + primaryButtonText: __('New project'), + }, + }, + components: { + ProjectsList, + GlLoadingIcon, + GlEmptyState, + }, + inject: { + projectsEmptyStateSvgPath: {}, + newProjectPath: { + default: null, + }, + }, + props: { + shouldShowEmptyStateButtons: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + projects: [], + }; + }, + apollo: { + projects: { + query: projectsQuery, + update(data) { + return formatProjects(data.organization.projects.nodes); + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.projects.loading; + }, + emptyStateProps() { + const baseProps = { + svgHeight: 144, + svgPath: this.projectsEmptyStateSvgPath, + title: this.$options.i18n.emptyState.title, + description: this.$options.i18n.emptyState.description, + }; + + if (this.shouldShowEmptyStateButtons && this.newProjectPath) { + return { + ...baseProps, + primaryButtonLink: this.newProjectPath, + primaryButtonText: this.$options.i18n.emptyState.primaryButtonText, + }; + } + + return baseProps; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> + <projects-list v-else-if="projects.length" :projects="projects" show-project-icon /> + <gl-empty-state v-else v-bind="emptyStateProps" /> +</template> diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/groups.query.graphql index 842c601e326..842c601e326 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/groups.query.graphql +++ b/app/assets/javascripts/organizations/shared/graphql/queries/groups.query.graphql diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/projects.query.graphql index 2a7971e1106..2a7971e1106 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/queries/projects.query.graphql +++ b/app/assets/javascripts/organizations/shared/graphql/queries/projects.query.graphql diff --git a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js b/app/assets/javascripts/organizations/shared/graphql/resolvers.js index 8a375b28797..c78266b0476 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/shared/graphql/resolvers.js @@ -1,8 +1,4 @@ -import { - organization, - organizationProjects, - organizationGroups, -} from 'jest/organizations/groups_and_projects/mock_data'; +import { organization, organizationProjects, organizationGroups } from '../../mock_data'; export default { Query: { diff --git a/app/assets/javascripts/organizations/groups_and_projects/utils.js b/app/assets/javascripts/organizations/shared/utils.js index d2a4e05e806..c1aafefc553 100644 --- a/app/assets/javascripts/organizations/groups_and_projects/utils.js +++ b/app/assets/javascripts/organizations/shared/utils.js @@ -1,5 +1,5 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/projects_list/constants'; +import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants'; export const formatProjects = (projects) => projects.map(({ id, nameWithNamespace, accessLevel, webUrl, ...project }) => ({ @@ -13,11 +13,14 @@ export const formatProjects = (projects) => }, webUrl, editPath: `${webUrl}/edit`, - actions: [ACTION_EDIT, ACTION_DELETE], + availableActions: [ACTION_EDIT, ACTION_DELETE], })); export const formatGroups = (groups) => - groups.map(({ id, ...group }) => ({ + groups.map(({ id, webUrl, ...group }) => ({ ...group, id: getIdFromGraphQLId(id), + webUrl, + editPath: `${webUrl}/-/edit`, + availableActions: [ACTION_EDIT, ACTION_DELETE], })); diff --git a/app/assets/javascripts/organizations/show/components/app.vue b/app/assets/javascripts/organizations/show/components/app.vue new file mode 100644 index 00000000000..47264d80454 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/app.vue @@ -0,0 +1,37 @@ +<script> +import OrganizationAvatar from './organization_avatar.vue'; +import GroupsAndProjects from './groups_and_projects.vue'; +import AssociationCounts from './association_counts.vue'; + +export default { + name: 'OrganizationShowApp', + components: { OrganizationAvatar, GroupsAndProjects, AssociationCounts }, + props: { + organization: { + type: Object, + required: true, + }, + groupsAndProjectsOrganizationPath: { + type: String, + required: true, + }, + associationCounts: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-py-6"> + <organization-avatar :organization="organization" /> + <association-counts + :association-counts="associationCounts" + :groups-and-projects-organization-path="groupsAndProjectsOrganizationPath" + /> + <groups-and-projects + :groups-and-projects-organization-path="groupsAndProjectsOrganizationPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/components/association_count_card.vue b/app/assets/javascripts/organizations/show/components/association_count_card.vue new file mode 100644 index 00000000000..0567f43132f --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/association_count_card.vue @@ -0,0 +1,54 @@ +<script> +import { GlIcon, GlLink, GlCard } from '@gitlab/ui'; +import { numberToMetricPrefix } from '~/lib/utils/number_utils'; +import { __ } from '~/locale'; + +export default { + name: 'AssociationCountCard', + components: { GlIcon, GlLink, GlCard }, + props: { + title: { + type: String, + required: true, + }, + iconName: { + type: String, + required: true, + }, + count: { + type: Number, + required: true, + }, + linkHref: { + type: String, + required: true, + }, + linkText: { + type: String, + required: false, + default: __('View all'), + }, + }, + computed: { + formattedCount() { + return numberToMetricPrefix(this.count, 0); + }, + }, +}; +</script> + +<template> + <gl-card> + <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> + <div class="gl-display-flex gl-align-items-center gl-text-gray-700"> + <gl-icon :name="iconName" /> + <span class="gl-ml-2">{{ title }}</span> + </div> + <gl-link :href="linkHref">{{ linkText }}</gl-link> + </div> + <span + class="gl-font-size-h-display gl-font-weight-bold gl-line-height-ratio-1000 gl-mt-2 gl-display-block" + >{{ formattedCount }}</span + > + </gl-card> +</template> diff --git a/app/assets/javascripts/organizations/show/components/association_counts.vue b/app/assets/javascripts/organizations/show/components/association_counts.vue new file mode 100644 index 00000000000..3b312924bd2 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/association_counts.vue @@ -0,0 +1,71 @@ +<script> +import { __, s__ } from '~/locale'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; +import AssociationCountCard from './association_count_card.vue'; + +export default { + name: 'AssociationCounts', + i18n: { + groups: __('Groups'), + projects: __('Projects'), + users: __('Users'), + viewAll: __('View all'), + manage: s__('Organization|Manage'), + }, + components: { AssociationCountCard }, + props: { + associationCounts: { + type: Object, + required: true, + }, + groupsAndProjectsOrganizationPath: { + type: String, + required: true, + }, + }, + computed: { + groupsLinkHref() { + return `${this.groupsAndProjectsOrganizationPath}?display=${RESOURCE_TYPE_GROUPS}`; + }, + projectsLinkHref() { + return `${this.groupsAndProjectsOrganizationPath}?display=${RESOURCE_TYPE_PROJECTS}`; + }, + associationCountCards() { + return [ + { + title: this.$options.i18n.groups, + iconName: 'group', + count: this.associationCounts.groups, + linkHref: this.groupsLinkHref, + }, + { + title: this.$options.i18n.projects, + iconName: 'project', + count: this.associationCounts.projects, + linkHref: this.projectsLinkHref, + }, + { + title: this.$options.i18n.users, + iconName: 'users', + count: this.associationCounts.users, + linkText: this.$options.i18n.manage, + // TODO: update `linkHref` prop to point to users route + // https://gitlab.com/gitlab-org/gitlab/-/issues/409313 + linkHref: '/', + }, + ]; + }, + }, +}; +</script> + +<template> + <div class="gl-display-grid gl-lg-grid-template-columns-4 gl-mt-5 gl-gap-5"> + <association-count-card + v-for="props in associationCountCards" + :key="props.title" + v-bind="props" + class="gl-w-full" + /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/components/groups_and_projects.vue b/app/assets/javascripts/organizations/show/components/groups_and_projects.vue new file mode 100644 index 00000000000..e8972f3b380 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/groups_and_projects.vue @@ -0,0 +1,110 @@ +<script> +import { GlCollapsibleListbox, GlLink } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import { s__, __ } from '~/locale'; +import GroupsView from '../../shared/components/groups_view.vue'; +import ProjectsView from '../../shared/components/projects_view.vue'; +import { RESOURCE_TYPE_GROUPS, RESOURCE_TYPE_PROJECTS } from '../../constants'; +import { FILTER_FREQUENTLY_VISITED } from '../constants'; +import { buildDisplayListboxItem } from '../utils'; + +export default { + name: 'OrganizationFrontPageGroupsAndProjects', + i18n: { + displayListboxLabel: __('Display'), + viewAll: s__('Organization|View all'), + }, + displayListboxLabelId: 'display-listbox-label', + components: { GlCollapsibleListbox, GlLink }, + displayListboxItems: [ + buildDisplayListboxItem({ + filter: FILTER_FREQUENTLY_VISITED, + resourceType: RESOURCE_TYPE_PROJECTS, + text: s__('Organization|Frequently visited projects'), + }), + buildDisplayListboxItem({ + filter: FILTER_FREQUENTLY_VISITED, + resourceType: RESOURCE_TYPE_GROUPS, + text: s__('Organization|Frequently visited groups'), + }), + ], + props: { + groupsAndProjectsOrganizationPath: { + type: String, + required: true, + }, + }, + computed: { + displayListboxSelected() { + const { display } = this.$route.query; + const [{ value: fallbackSelected }] = this.$options.displayListboxItems; + + return ( + this.$options.displayListboxItems.find(({ value }) => value === display)?.value || + fallbackSelected + ); + }, + resourceTypeSelected() { + return [RESOURCE_TYPE_PROJECTS, RESOURCE_TYPE_GROUPS].find((resourceType) => + this.displayListboxSelected.endsWith(resourceType), + ); + }, + routerView() { + switch (this.resourceTypeSelected) { + case RESOURCE_TYPE_GROUPS: + return GroupsView; + + case RESOURCE_TYPE_PROJECTS: + return ProjectsView; + + default: + return ProjectsView; + } + }, + groupsAndProjectsOrganizationPathWithQueryParam() { + return `${this.groupsAndProjectsOrganizationPath}?display=${this.resourceTypeSelected}`; + }, + }, + methods: { + pushQuery(query) { + const currentQuery = this.$route.query; + + if (isEqual(currentQuery, query)) { + return; + } + + this.$router.push({ query }); + }, + onDisplayListboxSelect(display) { + this.pushQuery({ display }); + }, + }, +}; +</script> + +<template> + <div class="gl-mt-7"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div> + <label + :id="$options.displayListboxLabelId" + class="gl-display-block gl-mb-2" + data-testid="label" + >{{ $options.i18n.displayListboxLabel }}</label + > + <gl-collapsible-listbox + block + toggle-class="gl-w-30" + :selected="displayListboxSelected" + :items="$options.displayListboxItems" + :toggle-aria-labelled-by="$options.displayListboxLabelId" + @select="onDisplayListboxSelect" + /> + </div> + <gl-link class="gl-mt-5" :href="groupsAndProjectsOrganizationPathWithQueryParam">{{ + $options.i18n.viewAll + }}</gl-link> + </div> + <component :is="routerView" should-show-empty-state-buttons class="gl-mt-5" /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/components/organization_avatar.vue b/app/assets/javascripts/organizations/show/components/organization_avatar.vue new file mode 100644 index 00000000000..c57ee0ea5b5 --- /dev/null +++ b/app/assets/javascripts/organizations/show/components/organization_avatar.vue @@ -0,0 +1,71 @@ +<script> +import { GlAvatar, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { + VISIBILITY_TYPE_ICON, + ORGANIZATION_VISIBILITY_TYPE, + VISIBILITY_LEVEL_PUBLIC_STRING, +} from '~/visibility_level/constants'; + +export default { + name: 'OrganizationAvatar', + AVATAR_SHAPE_OPTION_RECT, + i18n: { + copyButtonText: s__('Organization|Copy organization ID'), + orgId: s__('Organization|Org ID'), + }, + components: { GlAvatar, GlIcon, ClipboardButton }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + organization: { + type: Object, + required: true, + }, + }, + computed: { + visibilityIcon() { + return VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_PUBLIC_STRING]; + }, + visibilityTooltip() { + return ORGANIZATION_VISIBILITY_TYPE[VISIBILITY_LEVEL_PUBLIC_STRING]; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-avatar + :entity-id="organization.id" + :entity-name="organization.name" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="64" + /> + <div class="gl-ml-3"> + <div class="gl-display-flex gl-align-items-center"> + <h1 class="gl-m-0 gl-font-size-h1">{{ organization.name }}</h1> + <gl-icon + v-gl-tooltip="visibilityTooltip" + :name="visibilityIcon" + class="gl-text-secondary gl-ml-3" + /> + </div> + <div class="gl-display-flex gl-align-items-center"> + <span class="gl-text-secondary gl-font-sm" + >{{ $options.i18n.orgId }}: {{ organization.id }}</span + > + <clipboard-button + class="gl-ml-2" + category="tertiary" + size="small" + :title="$options.i18n.copyButtonText" + :text="organization.id.toString()" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/organizations/show/constants.js b/app/assets/javascripts/organizations/show/constants.js new file mode 100644 index 00000000000..fe29af67f6b --- /dev/null +++ b/app/assets/javascripts/organizations/show/constants.js @@ -0,0 +1 @@ +export const FILTER_FREQUENTLY_VISITED = 'frequently_visited'; diff --git a/app/assets/javascripts/organizations/show/index.js b/app/assets/javascripts/organizations/show/index.js new file mode 100644 index 00000000000..83a9c37e325 --- /dev/null +++ b/app/assets/javascripts/organizations/show/index.js @@ -0,0 +1,63 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import VueApollo from 'vue-apollo'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import createDefaultClient from '~/lib/graphql'; +import { ORGANIZATION_ROOT_ROUTE_NAME } from '../constants'; +import resolvers from '../shared/graphql/resolvers'; +import App from './components/app.vue'; + +export const createRouter = () => { + const routes = [{ path: '/', name: ORGANIZATION_ROOT_ROUTE_NAME }]; + + const router = new VueRouter({ + routes, + base: '/', + mode: 'history', + }); + + return router; +}; + +export const initOrganizationsShow = () => { + const el = document.getElementById('js-organizations-show'); + + if (!el) return false; + + const { + dataset: { appData }, + } = el; + const { + organization, + groupsAndProjectsOrganizationPath, + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + associationCounts, + } = convertObjectPropsToCamelCase(JSON.parse(appData)); + + Vue.use(VueRouter); + const router = createRouter(); + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + name: 'OrganizationShowRoot', + apolloProvider, + router, + provide: { + projectsEmptyStateSvgPath, + groupsEmptyStateSvgPath, + newGroupPath, + newProjectPath, + }, + render(createElement) { + return createElement(App, { + props: { organization, groupsAndProjectsOrganizationPath, associationCounts }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/show/utils.js b/app/assets/javascripts/organizations/show/utils.js new file mode 100644 index 00000000000..b4f935563aa --- /dev/null +++ b/app/assets/javascripts/organizations/show/utils.js @@ -0,0 +1,4 @@ +export const buildDisplayListboxItem = ({ filter, resourceType, text }) => ({ + text, + value: `${filter}_${resourceType}`, +}); |