Skip to content

#4422: dnsrecord edit form save [dg]#4746

Merged
DaisyGuti merged 14 commits intomainfrom
dg/4422-dnsrecord-edit-save
Mar 20, 2026
Merged

#4422: dnsrecord edit form save [dg]#4746
DaisyGuti merged 14 commits intomainfrom
dg/4422-dnsrecord-edit-save

Conversation

@DaisyGuti
Copy link
Contributor

@DaisyGuti DaisyGuti commented Mar 3, 2026

Ticket

Resolves #4422

Changes

  • Added edit (update) flow to DomainDNSRecordsView.post and refactored for flake8
  • Started the refactored post into methods on DNSRecord and DNSHostService
  • added db logic need functions to the DnsHostService
  • Added RequestError on domain view to capture CF API request failures, In _handle_edit(), a RequestError (timeout, connection reset, etc.) is now caught, logged, and re-raised as an APIError so the outer post() handler picks it up
  • fixed the cancel btn on the edit form

Setup

Please Note: DNS waffle flag must be on AND domain being work with must be enrolled in DNS hosting

  1. You will need to either add or have DNS records to edit so to do this you can click on manage from the admin for example or you can use this one ready for edit: https://getgov-dg.app.cloud.gov/domain/569/dns/records
  2. Edit the record.
  3. Click on save.
  4. The form should close and you will see your edits saved. You can test this by going to a different part of the site and coming back to the record for example.

Code Review Verification Steps

As the original developer, I have

Satisfied acceptance criteria and met development standards

  • Met the acceptance criteria, or will meet them in a subsequent PR
  • Created/modified automated tests
  • Update documentation in READMEs and/or onboarding guide

Ensured code standards are met (Original Developer)

  • If any updated dependencies on Pipfile, also update dependencies in requirements.txt.
  • Interactions with external systems are wrapped in try/except
  • Error handling exists for unusual or missing values

Validated user-facing changes (if applicable)

  • Follow the process for requesting a design review. If code is not user-facing, delete design reviewer checklist
  • Verify new pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
  • Checked keyboard navigability
  • Reviewed accessibility checklist using screen reader (such as NVDA with Chrome or Voiceover with Safari), ANDI, or WAVE:
    • Tested general usability
    • Page header structure
    • Landmarks
    • Links and buttons
  • Checked for errors or warnings with an a11y browser tool (such as ANDI or WAVE)

As a code reviewer, I have

Reviewed, tested, and left feedback about the changes

  • Pulled this branch locally and tested it
  • Verified code meets all checks above. Address any checks that are not satisfied
  • Reviewed this code and left comments. Indicate if comments must be addressed before code is merged
  • Checked that all code is adequately covered by tests
  • Verify migrations are valid and do not conflict with existing migrations

Validated user-facing changes as a developer

Note: Multiple code reviewers can share the checklists above, a second reviewer should not make a duplicate checklist. All checks should be checked before approving, even those labeled N/A.

  • New pages have been added to .pa11yci file so that they will be tested with our automated accessibility testing
  • Meets all designs and user flows provided by design/product
  • Checked keyboard navigability
  • Reviewed accessibility checklist using screen reader (such as NVDA with Chrome or Voiceover with Safari), ANDI, or WAVE:
    • Tested general usability
    • Page header structure
    • Landmarks
    • Links and buttons
  • Checked for errors or warnings with an a11y browser tool (such as ANDI or WAVE)
  • (Rarely needed) Tested as both an analyst and applicant user

As a designer reviewer, I have

Verified that the changes match the design intention

  • Checked that the design translated visually
  • Checked behavior. Comment any found issues or broken flows.
  • Checked different states (empty, one, some, error)
  • Checked for landmarks, page heading structure, and links

Validated user-facing changes as a designer

  • Checked keyboard navigability
  • Reviewed accessibility checklist using screen reader (such as NVDA with Chrome or Voiceover with Safari), ANDI, or WAVE:
    • Tested general usability
    • Page header structure
    • Landmarks
    • Links and buttons
  • Checked for errors or warnings with an a11y browser tool (such as ANDI or WAVE)
  • Tested with multiple browsers (check off which ones were used)
    • Chrome
    • Microsoft Edge
    • FireFox
    • Safari
  • (Rarely needed) Tested as both an analyst and applicant user

References

Screenshots

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When moving the swap to the outer div, it changed how Alpine tracked the dropdown state across htmx swaps. x-on:change handler was now on the form content which gets swapped by htmx - meaning after a swap the handler was on a fresh element that Alpine needed to reinitialize. The x-init was added to ensure Alpine resynced recordType after that reinitialization.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the edit path:

  1. edit submissions return just the updated display row rather than injecting a new row into the table
  2. Replaced counter == 1 first-record with an explicit is_first_record boolean from the view

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this was reset back to counter

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ANDI FIX: Edit branch now OOB-swaps the data (via update_cells) instead of the whole row, the edit form row is always OOB-swapped (for errors or reset)

@DaisyGuti DaisyGuti changed the title #4422: dnsrecord edit form save #4422: dnsrecord edit form save [dg] Mar 3, 2026
Copy link
Contributor

@kimallen kimallen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I worked through the server side code. I still need to look at the template/htmx work and test in the app, but I wanted to publish early comments.
I'm noticing a couple places where there's aberration from existing patterns (naming, where we put db query methods), so would be interested in knowing if there's strong reasons for changing those patterns.

logger.error(f"Error {e.response.status_code} while creating dns record: {e}")
raise
error_body = e.response.text
logger.error(f"Error {e.response.status_code} while creating dns record: {e}\nResponse body: {error_body}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice- thanks for expanding what we log. This will help with error handling.

raise
logger.info("Successfully enrolled %s in DNS hosting", domain_name)

def get_dns_record(self, domain, record_pk: int) -> DnsRecord | None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is purely a lookup, I wonder if this would better fit to fall under the DnsRecord Model? Is there a reason to put it here instead of the model? (same question for all of these methods)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes that would fit better. I'll move them over.

"""Find an item by name in a list of dictionaries."""
return next((item.get("id") for item in items if item.get("name") == name), None)

def _parse_dns_record_id(self, request) -> int | None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you say more about why this is necessary, and why we can't just get the id directly from the request.POST?

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id is only present in the POST for edit submissions

@github-project-automation github-project-automation bot moved this from 🎯 Ready to 👀 In review in .gov Product Board Mar 3, 2026
@erinysong erinysong self-assigned this Mar 3, 2026
<tr x-show="showFormId == {{ counter }}" class="hide-td-borders">

{# Inline edit form row for a DNS record. `counter` is pk-based. #}
<tr id="dnsrecord-edit-row-{{ counter }}" x-show="showFormId == {{ counter }}" class="hide-td-borders" {% if oob_swap %}hx-swap-oob="outerHTML"{% endif %}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

syntax/naming: The hide-td-borders class is from legacy code, but I just noticed we call this hide-td-borders but aren't we technically hiding the row's overall border? Wondering how we ended up with that since we're using the class on the tr itself too 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure how to answer this. This was already part of the form just kept it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LMK if this is something that we now want to remove.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@erinysong are you suggesting this should be named hide-tr-borders instead of hide-td-borders?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was asking mostly to ask if anyone else found the class name misleading/confusing since my first thought would've been that we're using this class to hide the border for the entire and not all the individual cells

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am still unclear on the resolution for this. If I missed it please re-iterate.

As this was pre-existing, if we want to change it etc. we might best address that during a clean up of the styling or a final pass of the work (another ticket) vs this PR, which is scoped for the saving of the existing edit form.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great conversation and question to bring up!

<tr x-show="showFormId == {{ counter }}" class="hide-td-borders">

{# Inline edit form row for a DNS record. `counter` is pk-based. #}
<tr id="dnsrecord-edit-row-{{ counter }}" x-show="showFormId == {{ counter }}" class="hide-td-borders" {% if oob_swap %}hx-swap-oob="outerHTML"{% endif %}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're identifying the rows by the record pk, why don't we just use the record.pk directly? Since counter implies that we're incrementally counting the rows themselves

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on that note, we used to use counter to identify our rows because initially we decided to label our rows by the order in which they appeared on the table to give allow the user to easily identify which row on the table they were on. I noticed in this PR we're removing that counter and the counter value is now the record pk. Can I hear more about why we're switching that value here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't realize the counter had any use other than identifying really if it was the first row. Having the idea of the row be the ID is a pretty standard pattern for tables like this, especially since we're not allowing users to move their rows or anything like that so we don't need to keep track of order. Is there a requirement that we need to keep the order? If so we if so, we should probably add that to the model. I don't think I've seen it in there yet. I'll have to look again...

Copy link
Contributor

@erinysong erinysong Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm yeah I've seen both incremental counters and the id of the data object be used. I think the incremental counters was an existing pattern from product decision to go incremental on form int identifiers for multiple portfolios because we didn't want to expose organization object id's. I'm not sure if we have that same level of concern for records so I'd defer to product if that's the case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OH I didn't realize it was a over-arching decision on the project. Yeah I can change it back to do a counter thing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think back then that decision was made specifically because we wanted to avoid exposing organization id's in our DOM, but I'm not sure if records would have that same scrutiny. Let's check in with product on Slack to see if that's the case! I can start a thread

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per latest, updating to remove the counter and go back to the id.

<tr x-show="showFormId == {{ counter }}" class="hide-td-borders">

{# Inline edit form row for a DNS record. `counter` is pk-based. #}
<tr id="dnsrecord-edit-row-{{ counter }}" x-show="showFormId == {{ counter }}" class="hide-td-borders" {% if oob_swap %}hx-swap-oob="outerHTML"{% endif %}>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also overall table design question from looking through dev tools: Can I hear more about why we load a hidden for each DNS row's edit form for each DNS record row specifically? My intuition would've been we only append/remove the row when we click the edit/cancel button for that one record row. So I'd be interested in hearing why we're loading hidden edit forms for each row 😮

Image

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we only will ever have a couple of rows doing it this way the x-show avoids that extra round trip (network calls etc.) and the form with all its pre-filled field values is already in the DOM ready to display when the user clicks Edit.

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we had hundreds of records or large records that we wouldn't do this and I'd use something like the htmx-fetched form on demand. But we're such a small record set, it is definitely a value add, and so we can take advantage of that and have the ability to lessen the load on the application and network.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it and makes sense given we likely won't have too many records here. Thank you for the rundown!

Copy link
Contributor

@kimallen kimallen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Observations when testing live (these are more around the form behavior and validations, so could potentially go in a different ticket):

  • The validation alerts only show up at the top of the page, not within the edit form. Is that the desired behavior?
  • Once opening the Edit form, I expected tabbing to enter the form. However, the next tab order was the "other options" and then it goes to the form fields. It seemed a bit confusing (especially with SR), and wondering if we want focus to go from the Edit button to the form directly (question for design?).
  • When I edit record and make a required field empty, no alert shows up at the top of the page, and an html alert shows up instead.
Image

We also seemed to have lost the fix that labeled the ids for each field uniquely per edit form. We now have the a11y error again where the labels can't find which field they are for. (so screen reader is not reading the label when focusing on an input)

to display corresponding values in the table rows"""
for record in dns_records:
data_dict = self.record_dict_for_initial_data(record)
record.form = DomainDNSRecordForm(initial=data_dict, prefix=f"edit_{record.id}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this line to correct issues in WAVE/ANDI with duplicate ids. Was this accidentally changed?

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not accidentally. But I'll see about adding it back in / or something like it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update to not use the interal ids and use the counter

@kimallen kimallen dismissed their stale review March 4, 2026 23:45

I'll be OOO the next couple days and don't want to block this if changes get made. Abe is also on point to review, so he can check on follow up of my comments. It will still need a design review, so that can serve as the second reviewer.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed ttl field from ChoiceField to TypedChoiceField(coerce=int) so the cleaned value is an integer, preventing a TypeError when the model's clean() method compares self.ttl < 60.


if errors:
raise ValidationError(errors)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored view post calls into here and dhshostservice where appropriate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored view post calls into here and dhshostservice where appropriate.

@DaisyGuti DaisyGuti added the design-review dev ticket needing design review label Mar 8, 2026
@DaisyGuti
Copy link
Contributor Author

Hello team, I believe I've address all your comments except one that is open/unresolved by @Erin Song regarding the class="hide-td-borders". Reset all back to counters and I reused that to uniquely id the inputs, thus leaving internal ids un-exposed per the other Slack thread and comments.

I went ahead and fixed the pickling errors as well on this PR bc it kept failing and there were real fixes needed especially in our area so those are in here too now.

FYSA: I will OOO/traveling this week Mon AM -Fri AM. But I'll be on AM of Tuesday and Thursday. Thank you again for the revs.

Cc: @Kim Allen [CTR] @Abraham Alam [CTR] @Erin Song (oh also I did send this over to design as well for this next rev round fysa)


logger = logging.getLogger(__name__)

context_dns_record = ContextVar("context_dns_record", default=None)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleanup


return is_first_record, record_id

def post(self, request, *args, **kwargs):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separated out the create, edit and form check into functions called from the post to fix flake 8 as the post was too complex.

"update_cells": is_edit and self.dns_record is not None,
},
headers={"HX-TRIGGER": hx_trigger_events},
headers={"HX-Trigger-After-Settle": json.dumps({"messagesRefresh": "", "recordSubmitSuccess": ""})},
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for htmx swaps and transitions to be complete, before messagesRefresh and recordSubmitSuccess fire.

@DaisyGuti DaisyGuti force-pushed the dg/4422-dnsrecord-edit-save branch from c367e61 to f8cabaa Compare March 19, 2026 08:22
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • revert failure_cases to expect HTTPStatusError for methods that re-raise directly
  • add dns_record_failure_cases with APIError for create/update dns record methods that wrap the error
  • set mock_response.text to a real string so APIError message assertions pass

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expect APIError (not HTTPStatusError) for DNS record error cases since CloudflareService wraps HTTP errors in APIError for those methods

Copy link
Contributor Author

@DaisyGuti DaisyGuti Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • updated mock create_dns_record (the method the view actuallycalls)
  • fix get_x_zone_id_if_zone_exists to return a list for nameservers instead of True, when nameservers = True (boolean), Django's template [domain_dns_nameservers_table.html] tries to iterates over True, so this would raise a TypeError during template rendering.

@DaisyGuti DaisyGuti requested review from erinysong and kimallen March 19, 2026 09:31
@DaisyGuti DaisyGuti force-pushed the dg/4422-dnsrecord-edit-save branch from 8320d9c to bd742fa Compare March 19, 2026 16:06
- Otherwise attach a blank/initial form from the DB model.
- Uses the record id to generate unique field IDs across multiple forms on the page.
"""
auto_id = f"id_edit_{record_obj.pk}_%s"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does %s do in this string?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string substitution - replaces the %s with the name of the form field

Copy link
Contributor

@kimallen kimallen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks so much for all your work and patience on this @DaisyGuti !
LGTM!

Copy link
Contributor

@erinysong erinysong left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work Daisy :D Thank you for working through all the changes and discussions we had around this PR!

@DaisyGuti DaisyGuti merged commit 317a523 into main Mar 20, 2026
12 checks passed
@DaisyGuti DaisyGuti deleted the dg/4422-dnsrecord-edit-save branch March 20, 2026 15:55
@github-project-automation github-project-automation bot moved this from 👀 In review to ✅ Done in .gov Product Board Mar 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

design-review dev ticket needing design review

Projects

Status: ✅ Done

Development

Successfully merging this pull request may close these issues.

Clicking "Save" updates a dns record

5 participants