From e08ee0adab3344f99e0c26c33419833d779a915d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Bele=20Reinfjell?= Date: Tue, 12 Jan 2021 19:43:29 +0100 Subject: [PATCH 1/4] Add functionality for using predefined filters on incidents view --- src/api/hooks.tsx | 4 +- src/api/index.tsx | 2 + .../incident/IncidentFilterToolbar.tsx | 76 +++++++++++++++++-- .../incidenttable/FilteredIncidentTable.tsx | 3 +- src/views/incident/IncidentView.tsx | 38 +++++++++- 5 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/api/hooks.tsx b/src/api/hooks.tsx index 09e1b6f5..51c4b9ca 100644 --- a/src/api/hooks.tsx +++ b/src/api/hooks.tsx @@ -14,7 +14,7 @@ import { Timeslot, TimeslotPK, } from "../api"; -import { toMap, pkGetter } from "../utils"; +import { toMap, pkGetter, identity } from "../utils"; type UsePromiseReturnType = { result: R | undefined; @@ -84,6 +84,8 @@ export const useApiNotificationProfiles = ( onResult?: (result: Map) => void, ) => createUsePromise>(asMap, onResult); +export const useApiFiltersList = () => createUsePromise((filters: Filter[]) => filters); + export const useApiFilters = (onResult?: (result: Map) => void) => createUsePromise>(asMap, onResult); diff --git a/src/api/index.tsx b/src/api/index.tsx index c5073d67..ffee5ba7 100644 --- a/src/api/index.tsx +++ b/src/api/index.tsx @@ -213,6 +213,8 @@ export type IncidentsFilterOptions = { sourceSystemIds?: number[] | string[]; sourceSystemNames?: string[]; tags?: string[]; + + filter?: Filter["pk"]; // NOT COMPLETE }; diff --git a/src/components/incident/IncidentFilterToolbar.tsx b/src/components/incident/IncidentFilterToolbar.tsx index a31ea4cc..1b9cf5fd 100644 --- a/src/components/incident/IncidentFilterToolbar.tsx +++ b/src/components/incident/IncidentFilterToolbar.tsx @@ -6,6 +6,14 @@ import Typography from "@material-ui/core/Typography"; import Toolbar from "@material-ui/core/Toolbar"; import IconButton from "@material-ui/core/IconButton"; import SettingsIcon from "@material-ui/icons/Settings"; +import Autocomplete from "@material-ui/lab/Autocomplete"; +import TextField from "@material-ui/core/TextField"; + +import InputLabel from "@material-ui/core/InputLabel"; +import MenuItem from "@material-ui/core/MenuItem"; +import FormHelperText from "@material-ui/core/FormHelperText"; +import FormControl from "@material-ui/core/FormControl"; +import Select from "@material-ui/core/Select"; import { ENABLE_WEBSOCKETS_SUPPORT } from "../../config"; @@ -14,7 +22,7 @@ import { IncidentsFilter, AutoUpdate } from "../../components/incidenttable/Filt import TagSelector, { Tag } from "../../components/tagselector"; import SourceSelector from "../../components/sourceselector"; -import api, { IncidentMetadata, SourceSystem } from "../../api"; +import api, { Filter, IncidentMetadata, SourceSystem } from "../../api"; import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; @@ -136,6 +144,45 @@ export const DropdownToolbar: React.FC = ({ ); }; +type UseExistingFilterToolbarItemPropsType = { + selectedFilter: number; + existingFilters: Filter[]; + onSelect: (filterIndex: number) => void; + className?: string; +}; + +export const UseExistingFilterToolbarItem: React.FC = ({ + selectedFilter, + existingFilters, + onSelect, + className, +}: UseExistingFilterToolbarItemPropsType) => { + return ( + + + Select from your filters + + ); +}; + type MoreSettingsToolbarItemPropsType = { open: boolean; onChange: (open: boolean) => void; @@ -163,13 +210,19 @@ export const MoreSettingsToolbarItem: React.FC }; type IncidentFilterToolbarPropsType = { + existingFilter: number; + existingFilters: Filter[]; filter: IncidentsFilter; + onExistingFilterChange: (filterIndex: number) => void; onFilterChange: (filter: IncidentsFilter) => void; disabled?: boolean; }; export const IncidentFilterToolbar: React.FC = ({ + existingFilter, + existingFilters, filter, + onExistingFilterChange, onFilterChange, disabled, }: IncidentFilterToolbarPropsType) => { @@ -227,6 +280,9 @@ export const IncidentFilterToolbar: React.FC = ( } }; + const useExistingFilter = existingFilter && existingFilter >= 0 && existingFilter < existingFilters.length; + const sources = (useExistingFilter && existingFilters[existingFilter]?.sourceSystemIds) || filter.sourcesById; + return (
@@ -252,7 +308,7 @@ export const IncidentFilterToolbar: React.FC = ( { onSourcesChange((selection.length !== 0 && selection) || "AllSources"); @@ -261,10 +317,20 @@ export const IncidentFilterToolbar: React.FC = ( /> - - + + + + + onExistingFilterChange(filterIndex)} + /> - setDropdownToolbarOpen(open)} diff --git a/src/components/incidenttable/FilteredIncidentTable.tsx b/src/components/incidenttable/FilteredIncidentTable.tsx index 66f59371..830c7e18 100644 --- a/src/components/incidenttable/FilteredIncidentTable.tsx +++ b/src/components/incidenttable/FilteredIncidentTable.tsx @@ -7,7 +7,7 @@ import "../../components/incidenttable/incidenttable.css"; import { Tag } from "../../components/tagselector"; import TablePagination from "@material-ui/core/TablePagination"; -import api, { IncidentsFilterOptions } from "../../api"; +import api, { Filter, IncidentsFilterOptions } from "../../api"; import { useApiPaginatedIncidents } from "../../api/hooks"; import { DEFAULT_AUTO_REFRESH_INTERVAL } from "../../config"; @@ -51,6 +51,7 @@ export type IncidentsFilter = { }; type FilteredIncidentsTablePropsType = { + existingFilter?: Filter["pk"]; filter: IncidentsFilter; onLoad?: () => void; }; diff --git a/src/views/incident/IncidentView.tsx b/src/views/incident/IncidentView.tsx index 18306447..a9b7617a 100644 --- a/src/views/incident/IncidentView.tsx +++ b/src/views/incident/IncidentView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useEffect, useState, useMemo } from "react"; import "./IncidentView.css"; import { withRouter } from "react-router-dom"; @@ -8,10 +8,22 @@ import "../../components/incidenttable/incidenttable.css"; import { ENABLE_WEBSOCKETS_SUPPORT } from "../../config"; import { IncidentFilterToolbar } from "../../components/incident/IncidentFilterToolbar"; +import api, { Filter } from "../../api"; +import { originalToTag } from "../../components/tagselector"; type IncidentViewPropsType = {}; const IncidentView: React.FC = ({}: IncidentViewPropsType) => { + const [existingFilters, setExistingFilters] = useState([]); + + useEffect(() => { + api.getAllFilters().then((filters: Filter[]) => { + setExistingFilters(filters); + }); + }, []); + + const [existingFilter, setExistingFilter] = useState(-1); + const [filter, setFilter] = useState({ autoUpdate: ENABLE_WEBSOCKETS_SUPPORT ? "realtime" : "interval", showAcked: false, @@ -21,10 +33,30 @@ const IncidentView: React.FC = ({}: IncidentViewPropsType show: "open", }); + const mergedFilter = useMemo(() => { + if (existingFilter || existingFilter >= existingFilters.length || existingFilter < 0) { + return filter; + } + + const existing: Filter = existingFilters[existingFilter]; + + const tags = [...existing.tags.map(originalToTag)]; + const sources = undefined; + const sourcesById = existing.sourceSystemIds; + + return { ...filter, tags, sources, sourcesById }; + }, [filter, existingFilter, existingFilters]); + return (
- - + +
); }; From 247c22b2d08b874a7445235ddc8deedd76e7eed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Bele=20Reinfjell?= Date: Tue, 12 Jan 2021 21:34:04 +0100 Subject: [PATCH 2/4] Create global App Context & create filter reducer/actions This is an application store, and is written in a strongly typed manner. --- src/contexts.tsx | 36 +++++++++++++++++++++++++++ src/index.tsx | 6 ++++- src/reducers/common.tsx | 10 ++++++++ src/reducers/filter.tsx | 55 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/contexts.tsx create mode 100644 src/reducers/common.tsx create mode 100644 src/reducers/filter.tsx diff --git a/src/contexts.tsx b/src/contexts.tsx new file mode 100644 index 00000000..4432acc9 --- /dev/null +++ b/src/contexts.tsx @@ -0,0 +1,36 @@ +import React, { createContext, useReducer } from "react"; + +import { Filter } from "./api"; +import { filterReducer, FilterActions } from "./reducers/filter"; + +export type InitialStateType = { + // List of all filters that the currently + // logged in user has access to. + filters: Filter[]; +}; + +const initialState: InitialStateType = { + filters: [], +}; + +type ActionsType = FilterActions /* | OtherAction | AnotherActoin ... */; + +const AppContext = createContext<{ + state: InitialStateType; + dispatch: React.Dispatch; +}>({ + state: initialState, + dispatch: () => null, +}); + +const mainReducer = ({ filters }: InitialStateType, action: ActionsType) => ({ + filters: filterReducer(filters, action), +}); + +const AppProvider: React.FC = ({ children }: { children?: React.ReactNode }) => { + const [state, dispatch] = useReducer(mainReducer, initialState); + + return {children}; +}; + +export { AppContext, AppProvider }; diff --git a/src/index.tsx b/src/index.tsx index ad78ec37..54e8c195 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,13 +3,17 @@ import ReactDOM from "react-dom"; import "./index.css"; import App from "./App"; import * as serviceWorker from "./serviceWorker"; +import { AppProvider } from "./contexts"; +// TODO: Remove use of store provider import { StoreProvider } from "./store"; import { BrowserRouter } from "react-router-dom"; ReactDOM.render( - + + + , document.getElementById("root"), diff --git a/src/reducers/common.tsx b/src/reducers/common.tsx new file mode 100644 index 00000000..2a35d734 --- /dev/null +++ b/src/reducers/common.tsx @@ -0,0 +1,10 @@ +export type ActionMap = { + [Key in keyof M]: M[Key] extends undefined + ? { + type: Key; + } + : { + type: Key; + payload: M[Key]; + }; +}; diff --git a/src/reducers/filter.tsx b/src/reducers/filter.tsx new file mode 100644 index 00000000..1e004cf7 --- /dev/null +++ b/src/reducers/filter.tsx @@ -0,0 +1,55 @@ +import { Filter } from "../api"; + +import { ActionMap } from "./common"; + +export enum FilterType { + Create = "CREATE_FILTER", + Delete = "DELETE_FILTER", + Modify = "MODIFY_FILTERS", + LoadAll = "LOAD_FILTERS", +} + +type FilterPayload = { + // Used to override all local modifications with + // data from backend + [FilterType.LoadAll]: Filter[]; + + // When a filter is modified. + [FilterType.Modify]: Filter; + + // When a new filter is created. + [FilterType.Create]: Filter; + + // When a filter is deleted + [FilterType.Delete]: Filter["pk"]; +}; + +export type FilterActions = ActionMap[keyof ActionMap]; +export const filterReducer = (state: Filter[], action: FilterActions) => { + switch (action.type) { + case FilterType.LoadAll: + return action.payload; + case FilterType.Modify: { + const index = state.findIndex((f: Filter) => f.pk === action.payload.pk); + const updated = [...state]; + updated[index] = { ...updated[index], ...action.payload }; + return updated; + } + case FilterType.Create: + return [...state, action.payload]; + case FilterType.Delete: + return [...state.filter((filter: Filter) => filter.pk !== action.payload)]; + } +}; + +type Action = (payload: P) => { type: T; payload: P }; +function makeAction(type: T): Action { + return (payload: P): { type: T; payload: P } => { + return { type, payload }; + }; +} + +export const loadAllFilters = makeAction(FilterType.LoadAll); +export const modifyFilter = makeAction(FilterType.Modify); +export const createFilter = makeAction(FilterType.Create); +export const deleteFilter = makeAction(FilterType.Delete); From 790058947b0afd51e8ea42fb9c292a6c68c46a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Bele=20Reinfjell?= Date: Tue, 12 Jan 2021 21:32:59 +0100 Subject: [PATCH 3/4] Use global context for sharing filters between views --- src/views/filters/FiltersView.tsx | 34 +++++++++++------------------ src/views/incident/IncidentView.tsx | 34 +++++++++++++++++++---------- 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/src/views/filters/FiltersView.tsx b/src/views/filters/FiltersView.tsx index e05da8f0..c6f72366 100644 --- a/src/views/filters/FiltersView.tsx +++ b/src/views/filters/FiltersView.tsx @@ -33,6 +33,9 @@ import { useAlertSnackbar, UseAlertSnackbarResultType } from "../../components/a import FilteredIncidentTable, { AutoUpdate } from "../../components/incidenttable/FilteredIncidentTable"; +import { AppContext } from "../../contexts"; +import { loadAllFilters, modifyFilter, createFilter, deleteFilter } from "../../reducers/filter"; + type FiltersTablePropsType = { filters: Filter[]; onFilterPreview: (filter: Filter) => void; @@ -118,6 +121,12 @@ const FiltersTable: React.FC = ({ type FiltersViewPropsType = {}; const FiltersView: React.FC = ({}: FiltersViewPropsType) => { + // Filters are kept in the global app state + const { + state: { filters }, + dispatch, + } = useContext(AppContext); + const { incidentSnackbar: filtersSnackbar, displayAlertSnackbar }: UseAlertSnackbarResultType = useAlertSnackbar(); // const [sourcesIdsFilter, setSourceIdsFilter] = useState([]); @@ -143,11 +152,6 @@ const FiltersView: React.FC = ({}: FiltersViewPropsType) = // and is the one that we can save/create. const [editingFilter, setEditingFilter] = useState(undefined); - // Filters is the list of existing filters. - // It is loaded once from the backend on refresh, but then - // only modified locally by using results from the backend etc. - const [filters, setFilters] = useState([]); - const filterExists = (name: string) => { return (filters.find((f) => f.name === name) && true) || false; }; @@ -167,7 +171,7 @@ const FiltersView: React.FC = ({}: FiltersViewPropsType) = api .getAllFilters() .then((filters) => { - setFilters(filters); + dispatch(loadAllFilters(filters)); setFiltersContext((prev: FiltersContextType) => { return { @@ -199,9 +203,7 @@ const FiltersView: React.FC = ({}: FiltersViewPropsType) = return { ...prev, savingFilter: false }; }); - setFilters((prev: Filter[]) => { - return [...prev, { ...filter, name: response.name, pk: response.pk }]; - }); + dispatch(createFilter({ ...filter, name: response.name, pk: response.pk })); displayAlertSnackbar(`Created filter ${name}`, "success"); }) .catch((error) => { @@ -226,12 +228,7 @@ const FiltersView: React.FC = ({}: FiltersViewPropsType) = return { ...prev, savingFilter: false }; }); - setFilters((prev: Filter[]) => { - const index = prev.findIndex((f: Filter) => f.pk === filter.pk); - const updated = [...prev]; - updated[index] = { ...updated[index], ...definition }; - return updated; - }); + dispatch(modifyFilter({ ...filter, ...definition })); displayAlertSnackbar(`Saved filter ${name}`, "success"); }) .catch((error) => { @@ -252,12 +249,7 @@ const FiltersView: React.FC = ({}: FiltersViewPropsType) = setFiltersContext((prev: FiltersContextType) => { return { ...prev, deletingFilter: undefined }; }); - setFilters((prev: Filter[]) => { - const n = [...prev]; - const index = n.findIndex((f: Filter) => f.pk === filter.pk); - n.splice(index, 1); - return n; - }); + dispatch(deleteFilter(filter.pk)); displayAlertSnackbar(`Deleted filter ${filter.name}`, "warning"); }) .catch((error) => { diff --git a/src/views/incident/IncidentView.tsx b/src/views/incident/IncidentView.tsx index a9b7617a..f71aa6ef 100644 --- a/src/views/incident/IncidentView.tsx +++ b/src/views/incident/IncidentView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useState, useMemo, useContext } from "react"; import "./IncidentView.css"; import { withRouter } from "react-router-dom"; @@ -11,14 +11,21 @@ import { IncidentFilterToolbar } from "../../components/incident/IncidentFilterT import api, { Filter } from "../../api"; import { originalToTag } from "../../components/tagselector"; +// Store +import { AppContext } from "../../contexts"; +import { loadAllFilters } from "../../reducers/filter"; + type IncidentViewPropsType = {}; const IncidentView: React.FC = ({}: IncidentViewPropsType) => { - const [existingFilters, setExistingFilters] = useState([]); + const { + state: { filters }, + dispatch, + } = useContext(AppContext); useEffect(() => { api.getAllFilters().then((filters: Filter[]) => { - setExistingFilters(filters); + dispatch(loadAllFilters(filters)); }); }, []); @@ -33,30 +40,35 @@ const IncidentView: React.FC = ({}: IncidentViewPropsType show: "open", }); - const mergedFilter = useMemo(() => { - if (existingFilter || existingFilter >= existingFilters.length || existingFilter < 0) { + // If the user has chosen to use an existing filter + // it will take the sources and tags from that filter. + // AND it will use the following properties: + // show=open, showAcked=true, autoupdate=interval + // The reasoning being that this is what the preview does. + // TODO: When real-time is supported better it should be enabled + const selectedFilter = useMemo((): IncidentsFilter => { + if (existingFilter == -1 || existingFilter >= filters.length || existingFilter < 0) { return filter; } - const existing: Filter = existingFilters[existingFilter]; + const existing: Filter = filters[existingFilter]; const tags = [...existing.tags.map(originalToTag)]; const sources = undefined; const sourcesById = existing.sourceSystemIds; - - return { ...filter, tags, sources, sourcesById }; - }, [filter, existingFilter, existingFilters]); + return { tags, sources, sourcesById, show: "open", showAcked: true, autoUpdate: "interval" }; + }, [filter, existingFilter, filters]); return (
- +
); }; From 32bb82c221b7cd20b1a180250448d15a4914d796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Bele=20Reinfjell?= Date: Tue, 12 Jan 2021 21:36:11 +0100 Subject: [PATCH 4/4] Disable all other filter settings when a predefined filter is used --- .../incident/IncidentFilterToolbar.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/components/incident/IncidentFilterToolbar.tsx b/src/components/incident/IncidentFilterToolbar.tsx index 1b9cf5fd..8eeff92d 100644 --- a/src/components/incident/IncidentFilterToolbar.tsx +++ b/src/components/incident/IncidentFilterToolbar.tsx @@ -82,6 +82,7 @@ type ButtonGroupSwitchPropsType = { getLabel: (option: T) => string; getColor: (selected: boolean) => "inherit" | "default" | "primary"; onSelect: (option: T) => void; + disabled?: boolean; }; export function ButtonGroupSwitch({ @@ -90,12 +91,18 @@ export function ButtonGroupSwitch({ getLabel, getColor, onSelect, + disabled, }: ButtonGroupSwitchPropsType) { return ( {options.map((option: T, index: number) => { return ( - ); @@ -257,9 +264,13 @@ export const IncidentFilterToolbar: React.FC = ( ? ["never", "realtime", "interval"] : ["never", "interval"]; + const useExistingFilter = + existingFilter != -1 && existingFilter >= 0 && existingFilter < existingFilters.length ? true : false; + const autoUpdateToolbarItem = ( @@ -280,15 +291,13 @@ export const IncidentFilterToolbar: React.FC = ( } }; - const useExistingFilter = existingFilter && existingFilter >= 0 && existingFilter < existingFilters.length; - const sources = (useExistingFilter && existingFilters[existingFilter]?.sourceSystemIds) || filter.sourcesById; - return (
({ open: "Open", closed: "Closed", both: "Both" }[show])} getColor={(selected: boolean) => (selected ? "primary" : "default")} @@ -298,7 +307,8 @@ export const IncidentFilterToolbar: React.FC = ( (showAcked ? "Both" : "Unacked")} getColor={(selected: boolean) => (selected ? "primary" : "default")} @@ -308,7 +318,7 @@ export const IncidentFilterToolbar: React.FC = ( { onSourcesChange((selection.length !== 0 && selection) || "AllSources"); @@ -319,7 +329,7 @@ export const IncidentFilterToolbar: React.FC = (