Fleet UI: Auto-dismiss success toasts after navigation (#48112)
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
+1
-1
@@ -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")) {
|
||||
|
||||
Reference in New Issue
Block a user