Browse Source

Feature: Mobile - Improved handling of ticket article update subscription.

Martin Gruner 2 years ago
parent
commit
4dfa5d065a

+ 905 - 0
app/frontend/apps/mobile/pages/ticket/__tests__/article-list/subscription.spec.ts

@@ -0,0 +1,905 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import type { LastArrayElement } from 'type-fest'
+import type { TicketArticlesQuery } from '@shared/graphql/types'
+import { convertToGraphQLId } from '@shared/graphql/utils'
+import { nullableMock } from '@tests/support/utils'
+import { mockPermissions } from '@tests/support/mock-permissions'
+import { visitView } from '@tests/support/components/visitView'
+import { mockApplicationConfig } from '@tests/support/mock-applicationConfig'
+import { defaultArticles, mockTicketDetailViewGql } from '../mocks/detail-view'
+
+beforeEach(() => {
+  mockPermissions(['ticket.agent'])
+})
+
+const now = new Date(2022, 1, 1, 0, 0, 0, 0)
+vi.setSystemTime(now)
+
+const ticketDate = new Date(2022, 0, 30, 0, 0, 0, 0)
+
+const address = {
+  __typename: 'AddressesField' as const,
+  parsed: null,
+  raw: '',
+}
+
+type ArticleNode = LastArrayElement<
+  TicketArticlesQuery['articles']['edges']
+>['node']
+
+const articleContent = (
+  id: number,
+  mockedArticleData: Partial<ArticleNode>,
+): ArticleNode => {
+  return {
+    __typename: 'TicketArticle',
+    id: convertToGraphQLId('TicketArticle', id),
+    internalId: id,
+    createdAt: ticketDate.toISOString(),
+    to: address,
+    replyTo: address,
+    cc: address,
+    from: address,
+    author: {
+      __typename: 'User',
+      id: 'fdsf214fse12d',
+      firstname: 'John',
+      lastname: 'Doe',
+      fullname: 'John Doe',
+      active: true,
+      image: null,
+      authorizations: [],
+    },
+    internal: false,
+    bodyWithUrls: '<p>default body</p>',
+    sender: {
+      __typename: 'TicketArticleSender',
+      name: 'Customer',
+    },
+    type: {
+      __typename: 'TicketArticleType',
+      name: 'article',
+    },
+    contentType: 'text/html',
+    attachmentsWithoutInline: [],
+    preferences: {},
+    ...mockedArticleData,
+  }
+}
+
+describe('ticket articles list with subscription', () => {
+  it('shows a newly created article', async () => {
+    const defaultArticlesQuery = defaultArticles()
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 4,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(4, {
+              bodyWithUrls: '<p>New article> only agents can see this haha</p>',
+              createdAt: new Date(2022, 0, 31, 0, 0, 0, 0).toISOString(),
+            }),
+            cursor: 'MI',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [defaultArticlesQuery, newArticlesQuery],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+
+    comments.forEach((_value, i) => {
+      expect(comments[i]).not.toHaveTextContent('New article')
+    })
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            addArticle: {
+              __typename: 'TicketArticle',
+              id: 'gid://zammad/Article/4',
+              createdAt: new Date(2022, 0, 31, 0, 0, 0, 0).toISOString(),
+            },
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(4)
+    expect(comments[3]).toHaveTextContent('New article')
+  })
+
+  it('updates the list after a former visible article is deleted', async () => {
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql()
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            removeArticleId: 'gid://zammad/Article/3',
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+  })
+
+  it('updates the list after a non-visible article is deleted', async () => {
+    mockApplicationConfig({
+      ticket_articles_min: 1,
+    })
+
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls: '<p>New article> only agents can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterDelete: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 2,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls: '<p>New article> only agents can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterDelete],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(view.getByText('load 1 more')).toBeInTheDocument()
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            removeArticleId: 'gid://zammad/Article/3',
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+  })
+
+  it('updates the list after a former visible article at the end of the list is switched to public', async () => {
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 2,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterUpdate: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls:
+                '<p>Existing article switched to public> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MH',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(
+      view.queryByText('Existing article switched to public'),
+    ).not.toBeInTheDocument()
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            addArticle: {
+              __typename: 'TicketArticle',
+              id: 'gid://zammad/Article/3',
+              createdAt: new Date(2022, 0, 31, 10, 0, 0, 0).toISOString(),
+            },
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+    expect(comments[2]).toHaveTextContent('Existing article switched to public')
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+  })
+
+  it('updates the list after a former visible article at the end of the list is switched to internal', async () => {
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls:
+                '<p>Existing article switched to internal> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterUpdate: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 2,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+    expect(comments[2]).toHaveTextContent(
+      'Existing article switched to internal',
+    )
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            removeArticleId: 'gid://zammad/Article/3',
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+    expect(
+      view.queryByText('Existing article switched to internal'),
+    ).not.toBeInTheDocument()
+  })
+
+  it('updates the list after a former visible article in between is switched to public', async () => {
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 2,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls:
+                '<p>Existing article switched to public> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MH',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterUpdate: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls:
+                '<p>Internal article switched to public> all can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls:
+                '<p>Existing article switched to public> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(
+      view.queryByText('Internal article switched to public'),
+    ).not.toBeInTheDocument()
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            addArticle: {
+              __typename: 'TicketArticle',
+              id: 'gid://zammad/Article/2',
+              createdAt: new Date(2022, 0, 27, 10, 0, 0, 0).toISOString(),
+            },
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+    expect(comments[2]).toHaveTextContent('Existing article switched to public')
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+  })
+
+  it('updates the list after a former visible article in between is switched to internal', async () => {
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls:
+                '<p>Existing article switched to internal> all can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterUpdate: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 2,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+    expect(comments[1]).toHaveTextContent(
+      'Existing article switched to internal',
+    )
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            removeArticleId: 'gid://zammad/Article/2',
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+    expect(
+      view.queryByText('Existing article switched to internal'),
+    ).not.toBeInTheDocument()
+  })
+
+  it('updates the list after a non-visible article in between is switched to public', async () => {
+    mockApplicationConfig({
+      ticket_articles_min: 1,
+    })
+
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MH',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterUpdate: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(2, {
+              bodyWithUrls:
+                '<p>New article switched to public> only agents can see this haha</p>',
+            }),
+            cursor: 'MI',
+          },
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(
+      view.queryByText('New article switched to public'),
+    ).not.toBeInTheDocument()
+    expect(view.getByText('load 1 more')).toBeInTheDocument()
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            addArticle: {
+              __typename: 'TicketArticle',
+              id: 'gid://zammad/Article/2',
+              createdAt: new Date(2022, 0, 30, 10, 0, 0, 0).toISOString(),
+            },
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(3)
+    expect(comments[1]).toHaveTextContent('New article switched to public')
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+  })
+
+  it('updates the list after a non-visible article in between is switched to internal', async () => {
+    mockApplicationConfig({
+      ticket_articles_min: 1,
+    })
+
+    const newArticlesQuery: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MH',
+        },
+      },
+    })
+
+    const newArticlesQueryAfterUpdate: TicketArticlesQuery = nullableMock({
+      description: {
+        __typename: 'TicketArticleConnection',
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(1, {
+              bodyWithUrls:
+                '<p>Existing article> only agents can see this haha</p>',
+            }),
+          },
+        ],
+      },
+      articles: {
+        __typename: 'TicketArticleConnection',
+        totalCount: 3,
+        edges: [
+          {
+            __typename: 'TicketArticleEdge',
+            node: articleContent(3, {
+              bodyWithUrls: '<p>Existing article> all can see this haha</p>',
+            }),
+            cursor: 'MH',
+          },
+        ],
+        pageInfo: {
+          __typename: 'PageInfo',
+          hasPreviousPage: false,
+          startCursor: 'MI',
+        },
+      },
+    })
+
+    const { waitUntilTicketLoaded, mockTicketArticleSubscription } =
+      mockTicketDetailViewGql({
+        articles: [newArticlesQuery, newArticlesQueryAfterUpdate],
+      })
+
+    const view = await visitView('/tickets/1')
+    await waitUntilTicketLoaded()
+
+    let comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(
+      view.queryByText('New article switched to internal'),
+    ).not.toBeInTheDocument()
+    expect(view.getByText('load 1 more')).toBeInTheDocument()
+
+    await mockTicketArticleSubscription.next(
+      nullableMock({
+        data: {
+          ticketArticleUpdates: {
+            removeArticleId: 'gid://zammad/Article/2',
+          },
+        },
+      }),
+    )
+
+    comments = view.getAllByRole('comment')
+    expect(comments.length).toBe(2)
+    expect(
+      view.queryByText('New article switched to internal'),
+    ).not.toBeInTheDocument()
+    expect(view.queryByText('load 1 more')).not.toBeInTheDocument()
+  })
+})

+ 1 - 1
app/frontend/apps/mobile/pages/ticket/__tests__/mocks/detail-view.ts

@@ -273,7 +273,7 @@ interface MockOptions {
   mockSubscription?: boolean
   mockFrontendObjectAttributes?: boolean
   ticket?: TicketQuery
-  articles?: TicketArticlesQuery
+  articles?: TicketArticlesQuery | TicketArticlesQuery[]
   ticketView?: TicketView
 }
 

+ 4 - 3
app/frontend/apps/mobile/pages/ticket/graphql/subscriptions/ticketArticlesUpdates.api.ts

@@ -9,13 +9,14 @@ export type ReactiveFunction<TParam> = () => TParam;
 export const TicketArticleUpdatesDocument = gql`
     subscription ticketArticleUpdates($ticketId: ID!) {
   ticketArticleUpdates(ticketId: $ticketId) {
-    createdArticle {
+    addArticle {
       id
+      createdAt
     }
-    updatedArticle {
+    updateArticle {
       ...ticketArticleAttributes
     }
-    deletedArticleId
+    removeArticleId
   }
 }
     ${TicketArticleAttributesFragmentDoc}`;

+ 4 - 3
app/frontend/apps/mobile/pages/ticket/graphql/subscriptions/ticketArticlesUpdates.graphql

@@ -1,11 +1,12 @@
 subscription ticketArticleUpdates($ticketId: ID!) {
   ticketArticleUpdates(ticketId: $ticketId) {
-    createdArticle {
+    addArticle {
       id
+      createdAt
     }
-    updatedArticle {
+    updateArticle {
       ...ticketArticleAttributes
     }
-    deletedArticleId
+    removeArticleId
   }
 }

+ 0 - 1
app/frontend/apps/mobile/pages/ticket/graphql/subscriptions/ticketUpdates.api.ts

@@ -12,7 +12,6 @@ export const TicketUpdatesDocument = gql`
   ticketUpdates(ticketId: $ticketId) {
     ticket {
       ...ticketAttributes
-      articleCount
       mentions {
         totalCount
         edges {

+ 0 - 1
app/frontend/apps/mobile/pages/ticket/graphql/subscriptions/ticketUpdates.graphql

@@ -2,7 +2,6 @@ subscription ticketUpdates($ticketId: ID!) {
   ticketUpdates(ticketId: $ticketId) {
     ticket {
       ...ticketAttributes
-      articleCount
       mentions {
         totalCount
         edges {

+ 87 - 38
app/frontend/apps/mobile/pages/ticket/views/TicketDetailArticlesView.vue

@@ -33,49 +33,99 @@ const props = defineProps<Props>()
 
 const application = useApplicationStore()
 
+const ticketId = computed(() => convertToGraphQLId('Ticket', props.internalId))
+
+const ticketArticlesMin = computed(() => {
+  return Number(application.config.ticket_articles_min ?? 5)
+})
+
 const articlesQuery = new QueryHandler(
   useTicketArticlesQuery(() => ({
-    ticketId: convertToGraphQLId('Ticket', props.internalId),
-    pageSize: Number(application.config.ticket_articles_min ?? 5),
+    ticketId: ticketId.value,
+    pageSize: ticketArticlesMin.value,
   })),
   { errorShowNotification: false },
 )
 
 const result = articlesQuery.result()
 
+const allArticleLoaded = computed(() => {
+  if (!result.value?.articles.totalCount) return false
+  return result.value?.articles.edges.length < result.value?.articles.totalCount
+})
+
+const refetchArticlesQuery = (pageSize: Maybe<number>) => {
+  articlesQuery.refetch({
+    ticketId: ticketId.value,
+    pageSize,
+  })
+}
+
+// When the last article is deleted, cursor has to be adjusted
+//  to show newly created articles in the list (if any).
+// Cursor is offset-based, so the old cursor is pointing to an unavailable article,
+//  thus using the cursor for the last article of the already filtered edges.
+const adjustPageInfoAfterDeletion = (nextEndCursorEdge?: Maybe<string>) => {
+  const newPageInfo: Pick<PageInfo, 'startCursor' | 'endCursor'> = {}
+
+  if (nextEndCursorEdge) {
+    newPageInfo.endCursor = nextEndCursorEdge
+  } else {
+    newPageInfo.startCursor = null
+    newPageInfo.endCursor = null
+  }
+
+  return newPageInfo
+}
+
 articlesQuery.subscribeToMore<
   TicketArticleUpdatesSubscriptionVariables,
   TicketArticleUpdatesSubscription
 >(() => ({
   document: TicketArticleUpdatesDocument,
   variables: {
-    ticketId: convertToGraphQLId('Ticket', props.internalId),
+    ticketId: ticketId.value,
   },
   onError: noop,
   updateQuery(previous, { subscriptionData }) {
     const updates = subscriptionData.data.ticketArticleUpdates
-    if (updates.deletedArticleId) {
-      const edges = previous.articles.edges.filter(
-        (edge) => edge.node.id !== updates.deletedArticleId,
+
+    if (!previous.articles || updates.updateArticle) return previous
+
+    const previousArticlesEdges = previous.articles.edges
+    const previousArticlesEdgesCount = previousArticlesEdges.length
+
+    if (updates.removeArticleId) {
+      const edges = previousArticlesEdges.filter(
+        (edge) => edge.node.id !== updates.removeArticleId,
       )
 
-      // When the last article is deleted, cursor has to be adjusted
-      //  to show newly created articles in the list (if any).
-      // Cursor is offset-based, so the old cursor is pointing to an unavailable article,
-      //  thus using the cursor for the last article of the already filtered edges.
-      const nextEndCursorEdge =
-        previous.articles.edges[previous.articles.edges.length - 2]
+      const removedArticleVisible = edges.length !== previousArticlesEdgesCount
 
-      const newPageInfo: Pick<PageInfo, 'startCursor' | 'endCursor'> = {}
+      if (removedArticleVisible && !allArticleLoaded.value) {
+        refetchArticlesQuery(ticketArticlesMin.value)
 
-      if (nextEndCursorEdge) {
-        newPageInfo.endCursor = nextEndCursorEdge.cursor
-      } else {
-        newPageInfo.startCursor = null
-        newPageInfo.endCursor = null
+        return previous
+      }
+
+      const result = {
+        ...previous,
+        articles: {
+          ...previous.articles,
+          edges,
+          totalCount: previous.articles.totalCount - 1,
+        },
       }
 
-      const { pageInfo } = previous.articles
+      if (removedArticleVisible) {
+        const nextEndCursorEdge =
+          previousArticlesEdges[previousArticlesEdgesCount - 2]
+
+        result.articles.pageInfo = {
+          ...previous.articles.pageInfo,
+          ...adjustPageInfoAfterDeletion(nextEndCursorEdge.cursor),
+        }
+      }
 
       // Trigger cache garbage collection after the returned article deletion subscription
       //  updated the article list.
@@ -83,28 +133,27 @@ articlesQuery.subscribeToMore<
         getApolloClient().cache.gc()
       })
 
-      return {
-        ...previous,
-        articles: {
-          ...previous.articles,
-          pageInfo: {
-            ...pageInfo,
-            ...newPageInfo,
+      return result
+    }
+
+    if (updates.addArticle) {
+      const needRefetch =
+        !previousArticlesEdges[previousArticlesEdgesCount - 1] ||
+        updates.addArticle.createdAt <=
+          previousArticlesEdges[previousArticlesEdgesCount - 1].node.createdAt
+
+      if (!allArticleLoaded.value || needRefetch) {
+        refetchArticlesQuery(null)
+      } else {
+        articlesQuery.fetchMore({
+          variables: {
+            pageSize: null,
+            loadDescription: false,
+            afterCursor: result.value?.articles.pageInfo.endCursor,
           },
-          edges,
-          totalCount: previous.articles.totalCount - 1,
-        },
+        })
       }
     }
-    if (updates.createdArticle) {
-      articlesQuery.fetchMore({
-        variables: {
-          pageSize: null,
-          loadDescription: false,
-          afterCursor: result.value?.articles.pageInfo.endCursor,
-        },
-      })
-    }
     return previous
   },
 }))

File diff suppressed because it is too large
+ 7 - 19
app/frontend/shared/graphql/types.ts


+ 7 - 0
app/frontend/tests/support/components/visitView.ts

@@ -12,6 +12,13 @@ vi.mock('@shared/server/apollo/client', () => {
     clearApolloClientStore: () => {
       return Promise.resolve()
     },
+    getApolloClient: () => {
+      return {
+        cache: {
+          gc: () => [],
+        },
+      }
+    },
   }
 })
 

+ 49 - 8
app/graphql/gql/subscriptions/ticket_article_updates.rb

@@ -5,24 +5,29 @@ module Gql::Subscriptions
 
     argument :ticket_id, GraphQL::Types::ID, description: 'Ticket identifier'
 
-    description 'Updates to ticket records'
+    description 'Changes to the list of ticket articles'
 
-    field :created_article, Gql::Types::Ticket::ArticleType, description: 'New ticket article'
-    field :updated_article, Gql::Types::Ticket::ArticleType, description: 'Changed ticket article'
-    field :deleted_article_id, GraphQL::Types::ID, description: 'ID of removed ticket article'
+    field :add_article, Gql::Types::Ticket::ArticleType, description: 'A new article needs to be added to the list'
+    field :update_article, Gql::Types::Ticket::ArticleType, description: 'An existing article was changed'
+    field :remove_article_id, GraphQL::Types::ID, description: 'An article must be removed from the list'
 
     class << self
       # Helper methods for triggering with custom payload.
       def trigger_after_create(article)
-        trigger({ created_article: article }, arguments: { ticket_id: Gql::ZammadSchema.id_from_object(article.ticket) })
+        trigger_for_ticket(article, { article: article, event: :create })
       end
 
       def trigger_after_update(article)
-        trigger({ updated_article: article }, arguments: { ticket_id: Gql::ZammadSchema.id_from_object(article.ticket) })
+        # Add information about changes to the internal flag for later processing.
+        trigger_for_ticket(article, { article: article, event: :update, internal_changed?: article.previous_changes['internal'].present? })
       end
 
       def trigger_after_destroy(article)
-        trigger({ deleted_article_id: Gql::ZammadSchema.id_from_object(article) }, arguments: { ticket_id: Gql::ZammadSchema.id_from_object(article.ticket) })
+        trigger_for_ticket(article, { article_id: Gql::ZammadSchema.id_from_object(article), event: :destroy })
+      end
+
+      def trigger_for_ticket(article, payload)
+        trigger(payload, arguments: { ticket_id: Gql::ZammadSchema.id_from_object(article.ticket) })
       end
     end
 
@@ -33,7 +38,43 @@ module Gql::Subscriptions
     # This needs to be passed a hash with the correct field name containing the article payload as root object,
     #   as we cannot change the (graphql-ruby) function signature of update(ticket_id:).
     def update(ticket_id:)
-      object
+      event = object[:event]
+      article = object[:article]
+
+      # Always send remove events.
+      if event == :destroy
+        return { remove_article_id: object[:article_id] }
+      end
+
+      # Send create only for articles with permission.
+      if event == :create
+        return article_permission? ? { add_article: article } : no_update
+      end
+
+      # For updated articles, there is a special handling if visibility changed.
+      if article_permission?
+        # If permission to see the article was just added, treat it as an add event.
+        return customer_visibility_changed? ? { add_article: article } : { update_article: article }
+
+      elsif customer_visibility_changed?
+        # If permission to see the article was just removed, treat it as a remove event.
+        return { remove_article_id: Gql::ZammadSchema.id_from_object(article) }
+      end
+
+      no_update
+    end
+
+    private
+
+    def customer_visibility_changed?
+      object[:internal_changed?] && !TicketPolicy.new(context.current_user, object[:article].ticket).agent_read_access?
+    end
+
+    # Only send updates for articles with read permission.
+    def article_permission?
+      Pundit.authorize context.current_user, object[:article], :show?
+    rescue Pundit::NotAuthorizedError
+      false
     end
   end
 end

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