diff --git a/app/models/reader_preferences.rb b/app/models/reader_preferences.rb new file mode 100644 index 00000000000..82df50ddfd2 --- /dev/null +++ b/app/models/reader_preferences.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true +class ReaderPreferences + + def self.get(preference) + preference_name = preference.to_s.upcase + value = client.get(preference_name) || ENV[preference_name] + + if value + return value.to_i + end + + "#{preference_name} is not in the list of allowed preferences.. #{valid_preferences}" + end + + def self.set(preference, new_value) + preference_name = preference.to_s.upcase + + current_value = client.get(preference_name) || ENV[preference_name] + + if current_value + client.set(preference_name, new_value.to_s) + "#{preference_name} set to #{new_value}" + else + "#{preference_name} is not in the list of allowed preferences.. #{valid_preferences}" + end + end + + def self.delete(preference) + preference_name = preference.to_s.upcase + + current_value = client.get(preference_name) + + if current_value + client.del preference_name + + "#{preference_name} was reset to default value #{ENV[preference_name]}" + else + "#{preference_name} is not set to a custom value" + end + end + + def self.client + # Use separate Redis namespace for test to avoid conflicts between test and dev environments + @cache_namespace ||= Rails.env.test? ? :reader_preferences_test : :reader_preferences + @client ||= Redis::Namespace.new(@cache_namespace, redis: redis) + end + + def self.redis + @redis ||= Redis.new(url: Rails.application.secrets.redis_url_cache) + end + + # Default values are stored as ENV enviroment dependent values in the file + # appeals-deployment/ansible/roles/caseflow-certification/defaults/main.yml + # Example: + # http_env_specific_environment: + # prod: + # READER_DELAY_BEFORE_PROGRESS_BAR: 1000 + # READER_SHOW_PROGRESS_BAR_THRESHOLD: 3000 + + # prodtest: + # READER_DELAY_BEFORE_PROGRESS_BAR: 1000 + # READER_SHOW_PROGRESS_BAR_THRESHOLD: 3000 + + # preprod: + # READER_DELAY_BEFORE_PROGRESS_BAR: 1000 + # READER_SHOW_PROGRESS_BAR_THRESHOLD: 3000 + + # uat: + # READER_DELAY_BEFORE_PROGRESS_BAR: 1000 + # READER_SHOW_PROGRESS_BAR_THRESHOLD: 3000 + + def self.bandwidth_warning_enabled? + max_file_wait_time = ENV.fetch("MAX_FILE_WAIT_TIME", nil) + max_file_wait_time.present? && max_file_wait_time.to_i > 0 + end + + def self.valid_preferences + @valid_preferences ||= ENV.select { |feature, value| feature.include?("READER") }.keys + end +end diff --git a/app/views/reader/appeal/index.html.erb b/app/views/reader/appeal/index.html.erb index e413c343c03..0f3a2c60c56 100644 --- a/app/views/reader/appeal/index.html.erb +++ b/app/views/reader/appeal/index.html.erb @@ -12,6 +12,7 @@ featureToggles: { interfaceVersion2: FeatureToggle.enabled?(:interface_version_2, user: current_user), bandwidthBanner: FeatureToggle.enabled?(:bandwidth_banner, user: current_user), + warningIconAndBanner: FeatureToggle.enabled?(:warning_icon_and_banner, user: current_user), windowSlider: FeatureToggle.enabled?(:window_slider, user: current_user), readerSelectorsMemoized: FeatureToggle.enabled?(:bulk_upload_documents, user: current_user), readerGetDocumentLogging: FeatureToggle.enabled?(:reader_get_document_logging, user: current_user), @@ -25,7 +26,13 @@ pdfPageRenderTimeInMs: FeatureToggle.enabled?(:pdf_page_render_time_in_ms, user: current_user), prefetchDisabled: FeatureToggle.enabled?(:prefetch_disabled, user: current_user), readerSearchImprovements: FeatureToggle.enabled?(:reader_search_improvements, user: current_user), - readerPrototype: FeatureToggle.enabled?(:reader_prototype, user: current_user) + readerPrototype: FeatureToggle.enabled?(:reader_prototype, user: current_user), + readerProgressBar: FeatureToggle.enabled?(:reader_progress_bar, user: current_user), + }, + readerPreferences: { + delayBeforeProgressBar: ReaderPreferences.get(:delay_before_progress_bar), + showProgressBarThreshold: ReaderPreferences.get(:show_progress_bar_threshold), + maximumFileWaitTime: ReaderPreferences.get(:maximum_file_wait_time), }, buildDate: build_date }) %> diff --git a/client/app/components/common/actions.js b/client/app/components/common/actions.js index d0c1f673cfd..8803a2b5a1e 100644 --- a/client/app/components/common/actions.js +++ b/client/app/components/common/actions.js @@ -121,6 +121,13 @@ export const setScheduledHearing = (payload) => ({ payload, }); +export const setFeatureToggles = (toggles) => ({ + type: ACTIONS.SET_FEATURE_TOGGLES, + payload: { + toggles + }, +}); + export const fetchScheduledHearings = (hearingDay) => (dispatch) => { // Dispatch the action to set the pending state for the hearing time dispatch({ type: ACTIONS.REQUEST_SCHEDULED_HEARINGS }); diff --git a/client/app/components/common/reducers.js b/client/app/components/common/reducers.js index 5337604207a..4e64d56284e 100644 --- a/client/app/components/common/reducers.js +++ b/client/app/components/common/reducers.js @@ -4,6 +4,7 @@ import { update } from '../../util/ReducerUtil'; export const initialState = { scheduledHearingsList: [], fetchingHearings: false, + isWarningIconAndBannerEnabled: false, dropdowns: { judges: {}, hearingCoordinators: {}, @@ -188,4 +189,15 @@ const commonComponentsReducer = (state = initialState, action = {}) => { } }; +const featureTogglesReducer = (state = initialState, action) => { + switch (action.type) { + case ACTIONS.SET_FEATURE_TOGGLES: + return { + ...state, + ...action.payload, + }; + default: + return state; + } +}; export default commonComponentsReducer; diff --git a/client/app/components/icons/SizeWarningIcon.jsx b/client/app/components/icons/SizeWarningIcon.jsx new file mode 100644 index 00000000000..fa2d9ba87a0 --- /dev/null +++ b/client/app/components/icons/SizeWarningIcon.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { ICON_SIZES } from '../../constants/AppConstants'; + +export const SizeWarningIcon = (props) => { + const { size, className, isWarningIconAndBannerEnabled } = props; + + if (!isWarningIconAndBannerEnabled) { + return null; + } + + return ( + + + ); +}; + + +SizeWarningIcon.propTypes = { + + /** + Sets height of the component, width is set automatically by the svg viewbox property. + Default height is 'ICON_SIZES.MEDIUM'. + */ + size: PropTypes.number, + + /** + Adds class to the component. Default value is ''. + */ + className: PropTypes.string, + + /** + Enables or disables the Size Warning Icon based on the feature toggle. + */ + isWarningIconAndBannerEnabled: PropTypes.bool.isRequired +}; + +SizeWarningIcon.defaultProps = { + size: ICON_SIZES.MEDIUM, + className: '' +}; diff --git a/client/app/reader/BandwidthAlert.jsx b/client/app/reader/BandwidthAlert.jsx index 67939c00640..4f45c226950 100644 --- a/client/app/reader/BandwidthAlert.jsx +++ b/client/app/reader/BandwidthAlert.jsx @@ -3,6 +3,7 @@ import Alert from '../components/Alert'; import { css } from 'glamor'; import { storeMetrics } from '../util/Metrics'; import uuid from 'uuid'; +import PropTypes from 'prop-types'; // variables being defined are in mbps const bandwidthThreshold = 1.5; @@ -20,7 +21,7 @@ class BandwidthAlert extends React.Component { } componentDidMount() { - if ('connection' in navigator) { + if (this.props.isWarningIconAndBannerEnabled && 'connection' in navigator) { this.updateConnectionInfo(); } } @@ -42,6 +43,11 @@ class BandwidthAlert extends React.Component { }; render() { + const { isWarningIconAndBannerEnabled } = this.props; + + if (!isWarningIconAndBannerEnabled) { + return null; + } if (this.state.displayBandwidthAlert) { return ( @@ -59,4 +65,8 @@ class BandwidthAlert extends React.Component { } } +BandwidthAlert.propTypes = { + isWarningIconAndBannerEnabled: PropTypes.bool.isRequired +}; + export default BandwidthAlert; diff --git a/client/app/reader/PdfListView.jsx b/client/app/reader/PdfListView.jsx index 4dbe9d3a6cd..da88b3d2470 100644 --- a/client/app/reader/PdfListView.jsx +++ b/client/app/reader/PdfListView.jsx @@ -18,108 +18,81 @@ import NoSearchResults from './NoSearchResults'; import { fetchAppealDetails, onReceiveAppealDetails } from '../reader/PdfViewer/PdfViewerActions'; import { shouldFetchAppeal } from '../reader/utils'; import { DOCUMENTS_OR_COMMENTS_ENUM } from './DocumentList/actionTypes'; +import { SizeWarningIcon } from '../components/icons/SizeWarningIcon'; export class PdfListView extends React.Component { - setClearAllFiltersCallbacks = (callbacks) => { - this.setState({ clearAllFiltersCallbacks: [...this.state.clearAllFiltersCallbacks, ...callbacks] }); - }; - - constructor() { - super(); + constructor(props) { + super(props); this.state = { - clearAllFiltersCallbacks: [] + clearAllFiltersCallbacks: [], }; } + setClearAllFiltersCallbacks = (callbacks) => { + this.setState((prevState) => ({ + clearAllFiltersCallbacks: [...prevState.clearAllFiltersCallbacks, ...callbacks], + })); + }; + componentDidMount() { - if (shouldFetchAppeal(this.props.appeal, this.props.match.params.vacolsId)) { - // if the appeal is fetched through case selected appeals, re-use that existing appeal - // information. - if (this.props.caseSelectedAppeal && - (this.props.caseSelectedAppeal.vacols_id === this.props.match.params.vacolsId)) { - this.props.onReceiveAppealDetails(this.props.caseSelectedAppeal); + const { appeal, match, caseSelectedAppeal, fetchAppealDetails, onReceiveAppealDetails } = this.props; + + if (shouldFetchAppeal(appeal, match.params.vacolsId)) { + if (caseSelectedAppeal && caseSelectedAppeal.vacols_id === match.params.vacolsId) { + onReceiveAppealDetails(caseSelectedAppeal); } else { - this.props.fetchAppealDetails(this.props.match.params.vacolsId); + fetchAppealDetails(match.params.vacolsId); } - - // if appeal is loaded from the assignments and it matches the vacols id - // in the url - } else if (this.props.appeal.vacols_id === this.props.match.params.vacolsId) { - this.props.onReceiveAppealDetails(this.props.appeal); + } else if (appeal?.vacols_id === match.params.vacolsId) { + onReceiveAppealDetails(appeal); } } render() { - const noDocuments = !_.size(this.props.documents) && _.size(this.props.docFilterCriteria.searchQuery) > 0; - let tableView; + const { documents, docFilterCriteria, viewingDocumentsOrComments, featureToggles } = this.props; + const noDocuments = !_.size(documents) && _.size(docFilterCriteria?.searchQuery) > 0; + let tableView; if (noDocuments) { tableView = ; - } else if (this.props.viewingDocumentsOrComments === DOCUMENTS_OR_COMMENTS_ENUM.COMMENTS) { - tableView = ; + } else if (viewingDocumentsOrComments === DOCUMENTS_OR_COMMENTS_ENUM.COMMENTS) { + tableView = ; } else { - tableView = ; + tableView = ; } - return
- {this.props.queueRedirectUrl && } - - -
- - {this.props.featureToggles.bandwidthBanner && } - + {this.props.queueRedirectUrl && ( + - {tableView} -
-
- -
; + )} + {featureToggles?.isWarningIconAndBannerEnabled && ( + + )} + + +
+ + {featureToggles?.bandwidthBanner && } + + {tableView} +
+
+ + + ); } } -const mapStateToProps = (state, props) => { - return { - documents: getFilteredDocuments(state), - ..._.pick(state.documentList, 'docFilterCriteria', 'viewingDocumentsOrComments'), - appeal: _.find(state.caseSelect.assignments, { vacols_id: props.match.params.vacolsId }) || - state.pdfViewer.loadedAppeal, - caseSelectedAppeal: state.caseSelect.selectedAppeal, - manifestVbmsFetchedAt: state.documentList.manifestVbmsFetchedAt, - queueRedirectUrl: state.documentList.queueRedirectUrl, - queueTaskType: state.documentList.queueTaskType - }; -}; - -const mapDispatchToProps = (dispatch) => ( - bindActionCreators({ - onReceiveAppealDetails, - fetchAppealDetails - }, dispatch) -); - PdfListView.propTypes = { documents: PropTypes.arrayOf(PropTypes.object).isRequired, onJumpToComment: PropTypes.func, @@ -127,21 +100,36 @@ PdfListView.propTypes = { appeal: PropTypes.object, efolderExpressUrl: PropTypes.string, userHasEfolderRole: PropTypes.bool, - readerSearchImprovements: PropTypes.bool, featureToggles: PropTypes.object, - match: PropTypes.object, + match: PropTypes.object.isRequired, caseSelectedAppeal: PropTypes.object, - onReceiveAppealDetails: PropTypes.func, - fetchAppealDetails: PropTypes.func, + onReceiveAppealDetails: PropTypes.func.isRequired, + fetchAppealDetails: PropTypes.func.isRequired, docFilterCriteria: PropTypes.object, - viewingDocumentsOrComments: PropTypes.string, + viewingDocumentsOrComments: PropTypes.oneOf(Object.values(DOCUMENTS_OR_COMMENTS_ENUM)), documentPathBase: PropTypes.string, showPdf: PropTypes.func, queueRedirectUrl: PropTypes.string, - queueTaskType: PropTypes.node + queueTaskType: PropTypes.node, + readerPreferences: PropTypes.object, }; -export default connect( - mapStateToProps, mapDispatchToProps -)(PdfListView); +const mapStateToProps = (state, props) => ({ + documents: getFilteredDocuments(state), + ..._.pick(state.documentList, 'docFilterCriteria', 'viewingDocumentsOrComments'), + appeal: _.find(state.caseSelect.assignments, { vacols_id: props.match.params.vacolsId }) || state.pdfViewer.loadedAppeal, + caseSelectedAppeal: state.caseSelect.selectedAppeal, + queueRedirectUrl: state.documentList.queueRedirectUrl, + queueTaskType: state.documentList.queueTaskType, +}); + +const mapDispatchToProps = (dispatch) => + bindActionCreators( + { + onReceiveAppealDetails, + fetchAppealDetails, + }, + dispatch + ); +export default connect(mapStateToProps, mapDispatchToProps)(PdfListView); diff --git a/client/test/app/reader/SizeWarningIcon-test.js b/client/test/app/reader/SizeWarningIcon-test.js new file mode 100644 index 00000000000..4e64d56284e --- /dev/null +++ b/client/test/app/reader/SizeWarningIcon-test.js @@ -0,0 +1,203 @@ +import { ACTIONS } from './actionTypes'; +import { update } from '../../util/ReducerUtil'; + +export const initialState = { + scheduledHearingsList: [], + fetchingHearings: false, + isWarningIconAndBannerEnabled: false, + dropdowns: { + judges: {}, + hearingCoordinators: {}, + regionalOffices: {} + }, + forms: {}, + alerts: [], + transitioningAlerts: {}, + // Used to handle communication between Case Details/ScheduleVeteran + scheduledHearing: { + taskId: null, + action: null, + disposition: null, + externalId: null, + polling: false, + notes: null + } +}; + +const dropdownsReducer = (state = {}, action = {}) => { + switch (action.type) { + case ACTIONS.FETCH_DROPDOWN_DATA: + return update(state, { + [action.payload.dropdownName]: { + $set: { + options: null, + isFetching: true, + errorMsg: null + } + } + }); + case ACTIONS.RECEIVE_DROPDOWN_DATA: + return update(state, { + [action.payload.dropdownName]: { + $set: { + options: action.payload.data, + isFetching: false, + errorMsg: null + } + } + }); + case ACTIONS.DROPDOWN_ERROR: + return update(state, { + [action.payload.dropdownName]: { + errorMsg: { + $set: action.payload.errorMsg + } + } + }); + default: + return state; + } +}; + +const formsReducer = (state = {}, action = {}) => { + const formState = state[action.payload.formName] || {}; + + switch (action.type) { + case ACTIONS.CHANGE_FORM_DATA: + return update(state, { + [action.payload.formName]: { + $set: action.payload.formData === null ? + {} : { + ...formState, + ...action.payload.formData + } + } + }); + default: + return state; + } +}; + +const commonComponentsReducer = (state = initialState, action = {}) => { + switch (action.type) { + case ACTIONS.RECEIVE_ALERTS: + return update(state, { + alerts: { + $set: [ + ...state.alerts, + ...action.payload.alerts + ] + } + }); + case ACTIONS.RECEIVE_TRANSITIONING_ALERT: + return update(state, { + alerts: { + $set: [ + ...state.alerts, + action.payload.alert + ] + }, + transitioningAlerts: { + $set: { + ...state.transitioningAlerts, + [action.payload.key]: action.payload.alert + } + } + }); + case ACTIONS.TRANSITION_ALERT: + return update(state, { + alerts: { + $set: [ + ...state.alerts.filter((alert) => alert !== state.transitioningAlerts[action.payload.key]), + state.transitioningAlerts[action.payload.key].next + ] + }, + transitioningAlerts: { + $set: { + ...state.transitioningAlerts, + ...state.transitioningAlerts[action.payload.key] = state.transitioningAlerts[action.payload.key].next + } + } + }); + case ACTIONS.REMOVE_ALERTS_WITH_EXPIRATION: + return update(state, { + alerts: { + $set: state.alerts.filter((alert) => action.payload.timestamps.indexOf(alert.timestamp) === -1) + } + }); + case ACTIONS.CLEAR_ALERTS: + return update(state, { + alerts: { + $set: [] + } + }); + case ACTIONS.RECEIVE_REGIONAL_OFFICES: + return update(state, { + regionalOffices: { + $set: action.payload.regionalOffices + } + }); + case ACTIONS.REGIONAL_OFFICE_CHANGE: + return update(state, { + selectedRegionalOffice: { + $set: action.payload.regionalOffice + } + }); + case ACTIONS.RECEIVE_HEARING_DAYS: + return update(state, { + hearingDays: { + $set: action.payload.hearingDays + } + }); + case ACTIONS.FETCH_DROPDOWN_DATA: + case ACTIONS.RECEIVE_DROPDOWN_DATA: + case ACTIONS.DROPDOWN_ERROR: + return update(state, { + dropdowns: { + $set: dropdownsReducer(state.dropdowns, action) + } + }); + case ACTIONS.CHANGE_FORM_DATA: + return update(state, { + forms: { + $set: formsReducer(state.forms, action) + } + }); + case ACTIONS.REQUEST_SCHEDULED_HEARINGS: + return { + ...state, + fetchingHearings: true + }; + case ACTIONS.SET_SCHEDULED_HEARINGS: + return { + ...state, + fetchingHearings: false, + scheduledHearingsList: action.payload + }; + case ACTIONS.SET_SCHEDULE_HEARING_PAYLOAD: + case ACTIONS.STOP_POLLING: + case ACTIONS.START_POLLING: + return { + ...state, + scheduledHearing: { + ...state.scheduledHearing, + ...action.payload + } + }; + default: + return state; + } +}; + +const featureTogglesReducer = (state = initialState, action) => { + switch (action.type) { + case ACTIONS.SET_FEATURE_TOGGLES: + return { + ...state, + ...action.payload, + }; + default: + return state; + } +}; +export default commonComponentsReducer;