mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #35058 - Open the Query save or save-as-new-ing flows in the UI even when a syntax error is found in the Query's SQL. - Continue blocking save when the query is empty - Update tests - JS –> TS housekeeping <img width="1162" height="1248" alt="Screenshot 2025-12-02 at 4 31 47 PM" src="https://github.com/user-attachments/assets/23b4e70d-f104-4b0e-b316-c03fb6492f59" /> <img width="1162" height="1248" alt="Screenshot 2025-12-02 at 4 31 50 PM" src="https://github.com/user-attachments/assets/5b5ad0b7-36f0-4c5e-a2ff-e9665263c8f1" /> # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/` - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually * "invalid" according to Fleet's UI. Though we make efforts to fix false negatives here as we become aware of them, that parsing is imperfectly aligned with SQL that osquery considers valid
230 lines
6.2 KiB
TypeScript
230 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}
|
|
// 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;
|