From 8874c5f50debdfde260d6df35bc9a9ac859c462f Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 23 Sep 2024 18:07:25 -0700 Subject: [PATCH 01/10] DESENG-670: Admin authoring header page --- .../versions/63890bdab166_merge_heads.py | 25 + .../828b4f34734a_status_block_updates.py | 174 +++++ .../met_api/constants/engagement_status.py | 12 +- met-api/src/met_api/models/engagement.py | 2 - .../met_api/models/engagement_status_block.py | 23 +- .../schemas/engagement_status_block.py | 5 + .../met_api/services/engagement_service.py | 189 ++++-- .../src/components/common/Input/FormField.tsx | 21 +- .../src/components/common/Input/TextInput.tsx | 3 + .../RichTextEditor/RichEditorStyles.css | 52 +- .../src/components/common/Typography/Body.tsx | 18 + .../components/common/Typography/Headers.tsx | 29 + .../create/authoring/AuthoringBanner.tsx | 630 ++++++++++-------- .../create/authoring/AuthoringContext.tsx | 123 +++- .../create/authoring/AuthoringDetails.tsx | 98 ++- .../create/authoring/AuthoringFeedback.tsx | 6 +- .../create/authoring/AuthoringFormLayout.tsx | 92 +++ .../create/authoring/AuthoringNavElements.tsx | 7 +- .../create/authoring/AuthoringSideNav.tsx | 9 +- .../create/authoring/AuthoringSummary.tsx | 85 +-- .../create/authoring/AuthoringTemplate.tsx | 117 +--- .../engagementAuthoringUpdateAction.tsx | 33 +- .../admin/create/authoring/types.ts | 16 +- .../EngagementFormTabs/EngagementForm.tsx | 22 - .../EngagementTabsContext.tsx | 22 +- .../src/components/engagement/form/types.ts | 4 - .../public/view/EngagementContentTabs.tsx | 3 +- .../public/view/EngagementDescription.tsx | 3 +- .../engagement/public/view/EngagementHero.tsx | 11 +- .../public/view/EngagementSurveyBlock.tsx | 3 +- .../engagement/public/view/index.tsx | 7 + .../src/components/imageUpload/Uploader.tsx | 10 +- .../layout/Header/InternalHeader.tsx | 2 +- met-web/src/constants/engagementStatus.ts | 3 +- met-web/src/models/engagement.ts | 4 - met-web/src/models/engagementStatusBlock.ts | 8 + met-web/src/routes/AuthenticatedRoutes.tsx | 66 +- .../src/services/engagementService/types.ts | 9 +- met-web/src/styles/Theme.ts | 9 + 39 files changed, 1260 insertions(+), 695 deletions(-) create mode 100644 met-api/migrations/versions/63890bdab166_merge_heads.py create mode 100644 met-api/migrations/versions/828b4f34734a_status_block_updates.py create mode 100644 met-web/src/components/engagement/admin/create/authoring/AuthoringFormLayout.tsx 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..5ed3a29c6 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( 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/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/services/engagement_service.py b/met-api/src/met_api/services/engagement_service.py index 35f35d16e..261327031 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) @@ -198,17 +212,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 +237,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 +257,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 +266,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 +300,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 +313,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 +346,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} > { + if (!error) { + return <>; + } + 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..a040d7061 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,56 @@ 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; const engagementUpdateForm = useForm({ defaultValues: useMemo(() => defaultValues, [defaultValues]), mode: 'onSubmit', reValidateMode: 'onChange', + resolver: pageName === 'banner' ? yupResolver(authoringTemplateSchema) : undefined, }); 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 +167,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 +176,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/AuthoringDetails.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx index d614388b1..e08e641f3 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx @@ -1,17 +1,29 @@ -import { Grid, IconButton, MenuItem, Select, SelectChangeEvent, Tab, Tabs } from '@mui/material'; -import { EyebrowText as FormDescriptionText } from 'components/common/Typography'; +import { + FormControlLabel, + Grid, + IconButton, + MenuItem, + Radio, + RadioGroup, + Select, + SelectChangeEvent, + Tab, + Tabs, +} from '@mui/material'; +import { BodyText, EyebrowText as FormDescriptionText, Header2 } from 'components/common/Typography'; import { colors, MetLabel, MetHeader3, MetLabel as MetBigLabel } from 'components/common'; import { Button, TextField } from 'components/common/Input'; import React, { SyntheticEvent, useState } from 'react'; import { RichTextArea } from 'components/common/Input/RichTextArea'; -import { AuthoringTemplateOutletContext, DetailsTabProps, TabValues } from './types'; -import { useOutletContext } from 'react-router-dom'; +import { DetailsTabProps, TabValues } from './types'; import { Palette } from 'styles/Theme'; import { Unless, When } from 'react-if'; import { TabContext, TabPanel } from '@mui/lab'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faX } from '@fortawesome/pro-regular-svg-icons'; import { EditorState } from 'draft-js'; +import { createPortal } from 'react-dom'; +import { getEditorStateFromRaw } from 'components/common/RichTextEditor/utils'; const handleDuplicateTabNames = (newTabs: TabValues[], newTabName: string) => { // Will add a sequencial number suffix for up to 10 numbers if there is a duplicate, then add (copy) if none of those are available. @@ -24,17 +36,16 @@ const handleDuplicateTabNames = (newTabs: TabValues[], newTabName: string) => { }; const AuthoringDetails = () => { - const { - setValue, - contentTabsEnabled, - tabs, - setTabs, - setSingleContentValues, - setContentTabsEnabled, - singleContentValues, - defaultTabValues, - }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template + 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 [currentTab, setCurrentTab] = useState(tabs[0]); + const [singleContentValues, setSingleContentValues] = useState({ ...defaultTabValues, heading: '' }); const tabsStyles = { borderBottom: `2px solid ${colors.surface.gray[60]}`, @@ -105,8 +116,64 @@ const AuthoringDetails = () => { setCurrentTab(newTabs[newTabs.length - 1]); }; + // 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 ( + {createPortal( + + + 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)" + /> + + + , + document.getElementById('pre-authoring-content') as HTMLElement, + )} { return ( { { export default AuthoringDetails; const DetailsTab = ({ - setValue, setTabs, setCurrentTab, setSingleContentValues, 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..f61093cdc 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,7 @@ import { Palette } from 'styles/Theme'; const AuthoringFeedback = () => { const [sectionHeading, setSectionHeading] = useState(''); - const [bodyText, setBodyText] = useState(''); + // const [bodyText, setBodyText] = useState(''); const [editorState, setEditorState] = useState(); const [surveyButtonText, setSurveyButtonText] = useState(''); const [thirdPartyCtaText, setThirdPartyCtaText] = useState(''); @@ -77,9 +77,9 @@ const AuthoringFeedback = () => { }; const handleEditorChange = (newEditorState: EditorState) => { - const plainText = newEditorState.getCurrentContent().getPlainText(); setEditorState(newEditorState); - setBodyText(plainText); + // const plainText = newEditorState.getCurrentContent().getPlainText(); + // 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..2407013a7 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,25 +54,17 @@ export interface AuthoringTemplateOutletContext { setValue: UseFormSetValue; getValues: UseFormGetValues; watch: UseFormWatch; - control: Control; + control: Control; engagement: Engagement; - contentTabsEnabled: string; - tabs: TabValues[]; - setTabs: Dispatch>; - singleContentValues: TabValues; - setSingleContentValues: Dispatch>; - setContentTabsEnabled: Dispatch>; - defaultTabValues: TabValues; isDirty: boolean; reset: UseFormReset; defaultValues: EngagementUpdateData; setDefaultValues: Dispatch>; - fetcher: FetcherWithComponents; - slug: string; + fetcher: FetcherWithComponents; + pageName: string; } export interface DetailsTabProps { - setValue: UseFormSetValue; setTabs: Dispatch>; setCurrentTab: Dispatch>; setSingleContentValues: Dispatch>; 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..9653fc65b 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 { @@ -151,6 +149,7 @@ export const EngagementTabsContext = createContext({ Upcoming: '', Open: '', Closed: '', + ViewResults: '', }, setSurveyBlockText: () => { throw new Error('setSurveyBlockText not implemented'); @@ -212,8 +211,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 +231,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 +254,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 +406,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': { From 6eb268f27ba3830fc206dff3c203d80f4f50dd74 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Mon, 23 Sep 2024 18:16:04 -0700 Subject: [PATCH 02/10] Update Changelog --- CHANGELOG.MD | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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) From 3130b297e6f2140246e2999f5d70081691a7d097 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 13:01:53 -0700 Subject: [PATCH 03/10] Clean up residual references to CTA in engagement models --- ...3bf5bda6_remove_cta_message_translation.py | 28 +++++++++++++++++++ met-api/src/met_api/models/engagement.py | 1 - .../met_api/models/engagement_translation.py | 2 -- met-api/src/met_api/schemas/engagement.py | 2 -- .../met_api/schemas/engagement_translation.py | 1 - .../met_api/services/engagement_service.py | 2 -- .../engagement_translation_service.py | 1 - met-api/tests/unit/api/test_engagement.py | 3 ++ .../EngagementTabsContext.tsx | 2 -- .../components/comment/CommentReview.test.tsx | 2 -- 10 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 met-api/migrations/versions/58923bf5bda6_remove_cta_message_translation.py 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/src/met_api/models/engagement.py b/met-api/src/met_api/models/engagement.py index 5ed3a29c6..9c404b7a7 100644 --- a/met-api/src/met_api/models/engagement.py +++ b/met-api/src/met_api/models/engagement.py @@ -147,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_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_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 261327031..8f883e93b 100644 --- a/met-api/src/met_api/services/engagement_service.py +++ b/met-api/src/met_api/services/engagement_service.py @@ -203,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 diff --git a/met-api/src/met_api/services/engagement_translation_service.py b/met-api/src/met_api/services/engagement_translation_service.py index 32849c796..0aac9c414 100644 --- a/met-api/src/met_api/services/engagement_translation_service.py +++ b/met-api/src/met_api/services/engagement_translation_service.py @@ -141,7 +141,6 @@ def _get_default_language_values(engagement, content, translation_data): translation_data['rich_content'] = content.json_content translation_data['consent_message'] = engagement.consent_message translation_data['sponsor_name'] = engagement.sponsor_name - translation_data['cta_message'] = engagement.cta_message engagement_slug = EngagementSlugModel.find_by_engagement_id(engagement_id) if engagement_slug: diff --git a/met-api/tests/unit/api/test_engagement.py b/met-api/tests/unit/api/test_engagement.py index ecf59da65..6d9416fdc 100644 --- a/met-api/tests/unit/api/test_engagement.py +++ b/met-api/tests/unit/api/test_engagement.py @@ -498,6 +498,7 @@ def test_patch_new_survey_block_engagement(client, jwt, session, 'status_block': [{ 'block_text': '{"foo":"bar"}', 'survey_status': SubmissionStatus.Upcoming.name, + 'link_type': 'none', }]} rv = client.patch('/api/engagements/', data=json.dumps(engagement_edits), headers=headers, content_type=ContentType.JSON.value) @@ -529,9 +530,11 @@ def test_update_survey_block_engagement(client, jwt, session, 'status_block': [{ 'block_text': block_text_for_upcoming, 'survey_status': SubmissionStatus.Closed.name, + 'link_type': 'none', }, { 'block_text': '{"foo2":"bar2"}', 'survey_status': SubmissionStatus.Open.name, + 'link_type': 'none', }]} rv = client.patch('/api/engagements/', data=json.dumps(engagement_edits), headers=headers, content_type=ContentType.JSON.value) diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx index 9653fc65b..208e65698 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementTabsContext.tsx @@ -49,8 +49,6 @@ const initialEngagementFormData = { is_internal: false, consent_message: '', sponsor_name: '', - cta_message: '', - cta_url: '', }; const initialEngagementSettingsFormData = { 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: '', }, }; From f1bdf439c838810c890ed031ecbabb285a9bc076 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 14:29:09 -0700 Subject: [PATCH 04/10] Undo changes to AuthoringFeedback.tsx --- .../create/authoring/AuthoringFeedback.tsx | 403 +++++++++++++----- .../admin/create/authoring/types.ts | 7 + 2 files changed, 302 insertions(+), 108 deletions(-) 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 f61093cdc..488d60441 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx @@ -1,21 +1,239 @@ -import { Grid, MenuItem, Select, SelectChangeEvent } from '@mui/material'; +// import React from 'react'; +// export const AuthoringFeedback = () => { +// return
AuthoringFeedback
; +// }; +// export default AuthoringFeedback; + +import { Grid, IconButton, MenuItem, Select, SelectChangeEvent, Tab, Tabs } from '@mui/material'; import { EyebrowText as FormDescriptionText } from 'components/common/Typography'; import { colors, MetLabel, MetHeader3, MetLabel as MetBigLabel } from 'components/common'; import { Button, TextField } from 'components/common/Input'; +import React, { SyntheticEvent, useState } from 'react'; import { RichTextArea } from 'components/common/Input/RichTextArea'; -import { EditorState } from 'draft-js'; -import React, { useState } from 'react'; +import { AuthoringTemplateOutletContext, DetailsTabProps, TabValues } from './types'; +import { useOutletContext } from 'react-router-dom'; import { Palette } from 'styles/Theme'; +import { Unless, When } from 'react-if'; +import { TabContext, TabPanel } from '@mui/lab'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faX } from '@fortawesome/pro-regular-svg-icons'; +import { EditorState } from 'draft-js'; + +const handleDuplicateTabNames = (newTabs: TabValues[], newTabName: string) => { + // Will add a sequencial number suffix for up to 10 numbers if there is a duplicate, then add (copy) if none of those are available. + for (let i = 2; i < 12; i++) { + if (!newTabs.find((tab) => tab.heading === `${newTabName} (${i})`)) { + return `${newTabName} (${i})`; + } + } + return `${newTabName} (copy)`; +}; + +const AuthoringDetails = () => { + const { + setValue, + contentTabsEnabled, + tabs, + setTabs, + setSingleContentValues, + setContentTabsEnabled, + singleContentValues, + defaultTabValues, + }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template + const [currentTab, setCurrentTab] = useState(tabs[0]); + + const tabsStyles = { + borderBottom: `2px solid ${colors.surface.gray[60]}`, + overflow: 'hidden', + '& .MuiTabs-flexContainer': { + justifyContent: 'flex-start', + width: 'max-content', + }, + }; + const tabStyles = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '48px', + padding: '4px 18px 2px 18px', + fontSize: '14px', + borderRadius: '0px 16px 0px 0px', + border: `1px solid ${colors.surface.gray[60]}`, + borderBottom: 'none', + boxShadow: + '0px 2px 5px 0px rgba(0, 0, 0, 0.12), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.20)', + backgroundColor: 'gray.10', + color: 'text.secondary', + fontWeight: 'normal', + '&.Mui-selected': { + backgroundColor: 'primary.main', + borderColor: 'primary.main', + color: 'white', + fontWeight: 'bold', + }, + outlineOffset: '-4px', + '&:focus-visible': { + outline: `2px solid`, + outlineColor: '#12508F', + border: '4px solid', + borderColor: '#12508F', + padding: '0px 20px 0px 14px', + }, + }; + + const handleCloseTab = (e: React.MouseEvent, tab: TabValues) => { + e.stopPropagation(); + const index = tabs.findIndex((t) => t.heading === tab.heading); + if (-1 < index) { + const newTabs = [...tabs]; + newTabs.splice(index, 1); + if (1 === newTabs.length) { + // If we're switching back to single content mode + 'Tab 1' !== tabs[0].heading + ? setSingleContentValues(tabs[0]) + : setSingleContentValues({ ...tabs[0], heading: '' }); // If the current Section Heading is "Tab 1" then change it to a blank value. + setTabs([tabs[0]]); + setContentTabsEnabled('false'); + } else { + setTabs(newTabs); + tab === currentTab && setCurrentTab(newTabs[index - 1]); // Switch tabs if you're closing the current one + } + } + }; -const AuthoringFeedback = () => { - const [sectionHeading, setSectionHeading] = useState(''); - // const [bodyText, setBodyText] = useState(''); - const [editorState, setEditorState] = useState(); - const [surveyButtonText, setSurveyButtonText] = useState(''); - const [thirdPartyCtaText, setThirdPartyCtaText] = useState(''); - const [thirdPartyCtaLink, setThirdPartyCtaLink] = useState(''); - const [currentWidget, setCurrentWidget] = useState(''); + const handleAddTab = () => { + const newTabs = [...tabs]; + const newTabName = 'Tab ' + (newTabs.length + 1); + newTabs.find((tab) => newTabName === tab.heading) + ? newTabs.push({ ...defaultTabValues, heading: handleDuplicateTabNames(newTabs, newTabName) }) + : newTabs.push({ ...defaultTabValues, heading: newTabName }); // Don't create duplicate entries + setTabs(newTabs); + setCurrentTab(newTabs[newTabs.length - 1]); + }; + return ( + + + + tab.heading === currentTab.heading) + ? tabs[tabs.findIndex((t) => t.heading === currentTab.heading)].heading + : tabs[tabs.length - 1].heading + } + > + tab.heading === currentTab.heading) + ? tabs[tabs.findIndex((t) => t.heading === currentTab.heading)].heading + : tabs[tabs.length - 1].heading + } + onChange={(event: SyntheticEvent, value: string) => + tabs.find((tab) => tab.heading === value) && + setCurrentTab(tabs[tabs.findIndex((tab) => tab.heading === value)]) + } + > + {tabs.map((tab, key) => { + return ( + + {tab.heading} + + handleCloseTab(e, tab)} + > + + + + + } + key={key} + value={tab.heading} + disableFocusRipple + /> + ); + })} + + + {tabs.map((tab, key) => { + return ( + + + + ); + })} + + + + + + + + ); +}; + +export default AuthoringDetails; + +const DetailsTab = ({ + // setValue, + setTabs, + setCurrentTab, + setSingleContentValues, + tabs, + tabIndex, + singleContentValues, + defaultTabValues, +}: DetailsTabProps) => { // Define the styles const metBigLabelStyles = { fontSize: '1.05rem', @@ -40,13 +258,15 @@ const AuthoringFeedback = () => { const metLabelStyles = { fontSize: '0.95rem', }; - const buttonStyles = { - height: '2.6rem', + const conditionalSelectStyles = { + width: '100%', + backgroundColor: colors.surface.white, borderRadius: '8px', - border: 'none', - padding: '0 1rem', - minWidth: '8.125rem', - fontSize: '0.9rem', + boxShadow: '0 0 0 1px #7A7876 inset', + lineHeight: '1.4375em', + height: '48px', + marginTop: '8px', + padding: '0', }; const widgetPreviewStyles = { margin: '2rem 4rem 4rem', @@ -57,16 +277,15 @@ const AuthoringFeedback = () => { justifyContent: 'center', borderRadius: '16px', }; - const conditionalSelectStyles = { - width: '100%', - backgroundColor: colors.surface.white, + const buttonStyles = { + height: '2.6rem', borderRadius: '8px', - boxShadow: '0 0 0 1px #7A7876 inset', - lineHeight: '1.4375em', - height: '48px', - marginTop: '8px', - padding: '0', + border: 'none', + padding: '0 1rem', + minWidth: '8.125rem', + fontSize: '0.9rem', }; + const toolbar = { options: ['inline', 'list', 'link', 'blockType', 'history'], inline: { @@ -76,31 +295,57 @@ const AuthoringFeedback = () => { list: { options: ['unordered', 'ordered'] }, }; - const handleEditorChange = (newEditorState: EditorState) => { - setEditorState(newEditorState); - // const plainText = newEditorState.getCurrentContent().getPlainText(); - // setBodyText(plainText); + const handleSectionHeadingChange = (value: string) => { + const newHeading = value; + if (2 > tabs.length && 0 === tabIndex) { + // If there are no tabs + setSingleContentValues({ ...singleContentValues, heading: newHeading }); + setTabs([{ ...defaultTabValues, heading: newHeading }]); + } else { + // If there are tabs + const newTabs = [...tabs]; + newTabs[tabIndex].heading = newTabs.find((tab) => tab.heading === newHeading) + ? handleDuplicateTabNames(newTabs, newHeading) + : newHeading; // If the new name is the same as an existing one, rename it + setSingleContentValues(newTabs[0]); + setTabs([...newTabs]); + setCurrentTab(newTabs[tabIndex]); + } }; const handleWidgetChange = (event: SelectChangeEvent) => { - setCurrentWidget(event.target.value); + const newWidget = event.target.value; + const newTabs = [...tabs]; + newTabs[tabIndex].widget = newWidget; + setTabs(newTabs); }; const handleRemoveWidget = () => { - if ('' === currentWidget) { + if ('' === tabs[tabIndex].widget) { return; } else { - setCurrentWidget(''); + const newTabs = [...tabs]; + newTabs[tabIndex].widget = ''; + setTabs(newTabs); } }; + const handleBodyTextChange = (newEditorState: EditorState) => { + const plainText = newEditorState.getCurrentContent().getPlainText(); + const newTabs = [...tabs]; + newTabs[tabIndex].bodyCopyEditorState = newEditorState; + newTabs[tabIndex].bodyCopyPlainText = plainText; + setTabs(newTabs); + }; + return ( Primary Content (Required) - This section of content should provide a brief overview of what your engagement is about and what - you would like your audience to do. + Primary content will display on the left two thirds of the page on large screens and full width on + small screens. (If you add optional supporting content in the section below, on small screens, your + primary content will display first (on top) followed by your supporting content (underneath). @@ -114,36 +359,39 @@ const AuthoringFeedback = () => { { - setSectionHeading(value); - }} + onChange={handleSectionHeadingChange} /> - - - - - - - - - - Supporting Content (Optional) @@ -228,7 +417,7 @@ const AuthoringFeedback = () => { sx={{ ...conditionalSelectStyles, maxWidth: '300px' }} id="widget_select" onChange={handleWidgetChange} - value={currentWidget} + value={tabs[tabIndex].widget} > Video Other @@ -246,7 +435,7 @@ const AuthoringFeedback = () => { item > - {currentWidget} {currentWidget && 'Widget'} + {tabs[tabIndex].widget} {tabs[tabIndex].widget && 'Widget'} {/* todo: show a preview of the widget here */} @@ -284,5 +473,3 @@ const AuthoringFeedback = () => { ); }; - -export default AuthoringFeedback; 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 2407013a7..35d100254 100644 --- a/met-web/src/components/engagement/admin/create/authoring/types.ts +++ b/met-web/src/components/engagement/admin/create/authoring/types.ts @@ -62,6 +62,13 @@ export interface AuthoringTemplateOutletContext { setDefaultValues: Dispatch>; fetcher: FetcherWithComponents; pageName: string; + contentTabsEnabled: string; + tabs: TabValues[]; + setTabs: Dispatch>; + setSingleContentValues: Dispatch>; + setContentTabsEnabled: Dispatch>; + singleContentValues: TabValues; + defaultTabValues: TabValues; } export interface DetailsTabProps { From 05429dc52820da971fe88cbcaeae8af84eb217b0 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 14:35:44 -0700 Subject: [PATCH 05/10] Reset accidental changes to authoringFeedback.tsx --- .../create/authoring/AuthoringDetails.tsx | 98 +---- .../create/authoring/AuthoringFeedback.tsx | 403 +++++------------- 2 files changed, 125 insertions(+), 376 deletions(-) diff --git a/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx index e08e641f3..d614388b1 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringDetails.tsx @@ -1,29 +1,17 @@ -import { - FormControlLabel, - Grid, - IconButton, - MenuItem, - Radio, - RadioGroup, - Select, - SelectChangeEvent, - Tab, - Tabs, -} from '@mui/material'; -import { BodyText, EyebrowText as FormDescriptionText, Header2 } from 'components/common/Typography'; +import { Grid, IconButton, MenuItem, Select, SelectChangeEvent, Tab, Tabs } from '@mui/material'; +import { EyebrowText as FormDescriptionText } from 'components/common/Typography'; import { colors, MetLabel, MetHeader3, MetLabel as MetBigLabel } from 'components/common'; import { Button, TextField } from 'components/common/Input'; import React, { SyntheticEvent, useState } from 'react'; import { RichTextArea } from 'components/common/Input/RichTextArea'; -import { DetailsTabProps, TabValues } from './types'; +import { AuthoringTemplateOutletContext, DetailsTabProps, TabValues } from './types'; +import { useOutletContext } from 'react-router-dom'; import { Palette } from 'styles/Theme'; import { Unless, When } from 'react-if'; import { TabContext, TabPanel } from '@mui/lab'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faX } from '@fortawesome/pro-regular-svg-icons'; import { EditorState } from 'draft-js'; -import { createPortal } from 'react-dom'; -import { getEditorStateFromRaw } from 'components/common/RichTextEditor/utils'; const handleDuplicateTabNames = (newTabs: TabValues[], newTabName: string) => { // Will add a sequencial number suffix for up to 10 numbers if there is a duplicate, then add (copy) if none of those are available. @@ -36,16 +24,17 @@ const handleDuplicateTabNames = (newTabs: TabValues[], newTabName: string) => { }; const AuthoringDetails = () => { - 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 { + setValue, + contentTabsEnabled, + tabs, + setTabs, + setSingleContentValues, + setContentTabsEnabled, + singleContentValues, + defaultTabValues, + }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template const [currentTab, setCurrentTab] = useState(tabs[0]); - const [singleContentValues, setSingleContentValues] = useState({ ...defaultTabValues, heading: '' }); const tabsStyles = { borderBottom: `2px solid ${colors.surface.gray[60]}`, @@ -116,64 +105,8 @@ const AuthoringDetails = () => { setCurrentTab(newTabs[newTabs.length - 1]); }; - // 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 ( - {createPortal( - - - 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)" - /> - - - , - document.getElementById('pre-authoring-content') as HTMLElement, - )} { return ( { { export default AuthoringDetails; const DetailsTab = ({ + setValue, setTabs, setCurrentTab, setSingleContentValues, 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 488d60441..f61093cdc 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringFeedback.tsx @@ -1,239 +1,21 @@ -// import React from 'react'; -// export const AuthoringFeedback = () => { -// return
AuthoringFeedback
; -// }; -// export default AuthoringFeedback; - -import { Grid, IconButton, MenuItem, Select, SelectChangeEvent, Tab, Tabs } from '@mui/material'; +import { Grid, MenuItem, Select, SelectChangeEvent } from '@mui/material'; import { EyebrowText as FormDescriptionText } from 'components/common/Typography'; import { colors, MetLabel, MetHeader3, MetLabel as MetBigLabel } from 'components/common'; import { Button, TextField } from 'components/common/Input'; -import React, { SyntheticEvent, useState } from 'react'; import { RichTextArea } from 'components/common/Input/RichTextArea'; -import { AuthoringTemplateOutletContext, DetailsTabProps, TabValues } from './types'; -import { useOutletContext } from 'react-router-dom'; -import { Palette } from 'styles/Theme'; -import { Unless, When } from 'react-if'; -import { TabContext, TabPanel } from '@mui/lab'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faX } from '@fortawesome/pro-regular-svg-icons'; import { EditorState } from 'draft-js'; +import React, { useState } from 'react'; +import { Palette } from 'styles/Theme'; -const handleDuplicateTabNames = (newTabs: TabValues[], newTabName: string) => { - // Will add a sequencial number suffix for up to 10 numbers if there is a duplicate, then add (copy) if none of those are available. - for (let i = 2; i < 12; i++) { - if (!newTabs.find((tab) => tab.heading === `${newTabName} (${i})`)) { - return `${newTabName} (${i})`; - } - } - return `${newTabName} (copy)`; -}; - -const AuthoringDetails = () => { - const { - setValue, - contentTabsEnabled, - tabs, - setTabs, - setSingleContentValues, - setContentTabsEnabled, - singleContentValues, - defaultTabValues, - }: AuthoringTemplateOutletContext = useOutletContext(); // Access the form functions and values from the authoring template - const [currentTab, setCurrentTab] = useState(tabs[0]); - - const tabsStyles = { - borderBottom: `2px solid ${colors.surface.gray[60]}`, - overflow: 'hidden', - '& .MuiTabs-flexContainer': { - justifyContent: 'flex-start', - width: 'max-content', - }, - }; - const tabStyles = { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: '48px', - padding: '4px 18px 2px 18px', - fontSize: '14px', - borderRadius: '0px 16px 0px 0px', - border: `1px solid ${colors.surface.gray[60]}`, - borderBottom: 'none', - boxShadow: - '0px 2px 5px 0px rgba(0, 0, 0, 0.12), 0px 2px 2px 0px rgba(0, 0, 0, 0.14), 0px 3px 1px -2px rgba(0, 0, 0, 0.20)', - backgroundColor: 'gray.10', - color: 'text.secondary', - fontWeight: 'normal', - '&.Mui-selected': { - backgroundColor: 'primary.main', - borderColor: 'primary.main', - color: 'white', - fontWeight: 'bold', - }, - outlineOffset: '-4px', - '&:focus-visible': { - outline: `2px solid`, - outlineColor: '#12508F', - border: '4px solid', - borderColor: '#12508F', - padding: '0px 20px 0px 14px', - }, - }; - - const handleCloseTab = (e: React.MouseEvent, tab: TabValues) => { - e.stopPropagation(); - const index = tabs.findIndex((t) => t.heading === tab.heading); - if (-1 < index) { - const newTabs = [...tabs]; - newTabs.splice(index, 1); - if (1 === newTabs.length) { - // If we're switching back to single content mode - 'Tab 1' !== tabs[0].heading - ? setSingleContentValues(tabs[0]) - : setSingleContentValues({ ...tabs[0], heading: '' }); // If the current Section Heading is "Tab 1" then change it to a blank value. - setTabs([tabs[0]]); - setContentTabsEnabled('false'); - } else { - setTabs(newTabs); - tab === currentTab && setCurrentTab(newTabs[index - 1]); // Switch tabs if you're closing the current one - } - } - }; - - const handleAddTab = () => { - const newTabs = [...tabs]; - const newTabName = 'Tab ' + (newTabs.length + 1); - newTabs.find((tab) => newTabName === tab.heading) - ? newTabs.push({ ...defaultTabValues, heading: handleDuplicateTabNames(newTabs, newTabName) }) - : newTabs.push({ ...defaultTabValues, heading: newTabName }); // Don't create duplicate entries - setTabs(newTabs); - setCurrentTab(newTabs[newTabs.length - 1]); - }; - - return ( - - - - tab.heading === currentTab.heading) - ? tabs[tabs.findIndex((t) => t.heading === currentTab.heading)].heading - : tabs[tabs.length - 1].heading - } - > - tab.heading === currentTab.heading) - ? tabs[tabs.findIndex((t) => t.heading === currentTab.heading)].heading - : tabs[tabs.length - 1].heading - } - onChange={(event: SyntheticEvent, value: string) => - tabs.find((tab) => tab.heading === value) && - setCurrentTab(tabs[tabs.findIndex((tab) => tab.heading === value)]) - } - > - {tabs.map((tab, key) => { - return ( - - {tab.heading} - - handleCloseTab(e, tab)} - > - - - - - } - key={key} - value={tab.heading} - disableFocusRipple - /> - ); - })} - - - {tabs.map((tab, key) => { - return ( - - - - ); - })} - - - - - - - - ); -}; - -export default AuthoringDetails; +const AuthoringFeedback = () => { + const [sectionHeading, setSectionHeading] = useState(''); + // const [bodyText, setBodyText] = useState(''); + const [editorState, setEditorState] = useState(); + const [surveyButtonText, setSurveyButtonText] = useState(''); + const [thirdPartyCtaText, setThirdPartyCtaText] = useState(''); + const [thirdPartyCtaLink, setThirdPartyCtaLink] = useState(''); + const [currentWidget, setCurrentWidget] = useState(''); -const DetailsTab = ({ - // setValue, - setTabs, - setCurrentTab, - setSingleContentValues, - tabs, - tabIndex, - singleContentValues, - defaultTabValues, -}: DetailsTabProps) => { // Define the styles const metBigLabelStyles = { fontSize: '1.05rem', @@ -258,15 +40,13 @@ const DetailsTab = ({ const metLabelStyles = { fontSize: '0.95rem', }; - const conditionalSelectStyles = { - width: '100%', - backgroundColor: colors.surface.white, + const buttonStyles = { + height: '2.6rem', borderRadius: '8px', - boxShadow: '0 0 0 1px #7A7876 inset', - lineHeight: '1.4375em', - height: '48px', - marginTop: '8px', - padding: '0', + border: 'none', + padding: '0 1rem', + minWidth: '8.125rem', + fontSize: '0.9rem', }; const widgetPreviewStyles = { margin: '2rem 4rem 4rem', @@ -277,15 +57,16 @@ const DetailsTab = ({ justifyContent: 'center', borderRadius: '16px', }; - const buttonStyles = { - height: '2.6rem', + const conditionalSelectStyles = { + width: '100%', + backgroundColor: colors.surface.white, borderRadius: '8px', - border: 'none', - padding: '0 1rem', - minWidth: '8.125rem', - fontSize: '0.9rem', + boxShadow: '0 0 0 1px #7A7876 inset', + lineHeight: '1.4375em', + height: '48px', + marginTop: '8px', + padding: '0', }; - const toolbar = { options: ['inline', 'list', 'link', 'blockType', 'history'], inline: { @@ -295,57 +76,31 @@ const DetailsTab = ({ list: { options: ['unordered', 'ordered'] }, }; - const handleSectionHeadingChange = (value: string) => { - const newHeading = value; - if (2 > tabs.length && 0 === tabIndex) { - // If there are no tabs - setSingleContentValues({ ...singleContentValues, heading: newHeading }); - setTabs([{ ...defaultTabValues, heading: newHeading }]); - } else { - // If there are tabs - const newTabs = [...tabs]; - newTabs[tabIndex].heading = newTabs.find((tab) => tab.heading === newHeading) - ? handleDuplicateTabNames(newTabs, newHeading) - : newHeading; // If the new name is the same as an existing one, rename it - setSingleContentValues(newTabs[0]); - setTabs([...newTabs]); - setCurrentTab(newTabs[tabIndex]); - } + const handleEditorChange = (newEditorState: EditorState) => { + setEditorState(newEditorState); + // const plainText = newEditorState.getCurrentContent().getPlainText(); + // setBodyText(plainText); }; const handleWidgetChange = (event: SelectChangeEvent) => { - const newWidget = event.target.value; - const newTabs = [...tabs]; - newTabs[tabIndex].widget = newWidget; - setTabs(newTabs); + setCurrentWidget(event.target.value); }; const handleRemoveWidget = () => { - if ('' === tabs[tabIndex].widget) { + if ('' === currentWidget) { return; } else { - const newTabs = [...tabs]; - newTabs[tabIndex].widget = ''; - setTabs(newTabs); + setCurrentWidget(''); } }; - const handleBodyTextChange = (newEditorState: EditorState) => { - const plainText = newEditorState.getCurrentContent().getPlainText(); - const newTabs = [...tabs]; - newTabs[tabIndex].bodyCopyEditorState = newEditorState; - newTabs[tabIndex].bodyCopyPlainText = plainText; - setTabs(newTabs); - }; - return ( Primary Content (Required) - Primary content will display on the left two thirds of the page on large screens and full width on - small screens. (If you add optional supporting content in the section below, on small screens, your - primary content will display first (on top) followed by your supporting content (underneath). + This section of content should provide a brief overview of what your engagement is about and what + you would like your audience to do. @@ -359,39 +114,36 @@ const DetailsTab = ({ { + setSectionHeading(value); + }} /> - + + + + + + + + + Supporting Content (Optional) @@ -417,7 +228,7 @@ const DetailsTab = ({ sx={{ ...conditionalSelectStyles, maxWidth: '300px' }} id="widget_select" onChange={handleWidgetChange} - value={tabs[tabIndex].widget} + value={currentWidget} > Video Other @@ -435,7 +246,7 @@ const DetailsTab = ({ item > - {tabs[tabIndex].widget} {tabs[tabIndex].widget && 'Widget'} + {currentWidget} {currentWidget && 'Widget'} {/* todo: show a preview of the widget here */} @@ -473,3 +284,5 @@ const DetailsTab = ({ ); }; + +export default AuthoringFeedback; From c4c6cb4f95938f912b47b8270667731f3c2c25e4 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 15:09:49 -0700 Subject: [PATCH 06/10] Add setValue to DetailsTabsProps --- .../src/components/engagement/admin/create/authoring/types.ts | 1 + 1 file changed, 1 insertion(+) 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 35d100254..7a47b603f 100644 --- a/met-web/src/components/engagement/admin/create/authoring/types.ts +++ b/met-web/src/components/engagement/admin/create/authoring/types.ts @@ -72,6 +72,7 @@ export interface AuthoringTemplateOutletContext { } export interface DetailsTabProps { + setValue: UseFormSetValue; setTabs: Dispatch>; setCurrentTab: Dispatch>; setSingleContentValues: Dispatch>; From 94f5a3127f3a81c2db66a58bfc43a3236a5d2211 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 15:42:04 -0700 Subject: [PATCH 07/10] Address PR comments --- CONTRIBUTING.md | 3 +++ met-web/src/components/common/Typography/Body.tsx | 4 +--- .../admin/create/authoring/AuthoringContext.tsx | 11 ++++++++++- .../admin/create/authoring/AuthoringFeedback.tsx | 3 --- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d475f89cb..b99b62211 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,3 +96,6 @@ 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`: A styled error message that can be displayed to the user. 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`. diff --git a/met-web/src/components/common/Typography/Body.tsx b/met-web/src/components/common/Typography/Body.tsx index 248299772..65adf36b4 100644 --- a/met-web/src/components/common/Typography/Body.tsx +++ b/met-web/src/components/common/Typography/Body.tsx @@ -51,9 +51,7 @@ export const BodyText = ({ }; export const ErrorMessage = ({ error }: { error?: string }) => { - if (!error) { - return <>; - } + if (!error) return null; return ( { } }, [fetcher.data]); const pageName = useMatch('/engagements/:engagementId/details/authoring/:page')?.params.page; + // Set the form resolver based on the page name + const resolver = useMemo(() => { + switch (pageName) { + case 'banner': + return yupResolver(authoringTemplateSchema); + default: + return undefined; + } + }, [pageName]); const engagementUpdateForm = useForm({ defaultValues: useMemo(() => defaultValues, [defaultValues]), mode: 'onSubmit', reValidateMode: 'onChange', - resolver: pageName === 'banner' ? yupResolver(authoringTemplateSchema) : undefined, + resolver: resolver, }); const onSubmit = async (data: EngagementUpdateData) => { const savedImageDetails = data.image_file 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 f61093cdc..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(''); @@ -78,8 +77,6 @@ const AuthoringFeedback = () => { const handleEditorChange = (newEditorState: EditorState) => { setEditorState(newEditorState); - // const plainText = newEditorState.getCurrentContent().getPlainText(); - // setBodyText(plainText); }; const handleWidgetChange = (event: SelectChangeEvent) => { From 528d9d0d5f06b9d5d843426e8319b9710a2f4151 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 16:24:48 -0700 Subject: [PATCH 08/10] Update developer documentation --- CONTRIBUTING.md | 4 +++- .../admin/create/authoring/AuthoringContext.tsx | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b99b62211..f294d08fb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +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`: A styled error message that can be displayed to the user. Accepts a `message` prop that is the error message to display. + - `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-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx b/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx index 260f9d11b..b2950ac33 100644 --- a/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx +++ b/met-web/src/components/engagement/admin/create/authoring/AuthoringContext.tsx @@ -142,10 +142,16 @@ export const AuthoringContext = () => { } }, [fetcher.data]); const pageName = useMatch('/engagements/:engagementId/details/authoring/:page')?.params.page; - // Set the form resolver based on the page name + /* 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; From 019c734927db0e813dfbee25b53f4f6d9c650e07 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 16:29:35 -0700 Subject: [PATCH 09/10] Reduce visual flicker by memoizing + lazy loading breadcrumbs --- .../common/Navigation/Breadcrumb.tsx | 79 +++++++++---------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/met-web/src/components/common/Navigation/Breadcrumb.tsx b/met-web/src/components/common/Navigation/Breadcrumb.tsx index d2b68ca38..35a060da9 100644 --- a/met-web/src/components/common/Navigation/Breadcrumb.tsx +++ b/met-web/src/components/common/Navigation/Breadcrumb.tsx @@ -1,8 +1,8 @@ import { Breadcrumbs } from '@mui/material'; -import React, { Suspense } from 'react'; +import React, { useMemo } from 'react'; import { BodyText } from '../Typography'; import { Link } from '../Navigation'; -import { Await, UIMatch, useMatches } from 'react-router-dom'; +import { UIMatch, useLocation, useMatches } from 'react-router-dom'; type BreadcrumbProps = { name: string; @@ -49,48 +49,47 @@ interface UIMatchWithCrumb * @returns A list of breadcrumbs. */ export const AutoBreadcrumbs: React.FC<{ smallScreenOnly?: boolean }> = ({ 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} + ); })} From 7a2c21db486fc0eeced45c8285e6435caf2da8d2 Mon Sep 17 00:00:00 2001 From: NatSquared Date: Tue, 24 Sep 2024 16:34:22 -0700 Subject: [PATCH 10/10] Update header documentation --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f294d08fb..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.