diff --git a/met-api/src/met_api/models/survey.py b/met-api/src/met_api/models/survey.py index 72cdef593..a54a8a43f 100644 --- a/met-api/src/met_api/models/survey.py +++ b/met-api/src/met_api/models/survey.py @@ -84,17 +84,18 @@ def filter_by_search_options(cls, survey_search_options: SurveySearchOptions, qu if survey_search_options.exclude_template: query = query.filter(Survey.is_template.is_(False)) - if survey_search_options.is_unlinked: - query = query.filter(Survey.engagement_id.is_(None)) - + # filter by status + status_filters = [] if survey_search_options.is_linked: - query = query.filter(Survey.engagement_id.isnot(None)) - - if survey_search_options.is_hidden: - query = query.filter(Survey.is_hidden.is_(True)) - + status_filters.append(Survey.engagement_id.isnot(None)) + if survey_search_options.is_unlinked: + status_filters.append(Survey.engagement_id.is_(None)) if survey_search_options.is_template: - query = query.filter(Survey.is_template.is_(True)) + status_filters.append(Survey.is_template.is_(True)) + if survey_search_options.is_hidden: + status_filters.append(Survey.is_hidden.is_(True)) + if status_filters: + query = query.filter(or_(*status_filters)) query = cls._filter_by_created_date(query, survey_search_options) diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 2d6c78c13..867ba929b 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -81,5 +81,5 @@ API.add_namespace(WIDGET_EVENTS_API, path='/widgets//events') API.add_namespace(WIDGET_MAPS_API, path='/widgets//maps') API.add_namespace(ENGAGEMENT_SLUG_API, path='/slugs') -API.add_namespace(REPORT_SETTING_API) +API.add_namespace(REPORT_SETTING_API, path='/surveys//reportsettings') API.add_namespace(WIDGET_VIDEO_API, path='/widgets//videos') diff --git a/met-api/src/met_api/resources/report_setting.py b/met-api/src/met_api/resources/report_setting.py index adc750a3f..730dc452e 100644 --- a/met-api/src/met_api/resources/report_setting.py +++ b/met-api/src/met_api/resources/report_setting.py @@ -26,29 +26,30 @@ from met_api.utils.util import allowedorigins, cors_preflight -API = Namespace('reportsetting', description='Endpoints for report setting management') +API = Namespace('reportsettings', description='Endpoints for report setting management') """Custom exception messages """ -@cors_preflight('POST, OPTIONS, PATCH') -@API.route('/') +@cors_preflight('GET, POST, PATCH, OPTIONS') +@API.route('') class ReportSetting(Resource): """Resource for managing report setting.""" @staticmethod - # @TRACER.trace() @cross_origin(origins=allowedorigins()) - @_jwt.requires_auth - def post(): - """Refresh the report setting to match the questions on survey.""" + @auth.optional + def get(survey_id): + """Fetch report setting for the survey id provided.""" try: - requestjson = request.get_json() - report_setting = ReportSettingService().refresh_report_setting(requestjson) + report_setting = ReportSettingService().get_report_setting(survey_id) - return jsonify(report_setting), HTTPStatus.OK - except KeyError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + if report_setting: + return jsonify(report_setting), HTTPStatus.OK + + return 'Report setting was not found', HTTPStatus.NOT_FOUND + except KeyError: + return 'Report setting was not found', HTTPStatus.INTERNAL_SERVER_ERROR except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR @@ -56,11 +57,11 @@ def post(): # @TRACER.trace() @cross_origin(origins=allowedorigins()) @_jwt.requires_auth - def patch(): + def patch(survey_id): """Update saved report setting partially.""" try: - requestjson = request.get_json() - report_setting = ReportSettingService().update_report_setting(requestjson) + new_report_settings = request.get_json() + report_setting = ReportSettingService().update_report_setting(survey_id, new_report_settings) return jsonify(report_setting), HTTPStatus.OK except KeyError as err: @@ -69,26 +70,3 @@ def patch(): return str(err), HTTPStatus.INTERNAL_SERVER_ERROR except ValidationError as err: return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR - - -@cors_preflight('GET, OPTIONS') -@API.route('/') -class ReportSettings(Resource): - """Resource for managing a report setting.""" - - @staticmethod - @cross_origin(origins=allowedorigins()) - @auth.optional - def get(survey_id): - """Fetch report setting for the survey id provided.""" - try: - report_setting = ReportSettingService().get_report_setting(survey_id) - - if report_setting: - return jsonify(report_setting), HTTPStatus.OK - - return 'Report setting was not found', HTTPStatus.NOT_FOUND - except KeyError: - return 'Report setting was not found', HTTPStatus.INTERNAL_SERVER_ERROR - except ValueError as err: - return str(err), HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/src/met_api/services/report_setting_service.py b/met-api/src/met_api/services/report_setting_service.py index 2810ca46b..dd058a23a 100644 --- a/met-api/src/met_api/services/report_setting_service.py +++ b/met-api/src/met_api/services/report_setting_service.py @@ -1,6 +1,7 @@ """Service for report setting management.""" from met_api.models.report_setting import ReportSetting as ReportSettingModel +from met_api.models.survey import Survey as SurveyModel from met_api.schemas.report_setting import ReportSettingSchema from met_api.constants.report_setting_type import FormIoComponentType @@ -136,15 +137,20 @@ def _delete_questions_removed_from_form(cls, survey_id, survey_question_keys): ReportSettingModel.delete_report_settings(survey_id, report_setting['question_key']) @classmethod - def update_report_setting(cls, report_setting_data): + def update_report_setting(cls, survey_id, new_report_settings): """Update report setting.""" - for data in report_setting_data: - report_setting_id = data.get('id', None) + survey = SurveyModel.find_by_id(survey_id) + if not survey: + raise KeyError(f'No survey found for {survey_id}') + for setting in new_report_settings: + report_setting_id = setting.get('id', None) report_setting = ReportSettingModel.find_by_id(report_setting_id) if not report_setting: raise ValueError(f'No report setting found for {report_setting_id}') + if report_setting.survey_id != survey_id: + raise KeyError(f'Report setting {report_setting.id} does not belong to survey {survey_id}') - report_setting.display = data.get('display', None) + report_setting.display = setting.get('display', None) report_setting.save() - return report_setting_data + return new_report_settings diff --git a/met-api/src/met_api/services/survey_service.py b/met-api/src/met_api/services/survey_service.py index faf29bd4b..a3a1e6dae 100644 --- a/met-api/src/met_api/services/survey_service.py +++ b/met-api/src/met_api/services/survey_service.py @@ -13,6 +13,7 @@ from met_api.schemas.survey import SurveySchema from met_api.services import authorization from met_api.services.membership_service import MembershipService +from met_api.services.report_setting_service import ReportSettingService from met_api.services.object_storage_service import ObjectStorageService from met_api.utils.roles import Role from met_api.utils.token_info import TokenInfo @@ -151,7 +152,14 @@ def update(cls, data: SurveySchema): if engagement and engagement.get('status_id', None) != Status.Draft.value: raise ValueError('Engagement already published') - return SurveyModel.update_survey(data) + + updated_survey = SurveyModel.update_survey(data) + ReportSettingService.refresh_report_setting({ + 'id': updated_survey.id, + 'form_json': updated_survey.form_json, + }) + + return updated_survey @staticmethod def validate_update_fields(data): diff --git a/met-api/tests/unit/api/test_report_setting.py b/met-api/tests/unit/api/test_report_setting.py index fee922718..00c655a17 100644 --- a/met-api/tests/unit/api/test_report_setting.py +++ b/met-api/tests/unit/api/test_report_setting.py @@ -16,41 +16,25 @@ Test-Suite to ensure that the Report setting endpoint is working as expected. """ -import json - from met_api.utils.enums import ContentType -from tests.utilities.factory_scenarios import TestJwtClaims, TestSurveyInfo -from tests.utilities.factory_utils import factory_auth_header, factory_survey_and_eng_model - - -def test_create_report_setting(client, jwt, session): # pylint:disable=unused-argument - """Assert that an report setting can be POSTed.""" - headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) - survey, eng = factory_survey_and_eng_model(TestSurveyInfo.survey3) - data = { - 'id': survey.id, - 'form_json': survey.form_json, - } - rv = client.post('/api/reportsetting/', data=json.dumps(data), - headers=headers, content_type=ContentType.JSON.value) - assert rv.status_code == 200 +from tests.utilities.factory_scenarios import TestJwtClaims, TestReportSettingInfo, TestSurveyInfo +from tests.utilities.factory_utils import ( + factory_auth_header, factory_survey_and_eng_model, factory_survey_report_setting_model) def test_get_report_setting(client, jwt, session): # pylint:disable=unused-argument """Assert that report setting can be fetched.""" headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.staff_admin_role) - survey, eng = factory_survey_and_eng_model(TestSurveyInfo.survey3) + survey, _ = factory_survey_and_eng_model(TestSurveyInfo.survey3) - data = { - 'id': survey.id, - 'form_json': survey.form_json, + report_setting_data = { + **TestReportSettingInfo.report_setting_1, + 'survey_id': survey.id, } - rv = client.post('/api/reportsetting/', data=json.dumps(data), - headers=headers, content_type=ContentType.JSON.value) - assert rv.status_code == 200 + factory_survey_report_setting_model(report_setting_data) rv = client.get( - f'/api/reportsetting/{survey.id}', + f'/api/surveys/{survey.id}/reportsettings', headers=headers, content_type=ContentType.JSON.value ) diff --git a/met-api/tests/unit/services/test_report_settings.py b/met-api/tests/unit/services/test_report_settings.py new file mode 100644 index 000000000..249a73800 --- /dev/null +++ b/met-api/tests/unit/services/test_report_settings.py @@ -0,0 +1,35 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the report settings service. + +Test suite to ensure that the Survey report settings service routines are working as expected. +""" +from met_api.services.report_setting_service import ReportSettingService +from tests.utilities.factory_scenarios import TestSurveyInfo +from tests.utilities.factory_utils import factory_survey_and_eng_model + + +def test_refresh_report_setting(session): # pylint:disable=unused-argument + """Assert report settings are refreshed.""" + survey, _ = factory_survey_and_eng_model(TestSurveyInfo.survey3) + survey_data = { + 'id': survey.id, + 'form_json': survey.form_json, + } + result = ReportSettingService.refresh_report_setting(survey_data) + assert result == survey_data + + report_settings = ReportSettingService.get_report_setting(survey.id) + assert len(report_settings) == 1 + assert report_settings[0].get('survey_id') == survey.id diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index db7024f4e..2498a5d5a 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -28,6 +28,7 @@ from met_api.constants.widget import WidgetType from met_api.utils.enums import LoginSource + fake = Faker() CONFIG = get_named_config('testing') @@ -526,3 +527,21 @@ class TestEngagementSlugInfo(dict, Enum): 'slug': fake.text(max_nb_chars=20), 'created_date': datetime.now().strftime('%Y-%m-%d'), } + + +class TestReportSettingInfo(dict, Enum): + """Test scenarios of feedback.""" + + report_setting_1 = { + 'created_by': str(fake.pyint()), + 'created_date': datetime.now().strftime('%Y-%m-%d'), + 'display': True, + 'id': 1, + 'question': 'What is your opinion about this?', + 'question_id': str(fake.pyint()), + 'question_key': 'simpletextarea', + 'question_type': 'simpletextarea', + 'survey_id': 1, + 'updated_by': str(fake.pyint()), + 'updated_date': datetime.now().strftime('%Y-%m-%d') + } diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index 0bf322b08..cd05dd7b1 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -29,6 +29,7 @@ from met_api.models.feedback import Feedback as FeedbackModel from met_api.models.membership import Membership as MembershipModel from met_api.models.participant import Participant as ParticipantModel +from met_api.models.report_setting import ReportSetting as ReportSettingModel from met_api.models.staff_user import StaffUser as StaffUserModel from met_api.models.submission import Submission as SubmissionModel from met_api.models.subscription import Subscription as SubscriptionModel @@ -40,8 +41,8 @@ from met_api.utils.enums import MembershipStatus from tests.utilities.factory_scenarios import ( TestCommentInfo, TestEngagementInfo, TestEngagementSlugInfo, TestFeedbackInfo, TestParticipantInfo, - TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestUserInfo, TestWidgetDocumentInfo, TestWidgetInfo, - TestWidgetItemInfo) + TestReportSettingInfo, TestSubmissionInfo, TestSurveyInfo, TestTenantInfo, TestUserInfo, TestWidgetDocumentInfo, + TestWidgetInfo, TestWidgetItemInfo) CONFIG = get_named_config('testing') @@ -301,3 +302,17 @@ def factory_engagement_slug_model(eng_slug_info: dict = TestEngagementSlugInfo.s ) slug.save() return slug + + +def factory_survey_report_setting_model(report_setting_info: dict = TestReportSettingInfo.report_setting_1): + """Produce a engagement model.""" + setting = ReportSettingModel( + survey_id=report_setting_info.get('survey_id'), + question_id=report_setting_info.get('question_id'), + question_key=report_setting_info.get('question_key'), + question_type=report_setting_info.get('question_type'), + question=report_setting_info.get('question'), + display=report_setting_info.get('display'), + ) + setting.save() + return setting diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 8a5131685..dbcd2f543 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -41,6 +41,11 @@ const Endpoints = { GET_LIST: `${AppConfig.apiUrl}/submissions/survey/survey_id`, GET: `${AppConfig.apiUrl}/submissions/submission_id`, }, + SurveyReportSetting: { + GET_LIST: `${AppConfig.apiUrl}/surveys/survey_id/reportsettings`, + UPDATE: `${AppConfig.apiUrl}/surveys/survey_id/reportsettings`, + CREATE: `${AppConfig.apiUrl}/surveys/survey_id/reportsettings`, + }, Subscription: { GET: `${AppConfig.apiUrl}/subscription/participant_id`, CREATE: `${AppConfig.apiUrl}/subscription/`, diff --git a/met-web/src/components/common/Table/ClientSidePagination.tsx b/met-web/src/components/common/Table/ClientSidePagination.tsx new file mode 100644 index 000000000..18b6d9894 --- /dev/null +++ b/met-web/src/components/common/Table/ClientSidePagination.tsx @@ -0,0 +1,65 @@ +import React, { useEffect } from 'react'; +import { MetTableProps } from '.'; +import { PaginationOptions } from './types'; + +interface ClientSidePaginationProps { + rows: T[]; + searchFilter?: { + key: keyof T; + value: string; + }; + children: (props: MetTableProps) => React.ReactElement>; +} + +export function ClientSidePagination({ rows, searchFilter, children }: ClientSidePaginationProps) { + const [paginationOptions, setPaginationOptions] = React.useState>({ + page: 1, + size: 10, // default page size + }); + const [pageInfo, setPageInfo] = React.useState({ total: rows.length }); + const [searchFilteredRows, setSearchFilteredRows] = React.useState([]); + const [paginatedRows, setPaginatedRows] = React.useState([]); + + const handleChangePagination = (newPaginationOptions: PaginationOptions) => { + const { page, size } = newPaginationOptions; + const paginated = searchFilteredRows.slice((page - 1) * size, page * size); + setPaginationOptions(newPaginationOptions); + setPageInfo({ total: rows.length }); + setPaginatedRows(paginated); + }; + + const handleFilter = () => { + if (!searchFilter?.value) { + setSearchFilteredRows(rows); + setPageInfo({ total: rows.length }); + setPaginationOptions({ ...paginationOptions, page: 1 }); + return; + } + const filtered = rows.filter((row) => + String(row[searchFilter.key]).toLowerCase().includes(searchFilter.value.toLowerCase()), + ); + const newTotal = filtered.length; + setSearchFilteredRows(filtered); + setPageInfo({ total: newTotal }); + setPaginationOptions({ ...paginationOptions, page: 1 }); + }; + + useEffect(() => { + handleFilter(); + }, [searchFilter, rows]); + + useEffect(() => { + handleChangePagination(paginationOptions); + }, [searchFilteredRows]); + + const metTableComponent = children({ + rows: paginatedRows, // Use the filtered rows for rendering + loading: false, + handleChangePagination, + paginationOptions, + pageInfo, + headCells: [], + }); + + return <>{metTableComponent}; +} diff --git a/met-web/src/components/common/Table/index.tsx b/met-web/src/components/common/Table/index.tsx index be76ca087..5aee5eaae 100644 --- a/met-web/src/components/common/Table/index.tsx +++ b/met-web/src/components/common/Table/index.tsx @@ -66,7 +66,7 @@ function MetTableHead({ order, orderBy, onRequestSort, headCells, loading, ne ); } -interface MetTableProps { +export interface MetTableProps { headCells: HeadCell[]; rows: T[]; hideHeader?: boolean; @@ -87,7 +87,7 @@ function MetTable({ noPagination = false, commentTable = false, // eslint-disable-next-line - handleChangePagination = (_pagination: PaginationOptions) => { }, + handleChangePagination = (_pagination: PaginationOptions) => {}, loading = false, paginationOptions = { page: 1, diff --git a/met-web/src/components/survey/building/index.tsx b/met-web/src/components/survey/building/index.tsx index 554d0598b..7d3ad1e24 100644 --- a/met-web/src/components/survey/building/index.tsx +++ b/met-web/src/components/survey/building/index.tsx @@ -160,12 +160,8 @@ const SurveyFormBuilder = () => { : 'The survey was successfully built', }), ); - if (savedSurvey.engagement?.id) { - navigate(`/engagements/${savedSurvey.engagement.id}/form`); - return; - } - navigate('/surveys'); + navigate(`/surveys/${savedSurvey.id}/report`); } catch (error) { setIsSaving(false); if (axios.isAxiosError(error)) { diff --git a/met-web/src/components/survey/listing/AdvancedSearch.tsx b/met-web/src/components/survey/listing/AdvancedSearch.tsx index 18169094f..2f7477639 100644 --- a/met-web/src/components/survey/listing/AdvancedSearch.tsx +++ b/met-web/src/components/survey/listing/AdvancedSearch.tsx @@ -12,6 +12,11 @@ import { } from '@mui/material'; import { MetLabel, PrimaryButton, SecondaryButton } from 'components/common'; import { AdvancedSearchFilters, SurveyListingContext } from './SurveyListingContext'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import CheckIcon from '@mui/icons-material/Check'; +import LinkIcon from '@mui/icons-material/Link'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import { Palette } from 'styles/Theme'; export const AdvancedSearch = () => { const isMediumScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('lg')); @@ -58,21 +63,48 @@ export const AdvancedSearch = () => { } - label="Hidden" + label={ + + + Hidden + + } /> } - label="Template" + label={ + + + Template + + } /> } - label="Ready" + label={ + <> + + + Ready + + + } /> } - label="Linked" + label={ + <> + + + Linked + + + } /> diff --git a/met-web/src/components/survey/report/ReportSettingsContext.tsx b/met-web/src/components/survey/report/ReportSettingsContext.tsx new file mode 100644 index 000000000..8da8886e1 --- /dev/null +++ b/met-web/src/components/survey/report/ReportSettingsContext.tsx @@ -0,0 +1,187 @@ +import React, { createContext, useEffect, useState } from 'react'; +import { SurveyReportSetting } from 'models/surveyReportSetting'; +import { useDispatch } from 'react-redux'; +import { useNavigate, useParams } from 'react-router-dom'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { fetchSurveyReportSettings, updateSurveyReportSettings } from 'services/surveyService/reportSettingsService'; +import { getSurvey } from 'services/surveyService'; +import { Survey } from 'models/survey'; +import { getSlugByEngagementId } from 'services/engagementSlugService'; + +export interface SearchFilter { + key: keyof SurveyReportSetting; + value: string; +} +export interface SurveyReportSettingsContextProps { + tableLoading: boolean; + surveyReportSettings: SurveyReportSetting[]; + searchFilter: SearchFilter; + setSearchFilter: React.Dispatch>; + savingSettings: boolean; + setSavingSettings: React.Dispatch>; + handleSaveSettings: (settings: SurveyReportSetting[]) => void; + survey: Survey | null; + loadingEngagementSlug: boolean; + engagementSlug: string | null; +} + +export const ReportSettingsContext = createContext({ + tableLoading: false, + surveyReportSettings: [], + searchFilter: { key: 'question', value: '' }, + setSearchFilter: () => { + throw new Error('setSearchFilter function must be overridden'); + }, + savingSettings: false, + setSavingSettings: () => { + throw new Error('setSavingSettings function must be overridden'); + }, + handleSaveSettings: () => { + throw new Error('handleSaveSettings function must be overridden'); + }, + survey: null, + loadingEngagementSlug: false, + engagementSlug: null, +}); + +export const ReportSettingsContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + const [tableLoading, setTableLoading] = useState(false); + const [searchFilter, setSearchFilter] = useState({ + key: 'question', + value: '', + }); + const [surveyReportSettings, setSurveyReportSettings] = useState([]); + const [savingSettings, setSavingSettings] = useState(false); + const [survey, setSurvey] = useState(null); + const [loadingSurvey, setLoadingSurvey] = useState(true); + const [loadingEngagementSlug, setLoadingEngagementSlug] = useState(true); + const [engagementSlug, setEngagementSlug] = useState(null); + + const { surveyId } = useParams<{ surveyId: string }>(); + + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const loadSurveySettings = async () => { + if (!surveyId || isNaN(Number(surveyId))) { + navigate('/surveys'); + return; + } + try { + setTableLoading(true); + const settings = await fetchSurveyReportSettings(surveyId); + setSurveyReportSettings(settings); + setTableLoading(false); + } catch (error) { + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while loading settings. Please try again.', + }), + ); + } + }; + + const loadSurvey = async () => { + if (!surveyId || isNaN(Number(surveyId))) { + return; + } + try { + const settings = await getSurvey(Number(surveyId)); + setSurvey(settings); + setLoadingSurvey(false); + } catch (error) { + setLoadingSurvey(false); + } + }; + + const loadEngagementSlug = async () => { + if (!survey) { + dispatch(openNotification({ severity: 'error', text: 'Failed to load dashboard link.' })); + return; + } + + if (!survey.engagement_id) { + setLoadingEngagementSlug(false); + return; + } + + try { + const slug = await getSlugByEngagementId(survey.engagement_id); + setEngagementSlug(slug.slug); + setLoadingEngagementSlug(false); + } catch (error) { + setLoadingEngagementSlug(false); + dispatch(openNotification({ severity: 'error', text: 'Failed to load dashboard link.' })); + } + }; + + useEffect(() => { + loadSurveySettings(); + loadSurvey(); + }, [surveyId]); + + useEffect(() => { + if (!loadingSurvey) { + loadEngagementSlug(); + } + }, [loadingSurvey]); + + const handleNavigateOnSave = () => { + if (survey?.engagement_id) { + navigate(`/engagements/${survey.engagement_id}/form`); + return; + } + navigate(`/surveys`); + }; + const handleSaveSettings = async (settings: SurveyReportSetting[]) => { + if (!surveyId || isNaN(Number(surveyId))) { + setSavingSettings(false); + return; + } + + if (!settings.length) { + handleNavigateOnSave(); + return; + } + + try { + await updateSurveyReportSettings(surveyId, settings); + setSavingSettings(false); + dispatch( + openNotification({ + severity: 'success', + text: 'Settings saved successfully.', + }), + ); + handleNavigateOnSave(); + } catch (error) { + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while saving settings. Please try again.', + }), + ); + setSavingSettings(false); + } + }; + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/survey/report/SearchBar.tsx b/met-web/src/components/survey/report/SearchBar.tsx new file mode 100644 index 000000000..ab6886f35 --- /dev/null +++ b/met-web/src/components/survey/report/SearchBar.tsx @@ -0,0 +1,42 @@ +import React, { useContext } from 'react'; +import { Stack, TextField } from '@mui/material'; +import { PrimaryButton } from 'components/common'; +import SearchIcon from '@mui/icons-material/Search'; +import { ReportSettingsContext } from './ReportSettingsContext'; + +const SearchBar = () => { + const { searchFilter, setSearchFilter } = useContext(ReportSettingsContext); + const [searchText, setSearchText] = React.useState(''); + + return ( + <> + + { + setSearchText(e.target.value); + }} + size="small" + /> + { + setSearchFilter({ + ...searchFilter, + value: searchText, + }); + }} + > + + + + + ); +}; + +export default SearchBar; diff --git a/met-web/src/components/survey/report/SettingsForm.tsx b/met-web/src/components/survey/report/SettingsForm.tsx new file mode 100644 index 000000000..ea80fe464 --- /dev/null +++ b/met-web/src/components/survey/report/SettingsForm.tsx @@ -0,0 +1,125 @@ +import React, { useContext, useState } from 'react'; +import { ClickAwayListener, Grid, InputAdornment, TextField, Tooltip } from '@mui/material'; +import { + MetHeader3, + MetLabel, + MetPageGridContainer, + MetPaper, + PrimaryButton, + SecondaryButton, +} from 'components/common'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { ReportSettingsContext } from './ReportSettingsContext'; +import SettingsTable from './SettingsTable'; +import SearchBar from './SearchBar'; +import { getBaseUrl } from 'helper'; + +const SettingsForm = () => { + const { setSavingSettings, savingSettings, engagementSlug, loadingEngagementSlug, survey } = + useContext(ReportSettingsContext); + + const [copyTooltip, setCopyTooltip] = useState(false); + + const baseUrl = getBaseUrl(); + const engagementUrl = !survey?.engagement_id + ? 'Link will appear when the survey is linked to an engagement' + : `${baseUrl}/${engagementSlug}`; + + const handleTooltipClose = () => { + setCopyTooltip(false); + }; + + const handleCopyUrl = () => { + if (!engagementSlug) return; + setCopyTooltip(true); + navigator.clipboard.writeText(engagementUrl); + }; + + return ( + + + Report Settings + + + + + + Link to Public Dashboard Report + + + + + + + + + ), + }} + /> + + + + + Select the questions you would like to display on the public report + + + + + + + + + setSavingSettings(true)} loading={savingSettings}> + Save + + + + + + + ); +}; + +export default SettingsForm; diff --git a/met-web/src/components/survey/report/SettingsTable.tsx b/met-web/src/components/survey/report/SettingsTable.tsx new file mode 100644 index 000000000..9b17c7e9b --- /dev/null +++ b/met-web/src/components/survey/report/SettingsTable.tsx @@ -0,0 +1,84 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { Checkbox } from '@mui/material'; +import { HeadCell } from 'components/common/Table/types'; +import MetTable from 'components/common/Table'; +import { ClientSidePagination } from 'components/common/Table/ClientSidePagination'; +import { SurveyReportSetting } from 'models/surveyReportSetting'; +import { ReportSettingsContext } from './ReportSettingsContext'; +import { updatedDiff } from 'deep-object-diff'; + +const SettingsTable = () => { + const { surveyReportSettings, searchFilter, savingSettings, handleSaveSettings, tableLoading } = + useContext(ReportSettingsContext); + const [displayedMap, setDisplayedMap] = useState<{ [key: number]: boolean }>({}); + + useEffect(() => { + const map = surveyReportSettings.reduce((acc, curr) => { + acc[curr.id] = curr.display; + return acc; + }, {} as { [key: number]: boolean }); + setDisplayedMap(map); + }, [surveyReportSettings]); + + useEffect(() => { + if (!savingSettings) { + return; + } + const updatedSettings = surveyReportSettings.map((setting) => { + return { + ...setting, + display: displayedMap[setting.id], + }; + }); + const diff = updatedDiff(surveyReportSettings, updatedSettings); + const diffKeys = Object.keys(diff); + const updatedDiffSettings = diffKeys.map((key) => updatedSettings[Number(key)]); + + handleSaveSettings(updatedDiffSettings); + }, [savingSettings]); + + const headCells: HeadCell[] = [ + { + key: 'id', + numeric: false, + disablePadding: true, + label: 'Include in Report', + allowSort: true, + renderCell: (row: SurveyReportSetting) => ( + { + setDisplayedMap({ + ...displayedMap, + [row.id]: !displayedMap[row.id], + }); + }} + /> + ), + }, + { + key: 'question', + numeric: false, + disablePadding: true, + label: 'Question', + allowSort: true, + renderCell: (row: SurveyReportSetting) => row.question, + }, + { + key: 'question_type', + numeric: false, + disablePadding: true, + label: 'Question Type', + allowSort: true, + renderCell: (row: SurveyReportSetting) => row.question_type, + }, + ]; + + return ( + + {(props) => } + + ); +}; + +export default SettingsTable; diff --git a/met-web/src/components/survey/report/index.tsx b/met-web/src/components/survey/report/index.tsx new file mode 100644 index 000000000..f2b58dbca --- /dev/null +++ b/met-web/src/components/survey/report/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import SettingsForm from './SettingsForm'; +import { ReportSettingsContextProvider } from './ReportSettingsContext'; + +const ReportSettings = () => { + return ( + + + + ); +}; + +export default ReportSettings; diff --git a/met-web/src/models/surveyReportSetting.tsx b/met-web/src/models/surveyReportSetting.tsx new file mode 100644 index 000000000..bf706336d --- /dev/null +++ b/met-web/src/models/surveyReportSetting.tsx @@ -0,0 +1,9 @@ +export interface SurveyReportSetting { + id: number; + survey_id: number; + question_id: number; + question_key: string; + question_type: string; + question: string; + display: boolean; +} diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index 1eac9ae26..19f3a29ee 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -22,6 +22,7 @@ import AuthGate from './AuthGate'; import { USER_ROLES } from 'services/userService/constants'; import UserProfile from 'components/userManagement/userDetails'; import ScrollToTop from 'components/scrollToTop'; +import ReportSettings from 'components/survey/report'; const AuthenticatedRoutes = () => { return ( @@ -34,6 +35,7 @@ const AuthenticatedRoutes = () => { } /> } /> } /> + } /> }> } /> diff --git a/met-web/src/services/surveyService/reportSettingsService/index.tsx b/met-web/src/services/surveyService/reportSettingsService/index.tsx new file mode 100644 index 000000000..e63eca647 --- /dev/null +++ b/met-web/src/services/surveyService/reportSettingsService/index.tsx @@ -0,0 +1,16 @@ +import http from 'apiManager/httpRequestHandler'; +import Endpoints from 'apiManager/endpoints'; +import { replaceUrl } from 'helper'; +import { SurveyReportSetting } from 'models/surveyReportSetting'; + +export const fetchSurveyReportSettings = async (surveyId: string): Promise => { + const url = replaceUrl(Endpoints.SurveyReportSetting.GET_LIST, 'survey_id', surveyId); + const responseData = await http.GetRequest(url); + return responseData.data ?? []; +}; + +export const updateSurveyReportSettings = async (surveyId: string, settingData: SurveyReportSetting[]) => { + const url = replaceUrl(Endpoints.SurveyReportSetting.UPDATE, 'survey_id', surveyId); + const responseData = await http.PatchRequest(url, settingData); + return responseData.data ?? []; +}; diff --git a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.test.tsx b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.test.tsx index e3b6e3288..9637c5e06 100644 --- a/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.test.tsx +++ b/met-web/tests/unit/components/engagement/form/edit/EngagementForm.Edit.test.tsx @@ -170,7 +170,7 @@ describe('Engagement form page tests', () => { expect(screen.getByText('Add Survey')).toBeDisabled(); }); - test.only('Can move to settings tab', async () => { + test('Can move to settings tab', async () => { useParamsMock.mockReturnValue({ engagementId: '1' }); getEngagementMock.mockReturnValueOnce( Promise.resolve({ diff --git a/openshift/README.md b/openshift/README.md index c8d351de1..99c8b1c68 100644 --- a/openshift/README.md +++ b/openshift/README.md @@ -29,6 +29,10 @@ In the tools namespace use the following to create the build configurations: oc process -f ./met-analytics.bc.yml | oc create -f - ``` +``` + oc process -f ./analytics-api.bc.yml | oc create -f - +``` + ## Image Puller Configuration Allow image pullers from the other namespaces to pull images from tools namespace: diff --git a/openshift/analytics-api.bc.yml b/openshift/analytics-api.bc.yml new file mode 100644 index 000000000..bd0cd8991 --- /dev/null +++ b/openshift/analytics-api.bc.yml @@ -0,0 +1,54 @@ +apiVersion: template.openshift.io/v1 +kind: Template +metadata: + name: api-build-template + annotations: + description: "Build Configuration Template for the Analytics API " + tags: "met, api, python" +objects: + - apiVersion: image.openshift.io/v1 + kind: ImageStream + metadata: + name: ${APP} + spec: + lookupPolicy: + local: false + - apiVersion: build.openshift.io/v1 + kind: BuildConfig + metadata: + name: ${APP} + labels: + app: ${APP} + spec: + nodeSelector: null + output: + to: + kind: ImageStreamTag + name: '${APP}:latest' + resources: {} + successfulBuildsHistoryLimit: 5 + failedBuildsHistoryLimit: 5 + strategy: + type: Docker + dockerStrategy: + dockerfilePath: Dockerfile + postCommit: {} + source: + type: Git + git: + uri: ${GITHUB_REPO} + ref: main + contextDir: ${GITHUB_CONTEXT_DIR} + triggers: + - type: ConfigChange + runPolicy: Serial +parameters: + - name: APP + description: "The application name" + value: analytics-api + - name: GITHUB_REPO + description: "The github repo uri" + value: 'https://github.com/bcgov/met-public.git' + - name: GITHUB_CONTEXT_DIR + description: "The repository folder context" + value: /analytics-api \ No newline at end of file diff --git a/openshift/web.bc.yml b/openshift/web.bc.yml index 2f175ca41..2bce6536a 100644 --- a/openshift/web.bc.yml +++ b/openshift/web.bc.yml @@ -27,8 +27,8 @@ objects: name: '${APP}:latest' resources: limits: - cpu: '2' - memory: 5Gi + cpu: '500m' + memory: 2Gi successfulBuildsHistoryLimit: 5 failedBuildsHistoryLimit: 5 strategy: