diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 48b75e3..7470317 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,5 +1,8 @@ name: E2E -on: workflow_dispatch +on: + workflow_dispatch: + pull_request: + branches: ['v*/main'] permissions: contents: read jobs: @@ -9,6 +12,10 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c094d8a..5b993d6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,6 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' - name: Setup Node.js uses: actions/setup-node@v7 with: diff --git a/README.md b/README.md index d6acd53..0df9413 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@ [![Umbraco](https://img.shields.io/badge/marketplace-umbraco-%233544b1#f5c1bc)](https://marketplace.umbraco.com/package/usupport) [![Release](https://github.com/Lantzify/uSupport/actions/workflows/main.yml/badge.svg)](https://github.com/Lantzify/uSupport/actions/workflows/main.yml) -[![Umbraco](https://img.shields.io/badge/our-umbraco-%233544b1)](https://our.umbraco.com/packages/backoffice-extensions/usupport/) +[![E2E](https://github.com/Lantzify/uSupport/actions/workflows/e2e.yml/badge.svg)](https://github.com/Lantzify/uSupport/actions/workflows/e2e.yml) ## What is uSupport -uSupport is an feature rich ticketing system designed specifically for Umbraco. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. +uSupport is a feature-rich ticketing system designed specifically for Umbraco. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. ## Why uSupport Say goodbye to the days of digging through countless old emails in search of vital details. With uSupport, everything you need is neatly organized within the Umbraco environment. Streamline your workflow, boost productivity, and effortlessly access all the information you need to address and resolve client issues efficiently. diff --git a/src/uSupport.E2E/tests/admin/types/create.types.spec.ts b/src/uSupport.E2E/tests/admin/types/create.types.spec.ts new file mode 100644 index 0000000..00ab125 --- /dev/null +++ b/src/uSupport.E2E/tests/admin/types/create.types.spec.ts @@ -0,0 +1,53 @@ +import { expect } from '@playwright/test'; +import { test } from '@umbraco-cms/acceptance-test-helpers'; +import { TYPES, createTicket } from '../../helpers'; + +test('No umbraco property', async ({ page }) => { + const type = `${TYPES.request.name} ${Date.now()}`; + const ticket = `A request ${Date.now()}`; + + await page.goto('/umbraco/section/usupport/workspace/usupport-ticket-type/create'); + await page.getByRole('textbox', { name: 'Enter a name...' }).fill(type); + await page.getByRole('textbox', { name: 'Enter a description...' }).fill(TYPES.request.summary); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.locator('uui-toast-notification').getByText('Type saved')).toBeVisible(); + + await createTicket(page, type, ticket, TYPES.request.summary); + + await expect(page.locator('usupport-tickets-table[list="active"]').getByRole('cell', { name: ticket })).toBeVisible(); +}); + +test('Umbraco property', async ({ page }) => { + const type = `${TYPES.meetingRequest.name} ${Date.now()}`; + const ticket = `Serious meeting ${Date.now()}`; + + await page.goto('/umbraco/section/usupport/workspace/usupport-ticket-type/create'); + await page.getByRole('textbox', { name: 'Enter a name...' }).fill(type); + await page.getByRole('textbox', { name: 'Enter a description...' }).fill(TYPES.meetingRequest.summary); + + await page.getByRole('button', { name: 'Choose' }).click(); + await page.getByRole('button', { name: 'Date Picker with time' }).click(); + await page.locator('#container').getByRole('button', { name: 'Choose' }).click(); + await page.getByRole('textbox', { name: 'Property name' }).click(); + await page.getByRole('textbox', { name: 'Property name' }).fill('Date'); + + await page.getByRole('button', { name: 'Save' }).click(); + await expect(page.locator('uui-toast-notification').getByText('Type saved')).toBeVisible(); + + await page.goto('/umbraco/section/content'); + await page.getByRole('tab', { name: 'Support', exact: true }).click(); + await page.getByRole('button', { name: 'Create ticket' }).click(); + await page.getByRole('button', { name: type }).click(); + + await page.getByRole('textbox', { name: 'Title' }).click(); + await page.getByRole('textbox', { name: 'Title' }).fill(ticket); + await page.getByRole('textbox', { name: 'Summary' }).fill("Let's have a serious meeting."); + + + await page.getByRole('textbox', { name: 'Set a date...' }).fill('2026-11-18T13:17:50'); + await page.locator('#container').getByRole('button', { name: 'Create ticket' }).click(); + await expect(page.locator('uui-toast-notification').getByText(/was created/)).toBeVisible(); + + await expect(page.locator('usupport-tickets-table[list="active"]').getByRole('cell', { name: ticket })).toBeVisible(); +}); \ No newline at end of file diff --git a/src/uSupport.E2E/tests/helpers.ts b/src/uSupport.E2E/tests/helpers.ts index ac282eb..aa49069 100644 --- a/src/uSupport.E2E/tests/helpers.ts +++ b/src/uSupport.E2E/tests/helpers.ts @@ -18,6 +18,17 @@ export const TICKETS = { type: 'Page error', title: 'Home page throws error', summary: 'When going to the home page it throws a 500 error' + }, +}; + +export const TYPES = { + request: { + name: 'Feature request', + summary: 'I would like to request a new feature' + }, + meetingRequest: { + name: 'Meeting request', + summary: 'I would like to request a meeting' } } diff --git a/src/uSupport.Tests/Migrations/MigrateContentPickerValuesTests.cs b/src/uSupport.Tests/Migrations/MigrateContentPickerValuesTests.cs new file mode 100644 index 0000000..6788026 --- /dev/null +++ b/src/uSupport.Tests/Migrations/MigrateContentPickerValuesTests.cs @@ -0,0 +1,28 @@ +using uSupport.Migrations.Updates._17._0._0; + +namespace uSupport.Tests.Migrations +{ + [TestFixture] + public class MigrateContentPickerValuesTests + { + private static readonly Guid Key = Guid.Parse("dcf18a51-6919-4cf8-89d1-36b94ce4d963"); + private static readonly string Expected = $"\"{Key}\""; + + private static Guid? IdToKey(int id) => id == 1234 ? Key : null; + + [TestCase("1234")] + [TestCase("\"1234\"")] + [TestCase("\"umb://document/dcf18a5169194cf889d136b94ce4d963\"")] + [TestCase("umb://document/dcf18a5169194cf889d136b94ce4d963")] + public void Converts_v13_values_to_document_key(string value) + => Assert.That(MigrateContentPickerValues.ToKeyJson(value, IdToKey), Is.EqualTo(Expected)); + + [TestCase("\"dcf18a51-6919-4cf8-89d1-36b94ce4d963\"")] // already v17 + [TestCase("\"umb://media/dcf18a5169194cf889d136b94ce4d963\"")] // not a document + [TestCase("9999")] // id that no longer exists + [TestCase("not a picker value")] + [TestCase("")] + public void Leaves_everything_else_alone(string value) + => Assert.That(MigrateContentPickerValues.ToKeyJson(value, IdToKey), Is.Null); + } +} diff --git a/src/uSupport/Client/package-lock.json b/src/uSupport/Client/package-lock.json index f754e1a..fd7c55b 100644 --- a/src/uSupport/Client/package-lock.json +++ b/src/uSupport/Client/package-lock.json @@ -3198,9 +3198,9 @@ } }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -3302,17 +3302,6 @@ "marked": "14.0.0" } }, - "node_modules/monaco-editor/node_modules/dompurify": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", - "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", - "dev": true, - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/monaco-editor/node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", diff --git a/src/uSupport/Client/package.json b/src/uSupport/Client/package.json index 4632028..4cee6ea 100644 --- a/src/uSupport/Client/package.json +++ b/src/uSupport/Client/package.json @@ -14,5 +14,9 @@ "cross-env": "^10.1.0", "typescript": "~5.9.3", "vite": "^7.2.4" + }, + "overrides": { + "dompurify": "^3.4.17", + "js-yaml": "^4.3.2" } } diff --git a/src/uSupport/Client/public/umbraco-package.json b/src/uSupport/Client/public/umbraco-package.json index c60bcf4..152b8f7 100644 --- a/src/uSupport/Client/public/umbraco-package.json +++ b/src/uSupport/Client/public/umbraco-package.json @@ -2,6 +2,7 @@ "$schema": "../../../uSupport.Web/umbraco-package-schema.json", "name": "uSupport", "alias": "uSupport", + "version": "17.0.0", "extensions": [ { "type": "backofficeEntryPoint", diff --git a/src/uSupport/Client/src/api/sdk.gen.ts b/src/uSupport/Client/src/api/sdk.gen.ts index e354dba..bf3d874 100644 --- a/src/uSupport/Client/src/api/sdk.gen.ts +++ b/src/uSupport/Client/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { DeleteUsupportTicketByIdData, DeleteUsupportTicketByIdResponses, DeleteUsupportTicketStatusByIdData, DeleteUsupportTicketStatusByIdErrors, DeleteUsupportTicketStatusByIdResponses, DeleteUsupportTicketTypeByIdData, DeleteUsupportTicketTypeByIdErrors, DeleteUsupportTicketTypeByIdResponses, GetUsupportSettingsData, GetUsupportSettingsResponses, GetUsupportTicketActiveData, GetUsupportTicketActiveResponses, GetUsupportTicketAnyResolvedData, GetUsupportTicketAnyResolvedResponses, GetUsupportTicketByIdData, GetUsupportTicketByIdErrors, GetUsupportTicketByIdHistoryData, GetUsupportTicketByIdHistoryResponses, GetUsupportTicketByIdResponses, GetUsupportTicketCommentTicketByTicketIdData, GetUsupportTicketCommentTicketByTicketIdResponses, GetUsupportTicketResolvedData, GetUsupportTicketResolvedResponses, GetUsupportTicketStatusByIdData, GetUsupportTicketStatusByIdDependentTicketsData, GetUsupportTicketStatusByIdDependentTicketsResponses, GetUsupportTicketStatusByIdErrors, GetUsupportTicketStatusByIdResponses, GetUsupportTicketStatusData, GetUsupportTicketStatusResponses, GetUsupportTicketTypeByIdData, GetUsupportTicketTypeByIdDependentTicketsData, GetUsupportTicketTypeByIdDependentTicketsResponses, GetUsupportTicketTypeByIdErrors, GetUsupportTicketTypeByIdResponses, GetUsupportTicketTypeData, GetUsupportTicketTypeResponses, PostUsupportTicketCommentData, PostUsupportTicketCommentErrors, PostUsupportTicketCommentResponses, PostUsupportTicketData, PostUsupportTicketResponses, PostUsupportTicketStatusData, PostUsupportTicketStatusResponses, PostUsupportTicketTypeData, PostUsupportTicketTypeResponses, PutUsupportSettingsData, PutUsupportSettingsResponses, PutUsupportTicketByIdData, PutUsupportTicketByIdErrors, PutUsupportTicketByIdResponses, PutUsupportTicketStatusByIdData, PutUsupportTicketStatusByIdErrors, PutUsupportTicketStatusByIdResponses, PutUsupportTicketStatusSortData, PutUsupportTicketStatusSortResponses, PutUsupportTicketTypeByIdData, PutUsupportTicketTypeByIdErrors, PutUsupportTicketTypeByIdResponses, PutUsupportTicketTypeSortData, PutUsupportTicketTypeSortResponses } from './types.gen'; +import type { DeleteUsupportTicketByIdData, DeleteUsupportTicketByIdErrors, DeleteUsupportTicketByIdResponses, DeleteUsupportTicketStatusByIdData, DeleteUsupportTicketStatusByIdErrors, DeleteUsupportTicketStatusByIdResponses, DeleteUsupportTicketTypeByIdData, DeleteUsupportTicketTypeByIdErrors, DeleteUsupportTicketTypeByIdResponses, GetUsupportSettingsData, GetUsupportSettingsResponses, GetUsupportTicketActiveData, GetUsupportTicketActiveResponses, GetUsupportTicketAnyResolvedData, GetUsupportTicketAnyResolvedResponses, GetUsupportTicketByIdData, GetUsupportTicketByIdErrors, GetUsupportTicketByIdHistoryData, GetUsupportTicketByIdHistoryResponses, GetUsupportTicketByIdResponses, GetUsupportTicketCommentTicketByTicketIdData, GetUsupportTicketCommentTicketByTicketIdResponses, GetUsupportTicketResolvedData, GetUsupportTicketResolvedResponses, GetUsupportTicketStatusByIdData, GetUsupportTicketStatusByIdDependentTicketsData, GetUsupportTicketStatusByIdDependentTicketsResponses, GetUsupportTicketStatusByIdErrors, GetUsupportTicketStatusByIdResponses, GetUsupportTicketStatusData, GetUsupportTicketStatusResponses, GetUsupportTicketTypeByIdData, GetUsupportTicketTypeByIdDependentTicketsData, GetUsupportTicketTypeByIdDependentTicketsResponses, GetUsupportTicketTypeByIdErrors, GetUsupportTicketTypeByIdResponses, GetUsupportTicketTypeData, GetUsupportTicketTypeResponses, PostUsupportTicketCommentData, PostUsupportTicketCommentErrors, PostUsupportTicketCommentResponses, PostUsupportTicketData, PostUsupportTicketResponses, PostUsupportTicketStatusData, PostUsupportTicketStatusResponses, PostUsupportTicketTypeData, PostUsupportTicketTypeErrors, PostUsupportTicketTypeResponses, PutUsupportSettingsData, PutUsupportSettingsResponses, PutUsupportTicketByIdData, PutUsupportTicketByIdErrors, PutUsupportTicketByIdResponses, PutUsupportTicketStatusByIdData, PutUsupportTicketStatusByIdErrors, PutUsupportTicketStatusByIdResponses, PutUsupportTicketStatusSortData, PutUsupportTicketStatusSortResponses, PutUsupportTicketTypeByIdData, PutUsupportTicketTypeByIdErrors, PutUsupportTicketTypeByIdResponses, PutUsupportTicketTypeSortData, PutUsupportTicketTypeSortResponses } from './types.gen'; export type Options = Options2 & { /** @@ -130,7 +130,7 @@ export class TicketTypeService { } public static postUsupportTicketType(options?: Options) { - return (options?.client ?? client).post({ + return (options?.client ?? client).post({ url: '/umbraco/management/api/v1/usupport/ticket-type', ...options, headers: { @@ -197,7 +197,7 @@ export class TicketService { } public static deleteUsupportTicketById(options: Options) { - return (options.client ?? client).delete({ + return (options.client ?? client).delete({ url: '/umbraco/management/api/v1/usupport/ticket/{id}', ...options }); diff --git a/src/uSupport/Client/src/api/types.gen.ts b/src/uSupport/Client/src/api/types.gen.ts index a4328c1..61bf539 100644 --- a/src/uSupport/Client/src/api/types.gen.ts +++ b/src/uSupport/Client/src/api/types.gen.ts @@ -18,11 +18,21 @@ export type CreateTicketRequestModel = { export type PreviewDeleteItemDtoModel = { id: string; + ticketId?: string | null; name?: string | null; type?: string | null; path?: string | null; }; +export type ProblemDetails = { + type?: string | null; + title?: string | null; + status?: number | null; + detail?: string | null; + instance?: string | null; + [key: string]: unknown | string | null | string | null | number | null | string | null | string | null | undefined; +}; + export type UpdateTicketRequestModel = { typeId: string; statusId: string; @@ -265,13 +275,15 @@ export type DeleteUsupportTicketStatusByIdErrors = { /** * Bad Request */ - 400: unknown; + 400: ProblemDetails; /** * Not Found */ 404: unknown; }; +export type DeleteUsupportTicketStatusByIdError = DeleteUsupportTicketStatusByIdErrors[keyof DeleteUsupportTicketStatusByIdErrors]; + export type DeleteUsupportTicketStatusByIdResponses = { /** * OK @@ -384,6 +396,15 @@ export type PostUsupportTicketTypeData = { url: '/umbraco/management/api/v1/usupport/ticket-type'; }; +export type PostUsupportTicketTypeErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; +}; + +export type PostUsupportTicketTypeError = PostUsupportTicketTypeErrors[keyof PostUsupportTicketTypeErrors]; + export type PostUsupportTicketTypeResponses = { /** * OK @@ -406,13 +427,15 @@ export type DeleteUsupportTicketTypeByIdErrors = { /** * Bad Request */ - 400: unknown; + 400: ProblemDetails; /** * Not Found */ 404: unknown; }; +export type DeleteUsupportTicketTypeByIdError = DeleteUsupportTicketTypeByIdErrors[keyof DeleteUsupportTicketTypeByIdErrors]; + export type DeleteUsupportTicketTypeByIdResponses = { /** * OK @@ -455,12 +478,18 @@ export type PutUsupportTicketTypeByIdData = { }; export type PutUsupportTicketTypeByIdErrors = { + /** + * Bad Request + */ + 400: ProblemDetails; /** * Not Found */ 404: unknown; }; +export type PutUsupportTicketTypeByIdError = PutUsupportTicketTypeByIdErrors[keyof PutUsupportTicketTypeByIdErrors]; + export type PutUsupportTicketTypeByIdResponses = { /** * OK @@ -527,6 +556,13 @@ export type DeleteUsupportTicketByIdData = { url: '/umbraco/management/api/v1/usupport/ticket/{id}'; }; +export type DeleteUsupportTicketByIdErrors = { + /** + * Not Found + */ + 404: unknown; +}; + export type DeleteUsupportTicketByIdResponses = { /** * OK diff --git a/src/uSupport/Client/src/dashboards/manifest.ts b/src/uSupport/Client/src/dashboards/manifest.ts index e85969e..ad6a2c6 100644 --- a/src/uSupport/Client/src/dashboards/manifest.ts +++ b/src/uSupport/Client/src/dashboards/manifest.ts @@ -15,7 +15,7 @@ export const manifests: Array = [ alias: 'uSupport.Dashboard.User', name: 'uSupport User Dashboard', element: () => import('./user-dashboard.element.js'), - weight: -10, + weight: 1000, meta: { label: '#dashboardTabs_uSupportUserDashboard', pathname: 'support' }, conditions: [{ alias: 'Umb.Condition.SectionAlias', match: 'Umb.Section.Content' }], }, diff --git a/src/uSupport/Client/src/dashboards/user-dashboard.element.ts b/src/uSupport/Client/src/dashboards/user-dashboard.element.ts index 306f5ae..6f19823 100644 --- a/src/uSupport/Client/src/dashboards/user-dashboard.element.ts +++ b/src/uSupport/Client/src/dashboards/user-dashboard.element.ts @@ -1,7 +1,7 @@ import { css, customElement, html, query } from '@umbraco-cms/backoffice/external/lit'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { umbOpenModal } from '@umbraco-cms/backoffice/modal'; -import { USUPPORT_CREATE_TICKET_MODAL } from '../tickets/modals/tokens.js'; +import { USUPPORT_CREATE_TICKET_MODAL, USUPPORT_OPEN_TICKET_MODAL } from '../tickets/modals/tokens.js'; import type { USupportTicketsTableElement } from '../tickets/components/tickets-table.element.js'; import '../tickets/components/tickets-table.element.js'; @@ -11,6 +11,12 @@ export class USupportUserDashboardElement extends UmbLitElement { @query('usupport-tickets-table[list="active"]') private _activeTable?: USupportTicketsTableElement; + // Update emails link here with ?ticket=, so authors without the uSupport section can open their ticket + protected firstUpdated() { + const id = new URLSearchParams(window.location.search).get('ticket'); + if (id) umbOpenModal(this, USUPPORT_OPEN_TICKET_MODAL, { data: { id } }).catch(() => undefined); + } + async #createTicket() { const result = await umbOpenModal(this, USUPPORT_CREATE_TICKET_MODAL).catch(() => undefined); if (result) this._activeTable?.load(); diff --git a/src/uSupport/Client/src/localization/ja.ts b/src/uSupport/Client/src/localization/ja.ts index c6efe22..6c9e3a8 100644 --- a/src/uSupport/Client/src/localization/ja.ts +++ b/src/uSupport/Client/src/localization/ja.ts @@ -48,7 +48,6 @@ export default { details: '詳細', internalComment: '内部コメント', internalCommentDescription: 'uSupport ユーザーにのみ表示されます', - internalCommentUpdated: '内部コメントを更新しました', emailTo: 'メール送信先:', comment: 'コメント', diff --git a/src/uSupport/Client/src/shared/lookup-actions.ts b/src/uSupport/Client/src/shared/lookup-actions.ts index 4acb55d..2d6df62 100644 --- a/src/uSupport/Client/src/shared/lookup-actions.ts +++ b/src/uSupport/Client/src/shared/lookup-actions.ts @@ -59,7 +59,7 @@ export class DeleteLookupAction extends LookupActionBase { `, @@ -70,7 +70,7 @@ export class DeleteLookupAction extends LookupActionBase { await umbConfirmModal(this, { headline: this.localize.term('actions_delete'), - content: this.localize.term('defaultdialogs_confirmdelete') + '?', + content: this.localize.term('defaultdialogs_confirmdelete'), color: 'danger', confirmLabel: this.localize.term('actions_delete'), }); diff --git a/src/uSupport/Client/src/ticket-types/ticket-type-editor.element.ts b/src/uSupport/Client/src/ticket-types/ticket-type-editor.element.ts index 4d01174..5ffc541 100644 --- a/src/uSupport/Client/src/ticket-types/ticket-type-editor.element.ts +++ b/src/uSupport/Client/src/ticket-types/ticket-type-editor.element.ts @@ -39,10 +39,11 @@ export class USupportTicketTypeEditorElement extends UmbLitElement { @change=${(e: Event) => this.#update({ dataTypeKey: (e.target as HTMLInputElement).value || null })}> - + diff --git a/src/uSupport/Client/src/tickets/components/comments.element.ts b/src/uSupport/Client/src/tickets/components/comments.element.ts index bf5b19c..5708e6f 100644 --- a/src/uSupport/Client/src/tickets/components/comments.element.ts +++ b/src/uSupport/Client/src/tickets/components/comments.element.ts @@ -1,4 +1,5 @@ import { css, customElement, html, nothing, property, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit'; +import { sanitizeHTML } from '@umbraco-cms/backoffice/utils'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UMB_NOTIFICATION_CONTEXT } from '@umbraco-cms/backoffice/notification'; import { USupportRepository, type TicketComment } from '../../repository/usupport.repository.js'; @@ -59,7 +60,7 @@ export class USupportCommentsElement extends UmbLitElement {
${comment.user?.name} ${formatDate(comment.date)} -
${unsafeHTML(comment.comment)}
+
${unsafeHTML(sanitizeHTML(comment.comment ?? ''))}
`, diff --git a/src/uSupport/Client/src/tickets/modals/open-ticket.modal.element.ts b/src/uSupport/Client/src/tickets/modals/open-ticket.modal.element.ts index cf1e0cf..8008be2 100644 --- a/src/uSupport/Client/src/tickets/modals/open-ticket.modal.element.ts +++ b/src/uSupport/Client/src/tickets/modals/open-ticket.modal.element.ts @@ -1,4 +1,5 @@ import { customElement, html, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit'; +import { sanitizeHTML } from '@umbraco-cms/backoffice/utils'; import { UmbModalBaseElement } from '@umbraco-cms/backoffice/modal'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import { USupportRepository, type Ticket } from '../../repository/usupport.repository.js'; @@ -27,12 +28,12 @@ export class USupportOpenTicketModalElement extends UmbModalBaseElement<{ id: st
- +

${ticket.title}

- -

${unsafeHTML(ticket.summary)}

+ +

${unsafeHTML(sanitizeHTML(ticket.summary ?? ''))}

${ticket.type?.dataTypeKey diff --git a/src/uSupport/Client/src/tickets/workspace/views/ticket.view.element.ts b/src/uSupport/Client/src/tickets/workspace/views/ticket.view.element.ts index 6873deb..7fb880a 100644 --- a/src/uSupport/Client/src/tickets/workspace/views/ticket.view.element.ts +++ b/src/uSupport/Client/src/tickets/workspace/views/ticket.view.element.ts @@ -1,4 +1,5 @@ import { customElement, html, nothing, state, unsafeHTML } from '@umbraco-cms/backoffice/external/lit'; +import { sanitizeHTML } from '@umbraco-cms/backoffice/utils'; import { UmbLitElement } from '@umbraco-cms/backoffice/lit-element'; import { UmbTextStyles } from '@umbraco-cms/backoffice/style'; import type { Ticket, TicketStatus, TicketType } from '../../../repository/usupport.repository.js'; @@ -43,12 +44,12 @@ export class USupportTicketViewElement extends UmbLitElement {
- +

${ticket.title}

- -

${unsafeHTML(ticket.summary)}

+ +

${unsafeHTML(sanitizeHTML(ticket.summary ?? ''))}


diff --git a/src/uSupport/Controllers/uSupportTicketController.cs b/src/uSupport/Controllers/uSupportTicketController.cs index 77ab07b..9dd7972 100644 --- a/src/uSupport/Controllers/uSupportTicketController.cs +++ b/src/uSupport/Controllers/uSupportTicketController.cs @@ -56,12 +56,20 @@ public uSupportTicketController(IHtmlSanitizer htmlSanitizer, [HttpGet("active")] [ProducesResponseType>(StatusCodes.Status200OK)] public IActionResult GetActive(long page = 1, string? searchTerm = null, string? sortColumn = null, bool sortDescending = true) - => Ok(_uSupportTicketService.GetPagedActiveTickets(page, searchTerm, ToSort(sortColumn, sortDescending))); + { + var tickets = _uSupportTicketService.GetPagedActiveTickets(page, searchTerm, ToSort(sortColumn, sortDescending)); + HideInternalComments(tickets.Items); + return Ok(tickets); + } [HttpGet("resolved")] [ProducesResponseType>(StatusCodes.Status200OK)] public IActionResult GetResolved(long page = 1, string? searchTerm = null, string? sortColumn = null, bool sortDescending = true) - => Ok(_uSupportTicketService.GetPagedResolvedTickets(page, searchTerm, ToSort(sortColumn, sortDescending))); + { + var tickets = _uSupportTicketService.GetPagedResolvedTickets(page, searchTerm, ToSort(sortColumn, sortDescending)); + HideInternalComments(tickets.Items); + return Ok(tickets); + } [HttpGet("any-resolved")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -77,12 +85,25 @@ public IActionResult GetById(Guid id) return NotFound(); ticket.Author = _userHelper.GetUser(ticket.AuthorId); + HideInternalComments([ticket]); return Ok(ticket); } [HttpGet("{id:guid}/history")] [ProducesResponseType>(StatusCodes.Status200OK)] - public IActionResult GetHistory(Guid id, long page = 1) => Ok(_uSupportTicketHistoryService.GetPagedByTicketId(id, page)); + public IActionResult GetHistory(Guid id, long page = 1) + { + var history = _uSupportTicketHistoryService.GetPagedByTicketId(id, page); + if (!IsSupportUser()) + { + foreach (var item in history.Items) + { + item.Changes = item.Changes?.Where(x => x.Field != "InternalComment").ToList(); + item.ChangesJson = null; + } + } + return Ok(history); + } [HttpPost] [ProducesResponseType(StatusCodes.Status200OK)] @@ -198,6 +219,19 @@ public IActionResult Delete(Guid id) return Ok(); } + // Internal comments are for uSupport section users only; everyone else can still read the tickets. + private bool IsSupportUser() + => CurrentUser(_backOfficeSecurityAccessor).AllowedSections.Contains(uSupportConstants.SectionAlias); + + private void HideInternalComments(IEnumerable tickets) + { + if (IsSupportUser()) + return; + + foreach (var ticket in tickets) + ticket.InternalComment = null; + } + private static uSupportSort? ToSort(string? sortColumn, bool sortDescending) => string.IsNullOrWhiteSpace(sortColumn) ? null : new uSupportSort { ColumnName = sortColumn, Descending = sortDescending }; } diff --git a/src/uSupport/Controllers/uSupportTicketTypeController.cs b/src/uSupport/Controllers/uSupportTicketTypeController.cs index 099e156..d9e96f9 100644 --- a/src/uSupport/Controllers/uSupportTicketTypeController.cs +++ b/src/uSupport/Controllers/uSupportTicketTypeController.cs @@ -45,8 +45,12 @@ public IActionResult GetById(Guid id) [HttpPost] [Authorize(Policy = uSupportConstants.uSupportSectionAccess)] [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public IActionResult Create(uSupportTicketType model) { + if (PropertyNameProblem(model) is { } problem) + return problem; + var schema = model.ConvertDtoToSchema(); schema.Id = Guid.NewGuid(); schema.Order = _uSupportTicketTypeService.GetTypesCount() + 1; @@ -61,12 +65,16 @@ public IActionResult Create(uSupportTicketType model) [Authorize(Policy = uSupportConstants.uSupportSectionAccess)] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] public IActionResult Update(Guid id, uSupportTicketType model) { var existing = _uSupportTicketTypeService.Get(id); if (existing == null) return NotFound(); + if (PropertyNameProblem(model) is { } problem) + return problem; + model.Id = id; model.Order = existing.Order; @@ -122,5 +130,10 @@ public IActionResult Sort(List ids) // ponytail: loads every ticket and filters in memory; add a WHERE TypeId query if ticket counts grow large private IEnumerable GetDependentTicketItems(Guid id) => _uSupportTicketService.GetAll().Where(x => x.TypeId == id).ConvertToDeletePreviewDto(); + + private IActionResult? PropertyNameProblem(uSupportTicketType model) + => model.DataTypeKey != null && string.IsNullOrWhiteSpace(model.PropertyName) + ? Problem(title: "A property name is required when a data type is selected.", statusCode: StatusCodes.Status400BadRequest) + : null; } } diff --git a/src/uSupport/Dtos/PreviewDeleteItemDto.cs b/src/uSupport/Dtos/PreviewDeleteItemDto.cs index faf7ca6..13f7aea 100644 --- a/src/uSupport/Dtos/PreviewDeleteItemDto.cs +++ b/src/uSupport/Dtos/PreviewDeleteItemDto.cs @@ -5,6 +5,7 @@ namespace uSupport.Dtos public class PreviewDeleteItemDto { public Guid Id { get; set; } + public string? TicketId { get; set; } public string? Name { get; set; } public string? Type { get; set; } public string? Path { get; set; } diff --git a/src/uSupport/Helpers/uSupportTypeHelper.cs b/src/uSupport/Helpers/uSupportTypeHelper.cs index 55da7ce..3fe96ea 100644 --- a/src/uSupport/Helpers/uSupportTypeHelper.cs +++ b/src/uSupport/Helpers/uSupportTypeHelper.cs @@ -89,6 +89,7 @@ public static PreviewDeleteItemDto ConvertToDeletePreviewDto(this uSupportTicket return new PreviewDeleteItemDto() { Id = ticket.Id, + TicketId = ticket.ExternalTicketId, Name = ticket.Title, Type = "ticket", Path = "" diff --git a/src/uSupport/Migrations/Create/uSupportTicketTypeTable.cs b/src/uSupport/Migrations/Create/uSupportTicketTypeTable.cs index 22763a4..992ea1a 100644 --- a/src/uSupport/Migrations/Create/uSupportTicketTypeTable.cs +++ b/src/uSupport/Migrations/Create/uSupportTicketTypeTable.cs @@ -21,7 +21,7 @@ protected override void DoMigrate() Icon = "icon-layout", Description = "A page isn''t working", PropertyName = "Page", - PropertyDescription = "Select the page that you''re are having an issue with", + PropertyDescription = "Select the page that you''re having an issue with", PropertyId = 1046, PropertyView = "contentpicker", Order = 1 diff --git a/src/uSupport/Migrations/Updates/17.0.0/MigrateContentPickerValues.cs b/src/uSupport/Migrations/Updates/17.0.0/MigrateContentPickerValues.cs new file mode 100644 index 0000000..0ef42bc --- /dev/null +++ b/src/uSupport/Migrations/Updates/17.0.0/MigrateContentPickerValues.cs @@ -0,0 +1,78 @@ +using NPoco; +using System.Text.Json; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Services; +using uSupport.Migrations.Schemas; +using Umbraco.Cms.Infrastructure.Migrations; +using static uSupport.Constants.uSupportConstants; + +namespace uSupport.Migrations.Updates._17._0._0 +{ + /// + /// v13 stored content picker values as an int node id or a document UDI. + /// The v17 document picker reads a document key, so convert them once. + /// + public class MigrateContentPickerValues : uSupportMigrationBase + { + private readonly IIdKeyMap _idKeyMap; + + public MigrateContentPickerValues(IMigrationContext context, IIdKeyMap idKeyMap) : base(context) + { + _idKeyMap = idKeyMap; + } + + protected override void DoMigrate() + { + // Go by the type's current data type, not the v13 PropertyId/PropertyView, which the v17 type editor never updates + var pickerTypeIds = Database.Fetch(new Sql().Select("*").From(TicketTypeTableAlias)) + .Where(x => x.DataTypeKey is Guid key && IsContentPicker(key)) + .Select(x => x.Id) + .ToHashSet(); + + if (pickerTypeIds.Count == 0) + return; + + // ponytail: loads every ticket's value once; fine for a one-off migration at ticket-system volumes + var tickets = Database.Fetch(new Sql($"SELECT Id, TypeId, PropertyValue FROM {TicketTableAlias} WHERE PropertyValue IS NOT NULL")) + .Where(x => pickerTypeIds.Contains(x.TypeId)); + + foreach (var ticket in tickets) + { + var value = ToKeyJson(ticket.PropertyValue!, id => + { + var key = _idKeyMap.GetKeyForId(id, UmbracoObjectTypes.Document); + return key.Success ? key.Result : null; + }); + + if (value == null) + continue; + + ticket.PropertyValue = value; + Database.Update(TicketTableAlias, "Id", ticket, new[] { "PropertyValue" }); + } + } + + // Also covers AddDataTypeKeyToTicketTypes' fallback, which points at the default content picker data type + private bool IsContentPicker(Guid dataTypeKey) + { + var id = _idKeyMap.GetIdForKey(dataTypeKey, UmbracoObjectTypes.DataType); + return id.Success && Database.ExecuteScalar("SELECT propertyEditorAlias FROM umbracoDataType WHERE nodeId = @0", id.Result) + == Umbraco.Cms.Core.Constants.PropertyEditors.Aliases.ContentPicker; + } + + /// + /// Returns the v17 value (a JSON string holding the document key), or null when there is nothing to convert. + /// + public static string? ToKeyJson(string value, Func idToKey) + { + var raw = value.Trim().Trim('"'); + + Guid? key = int.TryParse(raw, out var id) ? idToKey(id) + : UdiParser.TryParse(raw, out Udi? udi) && udi is GuidUdi { EntityType: Umbraco.Cms.Core.Constants.UdiEntityType.Document } guidUdi ? guidUdi.Guid + : null; + + return key == null ? null : JsonSerializer.Serialize(key.Value.ToString()); + } + } +} diff --git a/src/uSupport/Migrations/uSupportMigrationPlan.cs b/src/uSupport/Migrations/uSupportMigrationPlan.cs index 8ecccb3..c6c22fb 100644 --- a/src/uSupport/Migrations/uSupportMigrationPlan.cs +++ b/src/uSupport/Migrations/uSupportMigrationPlan.cs @@ -29,7 +29,8 @@ protected void DefinePlan() .To("uSupport-add-last-updated-to-tickets") .To("uSupport-add-internal-comment-column") .To("uSupport-settings") - .To("uSupport-17-data-type-key"); + .To("uSupport-17-data-type-key") + .To("uSupport-17-content-picker-values"); } } } \ No newline at end of file diff --git a/src/uSupport/Services/uSupportSettingsService.cs b/src/uSupport/Services/uSupportSettingsService.cs index 7204bbf..6a901ad 100644 --- a/src/uSupport/Services/uSupportSettingsService.cs +++ b/src/uSupport/Services/uSupportSettingsService.cs @@ -79,7 +79,7 @@ public async Task SendEmail(string toAddress, string subject, string templateVie throw new Exception("Failed to send email. Smtp from is not set in appsettings."); if (string.IsNullOrEmpty(toAddress) || toAddress == "None") - throw new Exception("Failed to send email. TicketUpdateEmail is not set in appsettings."); + throw new Exception("Failed to send email. TicketUpdateEmail is not set in uSupport settings."); EmailMessage message; var emailBody = await RenderEmailTemplateAsync(templateViewPath, model); diff --git a/src/uSupport/Services/uSupportTicketStatusService.cs b/src/uSupport/Services/uSupportTicketStatusService.cs index 6fc3987..9363e32 100644 --- a/src/uSupport/Services/uSupportTicketStatusService.cs +++ b/src/uSupport/Services/uSupportTicketStatusService.cs @@ -66,7 +66,7 @@ public IEnumerable GetAll() var context = GetScope(); try { - return context.Scope.Database.Query($"SELECT * FROM {TicketStatusTableAlias} ORDER BY [Order]"); + return context.Scope.Database.Fetch($"SELECT * FROM {TicketStatusTableAlias} ORDER BY [Order]"); } finally { diff --git a/src/uSupport/Views/Partials/uSupport/Emails/NewTicketEmail.cshtml b/src/uSupport/Views/Partials/uSupport/Emails/NewTicketEmail.cshtml index 9024be0..8c7e912 100644 --- a/src/uSupport/Views/Partials/uSupport/Emails/NewTicketEmail.cshtml +++ b/src/uSupport/Views/Partials/uSupport/Emails/NewTicketEmail.cshtml @@ -62,7 +62,7 @@

Submitted

-

@Model.Submitted.ToString("yyyy-MM-dd hh:mm")

+

@Model.Submitted.ToString("yyyy-MM-dd HH:mm")

diff --git a/src/uSupport/Views/Partials/uSupport/Emails/UpdateTicketEmail.cshtml b/src/uSupport/Views/Partials/uSupport/Emails/UpdateTicketEmail.cshtml index 65b62ff..d29c658 100644 --- a/src/uSupport/Views/Partials/uSupport/Emails/UpdateTicketEmail.cshtml +++ b/src/uSupport/Views/Partials/uSupport/Emails/UpdateTicketEmail.cshtml @@ -62,7 +62,7 @@

Last updated

-

@Model.LastUpdated?.ToString("yyyy-MM-dd hh:mm")

+

@Model.LastUpdated?.ToString("yyyy-MM-dd HH:mm")

@@ -124,7 +124,7 @@

@comment.User?.Name

-

@comment.Date.ToString("yyyy-MM-dd hh:mm")

+

@comment.Date.ToString("yyyy-MM-dd HH:mm")

@Html.Raw(comment.Comment)

@@ -159,7 +159,7 @@ - + View Ticket → diff --git a/src/uSupport/uSupport.csproj b/src/uSupport/uSupport.csproj index 0d5469d..d1a1fb3 100644 --- a/src/uSupport/uSupport.csproj +++ b/src/uSupport/uSupport.csproj @@ -25,8 +25,8 @@ https://github.com/Lantzify/uSupport umbraco umbraco-marketplace uSupport support https://github.com/Lantzify/uSupport - uSupport is an feature rich ticketing system designed specifically for the Umbraco CMS platform. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. - uSupport is an feature rich ticketing system designed specifically for the Umbraco CMS platform. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. + uSupport is a feature-rich ticketing system designed specifically for the Umbraco CMS platform. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. + uSupport is a feature-rich ticketing system designed specifically for the Umbraco CMS platform. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. true true umbraco-marketplace-readme.md diff --git a/umbraco-marketplace-readme.md b/umbraco-marketplace-readme.md index 6f0a8f8..8ee3f0c 100644 --- a/umbraco-marketplace-readme.md +++ b/umbraco-marketplace-readme.md @@ -1,5 +1,5 @@ ## What is uSupport -uSupport is an feature rich ticketing system designed specifically for Umbraco. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. +uSupport is a feature-rich ticketing system designed specifically for Umbraco. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses. ## Why uSupport Say goodbye to the days of digging through countless old emails in search of vital details. With uSupport, everything you need is neatly organized within the Umbraco environment. Streamline your workflow, boost productivity, and effortlessly access all the information you need to address and resolve client issues efficiently. diff --git a/umbraco-marketplace.json b/umbraco-marketplace.json index 6301297..426a45c 100644 --- a/umbraco-marketplace.json +++ b/umbraco-marketplace.json @@ -11,7 +11,7 @@ "Free" ], "Title": "uSupport", - "Description": "uSupport is an feature rich ticketing system designed specifically for the Umbraco. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses.", + "Description": "uSupport is a feature-rich ticketing system designed specifically for Umbraco. It is a complete ticketing solution that enables users to create, manage, and view tickets and their associated comments. With uSupport, you can quickly and easily create and manage tickets. uSupport is highly customizable, allowing users to set up their own ticket types and define ticket statuses.", "DiscussionForumUrl": "https://our.umbraco.com/packages/backoffice-extensions/usupport/usupport-support/", "DocumentationUrl": "https://Lantzify.github.io/uSupport-documentation/", "IssueTrackerUrl": "https://github.com/Lantzify/uSupport/issues",