Fleet UI: Auto-dismiss success toasts after navigation (#48112)

This commit is contained in:
RachelElysia
2026-06-23 22:51:39 -04:00
committed by GitHub
parent ff105b31ef
commit e0fc1e0e7a
13 changed files with 28 additions and 33 deletions
+3 -3
View File
@@ -69,9 +69,9 @@ Use react-router, not `window.location` / `window.history`. Direct window mutati
- Internal `<CustomLink>` (and any `router.push` / `<Link>`) to a fleet-scoped route MUST preserve the current fleet via `getPathWithQueryParams(PATHS.X, { fleet_id: teamId })`. Linking to the bare path drops fleet context and lands the user on the wrong fleet. Applies to any path that reads `fleet_id` from the query string (most `/software`, `/hosts`, `/policies`, `/queries`, `/controls` routes). `getPathWithQueryParams` filters undefined/null, so pass `teamId` directly — `fleet_id=0` (No team) is a valid, intentional value and must be preserved.
## Notifications
- Use `renderFlash(alertType, message)` from `NotificationContext`
- Types: `"success"`, `"error"`, `"warning-filled"`
- Use `renderMultiFlash()` for batch operations
- Use `notify.success(msg)` / `notify.error(msg, { response })` / `notify.batch([...])` from `components/ToastNotification`.
- **When showing a success toast and navigating, call `notify.success` before `router.push` / `router.replace`** — the reverse order can break auto-dismiss on the destination page (#48088).
- Success toasts auto-dismiss after 5s by default; error toasts are sticky by default.
## XSS Prevention
- ALWAYS sanitize user-generated HTML before `dangerouslySetInnerHTML`. Approved helpers:
@@ -24,8 +24,8 @@ const EmailTokenRedirect = ({
if (currentUser && token) {
try {
await usersAPI.confirmEmailChange(currentUser, token);
router.push(PATHS.ACCOUNT);
notify.success("Email updated successfully.");
router.push(PATHS.ACCOUNT);
} catch (error) {
console.log(error);
router.push(PATHS.LOGIN);
@@ -239,10 +239,10 @@ const nextToastId = (): ToastId => {
export const notify: INotify = {
success: (message, options) => {
const id = options?.id ?? nextToastId();
// Defer one tick:
// Toast fired in the same handler as a router.push is then created AFTER the
// route change — and after the route-change dismiss — so it lands on the
// destination page whether the caller notifies before or after navigating.
// Defer one tick so the toast is created after the route-change
// dismiss above, landing it on the destination page. When a handler
// both navigates and shows a success toast, call notify.success
// before router.push — the reverse order can break auto-dismiss (#48088).
setTimeout(() => {
toast.custom(
(sonnerId) => (
+10 -15
View File
@@ -575,7 +575,6 @@ initialized. View currently working contexts in the [context directory](../conte
```typescript
// Consuming a context — destructure what you need from useContext
const { renderFlash } = useContext(NotificationContext);
const { currentUser, isPremiumTier } = useContext(AppContext);
```
@@ -584,7 +583,6 @@ const { currentUser, isPremiumTier } = useContext(AppContext);
| Context | Purpose | Use this when |
|---|---|---|
| `AppContext` | Global app state: current user, config, team selection, role flags, license info | You need user identity, permissions, feature flags, or the active fleet |
| `NotificationContext` | Flash message banners (`renderFlash`, `renderMultiFlash`, `hideFlash`) | You need to show success/error/warning notifications after an action |
| `PolicyContext` | In-progress policy editing state: name, query, resolution, platform, labels | You're on the policy edit/create flow and need to persist form state across steps |
| `QueryContext` | In-progress report editing state: name, query body, frequency, targets, logging | You're on the report edit/create flow and need to persist form state across steps |
| `RoutingContext` | Stores a redirect location for post-auth navigation | You need to redirect the user after login (e.g., deep link they hit while logged out) |
@@ -615,7 +613,7 @@ const PageOrComponent = (props) => {
// do something
} catch(error) {
console.error(error);
// maybe trigger renderFlash
// maybe trigger notify.error
}
};
@@ -695,7 +693,7 @@ try {
await softwareAPI.install()
// successful messgae
} catch (e) {
renderFlash("error", getErrorMessage(e))
notify.error(getErrorMessage(e))
}
/* in helpers.tsx */
@@ -1067,20 +1065,17 @@ then the [app's context](#react-context) should be used.
If you are dealing with a page that *updates* any kind of config, set the local
config with the response of your update call to make sure it has the latest.
### Rendering flash messages
### Toast notifications
Flash messages by default will be hidden when the user performs any navigation that changes the URL,
in addition to the timeout set for success messages. The `renderFlash` method from notification
context accepts an optional third `options` argument which contains an optional
`persistOnPageChange` boolean field that can be set to `true` to negate this default behavior.
Use `notify.success(msg)` / `notify.error(msg, { response })` / `notify.batch([...])` from
`components/ToastNotification`. Success toasts auto-dismiss after 5s by default; error toasts are sticky by default.
Visible toasts are dismissed automatically on URL change.
If the `renderFlash` is accompanied by a router push, it's important to push to the router *before*
calling `renderFlash`. If the push comes after the `renderFlash` call,
the flash message may register the `push` and immediately hide itself.
**When showing a success toast and navigating, call `notify.success` before `router.push` / `router.replace`** — the reverse order can break auto-dismiss on the destination page (#48088).
```tsx
// first push
// first notify
notify.error("Something went wrong");
// then push
router.push(newPath);
// then flash
renderFlash("error", "Something went wrong");
```
@@ -56,10 +56,10 @@ const ConfirmInvitePage = ({ router, params }: IConfirmInvitePageProps) => {
try {
await usersAPI.create(dataForAPI);
router.push(paths.LOGIN);
notify.success(
"Registration successful! For security purposes, please log in."
);
router.push(paths.LOGIN);
} catch (error) {
const reason = getErrorReason(error);
console.error(reason);
@@ -69,8 +69,8 @@ const AppleMdmPage = ({ router }: { router: InjectedRouter }) => {
try {
await mdmAppleAPI.deleteApplePushCertificate();
await queryClient.invalidateQueries(["config"]);
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
notify.success("MDM turned off successfully.");
router.push(PATHS.ADMIN_INTEGRATIONS_MDM);
} catch (e) {
notify.error("Couldn't turn off MDM. Please try again.", {
response: e,
@@ -300,8 +300,8 @@ const TeamDetailsWrapper = ({
try {
await teamsAPI.destroy(teamIdForApi);
notify.success(`Successfully deleted ${currentTeamName}.`);
router.push(PATHS.ADMIN_FLEETS);
notify.success("Fleet removed");
} catch (response) {
notify.error("Something went wrong removing the fleet", { response });
console.error(response);
@@ -309,7 +309,7 @@ const TeamDetailsWrapper = ({
toggleDeleteFleetModal();
setIsUpdatingTeams(false);
}
}, [teamIdForApi, router, toggleDeleteFleetModal]);
}, [teamIdForApi, currentTeamName, router, toggleDeleteFleetModal]);
const onEditSubmit = useCallback(
async (formData: ITeamFormData) => {
@@ -722,8 +722,8 @@ const HostDetailsPage = ({
setIsUpdating(true);
try {
await hostAPI.destroy(host);
router.push(PATHS.MANAGE_HOSTS);
notify.success(`Host "${host.display_name}" was successfully deleted.`);
router.push(PATHS.MANAGE_HOSTS);
} catch (error) {
console.log(error);
notify.error(`Host "${host.display_name}" could not be deleted.`, {
@@ -337,8 +337,8 @@ const NewLabelPage = ({
setIsUpdating(true);
try {
await labelsAPI.create(formData);
router.push(PATHS.MANAGE_LABELS);
notify.success("Label added successfully.");
router.push(PATHS.MANAGE_LABELS);
} catch (error) {
const status = (error as { status: number }).status;
let errorMessage = "Couldn't add label. Please try again.";
@@ -156,8 +156,8 @@ const EditPacksPage = ({
packsAPI
.update(packId, updatedPack)
.then(() => {
router.push(PATHS.MANAGE_PACKS);
notify.success(`Successfully updated this pack.`);
router.push(PATHS.MANAGE_PACKS);
})
.catch((e) => {
if (
@@ -49,8 +49,8 @@ const PackComposerPage = ({ router }: IPackComposerPageProps): JSX.Element => {
const {
pack: { id: packID },
} = await create(formData);
router.push(PATHS.PACK(packID));
notify.success("Pack successfully created. Add queries to your pack.");
router.push(PATHS.PACK(packID));
} catch (e) {
if (
getErrorReason(e, {
@@ -181,12 +181,12 @@ const QueryEditor = ({
);
}
}
notify.success("Policy created.");
router.push(
getPathWithQueryParams(PATHS.POLICY_DETAILS(policy.id), {
fleet_id: policy.team_id,
})
);
notify.success("Policy created.");
} catch (createError) {
if (getErrorReason(createError).includes("already exists")) {
setBackendValidators({
@@ -262,13 +262,13 @@ const EditQueryPage = ({
setIsQuerySaving(true);
try {
const { query } = await queryAPI.create(formData);
notify.success("Report created.");
router.push(
getPathWithQueryParams(PATHS.REPORT_DETAILS(query.id), {
fleet_id: query.team_id,
host_id: hostId,
})
);
notify.success("Report created.");
setBackendValidators({});
} catch (createError) {
if (getErrorReason(createError).includes("already exists")) {