From 7ddfb9dc244257a775f3de85b6eed3d29c991e1e Mon Sep 17 00:00:00 2001 From: Rafael Gallani Date: Thu, 7 Dec 2023 14:29:51 -0300 Subject: [PATCH] Domains: Add Glue Records management UI (#84261) * Add initial structure * Fix styles * Improve styles * Add feature flags * Replace `Remove` link with button * Improve loading state * Improve placeholder labels * Update field labels * Add limit of glue records * Improve react-query cached data * Include placeholder style * Force lowercase nameservers * Render `Add Glue Records` action only when necessary * Fix cancel button behavior * Lazy query * Improve data caching * Only show glue records card for domains registered with us * Fix comments * Validate record and IP address fields before saving glue record * Rename method `handleAddGlueRecord` to `showGlueRecordForm` * Fix comment * Add missing dependency for `useEffect` * Simplify condition * Only show glue records card for domains registered through Key-Systems * Add error validation message to glue record fields * Remove references to domain forwarding and simplify CSS * Fetch data when expanding card to prevent stale data from being shown --------- Co-authored-by: Leonardo Sameshima Taba --- client/components/domains/accordion/index.tsx | 2 + client/components/domains/accordion/types.ts | 1 + .../domain-glue-record-query-key.ts | 5 + .../use-delete-glue-record-mutation.ts | 54 +++ .../use-domain-glue-records-query.ts | 56 +++ .../use-update-glue-record-mutation.ts | 58 +++ .../settings/cards/glue-records-card.tsx | 336 ++++++++++++++++++ .../settings/cards/style.scss | 43 ++- .../domain-management/settings/index.tsx | 18 + config/client.json | 1 + config/development.json | 1 + config/horizon.json | 1 + config/production.json | 1 + config/stage.json | 1 + config/test.json | 1 + config/wpcalypso.json | 1 + 16 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 client/data/domains/glue-records/domain-glue-record-query-key.ts create mode 100644 client/data/domains/glue-records/use-delete-glue-record-mutation.ts create mode 100644 client/data/domains/glue-records/use-domain-glue-records-query.ts create mode 100644 client/data/domains/glue-records/use-update-glue-record-mutation.ts create mode 100644 client/my-sites/domains/domain-management/settings/cards/glue-records-card.tsx diff --git a/client/components/domains/accordion/index.tsx b/client/components/domains/accordion/index.tsx index e36712f08b14f..94da541595a22 100644 --- a/client/components/domains/accordion/index.tsx +++ b/client/components/domains/accordion/index.tsx @@ -13,6 +13,7 @@ const Accordion = ( { isDisabled, expanded = false, onClose, + onOpen, className, }: AccordionProps ) => { const classes = classNames( { @@ -49,6 +50,7 @@ const Accordion = ( { } onClose={ onClose } + onOpen={ onOpen } > { children } diff --git a/client/components/domains/accordion/types.ts b/client/components/domains/accordion/types.ts index 3b57668e7175b..136265cb50409 100644 --- a/client/components/domains/accordion/types.ts +++ b/client/components/domains/accordion/types.ts @@ -6,6 +6,7 @@ export type AccordionProps = { subtitle?: string | React.ReactNode; expanded?: boolean; onClose?: () => void; + onOpen?: () => void; isPlaceholder?: boolean; isDisabled?: boolean; diff --git a/client/data/domains/glue-records/domain-glue-record-query-key.ts b/client/data/domains/glue-records/domain-glue-record-query-key.ts new file mode 100644 index 0000000000000..d145ff78aabd5 --- /dev/null +++ b/client/data/domains/glue-records/domain-glue-record-query-key.ts @@ -0,0 +1,5 @@ +import { QueryKey } from '@tanstack/react-query'; + +export function domainGlueRecordQueryKey( domainName: string ): QueryKey { + return [ 'glue-record', domainName ]; +} diff --git a/client/data/domains/glue-records/use-delete-glue-record-mutation.ts b/client/data/domains/glue-records/use-delete-glue-record-mutation.ts new file mode 100644 index 0000000000000..2a4f6e16eb997 --- /dev/null +++ b/client/data/domains/glue-records/use-delete-glue-record-mutation.ts @@ -0,0 +1,54 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { domainGlueRecordQueryKey } from 'calypso/data/domains/glue-records/domain-glue-record-query-key'; +import { + GlueRecordObject, + GlueRecordQueryData, +} from 'calypso/data/domains/glue-records/use-domain-glue-records-query'; +import { DomainsApiError } from 'calypso/lib/domains/types'; +import wp from 'calypso/lib/wp'; + +export default function useDeleteGlueRecordMutation( + domainName: string, + queryOptions: { + onSuccess?: () => void; + onError?: ( error: DomainsApiError ) => void; + } +) { + const queryClient = useQueryClient(); + const mutation = useMutation( { + mutationFn: ( glueRecord: GlueRecordObject ) => + wp.req + .post( + { + path: `/domains/glue-records/${ domainName }`, + apiNamespace: 'wpcom/v2', + method: 'DELETE', + }, + { + name_server: glueRecord.record, + } + ) + .then( () => glueRecord ), + ...queryOptions, + onSuccess( glueRecord: GlueRecordObject ) { + const key = domainGlueRecordQueryKey( domainName ); + queryClient.setQueryData( key, ( old: GlueRecordQueryData ) => { + if ( ! old ) { + return []; + } + return old.filter( ( item ) => item.nameserver !== glueRecord.record ); + } ); + queryOptions.onSuccess?.(); + }, + } ); + + const { mutate } = mutation; + + const deleteGlueRecord = useCallback( + ( glueRecord: GlueRecordObject ) => mutate( glueRecord ), + [ mutate ] + ); + + return { deleteGlueRecord, ...mutation }; +} diff --git a/client/data/domains/glue-records/use-domain-glue-records-query.ts b/client/data/domains/glue-records/use-domain-glue-records-query.ts new file mode 100644 index 0000000000000..bfa0f762581b9 --- /dev/null +++ b/client/data/domains/glue-records/use-domain-glue-records-query.ts @@ -0,0 +1,56 @@ +import { UseQueryResult, useQuery } from '@tanstack/react-query'; +import wp from 'calypso/lib/wp'; +import { domainGlueRecordQueryKey } from './domain-glue-record-query-key'; + +export type Maybe< T > = T | null | undefined; +export type GlueRecordResponse = GlueRecordObject[] | null | undefined; + +export type GlueRecordObject = { + record: string; + address: string; +}; + +export type GlueRecordQueryData = Maybe< GlueRecordApiObject[] >; + +export type GlueRecordApiObject = { + nameserver: string; + ip_addresses: string[]; +}; + +export const mapGlueRecordObjectToApiObject = ( record: GlueRecordObject ): GlueRecordApiObject => { + return { + nameserver: record.record.toLowerCase(), + ip_addresses: [ record.address ], + }; +}; + +const selectGlueRecords = ( response: GlueRecordApiObject[] | null ): GlueRecordResponse => { + if ( ! response ) { + return null; + } + + return response?.map( ( record: GlueRecordApiObject ) => { + return { + record: record.nameserver.toLowerCase(), + address: record.ip_addresses[ 0 ], + }; + } ); +}; + +export default function useDomainGlueRecordsQuery( + domainName: string +): UseQueryResult< GlueRecordResponse > { + return useQuery( { + queryKey: domainGlueRecordQueryKey( domainName ), + queryFn: () => + wp.req.get( { + path: `/domains/glue-records/${ domainName }`, + apiNamespace: 'wpcom/v2', + } ), + refetchOnWindowFocus: false, + select: selectGlueRecords, + enabled: false, + staleTime: 5 * 60 * 1000, + cacheTime: 5 * 60 * 1000, + } ); +} diff --git a/client/data/domains/glue-records/use-update-glue-record-mutation.ts b/client/data/domains/glue-records/use-update-glue-record-mutation.ts new file mode 100644 index 0000000000000..db706232c2879 --- /dev/null +++ b/client/data/domains/glue-records/use-update-glue-record-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { domainGlueRecordQueryKey } from 'calypso/data/domains/glue-records/domain-glue-record-query-key'; +import { DomainsApiError } from 'calypso/lib/domains/types'; +import wp from 'calypso/lib/wp'; +import { + GlueRecordObject, + GlueRecordQueryData, + mapGlueRecordObjectToApiObject, +} from './use-domain-glue-records-query'; + +export default function useUpdateGlueRecordMutation( + domainName: string, + queryOptions: { + onSuccess?: () => void; + onError?: ( error: DomainsApiError ) => void; + } +) { + const queryClient = useQueryClient(); + const mutation = useMutation( { + mutationFn: ( glueRecord: GlueRecordObject ) => + wp.req + .post( + { + path: `/domains/glue-records`, + apiNamespace: 'wpcom/v2', + }, + { + name_server: glueRecord.record.toLowerCase(), + ip_addresses: [ glueRecord.address ], + } + ) + .then( () => glueRecord ), + ...queryOptions, + onSuccess( glueRecord: GlueRecordObject ) { + const key = domainGlueRecordQueryKey( domainName ); + queryClient.setQueryData( key, ( old: GlueRecordQueryData ) => { + if ( ! old ) { + return [ mapGlueRecordObjectToApiObject( glueRecord ) ]; + } + return [ ...old, mapGlueRecordObjectToApiObject( glueRecord ) ]; + } ); + queryOptions.onSuccess?.(); + }, + onError( error: DomainsApiError ) { + queryOptions.onError?.( error ); + }, + } ); + + const { mutate } = mutation; + + const updateGlueRecord = useCallback( + ( glueRecord: GlueRecordObject ) => mutate( glueRecord ), + [ mutate ] + ); + + return { updateGlueRecord, ...mutation }; +} diff --git a/client/my-sites/domains/domain-management/settings/cards/glue-records-card.tsx b/client/my-sites/domains/domain-management/settings/cards/glue-records-card.tsx new file mode 100644 index 0000000000000..5df349a45afce --- /dev/null +++ b/client/my-sites/domains/domain-management/settings/cards/glue-records-card.tsx @@ -0,0 +1,336 @@ +import { Button, FormInputValidation, Gridicon } from '@automattic/components'; +import { useTranslate } from 'i18n-calypso'; +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Accordion from 'calypso/components/domains/accordion'; +import FormButton from 'calypso/components/forms/form-button'; +import FormFieldset from 'calypso/components/forms/form-fieldset'; +import FormLabel from 'calypso/components/forms/form-label'; +import FormTextInputWithAffixes from 'calypso/components/forms/form-text-input-with-affixes'; +import useDeleteGlueRecordMutation from 'calypso/data/domains/glue-records/use-delete-glue-record-mutation'; +import useDomainGlueRecordsQuery, { + GlueRecordObject, +} from 'calypso/data/domains/glue-records/use-domain-glue-records-query'; +import useUpdateGlueRecordMutation from 'calypso/data/domains/glue-records/use-update-glue-record-mutation'; +import { errorNotice, successNotice } from 'calypso/state/notices/actions'; +import type { ResponseDomain } from 'calypso/lib/domains/types'; + +import './style.scss'; + +const noticeOptions = { + duration: 5000, + id: `glue-records-notification`, +}; + +export default function GlueRecordsCard( { domain }: { domain: ResponseDomain } ) { + const dispatch = useDispatch(); + const translate = useTranslate(); + + const [ isExpanded, setIsExpanded ] = useState( false ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ isRemoving, setIsRemoving ] = useState( false ); + const [ isEditing, setIsEditing ] = useState( false ); + const [ record, setRecord ] = useState( '' ); + const [ ipAddress, setIpAddress ] = useState( '' ); + const [ isValidRecord, setIsValidRecord ] = useState( true ); + const [ isValidIpAddress, setIsValidIpAddress ] = useState( true ); + + const { + data, + isFetching: isLoadingData, + isError, + isStale, + refetch: refetchGlueRecordsData, + } = useDomainGlueRecordsQuery( domain.name ); + + const clearState = () => { + setIsEditing( false ); + setRecord( '' ); + setIpAddress( '' ); + setIsSaving( false ); + setIsRemoving( false ); + setIsValidRecord( true ); + setIsValidIpAddress( true ); + }; + + // Display success notices when the glue record is updated + const { updateGlueRecord } = useUpdateGlueRecordMutation( domain.name, { + onSuccess() { + dispatch( successNotice( translate( 'Glue record updated and enabled.' ), noticeOptions ) ); + clearState(); + }, + onError( error ) { + dispatch( errorNotice( error.message, noticeOptions ) ); + clearState(); + }, + } ); + + // Display success notices when the glue record is deleted + const { deleteGlueRecord } = useDeleteGlueRecordMutation( domain.name, { + onSuccess() { + dispatch( successNotice( translate( 'Glue record deleted successfully.' ), noticeOptions ) ); + clearState(); + }, + onError() { + dispatch( + errorNotice( + translate( 'An error occurred while deleting the glue record.' ), + noticeOptions + ) + ); + clearState(); + }, + } ); + + // Render an error if the glue record fails to load + useEffect( () => { + if ( isError ) { + dispatch( + errorNotice( + translate( 'An error occurred while fetching your glue record.' ), + noticeOptions + ) + ); + } + }, [ isError, dispatch, translate ] ); + + const showGlueRecordForm = () => { + setIsEditing( true ); + }; + + useEffect( () => { + if ( isExpanded && isStale ) { + refetchGlueRecordsData(); + } + }, [ isExpanded, isStale, refetchGlueRecordsData ] ); + + useEffect( () => { + if ( isLoadingData || ! data ) { + return; + } + + // If there are no glue records, start with the form to add one open + if ( data?.length === 0 ) { + showGlueRecordForm(); + } + }, [ isLoadingData, data, isExpanded ] ); + + const handleIpAddressChange = ( event: React.ChangeEvent< HTMLInputElement > ) => { + const ipAddress = event.target.value; + + setIpAddress( ipAddress ); + }; + + const handleRecordChange = ( event: React.ChangeEvent< HTMLInputElement > ) => { + const record = event.target.value; + + setRecord( record.toLowerCase() ); + }; + + const handleDelete = ( record: GlueRecordObject ) => { + setIsRemoving( true ); + deleteGlueRecord( record ); + }; + + const validateRecord = () => { + if ( ! record ) { + return false; + } + if ( ! record.match( /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/ ) ) { + return false; + } + return true; + }; + + const validateIpAddress = () => { + if ( ! ipAddress ) { + return false; + } + if ( ! ipAddress.match( /^(\d{1,3}\.){3}\d{1,3}$/ ) ) { + return false; + } + return true; + }; + + const validateGlueRecord = () => { + const recordIsValid = validateRecord(); + const ipAddressIsValid = validateIpAddress(); + setIsValidRecord( recordIsValid ); + setIsValidIpAddress( ipAddressIsValid ); + + return recordIsValid && ipAddressIsValid; + }; + + const handleSubmit = () => { + if ( ! validateGlueRecord() ) { + return; + } + + setIsSaving( true ); + updateGlueRecord( { + record: `${ record }.${ domain.domain }`, + address: ipAddress, + } ); + }; + + const handleCancel = () => { + clearState(); + + if ( data && data.length === 0 ) { + setIsExpanded( false ); + } + }; + + const FormViewRow = ( { child: child }: { child: GlueRecordObject } ) => ( + +
+
+
+
{ translate( 'Name server' ) }:
+
+ { child.record } +
+
+ +
+
{ translate( 'IP address' ) }:
+
+ { child.address } +
+
+
+
+ +
+
+
+ ); + + const FormRowEditable = ( { child }: { child: GlueRecordObject } ) => ( + <> + + { translate( 'Name server' ) } +
+ .{ domain.domain } } + isError={ ! isValidRecord } + /> + { ! isValidRecord && ( +
+ +
+ ) } +
+ { translate( 'IP address' ) } +
+ + { ! isValidIpAddress && ( +
+ +
+ ) } +
+
+ + { translate( 'Save' ) } + + + { translate( 'Cancel' ) } + +
+
+ + ); + + const expandCard = () => { + setIsExpanded( true ); + // We want to always fetch the latest glue record when the card is expanded + // otherwise the user might see stale data if they made an update and refreshed the page + refetchGlueRecordsData(); + }; + + const renderGlueRecords = () => { + if ( isLoadingData || ! data ) { + return ( +
+
+
+ ); + } + + return ( +
+
{ + e.preventDefault(); + return false; + } } + > + { data?.map( ( item ) => FormViewRow( { child: item } ) ) } + { isEditing && + FormRowEditable( { + child: { + record: '', + address: '', + }, + } ) } +
+ + { ! isEditing && data && data.length < 3 && ( + + ) } +
+ ); + }; + + return ( + setIsExpanded( false ) } + > + { renderGlueRecords() } + + ); +} diff --git a/client/my-sites/domains/domain-management/settings/cards/style.scss b/client/my-sites/domains/domain-management/settings/cards/style.scss index 7f85cbc7923b8..03d199f744240 100644 --- a/client/my-sites/domains/domain-management/settings/cards/style.scss +++ b/client/my-sites/domains/domain-management/settings/cards/style.scss @@ -132,7 +132,8 @@ } } -.domain-forwarding-card__accordion { +.domain-forwarding-card__accordion, +.domain-glue-records-card__accordion { color: var(--studio-gray-50); margin-bottom: 0 !important; @@ -425,3 +426,43 @@ } } +.domain-glue-records-card { + .glue-record-data { + flex-grow: 1; + } + + .glue-records__action-buttons { + margin-top: 15px; + } + + .is-placeholder { + height: 100px; + @include placeholder(); + } +} + +.domain-glue-records-card__accordion { + .domain-glue-records-card__error-field { + height: 20px; + } + + .domain-glue-records-card__fields { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 1rem; + } + + .domain-glue-records-card__fields-row { + display: flex; + flex-direction: row; + + .label { + width: 25%; + } + + .value { + overflow-wrap: anywhere; + } + } +} diff --git a/client/my-sites/domains/domain-management/settings/index.tsx b/client/my-sites/domains/domain-management/settings/index.tsx index c8e66bc38ae24..dc654d6b96a27 100644 --- a/client/my-sites/domains/domain-management/settings/index.tsx +++ b/client/my-sites/domains/domain-management/settings/index.tsx @@ -1,3 +1,4 @@ +import config from '@automattic/calypso-config'; import page from '@automattic/calypso-router'; import { Button } from '@automattic/components'; import { englishLocales } from '@automattic/i18n-utils'; @@ -26,6 +27,7 @@ import DomainMainPlaceholder from 'calypso/my-sites/domains/domain-management/co import DomainHeader from 'calypso/my-sites/domains/domain-management/components/domain-header'; import { WPCOM_DEFAULT_NAMESERVERS_REGEX } from 'calypso/my-sites/domains/domain-management/name-servers/constants'; import withDomainNameservers from 'calypso/my-sites/domains/domain-management/name-servers/with-domain-nameservers'; +import GlueRecordsCard from 'calypso/my-sites/domains/domain-management/settings/cards/glue-records-card'; import { domainManagementEdit, domainManagementEditContactInfo, @@ -631,6 +633,21 @@ const Settings = ( { ); }; + const renderDomainGlueRecordsSection = () => { + // We can only create glue records for domains registered with us through KS_RAM + if ( + ! config.isEnabled( 'domains/glue-records' ) || + ! domain || + domain.type !== domainTypes.REGISTERED || + domain.registrar !== 'KS_RAM' || + ! domain.canManageDnsRecords + ) { + return null; + } + + return ; + }; + const renderMainContent = () => { // TODO: If it's a registered domain or transfer and the domain's registrar is in maintenance, show maintenance card if ( ! domain ) { @@ -657,6 +674,7 @@ const Settings = ( { { renderContactInformationSecion() } { renderContactVerificationSection() } { renderDomainSecuritySection() } + { renderDomainGlueRecordsSection() } ); }; diff --git a/config/client.json b/config/client.json index ac9fd41a69db9..94c3474ce87fd 100644 --- a/config/client.json +++ b/config/client.json @@ -3,6 +3,7 @@ "boom_analytics_key", "client_slug", "daily_post_blog_id", + "domains/glue-records", "env", "env_id", "facebook_api_key", diff --git a/config/development.json b/config/development.json index 68f765aa160fd..b33fb7e635ecd 100644 --- a/config/development.json +++ b/config/development.json @@ -61,6 +61,7 @@ "domains/kracken-ui/pagination": true, "domains/new-status-design": true, "domains/premium-domain-purchases": true, + "domains/glue-records": true, "email-accounts/enabled": true, "external-media": true, "external-media/free-photo-library": true, diff --git a/config/horizon.json b/config/horizon.json index 0a0cb3bf49d68..04fc0c9a25ca1 100644 --- a/config/horizon.json +++ b/config/horizon.json @@ -33,6 +33,7 @@ "domains/new-status-design": true, "domains/premium-domain-purchases": true, "domains/transfer-to-any-user": true, + "domains/glue-records": true, "external-media": true, "external-media/free-photo-library": true, "external-media/google-photos": true, diff --git a/config/production.json b/config/production.json index 06517bd1f9c9d..1482dab4e13df 100644 --- a/config/production.json +++ b/config/production.json @@ -41,6 +41,7 @@ "domains/kracken-ui/pagination": true, "domains/new-status-design": true, "domains/premium-domain-purchases": true, + "domains/glue-records": false, "external-media": true, "external-media/free-photo-library": true, "external-media/google-photos": true, diff --git a/config/stage.json b/config/stage.json index 876b4d8a82caf..c211d76a9f453 100644 --- a/config/stage.json +++ b/config/stage.json @@ -38,6 +38,7 @@ "domains/kracken-ui/exact-match-filter": true, "domains/kracken-ui/pagination": true, "domains/new-status-design": true, + "domains/glue-records": true, "domains/premium-domain-purchases": true, "external-media": true, "external-media/free-photo-library": true, diff --git a/config/test.json b/config/test.json index 625a4ba90a80e..41506a3497fe8 100644 --- a/config/test.json +++ b/config/test.json @@ -38,6 +38,7 @@ "devdocs": true, "devdocs/redirect-loggedout-homepage": true, "difm/allow-extra-pages": false, + "domains/glue-records": true, "domains/transfer-to-any-user": true, "cookie-banner": false, "google-my-business": false, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index a4f168e9fced1..333c7d4079762 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -40,6 +40,7 @@ "devdocs/color-scheme-picker": true, "devdocs/redirect-loggedout-homepage": true, "domains/gdpr-consent-page": true, + "domains/glue-records": true, "domains/kracken-ui/exact-match-filter": true, "domains/kracken-ui/pagination": true, "domains/new-status-design": true,