fleet/frontend/pages/queries/edit/components/SaveAsNewQueryModal/SaveAsNewQueryModal.tsx
Nico 2e70ad2955
Surface queries in host details (#37646)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #27322 


[Figma](https://www.figma.com/design/v7WjL5zQuFIZerWYaSwy8o/-27322-Surface-custom-host-vitals?node-id=5636-4950&t=LuE3Kp09a5sj24Tt-0)

## Testing

- [x] Added/updated automated tests
- [ ] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually (WIP)

## Screenshots

### Host details

<img width="1481" height="1000" alt="Screenshot 2025-12-26 at 2 14
48 PM"
src="https://github.com/user-attachments/assets/3d9f02f9-f3a7-4a06-b3e4-414bb7b56e25"
/>

- `Queries` tab removed.
- Shows `Queries` card.

#### Queries Card

- Added client-side pagination.
- Added `Add query` button (screenshots below are with `Admin` role).

<img width="710" height="395" alt="Screenshot 2025-12-26 at 2 15 07 PM"
src="https://github.com/user-attachments/assets/b4e58269-d1b2-4c87-abfa-2cdfe47b533e"
/>

<img width="723" height="301" alt="Screenshot 2025-12-26 at 2 15 00 PM"
src="https://github.com/user-attachments/assets/2615d5bf-5d75-4e83-bc69-bc884232bf32"
/>

- As an `Observer`, `Add query` is not displayed

<img width="2240" height="1077" alt="Screenshot 2025-12-26 at 2 27
25 PM"
src="https://github.com/user-attachments/assets/426de709-d2ce-4bef-96f1-919ad5bddb13"
/>

- As a `Maintainer`, `Add query` is displayed

<img width="2236" height="1084" alt="Screenshot 2025-12-26 at 2 31
16 PM"
src="https://github.com/user-attachments/assets/218b0d18-2536-4336-88c8-41e7d09a5e9e"
/>



### New query page

If the user navigates from `Host details`, `host_id` search parameter is
added to the URL and the back button displays `Back to host details`.

<img width="1097" height="506" alt="Screenshot 2025-12-26 at 2 15 32 PM"
src="https://github.com/user-attachments/assets/61777c85-22f5-49dc-a3e6-dcd706119c70"
/>

### Host Queries (/hosts/:hostId/queries/:queryId)

`Performance impact` added above the table.

<img width="2029" height="626" alt="Screenshot 2025-12-26 at 2 16 00 PM"
src="https://github.com/user-attachments/assets/05c6b1bc-0587-4b0a-8167-142787592c6d"
/>
<img width="1555" height="482" alt="Screenshot 2025-12-26 at 2 16 05 PM"
src="https://github.com/user-attachments/assets/b9035b63-51c3-46c0-a903-c16d54c22986"
/>
2026-01-02 10:06:12 -03:00

233 lines
6.3 KiB
TypeScript

import React, { useCallback, useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { Location } from "history";
import { AppContext } from "context/app";
import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import { ICreateQueryRequestBody } from "interfaces/schedulable_query";
import queryAPI from "services/entities/queries";
import { NotificationContext } from "context/notification";
import { getErrorReason } from "interfaces/errors";
import {
INVALID_PLATFORMS_FLASH_MESSAGE,
INVALID_PLATFORMS_REASON,
} from "utilities/constants";
import {
API_ALL_TEAMS_ID,
APP_CONTEXT_ALL_TEAMS_ID,
ITeamSummary,
} from "interfaces/team";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import TeamsDropdown from "components/TeamsDropdown";
import { useTeamIdParam } from "hooks/useTeamIdParam";
const baseClass = "save-as-new-query-modal";
interface ISaveAsNewQueryModal {
router: InjectedRouter;
location: Location;
initialQueryData: ICreateQueryRequestBody;
hostId?: number;
onExit: () => void;
}
interface ISANQFormData {
queryName: string;
team: Partial<ITeamSummary>;
}
interface ISANQFormErrors {
queryName?: string;
team?: string;
}
const validateFormData = (formData: ISANQFormData): ISANQFormErrors => {
const errors: ISANQFormErrors = {};
if (!formData.queryName || formData.queryName.trim() === "") {
errors.queryName = "Name must be present";
}
return errors;
};
const SaveAsNewQueryModal = ({
router,
location,
initialQueryData,
hostId,
onExit,
}: ISaveAsNewQueryModal) => {
const { renderFlash } = useContext(NotificationContext);
const { isPremiumTier } = useContext(AppContext);
const [formData, setFormData] = useState<ISANQFormData>({
queryName: `Copy of ${initialQueryData.name}`,
team: {
id: initialQueryData.team_id,
name: undefined,
},
});
const [isSaving, setIsSaving] = useState(false);
const [formErrors, setFormErrors] = useState<ISANQFormErrors>({});
const { userTeams } = useTeamIdParam({
router,
location,
includeAllTeams: true,
includeNoTeam: false,
permittedAccessByTeamRole: {
admin: true,
maintainer: true,
observer: false,
observer_plus: false,
},
});
const onInputChange = useCallback(
({
name,
value,
}: {
name: string;
value: string | Partial<ITeamSummary>;
}) => {
const newFormData = { ...formData, [name]: value };
setFormData(newFormData);
const newErrors = validateFormData(newFormData);
const errsToSet: ISANQFormErrors = {};
Object.keys(formErrors).forEach((k) => {
if (k in newErrors) {
errsToSet[k as keyof ISANQFormErrors] =
newErrors[k as keyof ISANQFormErrors];
}
});
setFormErrors(errsToSet);
},
[formData, formErrors]
);
const onInputBlur = () => {
setFormErrors(validateFormData(formData));
};
const onTeamChange = useCallback(
(teamId: number) => {
const selectedTeam = userTeams?.find((team) => team.id === teamId);
setFormData((prevData) => ({
...prevData,
team: {
id: teamId,
name: selectedTeam ? selectedTeam.name : undefined,
},
}));
},
[userTeams]
);
// take all existing data for query from parent, allow editing name and team
const handleSave = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();
const errors = validateFormData(formData);
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
setIsSaving(true);
const {
queryName,
team: { id: teamId, name: teamName },
} = formData;
const createBody = {
...initialQueryData,
name: queryName,
team_id: teamId === APP_CONTEXT_ALL_TEAMS_ID ? API_ALL_TEAMS_ID : teamId,
};
try {
const { query: newQuery } = await queryAPI.create(createBody);
setIsSaving(false);
renderFlash("success", `Successfully added query ${newQuery.name}.`);
router.push(
getPathWithQueryParams(PATHS.QUERY_DETAILS(newQuery.id), {
team_id: newQuery.team_id,
host_id: hostId,
})
);
} catch (createError: unknown) {
let errFlash = "Could not create query. Please try again.";
const reason = getErrorReason(createError);
if (reason.includes("already exists")) {
let teamText;
if (teamId !== APP_CONTEXT_ALL_TEAMS_ID) {
teamText = teamName ? `the ${teamName} team` : "this team";
} else {
teamText = "all teams";
}
errFlash = `A query called "${queryName}" already exists for ${teamText}.`;
} else if (reason.includes(INVALID_PLATFORMS_REASON)) {
errFlash = INVALID_PLATFORMS_FLASH_MESSAGE;
}
setIsSaving(false);
renderFlash("error", errFlash);
}
};
return (
<Modal title="Save as new" onExit={onExit}>
<form onSubmit={handleSave} className={baseClass}>
<InputField
name="queryName"
onChange={onInputChange}
onBlur={onInputBlur}
value={formData.queryName}
error={formErrors.queryName}
inputClassName={`${baseClass}__name`}
label="Name"
autofocus
ignore1password
parseTarget
/>
{isPremiumTier && (userTeams?.length || 0) > 1 && (
<div className="form-field">
<div className="form-field__label">Team</div>
<TeamsDropdown
asFormField
currentUserTeams={userTeams || []}
selectedTeamId={formData.team.id}
onChange={onTeamChange}
/>
</div>
)}
<div className="modal-cta-wrap">
<Button
type="submit"
className="save-as-new-query"
isLoading={isSaving}
// empty SQL error handled by parent
disabled={Object.keys(formErrors).length > 0 || isSaving}
>
Save
</Button>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default SaveAsNewQueryModal;