diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 92aa8784f..e12ec525a 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,6 +1,10 @@ # Change Log -All notable changes to this project will be documented in this file. +All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/). + +## December 28, 2023 + +> **Feature**: Added the timeline widget. [🎟️DESENG-439](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-439) ## December 11, 2023 diff --git a/analytics-api/src/analytics_api/config.py b/analytics-api/src/analytics_api/config.py index 115518b40..269b4a411 100644 --- a/analytics-api/src/analytics_api/config.py +++ b/analytics-api/src/analytics_api/config.py @@ -28,7 +28,7 @@ load_dotenv(find_dotenv()) -def get_named_config(environment: str | None) -> '_Config': +def get_named_config(environment: 'str | None') -> '_Config': """ Retrieve a configuration object by name. Used by the Flask app factory. diff --git a/docs/MET_database_ERD.md b/docs/MET_database_ERD.md index db6470461..afd7018f1 100644 --- a/docs/MET_database_ERD.md +++ b/docs/MET_database_ERD.md @@ -130,6 +130,33 @@ erDiagram string updated_by } widget only one to zero or more widget_video : has + widget_timeline { + integer id PK + integer widget_id FK "The id from widget" + integer engagement_id FK "The id from engagement" + string title + string description + timestamp created_date + timestamp updated_date + string created_by + string updated_by + } + widget only one to zero or more widget_timeline : has + timeline_event { + integer id PK + integer widget_id FK "The id from widget" + integer engagement_id FK "The id from engagement" + integer timeline_id FK "The id from timeline" + string description + string time + enum status + integer position + timestamp created_date + timestamp updated_date + string created_by + string updated_by + } + widget only one to zero or more timeline_event : has widget_documents { integer id PK string title diff --git a/met-api/migrations/versions/3e4dc76a96ab_added_the_timeline_widget.py b/met-api/migrations/versions/3e4dc76a96ab_added_the_timeline_widget.py new file mode 100644 index 000000000..e2619da49 --- /dev/null +++ b/met-api/migrations/versions/3e4dc76a96ab_added_the_timeline_widget.py @@ -0,0 +1,44 @@ +"""Added the Timeline Widget. + +Revision ID: 3e4dc76a96ab +Revises: 02ff8ecc6b91 +Create Date: 2023-12-05 17:04:46.304368 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql +from met_api.constants.timeline_event_status import TimelineEventStatus + +# revision identifiers, used by Alembic. +revision = '3e4dc76a96ab' +down_revision = '02ff8ecc6b91' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('widget_timeline', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column('engagement_id', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_table('timeline_event', + sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True, nullable=False), + sa.Column('engagement_id', sa.Integer(), nullable=False), + sa.Column('timeline_id', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum(TimelineEventStatus), nullable=True), + sa.Column('position', sa.Integer(), nullable=True), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('time', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['engagement_id'], ['engagement.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['timeline_id'], ['widget_timeline.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + +def downgrade(): + op.drop_table('widget_timeline') + op.drop_table('timeline_event') diff --git a/met-api/migrations/versions/4114001e1a4c_add_widget_id_to_widget_timeline_and_.py b/met-api/migrations/versions/4114001e1a4c_add_widget_id_to_widget_timeline_and_.py new file mode 100644 index 000000000..a34d93048 --- /dev/null +++ b/met-api/migrations/versions/4114001e1a4c_add_widget_id_to_widget_timeline_and_.py @@ -0,0 +1,27 @@ +"""Add widget_id to widget_timeline and timeline_event tables + +Revision ID: 4114001e1a4c +Revises: c09e77fde608 +Create Date: 2023-12-11 15:46:30.773046 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '4114001e1a4c' +down_revision = 'c09e77fde608' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('widget_timeline', sa.Column('widget_id', sa.Integer())) + op.create_foreign_key('timeline_widget_fk', 'widget_timeline', 'widget', ['widget_id'], ['id'], ondelete='CASCADE') + op.add_column('timeline_event', sa.Column('widget_id', sa.Integer())) + op.create_foreign_key('event_widget_fk', 'timeline_event', 'widget', ['widget_id'], ['id'], ondelete='CASCADE') + +def downgrade(): + op.drop_column('widget_timeline', 'widget_id') + op.drop_column('timeline_event', 'widget_id') diff --git a/met-api/migrations/versions/c09e77fde608_added_enum_value_for_timeline_widget.py b/met-api/migrations/versions/c09e77fde608_added_enum_value_for_timeline_widget.py new file mode 100644 index 000000000..0d55ae41d --- /dev/null +++ b/met-api/migrations/versions/c09e77fde608_added_enum_value_for_timeline_widget.py @@ -0,0 +1,61 @@ +"""Added enum value for Timeline Widget. + +Revision ID: c09e77fde608 +Revises: 3e4dc76a96ab +Create Date: 2023-12-06 11:46:20.934373 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c09e77fde608' +down_revision = '3e4dc76a96ab' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('timeline_event', sa.Column('created_date', sa.DateTime(), nullable=False)) + op.add_column('timeline_event', sa.Column('updated_date', sa.DateTime(), nullable=True)) + op.add_column('timeline_event', sa.Column('created_by', sa.String(length=50), nullable=True)) + op.add_column('timeline_event', sa.Column('updated_by', sa.String(length=50), nullable=True)) + + op.add_column('widget_timeline', sa.Column('created_date', sa.DateTime(), nullable=False)) + op.add_column('widget_timeline', sa.Column('updated_date', sa.DateTime(), nullable=True)) + op.add_column('widget_timeline', sa.Column('created_by', sa.String(length=50), nullable=True)) + op.add_column('widget_timeline', sa.Column('updated_by', sa.String(length=50), nullable=True)) + + 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': 8, + 'name': 'CAC Form', + 'description': 'Add a CAC Form to your project', + }, + { + 'id': 9, + 'name': 'Timeline', + 'description': 'Create a timeline for a series of events', + }, + ] + ) + +def downgrade(): + op.drop_column('widget_timeline', 'updated_by') + op.drop_column('widget_timeline', 'created_by') + op.drop_column('widget_timeline', 'updated_date') + op.drop_column('widget_timeline', 'created_date') + op.drop_column('timeline_event', 'updated_by') + op.drop_column('timeline_event', 'created_by') + op.drop_column('timeline_event', 'updated_date') + op.drop_column('timeline_event', 'created_date') + op.delete(widget_type_table).filter_by(id=8) + op.delete(widget_type_table).filter_by(id=9) diff --git a/met-api/src/met_api/config.py b/met-api/src/met_api/config.py index 1c1d26e7d..b022ca549 100644 --- a/met-api/src/met_api/config.py +++ b/met-api/src/met_api/config.py @@ -36,7 +36,7 @@ os.environ = {k: v for k, v in os.environ.items() if v} -def get_named_config(environment: str | None) -> 'Config': +def get_named_config(environment: 'str | None') -> 'Config': """ Retrieve a configuration object by name. Used by the Flask app factory. diff --git a/met-api/src/met_api/constants/timeline_event_status.py b/met-api/src/met_api/constants/timeline_event_status.py new file mode 100644 index 000000000..20a52e107 --- /dev/null +++ b/met-api/src/met_api/constants/timeline_event_status.py @@ -0,0 +1,10 @@ +"""Constants of timeline events.""" +from enum import IntEnum + + +class TimelineEventStatus(IntEnum): + """Enum of timeline event status status.""" + + Pending = 1 + InProgress = 2 + Completed = 3 \ No newline at end of file diff --git a/met-api/src/met_api/models/__init__.py b/met-api/src/met_api/models/__init__.py index 88ff3621f..b61d5a5af 100644 --- a/met-api/src/met_api/models/__init__.py +++ b/met-api/src/met_api/models/__init__.py @@ -49,3 +49,5 @@ from .report_setting import ReportSetting from .widget_video import WidgetVideo from .cac_form import CACForm +from .widget_timeline import WidgetTimeline +from .timeline_event import TimelineEvent diff --git a/met-api/src/met_api/models/timeline_event.py b/met-api/src/met_api/models/timeline_event.py new file mode 100644 index 000000000..7a80a12dc --- /dev/null +++ b/met-api/src/met_api/models/timeline_event.py @@ -0,0 +1,51 @@ +"""Timeline Event model class. + +Manages the timeline events +""" +from __future__ import annotations + +from sqlalchemy.sql.schema import ForeignKey +from met_api.constants.timeline_event_status import TimelineEventStatus + +from .base_model import BaseModel +from .db import db + + +class TimelineEvent(BaseModel): + """Definition of the TimelineEvent entity.""" + + __tablename__ = 'timeline_event' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True) + timeline_id = db.Column(db.Integer, ForeignKey('widget_timeline.id', ondelete='CASCADE'), nullable=True) + status = db.Column(db.Enum(TimelineEventStatus), nullable=False) + position = db.Column(db.Integer, nullable=False) + description = db.Column(db.Text(), nullable=True) + time = db.Column(db.String(255), nullable=True) + + @classmethod + def delete_event(cls, timeline_id): + """Delete timeline.""" + timeline_event = db.session.query(TimelineEvent) \ + .filter(TimelineEvent.timeline_id == timeline_id) + timeline_event.delete() + db.session.commit() + + @classmethod + def get_timeline_events(cls, timeline_id) -> list[TimelineEvent]: + """Get timeline event.""" + timeline_event = db.session.query(TimelineEvent) \ + .filter(TimelineEvent.timeline_id == timeline_id) \ + .all() + return timeline_event + + @classmethod + def update_timeline_event(cls, timeline_id, event_data: dict) -> TimelineEvent: + """Update timeline event.""" + timeline_event: TimelineEvent = TimelineEvent.query.get(timeline_id) + if timeline_event: + for key, value in event_data.items(): + setattr(timeline_event, key, value) + timeline_event.save() + return timeline_event diff --git a/met-api/src/met_api/models/widget_timeline.py b/met-api/src/met_api/models/widget_timeline.py new file mode 100755 index 000000000..68a007793 --- /dev/null +++ b/met-api/src/met_api/models/widget_timeline.py @@ -0,0 +1,45 @@ +"""WidgetTimeline model class. + +Manages the timeline widget +""" +from __future__ import annotations +from typing import Optional +from sqlalchemy.sql.schema import ForeignKey +from met_api.models.timeline_event import TimelineEvent +from met_api.services.timeline_event_service import TimelineEventService +from .base_model import BaseModel +from .db import db + +class WidgetTimeline(BaseModel): # pylint: disable=too-few-public-methods, too-many-instance-attributes + """Definition of the Timeline entity.""" + + __tablename__ = 'widget_timeline' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE'), nullable=True) + widget_id = db.Column(db.Integer, ForeignKey('widget.id', ondelete='CASCADE'), nullable=True) + title = db.Column(db.String(255), nullable=True) + description = db.Column(db.Text(), nullable=True) + + # Relationship to timeline_event + events = db.relationship(TimelineEvent, backref='widget_timeline', lazy=True) + + @classmethod + def get_timeline(cls, timeline_id) -> list[WidgetTimeline]: + """Get timeline.""" + widget_timeline = db.session.query(WidgetTimeline) \ + .filter(WidgetTimeline.widget_id == timeline_id) \ + .all() + return widget_timeline + + @classmethod + def update_timeline(cls, timeline_id, timeline_data: dict) -> Optional[WidgetTimeline or None]: + """Update timeline.""" + TimelineEvent.delete_event(timeline_id) + widget_timeline: WidgetTimeline = WidgetTimeline.query.get(timeline_id) + if widget_timeline: + widget_timeline.title = timeline_data.get('title') + widget_timeline.description = timeline_data.get('description') + for event in timeline_data.get('events', []): + TimelineEventService.create_timeline_event(timeline_id, event) + widget_timeline.save() + return widget_timeline diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 2451f6c11..57db720bc 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -49,6 +49,7 @@ from .widget_video import API as WIDGET_VIDEO_API from .engagement_settings import API as ENGAGEMENT_SETTINGS_API from .cac_form import API as CAC_FORM_API +from .widget_timeline import API as WIDGET_TIMELINE_API __all__ = ('API_BLUEPRINT',) @@ -89,3 +90,4 @@ API.add_namespace(WIDGET_VIDEO_API, path='/widgets//videos') API.add_namespace(ENGAGEMENT_SETTINGS_API) API.add_namespace(CAC_FORM_API, path='/engagements//cacform') +API.add_namespace(WIDGET_TIMELINE_API, path='/widgets//timelines') diff --git a/met-api/src/met_api/resources/widget_timeline.py b/met-api/src/met_api/resources/widget_timeline.py new file mode 100644 index 000000000..19344a4c5 --- /dev/null +++ b/met-api/src/met_api/resources/widget_timeline.py @@ -0,0 +1,79 @@ +# 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 timeline widget resource.""" +from http import HTTPStatus + +from flask import jsonify, 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_timeline import WidgetTimelineSchema +from met_api.services.widget_timeline_service import WidgetTimelineService +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('widget_timelines', description='Endpoints for Timeline Widget Management') +"""Widget Timelines""" + + +@cors_preflight('GET, POST') +@API.route('') +class Timelines(Resource): + """Resource for managing timeline widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + def get(widget_id): + """Get timeline widget.""" + try: + widget_timeline = WidgetTimelineService().get_timeline(widget_id) + return jsonify(WidgetTimelineSchema().dump(widget_timeline, many=True)), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(widget_id): + """Create timeline widget.""" + try: + request_json = request.get_json() + widget_timeline = WidgetTimelineService().create_timeline(widget_id, request_json) + return WidgetTimelineSchema().dump(widget_timeline), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code + + +@cors_preflight('PATCH') +@API.route('/') +class Timeline(Resource): + """Resource for managing timeline widgets.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(widget_id, timeline_widget_id): + """Update timeline widget.""" + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'timeline_widget_update') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + try: + widget_timeline = WidgetTimelineService().update_timeline(widget_id, timeline_widget_id, request_json) + return WidgetTimelineSchema().dump(widget_timeline), HTTPStatus.OK + except BusinessException as err: + return str(err), err.status_code diff --git a/met-api/src/met_api/schemas/schemas/timeline_widget_update.json b/met-api/src/met_api/schemas/schemas/timeline_widget_update.json new file mode 100644 index 000000000..3cad6755e --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/timeline_widget_update.json @@ -0,0 +1,79 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/timeline_widget_update", + "type": "object", + "title": "The root schema", + "description": "The root schema comprises the entire JSON document.", + "default": {}, + "examples": [ + { + "title": "A timeline widget title", + "description": "A timeline widget description", + "events": [ + { + "id": 3, + "engagement_id": 4, + "timeline_id": 9, + "widget_id": 111, + "description": "An event description", + "time": "An event time", + "position": 0, + "status": 1 + }, + { + "id": 4, + "engagement_id": 4, + "timeline_id": 9, + "widget_id": 111, + "description": "An event description", + "time": "An event time", + "position": 1, + "status": 2 + } + ] + } + ], + "required": [], + "properties": { + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Timeline title", + "description": "The title of this timeline.", + "examples": ["A timeline widget title"] + }, + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Timeline description", + "description": "The description of this timeline.", + "examples": ["A timeline widget description"] + }, + "events": { + "$id": "#/properties/events", + "type": "array", + "title": "Timeline events", + "description": "The events of this timeline.", + "examples": [{ + "id": 3, + "engagement_id": 4, + "timeline_id": 9, + "widget_id": 111, + "description": "An event description", + "time": "An event time", + "position": 0, + "status": 1 + }, + { + "id": 4, + "engagement_id": 4, + "timeline_id": 9, + "widget_id": 111, + "description": "An event description", + "time": "An event time", + "position": 1, + "status": 2 + }] + } + } +} diff --git a/met-api/src/met_api/schemas/widget_timeline.py b/met-api/src/met_api/schemas/widget_timeline.py new file mode 100644 index 000000000..52ae8f133 --- /dev/null +++ b/met-api/src/met_api/schemas/widget_timeline.py @@ -0,0 +1,39 @@ +# 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 timeline schema.""" + +from met_api.models.widget_timeline import WidgetTimeline as WidgetTimelineModel +from met_api.models.timeline_event import TimelineEvent as TimelineEventModel + +from marshmallow import Schema +from marshmallow_sqlalchemy.fields import Nested + +class TimelineEventSchema(Schema): # pylint: disable=too-many-ancestors, too-few-public-methods + + class Meta: # pylint: disable=too-few-public-methods + """All of the fields in the Timeline Event schema.""" + + model = TimelineEventModel + fields = ('id', 'engagement_id', 'widget_id', 'timeline_id', 'description', 'time', 'position', 'status') + + +class WidgetTimelineSchema(Schema): # pylint: disable=too-many-ancestors, too-few-public-methods + + class Meta: # pylint: disable=too-few-public-methods + """All of the fields in the Widget Timeline schema.""" + + model = WidgetTimelineModel + fields = ('id', 'engagement_id', 'widget_id', 'title', 'description', 'events') + + events = Nested(TimelineEventSchema, many=True) diff --git a/met-api/src/met_api/services/timeline_event_service.py b/met-api/src/met_api/services/timeline_event_service.py new file mode 100644 index 000000000..1f9cf87a5 --- /dev/null +++ b/met-api/src/met_api/services/timeline_event_service.py @@ -0,0 +1,55 @@ +"""Service for Widget Timeline management.""" +from met_api.constants.membership_type import MembershipType +from met_api.models.widget_timeline import TimelineEvent as TimelineEventModel +from met_api.services import authorization +from met_api.utils.roles import Role + + +class TimelineEventService: + """Timeline event management service.""" + + @staticmethod + def get_timeline_event(timeline_id): + """Get timeline event by timeline id.""" + timeline_event = TimelineEventModel.get_event(timeline_id) + return timeline_event + + @staticmethod + def create_timeline_event(timeline_id, event_details: dict): + """Create timeline event for a timeline.""" + event_data = dict(event_details) + eng_id = event_data.get('engagement_id') + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + timeline_event = TimelineEventService._create_timeline_event_model(timeline_id, event_data) + timeline_event.commit() + return timeline_event + + @staticmethod + def update_timeline_event(timeline_id, event_id, event_data): + """Update timeline event.""" + timeline_event: TimelineEventModel = TimelineEventModel.find_by_id(event_id) + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=timeline_event.engagement_id) + + if not timeline_event: + raise KeyError('Event not found') + + if timeline_event.timeline_id != timeline_id: + raise ValueError('Invalid timeline and event') + + return TimelineEventModel.update_timeline_event(timeline_event.id, event_data) + + @staticmethod + def _create_timeline_event_model(timeline_id, event_data: dict): + timeline_event_model: TimelineEventModel = TimelineEventModel() + timeline_event_model.widget_id = event_data.get('widget_id') + timeline_event_model.engagement_id = event_data.get('engagement_id') + timeline_event_model.timeline_id = timeline_id + timeline_event_model.status = event_data.get('status') + timeline_event_model.position = event_data.get('position') + timeline_event_model.description = event_data.get('description') + timeline_event_model.time = event_data.get('time') + timeline_event_model.flush() + return timeline_event_model diff --git a/met-api/src/met_api/services/widget_timeline_service.py b/met-api/src/met_api/services/widget_timeline_service.py new file mode 100644 index 000000000..5cd436012 --- /dev/null +++ b/met-api/src/met_api/services/widget_timeline_service.py @@ -0,0 +1,69 @@ +"""Service for Widget Timeline management.""" +from met_api.constants.membership_type import MembershipType +from met_api.models.widget_timeline import WidgetTimeline as WidgetTimelineModel +from met_api.models.timeline_event import TimelineEvent as TimelineEventModel +from met_api.services import authorization +from met_api.utils.roles import Role + + +class WidgetTimelineService: + """Widget Timeline management service.""" + + @staticmethod + def get_timeline(widget_id: int): + """Get timeline by widget id.""" + widget_timeline = WidgetTimelineModel.get_timeline(widget_id) + return widget_timeline + + @staticmethod + def create_timeline(widget_id: int, timeline_details: dict): + """Create timeline for the widget.""" + timeline_data = dict(timeline_details) + eng_id = timeline_data.get('engagement_id') + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=eng_id) + + widget_timeline = WidgetTimelineService._create_timeline_model(widget_id, timeline_data) + widget_timeline.commit() + return widget_timeline + + @staticmethod + def update_timeline(widget_id: int, timeline_id: int, timeline_data: dict): + """Update timeline widget.""" + events = timeline_data.get("events") + first_event = events[0] + widget_timeline: WidgetTimelineModel = WidgetTimelineModel.find_by_id(timeline_id) + authorization.check_auth(one_of_roles=(MembershipType.TEAM_MEMBER.name, + Role.EDIT_ENGAGEMENT.value), engagement_id=first_event.get('engagement_id')) + if not widget_timeline: + raise KeyError('Timeline widget not found') + + if widget_timeline.widget_id != widget_id: + raise ValueError('Invalid widget ID') + + if widget_timeline.id != timeline_id: + raise ValueError('Invalid timeline ID') + + return WidgetTimelineModel.update_timeline(timeline_id, timeline_data) + + @staticmethod + def _create_timeline_model(widget_id: int, timeline_data: dict): + timeline_model: WidgetTimelineModel = WidgetTimelineModel() + timeline_model.widget_id = widget_id + timeline_model.engagement_id = timeline_data.get('engagement_id') + timeline_model.title = timeline_data.get('title') + timeline_model.description = timeline_data.get('description') + for event in timeline_data.get('events', []): + timeline_model.events.append( + TimelineEventModel( + widget_id = widget_id, + engagement_id = event.get('engagement_id'), + timeline_id = event.get('timeline_id'), + description = event.get('description'), + time = event.get('time'), + position = event.get('position'), + status = event.get('status'), + ) + ) + timeline_model.flush() + return timeline_model diff --git a/met-cron/config.py b/met-cron/config.py index 47cdda6bb..070319461 100644 --- a/met-cron/config.py +++ b/met-cron/config.py @@ -37,7 +37,7 @@ } -def get_named_config(environment: str | None) -> '_Config': +def get_named_config(environment: 'str | None') -> '_Config': """ Retrieve a configuration object by name. Used by the Flask app factory. diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 366337c76..a228feb0b 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -134,6 +134,11 @@ const Endpoints = { CREATE: `${AppConfig.apiUrl}/widgets/widget_id/videos`, UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/videos/video_widget_id`, }, + TimelineWidgets: { + GET: `${AppConfig.apiUrl}/widgets/widget_id/timelines`, + CREATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines`, + UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines/timeline_id`, + }, Tenants: { GET: `${AppConfig.apiUrl}/tenants/tenant_id`, }, diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Timeline/Form.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/Form.tsx new file mode 100644 index 000000000..fdbf8016d --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/Form.tsx @@ -0,0 +1,381 @@ +import React, { useContext, useEffect } from 'react'; +import Divider from '@mui/material/Divider'; +import { Grid, MenuItem, TextField, Select, SelectChangeEvent } from '@mui/material'; +import { MetDescription, MetLabel, MidScreenLoader, PrimaryButton, SecondaryButton } from 'components/common'; +import { SubmitHandler } from 'react-hook-form'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { WidgetDrawerContext } from '../WidgetDrawerContext'; +import { TimelineContext } from './TimelineContext'; +import { patchTimeline, postTimeline } from 'services/widgetService/TimelineService'; +import { WidgetTitle } from '../WidgetTitle'; +import { TimelineEvent } from 'models/timelineWidget'; + +interface DetailsForm { + title: string; + description: string; + events: TimelineEvent[]; +} + +interface WidgetState { + title: string; + description: string; +} + +const Form = () => { + const dispatch = useAppDispatch(); + const { widget, isLoadingTimelineWidget, timelineWidget } = useContext(TimelineContext); + const { handleWidgetDrawerOpen } = useContext(WidgetDrawerContext); + const [isCreating, setIsCreating] = React.useState(false); + + const newEvent: TimelineEvent = { + id: 0, + widget_id: widget?.id || 0, + timeline_id: 0, + engagement_id: widget?.engagement_id || 0, + description: '', + time: '', + status: 1, + position: 0, + }; + + const [timelineEvents, setTimelineEvents] = React.useState( + timelineWidget ? timelineWidget.events.sort((a, b) => a.position - b.position) : [newEvent], + ); + const [timelineWidgetState, setTimelineWidgetState] = React.useState({ + description: timelineWidget?.description || '', + title: timelineWidget?.title || '', + }); + + const timelineEventStatusTypes = [ + { + title: 'Pending', + value: 1, + }, + { + title: 'In Progress', + value: 2, + }, + { + title: 'Completed', + value: 3, + }, + ]; + + useEffect(() => { + if (timelineWidget) { + setTimelineEvents(timelineWidget.events.sort((a, b) => a.position - b.position)); + setTimelineWidgetState(timelineWidget); + } + }, [timelineWidget]); + + const createTimeline = async (data: DetailsForm) => { + if (!widget) { + return; + } + + const { title, description, events } = data; + await postTimeline(widget.id, { + widget_id: widget.id, + engagement_id: widget.engagement_id, + title: title, + description: description, + events: events, + }); + dispatch(openNotification({ severity: 'success', text: 'A new timeline was successfully added' })); + }; + + const updateTimeline = async (data: DetailsForm) => { + if (!widget || !timelineWidget) { + return; + } + + if (Object.keys(data).length === 0) { + return; + } + + await patchTimeline(widget.id, timelineWidget.id, { ...data }); + dispatch(openNotification({ severity: 'success', text: 'The timeline widget was successfully updated' })); + }; + + const saveTimelineWidget = (data: DetailsForm) => { + if (!timelineWidget) { + return createTimeline(data); + } + return updateTimeline(data); + }; + + const onSubmit: SubmitHandler = async (data: DetailsForm) => { + if (!widget) { + return; + } + try { + setIsCreating(true); + await saveTimelineWidget(data); + setIsCreating(false); + handleWidgetDrawerOpen(false); + } catch (error) { + dispatch(openNotification({ severity: 'error', text: 'An error occurred while trying to add the event' })); + setIsCreating(false); + } + }; + + const handleOnSubmit = (event: React.SyntheticEvent) => { + event.preventDefault(); + const eventsForSubmission = [...timelineEvents]; + eventsForSubmission.forEach((event, index) => { + event.position = index; + }); + eventsForSubmission.sort((a, b) => a.position - b.position); + /* eslint "no-warning-comments": [1, { "terms": ["todo", "fix me, replace any type"] }] */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const eventTarget = event.target as any; + const restructuredData = { + title: eventTarget['title']?.value, + description: eventTarget['description']?.value, + events: eventsForSubmission, + }; + setTimelineEvents(eventsForSubmission); + onSubmit(restructuredData); + }; + + const handleAddEvent = () => { + if (!timelineEvents) { + return; + } + const newEventWithCorrectIndex = newEvent; + newEventWithCorrectIndex.position = timelineEvents.length; + setTimelineEvents([...timelineEvents, newEventWithCorrectIndex]); + }; + + const handleRemoveEvent = (event: React.ChangeEvent) => { + if (!timelineEvents) { + return; + } + const position = Number(event.target.value); + const dataToSplice: TimelineEvent[] = [...timelineEvents]; + dataToSplice.splice(position, 1); + dataToSplice.forEach((event, index) => { + event.position = index; + }); + setTimelineEvents([...dataToSplice]); + }; + + const handleTextChange = (e: React.ChangeEvent, property: string) => { + if (!timelineEvents) { + return; + } + const newValue = e.currentTarget.value; + if ('description' === property) { + setTimelineWidgetState({ ...timelineWidgetState, description: newValue }); + } else if ('title' === property) { + setTimelineWidgetState({ ...timelineWidgetState, title: newValue }); + } + }; + + const handleEventTextChange = (e: React.ChangeEvent, index: number, property: string) => { + if (!timelineEvents) { + return; + } + const newValue = e.currentTarget.value; + const newArray = [...timelineEvents]; + if ('description' === property) { + newArray[index].description = newValue; + setTimelineEvents([...newArray]); + } else if ('time' === property) { + newArray[index].time = newValue; + setTimelineEvents([...newArray]); + } + }; + + const handleSelectChange = (e: SelectChangeEvent, index: number) => { + if (!timelineEvents) { + return; + } + const newValue = e.target.value; + const newArray = [...timelineEvents]; + newArray[index].status = Number(newValue); + setTimelineEvents([...newArray]); + }; + + if (isLoadingTimelineWidget || !widget) { + return ( + + + + + + ); + } + + return ( + + + + + + +
handleOnSubmit(event)} id="timelineForm"> + + + Title + The title must be less than 255 characters. + ) => { + handleTextChange(event, 'title'); + }} + /> + + + Description + ) => { + handleTextChange(event, 'description'); + }} + /> + + + {timelineEvents && + timelineEvents.map((tEvent, index) => ( + + {'EVENT ' + (index + 1)} + + + Event Description + Describe the timeline event. + ) => { + handleEventTextChange(event, index, 'description'); + }} + /> + + + + Event Time + When did the event happen? + ) => { + handleEventTextChange(event, index, 'time'); + }} + /> + + + + Event Status + + + + {1 < timelineEvents.length && ( + + ) => { + handleRemoveEvent(event); + }} + > + Remove Event + + + )} + + + + + + ))} + + handleAddEvent()}>Add Event + + + + + + + Save & Close + + + + handleWidgetDrawerOpen(false)}>Cancel + + + +
+
+
+ ); +}; + +export default Form; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Timeline/TimelineContext.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/TimelineContext.tsx new file mode 100644 index 000000000..0292bb3a2 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/TimelineContext.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 { fetchTimelineWidgets } from 'services/widgetService/TimelineService/index'; +import { TimelineWidget } from 'models/timelineWidget'; +import { openNotification } from 'services/notificationService/notificationSlice'; + +export interface TimelineContextProps { + widget: Widget | null; + isLoadingTimelineWidget: boolean; + timelineWidget: TimelineWidget | null; +} + +export type EngagementParams = { + engagementId: string; +}; + +export const TimelineContext = createContext({ + widget: null, + isLoadingTimelineWidget: true, + timelineWidget: null, +}); + +export const TimelineContextProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + const { widgets } = useContext(WidgetDrawerContext); + const dispatch = useAppDispatch(); + const widget = widgets.find((widget) => widget.widget_type_id === WidgetType.Timeline) ?? null; + const [isLoadingTimelineWidget, setIsLoadingTimelineWidget] = useState(true); + const [timelineWidget, setTimelineWidget] = useState(null); + + const loadTimelineWidget = async () => { + if (!widget) { + return; + } + try { + const result = await fetchTimelineWidgets(widget.id); + setTimelineWidget(result[result.length - 1]); + setIsLoadingTimelineWidget(false); + } catch (error) { + dispatch( + openNotification({ severity: 'error', text: 'An error occurred while trying to load timeline data' }), + ); + setIsLoadingTimelineWidget(false); + } + }; + + useEffect(() => { + loadTimelineWidget(); + }, [widget]); + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Timeline/TimelineOptionCard.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/TimelineOptionCard.tsx new file mode 100644 index 000000000..1a306e696 --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/TimelineOptionCard.tsx @@ -0,0 +1,101 @@ +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 AccessTimeIcon from '@mui/icons-material/AccessTime'; + +const Title = 'Timeline'; +const TimelineOptionCard = () => { + 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.Timeline); + if (alreadyExists) { + handleWidgetDrawerTabValueChange(WidgetTabValues.TIMELINE_FORM); + return; + } + + try { + setIsCreatingWidget(true); + await createWidget({ + widget_type_id: WidgetType.Timeline, + engagement_id: savedEngagement.id, + title: Title, + }).unwrap(); + await loadWidgets(); + dispatch( + openNotification({ + severity: 'success', + text: 'Timeline widget successfully created.', + }), + ); + setIsCreatingWidget(false); + handleWidgetDrawerTabValueChange(WidgetTabValues.TIMELINE_FORM); + } catch (error) { + setIsCreatingWidget(false); + dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating timeline widget' })); + handleWidgetDrawerOpen(false); + } + }; + + return ( + handleCreateWidget()} + > + + + + + + + + + + + + + + {Title} + + + Add a timeline to this engagement + + + + + + + ); +}; + +export default TimelineOptionCard; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/Timeline/index.tsx b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/index.tsx new file mode 100644 index 000000000..fff9d4dda --- /dev/null +++ b/met-web/src/components/engagement/form/EngagementWidgets/Timeline/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { TimelineContextProvider } from './TimelineContext'; +import Form from './Form'; + +export const TimelineForm = () => { + return ( + +
+ + ); +}; + +export default TimelineForm; diff --git a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx index 43f6af81f..29396bec7 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetCardSwitch.tsx @@ -107,6 +107,19 @@ export const WidgetCardSwitch = ({ widget, removeWidget }: WidgetCardSwitchProps }} /> + + { + removeWidget(widget.id); + }} + onEdit={() => { + handleWidgetDrawerTabValueChange(WidgetTabValues.TIMELINE_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 c8949658a..62e862efd 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetDrawerTabs.tsx @@ -10,6 +10,7 @@ import Phases from './Phases'; import EventsForm from './Events'; import MapForm from './Map'; import VideoForm from './Video'; +import TimelineForm from './Timeline'; import SubscribeForm from './Subscribe'; const WidgetDrawerTabs = () => { @@ -41,6 +42,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 0870513dc..4fbfcf768 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/WidgetOptionCards.tsx @@ -8,6 +8,7 @@ import SubscribeOptionCard from './Subscribe/SubscribeOptionCard'; import EventsOptionCard from './Events/EventsOptionCard'; import MapOptionCard from './Map/MapOptionCard'; import VideoOptionCard from './Video/VideoOptionCard'; +import TimelineOptionCard from './Timeline/TimelineOptionCard'; const WidgetOptionCards = () => { return ( @@ -37,6 +38,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 0a37d17c9..d66741f67 100644 --- a/met-web/src/components/engagement/form/EngagementWidgets/type.tsx +++ b/met-web/src/components/engagement/form/EngagementWidgets/type.tsx @@ -8,4 +8,5 @@ export const WidgetTabValues = { EVENTS_FORM: 'EVENTS_FORM', MAP_FORM: 'MAP_FORM', VIDEO_FORM: 'VIDEO_FORM', + TIMELINE_FORM: 'TIMELINE_FORM', }; diff --git a/met-web/src/components/engagement/view/widgets/Timeline/TimelineWidgetView.tsx b/met-web/src/components/engagement/view/widgets/Timeline/TimelineWidgetView.tsx new file mode 100644 index 000000000..7ea5e882d --- /dev/null +++ b/met-web/src/components/engagement/view/widgets/Timeline/TimelineWidgetView.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from 'react'; +import { MetPaper, MetHeader2, MetParagraph, MetHeader4 } from 'components/common'; +import { Grid, Skeleton, Divider } from '@mui/material'; +import { Widget } from 'models/widget'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { TimelineWidget, TimelineEvent } from 'models/timelineWidget'; +import { fetchTimelineWidgets } from 'services/widgetService/TimelineService'; + +interface TimelineWidgetProps { + widget: Widget; +} + +const TimelineWidgetView = ({ widget }: TimelineWidgetProps) => { + const dispatch = useAppDispatch(); + const [timelineWidget, setTimelineWidget] = useState({ + id: 0, + widget_id: 0, + engagement_id: 0, + title: '', + description: '', + events: [], + }); + const [isLoading, setIsLoading] = useState(true); + + const fetchTimeline = async () => { + try { + const timelines = await fetchTimelineWidgets(widget.id); + const timeline = timelines[timelines.length - 1]; + timeline.events.sort((a, b) => a.position - b.position); + setTimelineWidget(timeline); + setIsLoading(false); + } catch (error) { + setIsLoading(false); + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: 'Error occurred while fetching Engagement widgets information', + }), + ); + } + }; + + useEffect(() => { + fetchTimeline(); + }, [widget]); + + const handleRenderTimelineEvent = (tEvent: TimelineEvent, index: number) => { + const containerStylesObject = { + minHeight: index + 1 === timelineWidget.events.length ? '60px' : '80px', + display: 'flex', + flexDirection: 'row', + marginLeft: index + 1 === timelineWidget.events.length ? '24px' : '22px', + borderLeft: index + 1 === timelineWidget.events.length ? 'none' : '2px solid grey', + }; + const circleContainerStylesObject = { + padding: '2px', + border: '2px solid grey', + marginLeft: '-25px', + display: 'inline-flex', + backgroundColor: '#fff', + borderRadius: '50%', + height: '48px', + width: '48px', + }; + const circleStylesObject = { + width: '40px', + height: '40px', + borderRadius: '50%', + content: '""', + border: 1 !== tEvent.status ? '20px solid' : 'none', + borderColor: 3 === tEvent.status ? '#2e8540' : '#036', + }; + const checkmarkStylesObject = { + marginLeft: '-12px', + marginTop: '-23px', + fontSize: '31px', + color: '#fff', + }; + return ( + + + + {3 == tEvent.status && ( + + ✓ + + )} + + + + {tEvent.description} + + {tEvent.time} + + + + ); + }; + + if (isLoading) { + return ( + + + + + + + + + + + + + ); + } + + if (!timelineWidget) { + return null; + } + + return ( + + + + {timelineWidget.title} + + + + {timelineWidget.description} + + + {timelineWidget && + timelineWidget.events.map((tEvent, index) => handleRenderTimelineEvent(tEvent, index))} + + + + ); +}; + +export default TimelineWidgetView; diff --git a/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx b/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx index 0924ffa79..2e1391cad 100644 --- a/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx +++ b/met-web/src/components/engagement/view/widgets/WidgetSwitch.tsx @@ -7,6 +7,7 @@ import SubscribeWidget from './Subscribe/SubscribeWidget'; import EventsWidget from './Events/EventsWidget'; import MapWidget from './Map/MapWidget'; import VideoWidgetView from './Video/VideoWidgetView'; +import TimelineWidgetView from './Timeline/TimelineWidgetView'; interface WidgetSwitchProps { widget: Widget; } @@ -33,6 +34,9 @@ export const WidgetSwitch = ({ widget }: WidgetSwitchProps) => { + + + ); diff --git a/met-web/src/models/timelineWidget.tsx b/met-web/src/models/timelineWidget.tsx new file mode 100644 index 000000000..943d16820 --- /dev/null +++ b/met-web/src/models/timelineWidget.tsx @@ -0,0 +1,25 @@ +export interface TimelineWidget { + id: number; + engagement_id: number; + widget_id: number; + title: string; + description: string; + events: TimelineEvent[]; +} + +export interface TimelineEvent { + id: number; + engagement_id: number; + widget_id: number; + timeline_id: number; + description: string; + time: string; + status: EventStatus; + position: number; +} + +export enum EventStatus { + Pending = 1, + InProgress = 2, + Completed = 3, +} diff --git a/met-web/src/models/widget.tsx b/met-web/src/models/widget.tsx index 32e4cd3e4..529c072e2 100644 --- a/met-web/src/models/widget.tsx +++ b/met-web/src/models/widget.tsx @@ -22,4 +22,5 @@ export enum WidgetType { Map = 6, Video = 7, CACForm = 8, + Timeline = 9, } diff --git a/met-web/src/services/widgetService/TimelineService/index.tsx b/met-web/src/services/widgetService/TimelineService/index.tsx new file mode 100644 index 000000000..e90cd0b84 --- /dev/null +++ b/met-web/src/services/widgetService/TimelineService/index.tsx @@ -0,0 +1,58 @@ +import http from 'apiManager/httpRequestHandler'; +import Endpoints from 'apiManager/endpoints'; +import { replaceAllInURL, replaceUrl } from 'helper'; +import { TimelineWidget, TimelineEvent } from 'models/timelineWidget'; + +interface PostTimelineRequest { + widget_id: number; + engagement_id: number; + title: string; + description: string; + events: TimelineEvent[]; +} + +interface PatchTimelineRequest { + events?: TimelineEvent[]; + title?: string; + description?: string; +} + +export const postTimeline = async (widget_id: number, data: PostTimelineRequest): Promise => { + try { + const url = replaceUrl(Endpoints.TimelineWidgets.CREATE, 'widget_id', String(widget_id)); + const response = await http.PostRequest(url, data); + return response.data || Promise.reject('Failed to create timeline widget'); + } catch (err) { + return Promise.reject(err); + } +}; + +export const patchTimeline = async ( + widget_id: number, + timeline_id: number, + data: PatchTimelineRequest, +): Promise => { + try { + const url = replaceAllInURL({ + URL: Endpoints.TimelineWidgets.UPDATE, + params: { + widget_id: String(widget_id), + timeline_id: String(timeline_id), + }, + }); + const response = await http.PatchRequest(url, data); + return response.data || Promise.reject('Failed to update timeline widget'); + } catch (err) { + return Promise.reject(err); + } +}; + +export const fetchTimelineWidgets = async (widget_id: number): Promise => { + try { + const url = replaceUrl(Endpoints.TimelineWidgets.GET, 'widget_id', String(widget_id)); + const responseData = await http.GetRequest(url); + return responseData.data ?? []; + } catch (err) { + return Promise.reject(err); + } +};