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