fleet/frontend/pages/queries/edit/components/SaveAsNewQueryModal/SaveAsNewQueryModal.tsx
jacobshandling f0d3809b22
UI: Allow editing the name and team of a "Save as new" query (#30544)
## #14801 
### [Demo
video](https://drive.google.com/file/d/1Lovk7iwvgUv1NpfsqSt-Is0yTBt0SZ5O/view?usp=sharing)
<img width="1624" alt="Screenshot 2025-07-02 at 4 58 33 PM"
src="https://github.com/user-attachments/assets/86c7b214-e8e4-4e58-9969-b1373ed97691"
/>


* **New Features**
* Added the ability to select a team and update the name when saving a
query as a new copy, using a dedicated modal dialog.

* **Improvements**
* Enhanced the team selection dropdown with new styling options and
clarified prop names.
* Updated query editing workflow to use a modal for "Save as new"
actions.
* Improved type safety and clarity in several interfaces and utility
functions.

* **Bug Fixes**
  * Fixed inconsistencies in prop naming for team dropdown components.
* Ensured "Discard data" setting is maintained when "Save as new"ing a
query - it was previously not maintained correctly

* **Tests**
* Updated and removed tests to align with the new "Save as new" query
workflow and prop changes.
  * Added utilities for creating mock location objects in tests.

* **Style**
  * Added a new light grey color to the UI color palette.

- [x] Changes file added for user-visible changes in `changes/`
- [x] Added/updated automated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
2025-07-03 13:11:06 -07:00

229 lines
6.2 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;
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,
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,
})
);
} 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}
disabled={Object.keys(formErrors).length > 0 || isSaving}
>
Save
</Button>
<Button onClick={onExit} variant="inverse">
Cancel
</Button>
</div>
</form>
</Modal>
);
};
export default SaveAsNewQueryModal;