diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 5e80b1a5e..a90179b23 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,14 +1,23 @@ +## September 23, 2024 + +- **Feature** New Hero Banner page in authoring section [🎟️ DESENG-670](https://citz-gdx.atlassian.net/browse/DESENG-670) + - Hooks into existing AuthoringTemplate component for form submission + - Added new fields for hero banner content + - Added image upload functionality + - Added layout components for engagement authoring form fields + ## September 18, 2024 - **Feature** New Video Widget front end [🎟️ DESENG-692](https://citz-gdx.atlassian.net/browse/DESENG-692) + - Implemented Figma design - Created custom layover bar for videos that shows video provider and has a custom logo - Transcripts will not be implemented at this time - Summary page in authoring section [🎟️ DESENG-671](https://citz-gdx.atlassian.net/browse/DESENG-671) - - Streamlined data loaders and actions to account for page changes - - Updated data structure so that summary data is pulled from engagement table - - Added 'description_title' column to engagement table + - Streamlined data loaders and actions to account for page changes + - Updated data structure so that summary data is pulled from engagement table + - Added 'description_title' column to engagement table ## September 12, 2024 @@ -17,7 +26,7 @@ - Saves values to database - Returns success and error messages to user after form submission - Resets form dirty state after submission - + ## September 9, 2024 - **Feature** Add image widget [🎟️ DESENG-689](https://citz-gdx.atlassian.net/browse/DESENG-689) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d475f89cb..e9238fa86 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,7 +80,7 @@ Examples of when to Request Changes - Be sure to make use of shared components found under `src/components/common`. Below is a non-exhaustive list of common app components: - `Button`: A versatile button with different style types that are complimentary of MET styling. Choose a particular button style with: `variant=<"primary"|"secondary"|"tertiary"|undefined>` - `RichTextArea`: A WYSIWYG editor used app-wide. It will dynamically render out links and h2s as React components. - - `Header1`, `Header2`: MET-styled h1, h2 components. + - `Header1`, `Header2`, `Header3`: MET-styled h1, h2 and h3 components. Prefer these over using the native HTML headers. - `ResponsiveContainer`: A container that decreases its padding on smaller screens. - `ResponsiveWrapper`: A route wrapper that adds a responsive container around its child routes. - `Pagination`: Provides a pagination UI - a wrapper around Material UI's pagination. @@ -96,3 +96,8 @@ Examples of when to Request Changes - `FormStep`: A wrapper around a form component that accepts a completion criteria and displays the user's progress. Accepts a `step` prop that is a number (from 1 to 9) that represents the current step in the form. This will be rendered as an icon with a checkmark if the step is complete, and a number if it's the current step or if it's incomplete. - `SystemMessage`: An informational message that can be displayed to the user. Accepts a `type` prop that can be "error", "warning", "info", or "success", which affects the display of the message. - `WidgetPicker`: A modular widget picker component that can be placed anywhere in the engagement editing area. In order to align widgets in the backend with the frontend, a "location" prop is required. Add new locations to the `WidgetLocation` enum. + - `ErrorMessage`: Display a form validation error message with an error icon. Used when creating a custom form element, e.g. `TextField`. + Accepts a `message` prop that is the error message to display. + - `TextInput`: A styled text input that can be used in forms. Accepts a `placeholder` and all other props that a normal MUI TextField would accept. + - `TextField`: A convenience wrapper around `TextInput` that includes a label, requirement decorations, and helper/error text. Error text is internally rendered by `ErrorMessage`. + - `RichTextArea`: A WYSIWYG editor that can be used in forms. Hook into editor state with `editorState` and `onEditorStateChange`. Accepts aria accessibility props. Manage toolbar options with the `toolbar` prop. The editor is pre-styled, but can be overridden with `wrapperStyle`, `toolbarStyle`, and `editorStyle`. diff --git a/met-api/migrations/versions/58923bf5bda6_remove_cta_message_translation.py b/met-api/migrations/versions/58923bf5bda6_remove_cta_message_translation.py new file mode 100644 index 000000000..18f4cabbd --- /dev/null +++ b/met-api/migrations/versions/58923bf5bda6_remove_cta_message_translation.py @@ -0,0 +1,28 @@ +"""remove obsolete cta_message column from engagement_translation + +Revision ID: 58923bf5bda6 +Revises: 63890bdab166 +Create Date: 2024-09-24 13:00:12.107334 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '58923bf5bda6' +down_revision = '63890bdab166' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('engagement_translation', 'cta_message') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('engagement_translation', sa.Column('cta_message', sa.VARCHAR(length=50), autoincrement=False, nullable=True)) + # ### end Alembic commands ### diff --git a/met-api/migrations/versions/63890bdab166_merge_heads.py b/met-api/migrations/versions/63890bdab166_merge_heads.py new file mode 100644 index 000000000..f29ab024b --- /dev/null +++ b/met-api/migrations/versions/63890bdab166_merge_heads.py @@ -0,0 +1,25 @@ +"""Merge heads + +Revision ID: 63890bdab166 +Revises: df693f5ddaf9, 828b4f34734a +Create Date: 2024-09-20 13:55:27.230188 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "63890bdab166" +down_revision = ("df693f5ddaf9", "828b4f34734a") +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/met-api/migrations/versions/828b4f34734a_status_block_updates.py b/met-api/migrations/versions/828b4f34734a_status_block_updates.py new file mode 100644 index 000000000..1db57aeee --- /dev/null +++ b/met-api/migrations/versions/828b4f34734a_status_block_updates.py @@ -0,0 +1,174 @@ +"""Move cta_message and cta_url from engagement to engagement_status_block + +Revision ID: 828b4f34734a +Revises: e706db763790 +Create Date: 2024-09-16 10:18:49.858066 + +""" + +from enum import IntEnum, Enum +from alembic import op +import sqlalchemy as sa + + +class SubmissionStatus(IntEnum): + """Enum of engagement submission status.""" + + Upcoming = 1 + Open = 2 + Closed = 3 + ViewResults = 4 + + +# revision identifiers, used by Alembic. +revision = "828b4f34734a" +down_revision = "e706db763790" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + # Create a new enum type with the 'ViewResults' value + op.execute("ALTER TYPE submissionstatus RENAME TO submissionstatus_old") + op.execute( + "CREATE TYPE submissionstatus AS ENUM ('Upcoming', 'Open', 'Closed', 'ViewResults')" + ) + # Commit the enum change before dropping the old one + op.execute("COMMIT") + # Convert the existing data to the new enum type + connection.execute( + "ALTER TABLE engagement_status_block ALTER COLUMN survey_status TYPE submissionstatus USING survey_status::text::submissionstatus" + ) + # Drop the old enum type + op.execute("DROP TYPE submissionstatus_old") + + op.add_column( + "engagement_status_block", + sa.Column("button_text", sa.String(length=20), nullable=True), + ) + op.add_column( + "engagement_status_block", + sa.Column( + "link_type", sa.String(length=20), nullable=False, server_default="internal" + ), + ) + op.add_column( + "engagement_status_block", + sa.Column("internal_link", sa.String(length=50), nullable=True), + ) + op.add_column( + "engagement_status_block", + sa.Column("external_link", sa.String(length=300), nullable=True), + ) + # Migrate data from engagement to engagement_status_block + # Ad-hoc table definition for engagement + engagement_table = sa.Table( + "engagement", + sa.MetaData(), + sa.Column("id", sa.Integer), + sa.Column("cta_message", sa.String(50)), + sa.Column("cta_url", sa.String(500)), + ) + # Ad-hoc table definition for engagement_status_block + engagement_status_block_table = sa.Table( + "engagement_status_block", + sa.MetaData(), + sa.Column("id", sa.Integer), + sa.Column("engagement_id", sa.Integer), + sa.Column( + "survey_status", + sa.Enum( + SubmissionStatus, + name="submissionstatus", + metadata=sa.MetaData(), + create_constraint=True, + ), + ), + sa.Column("block_text", sa.JSON), + sa.Column("button_text", sa.String(20)), + sa.Column("link_type", sa.String(20)), + sa.Column("internal_link", sa.String(50)), + sa.Column("external_link", sa.String(300)), + sa.Column("created_date", sa.DateTime), + sa.Column("updated_date", sa.DateTime), + ) + # For each engagement... + for engagement in connection.execute(engagement_table.select()): + # Update existing engagement_status_blocks... + # If the URL starts with "http", it's an external link. + if engagement.cta_url and engagement.cta_url.startswith("http"): + link_type = "external" + external_link = engagement.cta_url + internal_link = None + else: + valid_statuses = ["hero", "description", "contentTabs", "provideFeedback"] + link_type = "internal" + external_link = None + internal_link = None + if engagement.cta_url in valid_statuses: + internal_link = engagement.cta_url + else: + internal_link = "provideFeedback" + connection.execute( + engagement_status_block_table.update() + .where(engagement_status_block_table.c.engagement_id == engagement.id) + .where(engagement_status_block_table.c.survey_status == "Open") + .values( + button_text=engagement.cta_message or "Provide Feedback", + link_type=link_type, + external_link=external_link, + internal_link=internal_link, + ) + ) + # And add the "View Results" block type. + connection.execute( + engagement_status_block_table.insert().values( + engagement_id=engagement.id, + survey_status=SubmissionStatus.ViewResults, + block_text={}, + button_text="View Results", + link_type="internal", + internal_link="provideFeedback", + external_link=None, + created_date=sa.func.now(), + updated_date=sa.func.now(), + ) + ) + # Drop the columns from the engagement table + op.drop_column("engagement", "cta_message") + op.drop_column("engagement", "cta_url") + + +def downgrade(): + op.drop_column("engagement_status_block", "external_link") + op.drop_column("engagement_status_block", "internal_link") + op.drop_column("engagement_status_block", "link_type") + op.drop_column("engagement_status_block", "button_text") + op.add_column( + "engagement", + sa.Column( + "cta_url", sa.VARCHAR(length=500), autoincrement=False, nullable=True + ), + ) + op.add_column( + "engagement", + sa.Column( + "cta_message", sa.VARCHAR(length=50), autoincrement=False, nullable=True + ), + ) + # Create a new enum type without the 'ViewResults' value + op.execute("ALTER TYPE submissionstatus RENAME TO submissionstatus_old") + op.execute("CREATE TYPE submissionstatus AS ENUM ('Upcoming', 'Open', 'Closed')") + # Commit the enum change before dropping the old one + op.execute("COMMIT") + # Convert the existing data to the new enum type + connection = op.get_bind() + connection.execute( + "UPDATE engagement_status_block SET survey_status = 'Closed' WHERE survey_status = 'ViewResults'" + ) + connection.execute( + "ALTER TABLE engagement_status_block ALTER COLUMN survey_status TYPE submissionstatus USING survey_status::text::submissionstatus" + ) + # Drop the old enum type + op.execute("DROP TYPE submissionstatus_old") diff --git a/met-api/src/met_api/constants/engagement_status.py b/met-api/src/met_api/constants/engagement_status.py index d5c1a154c..5829a8048 100644 --- a/met-api/src/met_api/constants/engagement_status.py +++ b/met-api/src/met_api/constants/engagement_status.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Constants of engagement status.""" -from enum import IntEnum +from enum import Enum, IntEnum class Status(IntEnum): @@ -43,3 +43,13 @@ class SubmissionStatus(IntEnum): Upcoming = 1 Open = 2 Closed = 3 + ViewResults = 4 + + +class EngagementViewSections(Enum): + """Enum of sections that can be displayed in the engagement view.""" + + HERO = 'hero' + DESCRIPTION = 'description' + CONTENT_TABS = 'contentTabs' + PROVIDE_FEEDBACK = 'provideFeedback' diff --git a/met-api/src/met_api/models/engagement.py b/met-api/src/met_api/models/engagement.py index 4127d0399..9c404b7a7 100644 --- a/met-api/src/met_api/models/engagement.py +++ b/met-api/src/met_api/models/engagement.py @@ -57,8 +57,6 @@ class Engagement(BaseModel): is_internal = db.Column(db.Boolean, nullable=False) consent_message = db.Column(JSON, unique=False, nullable=True) sponsor_name = db.Column(db.String(50), nullable=True) - cta_message = db.Column(db.String(50), nullable=True) - cta_url = db.Column(db.String(500), nullable=True) @classmethod def get_engagements_paginated( @@ -149,7 +147,6 @@ def update_engagement(cls, engagement: EngagementSchema) -> Engagement: 'consent_message': engagement.get( 'consent_message', record.consent_message), 'sponsor_name': engagement.get('sponsor_name', record.sponsor_name), - 'cta_message': engagement.get('cta_message', record.cta_message), 'cta_url': engagement.get('cta_url', record.cta_url), } query.update(update_fields) diff --git a/met-api/src/met_api/models/engagement_status_block.py b/met-api/src/met_api/models/engagement_status_block.py index 3cd3944a8..1788ec834 100644 --- a/met-api/src/met_api/models/engagement_status_block.py +++ b/met-api/src/met_api/models/engagement_status_block.py @@ -18,22 +18,33 @@ class EngagementStatusBlock(BaseModel): __tablename__ = 'engagement_status_block' __table_args__ = ( - db.UniqueConstraint('engagement_id', 'survey_status', name='unique_engagement_status_block'), + db.UniqueConstraint( + 'engagement_id', 'survey_status', name='unique_engagement_status_block' + ), ) id = db.Column(db.Integer, primary_key=True, autoincrement=True) - engagement_id = db.Column(db.Integer, ForeignKey('engagement.id', ondelete='CASCADE')) + engagement_id = db.Column( + db.Integer, ForeignKey('engagement.id', ondelete='CASCADE') + ) survey_status = db.Column(db.Enum(SubmissionStatus), nullable=False) block_text = db.Column(JSON, unique=False, nullable=False) + button_text = db.Column(db.String(20), nullable=True) + link_type = db.Column(db.String(20), nullable=False) + internal_link = db.Column(db.String(50), nullable=True) + external_link = db.Column(db.String(300), nullable=True) @classmethod def get_by_status(cls, engagement_id, survey_status): """Get Engagement Status by survey status.""" - return db.session.query(EngagementStatusBlock) \ - .filter(EngagementStatusBlock.survey_status == survey_status, - EngagementStatusBlock.engagement_id == engagement_id - ) \ + return ( + db.session.query(EngagementStatusBlock) + .filter( + EngagementStatusBlock.survey_status == survey_status, + EngagementStatusBlock.engagement_id == engagement_id, + ) .first() + ) @classmethod def save_status_blocks(cls, status_blocks: list) -> None: diff --git a/met-api/src/met_api/models/engagement_translation.py b/met-api/src/met_api/models/engagement_translation.py index ab395b231..aff20a2d3 100644 --- a/met-api/src/met_api/models/engagement_translation.py +++ b/met-api/src/met_api/models/engagement_translation.py @@ -31,7 +31,6 @@ class EngagementTranslation(BaseModel): open_status_block_text = db.Column(JSON, unique=False, nullable=True) closed_status_block_text = db.Column(JSON, unique=False, nullable=True) sponsor_name = db.Column(db.String(50)) - cta_message = db.Column(db.String(50)) # Add a unique constraint on engagement_id and language_id # A engagement has only one version in a particular language @@ -80,7 +79,6 @@ def __create_new_engagement_translation_entity(data): open_status_block_text=data.get('open_status_block_text', None), closed_status_block_text=data.get('closed_status_block_text', None), sponsor_name=data.get('sponsor_name', None), - cta_message=data.get('cta_message', None), ) @staticmethod diff --git a/met-api/src/met_api/schemas/engagement.py b/met-api/src/met_api/schemas/engagement.py index 33f1a01be..e70d44106 100644 --- a/met-api/src/met_api/schemas/engagement.py +++ b/met-api/src/met_api/schemas/engagement.py @@ -49,8 +49,6 @@ class Meta: # pylint: disable=too-few-public-methods is_internal = fields.Bool(data_key='is_internal') consent_message = fields.Str(data_key='consent_message') sponsor_name = fields.Str(data_key='sponsor_name') - cta_message = fields.Str(data_key='cta_message') - cta_url = fields.Str(data_key='cta_url') def get_submissions_meta_data(self, obj): """Get the meta data of the submissions made in the survey.""" diff --git a/met-api/src/met_api/schemas/engagement_status_block.py b/met-api/src/met_api/schemas/engagement_status_block.py index abd3859d6..bedb0da45 100644 --- a/met-api/src/met_api/schemas/engagement_status_block.py +++ b/met-api/src/met_api/schemas/engagement_status_block.py @@ -1,4 +1,5 @@ """Engagement status schema class.""" + from marshmallow import EXCLUDE, Schema, fields from marshmallow_enum import EnumField @@ -15,3 +16,7 @@ class Meta: # pylint: disable=too-few-public-methods survey_status = EnumField(SubmissionStatus, by_value=False) block_text = fields.Str(data_key='block_text') + button_text = fields.Str(data_key='button_text') + link_type = fields.Str(data_key='link_type') + internal_link = fields.Str(data_key='internal_link', allow_none=True) + external_link = fields.Str(data_key='external_link', allow_none=True) diff --git a/met-api/src/met_api/schemas/engagement_translation.py b/met-api/src/met_api/schemas/engagement_translation.py index 896d040da..4bcb3d5b3 100644 --- a/met-api/src/met_api/schemas/engagement_translation.py +++ b/met-api/src/met_api/schemas/engagement_translation.py @@ -26,4 +26,3 @@ class Meta: # pylint: disable=too-few-public-methods open_status_block_text = fields.Str(data_key='open_status_block_text') closed_status_block_text = fields.Str(data_key='closed_status_block_text') sponsor_name = fields.Str(data_key='sponsor_name') - cta_message = fields.Str(data_key='cta_message') diff --git a/met-api/src/met_api/services/engagement_service.py b/met-api/src/met_api/services/engagement_service.py index 35f35d16e..8f883e93b 100644 --- a/met-api/src/met_api/services/engagement_service.py +++ b/met-api/src/met_api/services/engagement_service.py @@ -1,4 +1,5 @@ """Service for engagement management.""" + from datetime import datetime from http import HTTPStatus @@ -7,6 +8,7 @@ from met_api.constants.engagement_status import Status from met_api.constants.membership_type import MembershipType from met_api.exceptions.business_exception import BusinessException +from met_api.models import Tenant as TenantModel from met_api.models.engagement import Engagement as EngagementModel from met_api.models.engagement_scope_options import EngagementScopeOptions from met_api.models.engagement_slug import EngagementSlug as EngagementSlugModel @@ -15,9 +17,9 @@ from met_api.models.submission import Submission as SubmissionModel from met_api.schemas.engagement import EngagementSchema from met_api.services import authorization +from met_api.services.engagement_content_service import EngagementContentService from met_api.services.engagement_settings_service import EngagementSettingsService from met_api.services.engagement_slug_service import EngagementSlugService -from met_api.services.engagement_content_service import EngagementContentService from met_api.services.object_storage_service import ObjectStorageService from met_api.services.project_service import ProjectService from met_api.utils import email_util, notification @@ -25,7 +27,6 @@ from met_api.utils.roles import Role from met_api.utils.template import Template from met_api.utils.token_info import TokenInfo -from met_api.models import Tenant as TenantModel class EngagementService: @@ -42,29 +43,38 @@ def get_engagement(self, engagement_id) -> EngagementSchema: engagement_model: EngagementModel = EngagementModel.find_by_id(engagement_id) if engagement_model: - if TokenInfo.get_id() is None \ - and engagement_model.status_id not in (Status.Published.value, Status.Closed.value): + if TokenInfo.get_id() is None and engagement_model.status_id not in ( + Status.Published.value, + Status.Closed.value, + ): # Non authenticated users only have access to published and closed engagements return None - if engagement_model.status_id in (Status.Draft.value, Status.Scheduled.value): + if engagement_model.status_id in ( + Status.Draft.value, + Status.Scheduled.value, + ): one_of_roles = ( MembershipType.TEAM_MEMBER.name, MembershipType.REVIEWER.name, - Role.VIEW_ALL_ENGAGEMENTS.value + Role.VIEW_ALL_ENGAGEMENTS.value, + ) + authorization.check_auth( + one_of_roles=one_of_roles, engagement_id=engagement_id ) - authorization.check_auth(one_of_roles=one_of_roles, engagement_id=engagement_id) engagement = EngagementSchema().dump(engagement_model) - engagement['banner_url'] = self.object_storage.get_url(engagement['banner_filename']) + engagement['banner_url'] = self.object_storage.get_url( + engagement['banner_filename'] + ) return engagement return None def get_engagements_paginated( - self, - external_user_id, - pagination_options: PaginationOptions, - search_options=None, - include_banner_url=False, + self, + external_user_id, + pagination_options: PaginationOptions, + search_options=None, + include_banner_url=False, ): """Get engagements paginated.""" user_roles = TokenInfo.get_user_roles() @@ -82,14 +92,13 @@ def get_engagements_paginated( if include_banner_url: engagements = self._attach_banner_url(engagements) - return { - 'items': engagements, - 'total': total - } + return {'items': engagements, 'total': total} def _attach_banner_url(self, engagements: list): for engagement in engagements: - engagement['banner_url'] = self.object_storage.get_url(engagement['banner_filename']) + engagement['banner_url'] = self.object_storage.get_url( + engagement['banner_filename'] + ) return engagements @staticmethod @@ -105,22 +114,18 @@ def _get_scope_options(user_roles, has_team_access): return EngagementScopeOptions(restricted=False) # check if user - return EngagementScopeOptions( - include_assigned=True - ) + return EngagementScopeOptions(include_assigned=True) if Role.VIEW_ENGAGEMENT.value in user_roles: # If user has VIEW_ENGAGEMENT role, e.g. TEAM MEMBER, return scope options to include assigned # engagements and public engagements return EngagementScopeOptions( engagement_status_ids=[Status.Published.value, Status.Closed.value], - include_assigned=True + include_assigned=True, ) if Role.VIEW_ASSIGNED_ENGAGEMENTS.value in user_roles: # If user has VIEW_ASSIGNED_ENGAGEMENTS role, e.g. REVIEWER, return scope options to include only # assigned engagements - return EngagementScopeOptions( - include_assigned=True - ) + return EngagementScopeOptions(include_assigned=True) # Default scope options for users without specific roles e.g. public users return EngagementScopeOptions( @@ -131,7 +136,10 @@ def _get_scope_options(user_roles, has_team_access): def close_engagements_due(): """Close published engagements that are due for a closeout.""" engagements = EngagementModel.close_engagements_due() - results = [EngagementService._send_closeout_emails(engagement) for engagement in engagements] + results = [ + EngagementService._send_closeout_emails(engagement) + for engagement in engagements + ] return results @staticmethod @@ -145,8 +153,12 @@ def publish_scheduled_engagements(): print('Engagements published: ', engagements) for engagement in engagements: - email_util.publish_to_email_queue(SourceType.ENGAGEMENT.value, engagement.id, - SourceAction.PUBLISHED.value, True) + email_util.publish_to_email_queue( + SourceType.ENGAGEMENT.value, + engagement.id, + SourceAction.PUBLISHED.value, + True, + ) print('Engagements published added to email queue: ', engagement.id) return engagements @@ -163,7 +175,9 @@ def create_engagement(request_json: dict): if request_json.get('status_block'): EngagementService._create_eng_status_block(eng_model.id, request_json) eng_model.commit() - email_util.publish_to_email_queue(SourceType.ENGAGEMENT.value, eng_model.id, SourceAction.CREATED.value, True) + email_util.publish_to_email_queue( + SourceType.ENGAGEMENT.value, eng_model.id, SourceAction.CREATED.value, True + ) EngagementSlugService.create_engagement_slug(eng_model.id) EngagementSettingsService.create_default_settings(eng_model.id) return eng_model.find_by_id(eng_model.id) @@ -189,8 +203,6 @@ def _create_engagement_model(engagement_data: dict) -> EngagementModel: is_internal=engagement_data.get('is_internal', False), consent_message=engagement_data.get('consent_message', None), sponsor_name=engagement_data.get('sponsor_name', None), - cta_message=engagement_data.get('cta_message', None), - cta_url=engagement_data.get('cta_url', None), ) new_engagement.save() return new_engagement @@ -198,17 +210,17 @@ def _create_engagement_model(engagement_data: dict) -> EngagementModel: @staticmethod def create_default_engagement_content(eng_id): """Create default engagement content for the given engagement ID.""" - default_engagement_content = { - 'title': 'Summary', - 'engagement_id': eng_id - } + default_engagement_content = {'title': 'Summary', 'engagement_id': eng_id} try: - eng_content = EngagementContentService.create_engagement_content(default_engagement_content, eng_id) + eng_content = EngagementContentService.create_engagement_content( + default_engagement_content, eng_id + ) except Exception as exc: # noqa: B902 current_app.logger.error('Failed to create default engagement content', exc) raise BusinessException( error='Failed to create default engagement content.', - status_code=HTTPStatus.INTERNAL_SERVER_ERROR) from exc + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) from exc return eng_content @@ -223,12 +235,17 @@ def create_default_content(eng_id: int, content_data: dict): 'json_content': content_data.get('rich_content', None), } try: - EngagementContentService.create_engagement_content(default_summary_content, eng_id) + EngagementContentService.create_engagement_content( + default_summary_content, eng_id + ) except Exception as exc: # noqa: B902 - current_app.logger.error('Failed to create default engagement summary content', exc) + current_app.logger.error( + 'Failed to create default engagement summary content', exc + ) raise BusinessException( error='Failed to create default engagement summary content.', - status_code=HTTPStatus.INTERNAL_SERVER_ERROR) from exc + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) from exc @staticmethod def _create_eng_status_block(eng_id, engagement_data: dict): @@ -238,7 +255,7 @@ def _create_eng_status_block(eng_id, engagement_data: dict): new_status_block: EngagementStatusBlockModel = EngagementStatusBlockModel( engagement_id=eng_id, survey_status=status.get('survey_status'), - block_text=status.get('block_text') + block_text=status.get('block_text'), ) status_blocks.append(new_status_block) @@ -247,19 +264,31 @@ def _create_eng_status_block(eng_id, engagement_data: dict): @staticmethod def _save_or_update_eng_block(engagement_id, status_block): for survey_block in status_block: - # see if there is one existing for the status ;if not create one + # Check for an existing status block with the same survey status survey_status = survey_block.get('survey_status') - survey_block = survey_block.get('block_text') - status_block: EngagementStatusBlockModel = EngagementStatusBlockModel. \ - get_by_status(engagement_id, survey_status) + survey_block_text = survey_block.get('block_text') + status_block: EngagementStatusBlockModel = ( + EngagementStatusBlockModel.get_by_status(engagement_id, survey_status) + ) + # If the status block exists, update it. Otherwise, create a new one. if status_block: - status_block.block_text = survey_block + status_block.block_text = survey_block_text + status_block.button_text = survey_block.get('button_text') + status_block.link_type = survey_block.get('link_type') + status_block.internal_link = survey_block.get('internal_link') + status_block.external_link = survey_block.get('external_link') status_block.commit() else: - new_status_block: EngagementStatusBlockModel = EngagementStatusBlockModel( - engagement_id=engagement_id, - survey_status=survey_status, - block_text=survey_block + new_status_block: EngagementStatusBlockModel = ( + EngagementStatusBlockModel( + engagement_id=engagement_id, + survey_status=survey_status, + block_text=survey_block_text, + button_text=survey_block.get('button_text'), + link_type=survey_block.get('link_type'), + internal_link=survey_block.get('internal_link'), + external_link=survey_block.get('external_link'), + ) ) new_status_block.save() @@ -269,8 +298,12 @@ def _validate_engagement_edit_data(engagement_id: int, data: dict): engagement = EngagementModel.find_by_id(engagement_id) draft_status_restricted_changes = (EngagementModel.is_internal.key,) engagement_has_been_opened = engagement.status_id != Status.Draft.value - if engagement_has_been_opened and any(field in data for field in draft_status_restricted_changes): - raise ValueError('Some fields cannot be updated after the engagement has been published') + if engagement_has_been_opened and any( + field in data for field in draft_status_restricted_changes + ): + raise ValueError( + 'Some fields cannot be updated after the engagement has been published' + ) @staticmethod def edit_engagement(data: dict): @@ -278,11 +311,8 @@ def edit_engagement(data: dict): survey_block = data.pop('status_block', None) engagement_id = data.get('id', None) authorization.check_auth( - one_of_roles=( - MembershipType.TEAM_MEMBER.name, - Role.EDIT_ENGAGEMENT.value - ), - engagement_id=engagement_id + one_of_roles=(MembershipType.TEAM_MEMBER.name, Role.EDIT_ENGAGEMENT.value), + engagement_id=engagement_id, ) EngagementService._validate_engagement_edit_data(engagement_id, data) @@ -314,31 +344,50 @@ def validate_fields(data): def _send_closeout_emails(engagement: EngagementModel) -> None: """Send the engagement closeout emails.Throws error if fails.""" lang_code = current_app.config['DEFAULT_LANGUAGE'] - subject, body, args = EngagementService._render_email_template(engagement, lang_code) + subject, body, args = EngagementService._render_email_template( + engagement, lang_code + ) participants = SubmissionModel.get_engaged_participants(engagement.id) template_id = current_app.config['EMAIL_TEMPLATES']['CLOSEOUT']['ID'] - emails = [participant.decode_email(participant.email_address) for participant in participants] + emails = [ + participant.decode_email(participant.email_address) + for participant in participants + ] # Removes duplicated records emails = list(set(emails)) try: - [notification.send_email(subject=subject, email=email_address, html_body=body, - args=args, template_id=template_id) for email_address in emails] + [ + notification.send_email( + subject=subject, + email=email_address, + html_body=body, + args=args, + template_id=template_id, + ) + for email_address in emails + ] except Exception as exc: # noqa: B902 - current_app.logger.error(' { @@ -45,20 +45,19 @@ export const FormField = ({ {instructions} - {error && ( + {error && errorPosition === 'top' && ( - - - {error} - + )} {children} + {error && errorPosition === 'bottom' && ( + + + + )} ); diff --git a/met-web/src/components/common/Input/TextInput.tsx b/met-web/src/components/common/Input/TextInput.tsx index 8c5118443..2c6befc48 100644 --- a/met-web/src/components/common/Input/TextInput.tsx +++ b/met-web/src/components/common/Input/TextInput.tsx @@ -119,6 +119,7 @@ const clearInputButton = (onClick: () => void) => { export type TextFieldProps = { error?: string; + errorPosition?: 'top' | 'bottom'; counter?: boolean; maxLength?: number; clearable?: boolean; @@ -131,6 +132,7 @@ export const TextField = ({ title, instructions, error, + errorPosition = 'top', name, required, optional, @@ -163,6 +165,7 @@ export const TextField = ({ required={required} optional={optional} error={error} + errorPosition={errorPosition} {...formFieldProps} > = ({ smallScreenOnly }) => { + const location = useLocation(); const matches = (useMatches() as UIMatchWithCrumb[]).filter((match) => match.handle?.crumb); + + const crumbs = useMemo(() => { + return matches.map((match) => { + const data = match.data as unknown; + const handle = match.handle as { + crumb?: (data: unknown) => Promise<{ name: string; link?: string }>; + }; + return handle?.crumb ? handle.crumb(data) : Promise.resolve({ name: '', link: '' }); + }); + }, [location.pathname]); + + const [resolvedCrumbs, setResolvedCrumbs] = React.useState<{ name: string; link?: string }[]>( + new Array(matches.length).fill({ name: 'Loading...', link: '' }), + ); + + React.useEffect(() => { + Promise.all(crumbs).then((results) => { + setResolvedCrumbs(results); + }); + }, [crumbs]); + return ( - {matches.map((match, index) => { - const data = match.data as unknown; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handle = match.handle as { - crumb?: (data: unknown) => Promise<{ name: string; link?: string }>; - }; - if (!handle?.crumb) return null; - const crumb = handle.crumb?.(data); - return ( - - Loading... - - } + {resolvedCrumbs.map((resolvedCrumb, index) => { + const name = resolvedCrumb?.name; + const link = index < matches.length - 1 ? resolvedCrumb?.link ?? matches[index].pathname : undefined; + return link ? ( + + {name} + + ) : ( + - - {(resolvedCrumb) => { - const name = resolvedCrumb?.name; - const link = - index < matches.length - 1 ? resolvedCrumb?.link ?? match.pathname : undefined; - return link ? ( - - {name} - - ) : ( - - {name} - - ); - }} - - + {name} + ); })} diff --git a/met-web/src/components/common/RichTextEditor/RichEditorStyles.css b/met-web/src/components/common/RichTextEditor/RichEditorStyles.css index 72337c6ee..37cfcf7f5 100644 --- a/met-web/src/components/common/RichTextEditor/RichEditorStyles.css +++ b/met-web/src/components/common/RichTextEditor/RichEditorStyles.css @@ -1,3 +1,49 @@ -.rdw-option-active { - box-shadow: 1px 1px 0px #BFBDBD inset !important; - } \ No newline at end of file +.rdw-editor-wrapper:has(.public-DraftEditor-content[contenteditable="true"]){ + background-color: white; + padding: 0.5em 1em; + border-radius: 8px; + border: 1px solid #605E5C; +} + +.rdw-editor-toolbar{ + padding: 2px; + border: none; + border-bottom: 1px solid #E1DFDD; +} + +.rdw-option-wrapper, .rdw-dropdown-optionwrapper.rdw-dropdown-optionwrapper { + background-color: white; + border-radius: 8px; + padding: 12px 8px; + box-shadow: none; + outline-offset: 0; +} + +li.rdw-dropdownoption-default { + border-radius: 4px; + padding: 2px; + outline-offset: 0; +} + +.rdw-option-wrapper:hover:not(:disabled) { + background-color: #D8EBFF; + box-shadow: none; +} + +.rdw-option-wrapper.rdw-option-active { + background-color: #C0DFFF; + outline: 1px solid #605E5C; +} + +.rdw-option-wrapper.rdw-option-active:hover { + background-color: #A7D2FF; +} + +.rdw-dropdown-wrapper.rdw-block-dropdown{ + border-radius: 8px; +} + +.rdw-dropdown-wrapper.rdw-block-dropdown:hover, .rdw-dropdown-wrapper.rdw-block-dropdown[aria-expanded="true"] { + background-color: #D8EBFF; + box-shadow: none; +} diff --git a/met-web/src/components/common/Typography/Body.tsx b/met-web/src/components/common/Typography/Body.tsx index 99a5195bb..65adf36b4 100644 --- a/met-web/src/components/common/Typography/Body.tsx +++ b/met-web/src/components/common/Typography/Body.tsx @@ -1,5 +1,8 @@ import React from 'react'; import { Typography, TypographyProps } from '@mui/material'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; +import { colors } from 'styles/Theme'; export const BodyText = ({ bold, @@ -47,6 +50,19 @@ export const BodyText = ({ ); }; +export const ErrorMessage = ({ error }: { error?: string }) => { + if (!error) return null; + return ( + + + {error} + + ); +}; + export const EyebrowText = ({ children, ...props diff --git a/met-web/src/components/common/Typography/Headers.tsx b/met-web/src/components/common/Typography/Headers.tsx index 869fa4d82..687a7524b 100644 --- a/met-web/src/components/common/Typography/Headers.tsx +++ b/met-web/src/components/common/Typography/Headers.tsx @@ -85,9 +85,38 @@ export const Header2 = ({ ); }; +export const Header3 = ({ + children, + weight, + component, + ...props +}: { + children: React.ReactNode; + weight?: 'bold' | 'regular' | 'thin'; + component?: React.ElementType; +} & TypographyProps) => { + return ( + + {children} + + ); +}; + const Headers = { Header1, Header2, + Header3, }; export default Headers; diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringBanner.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringBanner.tsx index b58521d0d..b46ba3a41 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringBanner.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringBanner.tsx @@ -1,136 +1,162 @@ +import React, { useState, useEffect } from 'react'; import { FormControlLabel, Grid, MenuItem, Radio, RadioGroup, Select } from '@mui/material'; -import React, { useState } from 'react'; import { useOutletContext } from 'react-router-dom'; import { TextField } from 'components/common/Input'; import { AuthoringTemplateOutletContext } from './types'; import { colors } from 'styles/Theme'; -import { EyebrowText as FormDescriptionText } from 'components/common/Typography'; -import { MetLabel, MetHeader3, MetLabel as MetBigLabel } from 'components/common'; +import { BodyText } from 'components/common/Typography'; import ImageUpload from 'components/imageUpload'; +import { AuthoringFormContainer, AuthoringFormSection } from './AuthoringFormLayout'; +import { Header3 } from 'components/common/Typography/Headers'; +import { EngagementViewSections } from 'components/engagement/public/view'; +import { Controller, useFormContext } from 'react-hook-form'; +import { SUBMISSION_STATUS } from 'constants/engagementStatus'; +import { RichTextArea } from 'components/common/Input/RichTextArea'; +import { getEditorStateFromRaw } from 'components/common/RichTextEditor/utils'; +import { convertToRaw, EditorState } from 'draft-js'; +import { defaultValuesObject, EngagementUpdateData } from './AuthoringContext'; +import { ErrorMessage } from 'components/common/Typography/Body'; const ENGAGEMENT_UPLOADER_HEIGHT = '360px'; const ENGAGEMENT_CROPPER_ASPECT_RATIO = 1920 / 700; const AuthoringBanner = () => { - const { engagement }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template + // Access the form functions and values from the authoring template + const { engagement, setDefaultValues }: AuthoringTemplateOutletContext = useOutletContext(); + const { + setValue, + getValues, + watch, + reset, + control, + formState: { errors }, + } = useFormContext(); - const [bannerImage, setBannerImage] = useState(); - const [savedBannerImageFileName, setSavedBannerImageFileName] = useState(engagement.banner_filename || ''); - - const [openCtaExternalURLEnabled, setOpenCtaExternalURLEnabled] = useState(false); - const [openCtaSectionSelectEnabled, setOpenCtaSectionSelectEnabled] = useState(true); - const [viewResultsCtaExternalURLEnabled, setViewResultsCtaExternalURLEnabled] = useState(false); - const [viewResultsSectionSelectEnabled, setViewResultsSectionSelectEnabled] = useState(true); + const open_section = engagement.status_block.find((block) => block.survey_status === SUBMISSION_STATUS.OPEN); + const closed_section = engagement.status_block.find((block) => block.survey_status === SUBMISSION_STATUS.CLOSED); + const upcoming_section = engagement.status_block.find( + (block) => block.survey_status === SUBMISSION_STATUS.UPCOMING, + ); + const view_results_section = engagement.status_block.find( + (block) => block.survey_status === SUBMISSION_STATUS.VIEW_RESULTS, + ); - //Define the styles - const metBigLabelStyles = { - fontSize: '1.05rem', - marginBottom: '0.7rem', - lineHeight: 1.167, - color: '#292929', - fontWeight: '700', - }; - const metHeader3Styles = { - fontSize: '1.05rem', - marginBottom: '0.7rem', - }; - const formDescriptionTextStyles = { - fontSize: '0.9rem', - marginBottom: '1.5rem', - }; - const metLabelStyles = { - fontSize: '0.95rem', - }; - const formItemContainerStyles = { - padding: '2rem 1.4rem !important', - margin: '1rem 0', - borderRadius: '16px', - }; - const conditionalSelectStyles = { - width: '100%', - backgroundColor: colors.surface.white, - borderRadius: '8px', - // boxShadow: '0 0 0 1px #7A7876 inset', - lineHeight: '1.4375em', - height: '48px', - marginTop: '8px', - padding: '0', - '&:disabled': { - boxShadow: 'none', - }, - }; + const [upcomingEditorState, setUpcomingEditorState] = useState( + getEditorStateFromRaw(upcoming_section?.block_text || ''), + ); + const [closedEditorState, setClosedEditorState] = useState( + getEditorStateFromRaw(closed_section?.block_text || ''), + ); const handleAddBannerImage = (files: File[]) => { if (files.length > 0) { - setBannerImage(files[0]); + setValue('image_file', files[0], { shouldDirty: true }); return; } - setBannerImage(null); - setSavedBannerImageFileName(''); + setValue('image_file', null, { shouldDirty: true }); + setValue('image_url', ''); }; - const handleRadioSelectChange = (event: React.FormEvent, source: string) => { - const newRadioValue = event.currentTarget.value; - if ('ctaLinkType' === source) { - setOpenCtaExternalURLEnabled('section' === newRadioValue ? false : true); - setOpenCtaSectionSelectEnabled('section' === newRadioValue ? true : false); - } else { - setViewResultsCtaExternalURLEnabled('section' === newRadioValue ? false : true); - setViewResultsSectionSelectEnabled('section' === newRadioValue ? true : false); - } + useEffect(() => { + reset(defaultValuesObject); + setValue('id', Number(engagement.id)); + setValue('name', engagement.name); + setValue('image_url', engagement.banner_url); + setValue('eyebrow', engagement.sponsor_name); + setValue('open_cta', open_section?.button_text); + setValue('open_cta_link_type', open_section?.link_type); + setValue('open_section_link', open_section?.internal_link); + setValue('open_external_link', open_section?.external_link); + setValue('view_results_cta', view_results_section?.button_text); + setValue('view_results_link_type', view_results_section?.link_type); + setValue('view_results_section_link', view_results_section?.internal_link); + setValue('view_results_external_link', view_results_section?.external_link); + setValue('closed_message', closed_section?.block_text); + setValue('upcoming_message', upcoming_section?.block_text); + setUpcomingEditorState(getEditorStateFromRaw(upcoming_section?.block_text || '')); + setClosedEditorState(getEditorStateFromRaw(closed_section?.block_text || '')); + setDefaultValues(getValues()); + }, [engagement]); + + const updateEditorState = (editorState: EditorState, field: 'upcoming_message' | 'closed_message') => { + const stateSetters = { + upcoming_message: setUpcomingEditorState, + closed_message: setClosedEditorState, + }; + stateSetters[field](editorState); + setValue(field, JSON.stringify(convertToRaw(editorState.getCurrentContent())), { shouldDirty: true }); + // Set the plain text value for the editor state for length validation + setValue(`_${field}_plain`, editorState.getCurrentContent().getPlainText()); }; return ( - - - - - - - - - - - - Hero Image (Required) - - - Please ensure you use high quality images that help to communicate the topic of your engagement. You - must ensure that any important subject matter is positioned on the right side. - + + + ( + + )} + /> + + + ( + + )} + /> + + + { height={ENGAGEMENT_UPLOADER_HEIGHT} cropAspectRatio={ENGAGEMENT_CROPPER_ASPECT_RATIO} /> - + - Engagement State Content Variants - + Engagement State Content Variants + The content in this section of your engagement may be changed based on the state or status of your engagement. Select the Section Preview or Page Preview button to see each of these states. - - - - - - - - - - - - - - + + + + Message Text + + + updateEditorState(value, 'upcoming_message')} + /> + + {upcomingEditorState.getCurrentContent().getPlainText().length}/150 + + + + ( + + )} + /> + ( + + + } label="In-Page Section Link" /> +
+ ( + + )} + /> +
+ + } label="External URL" /> +
+ ( + + )} + /> +
+
+ )} + /> +
+ + + Message Text + + + updateEditorState(value, 'closed_message')} + /> + + {closedEditorState.getCurrentContent().getPlainText().length}/150 + + + + ( + + )} + /> + ( + + + } label="In-Page Section Link" /> +
+ ( + + )} + /> +
+ + } label="External URL" /> +
- - - - -
+ ( + + )} + /> +
+ + )} + /> + + ); }; diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx index d5c116d3c..b2950ac33 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx @@ -1,10 +1,64 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import dayjs, { Dayjs } from 'dayjs'; import { FormProvider, useForm } from 'react-hook-form'; -import { createSearchParams, useFetcher, Outlet } from 'react-router-dom'; +import { createSearchParams, useFetcher, Outlet, useMatch } from 'react-router-dom'; import { EditorState } from 'draft-js'; +import * as yup from 'yup'; +import { EngagementViewSections } from 'components/engagement/public/view'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { saveObject } from 'services/objectStorageService'; -export interface EngagementUpdateData { +const authoringTemplateSchema = yup.object({ + name: yup.string().required('Engagement title is required'), + eyebrow: yup.string().nullable().max(40, 'Eyebrow text must be 40 characters or less'), + image_url: yup + .string() + .url('Image URL must be a valid URL') + .when('image_file', (image_file: File, schema: yup.AnySchema) => { + return image_file ? schema.notRequired() : schema.required('An image is required'); + }), + image_file: yup.mixed().nullable(), + upcoming_message: yup.string(), + _upcoming_message_plain: yup.string().max(150, '"Upcoming" state message must be 150 characters or less'), + open_cta: yup.string().max(20, '"Open" state CTA must be 20 characters or less'), + open_cta_link_type: yup.string().required('Primary CTA link type is required').oneOf(['internal', 'external']), + open_section_link: yup + .string() + .nullable() + .when('cta_link_type', { + is: 'internal', + then: yup.string().required('Section link is required').oneOf(Object.values(EngagementViewSections)), + }), + open_external_link: yup + .string() + .nullable() + .when('cta_link_type', { + is: 'external', + then: yup.string().url('External link must be a valid URL').required('A link is required'), + }), + closed_message: yup.string(), + _closed_message_plain: yup.string().max(150, '"Closed" state message must be 150 characters or less'), + view_results_cta: yup.string().max(20, '"View Results" state CTA must be 20 characters or less'), + view_results_link_type: yup.string().required('View results link type is required').oneOf(['internal', 'external']), + view_results_section_link: yup + .string() + .nullable() + .when('view_results_link_type', { + is: 'internal', + then: yup.string().required('A link is required').oneOf(Object.values(EngagementViewSections)), + }), + view_results_external_link: yup + .string() + .nullable() + .when('view_results_link_type', { + is: 'external', + then: yup.string().url('External link must be a valid URL').required('A link is required'), + }), +}); + +export interface EngagementUpdateData extends yup.TypeOf { id: number; status_id: number; taxon_id: number; @@ -33,7 +87,6 @@ export const defaultValuesObject = { status_id: 0, taxon_id: 0, content_id: 0, - name: '', start_date: dayjs(new Date(1970, 0, 1)), end_date: dayjs(new Date(1970, 0, 1)), description: '', @@ -50,19 +103,71 @@ export const defaultValuesObject = { text_content: '', json_content: '{ blocks: [], entityMap: {} }', summary_editor_state: EditorState.createEmpty(), -}; + // Hero banner fields + name: '', + eyebrow: '', + image_url: '', + image_file: undefined, + upcoming_message: '', + _upcoming_message_plain: '', + open_cta: '', + open_cta_link_type: 'internal', + open_section_link: EngagementViewSections.PROVIDE_FEEDBACK, + open_external_link: '', + closed_message: '', + _closed_message_plain: '', + view_results_cta: '', + view_results_link_type: 'internal', + view_results_section_link: EngagementViewSections.PROVIDE_FEEDBACK, + view_results_external_link: '', +} as EngagementUpdateData; export const AuthoringContext = () => { const [defaultValues, setDefaultValues] = useState(defaultValuesObject); const fetcher = useFetcher(); - const locationArray = window.location.href.split('/'); - const slug = locationArray[locationArray.length - 1]; + // Check if the form has succeeded or failed after a submit, and issue a message to the user. + const dispatch = useAppDispatch(); + useEffect(() => { + if ('success' === fetcher.data || 'failure' === fetcher.data) { + const responseText = + 'success' === fetcher.data ? 'Engagement saved successfully.' : 'Unable to save engagement.'; + const responseSeverity = 'success' === fetcher.data ? 'success' : 'error'; + dispatch( + openNotification({ + severity: responseSeverity, + text: responseText, + }), + ); + fetcher.data = undefined; + } + }, [fetcher.data]); + const pageName = useMatch('/engagements/:engagementId/details/authoring/:page')?.params.page; + /* Changes the resolver based on the page name. + If you require more complex validation, you can + define your own resolver and add a case for it here. + Using a global resolver is not recommended as required + fields will still be validated on other pages. + */ + const resolver = useMemo(() => { + switch (pageName) { + case 'banner': + // on the banner page, we need inter-field validation so we use the yup resolver + return yupResolver(authoringTemplateSchema); + default: + return undefined; + } + }, [pageName]); const engagementUpdateForm = useForm({ defaultValues: useMemo(() => defaultValues, [defaultValues]), mode: 'onSubmit', reValidateMode: 'onChange', + resolver: resolver, }); const onSubmit = async (data: EngagementUpdateData) => { + const savedImageDetails = data.image_file + ? await saveObject(data.image_file, { filename: data.image_file.name }) + : undefined; + fetcher.submit( createSearchParams({ id: 0 === data.id ? '' : data.id.toString(), @@ -77,7 +182,6 @@ export const AuthoringContext = () => { description: data.description, rich_description: data.rich_description, description_title: data.description_title, - banner_filename: data.banner_filename, status_block: data.status_block, title: data.title, icon_name: data.icon_name, @@ -87,10 +191,26 @@ export const AuthoringContext = () => { request_type: data.request_type, text_content: data.text_content, json_content: data.json_content, + + banner_filename: savedImageDetails?.uniquefilename || '', + + eyebrow: data.eyebrow || '', + upcoming_message: data.upcoming_message || '', + _upcoming_message_plain: data._upcoming_message_plain || '', + open_cta: data.open_cta || '', + open_cta_link_type: data.open_cta_link_type || '', + open_section_link: data.open_section_link || '', + open_external_link: data.open_external_link || '', + closed_message: data.closed_message || '', + _closed_message_plain: data._closed_message_plain || '', + view_results_cta: data.view_results_cta || '', + view_results_link_type: data.view_results_link_type || '', + view_results_section_link: data.view_results_section_link || '', + view_results_external_link: data.view_results_external_link || '', }), { method: 'post', - action: `/engagements/${data.id}/details/authoring/${slug}`, + action: `/engagements/${data.id}/details/authoring/${pageName}`, }, ); }; diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx index 63ccc6a32..52c04cccd 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx @@ -9,7 +9,6 @@ import { Palette } from 'styles/Theme'; const AuthoringFeedback = () => { const [sectionHeading, setSectionHeading] = useState(''); - const [bodyText, setBodyText] = useState(''); const [editorState, setEditorState] = useState(); const [surveyButtonText, setSurveyButtonText] = useState(''); const [thirdPartyCtaText, setThirdPartyCtaText] = useState(''); @@ -77,9 +76,7 @@ const AuthoringFeedback = () => { }; const handleEditorChange = (newEditorState: EditorState) => { - const plainText = newEditorState.getCurrentContent().getPlainText(); setEditorState(newEditorState); - setBodyText(plainText); }; const handleWidgetChange = (event: SelectChangeEvent) => { diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringFormLayout.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringFormLayout.tsx new file mode 100644 index 000000000..1b772db0c --- /dev/null +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringFormLayout.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Grid, GridProps, Box, BoxProps } from '@mui/material'; +import { BodyText } from 'components/common/Typography'; + +export const AuthoringFormContainer = ({ + children, + ...formContainerProps +}: { children: React.ReactNode } & GridProps) => { + return ( + + {children} + + ); +}; + +export const UnnamedAuthoringFormSection = ({ + children, + required, + gridItemProps, + ...formSectionProps +}: { children: React.ReactNode; required?: boolean; gridItemProps?: GridProps } & BoxProps) => { + return ( + + + {children} + + + ); +}; + +export const AuthoringFormSection = ({ + children, + required, + name, + details, + labelFor, + gridItemProps, + ...formSectionProps +}: { + children: React.ReactNode; + required?: boolean; + name?: string; + details?: string; + labelFor?: string; + gridItemProps?: GridProps; +} & BoxProps) => { + return ( + + {name && ( + + + {name} + + {required ? ' (Required)' : ' (Optional)'} + + + {details && ( + + {details} + + )} + + )} + {children} + + ); +}; diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringNavElements.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringNavElements.tsx index f49485544..17514e6d7 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringNavElements.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringNavElements.tsx @@ -1,5 +1,4 @@ import { USER_ROLES } from 'services/userService/constants'; -import { TenantState } from 'reduxSlices/tenantSlice'; export interface AuthoringRoute { name: string; @@ -10,11 +9,11 @@ export interface AuthoringRoute { required?: boolean; } -export const getAuthoringRoutes = (engagementId: number, tenant: TenantState): AuthoringRoute[] => [ +export const getAuthoringRoutes = (engagementId: number): AuthoringRoute[] => [ { name: 'Engagement Home', - path: `/${tenant.id}/engagements/${engagementId}/details/authoring`, - base: `/${tenant.id}/engagements`, + path: `/engagements/${engagementId}/details/authoring`, + base: `/engagements`, authenticated: false, allowedRoles: [USER_ROLES.EDIT_ENGAGEMENT], required: true, diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringSideNav.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringSideNav.tsx index 76fc7fc85..8aa441b37 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringSideNav.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringSideNav.tsx @@ -41,14 +41,13 @@ export const routeItemStyle = { const DrawerBox = ({ isMediumScreenOrLarger, setOpen, engagementId }: DrawerBoxProps) => { const permissions = useAppSelector((state) => state.user.roles); - const tenant = useAppSelector((state) => state.tenant); - const currentRoutePath = getRoutes(Number(engagementId), tenant) + const currentRoutePath = getRoutes(Number(engagementId)) .map((route) => route.path) .filter((route) => location.pathname.includes(route)) .reduce((prev, curr) => (prev.length > curr.length ? prev : curr)); - const allowedRoutes = getRoutes(Number(engagementId), tenant).filter((route) => { + const allowedRoutes = getRoutes(Number(engagementId)).filter((route) => { return !route.authenticated || route.allowedRoles.some((role) => permissions.includes(role)); }); @@ -154,7 +153,7 @@ const DrawerBox = ({ isMediumScreenOrLarger, setOpen, engagementId }: DrawerBoxP {/* Engagement Home link */} - {getRoutes(Number(engagementId), tenant)[0].name} + {getRoutes(Number(engagementId))[0].name}
{/* All other menu items */} diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx index e52d526cb..ceef70d92 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringSummary.tsx @@ -1,70 +1,29 @@ import { Grid } from '@mui/material'; import React, { useEffect } from 'react'; -import { useLoaderData, useOutletContext } from 'react-router-dom'; +import { useRouteLoaderData, useOutletContext } from 'react-router-dom'; import { TextField } from 'components/common/Input'; import { AuthoringTemplateOutletContext } from './types'; import { colors } from 'styles/Theme'; import { EyebrowText as FormDescriptionText } from 'components/common/Typography'; import { MetHeader3, MetLabel as MetBigLabel } from 'components/common'; import { RichTextArea } from 'components/common/Input/RichTextArea'; -import { convertFromRaw, convertToRaw, EditorState } from 'draft-js'; +import { convertToRaw, EditorState } from 'draft-js'; import { Controller } from 'react-hook-form'; -import { useAppDispatch } from 'hooks'; -import { openNotification } from 'services/notificationService/notificationSlice'; -import dayjs from 'dayjs'; -import { EngagementUpdateData } from './AuthoringContext'; +import { defaultValuesObject } from './AuthoringContext'; import { EngagementLoaderData } from 'components/engagement/public/view'; +import WidgetPicker from '../widgets'; +import { WidgetLocation } from 'models/widget'; +import { getEditorStateFromRaw } from 'components/common/RichTextEditor/utils'; const AuthoringSummary = () => { - const { setValue, control, reset, getValues, setDefaultValues, fetcher }: AuthoringTemplateOutletContext = + const { setValue, control, reset, getValues, setDefaultValues }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template. - // Check if the form has succeeded or failed after a submit, and issue a message to the user. - const dispatch = useAppDispatch(); - useEffect(() => { - if ('success' === fetcher.data || 'failure' === fetcher.data) { - const responseText = - 'success' === fetcher.data ? 'Engagement saved successfully.' : 'Unable to save engagement.'; - const responseSeverity = 'success' === fetcher.data ? 'success' : 'error'; - dispatch( - openNotification({ - severity: responseSeverity, - text: responseText, - }), - ); - fetcher.data = undefined; - } - }, [fetcher.data]); - - const { engagement } = useLoaderData() as EngagementLoaderData; - - const untouchedDefaultValues: EngagementUpdateData = { - id: 0, - status_id: 0, - taxon_id: 0, - content_id: 0, - name: '', - start_date: dayjs(new Date(1970, 0, 1)), - end_date: dayjs(new Date(1970, 0, 1)), - description: '', - rich_description: '', - description_title: '', - banner_filename: '', - status_block: [], - title: '', - icon_name: '', - metadata_value: '', - send_report: undefined, - slug: '', - request_type: '', - text_content: '', - json_content: '{ blocks: [], entityMap: {} }', - summary_editor_state: EditorState.createEmpty(), - }; + const { engagement } = useRouteLoaderData('single-engagement') as EngagementLoaderData; // Reset values to default and retrieve relevant content from loader. useEffect(() => { - reset(untouchedDefaultValues); + reset(defaultValuesObject); engagement.then((eng) => { setValue('id', Number(eng.id)); // Make sure it is valid JSON. @@ -73,10 +32,7 @@ const AuthoringSummary = () => { } setValue('description_title', eng.description_title || ''); setValue('description', eng.description); - setValue( - 'summary_editor_state', - EditorState.createWithContent(convertFromRaw(JSON.parse(eng.rich_description))), - ); + setValue('summary_editor_state', getEditorStateFromRaw(eng.rich_description || '')); // Update default values so that our loaded values are default. setDefaultValues(getValues()); }); @@ -103,22 +59,6 @@ const AuthoringSummary = () => { margin: '1rem 0', borderRadius: '16px', }; - const toolbarStyles = { - border: '1px solid #7A7876', - borderBottom: 'none', - borderRadius: '8px 8px 0 0', - marginBottom: '0', - maxWidth: '100%', - }; - const editorStyles = { - height: '15em', - padding: '0 1em 1em', - backgroundColor: colors.surface.white, - border: '1px solid #7A7876', - borderTop: 'none', - borderRadius: '0 0 8px 8px', - maxWidth: '100%', - }; const toolbar = { options: ['inline', 'list', 'link', 'blockType', 'history'], @@ -203,8 +143,6 @@ const AuthoringSummary = () => { field.onChange(handleEditorChange(value)); }} handlePastedText={() => false} - toolbarStyle={toolbarStyles} - editorStyle={editorStyles} toolbar={toolbar} /> ); @@ -218,6 +156,9 @@ const AuthoringSummary = () => { You may use a widget to add supporting content to your primary content. + + + ); diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringTemplate.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringTemplate.tsx index 851247147..a4ce9871a 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringTemplate.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringTemplate.tsx @@ -1,25 +1,19 @@ import React, { Suspense, useMemo, useState } from 'react'; -import { useOutletContext, Form, useParams, Await, Outlet, useLoaderData } from 'react-router-dom'; +import { useOutletContext, Form, useParams, Await, Outlet, useMatch, useRouteLoaderData } from 'react-router-dom'; import AuthoringBottomNav from './AuthoringBottomNav'; import { EngagementUpdateData } from './AuthoringContext'; import { useFormContext } from 'react-hook-form'; -import UnsavedWorkConfirmation from 'components/common/Navigation/UnsavedWorkConfirmation'; import { AuthoringContextType, StatusLabelProps } from './types'; import { AutoBreadcrumbs } from 'components/common/Navigation/Breadcrumb'; import { ResponsiveContainer } from 'components/common/Layout'; import { EngagementStatus } from 'constants/engagementStatus'; -import { EyebrowText, Header2 } from 'components/common/Typography'; +import { Header1, Header2 } from 'components/common/Typography'; import { useAppSelector } from 'hooks'; import { Language } from 'models/language'; import { getAuthoringRoutes } from './AuthoringNavElements'; -import { FormControlLabel, Grid, Radio, RadioGroup } from '@mui/material'; -import { SystemMessage } from 'components/common/Layout/SystemMessage'; -import { getEditorStateFromRaw } from 'components/common/RichTextEditor/utils'; -import { When } from 'react-if'; -import WidgetPicker from '../widgets'; -import { WidgetLocation } from 'models/widget'; import { Engagement } from 'models/engagement'; import { getTenantLanguages } from 'services/languageService'; +import { EngagementLoaderData } from 'components/engagement/public/view'; export const StatusLabel = ({ text, completed }: StatusLabelProps) => { const statusLabelStyle = { @@ -39,28 +33,16 @@ export const getLanguageValue = (currentLanguage: string, languages: Language[]) const AuthoringTemplate = () => { const { onSubmit, defaultValues, setDefaultValues, fetcher }: AuthoringContextType = useOutletContext(); const { engagementId } = useParams() as { engagementId: string }; // We need the engagement ID quickly, so let's grab it from useParams - const { engagement } = useLoaderData() as { engagement: Promise }; + const { engagement } = useRouteLoaderData('single-engagement') as EngagementLoaderData; const [currentLanguage, setCurrentLanguage] = useState(useAppSelector((state) => state.language.id)); - const [contentTabsEnabled, setContentTabsEnabled] = useState('false'); // todo: replace default value with stored value in engagement. - const defaultTabValues = { - heading: 'Tab 1', - bodyCopyPlainText: '', - bodyCopyEditorState: getEditorStateFromRaw(''), - widget: '', - }; - const [tabs, setTabs] = useState([defaultTabValues]); - const [singleContentValues, setSingleContentValues] = useState({ ...defaultTabValues, heading: '' }); const tenant = useAppSelector((state) => state.tenant); const languages = useMemo(() => getTenantLanguages(tenant.id), [tenant.id]); // todo: Using tenant language list until language data is integrated with the engagement. - const authoringRoutes = getAuthoringRoutes(Number(engagementId), tenant); - const location = window.location.href; - const locationArray = location.split('/'); - const slug = locationArray[locationArray.length - 1]; + const authoringRoutes = getAuthoringRoutes(Number(engagementId)); + const pageName = useMatch('/engagements/:engagementId/details/authoring/:page')?.params.page; const pageTitle = authoringRoutes.find((route) => { const pathArray = route.path.split('/'); - const pathSlug = pathArray[pathArray.length - 1]; - return pathSlug === slug; + return pathArray[pathArray.length - 1] === pageName; })?.name; const { @@ -70,30 +52,9 @@ const AuthoringTemplate = () => { watch, reset, control, - formState: { isDirty, isValid, isSubmitting }, + formState: { isDirty, isValid, isSubmitting, errors }, } = useFormContext(); - const eyebrowTextStyles = { - fontSize: '0.9rem', - marginBottom: '1rem', - }; - - // If switching from single to multiple, add "tab 2". If switching from multiple to single, remove all values but index 0. - const handleTabsRadio = (event: React.ChangeEvent) => { - const newTabsRadioValue = event.target.value; - if ('true' === newTabsRadioValue) { - setTabs([ - { ...tabs[0], heading: singleContentValues.heading ? singleContentValues.heading : 'Tab 1' }, - { ...defaultTabValues, heading: 'Tab 2' }, - ]); - setContentTabsEnabled('true'); - } else { - setSingleContentValues({ ...tabs[0], heading: 'Tab 1' !== tabs[0].heading ? tabs[0].heading : '' }); - setTabs([tabs[0]]); - setContentTabsEnabled('false'); - } - }; - return ( @@ -108,50 +69,10 @@ const AuthoringTemplate = () => { {/* todo: For the section status label when it's ready */} {/* */} -

{pageTitle}

- - Under construction - the settings in this section have no effect. - + {pageTitle} - - - - Content Configuration - - - In the Details Section of your engagement, you have the option to display your content in a - normal, static page section view (No Tabs), or for lengthy content, use Tabs. You may wish to - use tabs if your content is quite lengthy so you can organize it into smaller, more digestible - chunks and reduce the length of your engagement page. - - - - } - label="No Tabs" - /> - - - } - label="Tabs (2 Minimum)" - /> - - - - + {/* Portal target for anything that needs to be rendered before the section title + content */} +
@@ -171,28 +92,20 @@ const AuthoringTemplate = () => { context={{ setValue, watch, - setTabs, - setSingleContentValues, - setContentTabsEnabled, getValues, setDefaultValues, reset, engagement, control, - contentTabsEnabled, - tabs, - singleContentValues, - defaultTabValues, isDirty, + errors, defaultValues, fetcher, - slug, }} /> )} - {(languages: Language[]) => ( @@ -210,12 +123,6 @@ const AuthoringTemplate = () => { - - - - - - ); }; diff --git a/met-web/src/components/engagement/admin/create/authoring/engagementAuthoringUpdateAction.tsx b/met-web/src/components/engagement/admin/create/authoring/engagementAuthoringUpdateAction.tsx index 94fdb720a..7958b4361 100644 --- a/met-web/src/components/engagement/admin/create/authoring/engagementAuthoringUpdateAction.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/engagementAuthoringUpdateAction.tsx @@ -4,14 +4,43 @@ import { patchEngagementContent } from 'services/engagementContentService'; import { patchEngagementMetadata } from 'services/engagementMetadataService'; import { patchEngagementSettings } from 'services/engagementSettingService'; import { patchEngagementSlug } from 'services/engagementSlugService'; +import { EngagementStatusBlock } from 'models/engagementStatusBlock'; -export const engagementAuthoringUpdateAction: ActionFunction = async ({ request }) => { +export const engagementAuthoringUpdateAction: ActionFunction = async ({ request, context }) => { const formData = (await request.formData()) as FormData; const errors = []; const requestType = formData.get('request_type') as string; + const statusBlock = [ + { + survey_status: 'Open', + button_text: formData.get('open_cta') as string, + link_type: formData.get('open_cta_link_type') as string, + internal_link: formData.get('open_section_link') as string, + external_link: formData.get('open_external_link') as string, + }, + { + survey_status: 'ViewResults', + button_text: formData.get('view_results_cta') as string, + link_type: formData.get('view_results_link_type') as string, + internal_link: formData.get('view_results_section_link') as string, + external_link: formData.get('view_results_external_link') as string, + }, + { + survey_status: 'Closed', + block_text: formData.get('closed_message') as string, + link_type: 'none', + }, + { + survey_status: 'Upcoming', + block_text: formData.get('upcoming_message') as string, + link_type: 'none', + }, + ] as EngagementStatusBlock[]; + const engagement = await patchEngagement({ id: Number(formData.get('id')) as unknown as number, name: (formData.get('name') as string) || undefined, + sponsor_name: (formData.get('eyebrow') as string) || undefined, start_date: (formData.get('start_date') as string) || undefined, status_id: (Number(formData.get('status_id')) as unknown as number) || undefined, end_date: (formData.get('end_date') as string) || undefined, @@ -19,7 +48,7 @@ export const engagementAuthoringUpdateAction: ActionFunction = async ({ request rich_description: (formData.get('rich_description') as string) || undefined, description_title: (formData.get('description_title') as string) || undefined, banner_filename: (formData.get('banner_filename') as string) || undefined, - status_block: (formData.get('status_block') as unknown as unknown[]) || undefined, + status_block: statusBlock, }); // Update engagement content if necessary. diff --git a/met-web/src/components/engagement/admin/create/authoring/types.ts b/met-web/src/components/engagement/admin/create/authoring/types.ts index d797c8c7e..7a47b603f 100644 --- a/met-web/src/components/engagement/admin/create/authoring/types.ts +++ b/met-web/src/components/engagement/admin/create/authoring/types.ts @@ -23,7 +23,7 @@ export interface AuthoringContextType { onSubmit: SubmitHandler; defaultValues: EngagementUpdateData; setDefaultValues: Dispatch>; - fetcher: FetcherWithComponents; + fetcher: FetcherWithComponents; } export interface LanguageSelectorProps { @@ -54,21 +54,21 @@ export interface AuthoringTemplateOutletContext { setValue: UseFormSetValue; getValues: UseFormGetValues; watch: UseFormWatch; - control: Control; + control: Control; engagement: Engagement; + isDirty: boolean; + reset: UseFormReset; + defaultValues: EngagementUpdateData; + setDefaultValues: Dispatch>; + fetcher: FetcherWithComponents; + pageName: string; contentTabsEnabled: string; tabs: TabValues[]; setTabs: Dispatch>; - singleContentValues: TabValues; setSingleContentValues: Dispatch>; setContentTabsEnabled: Dispatch>; + singleContentValues: TabValues; defaultTabValues: TabValues; - isDirty: boolean; - reset: UseFormReset; - defaultValues: EngagementUpdateData; - setDefaultValues: Dispatch>; - fetcher: FetcherWithComponents; - slug: string; } export interface DetailsTabProps { diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx index 5eff801ad..835c8cbe6 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx @@ -234,28 +234,6 @@ const EngagementForm = () => { onChange={handleChange} /> - - - - - - diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index b11d7c5e4..208e65698 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx @@ -29,8 +29,6 @@ interface EngagementFormData { is_internal: boolean; consent_message: string; sponsor_name: string; - cta_message: string; - cta_url: string; } interface EngagementSettingsFormData { @@ -51,8 +49,6 @@ const initialEngagementFormData = { is_internal: false, consent_message: '', sponsor_name: '', - cta_message: '', - cta_url: '', }; const initialEngagementSettingsFormData = { @@ -151,6 +147,7 @@ export const EngagementTabsContext = createContext({ Upcoming: '', Open: '', Closed: '', + ViewResults: '', }, setSurveyBlockText: () => { throw new Error('setSurveyBlockText not implemented'); @@ -212,8 +209,6 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re is_internal: savedEngagement.is_internal || false, consent_message: savedEngagement.consent_message || '', sponsor_name: savedEngagement.sponsor_name, - cta_message: savedEngagement.cta_message, - cta_url: savedEngagement.cta_url, }); const [richDescription, setRichDescription] = useState(savedEngagement?.rich_description || ''); const [richContent, setRichContent] = useState(''); @@ -234,6 +229,9 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re Closed: savedEngagement.status_block.find((block) => block.survey_status === SUBMISSION_STATUS.CLOSED) ?.block_text || '', + ViewResults: + savedEngagement.status_block.find((block) => block.survey_status === SUBMISSION_STATUS.VIEW_RESULTS) + ?.block_text || '', }); // User listing @@ -254,8 +252,6 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re is_internal: savedEngagement.is_internal || false, consent_message: savedEngagement.consent_message || '', sponsor_name: savedEngagement.sponsor_name, - cta_message: savedEngagement.cta_message, - cta_url: savedEngagement.cta_url, }); setRichDescription(savedEngagement?.rich_description || ''); setRichConsentMessage(savedEngagement?.consent_message || ''); @@ -408,14 +404,26 @@ export const EngagementTabsContextProvider = ({ children }: { children: React.Re { survey_status: SUBMISSION_STATUS.UPCOMING, block_text: surveyBlockText.Upcoming, + link_type: 'none', }, { survey_status: SUBMISSION_STATUS.OPEN, block_text: surveyBlockText.Open, + button_text: 'Provide Feedback', + link_type: 'internal', + internal_link: 'provideFeedback', }, { survey_status: SUBMISSION_STATUS.CLOSED, block_text: surveyBlockText.Closed, + link_type: 'none', + }, + { + survey_status: SUBMISSION_STATUS.VIEW_RESULTS, + block_text: '', + link_type: 'internal', + internal_link: 'provideFeedback', + button_text: 'View Results', }, ]; const validateForm = () => { diff --git a/met-web/src/components/engagement/form/types.ts b/met-web/src/components/engagement/form/types.ts index 326682581..59d7e6dac 100644 --- a/met-web/src/components/engagement/form/types.ts +++ b/met-web/src/components/engagement/form/types.ts @@ -41,8 +41,6 @@ export interface EngagementForm { rich_content: string; status_block: EngagementStatusBlock[]; sponsor_name: string; - cta_message: string; - cta_url: string; } export interface EngagementFormUpdate { @@ -56,8 +54,6 @@ export interface EngagementFormUpdate { status_block?: EngagementStatusBlock[]; consent_message?: string; sponsor_name?: string; - cta_message?: string; - cta_url?: string; } export type EngagementParams = { diff --git a/met-web/src/components/engagement/public/view/EngagementContentTabs.tsx b/met-web/src/components/engagement/public/view/EngagementContentTabs.tsx index 0f2189dd3..40de166ae 100644 --- a/met-web/src/components/engagement/public/view/EngagementContentTabs.tsx +++ b/met-web/src/components/engagement/public/view/EngagementContentTabs.tsx @@ -8,6 +8,7 @@ import { Header2 } from 'components/common/Typography'; import { colors } from 'components/common'; import { RichTextArea } from 'components/common/Input/RichTextArea'; import { EngagementLoaderData } from './EngagementLoader'; +import { EngagementViewSections } from '.'; export const EngagementContentTabs = () => { const { content } = useLoaderData() as EngagementLoaderData; @@ -39,7 +40,7 @@ export const EngagementContentTabs = () => { }; return ( -
+
{ const { engagement, widgets } = useLoaderData() as { widgets: Widget[]; engagement: Engagement }; return ( -
+
{ const dateFormat = 'MMM DD, YYYY'; @@ -20,7 +21,7 @@ export const EngagementHero = () => { const engagementInfo = Promise.all([engagement, startDate, endDate]); return ( -
+
@@ -114,7 +115,10 @@ export const EngagementHero = () => { )} diff --git a/met-web/src/components/engagement/public/view/EngagementSurveyBlock.tsx b/met-web/src/components/engagement/public/view/EngagementSurveyBlock.tsx index 035d2078d..89b21bbbc 100644 --- a/met-web/src/components/engagement/public/view/EngagementSurveyBlock.tsx +++ b/met-web/src/components/engagement/public/view/EngagementSurveyBlock.tsx @@ -16,6 +16,7 @@ import { faChevronRight } from '@fortawesome/pro-regular-svg-icons'; import { Switch, Case } from 'react-if'; import { useAppSelector, useAppTranslation } from 'hooks'; import EmailModal from 'components/engagement/old-view/EmailModal'; +import { EngagementViewSections } from '.'; const gridContainerStyles = { width: '100%', @@ -99,7 +100,7 @@ export const EngagementSurveyBlock = () => { { return (
diff --git a/met-web/src/components/imageUpload/Uploader.tsx b/met-web/src/components/imageUpload/Uploader.tsx index 994ba5cd0..10665a2b1 100644 --- a/met-web/src/components/imageUpload/Uploader.tsx +++ b/met-web/src/components/imageUpload/Uploader.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useContext } from 'react'; import { Grid, Stack } from '@mui/material'; import Dropzone, { Accept } from 'react-dropzone'; import { ImageUploadContext } from './imageUploadContext'; -import { colors } from 'components/common'; import { Button } from 'components/common/Input'; interface UploaderProps { @@ -106,18 +105,19 @@ const Uploader = ({ height = '10em', accept = {}, children }: UploaderProps) => accept={accept} > {({ getRootProps, getInputProps }) => ( -
+
{ const pathname = window.location.href; const { engagementId } = useParams() as { engagementId: string }; const currentAuthoringSlug = pathname.split('/').slice(-2).join('/'); - const authoringRoutes = getAuthoringRoutes(Number(engagementId), tenant).map((route) => { + const authoringRoutes = getAuthoringRoutes(Number(engagementId)).map((route) => { // skip the "Engagement Home" link if ('Engagement Home' !== route.name) { const pathArray = route.path.split('/'); diff --git a/met-web/src/constants/engagementStatus.ts b/met-web/src/constants/engagementStatus.ts index 44f244f4e..0ae22186a 100644 --- a/met-web/src/constants/engagementStatus.ts +++ b/met-web/src/constants/engagementStatus.ts @@ -23,12 +23,13 @@ export enum EngagementDisplayStatus { Unpublished = 7, } -export type SubmissionStatusTypes = 'Upcoming' | 'Open' | 'Closed'; +export type SubmissionStatusTypes = 'Upcoming' | 'Open' | 'Closed' | 'ViewResults'; export const SUBMISSION_STATUS: { [status: string]: SubmissionStatusTypes } = { UPCOMING: 'Upcoming', OPEN: 'Open', CLOSED: 'Closed', + VIEW_RESULTS: 'ViewResults', }; export enum PollStatus { diff --git a/met-web/src/models/engagement.ts b/met-web/src/models/engagement.ts index e7ab0d9ec..eefe1cc27 100644 --- a/met-web/src/models/engagement.ts +++ b/met-web/src/models/engagement.ts @@ -28,8 +28,6 @@ export interface Engagement { is_internal: boolean; consent_message: string; sponsor_name: string; - cta_message: string; - cta_url: string; } export interface Status { @@ -99,8 +97,6 @@ export const createDefaultEngagement = (sponsorName?: string): Engagement => { is_internal: false, consent_message: '', sponsor_name: sponsorName ?? '', - cta_message: 'Provide Feedback', - cta_url: '', }; }; diff --git a/met-web/src/models/engagementStatusBlock.ts b/met-web/src/models/engagementStatusBlock.ts index 845aad993..0f4bacf7e 100644 --- a/met-web/src/models/engagementStatusBlock.ts +++ b/met-web/src/models/engagementStatusBlock.ts @@ -1,13 +1,21 @@ +import { EngagementViewSections } from 'components/engagement/public/view'; import { SubmissionStatusTypes } from 'constants/engagementStatus'; export interface EngagementStatusBlock { survey_status: SubmissionStatusTypes; block_text: string; + button_text?: string; + link_type: string; + internal_link?: string; + external_link?: string; } export const createDefaultStatusBlock = (): EngagementStatusBlock => { return { survey_status: 'Upcoming', block_text: '', + link_type: 'internal', + internal_link: EngagementViewSections.PROVIDE_FEEDBACK, + external_link: '', }; }; diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index 6150a41ff..64abc561e 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -102,12 +102,10 @@ const AuthenticatedRoutes = () => { loader={engagementLoader} handle={{ crumb: async (data: { engagement: Promise }) => { - return data.engagement.then((engagement) => { - return { - link: `/engagements/${engagement.id}/old-view`, - name: engagement.name, - }; - }); + return data.engagement.then((engagement) => ({ + name: engagement.name, + link: `/engagements/${engagement.id}/details/config`, + })); }, }} > @@ -130,81 +128,45 @@ const AuthenticatedRoutes = () => { element={} > }> - } id="authoring-loader" loader={engagementLoader}> + } id="authoring-loader"> } action={engagementAuthoringUpdateAction} - handle={{ - crumb: () => ({ - link: `banner`, - name: 'Hero Banner', - }), - }} + handle={{ crumb: () => ({ name: 'Hero Banner' }) }} /> } - handle={{ - crumb: () => ({ - link: `summary`, - name: 'Summary', - }), - }} + handle={{ crumb: () => ({ name: 'Summary' }) }} /> } - handle={{ - crumb: () => ({ - link: `details`, - name: 'Details', - }), - }} + handle={{ crumb: () => ({ name: 'Details' }) }} /> } - handle={{ - crumb: () => ({ - link: `feedback`, - name: 'Provide Feedback', - }), - }} + handle={{ crumb: () => ({ name: 'Provide Feedback' }) }} /> } - handle={{ - crumb: () => ({ - link: `results`, - name: 'View Results', - }), - }} + handle={{ crumb: () => ({ name: 'View Results' }) }} /> } - handle={{ - crumb: () => ({ - link: `subscribe`, - name: 'Subscribe', - }), - }} + handle={{ crumb: () => ({ name: 'Subscribe' }) }} /> } - handle={{ - crumb: () => ({ - link: `more`, - name: 'More Engagements', - }), - }} + handle={{ crumb: () => ({ name: 'More Engagements' }) }} /> @@ -214,9 +176,7 @@ const AuthenticatedRoutes = () => { path="config/edit" element={} action={engagementUpdateAction} - handle={{ - crumb: () => ({ name: 'Configure' }), - }} + handle={{ crumb: () => ({ name: 'Configure' }) }} /> }> diff --git a/met-web/src/services/engagementService/types.ts b/met-web/src/services/engagementService/types.ts index 1c961eceb..e21e8e3c8 100644 --- a/met-web/src/services/engagementService/types.ts +++ b/met-web/src/services/engagementService/types.ts @@ -1,4 +1,5 @@ import { Engagement } from 'models/engagement'; +import { EngagementStatusBlock } from 'models/engagementStatusBlock'; export interface EngagementState { allEngagements: Engagement[]; @@ -14,7 +15,7 @@ export interface PostEngagementRequest { content: string; rich_content: string; banner_filename?: string; - status_block?: unknown[]; + status_block?: EngagementStatusBlock[]; is_internal?: boolean; } @@ -27,7 +28,8 @@ export interface PutEngagementRequest { rich_description: string; description_title: string; banner_filename?: string; - status_block?: unknown[]; + status_block?: EngagementStatusBlock[]; + sponsor_name?: string; } export interface PatchEngagementRequest { @@ -40,6 +42,7 @@ export interface PatchEngagementRequest { rich_description?: string; description_title?: string; banner_filename?: string; - status_block?: unknown[]; + status_block?: EngagementStatusBlock[]; is_internal?: boolean; + sponsor_name?: string; } diff --git a/met-web/src/styles/Theme.ts b/met-web/src/styles/Theme.ts index 819bad08d..e888bcba9 100644 --- a/met-web/src/styles/Theme.ts +++ b/met-web/src/styles/Theme.ts @@ -205,6 +205,15 @@ export const BaseTheme = createTheme({ }, MuiSelect: { defaultProps: { + componentsProps: { + root: { + style: { + backgroundColor: 'white', + borderRadius: '8px', + }, + }, + }, + MenuProps: { sx: { '& .MuiMenu-list .MuiMenuItem-root:hover, .MuiMenu-list .MuiMenuItem-root.Mui-focusVisible': { diff --git a/met-web/tests/unit/components/comment/CommentReview.test.tsx b/met-web/tests/unit/components/comment/CommentReview.test.tsx index 9f5d97102..a70e34bff 100644 --- a/met-web/tests/unit/components/comment/CommentReview.test.tsx +++ b/met-web/tests/unit/components/comment/CommentReview.test.tsx @@ -108,8 +108,6 @@ describe('CommentReview Component', () => { is_internal: false, consent_message: '', sponsor_name: '', - cta_message: 'Provide Feedback', - cta_url: '', }, };