From 1d6870f0a7efdf9281efcfa9de21fd86102765bf Mon Sep 17 00:00:00 2001 From: Jacob Shandling <61553566+jacobshandling@users.noreply.github.com> Date: Mon, 17 Jul 2023 14:09:59 -0700 Subject: [PATCH] UI: Update the save query modal with scheduling-related fields (#12741) ## Addresses #12646 ### See issue for list of completed work ![Screenshot 2023-07-12 at 5 41 05 PM](https://github.com/fleetdm/fleet/assets/61553566/b4ece0c9-5df1-4320-9dce-1cd8c2758c6c) ### Also see PR #12713 **notes for review** on that PR for help manually testing this work in lieu of the completed API. - [x] Changes file added for user-visible changes in `changes/` - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Jacob Shandling --- changes/12646-update-save-query-modal | 1 + frontend/interfaces/schedulable_query.ts | 2 +- .../ManageQueriesPage/ManageQueriesPage.tsx | 2 +- .../pages/queries/QueryPage/QueryPage.tsx | 44 ++- .../NewQueryModal/NewQueryModal.tsx | 135 --------- .../components/NewQueryModal/index.ts | 1 - .../components/QueryForm/QueryForm.tsx | 26 +- .../SaveQueryModal/SaveQueryModal.tsx | 259 ++++++++++++++++++ .../components/SaveQueryModal/_styles.scss | 32 +++ .../components/SaveQueryModal/index.ts | 1 + .../queries/QueryPage/screens/QueryEditor.tsx | 18 +- frontend/router/paths.ts | 3 +- frontend/services/entities/queries.ts | 13 +- frontend/utilities/constants.ts | 4 + 14 files changed, 362 insertions(+), 179 deletions(-) create mode 100644 changes/12646-update-save-query-modal delete mode 100644 frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx delete mode 100644 frontend/pages/queries/QueryPage/components/NewQueryModal/index.ts create mode 100644 frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx create mode 100644 frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss create mode 100644 frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts diff --git a/changes/12646-update-save-query-modal b/changes/12646-update-save-query-modal new file mode 100644 index 0000000000..8c136872ea --- /dev/null +++ b/changes/12646-update-save-query-modal @@ -0,0 +1 @@ +- Update the save query modal to include scheduling-related fields. diff --git a/frontend/interfaces/schedulable_query.ts b/frontend/interfaces/schedulable_query.ts index aa9bc46013..77475e2c93 100644 --- a/frontend/interfaces/schedulable_query.ts +++ b/frontend/interfaces/schedulable_query.ts @@ -77,7 +77,7 @@ export interface IModifyQueryRequestBody // Delete a query by name /** DELETE /api/v1/fleet/queries/{name} */ export interface IDeleteQueryRequestBody { - team_id?: number; // searches for a global query if ommitted + team_id?: number; // searches for a global query if omitted } // Delete a query by id diff --git a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx index 2278e8b237..117cb6b798 100644 --- a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx +++ b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx @@ -150,7 +150,7 @@ const ManageQueriesPage = ({ } }, [location, filteredQueriesPath, setFilteredQueriesPath]); - const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY); + const onCreateQueryClick = () => router.push(PATHS.NEW_QUERY(currentTeamId)); const toggleDeleteQueryModal = useCallback(() => { setShowDeleteQueryModal(!showDeleteQueryModal); diff --git a/frontend/pages/queries/QueryPage/QueryPage.tsx b/frontend/pages/queries/QueryPage/QueryPage.tsx index 75f636686a..70294448d1 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.tsx +++ b/frontend/pages/queries/QueryPage/QueryPage.tsx @@ -12,7 +12,11 @@ import statusAPI from "services/entities/status"; import { IHost, IHostResponse } from "interfaces/host"; import { ILabel } from "interfaces/label"; import { ITeam } from "interfaces/team"; -import { IQueryFormData, IQuery, IStoredQueryResponse } from "interfaces/query"; +import { + ICreateQueryRequestBody, + IGetQueryResponse, + ISchedulableQuery, +} from "interfaces/schedulable_query"; import { ITarget } from "interfaces/target"; import QuerySidePanel from "components/side_panels/QuerySidePanel"; @@ -23,12 +27,15 @@ import CustomLink from "components/CustomLink"; import QueryEditor from "pages/queries/QueryPage/screens/QueryEditor"; import RunQuery from "pages/queries/QueryPage/screens/RunQuery"; +import useTeamIdParam from "hooks/useTeamIdParam"; interface IQueryPageProps { router: InjectedRouter; params: Params; location: { - query: { host_ids: string }; + pathname: string; + query: { host_ids: string; team_id?: string }; + search: string; }; } @@ -37,9 +44,15 @@ const baseClass = "query-page"; const QueryPage = ({ router, params: { id: paramsQueryId }, - location: { query: URLQuerySearch }, + location, }: IQueryPageProps): JSX.Element => { const queryId = paramsQueryId ? parseInt(paramsQueryId, 10) : null; + const { teamIdForApi: teamIdForQuery } = useTeamIdParam({ + location, + router, + includeAllTeams: true, + includeNoTeam: false, + }); const handlePageError = useErrorHandler(); const { @@ -78,13 +91,13 @@ const QueryPage = ({ isLoading: isStoredQueryLoading, data: storedQuery, error: storedQueryError, - } = useQuery( + } = useQuery( ["query", queryId], () => queryAPI.load(queryId as number), { enabled: !!queryId, refetchOnWindowFocus: false, - select: (data: IStoredQueryResponse) => data.query, + select: (data) => data.query, onSuccess: (returnedQuery) => { setLastEditedQueryId(returnedQuery.id); setLastEditedQueryName(returnedQuery.name); @@ -99,9 +112,9 @@ const QueryPage = ({ useQuery( "hostFromURL", () => - hostAPI.loadHostDetails(parseInt(URLQuerySearch.host_ids as string, 10)), + hostAPI.loadHostDetails(parseInt(location.query.host_ids as string, 10)), { - enabled: !!URLQuerySearch.host_ids && !queryParamHostsAdded, + enabled: !!location.query.host_ids && !queryParamHostsAdded, select: (data: IHostResponse) => data.host, onSuccess: (host) => { setTargetedHosts((prevHosts) => @@ -119,7 +132,9 @@ const QueryPage = ({ } ); - const { mutateAsync: createQuery } = useMutation((formData: IQueryFormData) => + const { + mutateAsync: createQuery, + } = useMutation((formData: ICreateQueryRequestBody) => queryAPI.create(formData) ); @@ -179,10 +194,11 @@ const QueryPage = ({ const goToQueryEditor = useCallback(() => setStep(QUERIES_PAGE_STEPS[1]), []); const renderScreen = () => { - const step1Opts = { + const step1Props = { router, baseClass, queryIdForEdit: queryId, + teamIdForQuery, showOpenSchemaActionText, storedQuery, isStoredQueryLoading, @@ -194,7 +210,7 @@ const QueryPage = ({ renderLiveQueryWarning, }; - const step2Opts = { + const step2Props = { baseClass, queryId, selectedTargets, @@ -211,7 +227,7 @@ const QueryPage = ({ setTargetsTotalCount, }; - const step3Opts = { + const step3Props = { queryId, selectedTargets, storedQuery, @@ -222,11 +238,11 @@ const QueryPage = ({ switch (step) { case QUERIES_PAGE_STEPS[2]: - return ; + return ; case QUERIES_PAGE_STEPS[3]: - return ; + return ; default: - return ; + return ; } }; diff --git a/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx b/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx deleted file mode 100644 index 836450c456..0000000000 --- a/frontend/pages/queries/QueryPage/components/NewQueryModal/NewQueryModal.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React, { useState, useEffect } from "react"; -import { size } from "lodash"; - -import { IQueryFormData } from "interfaces/query"; -import useDeepEffect from "hooks/useDeepEffect"; - -import Checkbox from "components/forms/fields/Checkbox"; -// @ts-ignore -import InputField from "components/forms/fields/InputField"; -import Button from "components/buttons/Button"; -import Modal from "components/Modal"; - -export interface INewQueryModalProps { - baseClass: string; - queryValue: string; - isLoading: boolean; - onCreateQuery: (formData: IQueryFormData) => void; - setIsSaveModalOpen: (isOpen: boolean) => void; - backendValidators: { [key: string]: string }; -} - -const validateQueryName = (name: string) => { - const errors: { [key: string]: string } = {}; - - if (!name) { - errors.name = "Query name must be present"; - } - - const valid = !size(errors); - return { valid, errors }; -}; - -const NewQueryModal = ({ - baseClass, - queryValue, - isLoading, - onCreateQuery, - setIsSaveModalOpen, - backendValidators, -}: INewQueryModalProps): JSX.Element => { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [observerCanRun, setObserverCanRun] = useState(false); - const [errors, setErrors] = useState<{ [key: string]: string }>( - backendValidators - ); - - useDeepEffect(() => { - if (name) { - setErrors({}); - } - }, [name]); - - useEffect(() => { - setErrors(backendValidators); - }, [backendValidators]); - - const handleUpdate = (evt: React.MouseEvent) => { - evt.preventDefault(); - - const { valid, errors: newErrors } = validateQueryName(name); - setErrors({ - ...errors, - ...newErrors, - }); - - if (valid) { - onCreateQuery({ - description, - name, - query: queryValue, - observer_can_run: observerCanRun, - }); - } - }; - - return ( - setIsSaveModalOpen(false)}> - <> -
- setName(value)} - value={name} - error={errors.name} - inputClassName={`${baseClass}__query-save-modal-name`} - label="Name" - placeholder="What is your query called?" - autofocus - /> - setDescription(value)} - value={description} - inputClassName={`${baseClass}__query-save-modal-description`} - label="Description" - type="textarea" - placeholder="What information does your query reveal? (optional)" - /> - - Observers can run - -

- Users with the Observer role will be able to run this query on hosts - where they have access. -

-
- - -
- - -
- ); -}; - -export default NewQueryModal; diff --git a/frontend/pages/queries/QueryPage/components/NewQueryModal/index.ts b/frontend/pages/queries/QueryPage/components/NewQueryModal/index.ts deleted file mode 100644 index acf83db4a9..0000000000 --- a/frontend/pages/queries/QueryPage/components/NewQueryModal/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./NewQueryModal"; diff --git a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx b/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx index f3b6edcdb0..e539a26f42 100644 --- a/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx +++ b/frontend/pages/queries/QueryPage/components/QueryForm/QueryForm.tsx @@ -26,7 +26,7 @@ import Checkbox from "components/forms/fields/Checkbox"; import Spinner from "components/Spinner"; import Icon from "components/Icon/Icon"; import AutoSizeInputField from "components/forms/fields/AutoSizeInputField"; -import NewQueryModal from "../NewQueryModal"; +import SaveQueryModal from "../SaveQueryModal"; import InfoIcon from "../../../../../../assets/images/icon-info-purple-14x14@2x.png"; const baseClass = "query-form"; @@ -34,12 +34,13 @@ const baseClass = "query-form"; interface IQueryFormProps { router: InjectedRouter; queryIdForEdit: number | null; + teamIdForQuery?: number; showOpenSchemaActionText: boolean; storedQuery: IQuery | undefined; isStoredQueryLoading: boolean; isQuerySaving: boolean; isQueryUpdating: boolean; - onCreateQuery: (formData: IQueryFormData) => void; + saveQuery: (formData: IQueryFormData) => void; onOsqueryTableSelect: (tableName: string) => void; goToSelectTargets: () => void; onUpdate: (formData: IQueryFormData) => void; @@ -63,12 +64,13 @@ const validateQuerySQL = (query: string) => { const QueryForm = ({ router, queryIdForEdit, + teamIdForQuery, showOpenSchemaActionText, storedQuery, isStoredQueryLoading, isQuerySaving, isQueryUpdating, - onCreateQuery, + saveQuery, onOsqueryTableSelect, goToSelectTargets, onUpdate, @@ -104,7 +106,7 @@ const QueryForm = ({ const savedQueryMode = !!queryIdForEdit; const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined - const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); + const [showSaveQueryModal, setShowSaveQueryModal] = useState(false); const [showQueryEditor, setShowQueryEditor] = useState( isObserverPlus || isAnyTeamObserverPlus || false ); @@ -142,6 +144,10 @@ const QueryForm = ({ storedQuery.author_id === currentUser.id : isAnyTeamMaintainerOrTeamAdmin; + const toggleSaveQueryModal = () => { + setShowSaveQueryModal(!showSaveQueryModal); + }; + const onLoad = (editor: IAceEditor) => { editor.setOptions({ enableLinking: true, @@ -260,7 +266,7 @@ const QueryForm = ({ if (valid) { if (!savedQueryMode) { - setIsSaveModalOpen(true); + setShowSaveQueryModal(true); } else { onUpdate({ name: lastEditedQueryName, @@ -565,12 +571,12 @@ const QueryForm = ({ - {isSaveModalOpen && ( - diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx b/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx new file mode 100644 index 0000000000..061fabc381 --- /dev/null +++ b/frontend/pages/queries/QueryPage/components/SaveQueryModal/SaveQueryModal.tsx @@ -0,0 +1,259 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { pull, size } from "lodash"; + +import useDeepEffect from "hooks/useDeepEffect"; + +import Checkbox from "components/forms/fields/Checkbox"; +// @ts-ignore +import InputField from "components/forms/fields/InputField"; +// @ts-ignore +import Dropdown from "components/forms/fields/Dropdown"; +import Button from "components/buttons/Button"; +import Modal from "components/Modal"; +import { + FREQUENCY_DROPDOWN_OPTIONS, + LOGGING_TYPE_OPTIONS, + MIN_OSQUERY_VERSION_OPTIONS, + SCHEDULE_PLATFORM_DROPDOWN_OPTIONS, +} from "utilities/constants"; +import RevealButton from "components/buttons/RevealButton"; +import { IPlatformString } from "interfaces/platform"; +import { + ICreateQueryRequestBody, + ISchedulableQuery, + QueryLoggingOption, +} from "interfaces/schedulable_query"; + +const baseClass = "save-query-modal"; +export interface ISaveQueryModalProps { + queryValue: string; + teamIdForQuery?: number; // query will be global if omitted + isLoading: boolean; + saveQuery: (formData: ICreateQueryRequestBody) => void; + toggleSaveQueryModal: () => void; + backendValidators: { [key: string]: string }; + existingQuery?: ISchedulableQuery; +} + +const validateQueryName = (name: string) => { + const errors: { [key: string]: string } = {}; + + if (!name) { + errors.name = "Query name must be present"; + } + + const valid = !size(errors); + return { valid, errors }; +}; + +const SaveQueryModal = ({ + queryValue, + teamIdForQuery, + isLoading, + saveQuery, + toggleSaveQueryModal, + backendValidators, + existingQuery, +}: ISaveQueryModalProps): JSX.Element => { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [selectedFrequency, setSelectedFrequency] = useState( + existingQuery?.interval ?? 3600 + ); + const [ + selectedPlatformOptions, + setSelectedPlatformOptions, + ] = useState(existingQuery?.platform ?? ""); + const [ + selectedMinOsqueryVersionOptions, + setSelectedMinOsqueryVersionOptions, + ] = useState(existingQuery?.min_osquery_version ?? ""); + const [ + selectedLoggingType, + setSelectedLoggingType, + ] = useState(existingQuery?.logging ?? "snapshot"); + const [observerCanRun, setObserverCanRun] = useState(false); + const [errors, setErrors] = useState<{ [key: string]: string }>( + backendValidators + ); + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false); + + const toggleAdvancedOptions = () => { + setShowAdvancedOptions(!showAdvancedOptions); + }; + + useDeepEffect(() => { + if (name) { + setErrors({}); + } + }, [name]); + + useEffect(() => { + setErrors(backendValidators); + }, [backendValidators]); + + const onClickSaveQuery = (evt: React.MouseEvent) => { + evt.preventDefault(); + + const { valid, errors: newErrors } = validateQueryName(name); + setErrors({ + ...errors, + ...newErrors, + }); + + if (valid) { + saveQuery({ + // from modal fields + name, + description, + interval: selectedFrequency, + observer_can_run: observerCanRun, + platform: selectedPlatformOptions, + min_osquery_version: selectedMinOsqueryVersionOptions, + logging: selectedLoggingType, + // from previous New query page + query: queryValue, + // from doubly previous ManageQueriesPage + team_id: teamIdForQuery, + }); + } + }; + + const onChangeSelectPlatformOptions = useCallback( + (values: string) => { + const valArray = values.split(","); + + // Remove All if another OS is chosen + // else if Remove OS if All is chosen + if (valArray.indexOf("") === 0 && valArray.length > 1) { + // TODO - inmprove type safety of all 3 options + setSelectedPlatformOptions( + pull(valArray, "").join(",") as IPlatformString + ); + } else if (valArray.length > 1 && valArray.indexOf("") > -1) { + setSelectedPlatformOptions(""); + } else { + setSelectedPlatformOptions(values as IPlatformString); + } + }, + [setSelectedPlatformOptions] + ); + + return ( + + <> +
+ setName(value)} + value={name} + error={errors.name} + inputClassName={`${baseClass}__name`} + label="Name" + placeholder="What is your query called?" + autofocus + /> + setDescription(value)} + value={description} + inputClassName={`${baseClass}__description`} + label="Description" + type="textarea" + placeholder="What information does your query reveal? (optional)" + /> +
+ { + setSelectedFrequency(value); + }} + placeholder={"Every hour"} + value={selectedFrequency} + label="Frequency" + wrapperClassName={`${baseClass}__form-field ${baseClass}__form-field--frequency`} + /> +

+ If automations are on, this is how often your query collects data. +

+
+ + Observers can run + +

+ Users with the Observer role will be able to run this query as a + live query. +

+ + {showAdvancedOptions && ( + <> + +

+ If automations are turned on, your query collects data on + compatible platforms. +
+ If you want more control, override platforms. +

+ + + + )} +
+ + +
+ + +
+ ); +}; + +export default SaveQueryModal; diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss b/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss new file mode 100644 index 0000000000..a4d1337350 --- /dev/null +++ b/frontend/pages/queries/QueryPage/components/SaveQueryModal/_styles.scss @@ -0,0 +1,32 @@ +.save-query-modal { + .fleet-checkbox { + display: flex; + align-items: center; + } + + .help-text { + margin-top: $pad-small; + margin-bottom: $pad-large; + font-weight: $regular; + font-size: 0.75rem; + color: $ui-fleet-black-75; + } + + &__form-field { + &--frequency { + margin-bottom: 0; + } + &--platform { + margin-bottom: 0; + margin-top: $pad-large; + } + } + + &__observer-can-run-wrapper { + margin-bottom: 0; + } + + &__advanced-options-toggle { + font-weight: $xbold; + } +} diff --git a/frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts b/frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts new file mode 100644 index 0000000000..fd4708d1b7 --- /dev/null +++ b/frontend/pages/queries/QueryPage/components/SaveQueryModal/index.ts @@ -0,0 +1 @@ +export { default } from "./SaveQueryModal"; diff --git a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx index 75321ef8c9..98b500b99d 100644 --- a/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx +++ b/frontend/pages/queries/QueryPage/screens/QueryEditor.tsx @@ -8,6 +8,10 @@ import { AppContext } from "context/app"; import { QueryContext } from "context/query"; import { NotificationContext } from "context/notification"; import { IQueryFormData, IQuery } from "interfaces/query"; +import { + ICreateQueryRequestBody, + ISchedulableQuery, +} from "interfaces/schedulable_query"; import PATHS from "router/paths"; import debounce from "utilities/debounce"; import deepDifference from "utilities/deep_difference"; @@ -19,15 +23,15 @@ interface IQueryEditorProps { router: InjectedRouter; baseClass: string; queryIdForEdit: number | null; + teamIdForQuery?: number; storedQuery: IQuery | undefined; storedQueryError: Error | null; showOpenSchemaActionText: boolean; isStoredQueryLoading: boolean; createQuery: UseMutateAsyncFunction< - { query: IQuery }, + ISchedulableQuery, unknown, - IQueryFormData, - unknown + ICreateQueryRequestBody >; onOsqueryTableSelect: (tableName: string) => void; goToSelectTargets: () => void; @@ -39,6 +43,7 @@ const QueryEditor = ({ router, baseClass, queryIdForEdit, + teamIdForQuery, storedQuery, storedQueryError, showOpenSchemaActionText, @@ -77,10 +82,10 @@ const QueryEditor = ({ [key: string]: string; }>({}); - const onSaveQueryFormSubmit = debounce(async (formData: IQueryFormData) => { + const saveQuery = debounce(async (formData: ICreateQueryRequestBody) => { setIsQuerySaving(true); try { - const { query }: { query: IQuery } = await createQuery(formData); + const query = await createQuery(formData); router.push(PATHS.EDIT_QUERY(query)); renderFlash("success", "Query created!"); setBackendValidators({}); @@ -149,12 +154,13 @@ const QueryEditor = ({ + `${URL_PREFIX}/queries/new${teamId ? `?team_id=${teamId}` : ""}`, RESET_PASSWORD: `${URL_PREFIX}/login/reset`, SETUP: `${URL_PREFIX}/setup`, USER_SETTINGS: `${URL_PREFIX}/profile`, diff --git a/frontend/services/entities/queries.ts b/frontend/services/entities/queries.ts index a4a512a2e2..a44f394365 100644 --- a/frontend/services/entities/queries.ts +++ b/frontend/services/entities/queries.ts @@ -4,21 +4,14 @@ import endpoints from "utilities/endpoints"; import { IQueryFormData } from "interfaces/query"; import { ISelectedTargets } from "interfaces/target"; import { AxiosResponse } from "axios"; +import { ICreateQueryRequestBody } from "interfaces/schedulable_query"; import { buildQueryStringFromParams } from "utilities/url"; -// Mock API requests to be used in developing FE for #7765 in parallel with BE development -// import { sendRequest } from "services/mock_service/service/service"; - export default { - create: ({ description, name, query, observer_can_run }: IQueryFormData) => { + create: (createQueryRequestBody: ICreateQueryRequestBody) => { const { QUERIES } = endpoints; - return sendRequest("POST", QUERIES, { - description, - name, - query, - observer_can_run, - }); + return sendRequest("POST", QUERIES, createQueryRequestBody); }, destroy: (id: string | number) => { const { QUERIES } = endpoints; diff --git a/frontend/utilities/constants.ts b/frontend/utilities/constants.ts index fa7dd4c870..f3b06ec3da 100644 --- a/frontend/utilities/constants.ts +++ b/frontend/utilities/constants.ts @@ -23,7 +23,11 @@ export const DEFAULT_GRAVATAR_LINK_DARK_FALLBACK = "/assets/images/icon-avatar-default-dark-24x24%402x.png"; export const FREQUENCY_DROPDOWN_OPTIONS = [ + { value: 0, label: "Never" }, + { value: 300, label: "Every 5 minutes" }, + { value: 600, label: "Every 10 minutes" }, { value: 900, label: "Every 15 minutes" }, + { value: 1800, label: "Every 30 minutes" }, { value: 3600, label: "Every hour" }, { value: 21600, label: "Every 6 hours" }, { value: 43200, label: "Every 12 hours" },