From f5cb98107b09f8c76d8c58414a3b23e56e708495 Mon Sep 17 00:00:00 2001 From: Thomas Hutterer <thutterer@gitlab.com> Date: Mon, 9 Sep 2024 09:33:46 +0000 Subject: [PATCH] Add EmptyState component to new Vue To-Do app --- .../todos/components/todos_app.vue | 13 +++ .../todos/components/todos_empty_state.vue | 80 +++++++++++++++++++ app/assets/javascripts/todos/constants.js | 10 +++ app/assets/javascripts/todos/index.js | 6 ++ app/views/dashboard/todos/vue.html.haml | 3 +- locale/gitlab.pot | 6 ++ .../components/todos_empty_state_spec.js | 79 ++++++++++++++++++ 7 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/todos/components/todos_empty_state.vue create mode 100644 spec/frontend/todos/components/todos_empty_state_spec.js diff --git a/app/assets/javascripts/todos/components/todos_app.vue b/app/assets/javascripts/todos/components/todos_app.vue index 558a4241d499..07506b51612c 100644 --- a/app/assets/javascripts/todos/components/todos_app.vue +++ b/app/assets/javascripts/todos/components/todos_app.vue @@ -6,6 +6,7 @@ import { s__ } from '~/locale'; import getTodosQuery from './queries/get_todos.query.graphql'; import getTodosCountQuery from './queries/get_todos_count.query.graphql'; import TodoItem from './todo_item.vue'; +import TodosEmptyState from './todos_empty_state.vue'; import TodosFilterBar, { SORT_OPTIONS } from './todos_filter_bar.vue'; const ENTRIES_PER_PAGE = 20; @@ -19,6 +20,7 @@ export default { GlBadge, GlTabs, GlTab, + TodosEmptyState, TodosFilterBar, TodoItem, }, @@ -93,9 +95,17 @@ export default { isLoading() { return this.$apollo.queries.todos.loading; }, + isFiltered() { + // Ignore sort value. It is always present and not really a filter. + const { sort: _, ...filters } = this.queryFilterValues; + return Object.values(filters).some((value) => value.length > 0); + }, showPagination() { return !this.isLoading && (this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage); }, + showEmptyState() { + return !this.isLoading && this.todos.length === 0; + }, showMarkAllAsDone() { return this.currentTab === 0; }, @@ -183,6 +193,9 @@ export default { :fade-done-todo="fadeDoneTodo" /> </ul> + + <todos-empty-state v-if="showEmptyState" :is-filtered="isFiltered" /> + <gl-keyset-pagination v-if="showPagination" v-bind="pageInfo" diff --git a/app/assets/javascripts/todos/components/todos_empty_state.vue b/app/assets/javascripts/todos/components/todos_empty_state.vue new file mode 100644 index 000000000000..55dda385cb8f --- /dev/null +++ b/app/assets/javascripts/todos/components/todos_empty_state.vue @@ -0,0 +1,80 @@ +<script> +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import emptyTodosAllDoneSvg from '@gitlab/svgs/dist/illustrations/empty-todos-all-done-md.svg'; +import emptyTodosSvg from '@gitlab/svgs/dist/illustrations/empty-todos-md.svg'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { TODO_EMPTY_TITLE_POOL } from '../constants'; + +export default { + components: { + GlEmptyState, + GlLink, + GlSprintf, + }, + inject: { + issuesDashboardPath: { + type: String, + required: true, + }, + mergeRequestsDashboardPath: { + type: String, + required: true, + }, + }, + props: { + isFiltered: { + type: Boolean, + required: true, + }, + }, + computed: { + title() { + return this.isFiltered + ? s__('Todos|Sorry, your filter produced no results') + : this.pickRandomTitle(); + }, + illustration() { + return this.isFiltered ? this.$options.emptyTodosSvg : this.$options.emptyTodosAllDoneSvg; + }, + }, + methods: { + pickRandomTitle() { + return this.$options.titles[Math.floor(Math.random() * this.$options.titles.length)]; + }, + }, + + emptyTodosAllDoneSvg, + emptyTodosSvg, + docsPath: helpPagePath('user/todos', { anchor: 'actions-that-create-to-do-items' }), + titles: TODO_EMPTY_TITLE_POOL, + + i18n: { + whatNext: s__( + 'Todos|Not sure where to go next? Take a look at your %{assignedIssuesLinkStart}assigned issues%{assignedIssuesLinkEnd} or %{mergeRequestLinkStart}merge requests%{mergeRequestLinkEnd}.', + ), + }, +}; +</script> + +<template> + <gl-empty-state :title="title" :svg-path="illustration"> + <template v-if="!isFiltered" #description> + <p> + <gl-sprintf :message="$options.i18n.whatNext"> + <template #assignedIssuesLink="{ content }"> + <gl-link :href="issuesDashboardPath">{{ content }}</gl-link> + </template> + <template #mergeRequestLink="{ content }"> + <gl-link :href="mergeRequestsDashboardPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + <p> + <a :href="$options.docsPath" target="_blank"> + {{ s__('Todos| What actions create to-do items?') }} + </a> + </p> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/todos/constants.js b/app/assets/javascripts/todos/constants.js index 3c5e852057cf..f7b2ea7208b8 100644 --- a/app/assets/javascripts/todos/constants.js +++ b/app/assets/javascripts/todos/constants.js @@ -1,3 +1,5 @@ +import { s__ } from '~/locale'; + export const TODO_STATE_DONE = 'done'; export const TODO_STATE_PENDING = 'pending'; @@ -21,3 +23,11 @@ export const TODO_ACTION_TYPE_MEMBER_ACCESS_REQUESTED = 'member_access_requested export const TODO_ACTION_TYPE_REVIEW_SUBMITTED = 'review_submitted'; export const TODO_ACTION_TYPE_OKR_CHECKIN_REQUESTED = 'okr_checkin_requested'; export const TODO_ACTION_TYPE_ADDED_APPROVER = 'added_approver'; + +export const TODO_EMPTY_TITLE_POOL = [ + s__("Todos|Good job! Looks like you don't have anything left on your To-Do List"), + s__("Todos|Isn't an empty To-Do List beautiful?"), + s__('Todos|Give yourself a pat on the back!'), + s__('Todos|Nothing left to do. High five!'), + s__('Todos|Henceforth, you shall be known as "To-Do Destroyer"'), +]; diff --git a/app/assets/javascripts/todos/index.js b/app/assets/javascripts/todos/index.js index 4364dbbcbcb5..075d7a4bf57a 100644 --- a/app/assets/javascripts/todos/index.js +++ b/app/assets/javascripts/todos/index.js @@ -14,11 +14,17 @@ export default () => { return false; } + const { issuesDashboardPath, mergeRequestsDashboardPath } = el.dataset; + return new Vue({ el, apolloProvider: new VueApollo({ defaultClient: createDefaultClient(), }), + provide: { + issuesDashboardPath, + mergeRequestsDashboardPath, + }, router: new VueRouter({ base: window.location.pathname, mode: 'history', diff --git a/app/views/dashboard/todos/vue.html.haml b/app/views/dashboard/todos/vue.html.haml index dea0fa913fff..f7e8e24a26ce 100644 --- a/app/views/dashboard/todos/vue.html.haml +++ b/app/views/dashboard/todos/vue.html.haml @@ -8,6 +8,7 @@ - if current_user.todos.any? = render ::Layouts::PageHeadingComponent.new(_('To-Do List')) - #js-todos-app-root + #js-todos-app-root{ data: { issues_dashboard_path: issues_dashboard_path(assignee_username: current_user.username), + merge_requests_dashboard_path: merge_requests_dashboard_path(assignee_username: current_user.username) } } - else = render 'dashboard/todos/user_has_no_todos' diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2c5474573f64..380e445e3e44 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -56651,6 +56651,9 @@ msgstr "" msgid "Todos|Merge train removed" msgstr "" +msgid "Todos|Not sure where to go next? Take a look at your %{assignedIssuesLinkStart}assigned issues%{assignedIssuesLinkEnd} or %{mergeRequestLinkStart}merge requests%{mergeRequestLinkEnd}." +msgstr "" + msgid "Todos|Not sure where to go next? Take a look at your %{strongStart}%{assignedIssuesLinkStart}assigned issues%{assignedIssuesLinkEnd}%{strongEnd} or %{strongStart}%{mergeRequestLinkStart}merge requests%{mergeRequestLinkEnd}%{mergeRequestLinkEnd}%{strongEnd}." msgstr "" @@ -56678,6 +56681,9 @@ msgstr "" msgid "Todos|Something went wrong. Please try again." msgstr "" +msgid "Todos|Sorry, your filter produced no results" +msgstr "" + msgid "Todos|The pipeline failed" msgstr "" diff --git a/spec/frontend/todos/components/todos_empty_state_spec.js b/spec/frontend/todos/components/todos_empty_state_spec.js new file mode 100644 index 000000000000..d6bab7d784d6 --- /dev/null +++ b/spec/frontend/todos/components/todos_empty_state_spec.js @@ -0,0 +1,79 @@ +import { mount } from '@vue/test-utils'; +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import emptyTodosAllDoneSvg from '@gitlab/svgs/dist/illustrations/empty-todos-all-done-md.svg'; +import emptyTodosSvg from '@gitlab/svgs/dist/illustrations/empty-todos-md.svg'; +import TodosEmptyState from '~/todos/components/todos_empty_state.vue'; +import { TODO_EMPTY_TITLE_POOL } from '~/todos/constants'; + +describe('TodosEmptyState', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(TodosEmptyState, { + propsData: { + isFiltered: false, + ...props, + }, + provide: { + issuesDashboardPath: '/dashboard/issues', + mergeRequestsDashboardPath: '/dashboard/merge_requests', + }, + }); + }; + + it('renders the empty state component', () => { + createComponent(); + expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true); + }); + + describe('when not filtered', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a random title', () => { + const title = wrapper.findComponent(GlEmptyState).props('title'); + expect(TODO_EMPTY_TITLE_POOL).toContain(title); + }); + + it('uses the correct illustration', () => { + expect(wrapper.findComponent(GlEmptyState).props('svgPath')).toBe(emptyTodosAllDoneSvg); + }); + + it('renders a description', () => { + expect(wrapper.findComponent(GlSprintf).exists()).toBe(true); + }); + + it('renders links to assigned issues and merge requests', () => { + const links = wrapper.findAllComponents(GlLink); + expect(links.at(0).attributes('href')).toBe('/dashboard/issues'); + expect(links.at(1).attributes('href')).toBe('/dashboard/merge_requests'); + }); + + it('renders a link to the documentation', () => { + const docLink = wrapper.findAll('a').at(2); + expect(docLink.attributes('href')).toBe(TodosEmptyState.docsPath); + expect(docLink.text()).toBe('What actions create to-do items?'); + }); + }); + + describe('when filtered', () => { + beforeEach(() => { + createComponent({ isFiltered: true }); + }); + + it('renders the correct title', () => { + expect(wrapper.findComponent(GlEmptyState).props('title')).toBe( + 'Sorry, your filter produced no results', + ); + }); + + it('does not render a description', () => { + expect(wrapper.findComponent(GlSprintf).exists()).toBe(false); + }); + + it('uses the correct illustration', () => { + expect(wrapper.findComponent(GlEmptyState).props('svgPath')).toBe(emptyTodosSvg); + }); + }); +}); -- GitLab