Browse Source

Feature: Desktop view - Implement overview preferences.

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Florian Liebe 10 months ago
parent
commit
e4582cf582

+ 2 - 2
app/assets/javascripts/app/controllers/_profile/overviews.coffee

@@ -12,8 +12,8 @@ class Overviews extends App.ControllerSubContent
       url:   "#{App.Config.get('api_path')}/user_overview_sortings"
       processData: true,
       success: (data, status, xhr) =>
-        App.UserOverviewSortingOverview.refresh(data.overviews)
-        App.UserOverviewSorting.refresh(data.overview_sortings)
+        App.UserOverviewSortingOverview.refresh(data.overviews, {clear: true})
+        App.UserOverviewSorting.refresh(data.overview_sortings, {clear: true})
         @render(data)
     )
 

+ 22 - 5
app/controllers/user/overview_sortings_controller.rb

@@ -1,8 +1,6 @@
 # Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
 class User::OverviewSortingsController < ApplicationController
-  include CanPrioritize
-
   prepend_before_action :authenticate_and_authorize!
 
   def index
@@ -25,10 +23,29 @@ class User::OverviewSortingsController < ApplicationController
   end
 
   def destroy
-    model_destroy_render(User::OverviewSorting, params)
+    ActiveRecord::Base.transaction do
+      model_destroy_render(User::OverviewSorting, params)
+    end
+
+    Gql::Subscriptions::User::Current::OverviewOrderingUpdates
+        .trigger_by(current_user)
   end
 
-  def prio_find(entry_prio)
-    klass.find_by(overview_id: entry_prio[0], user: current_user)
+  def prio
+    overview_ids = params[:prios].map(&:first)
+
+    authorized_overviews = Ticket::Overviews
+      .all(current_user:)
+      .where(id: overview_ids)
+      .sort_by { |elem| overview_ids.index(elem.id) }
+
+    Service::User::Overview::UpdateOrder
+      .new(current_user, authorized_overviews)
+      .execute
+
+    Gql::Subscriptions::User::Current::OverviewOrderingUpdates
+      .trigger_by(current_user)
+
+    render json: { success: true }, status: :ok
   end
 end

+ 1 - 0
app/frontend/apps/desktop/initializer/3RD-PARTY-ICONS.md

@@ -35,6 +35,7 @@
 - `assets/eye.svg`
 - `assets/files.svg`
 - `assets/filter.svg`
+- `assets/grip-vertical.svg`
 - `assets/image.svg`
 - `assets/info-circle.svg`
 - `assets/key.svg`

+ 12 - 0
app/frontend/apps/desktop/initializer/assets/grip-vertical.svg

@@ -0,0 +1,12 @@
+<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M7 2C7 2.55228 6.55228 3 6 3C5.44772 3 5 2.55228 5 2C5 1.44772 5.44772 1 6 1C6.55228 1 7 1.44772 7 2Z" />
+<path d="M10 2C10 2.55228 9.55228 3 9 3C8.44772 3 8 2.55228 8 2C8 1.44772 8.44772 1 9 1C9.55228 1 10 1.44772 10 2Z" />
+<path d="M7 5C7 5.55228 6.55228 6 6 6C5.44772 6 5 5.55228 5 5C5 4.44772 5.44772 4 6 4C6.55228 4 7 4.44772 7 5Z" />
+<path d="M10 5C10 5.55228 9.55228 6 9 6C8.44772 6 8 5.55228 8 5C8 4.44772 8.44772 4 9 4C9.55228 4 10 4.44772 10 5Z" />
+<path d="M7 8C7 8.55228 6.55228 9 6 9C5.44772 9 5 8.55228 5 8C5 7.44772 5.44772 7 6 7C6.55228 7 7 7.44772 7 8Z" />
+<path d="M10 8C10 8.55228 9.55228 9 9 9C8.44772 9 8 8.55228 8 8C8 7.44772 8.44772 7 9 7C9.55228 7 10 7.44772 10 8Z" />
+<path d="M7 11C7 11.5523 6.55228 12 6 12C5.44772 12 5 11.5523 5 11C5 10.4477 5.44772 10 6 10C6.55228 10 7 10.4477 7 11Z" />
+<path d="M10 11C10 11.5523 9.55228 12 9 12C8.44772 12 8 11.5523 8 11C8 10.4477 8.44772 10 9 10C9.55228 10 10 10.4477 10 11Z" />
+<path d="M7 14C7 14.5523 6.55228 15 6 15C5.44772 15 5 14.5523 5 14C5 13.4477 5.44772 13 6 13C6.55228 13 7 13.4477 7 14Z" />
+<path d="M10 14C10 14.5523 9.55228 15 9 15C8.44772 15 8 14.5523 8 14C8 13.4477 8.44772 13 9 13C9.55228 13 10 13.4477 10 14Z" />
+</svg>

+ 45 - 0
app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-overviews-a11y.spec.ts

@@ -0,0 +1,45 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { axe } from 'vitest-axe'
+
+import { visitView } from '#tests/support/components/visitView.ts'
+import { mockPermissions } from '#tests/support/mock-permissions.ts'
+import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
+
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import { mockUserCurrentOverviewListQuery } from '../graphql/queries/userCurrentOverviewList.mocks.ts'
+
+const userCurrentOverviewList = [
+  {
+    id: convertToGraphQLId('Overview', 1),
+    name: 'Open Tickets',
+  },
+  {
+    id: convertToGraphQLId('Overview', 2),
+    name: 'My Tickets',
+  },
+  {
+    id: convertToGraphQLId('Overview', 3),
+    name: 'All Tickets',
+  },
+]
+
+describe('personal settings for token access', () => {
+  beforeEach(() => {
+    mockUserCurrent({
+      firstname: 'John',
+      lastname: 'Doe',
+    })
+    mockPermissions(['user_preferences.overview_sorting'])
+  })
+
+  it('has no accessibility violations', async () => {
+    mockUserCurrentOverviewListQuery({ userCurrentOverviewList })
+
+    const view = await visitView('/personal-setting/ticket-overviews')
+
+    const results = await axe(view.html())
+    expect(results).toHaveNoViolations()
+  })
+})

+ 122 - 0
app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-overviews.spec.ts

@@ -0,0 +1,122 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { getAllByRole } from '@testing-library/vue'
+import { visitView } from '#tests/support/components/visitView.ts'
+import { mockPermissions } from '#tests/support/mock-permissions.ts'
+import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+import { mockUserCurrentOverviewListQuery } from '../graphql/queries/userCurrentOverviewList.mocks.ts'
+import { mockUserCurrentOverviewResetOrderMutation } from '../graphql/mutations/userCurrentOverviewResetOrder.mocks.ts'
+import { getUserCurrentOverviewOrderingUpdatesSubscriptionHandler } from '../graphql/subscriptions/userCurrentOverviewOrderingUpdates.mocks.ts'
+
+const userCurrentOverviewList = [
+  {
+    id: convertToGraphQLId('Overview', 1),
+    name: 'Open Tickets',
+  },
+  {
+    id: convertToGraphQLId('Overview', 2),
+    name: 'My Tickets',
+  },
+  {
+    id: convertToGraphQLId('Overview', 3),
+    name: 'All Tickets',
+  },
+]
+
+const userCurrentOverviewListAferReset = userCurrentOverviewList.reverse()
+
+describe('personal settings for token access', () => {
+  beforeEach(() => {
+    mockUserCurrent({
+      firstname: 'John',
+      lastname: 'Doe',
+    })
+    mockPermissions(['user_preferences.overview_sorting'])
+  })
+
+  it('shows the overviews order by priority', async () => {
+    mockUserCurrentOverviewListQuery({ userCurrentOverviewList })
+
+    const view = await visitView('/personal-setting/ticket-overviews')
+
+    const overviewContainer = view.getByLabelText('Order of ticket overviews')
+
+    const overviews = getAllByRole(overviewContainer, 'listitem')
+
+    userCurrentOverviewList.forEach((overview, index) => {
+      expect(overviews[index]).toHaveTextContent(overview.name)
+    })
+  })
+
+  // TODO: Cover the update of overview order when the items are moved around the list.
+  //   We may need to implement a testable mechanism for reordering the list, though, as drag events are not fully
+  //   supported in JSDOM due to missing client-rectangle coordinate mocking.
+  //   One approach could be to add keyboard shortcuts for changing the order, or perhaps even hidden buttons.
+
+  it('allows to reset the order of overviews', async () => {
+    mockUserCurrentOverviewListQuery({ userCurrentOverviewList })
+
+    const view = await visitView('/personal-setting/ticket-overviews')
+
+    mockUserCurrentOverviewResetOrderMutation({
+      userCurrentOverviewResetOrder: {
+        success: true,
+        overviews: userCurrentOverviewListAferReset,
+        errors: null,
+      },
+    })
+
+    const resetButton = view.getByRole('button', {
+      name: 'Reset Overview Order',
+    })
+
+    expect(resetButton).toBeInTheDocument()
+
+    await view.events.click(resetButton)
+
+    await waitForNextTick()
+
+    expect(
+      await view.findByRole('dialog', { name: 'Confirmation' }),
+    ).toBeInTheDocument()
+
+    await view.events.click(view.getByRole('button', { name: 'Yes' }))
+
+    await waitForNextTick()
+
+    userCurrentOverviewListAferReset.forEach((overview) => {
+      expect(view.getByText(overview.name)).toBeInTheDocument()
+    })
+  })
+
+  it('updates the overviews list when a new overview is added', async () => {
+    mockUserCurrentOverviewListQuery({ userCurrentOverviewList })
+
+    const view = await visitView('/personal-setting/ticket-overviews')
+
+    const overviewUpdateSubscription =
+      getUserCurrentOverviewOrderingUpdatesSubscriptionHandler()
+
+    userCurrentOverviewList.forEach((overview) => {
+      expect(view.getByText(overview.name)).toBeInTheDocument()
+    })
+
+    overviewUpdateSubscription.trigger({
+      userCurrentOverviewOrderingUpdates: {
+        overviews: [
+          ...userCurrentOverviewList,
+          {
+            id: convertToGraphQLId('Overview', 4),
+            name: 'New Overview',
+          },
+        ],
+      },
+    })
+
+    await waitForNextTick()
+
+    expect(view.getByText('New Overview')).toBeInTheDocument()
+  })
+})

+ 51 - 0
app/frontend/apps/desktop/pages/personal-setting/components/PersonalSettingOverviewOrder.vue

@@ -0,0 +1,51 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import Draggable from 'vuedraggable'
+
+export interface OverviewItem {
+  id: string
+  name: string
+}
+
+const localValue = defineModel<OverviewItem[]>('modelValue')
+</script>
+
+<template>
+  <div v-if="localValue" class="rounded-lg bg-blue-200 dark:bg-gray-700">
+    <!-- :TODO if we add proper a11y support   -->
+    <!--    <span class="hidden" aria-live="assertive" >{{assistiveText}}</span>-->
+    <span id="drag-and-drop-ticket-overviews" class="sr-only">
+      {{ $t('Drag and drop to reorder ticket overview list items.') }}
+    </span>
+
+    <div class="flex flex-col p-1">
+      <Draggable
+        v-model="localValue"
+        :animation="100"
+        draggable=".draggable"
+        role="list"
+        ghost-class="invisible"
+        item-key="id"
+      >
+        <template #item="{ element }">
+          <div
+            role="listitem"
+            draggable="true"
+            aria-describedby="drag-and-drop-ticket-overviews"
+            class="draggable flex h-9 cursor-grab items-center gap-2.5 p-2.5 active:cursor-grabbing"
+          >
+            <CommonIcon
+              class="fill-stone-200 dark:fill-neutral-500"
+              name="grip-vertical"
+              size="tiny"
+            />
+            <CommonLabel class="w-full text-black dark:text-white">
+              {{ $t(element.name) }}
+            </CommonLabel>
+          </div>
+        </template>
+      </Draggable>
+    </div>
+  </div>
+</template>

+ 26 - 0
app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/userCurrentOverviewResetOrder.api.ts

@@ -0,0 +1,26 @@
+import * as Types from '#shared/graphql/types.ts';
+
+import gql from 'graphql-tag';
+import { ErrorsFragmentDoc } from '../../../../../../shared/graphql/fragments/errors.api';
+import * as VueApolloComposable from '@vue/apollo-composable';
+import * as VueCompositionApi from 'vue';
+export type ReactiveFunction<TParam> = () => TParam;
+
+export const UserCurrentOverviewResetOrderDocument = gql`
+    mutation userCurrentOverviewResetOrder {
+  userCurrentOverviewResetOrder {
+    success
+    overviews {
+      id
+      name
+    }
+    errors {
+      ...errors
+    }
+  }
+}
+    ${ErrorsFragmentDoc}`;
+export function useUserCurrentOverviewResetOrderMutation(options: VueApolloComposable.UseMutationOptions<Types.UserCurrentOverviewResetOrderMutation, Types.UserCurrentOverviewResetOrderMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.UserCurrentOverviewResetOrderMutation, Types.UserCurrentOverviewResetOrderMutationVariables>> = {}) {
+  return VueApolloComposable.useMutation<Types.UserCurrentOverviewResetOrderMutation, Types.UserCurrentOverviewResetOrderMutationVariables>(UserCurrentOverviewResetOrderDocument, options);
+}
+export type UserCurrentOverviewResetOrderMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.UserCurrentOverviewResetOrderMutation, Types.UserCurrentOverviewResetOrderMutationVariables>;

+ 12 - 0
app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/userCurrentOverviewResetOrder.graphql

@@ -0,0 +1,12 @@
+mutation userCurrentOverviewResetOrder {
+  userCurrentOverviewResetOrder {
+    success
+    overviews {
+      id
+      name
+    }
+    errors {
+      ...errors
+    }
+  }
+}

+ 12 - 0
app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/userCurrentOverviewResetOrder.mocks.ts

@@ -0,0 +1,12 @@
+import * as Types from '#shared/graphql/types.ts';
+
+import * as Mocks from '#tests/graphql/builders/mocks.ts'
+import * as Operations from './userCurrentOverviewResetOrder.api.ts'
+
+export function mockUserCurrentOverviewResetOrderMutation(defaults: Mocks.MockDefaultsValue<Types.UserCurrentOverviewResetOrderMutation, Types.UserCurrentOverviewResetOrderMutationVariables>) {
+  return Mocks.mockGraphQLResult(Operations.UserCurrentOverviewResetOrderDocument, defaults)
+}
+
+export function waitForUserCurrentOverviewResetOrderMutationCalls() {
+  return Mocks.waitForGraphQLMockCalls<Types.UserCurrentOverviewResetOrderMutation>(Operations.UserCurrentOverviewResetOrderDocument)
+}

Some files were not shown because too many files changed in this diff