Skip to content

Fleet UI: View, add, edit, delete custom categories - #46443

Merged
RachelElysia merged 8 commits into
feat/39018-self-service-categoriesfrom
39018-fe-crud-custom-categories
Jun 1, 2026
Merged

Fleet UI: View, add, edit, delete custom categories#46443
RachelElysia merged 8 commits into
feat/39018-self-service-categoriesfrom
39018-fe-crud-custom-categories

Conversation

@RachelElysia

@RachelElysia RachelElysia commented May 29, 2026

Copy link
Copy Markdown
Member

Issue

Part of #39018
Closes #46370

Description

Screenrecording

This screenrecording was done E2E tested with @jkatz01 's BE work

Screen.Recording.2026-06-01.at.1.49.07.PM.mov

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Adds the Software > Library > Self-service categories page (Premium) with
add / edit / delete flows, wires a Categories button into the existing
Library page as the entry point, and adds tests covering the empty,
populated, conflict, cancel, and observer states.

BE is not implemented yet — service client wraps a dev-only mock store
behind NODE_ENV === "development" so the UI is usable in `make serve`.
@codecov

codecov Bot commented May 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.47917% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 66.95%. Comparing base (c9ae421) to head (61196e4).
⚠️ Report is 58 commits behind head on feat/39018-self-service-categories.

Files with missing lines Patch % Lines
...erviceCategoriesPage/SelfServiceCategoriesPage.tsx 98.66% 1 Missing ⚠️
Additional details and impacted files
@@                          Coverage Diff                           @@
##           feat/39018-self-service-categories   #46443      +/-   ##
======================================================================
+ Coverage                               66.88%   66.95%   +0.07%     
======================================================================
  Files                                    2785     2800      +15     
  Lines                                  222093   222404     +311     
  Branches                                11416    11527     +111     
======================================================================
+ Hits                                   148538   148910     +372     
+ Misses                                  60123    60061      -62     
- Partials                                13432    13433       +1     
Flag Coverage Δ
frontend 56.89% <99.47%> (+0.63%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@RachelElysia
RachelElysia changed the base branch from main to feat/39018-self-service-categories May 29, 2026 17:29
BE for #39018 is wiring up the real routes, so drop the dev-only
in-memory mock store and call the actual endpoints directly.
@RachelElysia
RachelElysia marked this pull request as ready for review June 1, 2026 17:51
@RachelElysia
RachelElysia requested a review from a team as a code owner June 1, 2026 17:51
@fleet-release
fleet-release requested a review from eashaw June 1, 2026 17:51

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@RachelElysia

Copy link
Copy Markdown
Member Author

@claude review

- Reveal row actions on :focus-within for keyboard users
- Derive isTeamAdmin/isTeamMaintainer from useTeamIdParam so the
  page reflects the route's fleet immediately, and key the React
  Query cache on teamIdForApi
- Disable Save in Edit modal when the name is unchanged
- Disable Cancel and lock modal content while submit/delete is
  in flight
- Use straight apostrophes in error copy + matching test assertions
- Add generic-error MSW handlers and tests for add/edit/delete
Comment on lines +62 to +67
<Modal
title="Add category"
onExit={onExit}
className={baseClass}
isContentDisabled={isSubmitting}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Follow-up to the prior review: the isContentDisabled={isSubmitting}/{isDeleting} fix applied to all three modals doesn't actually gate the X icon, Escape key, or backdrop click — those paths are only gated by disableClosingModal in Modal.tsx (lines 80, 137, 185), and Modal.tsx:32-37 JSDoc explicitly says "The top right will not be disabled and will still be clickable." Worse, Modal.tsx:191 sets autofocus={isContentDisabled}, so during an in-flight request the X button actively receives focus. If the user hits Escape / clicks X / clicks the backdrop mid-submit, the modal unmounts but the closure-captured onSuccess still fires when the request resolves — calling renderFlash("success", ...) on the parent (most jarring on Delete: "Successfully deleted self-service category." after a cancel, and the row really is gone). One-line fix per modal: use disableClosingModal={isSubmitting} (or disableClosingModal={isDeleting}) in addition to (or in place of) isContentDisabled on each of AddCategoryModal, EditCategoryModal, and DeleteCategoryModal.

Extended reasoning...

What the bug is

This is a follow-up to the previous review comment about the cancel-mid-submit race. The author applied the suggested fix on all three modals — isContentDisabled={isSubmitting} on AddCategoryModal (AddCategoryModal.tsx:66) and EditCategoryModal (EditCategoryModal.tsx:67), isContentDisabled={isDeleting} on DeleteCategoryModal (DeleteCategoryModal.tsx:43), plus disabled={isSubmitting}/disabled={isDeleting} on the Cancel buttons. That gates Cancel correctly. But isContentDisabled is the wrong prop for the X icon, Escape key, and backdrop click — those are governed exclusively by disableClosingModal. The original review suggested isContentDisabled as a complete fix, and that suggestion was incomplete.

The code path

Verified in frontend/components/Modal/Modal.tsx:

  1. Modal.tsx:32-37 JSDoc on isContentDisabled: "At the moment this will place an overlay over the modal content and make it unclickable. The top right will not be disabled and will still be clickable."
  2. Modal.tsx:80-89: the Escape-key useEffect only attaches/detaches based on !disableClosingModal. isContentDisabled is not consulted.
  3. Modal.tsx:135-144: handleBackgroundMouseUp (backdrop click) gates on !disableClosingModal (plus isFormDirtyRef). For DeleteCategoryModal there is no form input so isFormDirtyRef stays false and the backdrop closes freely during the DELETE request.
  4. Modal.tsx:185-196: the X-icon <Button> is rendered only when !disableClosingModalisContentDisabled doesn't hide or disable it.
  5. Modal.tsx:191: autofocus={isContentDisabled} literally moves focus to the close X when isContentDisabled is true. So during the request, the active element is the very button that's supposed to be blocked.
  6. Modal.tsx:198-202: the __disabled-overlay div sits inside the content-wrapper, beneath the header — it covers the form area, not the X icon.

The race

For AddCategoryModal (AddCategoryModal.tsx:38-48), EditCategoryModal (analogous), and DeleteCategoryModal (DeleteCategoryModal.tsx:25-35):

  1. User clicks Add/Save/Delete → setIsSubmitting(true) / setIsDeleting(true), await API call begins.
  2. While in flight, user hits Escape / clicks X / clicks backdrop. Per Modal.tsx the close paths are not gated.
  3. onExit fires → parent setShowAddModal(false) / setCategoryToEdit(null) / setCategoryToDelete(null) → modal unmounts.
  4. The pending Promise still holds the closure including the onSuccess prop. React unmounting the child does NOT abort the fetch (no AbortController).
  5. API resolves successfully → onSuccess() fires → parent's onAddSuccess/onEditSuccess/onDeleteSuccess in SelfServiceCategoriesPage.tsx:97-113 runs invalidateList() and renderFlash("success", "Successfully added/updated/deleted self-service category.").

Step-by-step proof (Delete flow — the most user-confusing case)

  • User opens DeleteCategoryModal for category id 1 named "🛟 Support" and clicks Delete.
  • onDelete (DeleteCategoryModal.tsx:25-35): setIsDeleting(true), await selfServiceCategoriesAPI.deleteCategory(1) starts.
  • Per Modal.tsx:191 focus moves to the X button. User reflexively hits Enter → handleClose runs (Modal.tsx:64-71) → onExit fires after the 100ms close animation.
  • Parent setCategoryToDelete(null) → modal unmounts.
  • DELETE resolves with 204. Async resumes inside the unmounted component's closure, calls onSuccess() → still pointing at the parent's onDeleteSuccess (SelfServiceCategoriesPage.tsx:109-113).
  • Parent runs invalidateList(), setCategoryToDelete(null) (no-op), and renderFlash("success", "Successfully deleted self-service category.").
  • User just "canceled" a destructive action, sees a success toast saying it was deleted, and the row disappears from the list.

The Escape and backdrop variants reproduce identically because Modal.tsx:80 and Modal.tsx:137 both only consult disableClosingModal. The backdrop variant is especially easy to hit on DeleteCategoryModal because there is no form input so isFormDirtyRef.current stays false, removing the only soft guard.

Impact

Nit — the data state on the server is consistent (the operation really did succeed), the window is one network round-trip, no data loss, no security issue. But the user-visible outcome contradicts the user's intent and contradicts the explicit fix that was just applied. The destructive Delete case is the most jarring.

Fix

One line per modal — switch the prop from isContentDisabled to disableClosingModal (or set both):

// AddCategoryModal.tsx:62-67, EditCategoryModal.tsx:63-68
<Modal
  title="Add category"   // or "Edit category"
  onExit={onExit}
  className={baseClass}
  disableClosingModal={isSubmitting}
>

// DeleteCategoryModal.tsx:39-44
<Modal
  title="Delete category"
  onExit={onExit}
  className={baseClass}
  disableClosingModal={isDeleting}
>

disableClosingModal is the prop that actually gates the X icon, Escape key, and backdrop click (Modal.tsx:80, 137, 185). If you want the dimmed-content visual treatment AND the gated close paths, pass both. (An AbortController on the API call would also work as a no-op-on-resolve mitigation, but a one-character prop change is the lighter fix and matches the same convention already in use elsewhere in this app.)

@RachelElysia RachelElysia Jun 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is not a pattern in our API calling modals so it will not be addressed here.

@RachelElysia
RachelElysia merged commit 9680221 into feat/39018-self-service-categories Jun 1, 2026
18 checks passed
@RachelElysia
RachelElysia deleted the 39018-fe-crud-custom-categories branch June 1, 2026 19:46
@RachelElysia

Copy link
Copy Markdown
Member Author

@eashaw - as long as I get your approval before the feat is ready to merge 🙏 thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Frontend: Custom self-service software categories - Fleet UI > Software > Library > Self-service categories page

3 participants