From 254d64ce4422de5f417f198a8bc1127bf5e37cf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 09:16:51 +0200 Subject: [PATCH 001/130] Add: Add a react hook for storing instance variables An instance variable stores the value directly and doesn't cause re-renders if it is changed. Variables returned from this hooks are comparable to instance variables for class components. --- src/web/hooks/useInstanceVariable.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/web/hooks/useInstanceVariable.js diff --git a/src/web/hooks/useInstanceVariable.js b/src/web/hooks/useInstanceVariable.js new file mode 100644 index 0000000000..e21e96afd0 --- /dev/null +++ b/src/web/hooks/useInstanceVariable.js @@ -0,0 +1,26 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useRef} from 'react'; + +/** + * A hook to store an instance variable for a function component. + * + * This variable is persistent during render phases and changes to the variable + * don't cause re-renders like useState does. It is comparable to storing a + * variable to this in a class component. + * + * https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables + * + * @param {*} initialValue Initial value of the variable. Can be any kind of + * object. Most of the time it should be initialized + * with an empty object. + */ +const useInstanceVariable = initialValue => { + const ref = useRef(initialValue); + return ref.current; +}; + +export default useInstanceVariable; From 23bb59696be7343a0b3d96fb615f9f83fcd8b134 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 09:51:38 +0200 Subject: [PATCH 002/130] Add a test for useInstanceVariable hook --- .../hooks/__tests__/useInstanceVariable.jsx | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/web/hooks/__tests__/useInstanceVariable.jsx diff --git a/src/web/hooks/__tests__/useInstanceVariable.jsx b/src/web/hooks/__tests__/useInstanceVariable.jsx new file mode 100644 index 0000000000..876b407d87 --- /dev/null +++ b/src/web/hooks/__tests__/useInstanceVariable.jsx @@ -0,0 +1,45 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable react/prop-types */ + +import {describe, test, expect, testing} from '@gsa/testing'; + +import {useCallback} from 'react'; + +import {fireEvent, rendererWith, screen} from 'web/utils/testing'; + +import useInstanceVariable from '../useInstanceVariable'; + +const TestComponent = ({callback}) => { + const someVariable = useInstanceVariable({value: 1}); + const changeValue = useCallback(() => { + someVariable.value = 2; + callback(someVariable.value); + }, [someVariable, callback]); + return ( +
+
{someVariable.value}
+
+ ); +}; + +describe('useInstanceVariable tests', () => { + test('should render the value', () => { + const callback = testing.fn(); + const {render} = rendererWith(); + + render(); + + const t1 = screen.getByTestId('t1'); + expect(t1).toHaveTextContent('1'); + const b1 = screen.getByTestId('changeValue'); + fireEvent.click(b1); + expect(t1).toHaveTextContent('1'); + + expect(callback).toHaveBeenCalledWith(2); + }); +}); From ce1c1829ad8b8bbe443399e2cf4ce4b0535fdb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 10:09:40 +0200 Subject: [PATCH 003/130] CI: Allow the dependency review workflow to write a message to the PR The dependency review workflow is able to write a summary message to the PR if it is allowed to write to the workflow. --- .github/workflows/dependency-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 36afcc3257..d4be39743d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -3,6 +3,7 @@ on: [pull_request] permissions: contents: read + pull-requests: write jobs: dependency-review: From a6d9b92655410fa47ed4b5e5ad06ae3dd2fe4e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 14:15:49 +0200 Subject: [PATCH 004/130] Add: Add a useShallowEqualSelector hook The useShallowEqualSelector hooks allows to avoid re-renders if an object is selected from the redux store but its value(s) didn't change. With the standard selector which uses `===` comparison even updating an object's value to the same value will cause a re-render (because a new state object is created). This pattern can be found at https://react-redux.js.org/api/hooks#recipe-useshallowequalselector --- .../__tests__/useShallowEqualSelector.jsx | 96 +++++++++++++++++++ src/web/hooks/useShallowEqualSelector.js | 20 ++++ 2 files changed, 116 insertions(+) create mode 100644 src/web/hooks/__tests__/useShallowEqualSelector.jsx create mode 100644 src/web/hooks/useShallowEqualSelector.js diff --git a/src/web/hooks/__tests__/useShallowEqualSelector.jsx b/src/web/hooks/__tests__/useShallowEqualSelector.jsx new file mode 100644 index 0000000000..8f0f9fa382 --- /dev/null +++ b/src/web/hooks/__tests__/useShallowEqualSelector.jsx @@ -0,0 +1,96 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable react/prop-types */ + +import {useCallback} from 'react'; +import {useSelector, useDispatch} from 'react-redux'; +import {configureStore} from '@reduxjs/toolkit'; + +import {describe, test, expect, testing} from '@gsa/testing'; + +import {fireEvent, rendererWith, screen} from 'web/utils/testing'; + +import useShallowEqualSelector from '../useShallowEqualSelector'; + +const reducer = (state = {value: 0}, action) => { + switch (action.type) { + case 'increment': + return {...state, value: 1}; + default: + return state; + } +}; + +const update = () => ({type: 'increment'}); + +const TestComponent1 = ({renderCallback}) => { + const state = useSelector(state => state.counter); + const dispatch = useDispatch(); + const updateCounter = useCallback(() => dispatch(update()), [dispatch]); + renderCallback(); + return ( +
+
{state.value}
+ +
+ ); +}; + +const TestComponent2 = ({renderCallback}) => { + const state = useShallowEqualSelector(state => state.counter); + renderCallback(); + return ( +
+
{state.value}
+
+ ); +}; + +describe('useShallowEqualSelector tests', () => { + test('should return the selected state', () => { + const renderCount = testing.fn(); + const shallowRenderCount = testing.fn(); + const store = configureStore({ + reducer: { + counter: reducer, + }, + middleware: () => [], + }); + + const {render} = rendererWith({store}); + + render( + <> + + + , + ); + + const counter = screen.getByTestId('counter'); + const shallowCounter = screen.getByTestId('shallowCounter'); + expect(counter).toHaveTextContent('0'); + expect(shallowCounter).toHaveTextContent('0'); + expect(renderCount).toHaveBeenCalledTimes(1); + expect(shallowRenderCount).toHaveBeenCalledTimes(1); + + const updateCounter = screen.getByTestId('update'); + fireEvent.click(updateCounter); + + expect(counter).toHaveTextContent('1'); + expect(renderCount).toHaveBeenCalledTimes(2); + expect(shallowCounter).toHaveTextContent('1'); + expect(shallowRenderCount).toHaveBeenCalledTimes(2); + + fireEvent.click(updateCounter); + + expect(counter).toHaveTextContent('1'); + expect(renderCount).toHaveBeenCalledTimes(3); + expect(shallowCounter).toHaveTextContent('1'); + expect(shallowRenderCount).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web/hooks/useShallowEqualSelector.js b/src/web/hooks/useShallowEqualSelector.js new file mode 100644 index 0000000000..5cf2ca5dbc --- /dev/null +++ b/src/web/hooks/useShallowEqualSelector.js @@ -0,0 +1,20 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useSelector, shallowEqual} from 'react-redux'; + +/** + * A hook to use a redux selector with shallow equality check + * + * By default useSelector uses a strict equality check `===` to determine if the + * state has changed. This hook uses a shallow equality check to determine if the + * state has changed. + * + * @param {*} selector A redux selector + * @returns {*} The selected state + */ +const useShallowEqualSelector = selector => useSelector(selector, shallowEqual); + +export default useShallowEqualSelector; From b94db25ba9279663dc255f89d2a5b50cd48ce55b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 14:38:54 +0200 Subject: [PATCH 005/130] Add: Add a usePageFilter hook to get the applied filter of a page The new usePageFilter hook allows to get the current applied filter of a page from the redux store. --- src/web/hooks/usePageFilter.js | 147 +++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/web/hooks/usePageFilter.js diff --git a/src/web/hooks/usePageFilter.js b/src/web/hooks/usePageFilter.js new file mode 100644 index 0000000000..7ca8ef27ec --- /dev/null +++ b/src/web/hooks/usePageFilter.js @@ -0,0 +1,147 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {useEffect, useState} from 'react'; + +import {useLocation} from 'react-router-dom'; + +import {useSelector, useDispatch} from 'react-redux'; + +import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/users'; + +import Filter, { + DEFAULT_FALLBACK_FILTER, + DEFAULT_ROWS_PER_PAGE, +} from 'gmp/models/filter'; + +import {isDefined, hasValue} from 'gmp/utils/identity'; + +import getPage from 'web/store/pages/selectors'; +import {pageFilter as setPageFilter} from 'web/store/pages/actions'; +import {loadUserSettingDefault} from 'web/store/usersettings/defaults/actions'; +import {getUserSettingsDefaults} from 'web/store/usersettings/defaults/selectors'; +import {loadUserSettingsDefaultFilter} from 'web/store/usersettings/defaultfilters/actions'; +import {getUserSettingsDefaultFilter} from 'web/store/usersettings/defaultfilters/selectors'; + +import useGmp from 'web/utils/useGmp'; + +/** + * Hook to get the default filter of a page from the store + * + * @param {String} pageName + * @returns Array of the default filter and and error if the filter could not be loaded + */ +const useDefaultFilter = pageName => + useSelector(state => { + const defaultFilterSel = getUserSettingsDefaultFilter(state, pageName); + return [defaultFilterSel.getFilter(), defaultFilterSel.getError()]; + }); + +/** + * Hook to get the filter of a page + * + * @param {String} pageName Name of the page + * @param {Object} options Options object + * @returns Array of the applied filter and boolean indicating if the filter is still loading + */ +const usePageFilter = ( + pageName, + {fallbackFilter, locationQueryFilterString} = {}, +) => { + const gmp = useGmp(); + const dispatch = useDispatch(); + const location = useLocation(); + + // only use location directly if locationQueryFilterString is undefined + // use null as value for not set at all + if (!isDefined(locationQueryFilterString)) { + locationQueryFilterString = location?.query?.filter; + } + + let returnedFilter; + + const [defaultSettingFilter, defaultSettingsFilterError] = + useDefaultFilter(pageName); + + useEffect(() => { + if ( + !isDefined(defaultSettingFilter) && + !isDefined(defaultSettingsFilterError) + ) { + dispatch(loadUserSettingsDefaultFilter(gmp)(pageName)); + } + }, [ + defaultSettingFilter, + defaultSettingsFilterError, + dispatch, + gmp, + pageName, + ]); + + let [rowsPerPage, rowsPerPageError] = useSelector(state => { + const userSettingDefaultSel = getUserSettingsDefaults(state); + return [ + userSettingDefaultSel.getValueByName('rowsperpage'), + userSettingDefaultSel.getError(), + ]; + }); + + useEffect(() => { + if (!isDefined(rowsPerPage)) { + dispatch(loadUserSettingDefault(gmp)(ROWS_PER_PAGE_SETTING_ID)); + } + }, [returnedFilter, rowsPerPage, gmp, dispatch]); + + const [locationQueryFilter, setLocationQueryFilter] = useState( + hasValue(locationQueryFilterString) + ? Filter.fromString(locationQueryFilterString) + : undefined, + ); + + useEffect(() => { + if (hasValue(locationQueryFilterString)) { + dispatch( + setPageFilter(pageName, Filter.fromString(locationQueryFilterString)), + ); + } + setLocationQueryFilter(undefined); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const pageFilter = useSelector(state => getPage(state).getFilter(pageName)); + + if (hasValue(locationQueryFilter)) { + returnedFilter = locationQueryFilter; + } else if (isDefined(pageFilter)) { + returnedFilter = pageFilter; + } else if ( + isDefined(defaultSettingFilter) && + !isDefined(defaultSettingsFilterError) && + defaultSettingFilter !== null + ) { + returnedFilter = defaultSettingFilter; + } else if (isDefined(fallbackFilter)) { + returnedFilter = fallbackFilter; + } else { + returnedFilter = DEFAULT_FALLBACK_FILTER; + } + + if (!isDefined(rowsPerPage) && isDefined(rowsPerPageError)) { + rowsPerPage = DEFAULT_ROWS_PER_PAGE; + } + + if (!returnedFilter.has('rows') && isDefined(rowsPerPage)) { + returnedFilter = returnedFilter.set('rows', rowsPerPage); + } + + const finishedLoading = + isDefined(returnedFilter) && + (isDefined(defaultSettingFilter) || + isDefined(defaultSettingsFilterError)) && + isDefined(rowsPerPage); + + return [returnedFilter, !finishedLoading]; +}; + +export default usePageFilter; From fb4e6cda0105e8183920c90226cb8a3e32b48081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 14:53:10 +0200 Subject: [PATCH 006/130] Allow to change, reset and remove a page filter Update the usePageFilter hook to add additional functions to change, reset and remove a page filter. --- src/web/hooks/usePageFilter.js | 43 ++++++++++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/src/web/hooks/usePageFilter.js b/src/web/hooks/usePageFilter.js index 7ca8ef27ec..813376f3b5 100644 --- a/src/web/hooks/usePageFilter.js +++ b/src/web/hooks/usePageFilter.js @@ -3,9 +3,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useEffect, useState} from 'react'; +import {useCallback, useEffect, useState} from 'react'; -import {useLocation} from 'react-router-dom'; +import {useLocation, useHistory} from 'react-router-dom'; import {useSelector, useDispatch} from 'react-redux'; @@ -14,6 +14,7 @@ import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/users'; import Filter, { DEFAULT_FALLBACK_FILTER, DEFAULT_ROWS_PER_PAGE, + RESET_FILTER, } from 'gmp/models/filter'; import {isDefined, hasValue} from 'gmp/utils/identity'; @@ -40,11 +41,13 @@ const useDefaultFilter = pageName => }); /** - * Hook to get the filter of a page + * Hook to get and update the filter of a page * * @param {String} pageName Name of the page * @param {Object} options Options object - * @returns Array of the applied filter and boolean indicating if the filter is still loading + * @returns Array of the applied filter, boolean indicating if the filter is + * still loading, function to change the filter, function to remove the + * filter and function to reset the filter */ const usePageFilter = ( pageName, @@ -53,6 +56,7 @@ const usePageFilter = ( const gmp = useGmp(); const dispatch = useDispatch(); const location = useLocation(); + const history = useHistory(); // only use location directly if locationQueryFilterString is undefined // use null as value for not set at all @@ -141,7 +145,36 @@ const usePageFilter = ( isDefined(defaultSettingsFilterError)) && isDefined(rowsPerPage); - return [returnedFilter, !finishedLoading]; + const changeFilter = useCallback( + filter => { + dispatch(setPageFilter(pageName, filter)); + }, + [dispatch, pageName], + ); + + const removeFilter = useCallback(() => { + // remove filter from store by setting it to the default filter with first=1 only + changeFilter(RESET_FILTER); + }, [changeFilter]); + + const resetFilter = useCallback(() => { + const query = {...location.query}; + + // remove filter param from url + delete query.filter; + + history.push({pathname: location.pathname, query}); + + changeFilter(); + }, [changeFilter, history, location]); + + return [ + returnedFilter, + !finishedLoading, + changeFilter, + removeFilter, + resetFilter, + ]; }; export default usePageFilter; From b9e2cf5a3588bd7dcfa9eb6e69288d08152c6049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 15:52:54 +0200 Subject: [PATCH 007/130] Use useShallowEqualSelector in usePageFilter The selectors where invented for usage with mapStateToProps therefore they return objects at the moment. To avoid unnecessary re-renders the returned objects need to be compared with shallow equal. --- src/web/hooks/usePageFilter.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/web/hooks/usePageFilter.js b/src/web/hooks/usePageFilter.js index 813376f3b5..fca0d353a7 100644 --- a/src/web/hooks/usePageFilter.js +++ b/src/web/hooks/usePageFilter.js @@ -7,7 +7,7 @@ import {useCallback, useEffect, useState} from 'react'; import {useLocation, useHistory} from 'react-router-dom'; -import {useSelector, useDispatch} from 'react-redux'; +import {useDispatch} from 'react-redux'; import {ROWS_PER_PAGE_SETTING_ID} from 'gmp/commands/users'; @@ -28,6 +28,8 @@ import {getUserSettingsDefaultFilter} from 'web/store/usersettings/defaultfilter import useGmp from 'web/utils/useGmp'; +import useShallowEqualSelector from './useShallowEqualSelector'; + /** * Hook to get the default filter of a page from the store * @@ -35,7 +37,7 @@ import useGmp from 'web/utils/useGmp'; * @returns Array of the default filter and and error if the filter could not be loaded */ const useDefaultFilter = pageName => - useSelector(state => { + useShallowEqualSelector(state => { const defaultFilterSel = getUserSettingsDefaultFilter(state, pageName); return [defaultFilterSel.getFilter(), defaultFilterSel.getError()]; }); @@ -84,7 +86,7 @@ const usePageFilter = ( pageName, ]); - let [rowsPerPage, rowsPerPageError] = useSelector(state => { + let [rowsPerPage, rowsPerPageError] = useShallowEqualSelector(state => { const userSettingDefaultSel = getUserSettingsDefaults(state); return [ userSettingDefaultSel.getValueByName('rowsperpage'), @@ -113,7 +115,9 @@ const usePageFilter = ( setLocationQueryFilter(undefined); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const pageFilter = useSelector(state => getPage(state).getFilter(pageName)); + const pageFilter = useShallowEqualSelector(state => + getPage(state).getFilter(pageName), + ); if (hasValue(locationQueryFilter)) { returnedFilter = locationQueryFilter; From 2ee883c2b2e9fb6281ace8a430cf7b59dbb59d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Thu, 6 Jun 2024 15:54:35 +0200 Subject: [PATCH 008/130] Add tests for usePageFilter hook --- src/web/hooks/__tests__/usePageFilter.jsx | 378 ++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100644 src/web/hooks/__tests__/usePageFilter.jsx diff --git a/src/web/hooks/__tests__/usePageFilter.jsx b/src/web/hooks/__tests__/usePageFilter.jsx new file mode 100644 index 0000000000..6f12833aa5 --- /dev/null +++ b/src/web/hooks/__tests__/usePageFilter.jsx @@ -0,0 +1,378 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/* eslint-disable react/prop-types */ + +import {describe, test, expect, testing} from '@gsa/testing'; + +import Filter, {DEFAULT_FALLBACK_FILTER} from 'gmp/models/filter'; + +import {fireEvent, rendererWith, screen} from 'web/utils/testing'; + +import {loadingActions} from 'web/store/usersettings/defaults/actions'; +import {defaultFilterLoadingActions} from 'web/store/usersettings/defaultfilters/actions'; + +import usePageFilter from '../usePageFilter'; +import {pageFilter} from 'web/store/pages/actions'; + +const TestComponent = ({fallbackFilter}) => { + const [filter, isLoadingFilter, changeFilter, removeFilter, resetFilter] = + usePageFilter('somePage', {fallbackFilter}); + return ( + <> + {isLoadingFilter ? ( +
Loading...
+ ) : ( + <> +
{filter.toFilterString()}
+ - - - -
-
-
-
- - - -
- -`; - -exports[`TagsDialog dialog component tests > should render dialog 1`] = ` -.c3 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - justify-content: space-between; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c7 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - -webkit-align-items: flex-start; - -webkit-box-align: flex-start; - -ms-flex-align: flex-start; - align-items: flex-start; -} - -.c10 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; -} - -.c13 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: start; - -ms-flex-pack: start; - -webkit-justify-content: start; - justify-content: start; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c22 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c28 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; - -webkit-box-pack: end; - -ms-flex-pack: end; - -webkit-justify-content: flex-end; - justify-content: flex-end; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c16 { - margin-left: -5px; -} - -.c16>* { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; -} - -.c16>* { - margin-left: 5px; -} - -.c15 { - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; -} - -.c26 { - cursor: pointer; -} - -.c27 { - height: 16px; - width: 16px; - line-height: 16px; -} - -.c27 * { - height: inherit; - width: inherit; -} - -.c1 { - position: relative; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - margin: 10% auto; - border: 0; - outline: 0; - width: 650px; - height: px; -} - -.c0 { - position: fixed; - font-family: Trebuchet MS,Tahoma,Verdana,Arial,sans-serif; - font-size: 1.1em; - top: 0; - right: 0; - bottom: 0; - left: 0; - margin: 0; - background: rgba(102, 102, 102, 0.5); - z-index: 600; - -webkit-transition: opacity 1s ease-in; - transition: opacity 1s ease-in; - width: 100%; - height: 100%; -} - -.c35 { - width: 0; - height: 0; - cursor: nwse-resize; - border-right: 20px solid transparent; - border-bottom: 20px solid transparent; - border-top: 20px solid #fff; -} - -.c34 { - position: absolute; - bottom: 3px; - right: 3px; - width: 20px; - height: 20px; - background: repeating-linear-gradient( -45deg, transparent, transparent 2px, #000 2px, #000 3px ); -} - -.c2 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - height: inherit; - padding: 0; - background: #fff; - box-shadow: 5px 5px 10px #7F7F7F; - border-radius: 3px; - border: 1px solid #7F7F7F; -} - -.c5 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - font-weight: bold; - font-size: 12px; - font-family: Verdana,sans-serif; - color: #074320; - cursor: pointer; - border-radius: 2px; - padding: 0; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; -} - -.c5 :hover { - border: 1px solid #074320; -} - -.c6 { - height: 24px; - width: 24px; - line-height: 24px; -} - -.c6 * { - height: inherit; - width: inherit; -} - -.c31 { - display: inline-block; - padding: 0 15px; - color: #4C4C4C; - text-align: center; - vertical-align: middle; - font-size: 11px; - font-weight: bold; - line-height: 30px; - -webkit-text-decoration: none; - text-decoration: none; - white-space: nowrap; - background-color: #fff; - border-radius: 2px; - border: 1px solid #bfbfbf; - cursor: pointer; - overflow: visible; - z-index: 1; -} - -.c31:focus, -.c31:hover { - border: 1px solid #4C4C4C; -} - -.c31:hover { - -webkit-text-decoration: none; - text-decoration: none; - background: #11ab51; - font-weight: bold; - color: #fff; -} - -.c31[disabled] { - cursor: not-allowed; - opacity: 0.65; - box-shadow: none; -} - -.c31 img { - height: 32px; - width: 32px; - margin-top: 5px 10px 5px -10px; - vertical-align: middle; -} - -.c31:link { - -webkit-text-decoration: none; - text-decoration: none; - color: #4C4C4C; -} - -.c32 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; -} - -.c33 { - border: 1px solid #7F7F7F; - color: #fff; - background: #11ab51; -} - -.c33 :hover { - color: #074320; - background: #A1DDBA; -} - -.c29 { - border-width: 1px 0 0 0; - border-style: solid; - border-color: #e5e5e5; - margin-top: 15px; - padding: 10px 20px 10px 20px; -} - -.c30 { - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - justify-content: space-between; -} - -.c4 { - padding: 5px 5px 5px 10px; - margin-bottom: 15px; - border-radius: 2px 2px 0 0; - border-bottom: 1px solid #7F7F7F; - color: #fff; - font-weight: bold; - background: #11ab51; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: justify; - -webkit-justify-content: space-between; - justify-content: space-between; - -webkit-flex-shrink: 0; - -ms-flex-negative: 0; - flex-shrink: 0; - cursor: -webkit-grab; - cursor: grab; -} - -.c9 { - overflow: auto; - padding: 0 15px; - width: 100%; - height: 100%; - max-height: 400px; -} - -.c8 { - overflow: hidden; - height: 100%; -} - -.c11 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: start; - -ms-flex-pack: start; - -webkit-justify-content: start; - justify-content: start; - padding-bottom: 10px; -} - -.c12 { - display: inline-block; - max-width: 100%; - font-weight: bold; - text-align: right; - padding-left: 10px; - padding-right: 10px; - width: 33.33333333%; - margin-left: 0; -} - -.c14 { - width: 66.66666667%; - padding-left: 10px; - padding-right: 10px; -} - -.c23 { - background-color: transparent; - border: none; - cursor: pointer; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - justify-content: center; - outline: none; - margin: 1px; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - cursor: pointer; -} - -.c24 { - height: 16px; - width: 16px; - line-height: 16px; -} - -.c24 * { - height: inherit; - width: inherit; -} - -.c19 { - border: 1px solid #bfbfbf; - border-radius: 2px; - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-align-items: stretch; - -webkit-box-align: stretch; - -ms-flex-align: stretch; - align-items: stretch; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - padding: 1px 5px; - background-color: #fff; - color: #000; - font-weight: normal; -} - -.c18 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; - position: relative; - width: 230px; -} - -.c20 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; - -webkit-align-items: center; - -webkit-box-align: center; - -ms-flex-align: center; - align-items: center; - -webkit-box-flex: 1; - -webkit-flex-grow: 1; - -ms-flex-positive: 1; - flex-grow: 1; - word-break: keep-all; - white-space: nowrap; - overflow: hidden; - cursor: pointer; -} - -.c25 { - color: #c12c30; - font-weight: bold; - font-size: 19px; - padding-bottom: 1px; - padding-left: 4px; - display: none; -} - -.c21 { - cursor: default; -} - -.c17 { - display: -webkit-box; - display: -webkit-flex; - display: -ms-flexbox; - display: flex; -} - -@media print { - .c26 { - display: none; - } -} - - -
-
-
-