diff --git a/packages/manager/.changeset/pr-10745-changed-1723666784731.md b/packages/manager/.changeset/pr-10745-changed-1723666784731.md new file mode 100644 index 00000000000..1db2047bf20 --- /dev/null +++ b/packages/manager/.changeset/pr-10745-changed-1723666784731.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Improve support ticket form pre-population and field labels ([#10745](https://github.com/linode/manager/pull/10745)) diff --git a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts index 07636b6f0ee..bffc53d6cef 100644 --- a/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts +++ b/packages/manager/cypress/e2e/core/helpAndSupport/open-support-ticket.spec.ts @@ -365,14 +365,14 @@ describe('open support tickets', () => { description: '', entityId: '', entityInputValue: '', - entityType: 'general' as EntityType, + entityType: 'linode_id' as EntityType, selectedSeverity: undefined, summary: 'Account Limit Increase', ticketType: 'accountLimit' as TicketType, - companyName: mockAccount.company, customerName: `${mockAccount.first_name} ${mockAccount.last_name}`, + companyName: mockAccount.company, numberOfEntities: '2', - linodePlan: 'Nanode 1 GB', + linodePlan: 'Nanode 1GB', useCase: randomString(), publicInfo: randomString(), }; @@ -449,6 +449,13 @@ describe('open support tickets', () => { .should('be.visible') .should('have.value', mockFormFields.companyName); + // Confirm plan pre-populates from form payload data. + cy.findByLabelText('Which Linode plan do you need access to?', { + exact: false, + }) + .should('be.visible') + .should('have.value', mockFormFields.linodePlan); + // Confirm helper text and link. cy.findByText('Current number of Linodes: 1').should('be.visible'); cy.findByText('View types of plans') @@ -467,16 +474,11 @@ describe('open support tickets', () => { cy.findByText('Links to public information are required.'); // Complete the rest of the form. - cy.findByLabelText('Total number of entities you need?') + cy.findByLabelText('Total number of Linodes you need?') .should('be.visible') .click() .type(mockFormFields.numberOfEntities); - cy.findByLabelText('Which Linode plan do you need access to?') - .should('be.visible') - .click() - .type(mockFormFields.linodePlan); - cy.get('[data-qa-ticket-use-case]') .should('be.visible') .click() @@ -511,9 +513,13 @@ describe('open support tickets', () => { cy.contains( `#${mockAccountLimitTicket.id}: ${mockAccountLimitTicket.summary}` ).should('be.visible'); - Object.values(ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP).forEach( - (fieldLabel) => { - cy.findByText(fieldLabel).should('be.visible'); + Object.entries(ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP).forEach( + ([key, fieldLabel]) => { + let _fieldLabel = fieldLabel; + if (key === 'useCase' || key === 'numberOfEntities') { + _fieldLabel = _fieldLabel.replace('entities', 'Linodes'); + } + cy.findByText(_fieldLabel).should('be.visible'); } ); }); diff --git a/packages/manager/src/components/ErrorMessage.tsx b/packages/manager/src/components/ErrorMessage.tsx index b0092809348..68917fb0fd3 100644 --- a/packages/manager/src/components/ErrorMessage.tsx +++ b/packages/manager/src/components/ErrorMessage.tsx @@ -3,23 +3,28 @@ import React from 'react'; import { SupportTicketGeneralError } from './SupportTicketGeneralError'; import { Typography } from './Typography'; -import type { EntityType } from 'src/features/Support/SupportTickets/SupportTicketDialog'; +import type { + EntityType, + FormPayloadValues, +} from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface Props { entityType: EntityType; + formPayloadValues?: FormPayloadValues; message: string; } export const supportTextRegex = /(open a support ticket|contact Support)/i; export const ErrorMessage = (props: Props) => { - const { entityType, message } = props; + const { entityType, formPayloadValues, message } = props; const isSupportTicketError = supportTextRegex.test(message); if (isSupportTicketError) { return ( ); diff --git a/packages/manager/src/components/SupportLink/SupportLink.tsx b/packages/manager/src/components/SupportLink/SupportLink.tsx index 16dd526b831..a9a112354e6 100644 --- a/packages/manager/src/components/SupportLink/SupportLink.tsx +++ b/packages/manager/src/components/SupportLink/SupportLink.tsx @@ -4,12 +4,14 @@ import { Link } from 'react-router-dom'; import type { LinkProps } from 'react-router-dom'; import type { EntityType, + FormPayloadValues, TicketType, } from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface SupportLinkProps { description?: string; entity?: EntityForTicketDetails; + formPayloadValues?: FormPayloadValues; onClick?: LinkProps['onClick']; text: string; ticketType?: TicketType; @@ -22,7 +24,16 @@ export interface EntityForTicketDetails { } const SupportLink = (props: SupportLinkProps) => { - const { description, entity, onClick, text, ticketType, title } = props; + const { + description, + entity, + formPayloadValues, + onClick, + text, + ticketType, + title, + } = props; + return ( { state: { description, entity, + formPayloadValues, open: true, ticketType, title, diff --git a/packages/manager/src/components/SupportTicketGeneralError.tsx b/packages/manager/src/components/SupportTicketGeneralError.tsx index 6e09556dc37..d026c879ef9 100644 --- a/packages/manager/src/components/SupportTicketGeneralError.tsx +++ b/packages/manager/src/components/SupportTicketGeneralError.tsx @@ -7,10 +7,14 @@ import { capitalize } from 'src/utilities/capitalize'; import { supportTextRegex } from './ErrorMessage'; import { Typography } from './Typography'; -import type { EntityType } from 'src/features/Support/SupportTickets/SupportTicketDialog'; +import type { + EntityType, + FormPayloadValues, +} from 'src/features/Support/SupportTickets/SupportTicketDialog'; interface SupportTicketGeneralErrorProps { entityType: EntityType; + formPayloadValues?: FormPayloadValues; generalError: string; } @@ -19,7 +23,7 @@ const accountLimitRegex = /(limit|limit for the number of active services) on yo export const SupportTicketGeneralError = ( props: SupportTicketGeneralErrorProps ) => { - const { entityType, generalError } = props; + const { entityType, formPayloadValues, generalError } = props; const theme = useTheme(); const limitError = generalError.split(supportTextRegex); @@ -50,6 +54,7 @@ export const SupportTicketGeneralError = ( isAccountLimitSupportTicket ? 'accountLimit' : 'general' } entity={{ id: undefined, type: entityType }} + formPayloadValues={formPayloadValues} key={`${substring}-${idx}`} /> ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 2ff221c7f2c..2e82e2817e7 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -212,7 +212,11 @@ export const CreateCluster = () => { {generalError && ( - + )} diff --git a/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx b/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx index 833742288a5..0b8dc8ed2ff 100644 --- a/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx +++ b/packages/manager/src/features/Linodes/LinodeCreatev2/Error.tsx @@ -10,9 +10,11 @@ import type { CreateLinodeRequest } from '@linode/api-v4'; export const Error = () => { const { formState: { errors }, + getValues, } = useFormContext(); const generalError = errors.root?.message ?? errors.interfaces?.message; + const values = getValues(); if (!generalError) { return null; @@ -21,7 +23,11 @@ export const Error = () => { return ( - + ); diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 80255fcf94d..d214eee5753 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -846,7 +846,11 @@ export class LinodeCreate extends React.PureComponent< )} {generalError && ( - + )} {userCannotCreateLinode && ( diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx index 3c1f061adaa..271ff3dfbcc 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketAccountLimitFields.tsx @@ -4,31 +4,71 @@ import { Controller, useFormContext } from 'react-hook-form'; import { Link } from 'src/components/Link'; import { TextField } from 'src/components/TextField'; import { useAccount } from 'src/queries/account/account'; +import { useSpecificTypes, useTypeQuery } from 'src/queries/types'; +import { extendTypesQueryResult } from 'src/utilities/extendType'; import { ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP } from './constants'; import { SupportTicketProductSelectionFields } from './SupportTicketProductSelectionFields'; +import { getEntityNameFromEntityType } from './ticketUtils'; import type { CustomFields } from './constants'; -import type { SupportTicketFormFields } from './SupportTicketDialog'; +import type { + FormPayloadValues, + SupportTicketFormFields, +} from './SupportTicketDialog'; export interface AccountLimitCustomFields extends CustomFields { linodePlan: string; numberOfEntities: string; } -export const SupportTicketAccountLimitFields = () => { +interface Props { + prefilledFormPayloadValues?: FormPayloadValues; +} + +export const SupportTicketAccountLimitFields = ({ + prefilledFormPayloadValues, +}: Props) => { const { control, formState, reset, watch } = useFormContext< AccountLimitCustomFields & SupportTicketFormFields >(); + const { entityType } = watch(); - const { data: account } = useAccount(); + const prefilledLinodeType = + prefilledFormPayloadValues && 'type' in prefilledFormPayloadValues + ? prefilledFormPayloadValues.type + : ''; + const prefilledKubeTypes = + prefilledFormPayloadValues && 'node_pools' in prefilledFormPayloadValues + ? prefilledFormPayloadValues.node_pools?.map((pool) => pool.type) + : []; - const { entityType } = watch(); + const { data: account } = useAccount(); + const { data: linodeType } = useTypeQuery( + prefilledLinodeType ?? '', + Boolean(prefilledLinodeType) + ); + const kubeTypesQuery = useSpecificTypes( + prefilledKubeTypes ?? [], + Boolean(prefilledKubeTypes) + ); + const kubeTypes = extendTypesQueryResult(kubeTypesQuery); + // Must be in the same order as the fields are displayed in the form. const defaultValues = { - companyName: account?.company, - customerName: `${account?.first_name} ${account?.last_name}`, ...formState.defaultValues, + customerName: `${account?.first_name ?? ''} ${account?.last_name ?? ''}`, + // eslint-disable-next-line perfectionist/sort-objects + companyName: account?.company ?? '', + numberOfEntities: '', + // eslint-disable-next-line perfectionist/sort-objects + linodePlan: + linodeType?.label ?? + kubeTypes.map((type) => type.formattedLabel).join(', ') ?? + '', + useCase: '', + // eslint-disable-next-line perfectionist/sort-objects + publicInfo: '', }; const shouldShowLinodePlanField = @@ -95,10 +135,13 @@ export const SupportTicketAccountLimitFields = () => { ( + | Partial; + export interface TicketTypeData { dialogTitle: string; helperText: JSX.Element | string; @@ -146,33 +153,48 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { prefilledTitle, } = props; + const location = useLocation(); + const stateParams = location.state; + + // Collect prefilled data from props or Link parameters. + const _prefilledDescription: string = + prefilledDescription ?? stateParams?.description ?? undefined; + const _prefilledEntity: EntityForTicketDetails = + prefilledEntity ?? stateParams?.entity ?? undefined; + const _prefilledTitle: string = + prefilledTitle ?? stateParams?.title ?? undefined; + const prefilledFormPayloadValues: FormPayloadValues = + stateParams?.formPayloadValues ?? undefined; + const _prefilledTicketType: TicketType = + prefilledTicketType ?? stateParams?.ticketType ?? undefined; + + // Use the prefilled title if one is given, otherwise, use any default prefill titles by ticket type, if extant. + const newPrefilledTitle = _prefilledTitle + ? _prefilledTitle + : _prefilledTicketType && TICKET_TYPE_MAP[_prefilledTicketType] + ? TICKET_TYPE_MAP[_prefilledTicketType].ticketTitle + : undefined; + const formContainerRef = React.useRef(null); const hasSeverityCapability = useTicketSeverityCapability(); const valuesFromStorage = storage.supportTicket.get(); - // Use a prefilled title if one is given, otherwise, use any default prefill titles by ticket type, if extant. - const _prefilledTitle = prefilledTitle - ? prefilledTitle - : prefilledTicketType && TICKET_TYPE_MAP[prefilledTicketType] - ? TICKET_TYPE_MAP[prefilledTicketType].ticketTitle - : undefined; - // Ticket information const form = useForm({ defaultValues: { description: getInitialValue( - prefilledDescription, + _prefilledDescription, valuesFromStorage.description ), - entityId: prefilledEntity?.id ? String(prefilledEntity.id) : '', + entityId: _prefilledEntity?.id ? String(_prefilledEntity.id) : '', entityInputValue: '', - entityType: prefilledEntity?.type ?? 'general', - summary: getInitialValue(_prefilledTitle, valuesFromStorage.summary), - ticketType: prefilledTicketType ?? 'general', + entityType: _prefilledEntity?.type ?? 'general', + summary: getInitialValue(newPrefilledTitle, valuesFromStorage.summary), + ticketType: _prefilledTicketType ?? 'general', }, - resolver: yupResolver(SCHEMA_MAP[prefilledTicketType ?? 'general']), + resolver: yupResolver(SCHEMA_MAP[_prefilledTicketType ?? 'general']), }); const { @@ -458,7 +480,11 @@ export const SupportTicketDialog = (props: SupportTicketDialogProps) => { )} {ticketType === 'smtp' && } - {ticketType === 'accountLimit' && } + {ticketType === 'accountLimit' && ( + + )} {(!ticketType || ticketType === 'general') && ( <> {props.hideProductSelection ? null : ( diff --git a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx index 57deb302df3..1998e626fc5 100644 --- a/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx +++ b/packages/manager/src/features/Support/SupportTickets/SupportTicketProductSelectionFields.tsx @@ -18,6 +18,7 @@ import { ENTITY_ID_TO_NAME_MAP, ENTITY_MAP, } from './constants'; +import { getEntityNameFromEntityType } from './ticketUtils'; import type { AccountLimitCustomFields } from './SupportTicketAccountLimitFields'; import type { @@ -183,10 +184,7 @@ export const SupportTicketProductSelectionFields = (props: Props) => { return eachTopic.value === entityType; }); - const _entityType = - entityType !== 'general' && entityType !== 'none' - ? `${ENTITY_ID_TO_NAME_MAP[entityType]}s` - : 'entities'; + const _entityType = getEntityNameFromEntityType(entityType, true); return ( // eslint-disable-next-line react/jsx-no-useless-fragment @@ -195,10 +193,13 @@ export const SupportTicketProductSelectionFields = (props: Props) => { ( { stateParams ? stateParams.open : parsedParams.drawerOpen === 'true' ); - // @todo this should be handled in the support ticket component - // and probably does not need to use state - const [prefilledDescription] = React.useState( - stateParams ? stateParams.description : undefined - ); - const [prefilledTitle] = React.useState( - stateParams ? stateParams.title : undefined - ); - const [prefilledEntity] = React.useState( - stateParams ? stateParams.entity : undefined - ); - const [prefilledTicketType] = React.useState( - stateParams ? stateParams.ticketType : undefined - ); - const handleAddTicketSuccess = ( ticketId: number, attachmentErrors: AttachmentError[] = [] @@ -106,10 +91,6 @@ const SupportTicketsLanding = () => { onClose={() => setDrawerOpen(false)} onSuccess={handleAddTicketSuccess} open={drawerOpen} - prefilledDescription={prefilledDescription} - prefilledEntity={prefilledEntity} - prefilledTicketType={prefilledTicketType} - prefilledTitle={prefilledTitle} /> ); diff --git a/packages/manager/src/features/Support/SupportTickets/constants.tsx b/packages/manager/src/features/Support/SupportTickets/constants.tsx index 3bb669ca145..9e1134a0f02 100644 --- a/packages/manager/src/features/Support/SupportTickets/constants.tsx +++ b/packages/manager/src/features/Support/SupportTickets/constants.tsx @@ -88,8 +88,9 @@ export const ENTITY_ID_TO_NAME_MAP: Record = { // General custom fields common to multiple custom ticket types. export const CUSTOM_FIELD_NAME_TO_LABEL_MAP: Record = { - companyName: 'Business or company name', customerName: 'First and last name', + // eslint-disable-next-line perfectionist/sort-objects + companyName: 'Business or company name', publicInfo: "Links to public information - e.g. your business or application's website, Twitter profile, GitHub, etc.", useCase: 'A clear and detailed description of your use case', @@ -104,8 +105,9 @@ export const SMTP_FIELD_NAME_TO_LABEL_MAP: Record = { export const ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP: Record = { ...CUSTOM_FIELD_NAME_TO_LABEL_MAP, - linodePlan: 'Which Linode plan do you need access to?', numberOfEntities: 'Total number of entities you need?', + // eslint-disable-next-line perfectionist/sort-objects + linodePlan: 'Which Linode plan do you need access to?', useCase: 'A detailed description of your use case and why you need access to more/larger entities', }; diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts index 025f0469bea..e30ec784902 100644 --- a/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.test.ts @@ -1,5 +1,5 @@ import { SMTP_FIELD_NAME_TO_LABEL_MAP } from './constants'; -import { formatDescription } from './ticketUtils'; +import { formatDescription, getEntityNameFromEntityType } from './ticketUtils'; import type { SupportTicketFormFields } from './SupportTicketDialog'; import type { SMTPCustomFields } from './SupportTicketSMTPFields'; @@ -47,3 +47,19 @@ describe('formatDescription', () => { ).toEqual(expectedFormattedDescription); }); }); + +describe('getEntityNameFromEntityType', () => { + it('returns a human-readable entity name or default from the entity type', () => { + const nbEntityType = 'nodebalancer_id'; + const generalEntityType = 'general'; + + expect(getEntityNameFromEntityType(nbEntityType)).toEqual('NodeBalancer'); + expect(getEntityNameFromEntityType(nbEntityType, true)).toEqual( + 'NodeBalancers' + ); + expect(getEntityNameFromEntityType(generalEntityType)).toEqual('entity'); + expect(getEntityNameFromEntityType(generalEntityType, true)).toEqual( + 'entities' + ); + }); +}); diff --git a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts index 2aa2d2a7212..c3bc84d61a7 100644 --- a/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts +++ b/packages/manager/src/features/Support/SupportTickets/ticketUtils.ts @@ -6,12 +6,14 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP, + ENTITY_ID_TO_NAME_MAP, SMTP_FIELD_NAME_TO_LABEL_MAP, TICKET_TYPE_TO_CUSTOM_FIELD_KEYS_MAP, } from './constants'; import type { AllSupportTicketFormFields, + EntityType, SupportTicketFormFields, TicketType, } from './SupportTicketDialog'; @@ -105,9 +107,33 @@ export const formatDescription = ( if (ticketType === 'smtp') { label = SMTP_FIELD_NAME_TO_LABEL_MAP[key]; } else if (ticketType === 'accountLimit') { - label = ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP[key]; + // Use the specific entity type in the form labels to match what is displayed to the user. + if (key === 'numberOfEntities' || key === 'useCase') { + label = ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP[key].replace( + 'entities', + getEntityNameFromEntityType(values.entityType, true) + ); + } else { + label = ACCOUNT_LIMIT_FIELD_NAME_TO_LABEL_MAP[key]; + } } return `**${label}**\n${value ? value : 'No response'}`; }) .join('\n\n'); }; + +/** + * getEntityNameFromEntityType + * + * @param entityType - the entity type submitted with the support ticket; ends in '_id' + * @param isPlural - if true, pluralize the entity name; defaults to false + * @returns human readable entity name, singular or plural; falls back on generic 'entity' + */ +export const getEntityNameFromEntityType = ( + entityType: EntityType, + isPlural = false +) => { + return entityType !== 'general' && entityType !== 'none' + ? `${ENTITY_ID_TO_NAME_MAP[entityType]}${isPlural ? 's' : ''}` + : `${isPlural ? 'entities' : 'entity'}`; +};