From 3300f31fd6fa5d68e98a7226d2ee75dcb342f8da Mon Sep 17 00:00:00 2001 From: saravanpa-aot Date: Wed, 12 Jul 2023 16:03:10 -0700 Subject: [PATCH 1/3] Update nginx.dev.conf to accomdate epic engage urls --- met-web/nginx/nginx.dev.conf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/met-web/nginx/nginx.dev.conf b/met-web/nginx/nginx.dev.conf index 296a97fbc..948abd96b 100644 --- a/met-web/nginx/nginx.dev.conf +++ b/met-web/nginx/nginx.dev.conf @@ -46,9 +46,9 @@ http { worker-src 'self' blob:; img-src 'self' data: blob: https://citz-gdx.objectstore.gov.bc.ca; style-src 'self' 'unsafe-inline'; - connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com; - frame-src 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca; - frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca"; + connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com; + frame-src 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca; + frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca "; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; From b34ac342cb18b7b69e26b4f13acbe16550a07f0e Mon Sep 17 00:00:00 2001 From: saravanpa-aot Date: Wed, 12 Jul 2023 16:27:33 -0700 Subject: [PATCH 2/3] Update nginx.dev.conf --- met-web/nginx/nginx.dev.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/met-web/nginx/nginx.dev.conf b/met-web/nginx/nginx.dev.conf index 948abd96b..994754a1f 100644 --- a/met-web/nginx/nginx.dev.conf +++ b/met-web/nginx/nginx.dev.conf @@ -48,7 +48,7 @@ http { style-src 'self' 'unsafe-inline'; connect-src 'self' https://spt.apps.gov.bc.ca/com.snowplowanalytics.snowplow/tp2 https://met-analytics-api-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://kit.fontawesome.com https://ka-f.fontawesome.com https://citz-gdx.objectstore.gov.bc.ca https://api.mapbox.com https://governmentofbc.maps.arcgis.com https://tiles.arcgis.com https://www.arcgis.com; frame-src 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca https://met-analytics-dev.apps.gold.devops.gov.bc.ca; - frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca "; + frame-ancestors 'self' https://met-oidc-dev.apps.gold.devops.gov.bc.ca https://epic-engage-oidc-dev.apps.gold.devops.gov.bc.ca"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains"; add_header X-Content-Type-Options "nosniff"; add_header X-XSS-Protection 1; From bd80e5f06418ebe9edef3fae3d25fafaa6c42643 Mon Sep 17 00:00:00 2001 From: jadmsaadaot <91914654+jadmsaadaot@users.noreply.github.com> Date: Thu, 13 Jul 2023 11:49:48 -0700 Subject: [PATCH 3/3] Add video widget admin side component and resource (#1828) * Add video widget model and option card * add video widget service and resource * Add video widget crud and UI component * Fix linting issue --- .../versions/47fc88fe0477_video_widget.py | 52 +++++ met-api/src/met_api/models/__init__.py | 1 + met-api/src/met_api/models/widget_video.py | 39 ++++ met-api/src/met_api/resources/__init__.py | 2 + met-api/src/met_api/resources/document.py | 2 +- met-api/src/met_api/resources/widget_map.py | 2 +- met-api/src/met_api/resources/widget_video.py | 80 +++++++ .../schemas/schemas/video_widget_update.json | 31 +++ met-api/src/met_api/schemas/widget_video.py | 28 +++ .../met_api/services/widget_video_service.py | 42 ++++ met-web/src/apiManager/endpoints/index.ts | 5 + .../form/EngagementWidgets/Video/Form.tsx | 207 ++++++++++++++++++ .../EngagementWidgets/Video/VideoContext.tsx | 63 ++++++ .../Video/VideoOptionCard.tsx | 93 ++++++++ .../form/EngagementWidgets/Video/index.tsx | 13 ++ .../EngagementWidgets/WidgetCardSwitch.tsx | 13 ++ .../EngagementWidgets/WidgetDrawerTabs.tsx | 4 + .../EngagementWidgets/WidgetOptionCards.tsx | 4 + .../form/EngagementWidgets/type.tsx | 1 + met-web/src/models/videoWidget.ts | 7 + met-web/src/models/widget.tsx | 1 + .../widgetService/VideoService/index.tsx | 62 ++++++ 22 files changed, 750 insertions(+), 2 deletions(-) create mode 100644 met-api/migrations/versions/47fc88fe0477_video_widget.py create mode 100644 met-api/src/met_api/models/widget_video.py create mode 100644 met-api/src/met_api/resources/widget_video.py create mode 100644 met-api/src/met_api/schemas/schemas/video_widget_update.json create mode 100644 met-api/src/met_api/schemas/widget_video.py create mode 100644 met-api/src/met_api/services/widget_video_service.py create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Video/VideoContext.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Video/VideoOptionCard.tsx create mode 100644 met-web/src/components/engagement/form/EngagementWidgets/Video/index.tsx create mode 100644 met-web/src/models/videoWidget.ts create mode 100644 met-web/src/services/widgetService/VideoService/index.tsx diff --git a/met-api/migrations/versions/47fc88fe0477_video_widget.py b/met-api/migrations/versions/47fc88fe0477_video_widget.py new file mode 100644 index 000000000..d31bb95f5 --- /dev/null +++ b/met-api/migrations/versions/47fc88fe0477_video_widget.py @@ -0,0 +1,52 @@ +"""Add video widget + +Revision ID: 47fc88fe0477 +Revises: b3b5c66cea4b +Create Date: 2023-07-11 10:44:35.980432 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = '47fc88fe0477' +down_revision = 'b3b5c66cea4b' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('widget_video', + sa.Column('created_date', sa.DateTime(), nullable=False), + sa.Column('updated_date', sa.DateTime(), nullable=True), + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('widget_id', sa.Integer(), nullable=True), + sa.Column('engagement_id', sa.Integer(), nullable=True), + sa.Column('video_url', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['widget_id'], ['widget.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + widget_type_table = sa.table('widget_type', + sa.Column('id', sa.Integer), + sa.Column('name', sa.String), + sa.Column('description', sa.String)) + + op.bulk_insert(widget_type_table, [ + {'id': 7, 'name': 'Video', 'description': 'Add a link to a hosted video and link preview'} + ]) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('widget_video') + + conn = op.get_bind() + + conn.execute('DELETE FROM widget_type WHERE id=7') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 8b6580968..5fbe8d451 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -44,3 +44,4 @@ from .email_queue import EmailQueue from .engagement_slug import EngagementSlug from .report_setting import ReportSetting +from .widget_video import WidgetVideo diff --git a/met-api/src/met_api/models/widget_video.py b/met-api/src/met_api/models/widget_video.py new file mode 100644 index 000000000..82ad1d672 --- /dev/null +++ b/met-api/src/met_api/models/widget_video.py @@ -0,0 +1,39 @@ +"""WidgetVideo model class. + +Manages the video widget +""" +from __future__ import annotations + +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class WidgetVideo(BaseModel): # pylint: disable=too-few-public-methods, too-many-instance-attributes + """Definition of the Video entity.""" + + __tablename__ = 'widget_video' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True) + video_url = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text()) + + @classmethod + def get_video(cls, widget_id) -> list[WidgetVideo]: + """Get video.""" + widget_video = db.session.query(WidgetVideo) \ + .filter(WidgetVideo.widget_id == widget_id) \ + .all() + return widget_video + + @classmethod + def update_video(cls, video_widget_id, video_data: dict) -> WidgetVideo: + """Update video.""" + widget_video: WidgetVideo = WidgetVideo.query.get(video_widget_id) + if widget_video: + for key, value in video_data.items(): + setattr(widget_video, key, value) + widget_video.save() + return widget_video diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 698ef7c31..867ba929b 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -45,6 +45,7 @@ from .tenant import API as TENANT_API from .engagement_slug import API as ENGAGEMENT_SLUG_API from .report_setting import API as REPORT_SETTING_API +from .widget_video import API as WIDGET_VIDEO_API __all__ = ('API_BLUEPRINT',) @@ -81,3 +82,4 @@ API.add_namespace(WIDGET_MAPS_API, path='/widgets//maps') API.add_namespace(ENGAGEMENT_SLUG_API, path='/slugs') 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/document.py b/met-api/src/met_api/resources/document.py index 302308598..abc4b96eb 100644 --- a/met-api/src/met_api/resources/document.py +++ b/met-api/src/met_api/resources/document.py @@ -11,7 +11,7 @@ # 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. -"""API endpoints for managing a FOI Requests resource.""" +"""API endpoints for managing documents resource.""" from http import HTTPStatus diff --git a/met-api/src/met_api/resources/widget_map.py b/met-api/src/met_api/resources/widget_map.py index 3945ee78d..fa2a786c2 100644 --- a/met-api/src/met_api/resources/widget_map.py +++ b/met-api/src/met_api/resources/widget_map.py @@ -11,7 +11,7 @@ # 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. -"""API endpoints for managing a FOI Requests resource.""" +"""API endpoints for managing map resource.""" import json from http import HTTPStatus diff --git a/met-api/src/met_api/resources/widget_video.py b/met-api/src/met_api/resources/widget_video.py new file mode 100644 index 000000000..62fec02b2 --- /dev/null +++ b/met-api/src/met_api/resources/widget_video.py @@ -0,0 +1,80 @@ +# Copyright © 2021 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. +"""API endpoints for managing a video widget resource.""" +from http import HTTPStatus + +from flask import request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas import utils as schema_utils +from met_api.schemas.widget_video import WidgetVideoSchema +from met_api.services.widget_video_service import WidgetVideoService +from met_api.utils.roles import Role +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('widget_videos', description='Endpoints for Video Widget Management') +"""Widget Videos""" + + +@cors_preflight('GET, POST, PATCH, OPTIONS') +@API.route('') +class Videos(Resource): + """Resource for managing video widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(widget_id): + """Get video widget.""" + try: + widget_video = WidgetVideoService().get_video(widget_id) + return WidgetVideoSchema().dump(widget_video, many=True), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.has_one_of_roles([Role.EDIT_ENGAGEMENT.value]) + def post(widget_id): + """Create video widget.""" + try: + request_json = request.get_json() + widget_video = WidgetVideoService().create_video(widget_id, request_json) + return WidgetVideoSchema().dump(widget_video), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + +@cors_preflight('PATCH') +@API.route('/') +class Video(Resource): + """Resource for managing video widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.has_one_of_roles([Role.EDIT_ENGAGEMENT.value]) + def patch(widget_id, video_widget_id): + """Update video widget.""" + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'video_widget_update') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + try: + widget_video = WidgetVideoService().update_video(widget_id, video_widget_id, request_json) + return WidgetVideoSchema().dump(widget_video), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code diff --git a/met-api/src/met_api/schemas/schemas/video_widget_update.json b/met-api/src/met_api/schemas/schemas/video_widget_update.json new file mode 100644 index 000000000..76c152338 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/video_widget_update.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/video_widget_update", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "description": "A video widget description", + "video_url": "https://www.youtube.com" + } + ], + "required": [], + "properties": { + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Video description", + "description": "The description of this video.", + "examples": ["A video widget description"] + }, + "video_url": { + "$id": "#/properties/video_url", + "type": "string", + "title": "Video url", + "description": "The url link to this video.", + "examples": ["https://www.youtube.com"] + } + } +} diff --git a/met-api/src/met_api/schemas/widget_video.py b/met-api/src/met_api/schemas/widget_video.py new file mode 100644 index 000000000..5f8b3af2c --- /dev/null +++ b/met-api/src/met_api/schemas/widget_video.py @@ -0,0 +1,28 @@ +# 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. +"""Manager for widget video schema.""" + +from met_api.models.widget_video import WidgetVideo as WidgetVideoModel + +from marshmallow import Schema + + +class WidgetVideoSchema(Schema): # pylint: disable=too-many-ancestors, too-few-public-methods + """This is the schema for the video model.""" + + class Meta: # pylint: disable=too-few-public-methods + """Videos all of the Widget Video fields to a default schema.""" + + model = WidgetVideoModel + fields = ('id', 'widget_id', 'engagement_id', 'video_url', 'description') diff --git a/met-api/src/met_api/services/widget_video_service.py b/met-api/src/met_api/services/widget_video_service.py new file mode 100644 index 000000000..03863b9cf --- /dev/null +++ b/met-api/src/met_api/services/widget_video_service.py @@ -0,0 +1,42 @@ +"""Service for Widget Video management.""" +from met_api.models.widget_video import WidgetVideo as WidgetVideoModel + + +class WidgetVideoService: + """Widget Video management service.""" + + @staticmethod + def get_video(widget_id): + """Get video by widget id.""" + widget_video = WidgetVideoModel.get_video(widget_id) + return widget_video + + @staticmethod + def create_video(widget_id, video_details: dict): + """Create video for the widget.""" + video_data = dict(video_details) + widget_video = WidgetVideoService._create_video_model(widget_id, video_data) + widget_video.commit() + return widget_video + + @staticmethod + def update_video(widget_id, video_widget_id, video_data): + """Update video widget.""" + widget_video: WidgetVideoModel = WidgetVideoModel.find_by_id(video_widget_id) + if not widget_video: + raise KeyError('Video widget not found') + + if widget_video.widget_id != widget_id: + raise ValueError('Invalid widgets and video') + + return WidgetVideoModel.update_video(widget_video.id, video_data) + + @staticmethod + def _create_video_model(widget_id, video_data: dict): + video_model: WidgetVideoModel = WidgetVideoModel() + video_model.widget_id = widget_id + video_model.engagement_id = video_data.get('engagement_id') + video_model.video_url = video_data.get('video_url') + video_model.description = video_data.get('description') + video_model.flush() + return video_model diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 8fd86d5bd..dbcd2f543 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -110,6 +110,11 @@ const Endpoints = { CREATE: `${AppConfig.apiUrl}/widgets/widget_id/maps`, SHAPEFILE_PREVIEW: `${AppConfig.apiUrl}/shapefile`, }, + VideoWidgets: { + GET: `${AppConfig.apiUrl}/widgets/widget_id/videos`, + CREATE: `${AppConfig.apiUrl}/widgets/widget_id/videos`, + UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/videos/video_widget_id`, + }, Tenants: { GET: `${AppConfig.apiUrl}/tenants/tenant_id`, }, diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx new file mode 100644 index 000000000..ebc9b8bef --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Video/Form.tsx @@ -0,0 +1,207 @@ +import React, { useContext, useEffect } from 'react'; +import Divider from '@mui/material/Divider'; +import { Grid } from '@mui/material'; +import { + MetDescription, + MetHeader3, + MetLabel, + MidScreenLoader, + PrimaryButton, + SecondaryButton, +} from 'components/common'; +import { useForm, FormProvider, SubmitHandler } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useAppDispatch } from 'hooks'; +import ControlledTextField from 'components/common/ControlledInputComponents/ControlledTextField'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { VideoContext } from './VideoContext'; +import { patchVideo, postVideo } from 'services/widgetService/VideoService'; +import { updatedDiff } from 'deep-object-diff'; + +const schema = yup + .object({ + videoUrl: yup + .string() + .url('Please enter a valid Link') + .required('Please enter a valid Link') + .max(255, 'Video link cannot exceed 255 characters'), + description: yup + .string() + .required('Please enter a description') + .max(500, 'Description cannot exceed 500 characters'), + }) + .required(); + +type DetailsForm = yup.TypeOf; + +const Form = () => { + const dispatch = useAppDispatch(); + const { widget, isLoadingVideoWidget, videoWidget } = useContext(VideoContext); + const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); + const [isCreating, setIsCreating] = React.useState(false); + + const methods = useForm({ + resolver: yupResolver(schema), + }); + + const { handleSubmit, reset } = methods; + + useEffect(() => { + if (videoWidget) { + methods.setValue('description', videoWidget.description); + methods.setValue('videoUrl', videoWidget.video_url); + } + }, [videoWidget]); + + const createVideo = async (data: DetailsForm) => { + if (!widget) { + return; + } + + const validatedData = await schema.validate(data); + const { videoUrl, description } = validatedData; + await postVideo(widget.id, { + widget_id: widget.id, + engagement_id: widget.engagement_id, + video_url: videoUrl, + description: description, + }); + dispatch(openNotification({ severity: 'success', text: 'A new video was successfully added' })); + }; + + const updateVideo = async (data: DetailsForm) => { + if (!widget || !videoWidget) { + return; + } + + const validatedData = await schema.validate(data); + const updatedDate = updatedDiff( + { + description: videoWidget.description, + video_url: videoWidget.video_url, + }, + { + description: validatedData.description, + video_url: validatedData.videoUrl, + }, + ); + + if (Object.keys(updatedDate).length === 0) { + return; + } + + await patchVideo(widget.id, videoWidget.id, { + ...updatedDate, + }); + dispatch(openNotification({ severity: 'success', text: 'The video widget was successfully updated' })); + }; + + const saveVideoWidget = (data: DetailsForm) => { + if (!videoWidget) { + return createVideo(data); + } + return updateVideo(data); + }; + const onSubmit: SubmitHandler = async (data: DetailsForm) => { + if (!widget) { + return; + } + try { + setIsCreating(true); + await saveVideoWidget(data); + setIsCreating(false); + reset({}); + handleWidgetDrawerOpen(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to add event' })); + setIsCreating(false); + } + }; + + if (isLoadingVideoWidget) { + return ( + + + + + + ); + } + + return ( + + + Video + + + + +
+ + + Description + + + + Video Link + + The video must be hosted on one of the following platforms: + + + + + + + Save & Close + + + + handleWidgetDrawerOpen(false)}> + Cancel + + + + +
+
+
+
+ ); +}; + +export default Form; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Video/VideoContext.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Video/VideoContext.tsx new file mode 100644 index 000000000..1e402c804 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Video/VideoContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { Widget, WidgetType } from 'models/widget'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { useAppDispatch } from 'hooks'; +import { fetchVideoWidgets } from 'services/widgetService/VideoService'; +import { VideoWidget } from 'models/videoWidget'; +import { openNotification } from 'services/notificationService/notificationSlice'; + +export interface VideoContextProps { + widget: Widget | null; + isLoadingVideoWidget: boolean; + videoWidget: VideoWidget | null; +} + +export type EngagementParams = { + engagementId: string; +}; + +export const VideoContext = createContext({ + widget: null, + isLoadingVideoWidget: true, + videoWidget: null, +}); + +export const VideoContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + const { widgets } = useContext(WidgetDrawerContext); + const dispatch = useAppDispatch(); + const widget = widgets.find((widget) => widget.widget_type_id === WidgetType.Video) ?? null; + const [isLoadingVideoWidget, setIsLoadingVideoWidget] = useState(true); + const [videoWidget, setVideoWidget] = useState(null); + + const loadVideoWidget = async () => { + if (!widget) { + return; + } + try { + const result = await fetchVideoWidgets(widget.id); + setVideoWidget(result[result.length - 1]); + setIsLoadingVideoWidget(false); + } catch (error) { + dispatch( + openNotification({ severity: 'error', text: 'An error occurred while trying to load video data' }), + ); + setIsLoadingVideoWidget(false); + } + }; + + useEffect(() => { + loadVideoWidget(); + }, [widget]); + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Video/VideoOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Video/VideoOptionCard.tsx new file mode 100644 index 000000000..ee0ebb473 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Video/VideoOptionCard.tsx @@ -0,0 +1,93 @@ +import React, { useContext, useState } from 'react'; +import { MetPaper, MetBody, MetHeader4 } from 'components/common'; +import { Grid, CircularProgress } from '@mui/material'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { WidgetType } from 'models/widget'; +import { Else, If, Then } from 'react-if'; +import { ActionContext } from '../../ActionContext'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { optionCardStyle } from '../Phases/PhasesOptionCard'; +import { WidgetTabValues } from '../type'; +import LocationOnIcon from '@mui/icons-material/LocationOn'; +import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; + +const VideoOptionCard = () => { + const { widgets, loadWidgets, handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = + useContext(WidgetDrawerContext); + const { savedEngagement } = useContext(ActionContext); + const dispatch = useAppDispatch(); + const [createWidget] = useCreateWidgetMutation(); + const [isCreatingWidget, setIsCreatingWidget] = useState(false); + + const handleCreateWidget = async () => { + const alreadyExists = widgets.map((widget) => widget.widget_type_id).includes(WidgetType.Video); + if (alreadyExists) { + handleWidgetDrawerTabValueChange(WidgetTabValues.VIDEO_FORM); + return; + } + + try { + setIsCreatingWidget(true); + await createWidget({ + widget_type_id: WidgetType.Video, + engagement_id: savedEngagement.id, + }).unwrap(); + await loadWidgets(); + dispatch( + openNotification({ + severity: 'success', + text: 'Video widget successfully created.', + }), + ); + setIsCreatingWidget(false); + handleWidgetDrawerTabValueChange(WidgetTabValues.VIDEO_FORM); + } catch (error) { + setIsCreatingWidget(false); + dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating video widget' })); + handleWidgetDrawerOpen(false); + } + }; + + return ( + handleCreateWidget()} + > + + + + + + + + + + + + + + Video + + + Add a link to a hosted video and link preview + + + + + + + ); +}; + +export default VideoOptionCard; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Video/index.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Video/index.tsx new file mode 100644 index 000000000..e5bbe50cf --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Video/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { VideoContextProvider } from './VideoContext'; +import Form from './Form'; + +export const VideoForm = () => { + return ( + +
+ + ); +}; + +export default VideoForm; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx index 881834a06..2a6e158c2 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx @@ -90,6 +90,19 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps }} /> + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.VIDEO_FORM); + handleWidgetDrawerOpen(true); + }} + /> + ); diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx index 7e14473f8..d33d3be47 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx @@ -9,6 +9,7 @@ import Documents from './Documents'; import Phases from './Phases'; import EventsForm from './Events'; import MapForm from './Map'; +import VideoForm from './Video'; const WidgetDrawerTabs = () => { const { widgetDrawerTabValue } = useContext(WidgetDrawerContext); @@ -33,6 +34,9 @@ const WidgetDrawerTabs = () => { + + + ); diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx index c3893a4e4..8a1b2efc2 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx @@ -7,6 +7,7 @@ import PhasesOptionCard from './Phases/PhasesOptionCard'; import SubscribeOptionCard from './Subscribe/SubscribeOptionCard'; import EventsOptionCard from './Events/EventsOptionCard'; import MapOptionCard from './Map/MapOptionCard'; +import VideoOptionCard from './Video/VideoOptionCard'; const WidgetOptionCards = () => { return ( @@ -33,6 +34,9 @@ const WidgetOptionCards = () => { + + + ); }; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/type.tsx b/met-web/src/components/engagement/form/EngagementWidgets/type.tsx index c1cb324a9..fb6829148 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/type.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/type.tsx @@ -6,4 +6,5 @@ export const WidgetTabValues = { PHASES_FORM: 'PHASES_FORM', EVENTS_FORM: 'EVENTS_FORM', MAP_FORM: 'MAP_FORM', + VIDEO_FORM: 'VIDEO_FORM', }; diff --git a/met-web/src/models/videoWidget.ts b/met-web/src/models/videoWidget.ts new file mode 100644 index 000000000..b5d681e6b --- /dev/null +++ b/met-web/src/models/videoWidget.ts @@ -0,0 +1,7 @@ +export interface VideoWidget { + id: number; + widget_id: number; + engagement_id: number; + video_url: string; + description: string; +} diff --git a/met-web/src/models/widget.tsx b/met-web/src/models/widget.tsx index 6b19ef052..9bbdcde90 100644 --- a/met-web/src/models/widget.tsx +++ b/met-web/src/models/widget.tsx @@ -19,4 +19,5 @@ export enum WidgetType { Subscribe = 4, Events = 5, Map = 6, + Video = 7, } diff --git a/met-web/src/services/widgetService/VideoService/index.tsx b/met-web/src/services/widgetService/VideoService/index.tsx new file mode 100644 index 000000000..adc12bb01 --- /dev/null +++ b/met-web/src/services/widgetService/VideoService/index.tsx @@ -0,0 +1,62 @@ +import http from 'apiManager/httpRequestHandler'; +import Endpoints from 'apiManager/endpoints'; +import { replaceAllInURL, replaceUrl } from 'helper'; +import { VideoWidget } from 'models/videoWidget'; + +export const fetchVideoWidgets = async (widget_id: number): Promise => { + try { + const url = replaceUrl(Endpoints.VideoWidgets.GET, 'widget_id', String(widget_id)); + const responseData = await http.GetRequest(url); + return responseData.data ?? []; + } catch (err) { + return Promise.reject(err); + } +}; + +interface PostVideoRequest { + widget_id: number; + engagement_id: number; + video_url: string; + description: string; +} + +export const postVideo = async (widget_id: number, data: PostVideoRequest): Promise => { + try { + const url = replaceUrl(Endpoints.VideoWidgets.CREATE, 'widget_id', String(widget_id)); + const response = await http.PostRequest(url, data); + if (response.data) { + return response.data; + } + return Promise.reject('Failed to create video widget'); + } catch (err) { + return Promise.reject(err); + } +}; + +interface PatchVideoRequest { + video_url?: string; + description?: string; +} + +export const patchVideo = async ( + widget_id: number, + video_widget_id: number, + data: PatchVideoRequest, +): Promise => { + try { + const url = replaceAllInURL({ + URL: Endpoints.VideoWidgets.UPDATE, + params: { + widget_id: String(widget_id), + video_widget_id: String(video_widget_id), + }, + }); + const response = await http.PatchRequest(url, data); + if (response.data) { + return response.data; + } + return Promise.reject('Failed to create video widget'); + } catch (err) { + return Promise.reject(err); + } +};