diff --git a/CHANGELOG.MD b/CHANGELOG.MD index f4f7fe364..0e0c90bf4 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,4 +1,13 @@ +## September 9, 2024 + +- **Feature** Add image widget [🎟️ DESENG-689](https://citz-gdx.atlassian.net/browse/DESENG-689) + - Added a new "ImageWidget" widget type in the API + - Image widgets can have a title, optional description, uploaded image, and optional alt text + - Added image widget option to the engagement authoring wizard + - Added image widget display for the engagement view page + ## September 3, 2024 + - **Feature** New authoring content section [🎟️ DESENG-668](https://citz-gdx.atlassian.net/browse/DESENG-668) - Implemented authoring side nav - Implemented authoring bottom nav diff --git a/met-api/migrations/versions/e706db763790_add_new_image_type_to_widget_type_table.py b/met-api/migrations/versions/e706db763790_add_new_image_type_to_widget_type_table.py new file mode 100644 index 000000000..901cd109e --- /dev/null +++ b/met-api/migrations/versions/e706db763790_add_new_image_type_to_widget_type_table.py @@ -0,0 +1,74 @@ +"""Add new Image type to widget type table + +Revision ID: e706db763790 +Revises: 42641011576a +Create Date: 2024-09-04 14:03:57.967946 + +""" + +from datetime import datetime, UTC +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import table, column +from sqlalchemy import String, Integer, DateTime + + +# revision identifiers, used by Alembic. +revision = "e706db763790" +down_revision = "42641011576a" +branch_labels = None +depends_on = None + + +def upgrade(): + # Temporary table model for existing widget_type table + widget_type_table = table( + "widget_type", + column("id", Integer), + column("name", String), + column("description", String), + column("created_date", DateTime), + column("updated_date", DateTime), + column("created_by", String), + column("updated_by", String), + ) + # Insert new widget type + op.bulk_insert( + widget_type_table, + [ + { + "id": 11, + "name": "Image", + "description": "Displays a static image, with optional caption", + "created_by": "migration", + "updated_by": "migration", + "created_date": datetime.now(UTC), + "updated_date": datetime.now(UTC), + } + ], + ) + op.create_table( + "widget_image", + 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("image_url", sa.String(length=255), nullable=False), + sa.Column("alt_text", sa.String(length=255), nullable=True), + 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"), + ) + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("widget_image") + op.execute("DELETE FROM widget_type WHERE id = 11") + # ### end Alembic commands ### diff --git a/met-api/src/met_api/constants/widget.py b/met-api/src/met_api/constants/widget.py index 6507d73c2..7ed80a60a 100644 --- a/met-api/src/met_api/constants/widget.py +++ b/met-api/src/met_api/constants/widget.py @@ -26,3 +26,4 @@ class WidgetType(IntEnum): Video = 7 Timeline = 9 Poll = 10 + Image = 11 diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 075d7518b..8ed18ff4e 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -49,6 +49,7 @@ from .engagement_slug import EngagementSlug from .report_setting import ReportSetting from .widget_video import WidgetVideo +from .widget_image import WidgetImage from .cac_form import CACForm from .engagement_metadata import EngagementMetadata, MetadataTaxon from .widget_timeline import WidgetTimeline diff --git a/met-api/src/met_api/models/widget_image.py b/met-api/src/met_api/models/widget_image.py new file mode 100644 index 000000000..010406a42 --- /dev/null +++ b/met-api/src/met_api/models/widget_image.py @@ -0,0 +1,43 @@ +"""WidgetImage model class. + +Manages the image widget +""" + +from __future__ import annotations + +from sqlalchemy.sql.schema import ForeignKey + +from .base_model import BaseModel +from .db import db + + +class WidgetImage( + BaseModel +): # pylint: disable=too-few-public-methods, too-many-instance-attributes + """Definition of the Image entity.""" + + __tablename__ = 'widget_image' + 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 + ) + image_url = db.Column(db.String(255), nullable=False) + alt_text = db.Column(db.String(255)) + description = db.Column(db.Text()) + + @classmethod + def get_image(cls, widget_id) -> list[WidgetImage]: + """Get an image by widget_id.""" + return WidgetImage.query.filter(WidgetImage.widget_id == widget_id).all() + + @classmethod + def update_image(cls, widget_id, widget_data) -> WidgetImage: + """Update an image by widget_id.""" + image = WidgetImage.get_image(widget_id)[0] + for key, value in widget_data.items(): + setattr(image, key, value) + image.save() + return image diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index f3494bf61..c22b0b83f 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -54,6 +54,7 @@ from .cac_form import API as CAC_FORM_API from .widget_timeline import API as WIDGET_TIMELINE_API from .widget_poll import API as WIDGET_POLL_API +from .widget_image import API as WIDGET_IMAGE_API from .language import API as LANGUAGE_API from .widget_translation import API as WIDGET_TRANSLATION_API from .survey_translation import API as SURVEY_TRANSLATION_API @@ -69,7 +70,9 @@ URL_PREFIX = '/api/' API_BLUEPRINT = Blueprint('API', __name__, url_prefix=URL_PREFIX) -API = Api(API_BLUEPRINT, title='MET API', version='1.0', description='The Core API for MET') +API = Api( + API_BLUEPRINT, title='MET API', version='1.0', description='The Core API for MET' +) # HANDLER = ExceptionHandler(API) @@ -102,6 +105,7 @@ API.add_namespace(CAC_FORM_API, path='/engagements//cacform') API.add_namespace(WIDGET_TIMELINE_API, path='/widgets//timelines') API.add_namespace(WIDGET_POLL_API, path='/widgets//polls') +API.add_namespace(WIDGET_IMAGE_API, path='/widgets//images') API.add_namespace(LANGUAGE_API, path='/languages') API.add_namespace(WIDGET_TRANSLATION_API, path='/widget//translations') API.add_namespace(SURVEY_TRANSLATION_API, path='/surveys//translations') diff --git a/met-api/src/met_api/resources/widget_image.py b/met-api/src/met_api/resources/widget_image.py new file mode 100644 index 000000000..b43ec272d --- /dev/null +++ b/met-api/src/met_api/resources/widget_image.py @@ -0,0 +1,102 @@ +# 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 image widget resource.""" +from http import HTTPStatus + +from flask import request +from flask_cors import cross_origin +from flask_restx import Namespace, Resource, fields + +from met_api.auth import jwt as _jwt +from met_api.exceptions.business_exception import BusinessException +from met_api.schemas.widget_image import WidgetImageSchema +from met_api.services.widget_image_service import WidgetImageService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('widget_images', description='Endpoints for Image Widget Management') + +# Do not allow updating the widget_id or engagement_id via API calls + +image_creation_model = API.model( + 'ImageCreation', + { + 'image_url': fields.String(description='The URL of the image', required=True), + 'alt_text': fields.String(description='The alt text for the image'), + 'description': fields.String(description='The description of the image'), + }, +) + +image_update_model = API.model( + 'ImageUpdate', + { + 'image_url': fields.String(description='The URL of the image'), + 'alt_text': fields.String(description='The alt text for the image'), + 'description': fields.String(description='The description of the image'), + }, +) + + +@cors_preflight('GET, POST, PATCH, OPTIONS') +@API.route('') +class Images(Resource): + """Resource for managing image widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(widget_id): + """Get image widget.""" + try: + widget_image = WidgetImageService().get_image(widget_id) + return ( + WidgetImageSchema().dump(widget_image, many=True), + HTTPStatus.OK, + ) + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + @API.expect(image_creation_model, validate=True) + def post(widget_id): + """Create image widget.""" + try: + request_json = request.get_json() + widget_image = WidgetImageService().create_image(widget_id, request_json) + return WidgetImageSchema().dump(widget_image), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + +@cors_preflight('PATCH') +@API.route('/') +class Image(Resource): + """Resource for managing specific image widget instances by ID.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + @API.expect(image_update_model, validate=True) + def patch(widget_id, image_widget_id): + """Update image widget.""" + request_json = request.get_json() + try: + WidgetImageSchema().load(request_json) + widget_image = WidgetImageService().update_image( + widget_id, image_widget_id, request_json + ) + return WidgetImageSchema().dump(widget_image), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code diff --git a/met-api/src/met_api/schemas/widget_image.py b/met-api/src/met_api/schemas/widget_image.py new file mode 100644 index 000000000..f0fd757ef --- /dev/null +++ b/met-api/src/met_api/schemas/widget_image.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. +"""Widget image schema definition.""" + +from met_api.models.widget_image import WidgetImage as WidgetImageModel + +from marshmallow import Schema + + +class WidgetImageSchema(Schema): + """This is the schema for the image model.""" + + class Meta: # pylint: disable=too-few-public-methods + """Images all of the Widget Image fields to a default schema.""" + + model = WidgetImageModel + fields = ( + 'id', + 'widget_id', + 'engagement_id', + 'image_url', + 'alt_text', + 'description', + ) diff --git a/met-api/src/met_api/services/widget_image_service.py b/met-api/src/met_api/services/widget_image_service.py new file mode 100644 index 000000000..6229dc61b --- /dev/null +++ b/met-api/src/met_api/services/widget_image_service.py @@ -0,0 +1,58 @@ +"""Service for Widget Image management.""" + +from met_api.constants.membership_type import MembershipType +from met_api.models.widget_image import WidgetImage as WidgetImageModel +from met_api.services import authorization +from met_api.utils.roles import Role + + +class WidgetImageService: + """Widget image management service.""" + + @staticmethod + def get_image(widget_id): + """Get image by widget id.""" + widget_image = WidgetImageModel.get_image(widget_id) + return widget_image + + @staticmethod + def create_image(widget_id, image_details: dict): + """Create image for the widget.""" + image_data = dict(image_details) + eng_id = image_data.get('engagement_id') + authorization.check_auth( + one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value), + engagement_id=eng_id, + ) + + widget_image = WidgetImageService._create_image_model(widget_id, image_data) + widget_image.commit() + return widget_image + + @staticmethod + def update_image(widget_id, image_widget_id, image_data): + """Update image widget.""" + widget_image: WidgetImageModel = WidgetImageModel.find_by_id(image_widget_id) + authorization.check_auth( + one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value), + engagement_id=widget_image.engagement_id, + ) + + if not widget_image: + raise KeyError('image widget not found') + + if widget_image.widget_id != widget_id: + raise ValueError('Invalid widgets and image') + + return WidgetImageModel.update_image(widget_id, image_data) + + @staticmethod + def _create_image_model(widget_id, image_data: dict): + image_model: WidgetImageModel = WidgetImageModel() + image_model.widget_id = widget_id + image_model.engagement_id = image_data.get('engagement_id') + image_model.image_url = image_data.get('image_url') + image_model.description = image_data.get('description') + image_model.alt_text = image_data.get('alt_text') + image_model.flush() + return image_model diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 803428fc1..a7c5f5fc3 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -501,6 +501,13 @@ class TestWidgetInfo(dict, Enum): 'created_date': datetime.now().strftime('%Y-%m-%d'), 'updated_date': datetime.now().strftime('%Y-%m-%d'), } + widget_image = { + 'widget_type_id': WidgetType.Image.value, + 'created_by': '123', + 'updated_by': '123', + 'created_date': datetime.now().strftime('%Y-%m-%d'), + 'updated_date': datetime.now().strftime('%Y-%m-%d'), + } class TestWidgetItemInfo(dict, Enum): diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index db80cd18a..9d52daf7f 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -162,6 +162,11 @@ const Endpoints = { CREATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines`, UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines/timeline_id`, }, + ImageWidgets: { + GET: `${AppConfig.apiUrl}/widgets/widget_id/images`, + CREATE: `${AppConfig.apiUrl}/widgets/widget_id/images`, + UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/images/image_widget_id`, + }, Tenants: { CREATE: `${AppConfig.apiUrl}/tenants/`, GET: `${AppConfig.apiUrl}/tenants/tenant_id`, diff --git a/met-web/src/apiManager/httpRequestHandler/index.ts b/met-web/src/apiManager/httpRequestHandler/index.ts index f6c5ad8b7..46d5776e6 100644 --- a/met-web/src/apiManager/httpRequestHandler/index.ts +++ b/met-web/src/apiManager/httpRequestHandler/index.ts @@ -26,13 +26,14 @@ const GetRequest = (url: string, params = {}, headers = {}, responseType?: st return axios.get(url, requestOptions); }; -const PostRequest = (url: string, data = {}, params = {}) => { +const PostRequest = (url: string, data = {}, params = {}, headers = {}) => { return axios.post(url, data, { params, headers: { 'Content-type': 'application/json', Authorization: `Bearer ${UserService.getToken()}`, 'tenant-id': `${sessionStorage.getItem('tenantId')}`, + ...headers, }, }); }; diff --git a/met-web/src/components/common/Input/TextInput.tsx b/met-web/src/components/common/Input/TextInput.tsx index d224caab9..8c5118443 100644 --- a/met-web/src/components/common/Input/TextInput.tsx +++ b/met-web/src/components/common/Input/TextInput.tsx @@ -17,7 +17,6 @@ type TextInputProps = { export const textInputStyles = { display: 'flex', - height: '48px', padding: '8px 16px', alignItems: 'center', justifyContent: 'center', @@ -56,7 +55,6 @@ export const TextInput: React.FC = ({ inputProps, ...textFieldProps }: TextInputProps) => { - // Exclude props that are not meant for the input element return ( ) => { - return ; + return ; }; // Define a Custom MUI Textfield diff --git a/met-web/src/components/engagement/admin/view/AuthoringTab.tsx b/met-web/src/components/engagement/admin/view/AuthoringTab.tsx index c25bc1026..6ed422126 100644 --- a/met-web/src/components/engagement/admin/view/AuthoringTab.tsx +++ b/met-web/src/components/engagement/admin/view/AuthoringTab.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { AuthoringValue, AuthoringButtonProps, StatusCircleProps } from './types'; -import { Header2 } from 'components/common/Typography'; +import { BodyText, Header2 } from 'components/common/Typography'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowRightLong } from '@fortawesome/pro-light-svg-icons'; import { faCheck } from '@fortawesome/pro-solid-svg-icons'; @@ -10,6 +10,9 @@ import { Unless, When } from 'react-if'; import { Grid } from '@mui/material'; import { colors } from 'styles/Theme'; import { Link } from 'components/common/Navigation'; +import { WidgetLocation } from 'models/widget'; +import WidgetPicker from '../create/widgets'; +import { OutlineBox } from 'components/common/Layout'; import { getDefaultAuthoringTabValues } from './AuthoringTabElements'; export const StatusCircle = (props: StatusCircleProps) => { @@ -176,6 +179,14 @@ export const AuthoringTab = () => { ))} + + + Widget Configuration (temporary location) + + + + + ); }; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Image/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Image/Form.tsx new file mode 100644 index 000000000..fa5c91a31 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Image/Form.tsx @@ -0,0 +1,255 @@ +import React, { useContext, useEffect } from 'react'; +import Divider from '@mui/material/Divider'; +import { Grid } from '@mui/material'; +import { MetLabel } from 'components/common'; +import { useForm, SubmitHandler, Controller } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { patchImage, postImage } from 'services/widgetService/ImageService'; +import { updatedDiff } from 'deep-object-diff'; +import { WidgetTitle } from '../WidgetTitle'; +import { Widget, WidgetLocation } from 'models/widget'; +import ImageUpload from 'components/imageUpload'; +import { useAsyncValue } from 'react-router-dom'; +import { ImageWidget } from 'models/imageWidget'; +import { Button, TextField } from 'components/common/Input'; +import { saveObject } from 'services/objectStorageService'; +import { SystemMessage } from 'components/common/Layout/SystemMessage'; +import { When } from 'react-if'; + +const Form = () => { + const schema = yup + .object({ + image_url: yup.string().required('Please upload an image'), + description: yup.string().max(500, 'Description cannot exceed 500 characters'), + alt_text: yup.string().max(255, 'Alt text cannot exceed 255 characters'), + }) + .required(); + + type ImageWidgetForm = yup.TypeOf; + const dispatch = useAppDispatch(); + const [widget, imageWidget] = useAsyncValue() as [Widget, ImageWidget]; + const [previewImage, setPreviewImage] = React.useState(null); + const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); + const [isCreating, setIsCreating] = React.useState(false); + + const imageForm = useForm({ + resolver: yupResolver(schema), + }); + + const { + handleSubmit, + reset, + setValue, + control, + formState: { errors }, + } = imageForm; + + useEffect(() => { + if (imageWidget) { + setValue('description', imageWidget.description || ''); + setValue('image_url', imageWidget.image_url); + setValue('alt_text', imageWidget.alt_text || ''); + } + }, [imageWidget]); + + const handleAddImageFile = (files: File[]) => { + if (files.length > 0) { + setPreviewImage(files[0]); + setValue('image_url', files[0].name, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + return; + } + + setPreviewImage(null); + setValue('image_url', '', { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + }; + + const handleUploadImageFile = async () => { + if (!previewImage) { + return; + } + try { + const savedImage = await saveObject(previewImage, { filename: previewImage.name }); + return savedImage?.filepath || ''; + } catch (error) { + console.error(error); + throw new Error('Error occurred during banner image upload'); + } + }; + + const createImage = async (data: ImageWidgetForm) => { + const validatedData = await schema.validate(data); + const { alt_text, image_url, description } = validatedData; + await postImage(widget.id, { + widget_id: widget.id, + engagement_id: widget.engagement_id, + image_url: image_url, + alt_text: alt_text, + description: description, + location: widget.location in WidgetLocation ? widget.location : null, + }); + dispatch(openNotification({ severity: 'success', text: 'A new image was successfully added' })); + }; + + const updateImage = async (data: ImageWidgetForm) => { + const validatedData = await schema.validate(data); + const updatedData = updatedDiff( + { + image_url: imageWidget.image_url, + alt_text: imageWidget.alt_text, + description: imageWidget.description, + }, + { + image_url: validatedData.image_url, + alt_text: validatedData.alt_text, + description: validatedData.description, + }, + ); + if (Object.keys(updatedData).length === 0) { + return; + } + await patchImage(imageWidget.widget_id, imageWidget.id, { + ...updatedData, + }); + dispatch(openNotification({ severity: 'success', text: 'The image widget was successfully updated' })); + }; + + const saveImageWidget = async (data: ImageWidgetForm) => { + if (!widget) { + return; + } + if (!imageWidget) { + return createImage(data); + } + return updateImage(data); + }; + + const uploadImageAndSubmit: React.FormEventHandler = async (event) => { + event.preventDefault(); + if (previewImage) { + const image_url = await handleUploadImageFile(); + setValue('image_url', image_url); + } + await handleSubmit(onSubmit)(); + }; + + const onSubmit: SubmitHandler = async (data: ImageWidgetForm) => { + if (!widget) { + return; + } + try { + setIsCreating(true); + await saveImageWidget(data); + setIsCreating(false); + reset({}); + handleWidgetDrawerOpen(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to add image' })); + setIsCreating(false); + } + }; + + return ( + + + + + + +
+ + + + ( + + )} + /> + + + {errors.description?.message} + + + + + + ( + + )} + /> + + + {errors.image_url?.message} + + + + + + ( + + )} + /> + + + Error: {errors.alt_text?.message} + + + + + + + + + + + + +
+
+
+ ); +}; + +export default Form; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Image/ImageOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Image/ImageOptionCard.tsx new file mode 100644 index 000000000..5d9606ea8 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Image/ImageOptionCard.tsx @@ -0,0 +1,105 @@ +import React, { useContext, useState } from 'react'; +import { MetPaper, MetLabel, MetDescription } 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 '../constants'; +import { WidgetTabValues } from '../type'; +import { useCreateWidgetMutation } from 'apiManager/apiSlices/widgets'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faImage } from '@fortawesome/pro-regular-svg-icons'; + +const Title = 'Image'; +const ImageOptionCard = () => { + 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.some((widget) => widget.widget_type_id === WidgetType.Image); + if (alreadyExists) { + handleWidgetDrawerTabValueChange(WidgetTabValues.IMAGE_FORM); + return; + } + + try { + setIsCreatingWidget(true); + await createWidget({ + widget_type_id: WidgetType.Image, + engagement_id: savedEngagement.id, + title: Title, + }).unwrap(); + await loadWidgets(); + dispatch( + openNotification({ + severity: 'success', + text: 'Image widget successfully created.', + }), + ); + setIsCreatingWidget(false); + handleWidgetDrawerTabValueChange(WidgetTabValues.IMAGE_FORM); + } catch (error) { + setIsCreatingWidget(false); + dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating Image widget' })); + handleWidgetDrawerOpen(false); + } + }; + + return ( + handleCreateWidget()} + > + + + + + + + + + + + + + + {Title} + + + Displays a static image, with optional caption + + + + + + + ); +}; + +export default ImageOptionCard; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Image/index.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Image/index.tsx new file mode 100644 index 000000000..3cdf97f7b --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Image/index.tsx @@ -0,0 +1,34 @@ +import React, { Suspense, useContext } from 'react'; +import Form from './Form'; +import { Grid } from '@mui/material'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { WidgetType } from 'models/widget'; +import { fetchImageWidgets } from 'services/widgetService/ImageService'; +import { Await } from 'react-router-dom'; +import { MidScreenLoader } from 'components/common'; + +export const ImageForm = () => { + const { widgets } = useContext(WidgetDrawerContext); + const widget = widgets.find((widget) => widget.widget_type_id === WidgetType.Image) ?? null; + const imageWidget = widget + ? fetchImageWidgets(widget.id).then((result) => (result.length ? result[result.length - 1] : null)) + : Promise.resolve(null); + const widgetBundle = Promise.all([widget, imageWidget]); + return ( + }> + +
+ + + ); +}; + +const LoadingCard = () => ( + + + + + +); + +export default ImageForm; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx index 5ebb203f3..bea1104df 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx @@ -14,121 +14,133 @@ export const WidgetCardSwitch = ({ singleSelection = false, widget, removeWidget const { handleWidgetDrawerOpen, handleWidgetDrawerTabValueChange } = useContext(WidgetDrawerContext); return ( - <> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.WHO_IS_LISTENING_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.DOCUMENT_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.SUBSCRIBE_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.EVENTS_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.MAP_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.VIDEO_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.TIMELINE_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - { - removeWidget(widget.id); - }} - onEdit={() => { - handleWidgetDrawerTabValueChange(WidgetTabValues.POLL_FORM); - handleWidgetDrawerOpen(true); - }} - /> - - - + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.WHO_IS_LISTENING_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.DOCUMENT_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.SUBSCRIBE_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.EVENTS_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.MAP_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.VIDEO_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.TIMELINE_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.POLL_FORM); + handleWidgetDrawerOpen(true); + }} + /> + + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.IMAGE_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 adb93b423..2ef150cb5 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx @@ -12,6 +12,7 @@ import VideoForm from './Video'; import TimelineForm from './Timeline'; import SubscribeForm from './Subscribe'; import PollForm from './Poll'; +import ImageForm from './Image'; const WidgetDrawerTabs = () => { const { widgetDrawerTabValue } = useContext(WidgetDrawerContext); @@ -45,6 +46,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 c4af95153..d83b9e803 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx @@ -9,6 +9,7 @@ import MapOptionCard from './Map/MapOptionCard'; import VideoOptionCard from './Video/VideoOptionCard'; import TimelineOptionCard from './Timeline/TimelineOptionCard'; import PollOptionCard from './Poll/PollOptionCard'; +import ImageOptionCard from './Image/ImageOptionCard'; const WidgetOptionCards = () => { return ( @@ -41,6 +42,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 c4320efde..4c0e139f1 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/type.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/type.tsx @@ -9,4 +9,5 @@ export const WidgetTabValues = { VIDEO_FORM: 'VIDEO_FORM', TIMELINE_FORM: 'TIMELINE_FORM', POLL_FORM: 'POLL_FORM', + IMAGE_FORM: 'IMAGE_FORM', }; diff --git a/met-web/src/components/engagement/old-view/widgets/Image/ImageWidgetView.tsx b/met-web/src/components/engagement/old-view/widgets/Image/ImageWidgetView.tsx new file mode 100644 index 000000000..2a8416f9a --- /dev/null +++ b/met-web/src/components/engagement/old-view/widgets/Image/ImageWidgetView.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState } from 'react'; +import { MetPaper } from 'components/common'; +import { Grid, Skeleton, Paper } from '@mui/material'; +import { Widget } from 'models/widget'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { ImageWidget } from 'models/imageWidget'; +import { fetchImageWidgets } from 'services/widgetService/ImageService'; +import { BodyText, Header2 } from 'components/common/Typography'; + +interface ImageWidgetProps { + widget: Widget; +} + +const ImageWidgetView = ({ widget }: ImageWidgetProps) => { + const dispatch = useAppDispatch(); + const [imageWidget, setImageWidget] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const fetchImage = async () => { + try { + const images = await fetchImageWidgets(widget.id); + const image = images[images.length - 1]; + setImageWidget(image); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while fetching widget information', + }), + ); + } + }; + + useEffect(() => { + fetchImage(); + }, [widget]); + + if (isLoading) { + return ( + + + + + + + + + + + + + ); + } + + if (!imageWidget) { + return null; + } + + return ( + + + {widget.title} + + + {imageWidget.description} + + + + {imageWidget.alt_text} + + + + ); +}; + +export default ImageWidgetView; diff --git a/met-web/src/components/engagement/old-view/widgets/WidgetSwitch.tsx b/met-web/src/components/engagement/old-view/widgets/WidgetSwitch.tsx index 06ad0ddef..831a65656 100644 --- a/met-web/src/components/engagement/old-view/widgets/WidgetSwitch.tsx +++ b/met-web/src/components/engagement/old-view/widgets/WidgetSwitch.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Widget, WidgetType } from 'models/widget'; -import { Switch, Case } from 'react-if'; +import { Switch, Case, Default } from 'react-if'; import WhoIsListeningWidget from './WhoIsListeningWidget'; import DocumentWidget from './DocumentWidget'; import SubscribeWidget from './Subscribe/SubscribeWidget'; @@ -9,6 +9,7 @@ import MapWidget from './Map/MapWidget'; import VideoWidgetView from './Video/VideoWidgetView'; import TimelineWidgetView from './Timeline/TimelineWidgetView'; import PollWidgetView from './Poll/PollWidgetView'; +import ImageWidgetView from './Image/ImageWidgetView'; interface WidgetSwitchProps { widget: Widget; } @@ -41,6 +42,10 @@ export const WidgetSwitch = ({ widget }: WidgetSwitchProps) => { + + + + Error: Unknown widget type! ); diff --git a/met-web/src/components/engagement/public/view/EngagementDescription.tsx b/met-web/src/components/engagement/public/view/EngagementDescription.tsx index ba79994e3..bc7469afb 100644 --- a/met-web/src/components/engagement/public/view/EngagementDescription.tsx +++ b/met-web/src/components/engagement/public/view/EngagementDescription.tsx @@ -10,7 +10,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowLeftLong } from '@fortawesome/pro-light-svg-icons'; import { Widget } from 'models/widget'; import { WidgetSwitch } from 'components/engagement/old-view/widgets/WidgetSwitch'; -import { BaseTheme, DarkTheme } from 'styles/Theme'; +import { DarkTheme } from 'styles/Theme'; import { RichTextArea } from 'components/common/Input/RichTextArea'; export const EngagementDescription = () => { @@ -83,27 +83,25 @@ export const EngagementDescription = () => { }> - - - {(resolvedWidgets: Widget[]) => { - const widget = resolvedWidgets?.[0]; - if (widget) - return ( - - ; - - ); - }} - - + + {(resolvedWidgets: Widget[]) => { + const widget = resolvedWidgets[0]; + if (widget) + return ( + + + + ); + }} + diff --git a/met-web/src/components/imageUpload/index.tsx b/met-web/src/components/imageUpload/index.tsx index 6a1f53cf0..3639137d9 100644 --- a/met-web/src/components/imageUpload/index.tsx +++ b/met-web/src/components/imageUpload/index.tsx @@ -48,7 +48,7 @@ export const ImageUpload = ({ Supported formats: JPG, PNG, WEBP - diff --git a/met-web/src/models/imageWidget.ts b/met-web/src/models/imageWidget.ts new file mode 100644 index 000000000..1d6377a46 --- /dev/null +++ b/met-web/src/models/imageWidget.ts @@ -0,0 +1,8 @@ +export interface ImageWidget { + id: number; + widget_id: number; + engagement_id: number; + image_url: string; + alt_text: string; + description: string; +} diff --git a/met-web/src/models/widget.tsx b/met-web/src/models/widget.tsx index a0bbff8b1..3980ee435 100644 --- a/met-web/src/models/widget.tsx +++ b/met-web/src/models/widget.tsx @@ -24,6 +24,7 @@ export enum WidgetType { CACForm = 8, Timeline = 9, Poll = 10, + Image = 11, } export enum WidgetLocation { diff --git a/met-web/src/services/widgetService/ImageService/index.tsx b/met-web/src/services/widgetService/ImageService/index.tsx new file mode 100644 index 000000000..b97b21206 --- /dev/null +++ b/met-web/src/services/widgetService/ImageService/index.tsx @@ -0,0 +1,60 @@ +import http from 'apiManager/httpRequestHandler'; +import Endpoints from 'apiManager/endpoints'; +import { replaceAllInURL, replaceUrl } from 'helper'; +import { ImageWidget } from 'models/imageWidget'; +import { WidgetLocation } from 'models/widget'; + +export const fetchImageWidgets = async (widget_id: number): Promise => { + try { + const url = replaceUrl(Endpoints.ImageWidgets.GET, 'widget_id', String(widget_id)); + const responseData = await http.GetRequest(url); + return responseData.data ?? []; + } catch (err) { + return Promise.reject(err); + } +}; + +interface PostImageRequest { + widget_id: number; + engagement_id: number; + image_url: string; + alt_text?: string; + description?: string; + location: WidgetLocation | null; +} + +export const postImage = async (widget_id: number, data: PostImageRequest): Promise => { + try { + const url = replaceUrl(Endpoints.ImageWidgets.CREATE, 'widget_id', String(widget_id)); + const response = await http.PostRequest(url, data); + return response.data || Promise.reject('Failed to create image widget'); + } catch (err) { + return Promise.reject(err); + } +}; + +interface PatchImageRequest { + image_url?: string; + alt_text?: string; + description?: string; +} + +export const patchImage = async ( + widget_id: number, + image_widget_id: number, + data: PatchImageRequest, +): Promise => { + try { + const url = replaceAllInURL({ + URL: Endpoints.ImageWidgets.UPDATE, + params: { + widget_id: String(widget_id), + image_widget_id: String(image_widget_id), + }, + }); + const response = await http.PatchRequest(url, data); + return response.data || Promise.reject('Failed to create image widget'); + } catch (err) { + return Promise.reject(err); + } +}; diff --git a/met-web/src/services/widgetService/MapService/index.tsx b/met-web/src/services/widgetService/MapService/index.tsx index de1aa1edb..1b127cb21 100644 --- a/met-web/src/services/widgetService/MapService/index.tsx +++ b/met-web/src/services/widgetService/MapService/index.tsx @@ -40,7 +40,12 @@ export const postMap = async (widget_id: number, data: PostMapRequest): Promise< } formdata.append('engagement_id', data.engagement_id.toString()); formdata.append('marker_label', data.marker_label ? data.marker_label : ''); - const response = await http.PostRequest(url, formdata); + const response = await http.PostRequest( + url, + formdata, + {}, + { 'Content-type': 'multipart/form-data' }, + ); if (response.data) { return response.data; } @@ -56,11 +61,12 @@ interface PreviewShapefileRequest { export const previewShapeFile = async (data: PreviewShapefileRequest): Promise => { try { + const url = Endpoints.Maps.SHAPEFILE_PREVIEW; const formdata = new FormData(); if (data.file) { - formdata.append('file', data.file); + formdata.append('file', data.file, data.file.name); } - const response = await http.PostRequest(Endpoints.Maps.SHAPEFILE_PREVIEW, formdata); + const response = await http.PostRequest(url, formdata, {}, { 'Content-type': 'multipart/form-data' }); if (response.data) { return response.data; } diff --git a/met-web/tests/unit/components/factory.ts b/met-web/tests/unit/components/factory.ts index 99092cd52..31d02d593 100644 --- a/met-web/tests/unit/components/factory.ts +++ b/met-web/tests/unit/components/factory.ts @@ -19,6 +19,7 @@ import { Tenant } from 'models/tenant'; import { EngagementContent } from 'models/engagementContent'; import { UserState } from 'services/userService/types'; import { USER_ROLES } from 'services/userService/constants'; +import { ImageWidget } from 'models/imageWidget'; const tenant: Tenant = { name: 'Tenant 1', @@ -214,6 +215,15 @@ const timeLineWidget: Widget = { location: 1, }; +const imageWidget: Widget = { + id: 1, + title: 'Image', + widget_type_id: WidgetType.Image, + engagement_id: 1, + items: [], + location: 1, +}; + const mockPollAnswer1: PollAnswer = { id: 0, answer_text: 'answer 1', @@ -242,6 +252,15 @@ const mockVideo: VideoWidget = { description: 'Video description', }; +const mockImage: ImageWidget = { + id: 1, + widget_id: 1, + engagement_id: 1, + image_url: 'https://image.url', + alt_text: 'Image alt text', + description: 'Image description', +}; + const mockTimeLineEvent1: TimelineEvent = { id: 1, engagement_id: 1, @@ -261,6 +280,7 @@ const mockTimeLine: TimelineWidget = { description: 'Time Line Description', events: [mockTimeLineEvent1], }; + const engagementMetadata: EngagementMetadata = { engagement_id: 1, taxon_id: 1, @@ -344,6 +364,8 @@ export { videoWidget, mockVideo, timeLineWidget, + imageWidget, + mockImage, mockTimeLine, subscribeWidget, engagementContentData,