diff --git a/client/components/domains/accordion/index.tsx b/client/components/domains/accordion/index.tsx
index e36712f08b14f7..94da541595a224 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 3b57668e7175b9..136265cb504091 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 00000000000000..d145ff78aabd53
--- /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 00000000000000..2a4f6e16eb9979
--- /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 00000000000000..bfa0f762581b99
--- /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 00000000000000..db706232c28794
--- /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 00000000000000..5df349a45afced
--- /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 } ) => (
+