From 0959d827dacb06b68335ab82c226e9c06e90a9b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nat=C2=B2?= Date: Fri, 24 May 2024 09:35:27 -0700 Subject: [PATCH] [To Main] Tenant CRUD implementation (#2524) * [To Feature Branch] - DESENG-605 - Tenant backend api (#2508) * DESENG-605: Tenant API and Unit test * DESENG-605 - Add Tenant - Frontend UI (#2510) * DESENG-605 - Tenant detail page (#2511) * DESENG-605: Tenant API and Unit test * DESENG-605: Tenant detail page * DESENG-606: Add tenant edit page (#2513) * DESENG-605: Delete tenant flow * DESENG-605 - Delete tenant functionality (#2515) * DESENG-605 - Adding Unit test (#2518) * DESENG-605: Tenant unit test for listing and detail * Multi tenancy - UX Pass 1 (#2519) * DESENG-605-606-unit-tests (#2522) * Frontend unit tests * Update changelog --------- Co-authored-by: Ratheesh kumar R --- CHANGELOG.MD | 20 +- .../2c2ce3421cd6_tenant_table_update.py | 44 ++ met-api/migrations/versions/ae232e299180_.py | 28 ++ met-api/src/met_api/models/tenant.py | 43 +- met-api/src/met_api/resources/tenant.py | 65 ++- .../src/met_api/schemas/schemas/tenant.json | 95 +++++ .../schemas/schemas/tenant_update.json | 94 +++++ met-api/src/met_api/schemas/tenant.py | 5 + .../src/met_api/services/tenant_service.py | 60 ++- met-api/tests/unit/api/test_tenant.py | 70 ++++ met-api/tests/unit/models/test_tenant.py | 90 ++++ .../unit/services/test_tenant_service.py | 127 ++++++ met-api/tests/utilities/factory_scenarios.py | 4 + met-api/tests/utilities/factory_utils.py | 2 + met-web/jest.config.ts | 2 + met-web/src/apiManager/endpoints/index.ts | 5 +- .../src/components/MetDesignSystem/index.tsx | 0 .../src/components/common/Input/Button.tsx | 208 ++++++++-- .../src/components/common/Input/FormField.tsx | 65 +++ .../src/components/common/Input/TextInput.tsx | 183 +++++++++ met-web/src/components/common/Input/index.tsx | 2 + .../src/components/common/Layout/Details.tsx | 51 +++ .../src/components/common/Layout/index.tsx | 1 + .../components/common/Modals/ConfirmModal.tsx | 122 +++--- .../components/common/Modals/UpdateModal.tsx | 7 +- .../src/components/common/Modals/types.tsx | 2 + .../common/Navigation/Breadcrumb.tsx | 9 +- .../src/components/common/Typography/Body.tsx | 99 +++-- met-web/src/components/common/index.tsx | 82 ++-- met-web/src/components/common/modal.tsx | 9 +- .../imageUpload/UploadGuidelines.tsx | 42 ++ .../src/components/imageUpload/Uploader.tsx | 47 +-- .../src/components/imageUpload/cropModal.tsx | 30 +- met-web/src/components/imageUpload/index.tsx | 20 +- .../metadataManagement/TaxonEditForm.tsx | 6 +- .../components/tenantManagement/Create.tsx | 61 +++ .../components/tenantManagement/Detail.tsx | 377 +++++++++++++++++ .../src/components/tenantManagement/Edit.tsx | 93 +++++ .../components/tenantManagement/Listing.tsx | 34 +- .../tenantManagement/TenantForm.tsx | 387 ++++++++++++++++++ met-web/src/models/tenant.ts | 9 +- met-web/src/routes/AuthenticatedRoutes.tsx | 16 +- .../notificationModalSlice.tsx | 2 +- .../notificationModalService/types.ts | 17 +- met-web/src/services/tenantService/index.tsx | 26 ++ met-web/src/utils/index.ts | 21 + met-web/tests/unit/components/factory.ts | 1 + .../tenantManagement/CreateTenant.test.tsx | 227 ++++++++++ .../tenantManagement/EditTenant.test.tsx | 202 +++++++++ .../tenantManagement/TenantDetail.test.tsx | 155 +++++++ .../tenantManagement/TenantListing.test.tsx | 89 ++++ 51 files changed, 3228 insertions(+), 228 deletions(-) create mode 100644 met-api/migrations/versions/2c2ce3421cd6_tenant_table_update.py create mode 100644 met-api/migrations/versions/ae232e299180_.py create mode 100644 met-api/src/met_api/schemas/schemas/tenant.json create mode 100644 met-api/src/met_api/schemas/schemas/tenant_update.json create mode 100644 met-api/tests/unit/models/test_tenant.py create mode 100644 met-api/tests/unit/services/test_tenant_service.py delete mode 100644 met-web/src/components/MetDesignSystem/index.tsx create mode 100644 met-web/src/components/common/Input/FormField.tsx create mode 100644 met-web/src/components/common/Input/TextInput.tsx create mode 100644 met-web/src/components/common/Layout/Details.tsx create mode 100644 met-web/src/components/imageUpload/UploadGuidelines.tsx create mode 100644 met-web/src/components/tenantManagement/Create.tsx create mode 100644 met-web/src/components/tenantManagement/Detail.tsx create mode 100644 met-web/src/components/tenantManagement/Edit.tsx create mode 100644 met-web/src/components/tenantManagement/TenantForm.tsx create mode 100644 met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx create mode 100644 met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx create mode 100644 met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx create mode 100644 met-web/tests/unit/components/tenantManagement/TenantListing.test.tsx diff --git a/CHANGELOG.MD b/CHANGELOG.MD index 70f5a67c1..bc4f1c293 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -1,5 +1,18 @@ ## May 23, 2024 +- **Feature** Finish tenant management UX [🎟️ DESENG-605](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-605), [🎟️ DESENG-606](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-606) + - Added a new tenant detail page + - Added a form for filling out tenant details + - Added a new tenant creation page + - Added a new tenant edit page + +- **Feature** Add design system components to common components + - Added Table components, DetailsContainer, and Detail for layout + - Reworked button components to match the design system and support new colors + - Reworked modal components to match the design system + - Reworked image upload component to match the design system + - Added new form components that align with the design system + - **Bugfix** Security issue with email verification [🎟️ DESENG-618](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-618) - Removed verification token from the response object - Updated the test to reflect the change @@ -29,13 +42,14 @@ ## May 13, 2024 - **Bugfix** Date shown on rejection email preview is incorrect [🎟️ DESENG-597](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-597) + - Fixed the date formatting issue and using the end_date in the email preview - Updated unit tests - **Feature** Create role for metadata management [🎟️ DESENG-603](https://apps.itsm.gov.bc.ca/jira/browse/DESENG-603) -- Implemented a new role named "manage_metadata" within the Admin group to restrci access for metadata management -- Updated the frontend to restrict access to the "metadata management" link in the menu for users without the newly added role -- Backend changes to incorporate the new role for access control purposes, ensuring only authorized users can perform metadata management actions + - Implemented a new role named "manage_metadata" within the Admin group to restrci access for metadata management + - Updated the frontend to restrict access to the "metadata management" link in the menu for users without the newly added role + - Backend changes to incorporate the new role for access control purposes, ensuring only authorized users can perform metadata management actions ## May 10, 2024 diff --git a/met-api/migrations/versions/2c2ce3421cd6_tenant_table_update.py b/met-api/migrations/versions/2c2ce3421cd6_tenant_table_update.py new file mode 100644 index 000000000..9f68a95dc --- /dev/null +++ b/met-api/migrations/versions/2c2ce3421cd6_tenant_table_update.py @@ -0,0 +1,44 @@ +"""Tenant table update + +Revision ID: 2c2ce3421cd6 +Revises: 1407e0ad88f6 +Create Date: 2024-05-14 11:45:22.800840 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2c2ce3421cd6' +down_revision = '5388f257abfb' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tenant', sa.Column('contact_name', sa.String(length=50), nullable=True, comment='Name of the primary contact')) + op.add_column('tenant', sa.Column('contact_email', sa.String(length=255), nullable=True, comment='Email of the primary contact')) + op.add_column('tenant', sa.Column('logo_credit', sa.String(length=60), nullable=True, comment='Hero banner image credit')) + op.add_column('tenant', sa.Column('logo_description', sa.String(length=80), nullable=True, comment='Hero banner image description')) + # Set the default values for existing rows + op.execute('UPDATE tenant SET contact_name = \'Default Contact Name\' WHERE contact_name IS NULL') + op.execute('UPDATE tenant SET contact_email = \'default@example.com\' WHERE contact_email IS NULL') + # Remove server_default after setting the default values + # future inserts into the table do not use these default values + op.alter_column('tenant', 'contact_name', server_default=None) + op.alter_column('tenant', 'contact_email', server_default=None) + # Alter the columns to set them as NOT NULL + op.alter_column('tenant', 'contact_name', nullable=False) + op.alter_column('tenant', 'contact_email', nullable=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tenant', 'logo_description') + op.drop_column('tenant', 'logo_credit') + op.drop_column('tenant', 'contact_email') + op.drop_column('tenant', 'contact_name') + # ### end Alembic commands ### diff --git a/met-api/migrations/versions/ae232e299180_.py b/met-api/migrations/versions/ae232e299180_.py new file mode 100644 index 000000000..33fd06507 --- /dev/null +++ b/met-api/migrations/versions/ae232e299180_.py @@ -0,0 +1,28 @@ +"""add unique constraint to tenant short_name + +Revision ID: ae232e299180 +Revises: 2c2ce3421cd6 +Create Date: 2024-05-17 15:41:52.346746 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'ae232e299180' +down_revision = '2c2ce3421cd6' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint(None, 'tenant', ['short_name']) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'tenant', type_='unique') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/tenant.py b/met-api/src/met_api/models/tenant.py index c38da950b..c486ba281 100644 --- a/met-api/src/met_api/models/tenant.py +++ b/met-api/src/met_api/models/tenant.py @@ -3,7 +3,7 @@ Manages the tenants """ from __future__ import annotations - +from typing import List, Optional from .base_model import BaseModel from .db import db @@ -13,13 +13,52 @@ class Tenant(BaseModel): __tablename__ = 'tenant' id = db.Column(db.Integer, primary_key=True, autoincrement=True) - short_name = db.Column(db.String(10), comment='A small code for the tenant ie GDX , EAO.', nullable=False) + short_name = db.Column(db.String(10), nullable=False, unique=True, + comment='A small code for the tenant ie GDX , EAO.') name = db.Column(db.String(50), comment='Full name of the ministry.ie Env Assessment Office') + contact_name = db.Column( + db.String(50), comment='Name of the primary contact', nullable=False, default='Default Contact Name' + ) + contact_email = db.Column( + db.String(255), comment='Email of the primary contact', nullable=False, default='Default Contact Email' + ) description = db.Column(db.String(300)) title = db.Column(db.String(30), nullable=False) logo_url = db.Column(db.String(300), nullable=True) + logo_credit = db.Column(db.String(60), comment='Hero banner image credit', nullable=True) + logo_description = db.Column(db.String(80), comment='Hero banner image description', nullable=True) @staticmethod def find_by_short_name(short_name: str) -> Tenant: """Find tenant using short name.""" return db.session.query(Tenant).filter(Tenant.short_name.ilike(short_name)).one_or_none() + + @staticmethod + def find_by_id(identifier: int) -> Optional[Tenant]: + """Find tenant by id.""" + return db.session.query(Tenant).filter(Tenant.id == identifier).one_or_none() + + @staticmethod + def find_all() -> List[Tenant]: + """Return all tenants.""" + return db.session.query(Tenant).all() + + def save(self): + """Save tenant to the database.""" + db.session.add(self) + db.session.commit() + + def delete(self): + """Delete tenant from the database.""" + if self.id is None or not Tenant.find_by_id(self.id): + raise ValueError('Tenant not found.') + db.session.delete(self) + db.session.commit() + + def update(self, data: dict): + """Update tenant with data.""" + if self.id is None or not Tenant.find_by_id(self.id): + raise ValueError('Tenant not found.') + for key, value in data.items(): + setattr(self, key, value) + db.session.commit() diff --git a/met-api/src/met_api/resources/tenant.py b/met-api/src/met_api/resources/tenant.py index be3386b0f..d2585a22a 100644 --- a/met-api/src/met_api/resources/tenant.py +++ b/met-api/src/met_api/resources/tenant.py @@ -15,10 +15,15 @@ from http import HTTPStatus +from flask import request from flask_cors import cross_origin from flask_restx import Namespace, Resource +from marshmallow import ValidationError from met_api.auth import auth +from met_api.auth import jwt as _jwt +from met_api.schemas import utils as schema_utils +from met_api.schemas.tenant import TenantSchema from met_api.services.tenant_service import TenantService from met_api.utils.roles import Role from met_api.utils.tenant_validator import require_role @@ -47,20 +52,70 @@ def get(): except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def post(): + """Create a new tenant.""" + try: + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'tenant') + if not valid_format: + print(errors) + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST -@cors_preflight('GET OPTIONS') -@API.route('/') + tenant = TenantSchema().load(request_json) + created_tenant = TenantService().create(tenant) + return created_tenant, HTTPStatus.CREATED + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR + + +@cors_preflight('GET PATCH DELETE OPTIONS') +@API.route('/') class Tenant(Resource): """Resource for managing a single tenant.""" @staticmethod @cross_origin(origins=allowedorigins()) @auth.optional - def get(tenant_id): + def get(tenant_short_name: str): """Fetch a tenant.""" try: - tenant = TenantService().get(tenant_id) - + tenant = TenantService().get(tenant_short_name) return tenant, HTTPStatus.OK + except ValueError as err: return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def delete(tenant_short_name: str): + """Delete a tenant.""" + try: + TenantService().delete(tenant_short_name) + return {'status': 'success', 'message': 'Tenant deleted successfully'}, HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @_jwt.requires_auth + def patch(tenant_short_name: str): + """Update a tenant.""" + try: + request_json = request.get_json() + valid_format, errors = schema_utils.validate(request_json, 'tenant_update') + if not valid_format: + return {'message': schema_utils.serialize(errors)}, HTTPStatus.BAD_REQUEST + updated_tenant = TenantService().update(tenant_short_name, request_json) + return updated_tenant, HTTPStatus.OK + except (KeyError, ValueError) as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except ValidationError as err: + return str(err.messages), HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/met-api/src/met_api/schemas/schemas/tenant.json b/met-api/src/met_api/schemas/schemas/tenant.json new file mode 100644 index 000000000..1fcbd2ae4 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/tenant.json @@ -0,0 +1,95 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/tenant", + "type": "object", + "title": "Tenant Schema", + "description": "The schema for a tenant.", + "default": {}, + "examples": [ + { + "short_name": "GDX", + "name": "Government Digital Experience", + "contact_name": "John Doe", + "contact_email": "john.doe@gov.bc.ca", + "title": "Modern Engagement", + "description": "Responsible for digital experience projects.", + "logo_url": "https://example.com/logo.png", + "logo_credit": "Logo by Example", + "logo_description": "Official logo of the Government Digital Experience." + } + ], + "required": ["short_name", "contact_name", "contact_email", "title"], + "properties": { + "id": { + "$id": "#/properties/id", + "type": "integer", + "title": "Tenant ID", + "description": "The unique identifier for the tenant.", + "examples": [1] + }, + "short_name": { + "$id": "#/properties/short_name", + "type": "string", + "title": "Short Name", + "description": "A small code for the tenant, e.g., GDX, EAO.", + "examples": ["GDX"] + }, + "name": { + "$id": "#/properties/name", + "type": "string", + "title": "Full Name", + "description": "Full name of the tenant or ministry, e.g., Government Digital Experience.", + "examples": ["Government Digital Experience"] + }, + "contact_name": { + "$id": "#/properties/contact_name", + "type": "string", + "title": "Contact Name", + "description": "Name of the primary contact.", + "examples": ["John Doe"] + }, + "contact_email": { + "$id": "#/properties/contact_email", + "type": "string", + "title": "Contact Email", + "description": "Email of the primary contact.", + "examples": ["john.doe@gov.bc.ca"] + }, + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Title", + "description": "Title of the tenant's website.", + "examples": ["Modern Engagement"] + }, + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Description", + "description": "A brief description of the tenant.", + "examples": ["Responsible for digital experience projects."] + }, + "logo_url": { + "$id": "#/properties/logo_url", + "type": "string", + "title": "Logo URL", + "description": "URL to the tenant's logo.", + "examples": ["https://example.com/logo.png"] + }, + "logo_credit": { + "$id": "#/properties/logo_credit", + "type": "string", + "title": "Logo Credit", + "description": "Credit for the logo image.", + "examples": ["Logo by Example"] + }, + "logo_description": { + "$id": "#/properties/logo_description", + "type": "string", + "title": "Logo Description", + "description": "Description of the logo image.", + "examples": ["Official logo of the Government Digital Experience."] + } + } + } + \ No newline at end of file diff --git a/met-api/src/met_api/schemas/schemas/tenant_update.json b/met-api/src/met_api/schemas/schemas/tenant_update.json new file mode 100644 index 000000000..49b60e918 --- /dev/null +++ b/met-api/src/met_api/schemas/schemas/tenant_update.json @@ -0,0 +1,94 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "https://met.gov.bc.ca/.well_known/schemas/tenant_update", + "type": "object", + "title": "Tenant Schema", + "description": "The schema for a tenant update.", + "default": {}, + "examples": [ + { + "short_name": "GDX", + "name": "Government Digital Experience", + "contact_name": "John Doe", + "contact_email": "john.doe@gov.bc.ca", + "title": "Modern Engagement", + "description": "Responsible for digital experience projects.", + "logo_url": "https://example.com/logo.png", + "logo_credit": "Logo by Example", + "logo_description": "Official logo of the Government Digital Experience." + } + ], + "properties": { + "id": { + "$id": "#/properties/id", + "type": "integer", + "title": "Tenant ID", + "description": "The unique identifier for the tenant.", + "examples": [1] + }, + "short_name": { + "$id": "#/properties/short_name", + "type": "string", + "title": "Short Name", + "description": "A small code for the tenant, e.g., GDX, EAO.", + "examples": ["GDX"] + }, + "name": { + "$id": "#/properties/name", + "type": "string", + "title": "Full Name", + "description": "Full name of the tenant or ministry, e.g., Government Digital Experience.", + "examples": ["Government Digital Experience"] + }, + "contact_name": { + "$id": "#/properties/contact_name", + "type": "string", + "title": "Contact Name", + "description": "Name of the primary contact.", + "examples": ["John Doe"] + }, + "contact_email": { + "$id": "#/properties/contact_email", + "type": "string", + "title": "Contact Email", + "description": "Email of the primary contact.", + "examples": ["john.doe@gov.bc.ca"] + }, + "title": { + "$id": "#/properties/title", + "type": "string", + "title": "Title", + "description": "Title of the tenant's website.", + "examples": ["Modern Engagement"] + }, + "description": { + "$id": "#/properties/description", + "type": "string", + "title": "Description", + "description": "A brief description of the tenant.", + "examples": ["Responsible for digital experience projects."] + }, + "logo_url": { + "$id": "#/properties/logo_url", + "type": "string", + "title": "Logo URL", + "description": "URL to the tenant's logo.", + "examples": ["https://example.com/logo.png"] + }, + "logo_credit": { + "$id": "#/properties/logo_credit", + "type": "string", + "title": "Logo Credit", + "description": "Credit for the logo image.", + "examples": ["Logo by Example"] + }, + "logo_description": { + "$id": "#/properties/logo_description", + "type": "string", + "title": "Logo Description", + "description": "Description of the logo image.", + "examples": ["Official logo of the Government Digital Experience."] + } + } + } + \ No newline at end of file diff --git a/met-api/src/met_api/schemas/tenant.py b/met-api/src/met_api/schemas/tenant.py index 50d28d255..5ab57a511 100644 --- a/met-api/src/met_api/schemas/tenant.py +++ b/met-api/src/met_api/schemas/tenant.py @@ -12,6 +12,11 @@ class Meta: # pylint: disable=too-few-public-methods unknown = EXCLUDE name = fields.Str(data_key='name') + short_name = fields.Str(data_key='short_name') description = fields.Str(data_key='description') title = fields.Str(data_key='title') logo_url = fields.Str(data_key='logo_url') + contact_name = fields.Str(data_key='contact_name') + contact_email = fields.Str(data_key='contact_email') + logo_credit = fields.Str(data_key='logo_credit') + logo_description = fields.Str(data_key='logo_description') diff --git a/met-api/src/met_api/services/tenant_service.py b/met-api/src/met_api/services/tenant_service.py index 8a0fd7b3a..036af01d9 100644 --- a/met-api/src/met_api/services/tenant_service.py +++ b/met-api/src/met_api/services/tenant_service.py @@ -5,8 +5,12 @@ from met_api.models.tenant import Tenant as TenantModel from met_api.schemas.tenant import TenantSchema +from met_api.services import authorization +from met_api.utils.roles import Role from ..utils.cache import cache +NOT_FOUND_MSG = 'Tenant not found.' + class TenantService: """Tenant management service.""" @@ -27,10 +31,60 @@ def get(cls, tenant_id): """Get a tenant by id.""" tenant = TenantModel.find_by_short_name(tenant_id) if not tenant: - raise ValueError('Tenant not found.', cls, tenant_id) + raise ValueError(NOT_FOUND_MSG, cls, tenant_id) return TenantSchema().dump(tenant) - def get_all(self): + @classmethod + def get_all(cls): """Get all tenants.""" - tenants = TenantModel.query.all() + tenants = TenantModel.query.order_by(TenantModel.name.asc()).all() return TenantSchema().dump(tenants, many=True) + + @classmethod + def create(cls, data: dict): + """Create a new tenant.""" + one_of_roles = ( + Role.SUPER_ADMIN.value, + ) + authorization.check_auth(one_of_roles=one_of_roles) + tenant = TenantModel(**data) + try: + tenant.save() + except SQLAlchemyError as e: + current_app.logger.error('Error creating tenant {}', e) + raise ValueError('Error creating tenant.') from e + return TenantSchema().dump(tenant) + + @classmethod + def update(cls, tenant_id: str, data: dict): + """Update an existing tenant.""" + one_of_roles = ( + Role.SUPER_ADMIN.value, + ) + authorization.check_auth(one_of_roles=one_of_roles) + tenant = TenantModel.find_by_short_name(tenant_id) + if not tenant: + raise ValueError(NOT_FOUND_MSG, cls, tenant_id) + try: + tenant.update(data) + except SQLAlchemyError as e: + current_app.logger.error('Error updating tenant {}', e) + raise ValueError('Error updating tenant.') from e + return TenantSchema().dump(tenant) + + @classmethod + def delete(cls, tenant_id: str): + """Delete an existing tenant.""" + one_of_roles = ( + Role.SUPER_ADMIN.value, + ) + authorization.check_auth(one_of_roles=one_of_roles) + tenant = TenantModel.find_by_short_name(tenant_id) + if not tenant: + raise ValueError(NOT_FOUND_MSG, cls, tenant_id) + try: + tenant.delete() + except SQLAlchemyError as e: + current_app.logger.error('Error deleting tenant {}', e) + raise ValueError('Error deleting tenant.') from e + return {'status': 'success', 'message': 'Tenant deleted successfully'} diff --git a/met-api/tests/unit/api/test_tenant.py b/met-api/tests/unit/api/test_tenant.py index b395612be..b530c81ee 100644 --- a/met-api/tests/unit/api/test_tenant.py +++ b/met-api/tests/unit/api/test_tenant.py @@ -16,6 +16,8 @@ Test-Suite to ensure that the tenant endpoint is working as expected. """ +import json +import pytest from http import HTTPStatus from unittest.mock import patch @@ -43,3 +45,71 @@ def test_get_tenant(client, jwt, session): # pylint:disable=unused-argument rv = client.get(f'/api/tenants/{tenant_short_name}', headers=headers, content_type=ContentType.JSON.value) assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR + + +@pytest.mark.parametrize('tenant_info', [TestTenantInfo.tenant1]) +def test_create_tenant(client, jwt, session, tenant_info, setup_super_admin_user_and_claims): + """Assert that a tenant can be POSTed.""" + _, claims = setup_super_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + # remove logo_url from tenant_info + tenant_info.pop('logo_url') + rv = client.post('/api/tenants/', data=json.dumps(tenant_info), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.CREATED + assert rv.json.get('short_name') == tenant_info.get('short_name') + assert rv.json.get('name') == tenant_info.get('name') + assert rv.json.get('contact_name') == tenant_info.get('contact_name') + assert rv.json.get('contact_email') == tenant_info.get('contact_email') + assert rv.json.get('title') == tenant_info.get('title') + + +@pytest.mark.parametrize('tenant_info', [TestTenantInfo.tenant1]) +def test_get_tenants(client, jwt, session, tenant_info, setup_super_admin_user_and_claims): + """Assert that tenants can be fetched.""" + _, claims = setup_super_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + factory_tenant_model(tenant_info) + + rv = client.get('/api/tenants/', headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + assert len(rv.json) > 1 + tenant_short_names = [tenant.get('short_name') for tenant in rv.json] + # Check if the tenant with short_name exists in the response + assert tenant_info.get('short_name') in tenant_short_names + + +@pytest.mark.parametrize('tenant_info', [TestTenantInfo.tenant1]) +def test_patch_tenant(client, jwt, session, tenant_info, setup_super_admin_user_and_claims): + """Assert that a tenant can be PATCHed.""" + _, claims = setup_super_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + tenant = factory_tenant_model(tenant_info) + + rv = client.get(f'/api/tenants/{tenant.short_name}', headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('short_name') == tenant_info.get('short_name') + + tenant_edits = { + 'name': 'Example Inc', + 'title': 'Example Title', + } + + rv = client.patch(f'/api/tenants/{tenant.short_name}', data=json.dumps(tenant_edits), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + + rv = client.get(f'/api/tenants/{tenant.short_name}', headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK + assert rv.json.get('name') == tenant_edits.get('name') + + +@pytest.mark.parametrize('tenant_info', [TestTenantInfo.tenant1]) +def test_delete_tenant(client, jwt, session, tenant_info, setup_super_admin_user_and_claims): + """Assert that a tenant can be deleted.""" + _, claims = setup_super_admin_user_and_claims + headers = factory_auth_header(jwt=jwt, claims=claims) + tenant = factory_tenant_model(tenant_info) + + rv = client.delete(f'/api/tenants/{tenant.short_name}', headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK diff --git a/met-api/tests/unit/models/test_tenant.py b/met-api/tests/unit/models/test_tenant.py new file mode 100644 index 000000000..2c4cdc3f0 --- /dev/null +++ b/met-api/tests/unit/models/test_tenant.py @@ -0,0 +1,90 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the Tenant model. + +Test suite to ensure that the Tenant model routines are working as expected. +""" +import pytest +from faker import Faker + +from met_api.models.tenant import Tenant as TenantModel +from tests.utilities.factory_utils import factory_tenant_model +from tests.utilities.factory_scenarios import TestTenantInfo + +fake = Faker() + + +def test_tenant_creation(session): + """Assert that a tenant can be created and fetched.""" + tenant = factory_tenant_model() + assert tenant.id is not None + tenant_existing = TenantModel.find_by_id(tenant.id) + assert tenant.short_name == tenant_existing.short_name + + +def test_find_tenant_by_short_name(session): + """Assert that a tenant can be found by short name.""" + tenant = factory_tenant_model() + tenant_existing = TenantModel.find_by_short_name(tenant.short_name) + assert tenant_existing is not None + assert tenant.short_name == tenant_existing.short_name + + +def test_find_all_tenants(session): + """Assert that all tenants can be fetched.""" + factory_tenant_model() + factory_tenant_model({**TestTenantInfo.tenant1, 'short_name': 'GDX2'}) + tenants = TenantModel.find_all() + assert len(tenants) >= 2 + + +def test_update_tenant(session): + """Assert that a tenant can be updated.""" + tenant = factory_tenant_model() + new_name = fake.company() + tenant_data = {'name': new_name} + tenant.update(tenant_data) + updated_tenant = TenantModel.find_by_id(tenant.id) + assert updated_tenant.name == new_name + + +def test_delete_tenant(session): + """Assert that a tenant can be deleted.""" + tenant = factory_tenant_model() + tenant_id = tenant.id + tenant.delete() + deleted_tenant = TenantModel.find_by_id(tenant_id) + assert deleted_tenant is None + + +def test_find_tenant_by_nonexistent_id(session): + """Assert that finding a tenant by a nonexistent ID returns None.""" + tenant = TenantModel.find_by_id(99999) + assert tenant is None + + +def test_update_nonexistent_tenant(session): + """Assert that updating a nonexistent tenant raises an error.""" + tenant_data = {'name': fake.company()} + tenant = TenantModel() + with pytest.raises(ValueError): + tenant.update(tenant_data) + + +def test_delete_nonexistent_tenant(session): + """Assert that deleting a nonexistent tenant raises an error.""" + tenant = TenantModel() + with pytest.raises(ValueError) as excinfo: + tenant.delete() + assert str(excinfo.value) == 'Tenant not found.' diff --git a/met-api/tests/unit/services/test_tenant_service.py b/met-api/tests/unit/services/test_tenant_service.py new file mode 100644 index 000000000..43fb7285c --- /dev/null +++ b/met-api/tests/unit/services/test_tenant_service.py @@ -0,0 +1,127 @@ +"""Unit tests for the tenant service.""" +from unittest.mock import patch + +import pytest +from sqlalchemy.exc import SQLAlchemyError + +from met_api.models.tenant import Tenant as TenantModel +from met_api.schemas.tenant import TenantSchema +from met_api.services import authorization +from met_api.services.tenant_service import TenantService +from tests.utilities.factory_utils import TestTenantInfo, factory_tenant_model + + +def test_get_tenant(session): + """Test getting a tenant by id.""" + tenant = factory_tenant_model() + with patch.object(TenantModel, 'find_by_short_name', return_value=tenant): + result = TenantService.get('GDX') + assert result['short_name'] == tenant.short_name + assert result['name'] == tenant.name + + +def test_get_all_tenants(session): + """Test getting all tenants.""" + tenant = factory_tenant_model() + tenant2 = factory_tenant_model({**TestTenantInfo.tenant1, 'short_name': 'GDX2'}) + result = TenantService.get_all() + tenant_short_names = [tenant.get('short_name') for tenant in result] + assert tenant.short_name in tenant_short_names + assert tenant2.short_name in tenant_short_names + + +def test_get_tenant_not_found(session): + """Test getting a tenant by id that does not exist.""" + with patch.object(TenantModel, 'find_by_short_name', return_value=None): + with pytest.raises(ValueError): + TenantService.get('NONEXISTENT') + + +def test_create_tenant(session): + """Test creating a tenant.""" + with patch.object(authorization, 'check_auth', return_value=True): + tenant_data = { + 'short_name': 'GDX', + 'name': 'Government Digital Experience', + 'contact_name': 'John Doe', + 'contact_email': 'john.doe@gov.bc.ca', + 'title': 'Director' + } + with patch.object(TenantModel, 'save', return_value=None): + with patch.object(TenantSchema, 'dump', return_value=tenant_data): + result = TenantService.create(tenant_data) + assert result['short_name'] == tenant_data['short_name'] + assert result['name'] == tenant_data['name'] + + +def test_create_tenant_error(session): + """Test creating a tenant with an error.""" + tenant_data = { + 'short_name': 'GDX', + 'name': 'Government Digital Experience' + } + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'save', side_effect=SQLAlchemyError): + with pytest.raises(ValueError): + TenantService.create(tenant_data) + + +def test_update_tenant(session): + """Test updating a tenant.""" + tenant_data = {'name': 'Updated Name'} + tenant = factory_tenant_model() + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'find_by_id', return_value=tenant): + with patch.object(TenantModel, 'update', return_value=None): + with patch.object(TenantSchema, 'dump', return_value={**tenant_data, 'short_name': 'GDX'}): + result = TenantService.update(tenant.short_name, tenant_data) + assert result['name'] == tenant_data['name'] + + +def test_update_tenant_not_found(session): + """Test updating a tenant that does not exist.""" + tenant_data = {'name': 'Updated Name'} + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'find_by_id', return_value=None): + with pytest.raises(ValueError): + TenantService.update('1', tenant_data) + + +def test_update_tenant_error(session): + """Test updating a tenant with an error.""" + tenant_data = {'name': 'Updated Name'} + tenant = factory_tenant_model() + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'find_by_id', return_value=tenant): + with patch.object(TenantModel, 'update', side_effect=SQLAlchemyError): + with pytest.raises(ValueError): + TenantService.update(tenant.short_name, tenant_data) + + +def test_delete_tenant(session): + """Test deleting a tenant.""" + tenant = factory_tenant_model() + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'find_by_id', return_value=tenant): + with patch.object(TenantModel, 'delete', return_value=None): + result = TenantService.delete(tenant.short_name) + assert result['status'] == 'success' + assert result['message'] == 'Tenant deleted successfully' + + +def test_delete_tenant_not_found(session): + """Test deleting a tenant that does not exist.""" + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'find_by_id', return_value=None): + with pytest.raises(ValueError): + TenantService.delete('1') + + +def test_delete_tenant_error(session): + """Test deleting a tenant with an error.""" + tenant = factory_tenant_model() + with patch.object(authorization, 'check_auth', return_value=True): + with patch.object(TenantModel, 'find_by_id', return_value=tenant): + with patch.object(TenantModel, 'delete', side_effect=SQLAlchemyError): + with pytest.raises(ValueError): + TenantService.delete(tenant.short_name) diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index 5eb912d4e..88e1bdc1b 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -159,6 +159,8 @@ class TestTenantInfo(dict, Enum): tenant1 = { 'short_name': 'EAO', 'name': fake.name(), + 'contact_name': fake.name(), + 'contact_email': fake.email(), 'description': fake.text(max_nb_chars=300), 'title': fake.text(max_nb_chars=20), 'logo_url': None, @@ -166,6 +168,8 @@ class TestTenantInfo(dict, Enum): tenant2 = { 'short_name': 'EMLI', 'name': fake.name(), + 'contact_name': fake.name(), + 'contact_email': fake.email(), 'description': fake.text(max_nb_chars=300), 'title': fake.text(max_nb_chars=20), 'logo_url': None, diff --git a/met-api/tests/utilities/factory_utils.py b/met-api/tests/utilities/factory_utils.py index a1954ab5a..2db805a7d 100644 --- a/met-api/tests/utilities/factory_utils.py +++ b/met-api/tests/utilities/factory_utils.py @@ -187,6 +187,8 @@ def factory_tenant_model(tenant_info: dict = TestTenantInfo.tenant1): tenant = Tenant( short_name=tenant_info.get('short_name'), name=tenant_info.get('name'), + contact_name=tenant_info.get('contact_name'), + contact_email=tenant_info.get('contact_email'), description=tenant_info.get('description'), title=tenant_info.get('title'), logo_url=tenant_info.get('logo_url'), diff --git a/met-web/jest.config.ts b/met-web/jest.config.ts index aa3653754..c4144dc5d 100644 --- a/met-web/jest.config.ts +++ b/met-web/jest.config.ts @@ -188,6 +188,8 @@ const config: Config.InitialOptions = { // Whether to use watchman for file crawling // watchman: true, + + testTimeout: 10000, }; export default config; diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index f9a9f23cb..edef59b64 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -173,8 +173,11 @@ const Endpoints = { UPDATE: `${AppConfig.apiUrl}/widgets/widget_id/timelines/timeline_id`, }, Tenants: { - GET: `${AppConfig.apiUrl}/tenants/tenant_id`, + CREATE: `${AppConfig.apiUrl}/tenants/`, GET_LIST: `${AppConfig.apiUrl}/tenants/`, + GET: `${AppConfig.apiUrl}/tenants/tenant_id`, + UPDATE: `${AppConfig.apiUrl}/tenants/tenant_id`, + DELETE: `${AppConfig.apiUrl}/tenants/tenant_id`, }, AnalyticsUserResponseDetail: { GET_COUNT_BY_MONTH: `${AppConfig.analyticsApiUrl}/responses/month/engagement_id`, diff --git a/met-web/src/components/MetDesignSystem/index.tsx b/met-web/src/components/MetDesignSystem/index.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/met-web/src/components/common/Input/Button.tsx b/met-web/src/components/common/Input/Button.tsx index a861e0d24..3ccf55fbe 100644 --- a/met-web/src/components/common/Input/Button.tsx +++ b/met-web/src/components/common/Input/Button.tsx @@ -1,11 +1,20 @@ import React from 'react'; import { Button as MuiButton, ButtonProps as MuiButtonProps } from '@mui/material'; import { globalFocusShadow, colors, elevations } from '../../common'; +import { isDarkColor } from 'utils'; + +const buttonStyles = { + borderRadius: '16px', + padding: '0 1.5rem', + marginBottom: '1.5rem', + fontWeight: 500, + fontSize: '16px', +}; type ButtonProps = { children: React.ReactNode; + color?: 'default' | 'danger' | 'warning' | 'success' | string; onClick?: () => void; - variant: 'primary' | 'secondary' | 'tertiary'; size?: 'small' | 'medium' | 'large'; icon?: React.ReactNode; iconPosition?: 'left' | 'right'; @@ -18,73 +27,212 @@ const sizeMap = { large: '56px', }; -export const Button: React.FC = ({ +export const PrimaryButton: React.FC = ({ children, + color = 'default', + onClick, + size = 'medium', + icon, + iconPosition = 'left', + disabled, + ...buttonProps +}) => { + const height: string = sizeMap[size]; + const customColor = colors.button[color as keyof typeof colors.button]?.shade ?? color; + const bgColor = customColor; + const darkBgColor = `color-mix(in srgb, ${bgColor}, black 20%)`; + // Use inverted text color for dark backgrounds + const textColors = isDarkColor(bgColor, 0.4) ? colors.type.inverted : colors.type.regular; + + return ( + + {children} + + ); +}; + +export const SecondaryButton: React.FC = ({ + children, + color = 'default', onClick, - variant = 'secondary', size = 'medium', icon, iconPosition = 'left', disabled, ...buttonProps }) => { - const colorMap = colors.button[variant]; const height: string = sizeMap[size]; - const typeColors = variant == 'primary' ? colors.type.inverted : colors.type.regular; - // Don't display elevation changes for tertiary buttons - const elevation = - variant == 'tertiary' ? { z1: elevations.none, z4: elevations.none, z9: elevations.none } : elevations; + const customColor = colors.notification[color as keyof typeof colors.notification]?.shade ?? color; + const isCustom = color !== 'default'; + const textColor = isCustom ? customColor : colors.type.regular.primary; + const darkTextColor = isCustom ? `color-mix(in srgb, ${textColor}, black 5%)` : textColor; + const borderColor = isCustom ? customColor : colors.surface.gray[80]; + const darkBorderColor = isCustom ? `color-mix(in srgb, ${borderColor}, black 5%)` : colors.surface.gray[110]; + const darkBackgroundColor = isCustom ? '#F8F8F8' : `color-mix(in srgb, white, black 5%)`; return ( + {children} + + ); +}; + +const TertiaryButton = ({ + children, + color = 'default', + onClick, + size = 'medium', + icon, + iconPosition = 'left', + disabled, + ...buttonProps +}: ButtonProps) => { + const height: string = sizeMap[size]; + const customColor = colors.notification[color as keyof typeof colors.notification]?.shade ?? color; + const activeColor = color !== 'default' ? `color-mix(in srgb, ${customColor}, white 90%)` : colors.surface.blue[10]; + + return ( + {children} ); }; + +export const Button = ({ + variant = 'secondary', + ...props +}: ButtonProps & { + variant?: 'primary' | 'secondary' | 'tertiary'; +}) => { + switch (variant) { + case 'primary': + return ; + case 'secondary': + return ; + case 'tertiary': + return ; + default: + return ; + } +}; diff --git a/met-web/src/components/common/Input/FormField.tsx b/met-web/src/components/common/Input/FormField.tsx new file mode 100644 index 000000000..1768b77a0 --- /dev/null +++ b/met-web/src/components/common/Input/FormField.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Grid, GridProps } from '@mui/material'; +import { BodyText } from '../Typography'; +import { colors } from '..'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faExclamationCircle } from '@fortawesome/free-solid-svg-icons'; + +export type FormFieldProps = { + title: string; + disabled?: boolean; + instructions?: string; + required?: boolean; + optional?: boolean; + error?: string; + children: React.ReactNode; +} & GridProps; + +export const FormField = ({ + title, + disabled, + instructions, + required, + optional, + error, + children, + ...gridProps +}: FormFieldProps) => { + return ( + + ); +}; diff --git a/met-web/src/components/common/Input/TextInput.tsx b/met-web/src/components/common/Input/TextInput.tsx new file mode 100644 index 000000000..02c5f3265 --- /dev/null +++ b/met-web/src/components/common/Input/TextInput.tsx @@ -0,0 +1,183 @@ +import React, { useEffect } from 'react'; +import { Button as MuiButton, Input, InputProps, Box } from '@mui/material'; +import { colors, globalFocusVisible } from '..'; +import { FormField, FormFieldProps } from './FormField'; +import { BodyText } from '../Typography'; +import { faCircleXmark } from '@fortawesome/pro-regular-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +type TextInputProps = { + value?: string; + onChange?: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} & Omit; + +export const TextInput: React.FC = ({ + id, + value, + onChange, + placeholder, + disabled, + sx, + error, + inputProps, + ...textFieldProps +}: TextInputProps) => { + // Exclude props that are not meant for the input element + return ( + onChange?.(e.target.value)} + placeholder={placeholder} + disabled={disabled} + sx={{ + display: 'flex', + height: '48px', + padding: '8px 16px', + alignItems: 'center', + justifyContent: 'center', + gap: '10px', + alignSelf: 'stretch', + borderRadius: '8px', + boxShadow: error + ? `0 0 0 2px ${colors.notification.error.shade} inset` + : `0 0 0 1px ${colors.surface.gray[80]} inset`, + caretColor: colors.surface.blue[90], + '&:hover': { + boxShadow: `0 0 0 2px ${colors.surface.gray[90]} inset`, + '&:has(:disabled)': { + boxShadow: `0 0 0 1px ${colors.surface.gray[80]} inset`, + }, + }, + '&.Mui-focused': { + boxShadow: `0 0 0 4px ${colors.focus.regular.outer}`, + '&:has(:disabled)': { + // make sure disabled state doesn't override focus state + boxShadow: `0 0 0 1px ${colors.surface.gray[80]} inset`, + }, + }, + '&:has(:disabled)': { + background: colors.surface.gray[10], + color: colors.type.regular.secondary, + userSelect: 'none', + cursor: 'not-allowed', + }, + ...sx, + }} + inputProps={{ + error: error, + ...inputProps, + 'aria-describedby': id, + sx: { + fontSize: '16px', + lineHeight: '24px', + color: colors.type.regular.primary, + '&::placeholder': { + color: colors.type.regular.secondary, + }, + '&:disabled': { + cursor: 'not-allowed', + }, + border: 'none', + ...inputProps?.sx, + }, + }} + {...textFieldProps} + /> + ); +}; + +const clearInputButton = (onClick: () => void) => { + return ( + + + + ); +}; + +export type TextFieldProps = { + error?: string; + counter?: boolean; + maxLength?: number; + clearable?: boolean; +} & Omit & + Omit; + +export const TextField = ({ + title, + instructions, + error, + required, + optional, + clearable, + onChange, + disabled, + ...textInputProps +}: TextFieldProps) => { + const [value, setValue] = React.useState(textInputProps.value || ''); + + useEffect(() => { + setValue(textInputProps.value || ''); + }, [textInputProps.value]); + + const handleSetValue = (newValue: string) => { + onChange?.(newValue) ?? setValue(newValue); + }; + + const isError = !!error; + const length = value.length; + return ( + + handleSetValue('')) : undefined} + {...textInputProps} + inputProps={{ ...textInputProps.inputProps, maxLength: textInputProps.maxLength }} + onChange={handleSetValue} + /> + + {textInputProps.counter && textInputProps.maxLength && ( + + {length}/{textInputProps.maxLength} + + )} + + + ); +}; + +export const TextAreaField = ({ ...textFieldProps }: Omit) => { + return ; +}; diff --git a/met-web/src/components/common/Input/index.tsx b/met-web/src/components/common/Input/index.tsx index fe9c53c51..2890ee670 100644 --- a/met-web/src/components/common/Input/index.tsx +++ b/met-web/src/components/common/Input/index.tsx @@ -1 +1,3 @@ export { Button } from './Button'; +export { TextInput, TextField, TextAreaField } from './TextInput'; +export { FormField } from './FormField'; diff --git a/met-web/src/components/common/Layout/Details.tsx b/met-web/src/components/common/Layout/Details.tsx new file mode 100644 index 000000000..3354373dc --- /dev/null +++ b/met-web/src/components/common/Layout/Details.tsx @@ -0,0 +1,51 @@ +import { Box, BoxProps } from '@mui/material'; +import React from 'react'; +import { colors } from '..'; + +export const DetailsContainer = ({ children, ...formContainerProps }: { children: React.ReactNode } & BoxProps) => { + const { sx, ...rest } = formContainerProps; + return ( + + {children} + + ); +}; +export const Detail = ({ + children, + invisible, + ...containerProps +}: { children: React.ReactNode; invisible?: boolean } & BoxProps) => { + const { sx, ...rest } = containerProps; + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/common/Layout/index.tsx b/met-web/src/components/common/Layout/index.tsx index 985e99829..eab336533 100644 --- a/met-web/src/components/common/Layout/index.tsx +++ b/met-web/src/components/common/Layout/index.tsx @@ -21,3 +21,4 @@ export const ResponsiveContainer = styled(MuiBox)(({ theme }) => { }); export { Table, TableHead, TableHeadRow, TableHeadCell, TableBody, TableRow, TableCell, TableContainer } from './Table'; +export { DetailsContainer, Detail } from './Details'; diff --git a/met-web/src/components/common/Modals/ConfirmModal.tsx b/met-web/src/components/common/Modals/ConfirmModal.tsx index 7f480c50c..57a675d6c 100644 --- a/met-web/src/components/common/Modals/ConfirmModal.tsx +++ b/met-web/src/components/common/Modals/ConfirmModal.tsx @@ -1,72 +1,90 @@ import React from 'react'; -import { Grid, Stack, useMediaQuery, Theme } from '@mui/material'; -import { modalStyle, PrimaryButtonOld, SecondaryButtonOld, MetHeader1Old, MetBodyOld } from 'components/common'; +import { Grid, Stack } from '@mui/material'; +import { colors, modalStyle } from 'components/common'; +import { Button } from '../Input'; +import { Header2, BodyText } from '../Typography'; import { NotificationModalProps } from './types'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faCheckCircle, + faExclamationCircle, + faExclamationTriangle, + faInfoCircle, +} from '@fortawesome/free-solid-svg-icons'; const ConfirmModal = ({ + style = 'default', header, + subHeader, subText, handleConfirm, handleClose, - confirmButtonText, - cancelButtonText, + confirmButtonText = 'Confirm', + cancelButtonText = 'Cancel', }: NotificationModalProps) => { - const isSmallScreen = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); - + const palette = colors.notification[style]; + const iconMap = { + default: faInfoCircle, + danger: faExclamationCircle, + warning: faExclamationTriangle, + success: faCheckCircle, + }; return ( - - - - {header} - - + + - - {subText.map((subtext, index) => ( - - - {subtext.text} - + + + + {header} + + + {subHeader && ( + + {subHeader} + + )} + + {subText.map((subtext, index) => ( + + + {subtext.text} + + + ))} + + + + + - ))} - - - {isSmallScreen ? ( - <> - - {confirmButtonText ? confirmButtonText : 'Confirm'} - - - {cancelButtonText ? cancelButtonText : 'Cancel'} - - - ) : ( - <> - - {cancelButtonText ? cancelButtonText : 'Cancel'} - - - {confirmButtonText ? confirmButtonText : 'Confirm'} - - - )} - diff --git a/met-web/src/components/common/Modals/UpdateModal.tsx b/met-web/src/components/common/Modals/UpdateModal.tsx index 8207e6021..67dfebd3a 100644 --- a/met-web/src/components/common/Modals/UpdateModal.tsx +++ b/met-web/src/components/common/Modals/UpdateModal.tsx @@ -1,16 +1,17 @@ import React from 'react'; import { Grid } from '@mui/material'; -import { modalStyle, PrimaryButtonOld, MetHeader1Old, MetBodyOld } from 'components/common'; +import { modalStyle, PrimaryButtonOld, MetHeader1Old, MetBodyOld, colors } from 'components/common'; import { NotificationModalProps } from './types'; -const UpdateModal = ({ header, subText, handleClose }: NotificationModalProps) => { +const UpdateModal = ({ header, style = 'default', subText, handleClose }: NotificationModalProps) => { + const palette = colors.button[style]; return ( diff --git a/met-web/src/components/common/Modals/types.tsx b/met-web/src/components/common/Modals/types.tsx index 9a88050be..9b7246dc9 100644 --- a/met-web/src/components/common/Modals/types.tsx +++ b/met-web/src/components/common/Modals/types.tsx @@ -6,7 +6,9 @@ export interface ModalSubtext { } export interface NotificationModalProps { + style?: 'default' | 'danger' | 'warning' | 'success'; header: string; + subHeader?: string; subText: ModalSubtext[]; handleConfirm?: () => void; handleClose?: () => void; diff --git a/met-web/src/components/common/Navigation/Breadcrumb.tsx b/met-web/src/components/common/Navigation/Breadcrumb.tsx index 4c94ed09e..9bc5c7d8d 100644 --- a/met-web/src/components/common/Navigation/Breadcrumb.tsx +++ b/met-web/src/components/common/Navigation/Breadcrumb.tsx @@ -15,11 +15,16 @@ export const BreadcrumbTrail: React.FC<{ crumbs: BreadcrumbProps[]; smallScreenO {crumbs.map((crumb, index) => crumb.link ? ( - + {crumb.name} ) : ( - + {crumb.name} ), diff --git a/met-web/src/components/common/Typography/Body.tsx b/met-web/src/components/common/Typography/Body.tsx index 55c1e100e..53b687c26 100644 --- a/met-web/src/components/common/Typography/Body.tsx +++ b/met-web/src/components/common/Typography/Body.tsx @@ -1,31 +1,78 @@ -import { styled } from '@mui/material'; +import React from 'react'; +import { Typography, TypographyProps } from '@mui/material'; import { globalFocusVisible, colors } from '../../common'; import { Link as RouterLink } from 'react-router-dom'; -export const BodyText = styled('p')<{ bold?: boolean; small?: boolean }>(({ bold, small }) => ({ - margin: 0, - lineHeight: small ? '1.375' : '1.5', - fontSize: small ? '0.875rem' : '1rem', - color: '#292929', - fontWeight: bold ? 700 : 400, -})); - -export const Link = styled(RouterLink)<{ bold?: boolean; small?: boolean }>(({ bold, small }) => ({ - textDecoration: 'none', - lineHeight: small ? '1.375' : '1.5', - fontSize: small ? '0.875rem' : '1rem', - fontWeight: bold ? 700 : 400, - color: colors.type.regular.link, - '&:hover': { - textDecoration: 'underline', - }, - '&:focus': { - ...globalFocusVisible, - }, -})); - -const Body = { - BodyText, +export const BodyText = ({ + bold, + size = 'regular', + children, + ...props +}: { + bold?: boolean; + size?: 'small' | 'regular' | 'large'; + children: React.ReactNode; +} & TypographyProps) => { + const fontSize = { + small: '14px', + regular: '16px', + large: '18px', + }[size]; + const lineHeight = { + small: '22px', + regular: '24px', + large: '24px', + }[size]; + return ( + + {children} + + ); }; -export default Body; +export const Link = ({ + bold, + size = 'regular', + children, + ...props +}: { + bold?: boolean; + size?: 'small' | 'regular' | 'large'; + to: string; + children: React.ReactNode; +}) => { + const fontSize = { + small: '14px', + regular: '16px', + large: '18px', + }[size]; + const lineHeight = { + small: '1.375', + regular: '1.5', + large: '1.625', + }[size]; + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/common/index.tsx b/met-web/src/components/common/index.tsx index d4466802d..c9f79d500 100644 --- a/met-web/src/components/common/index.tsx +++ b/met-web/src/components/common/index.tsx @@ -39,27 +39,30 @@ export const colors = { }, }, button: { - primary: { - enabled: '#053662', - hover: '#1E5189', - focused: '#053662', - pressed: '#032543', - disabled: '#EDEBE9', + default: { + shade: '#12508F', + icon: '#12508F', + tint: '#FFF8E8', }, - secondary: { - enabled: '#FFFFFF', - hover: '#EDEBE9', - focused: '#FFFFFF', - pressed: '#E0DEDC', - disabled: '#EDEBE9', - stroke: '#201F1E', + success: { + shade: '#42814A', + icon: '#42814A', + tint: '#F6FFF8', }, - tertiary: { - enabled: '#FFFFFF', - hover: '#F1F8FF', - focused: '#FFFFFF', - pressed: '#F1F8FF', - disabled: '#FFFFFF', + warning: { + shade: '#FCBA19', + icon: '#C08C07', + tint: '#FFECBE', + }, + danger: { + shade: '#CE3E39', + icon: '#CE3E39', + tint: '#F4E1E2', + }, + error: { + shade: '#CE3E39', + icon: '#CE3E39', + tint: '#F4E1E2', }, }, focus: { @@ -108,6 +111,34 @@ export const colors = { 90: '#825C00', 100: '#593F00', }, + white: '#FFFFFF', + }, + notification: { + default: { + shade: '#12508F', + icon: '#12508F', + tint: '#FFF8E8', + }, + success: { + shade: '#42814A', + icon: '#42814A', + tint: '#F6FFF8', + }, + warning: { + shade: '#FCBA19', + icon: '#C08C07', + tint: '#FFECBE', + }, + danger: { + shade: '#CE3E39', + icon: '#CE3E39', + tint: '#F4E1E2', + }, + error: { + shade: '#CE3E39', + icon: '#CE3E39', + tint: '#F4E1E2', + }, }, }; @@ -115,9 +146,10 @@ export const elevations = { // For use with CSS box-shadow property // Not complete in Figma yet none: '0px 0px transparent', - z1: '0px 2px 1px 0px rgba(0, 0, 0, 0.14), 0px 1px 6px 0px rgba(0, 0, 0, 0.60) inset', - z4: '0px 2px 3px 0px rgba(0, 0, 0, 0.10), 0px 6px 6px 0px rgba(0, 0, 0, 0.09), 0px 14px 9px 0px rgba(0, 0, 0, 0.05)', - z9: '0px 5px 6px 0px rgba(0, 0, 0, 0.20), 0px 9px 12px 0px rgba(0, 0, 0, 0.14), 0px 3px 16px 0px rgba(0, 0, 0, 0.12)', + pressed: '0px 4px 4px 0px rgba(0, 0, 0, 0.25) inset, 0px 0px 16px 0px rgba(0, 0, 0, 0.16) inset', + default: + '0px 12px 10px 0px rgba(0, 0, 0, 0.01), 0px 7px 9px 0px rgba(0, 0, 0, 0.05), 0px 3px 6px 0px rgba(0, 0, 0, 0.09), 0px 1px 3px 0px rgba(0, 0, 0, 0.10)', + hover: '0px 5px 6px 0px rgba(0, 0, 0, 0.20), 0px 9px 12px 0px rgba(0, 0, 0, 0.14), 0px 3px 16px 0px rgba(0, 0, 0, 0.12)', }; export const globalFocusShadow = `inset 0px 0px 0px 2px ${colors.focus.regular.inner}`; @@ -452,6 +484,9 @@ export const RepeatedGrid = ({ times = 1, children, ...rest }: RepeatedGridProps }; export const modalStyle = { + borderRadius: '8px', + borderTop: '8px solid', + borderColor: colors.notification.default.shade, position: 'absolute', top: '50%', left: '48%', @@ -461,8 +496,9 @@ export const modalStyle = { bgcolor: 'background.paper', boxShadow: 10, pt: 2, - px: 4, pb: 3, + pl: 3, + pr: 4, m: 1, overflowY: 'scroll', color: Palette.text.primary, diff --git a/met-web/src/components/common/modal.tsx b/met-web/src/components/common/modal.tsx index 0d2c1add1..d9bb1d84a 100644 --- a/met-web/src/components/common/modal.tsx +++ b/met-web/src/components/common/modal.tsx @@ -9,9 +9,8 @@ import { When } from 'react-if'; export const NotificationModal = () => { const dispatch = useAppDispatch(); const open = useAppSelector((state) => state.notificationModal.open); - const { header, subText, handleClose, handleConfirm, confirmButtonText, cancelButtonText } = useAppSelector( - (state) => state.notificationModal.data, - ); + const { style, header, subHeader, subText, handleClose, handleConfirm, confirmButtonText, cancelButtonText } = + useAppSelector((state) => state.notificationModal.data); const type = useAppSelector((state) => state.notificationModal.type); function _handleClose() { @@ -33,11 +32,13 @@ export const NotificationModal = () => { > <> - + ; +const xIcon = ; + +export const UploadGuidelines = () => ( + + + + {checkIcon} Wide images (Landscape) + + + {checkIcon} Decorative subject matter + + + {checkIcon} Any subject of focus is on the right + + + {checkIcon} 2500px or more (width) + + + + + {xIcon} Tall images (Portrait) + + + {xIcon} Informative imagery, text or logos + + + {xIcon} Images with key elements on the left + + + {xIcon} Less than 1500px (width) + + + +); diff --git a/met-web/src/components/imageUpload/Uploader.tsx b/met-web/src/components/imageUpload/Uploader.tsx index 6ec193d1b..994ba5cd0 100644 --- a/met-web/src/components/imageUpload/Uploader.tsx +++ b/met-web/src/components/imageUpload/Uploader.tsx @@ -1,21 +1,16 @@ import React, { useEffect, useContext } from 'react'; -import { Grid, Stack, Typography } from '@mui/material'; +import { Grid, Stack } from '@mui/material'; import Dropzone, { Accept } from 'react-dropzone'; -import { PrimaryButtonOld, SecondaryButtonOld } from 'components/common'; import { ImageUploadContext } from './imageUploadContext'; +import { colors } from 'components/common'; +import { Button } from 'components/common/Input'; interface UploaderProps { - margin?: number; - helpText?: string; height?: string; accept?: Accept; + children: React.ReactNode; } -const Uploader = ({ - margin = 2, - helpText = 'Drag and drop some files here, or click to select files', - height = '10em', - accept = {}, -}: UploaderProps) => { +const Uploader = ({ height = '10em', accept = {}, children }: UploaderProps) => { const { handleAddFile, addedImageFileUrl, @@ -45,12 +40,13 @@ const Uploader = ({ item xs={12} style={{ - border: '1px dashed #606060', + borderRadius: '8px', height: height, padding: '0', }} > - - + @@ -115,18 +110,20 @@ const Uploader = ({ - {helpText} + {children} )} diff --git a/met-web/src/components/imageUpload/cropModal.tsx b/met-web/src/components/imageUpload/cropModal.tsx index cbd95a1bd..49ab80ff3 100644 --- a/met-web/src/components/imageUpload/cropModal.tsx +++ b/met-web/src/components/imageUpload/cropModal.tsx @@ -1,12 +1,16 @@ import React, { useContext, useState } from 'react'; import Modal from '@mui/material/Modal'; import { Container, Grid, Paper } from '@mui/material'; -import { MetDescription, modalStyle, PrimaryButtonOld } from 'components/common'; +import { modalStyle } from 'components/common'; +import { Button } from 'components/common/Input'; import Cropper, { Area } from 'react-easy-crop'; import { ImageUploadContext } from './imageUploadContext'; import { Box } from '@mui/system'; import getCroppedImg from './cropImage'; import { blobToFile } from 'utils'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCheck } from '@fortawesome/pro-regular-svg-icons'; +import { BodyText } from 'components/common/Typography'; export const CropModal = () => { const { @@ -90,22 +94,26 @@ export const CropModal = () => { marginTop: '30em', }} > - + - - The image will be cropped at the correct ratio to display as a banner on MET. You - can zoom in or out and move the image around. Please note that part of the image - could be hidden depending on the display size. - + + The image will be cropped at the correct ratio to display as a banner in MET. You + can zoom in or out and move the image around. Please note that part of the image may + be hidden depending on the user's display size. + - - + diff --git a/met-web/src/components/imageUpload/index.tsx b/met-web/src/components/imageUpload/index.tsx index 7b1e509fd..83abb6a2c 100644 --- a/met-web/src/components/imageUpload/index.tsx +++ b/met-web/src/components/imageUpload/index.tsx @@ -3,6 +3,11 @@ import { CropModal } from './cropModal'; import { ImageUploadContextProvider } from './imageUploadContext'; import Uploader from './Uploader'; import { Accept } from 'react-dropzone'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faCloudUpload } from '@fortawesome/pro-regular-svg-icons'; +import { colors } from 'components/common'; +import { BodyText } from 'components/common/Typography'; +import { Button } from 'components/common/Input'; interface UploaderProps { margin?: number; @@ -19,7 +24,7 @@ export const ImageUpload = ({ handleAddFile, savedImageUrl = '', savedImageName = '', - helpText = 'Drag and drop an image here, or click to select an image from your device. Formats accepted are: jpg, png, webp.', + helpText = 'Drag and drop your image here.', height = '10em', cropAspectRatio = 1, accept = { @@ -35,7 +40,18 @@ export const ImageUpload = ({ savedImageName={savedImageName} cropAspectRatio={cropAspectRatio} > - + + + + {helpText} + + + Supported formats: JPG, PNG, WEBP + + + ); diff --git a/met-web/src/components/metadataManagement/TaxonEditForm.tsx b/met-web/src/components/metadataManagement/TaxonEditForm.tsx index 6854abbfa..06613f196 100644 --- a/met-web/src/components/metadataManagement/TaxonEditForm.tsx +++ b/met-web/src/components/metadataManagement/TaxonEditForm.tsx @@ -161,11 +161,7 @@ const TaxonEditForm = ({ taxon }: { taxon: MetadataTaxon }): JSX.Element => { if (Object.keys(formErrors).length) { dispatch( - // openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), - openNotification({ - severity: 'info', - text: 'This state is never used and I had to make a custom function to open it', - }), + openNotification({ text: 'Please correct the highlighted errors before saving.', severity: 'error' }), ); return false; } diff --git a/met-web/src/components/tenantManagement/Create.tsx b/met-web/src/components/tenantManagement/Create.tsx new file mode 100644 index 000000000..ea3af4a74 --- /dev/null +++ b/met-web/src/components/tenantManagement/Create.tsx @@ -0,0 +1,61 @@ +import React from 'react'; + +import { Grid } from '@mui/material'; +import { ResponsiveContainer } from 'components/common/Layout'; +import { Header1, Header2, BodyText } from 'components/common/Typography/'; +import { BreadcrumbTrail } from 'components/common/Navigation'; +import { TenantForm } from './TenantForm'; +import { createTenant } from 'services/tenantService'; +import { SubmitHandler } from 'react-hook-form'; +import { Tenant } from 'models/tenant'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { useNavigate } from 'react-router-dom'; + +const TenantCreationPage = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const onSubmit: SubmitHandler = async (data) => { + try { + await createTenant(data); + dispatch(openNotification({ text: 'Tenant created successfully!', severity: 'success' })); + navigate('../tenantadmin'); + } catch (error) { + dispatch(openNotification({ text: 'Unknown error while creating tenant', severity: 'error' })); + console.error(error); + } + }; + + const onCancel = () => { + navigate('../tenantadmin'); + }; + + return ( + + + + Create Tenant Instance + + + + Tenant Details + + + + * Required fields + + + + + ); +}; + +export default TenantCreationPage; diff --git a/met-web/src/components/tenantManagement/Detail.tsx b/met-web/src/components/tenantManagement/Detail.tsx new file mode 100644 index 000000000..5da1c1364 --- /dev/null +++ b/met-web/src/components/tenantManagement/Detail.tsx @@ -0,0 +1,377 @@ +import React, { useEffect } from 'react'; +import { Box, Grid, Skeleton } from '@mui/material'; +import { Header1, Header2, BodyText } from 'components/common/Typography/'; +import { ResponsiveContainer, DetailsContainer, Detail } from 'components/common/Layout'; +import { useParams, useNavigate } from 'react-router-dom'; +import { getTenant, deleteTenant } from 'services/tenantService'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { BreadcrumbTrail } from 'components/common/Navigation/Breadcrumb'; +import { Tenant } from 'models/tenant'; +import { Button } from 'components/common/Input/Button'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPenToSquare, faTrashCan, faCopy } from '@fortawesome/pro-regular-svg-icons'; +import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice'; +import LandingPageBanner from 'assets/images/LandingPageBanner.png'; +import { globalFocusVisible } from 'components/common'; + +const TenantDetail = () => { + const [tenant, setTenant] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const navigate = useNavigate(); + const { tenantId } = useParams<{ tenantId: string }>(); + const dispatch = useAppDispatch(); + const penToSquareIcon = ; + const trashCanIcon = ; + const faCopyIcon = ; + + useEffect(() => { + if (!tenantId) { + return; + } + const fetchTenant = () => { + getTenant(tenantId) + .then((returnedTenant) => { + setTenant(returnedTenant); + setLoading(false); + }) + .catch((error) => { + dispatch(openNotification({ text: error.message, severity: 'error' })); + setLoading(false); + }); + }; + fetchTenant(); + }, [tenantId, dispatch]); + + if (loading || !tenant) { + return ( + + + + Loading... + + + + + Loading... + + + + + + + + + + + Loading... + + + Loading... + + + + + + Loading... + + + Loading... + + + + + + Loading... + + + Loading... + + + + + + Loading... + + + Loading... + + + + + + Loading... + + + Loading... + + + + + + + Loading... + + + Loading... + + + Loading... + + + Loading... + + + + + + + + + + + ); + } + + const handleDeleteTenant = async (tenantId: string) => { + try { + await deleteTenant(tenantId); + dispatch( + openNotification({ + severity: 'success', + text: `Tenant "${tenantId}" successfully deleted`, + }), + ); + navigate('../tenantadmin'); + } catch (error) { + console.log(error); + dispatch( + openNotification({ + severity: 'error', + text: `Error occurred while trying to delete tenant "${tenantId}"`, + }), + ); + } + }; + + const handleDeleteClick = (tenant: Tenant) => { + dispatch( + openNotificationModal({ + open: true, + data: { + style: 'danger', + header: 'Delete Tenant Instance?', + subHeader: `Are you sure you want to delete "${tenant.name}"?`, + subText: [ + { + text: 'If you delete this tenant, all the data associated with it will be deleted. This action cannot be undone.', + }, + ], + handleConfirm: () => { + handleDeleteTenant(tenant.short_name); + }, + }, + type: 'confirm', + }), + ); + }; + + const copyEmail = () => { + navigator.clipboard.writeText(tenant.contact_email ?? ''); + dispatch(openNotification({ text: 'Copied to clipboard', severity: 'info' })); + }; + + return ( + + + {tenant.name} + + + Tenant Details + + + + + + + + + + + Tenant Instance Name + {tenant.name} + + + + + + + + Primary Contact + + + {tenant.contact_name} + + ) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + copyEmail(); + } + }} + > + + {tenant.contact_email} {faCopyIcon} + + + + + + + + + + + Short Name + {tenant.short_name} + + + + + + + + Hero Banner Title + {tenant.title} + + + + + + + + Hero Banner Description + {tenant.description} + + + + + + + + Hero Banner Image +
+ + {tenant.logo_credit && ( + + Photo Credit + {tenant.logo_credit} + + )} + {tenant.logo_description && ( + + Description + {tenant.logo_description} + + )} + + + + + + + + + + + ); +}; + +export default TenantDetail; diff --git a/met-web/src/components/tenantManagement/Edit.tsx b/met-web/src/components/tenantManagement/Edit.tsx new file mode 100644 index 000000000..301e1580f --- /dev/null +++ b/met-web/src/components/tenantManagement/Edit.tsx @@ -0,0 +1,93 @@ +import React, { useEffect } from 'react'; + +import { Grid } from '@mui/material'; +import { ResponsiveContainer } from 'components/common/Layout'; +import { Header1, Header2, BodyText } from 'components/common/Typography/'; +import { BreadcrumbTrail } from 'components/common/Navigation'; +import { TenantForm } from './TenantForm'; +import { updateTenant, getTenant } from 'services/tenantService'; +import { SubmitHandler } from 'react-hook-form'; +import { Tenant } from 'models/tenant'; +import { useAppDispatch } from 'hooks'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { useNavigate, useParams } from 'react-router-dom'; +import NotFound from 'routes/NotFound'; +import { MidScreenLoader } from 'components/common'; + +const TenantEditPage = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + const { tenantShortName: shortName } = useParams<{ tenantShortName: string }>(); + const [tenant, setTenant] = React.useState(); + const [loading, setLoading] = React.useState(true); + + useEffect(() => { + const fetchTenant = async () => { + if (!shortName) { + setLoading(false); + return; + } + try { + const tenant = await getTenant(shortName); + setTenant(tenant); + setLoading(false); + } catch (error) { + setLoading(false); + } + }; + + fetchTenant(); + }, [shortName, dispatch]); + + if (loading) { + return ; + } + + if (!tenant || !shortName) { + return ; + } + + const onSubmit: SubmitHandler = async (data) => { + try { + await updateTenant(data, shortName); + dispatch(openNotification({ text: 'Tenant updated successfully!', severity: 'success' })); + navigate(`../tenantadmin/${shortName}/detail`); + } catch (error) { + dispatch(openNotification({ text: 'Unknown error while saving tenant', severity: 'error' })); + console.error(error); + } + }; + + const onCancel = () => { + navigate(`../tenantadmin/${shortName}/detail`); + }; + + return ( + + + + Edit Tenant Instance + + + + Tenant Details + + + + * Required fields + + + + + ); +}; + +export default TenantEditPage; diff --git a/met-web/src/components/tenantManagement/Listing.tsx b/met-web/src/components/tenantManagement/Listing.tsx index c5f5f1d58..bea58e5f2 100644 --- a/met-web/src/components/tenantManagement/Listing.tsx +++ b/met-web/src/components/tenantManagement/Listing.tsx @@ -1,4 +1,4 @@ -import { faCirclePlus } from '@fortawesome/pro-regular-svg-icons'; +import { faPlus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Box, Grid, Skeleton } from '@mui/material'; import { Button } from 'components/common/Input/Button'; @@ -21,12 +21,14 @@ import { Else, If, Then } from 'react-if'; import { BreadcrumbTrail } from 'components/common/Navigation/Breadcrumb'; import { useAppDispatch } from 'hooks'; import { openNotification } from 'services/notificationService/notificationSlice'; +import { useNavigate } from 'react-router-dom'; -const TenantListing = () => { +const TenantListingPage = () => { const [tenants, setTenants] = React.useState([]); const [loading, setLoading] = React.useState(true); const dispatch = useAppDispatch(); - const circlePlusIcon = ; + const navigate = useNavigate(); + const circlePlusIcon = ; useEffect(() => { const fetchTenants = () => { getAllTenants() @@ -55,9 +57,14 @@ const TenantListing = () => { Tenant Instances {!loading && `(${tenants.length})`} - {/* TODO: redirect to "Create Tenant Instance" page */} - @@ -99,7 +106,13 @@ const TenantListing = () => { {tenants.map((tenant) => ( { - return; + navigate(`/tenantadmin/${tenant.short_name}/detail`); + }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + navigate(`/tenantadmin/${tenant.short_name}/detail`); + } }} key={tenant.name} tabIndex={0} @@ -108,11 +121,10 @@ const TenantListing = () => { {tenant.name} - {/* TODO: Replace when primary contact info is added to tenants */} - <Primary Contact> + {tenant.contact_name} - {tenant.description} + {tenant.description} ))} @@ -125,4 +137,4 @@ const TenantListing = () => { ); }; -export default TenantListing; +export default TenantListingPage; diff --git a/met-web/src/components/tenantManagement/TenantForm.tsx b/met-web/src/components/tenantManagement/TenantForm.tsx new file mode 100644 index 000000000..f5c49504b --- /dev/null +++ b/met-web/src/components/tenantManagement/TenantForm.tsx @@ -0,0 +1,387 @@ +import React, { useEffect, useState } from 'react'; +import { Box, FormGroup } from '@mui/material'; +import { Button, TextAreaField, TextField } from 'components/common/Input'; +import { DetailsContainer, Detail } from 'components/common/Layout'; +import { BodyText } from 'components/common/Typography/'; +import ImageUpload from 'components/imageUpload'; +import { Tenant } from 'models/tenant'; +import { Controller, useForm, SubmitHandler } from 'react-hook-form'; +import { saveObject } from 'services/objectStorageService'; +import { UploadGuidelines } from 'components/imageUpload/UploadGuidelines'; +import { getAllTenants } from 'services/tenantService'; +import { useAppDispatch } from 'hooks'; +import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice'; + +export const TenantForm = ({ + initialTenant, + onSubmit, + submitText = 'Save', + onCancel, + cancelText = 'Cancel', +}: { + initialTenant?: Tenant; + onSubmit: (data: Tenant) => void; + submitText?: string; + onCancel?: () => void; + cancelText?: string; +}) => { + const [bannerImage, setBannerImage] = useState(); + const [savedBannerImageFileName, setSavedBannerImageFileName] = useState(initialTenant?.logo_url ?? ''); + const [tenantShortNames, setTenantShortNames] = useState([]); + const dispatch = useAppDispatch(); + + useEffect(() => { + getAllTenants().then((tenants) => + setTenantShortNames(tenants.map((tenant) => tenant.short_name.toLowerCase())), + ); + }, []); + + const { handleSubmit, formState, control, reset, setValue, watch } = useForm({ + defaultValues: { + ...(initialTenant || { + name: '', + contact_name: '', + contact_email: '', + short_name: '', + title: '', + description: '', + logo_url: '', + logo_credit: '', + logo_description: '', + }), + }, + mode: 'onBlur', + reValidateMode: 'onChange', + }); + const { isDirty, isValid, errors } = formState; + + const hasLogoUrl = watch('logo_url'); + + useEffect(() => { + reset({ + ...(initialTenant || { + name: '', + contact_name: '', + contact_email: '', + short_name: '', + title: '', + description: '', + logo_url: '', + logo_credit: '', + logo_description: '', + }), + }); + setSavedBannerImageFileName(initialTenant?.logo_url ?? ''); + setValue('logo_url', initialTenant?.logo_url ?? ''); + }, [initialTenant, reset]); + + const handleAddHeroImage = (files: File[]) => { + if (files.length > 0) { + setBannerImage(files[0]); + setSavedBannerImageFileName(files[0].name); + setValue('logo_url', files[0].name, { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + return; + } + + setBannerImage(null); + setSavedBannerImageFileName(''); + setValue('logo_url', '', { shouldValidate: true, shouldDirty: true, shouldTouch: true }); + }; + + const handleUploadHeroImage = async () => { + if (!bannerImage) { + return savedBannerImageFileName; + } + try { + const savedDocumentDetails = await saveObject(bannerImage, { filename: bannerImage.name }); + return savedDocumentDetails?.filepath || ''; + } catch (error) { + console.log(error); + throw new Error('Error occurred during banner image upload'); + } + }; + + const onFormSubmit: SubmitHandler = async (data) => { + try { + data.logo_url = await handleUploadHeroImage(); + if (!data.logo_url) { + data.logo_credit = ''; + data.logo_description = ''; + } + onSubmit(data); + } catch (error) { + console.error(error); + } + }; + + const handleCancelAttempt = () => { + if (isDirty) { + dispatch( + openNotificationModal({ + open: true, + data: { + style: 'warning', + header: 'Unsaved Changes', + subHeader: + 'If you leave this page, your changes will not be saved. Are you sure you want to leave this page?', + subText: [], + confirmButtonText: 'Leave', + cancelButtonText: 'Stay', + handleConfirm: onCancel, + }, + type: 'confirm', + }), + ); + } else { + onCancel?.(); + } + }; + + const handleKeys = (event: React.KeyboardEvent) => { + // Handle as many key combinations as possible + if ((event.ctrlKey || event.metaKey || event.altKey) && event.key === 'Enter') { + event.nativeEvent.stopImmediatePropagation(); + event.preventDefault(); // Prevent default to stop any native form submission + handleSubmit(onFormSubmit)(); + } + }; + + return ( + + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + + tenantShortNames.includes(value.toLowerCase()) && + value.toLowerCase() !== initialTenant?.short_name.toLowerCase() + ? 'This short name is already in use' + : true, + }} + render={({ field }) => ( + + met.gov.bc.ca/ + + } + /> + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + + Hero Banner Image + + + If you do not add a hero banner image, a default image will be used. + +
+ + Image Guidance + + + The image you upload will be displayed at the top of your home page as a decorative element + behind the page title and description. When choosing an image keep in mind that much of the left + side of your image will be covered by this text and will not be visible. + +
+ + + + + ( + + )} + /> + ( + + )} + /> +
+ + + {onCancel && ( + + )} + +
+
+ ); +}; diff --git a/met-web/src/models/tenant.ts b/met-web/src/models/tenant.ts index 254303979..5175c8290 100644 --- a/met-web/src/models/tenant.ts +++ b/met-web/src/models/tenant.ts @@ -1,7 +1,12 @@ export interface Tenant { - name: string; + name?: string; + title: string; description?: string; - title?: string; logo_url?: string; basename?: string; + contact_name?: string; + contact_email?: string; + short_name: string; + logo_credit?: string; + logo_description?: string; } diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index e07a8f4b4..d3f55739d 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -25,7 +25,10 @@ import UserProfile from 'components/userManagement/userDetails'; import ScrollToTop from 'components/scrollToTop'; import ReportSettings from 'components/survey/report'; import FormioListener from 'components/FormioListener'; -import TenantListing from 'components/tenantManagement/Listing'; +import TenantListingPage from 'components/tenantManagement/Listing'; +import TenantCreationPage from 'components/tenantManagement/Create'; +import TenantEditPage from 'components/tenantManagement/Edit'; +import TenantDetail from 'components/tenantManagement/Detail'; import Language from 'components/language'; const AuthenticatedRoutes = () => { @@ -69,7 +72,16 @@ const AuthenticatedRoutes = () => { } /> }> - } /> + } /> + + }> + } /> + + }> + } /> + + }> + } /> }> } /> diff --git a/met-web/src/services/notificationModalService/notificationModalSlice.tsx b/met-web/src/services/notificationModalService/notificationModalSlice.tsx index fac6f25ad..5a75b9934 100644 --- a/met-web/src/services/notificationModalService/notificationModalSlice.tsx +++ b/met-web/src/services/notificationModalService/notificationModalSlice.tsx @@ -3,7 +3,7 @@ import { NotificationModalState } from './types'; const initialState: NotificationModalState = { open: false, - data: { header: '', subText: [], confirmButtonText: 'Confirm', cancelButtonText: 'Cancel' }, + data: { style: 'default', header: '', subText: [], confirmButtonText: 'Confirm', cancelButtonText: 'Cancel' }, type: '', }; diff --git a/met-web/src/services/notificationModalService/types.ts b/met-web/src/services/notificationModalService/types.ts index cc70ead1c..57c7fa15f 100644 --- a/met-web/src/services/notificationModalService/types.ts +++ b/met-web/src/services/notificationModalService/types.ts @@ -1,20 +1,7 @@ -import { ModalSubtext } from 'components/common/Modals/types'; - -export interface ModalProps { - header: string; - subTextArray: ModalSubtext[]; - handleClose: () => void; -} +import { NotificationModalProps } from 'components/common/Modals/types'; export interface NotificationModalState { open: boolean; - data: { - header: string; - subText: ModalSubtext[]; - confirmButtonText?: string; - cancelButtonText?: string; - handleConfirm?: () => void; - handleClose?: () => void; - }; + data: NotificationModalProps; type: string; } diff --git a/met-web/src/services/tenantService/index.tsx b/met-web/src/services/tenantService/index.tsx index 451b26bdf..b43c3302b 100644 --- a/met-web/src/services/tenantService/index.tsx +++ b/met-web/src/services/tenantService/index.tsx @@ -19,3 +19,29 @@ export const getAllTenants = async (): Promise => { } return Promise.reject(Error('Failed to fetch tenants')); }; + +export const createTenant = async (tenant: Tenant): Promise => { + const response = await http.PostRequest(Endpoints.Tenants.CREATE, tenant); + if (response.data) { + return response.data; + } + return Promise.reject(Error('Failed to create tenant')); +}; + +export const updateTenant = async (tenant: Tenant, shortName: string): Promise => { + const url = replaceUrl(Endpoints.Tenants.UPDATE, 'tenant_id', shortName); + const response = await http.PatchRequest(url, tenant); + if (response.data) { + return response.data; + } + return Promise.reject(Error('Failed to update tenant')); +}; + +export const deleteTenant = async (shortName: string): Promise => { + const url = replaceUrl(Endpoints.Tenants.UPDATE, 'tenant_id', shortName); + const response = await http.DeleteRequest(url); + if (response.data) { + return response.data; + } + return Promise.reject(Error('Failed to delete tenant')); +}; diff --git a/met-web/src/utils/index.ts b/met-web/src/utils/index.ts index aaa6ff31c..7ee2c2994 100644 --- a/met-web/src/utils/index.ts +++ b/met-web/src/utils/index.ts @@ -60,3 +60,24 @@ export const findTenantInPath = () => { const pathSegments = determinePathSegments(); return pathSegments.length > 0 ? pathSegments[0].toLowerCase() : ''; }; + +// Convert hex color to RGB +export const hexToRgb = (hex: string) => { + hex = hex.replace('#', ''); + const bigint = parseInt(hex.slice(1), 16); + const r = (bigint >> 16) & 255; + const g = (bigint >> 8) & 255; + const b = bigint & 255; + return [r, g, b]; +}; + +// Whether a color is dark (i.e. needs an inverted text style) or not +export const isDarkColor = (color: string, sensitivity = 0.5) => { + // Ensure sensitivity is between 0 and 1 + sensitivity = Math.max(0, Math.min(1, sensitivity)); + // Convert the color to RGB + const [r, g, b] = hexToRgb(color); + // Calculate the perceived luminance of the color + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance < sensitivity; +}; diff --git a/met-web/tests/unit/components/factory.ts b/met-web/tests/unit/components/factory.ts index 9fb1623b4..64e63e3de 100644 --- a/met-web/tests/unit/components/factory.ts +++ b/met-web/tests/unit/components/factory.ts @@ -22,6 +22,7 @@ const tenant: Tenant = { name: 'Tenant 1', title: 'Tenant Title', description: 'Tenant Description', + short_name: 'tenant1', }; const survey: Survey = { diff --git a/met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx b/met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx new file mode 100644 index 000000000..f30facee7 --- /dev/null +++ b/met-web/tests/unit/components/tenantManagement/CreateTenant.test.tsx @@ -0,0 +1,227 @@ +import React, { ReactNode } from 'react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as reactRedux from 'react-redux'; +import * as reactRouter from 'react-router'; +import * as tenantService from 'services/tenantService'; +import TenantCreationPage from 'components/tenantManagement/Create'; +import { USER_ROLES } from 'services/userService/constants'; + +const mockTenant = { + id: 1, + name: 'Tenant One', + title: 'Title One', + description: 'Description One', + contact_name: 'Contact One', + short_name: 'tenantone', + contact_email: 'contactone@example.com', + logo_url: 'https://example.com/logo.png', + logo_credit: 'Photographer One', + logo_description: 'Logo Description One', +}; + +jest.mock('axios'); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Grid: ({ children }: { children: ReactNode }) =>
{children}
, + Skeleton: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock('components/common/Typography/', () => ({ + Header1: ({ children }: { children: ReactNode }) =>

{children}

, + Header2: ({ children }: { children: ReactNode }) =>

{children}

, + BodyText: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +jest.mock('components/common/Layout', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) =>
{children}
, + DetailsContainer: ({ children }: { children: ReactNode }) =>
{children}
, + Detail: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.SUPER_ADMIN], + }; + }), + useDispatch: jest.fn(), +})); + +const navigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => { + return { tenantShortName: mockTenant.short_name }; + }), + useNavigate: jest.fn(() => navigate), +})); + +jest.mock('services/tenantService', () => ({ + getAllTenants: jest.fn(), + createTenant: jest.fn(), +})); + +jest.mock('services/notificationService/notificationSlice', () => ({ + openNotification: jest.fn(), +})); + +let capturedNotification: any; +jest.mock('services/notificationModalService/notificationModalSlice', () => ({ + openNotificationModal: jest.fn((notification: any) => { + capturedNotification = notification; + }), +})); + +// Mocking BreadcrumbTrail component +jest.mock('components/common/Navigation/Breadcrumb', () => ({ + BreadcrumbTrail: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +describe('Tenant Detail Page tests', () => { + const dispatch = jest.fn(); + + const editField = async (placeholder: string, value: string) => { + const field = screen.getByPlaceholderText(placeholder) as HTMLInputElement; + field.focus(); + field.setSelectionRange(0, field.value.length); + fireEvent.change(field, { target: { value } }); + fireEvent.blur(field); // Trigger validation + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(dispatch); + jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(navigate); + jest.spyOn(tenantService, 'getAllTenants').mockResolvedValue([mockTenant]); + jest.spyOn(tenantService, 'createTenant').mockResolvedValue(mockTenant); + render(); + }); + + test('Tenant creation page is rendered', async () => { + await waitFor(() => { + expect(screen.getByText('Create Tenant Instance')).toBeVisible(); + expect(screen.getByText('Tenant Details')).toBeVisible(); + expect(screen.getByText('* Required fields')).toBeVisible(); + }); + + // The page should be fetching the tenant data to validate the short name + expect(tenantService.getAllTenants).toHaveBeenCalledTimes(1); + + // Check that the form isn't pre-filled + await waitFor(() => { + const fields = screen.getAllByRole('textbox'); + expect(fields).toHaveLength(8); + expect(screen.getByPlaceholderText('Name')).toContainValue(''); + expect(screen.getByPlaceholderText('Full Name')).toContainValue(''); + expect(screen.getByPlaceholderText('Email')).toContainValue(''); + expect(screen.getByPlaceholderText('shortname')).toContainValue(''); + expect(screen.getByPlaceholderText('Title')).toContainValue(''); + expect(screen.getByPlaceholderText('Description')).toContainValue(''); + expect(screen.getByText('Drag and drop your image here.')).toBeVisible(); + expect(screen.getByTestId('tenant-form/image-credit')).toContainValue(''); + expect(screen.getByTestId('tenant-form/image-description')).toContainValue(''); + }); + + // Check that the buttons are visible + expect(screen.getByText('Create Instance')).toBeVisible(); + // Button should be disabled until form is completed + expect(screen.getByText('Create Instance')).toBeDisabled(); + expect(screen.getByText('Cancel')).toBeVisible(); + }); + + test('Button is enabled after form is filled', async () => { + await waitFor(() => { + editField('Name', 'New Name'); + editField('Full Name', 'New Full Name'); + editField('Email', 'contactone@example.com'); + editField('shortname', 'newname'); + editField('Title', 'New Title'); + editField('Description', 'New Description'); + expect(screen.getByText('Create Instance')).toBeEnabled(); + }); + }); + + test('Email throws error if invalid', async () => { + await waitFor(() => { + editField('Email', 'invalid-email'); + expect(screen.getByText("That doesn't look like a valid email...")).toBeVisible(); + expect(screen.getByText('Create Instance')).toBeDisabled(); + }); + }); + + test('Short name throws error if invalid', async () => { + await waitFor(() => { + editField('shortname', 'invalid shortname'); + expect(screen.getByText('Your input contains invalid symbols')).toBeVisible(); + expect(screen.getByText('Create Instance')).toBeDisabled(); + }); + }); + + test('Character limit is enforced on fields', async () => { + await waitFor(() => { + editField('Title', 'a'.repeat(256)); + expect(screen.getByText('This input is too long!')).toBeVisible(); + expect(screen.getByText('Create Instance')).toBeDisabled(); + }); + }); + + test('Unique short name is enforced', async () => { + await waitFor(() => { + editField('shortname', mockTenant.short_name); + expect(screen.getByText('This short name is already in use')).toBeVisible(); + expect(screen.getByText('Create Instance')).toBeDisabled(); + }); + }); + + test('Cancel button navigates back to tenant listing page', async () => { + await waitFor(() => { + fireEvent.click(screen.getByText('Cancel')); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(`../tenantadmin`); + }); + }); + + test('User is prompted for confirmation when navigating with unsaved changes', async () => { + await waitFor(() => { + editField('Name', 'New Name'); + fireEvent.click(screen.getByText('Cancel')); + }); + await waitFor(() => { + expect(capturedNotification).toBeDefined(); + expect(capturedNotification.data.header).toBe('Unsaved Changes'); + expect(capturedNotification.data.handleConfirm).toBeDefined(); + }); + capturedNotification.data.handleConfirm(); + expect(navigate).toHaveBeenCalledTimes(1); + }); + + test('Create instance button calls createTenant action', async () => { + await waitFor(() => { + editField('Name', 'New Name'); + editField('Full Name', 'New Full Name'); + editField('Email', 'contactone@example.com'); + editField('shortname', 'newname'); + editField('Title', 'New Title'); + editField('Description', 'New Description'); + expect(screen.getByText('Create Instance')).toBeEnabled(); + fireEvent.click(screen.getByText('Create Instance')); + const updatedTenant = { + name: 'New Name', + title: 'New Title', + description: 'New Description', + contact_name: 'New Full Name', + contact_email: 'contactone@example.com', + short_name: 'newname', + logo_url: '', + logo_credit: '', + logo_description: '', + }; + expect(tenantService.createTenant).toHaveBeenCalledWith(updatedTenant); + }); + }); +}); diff --git a/met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx b/met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx new file mode 100644 index 000000000..148e6fee3 --- /dev/null +++ b/met-web/tests/unit/components/tenantManagement/EditTenant.test.tsx @@ -0,0 +1,202 @@ +import React, { ReactNode } from 'react'; +import { render, waitFor, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as reactRedux from 'react-redux'; +import * as reactRouter from 'react-router'; +import * as tenantService from 'services/tenantService'; +import TenantEditPage from 'components/tenantManagement/Edit'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { USER_ROLES } from 'services/userService/constants'; + +const mockTenant = { + id: 1, + name: 'Tenant One', + title: 'Title One', + description: 'Description One', + contact_name: 'Contact One', + short_name: 'tenantone', + contact_email: 'contactone@example.com', + logo_url: 'https://example.com/logo.png', + logo_credit: 'Photographer One', + logo_description: 'Logo Description One', +}; + +jest.mock('axios'); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Grid: ({ children }: { children: ReactNode }) =>
{children}
, + Skeleton: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock('components/common/Typography/', () => ({ + Header1: ({ children }: { children: ReactNode }) =>

{children}

, + Header2: ({ children }: { children: ReactNode }) =>

{children}

, + BodyText: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +jest.mock('components/common/Layout', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) =>
{children}
, + DetailsContainer: ({ children }: { children: ReactNode }) =>
{children}
, + Detail: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.SUPER_ADMIN], + }; + }), + useDispatch: jest.fn(), +})); + +const navigate = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => { + return { tenantShortName: mockTenant.short_name }; + }), + useNavigate: jest.fn(() => navigate), +})); + +jest.mock('services/tenantService', () => ({ + getTenant: jest.fn(), + getAllTenants: jest.fn(), + updateTenant: jest.fn(), +})); + +jest.mock('services/notificationService/notificationSlice', () => ({ + openNotification: jest.fn(), +})); + +jest.mock('services/notificationModalService/notificationModalSlice', () => ({ + openNotificationModal: jest.fn(), +})); + +// Mocking BreadcrumbTrail component +jest.mock('components/common/Navigation/Breadcrumb', () => ({ + BreadcrumbTrail: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +describe('Tenant Detail Page tests', () => { + const dispatch = jest.fn(); + + const editField = (placeholder: string, value: string) => { + const field = screen.getByPlaceholderText(placeholder) as HTMLInputElement; + field.focus(); + field.setSelectionRange(0, field.value.length); + fireEvent.change(field, { target: { value } }); + fireEvent.blur(field); // Trigger validation + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(dispatch); + jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(navigate); + jest.spyOn(tenantService, 'getAllTenants').mockResolvedValue([mockTenant]); + jest.spyOn(tenantService, 'getTenant').mockResolvedValue(mockTenant); + render( + + + } /> + + , + ); + }); + + test('Loader is displayed while fetching tenant data', async () => { + // Ensure the fetch does not resolve (force the loader to display) + jest.spyOn(tenantService, 'getTenant').mockReturnValue(new Promise(() => {})); + + await waitFor(() => { + expect(screen.getByTestId('loader')).toBeVisible(); + expect(tenantService.getTenant).toHaveBeenCalledTimes(1); + }); + }); + + test('Tenant edit page is rendered', async () => { + await waitFor(() => { + expect(screen.getByText('Edit Tenant Instance')).toBeVisible(); + expect(screen.getByText('Tenant Details')).toBeVisible(); + expect(screen.getByText('* Required fields')).toBeVisible(); + }); + + // The data should already be fetched if the header is displayed + expect(tenantService.getTenant).toHaveBeenCalledTimes(1); + + // Check that the form is populated with the correct data + await waitFor(() => { + const fields = screen.getAllByRole('textbox'); + expect(fields).toHaveLength(8); + expect(screen.getByPlaceholderText('Name')).toContainValue('Tenant One'); + expect(screen.getByPlaceholderText('Full Name')).toContainValue('Contact One'); + expect(screen.getByPlaceholderText('Email')).toContainValue('contactone@example.com'); + expect(screen.getByPlaceholderText('shortname')).toContainValue('tenantone'); + expect(screen.getByPlaceholderText('Title')).toContainValue('Title One'); + expect(screen.getByPlaceholderText('Description')).toContainValue('Description One'); + expect(screen.getByTestId('uploaded-image')).toHaveAttribute('src', 'https://example.com/logo.png'); + expect(screen.getByTestId('tenant-form/image-credit')).toContainValue('Photographer One'); + expect(screen.getByTestId('tenant-form/image-description')).toContainValue('Logo Description One'); + }); + + // Check that the buttons are visible + expect(screen.getByText('Update')).toBeVisible(); + expect(screen.getByText('Update')).toBeDisabled(); // Button should be disabled until form is edited + expect(screen.getByText('Cancel')).toBeVisible(); + }); + + test('Button is enabled after form is edited', async () => { + await waitFor(() => { + editField('Name', 'New Name'); + expect(screen.getByText('Update')).not.toBeDisabled(); + }); + }); + + test('Email throws error if invalid', async () => { + await waitFor(() => { + editField('Email', 'invalid-email'); + expect(screen.getByText("That doesn't look like a valid email...")).toBeVisible(); + expect(screen.getByText('Update')).toBeDisabled(); + }); + }); + + test('Short name throws error if invalid', async () => { + await waitFor(() => { + editField('shortname', 'invalid shortname'); + expect(screen.getByText('Your input contains invalid symbols')).toBeVisible(); + expect(screen.getByText('Update')).toBeDisabled(); + }); + }); + + test('Character limit is enforced on fields', async () => { + await waitFor(() => { + editField('Title', 'a'.repeat(256)); + expect(screen.getByText('This input is too long!')).toBeVisible(); + expect(screen.getByText('Update')).toBeDisabled(); + }); + }); + + test('Cancel button navigates back to tenant details page', async () => { + await waitFor(() => { + fireEvent.click(screen.getByText('Cancel')); + expect(navigate).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledWith(`../tenantadmin/${mockTenant.short_name}/detail`); + }); + }); + + test('Update button calls updateTenant action', async () => { + await waitFor(() => { + editField('Name', 'New Name'); + fireEvent.click(screen.getByText('Update')); + expect(tenantService.updateTenant).toHaveBeenCalledTimes(1); + const updatedTenant = { + ...mockTenant, + name: 'New Name', + }; + expect(tenantService.updateTenant).toHaveBeenCalledWith(updatedTenant, mockTenant.short_name); + }); + }); +}); diff --git a/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx b/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx new file mode 100644 index 000000000..715dce718 --- /dev/null +++ b/met-web/tests/unit/components/tenantManagement/TenantDetail.test.tsx @@ -0,0 +1,155 @@ +import React, { ReactNode } from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import * as reactRedux from 'react-redux'; +import * as reactRouter from 'react-router'; +import * as tenantService from 'services/tenantService'; +import TenantDetail from '../../../../src/components/tenantManagement/Detail'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { USER_ROLES } from 'services/userService/constants'; +import { openNotificationModal } from 'services/notificationModalService/notificationModalSlice'; + +const mockTenant = { + id: 1, + name: 'Tenant One', + title: 'Title One', + description: 'Description One', + contact_name: 'Contact One', + short_name: 'tenantone', + contact_email: 'contactone@example.com', + logo_url: 'https://example.com/logo.png', + logo_credit: 'Photographer One', + logo_description: 'Logo Description One', +}; + +jest.mock('axios'); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Box: ({ children }: { children: ReactNode }) =>
{children}
, + Grid: ({ children }: { children: ReactNode }) =>
{children}
, + Skeleton: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock('components/common/Typography/', () => ({ + Header1: ({ children }: { children: ReactNode }) =>

{children}

, + Header2: ({ children }: { children: ReactNode }) =>

{children}

, + BodyText: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +jest.mock('components/common/Layout', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) =>
{children}
, + DetailsContainer: ({ children }: { children: ReactNode }) =>
{children}
, + Detail: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.SUPER_ADMIN], + }; + }), + useDispatch: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => { + return { tenantId: mockTenant.short_name }; + }), + useNavigate: jest.fn(), +})); + +jest.mock('services/tenantService', () => ({ + getTenant: jest.fn(), + deleteTenant: jest.fn(), +})); + +jest.mock('services/notificationService/notificationSlice', () => ({ + openNotification: jest.fn(), +})); + +let capturedNotification: any; +jest.mock('services/notificationModalService/notificationModalSlice', () => ({ + openNotificationModal: jest.fn((notification: any) => { + capturedNotification = notification; + }), +})); + +// Mocking BreadcrumbTrail component +jest.mock('components/common/Navigation/Breadcrumb', () => ({ + BreadcrumbTrail: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +describe('Tenant Detail Page tests', () => { + const dispatch = jest.fn(); + const navigate = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(reactRedux, 'useDispatch').mockReturnValue(dispatch); + jest.spyOn(reactRouter, 'useNavigate').mockReturnValue(navigate); + jest.spyOn(tenantService, 'getTenant').mockResolvedValue(mockTenant); + }); + + test('Tenant detail is rendered', async () => { + render( + + + } /> + + , + ); + + await waitFor(() => { + const tenantNames = screen.getAllByText('Tenant One'); + expect(tenantNames).toHaveLength(2); + expect(screen.getByText('Title One')).toBeVisible(); + expect(screen.getByText('Description One')).toBeVisible(); + expect(screen.getByText('Contact One')).toBeVisible(); + expect(screen.getByText('contactone@example.com')).toBeVisible(); + expect(screen.getByText('Photographer One')).toBeVisible(); + expect(screen.getByText('Logo Description One')).toBeVisible(); + + expect(screen.getByText('Edit')).toBeVisible(); + expect(screen.getByText('Delete Tenant Instance')).toBeVisible(); + }); + }); + + test('Loading state is rendered initially', () => { + jest.spyOn(tenantService, 'getTenant').mockReturnValue(new Promise(() => {})); // Mock unresolved promise + + render( + + + } /> + + , + ); + + const loadingTexts = screen.getAllByText('Loading...'); + expect(loadingTexts.length).toBeGreaterThan(0); + }); + + test('Delete popup works as expected', async () => { + render( + + + } /> + + , + ); + + await waitFor(() => { + screen.getByText('Delete Tenant Instance').click(); + }); + await waitFor(() => { + expect(openNotificationModal).toHaveBeenCalledTimes(1); + expect(capturedNotification.data.header).toBe('Delete Tenant Instance?'); + capturedNotification.data.handleConfirm(); + // Test that the deleteTenant function was called + expect(tenantService.deleteTenant).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/met-web/tests/unit/components/tenantManagement/TenantListing.test.tsx b/met-web/tests/unit/components/tenantManagement/TenantListing.test.tsx new file mode 100644 index 000000000..4f35d4a2b --- /dev/null +++ b/met-web/tests/unit/components/tenantManagement/TenantListing.test.tsx @@ -0,0 +1,89 @@ +import React, { ReactNode } from 'react'; +import { render, waitFor, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { setupEnv } from '../setEnvVars'; +import * as reactRedux from 'react-redux'; +import * as reactRouter from 'react-router'; +import * as tenantService from 'services/tenantService'; +import TenantListingPage from '../../../../src/components/tenantManagement/Listing'; +import { USER_ROLES } from 'services/userService/constants'; +import { MemoryRouter } from 'react-router-dom'; + +const mockTenantOne = { + id: 1, + name: 'Tenant One', + title: 'Title One', + description: 'Description One', + contact_name: 'Contact One', + short_name: 'tenantone', +}; + +const mockTenantTwo = { + id: 2, + name: 'Tenant Two', + title: 'Title Two', + description: 'Description Two', + contact_name: 'Contact Two', + short_name: 'tenanttwo', +}; + +jest.mock('axios'); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Link: ({ children }: { children: ReactNode }) => { + return {children}; + }, +})); + +jest.mock('components/common', () => ({ + ...jest.requireActual('components/common'), + PrimaryButtonOld: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => { + return ; + }, +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.SUPER_ADMIN], + }; + }), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useLocation: jest.fn(() => ({ + search: '', + })), +})); + +describe('Tenant Listing Page tests', () => { + jest.spyOn(reactRedux, 'useSelector').mockImplementation(() => jest.fn()); + jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); + jest.spyOn(reactRouter, 'useNavigate').mockImplementation(() => jest.fn()); + jest.spyOn(tenantService, 'getAllTenants').mockReturnValue(Promise.resolve([mockTenantOne, mockTenantTwo])); + + beforeEach(() => { + setupEnv(); + }); + + test('Tenant table is rendered', async () => { + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText('Tenant One')).toBeVisible(); + expect(screen.getByText('Tenant Two')).toBeVisible(); + expect(screen.getByText('Description One')).toBeVisible(); + expect(screen.getByText('Description Two')).toBeVisible(); + + expect(screen.getByText('Add Tenant')).toBeVisible(); + }); + }); +});