diff --git a/met-api/migrations/versions/c37ea4837943_create_image_info_table.py b/met-api/migrations/versions/c37ea4837943_create_image_info_table.py new file mode 100644 index 000000000..ab851d0db --- /dev/null +++ b/met-api/migrations/versions/c37ea4837943_create_image_info_table.py @@ -0,0 +1,40 @@ +"""create image info table + +Revision ID: c37ea4837943 +Revises: a3e6dae331ab +Create Date: 2024-08-01 15:21:53.966495 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = 'c37ea4837943' +down_revision = 'a3e6dae331ab' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('image_info', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('unique_name', sa.String()), + sa.Column('display_name', sa.String()), + sa.Column('date_uploaded', sa.DateTime()), + sa.Column('tenant_id', sa.Integer(), nullable=True), + sa.Column('created_date', sa.DateTime()), + sa.Column('updated_date', sa.DateTime()), + sa.Column('created_by', sa.String(length=50), nullable=True), + sa.Column('updated_by', sa.String(length=50), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenant.id'], ondelete='SET NULL'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('image_info') + # ### end Alembic commands ### diff --git a/met-api/src/met_api/models/image_info.py b/met-api/src/met_api/models/image_info.py new file mode 100644 index 000000000..1247f2961 --- /dev/null +++ b/met-api/src/met_api/models/image_info.py @@ -0,0 +1,52 @@ +"""ImageInfo model class. + +Manages the ImageInfo +""" + +from sqlalchemy import asc, desc +from sqlalchemy.sql import text + +from met_api.models import db +from met_api.models.base_model import BaseModel +from met_api.models.pagination_options import PaginationOptions + + +class ImageInfo(BaseModel): + """Definition of the ImageInfo entity.""" + + __tablename__ = 'image_info' + id = db.Column(db.Integer, primary_key=True, autoincrement=True) + unique_name = db.Column(db.String()) + display_name = db.Column(db.String()) + date_uploaded = db.Column(db.DateTime) + tenant_id = db.Column(db.Integer, db.ForeignKey('tenant.id'), nullable=True) + created_date = db.Column(db.DateTime) + updated_date = db.Column(db.DateTime) + + @classmethod + def get_images_paginated(cls, pagination_options: PaginationOptions, search_options=None): + """Get images paginated.""" + query = db.session.query(ImageInfo) + + query = cls._add_tenant_filter(query) + + if search_options: + query = cls._filter_by_search_text(query, search_options) + + sort = cls._get_sort_order(pagination_options) + query = query.order_by(sort) + + page = query.paginate(page=pagination_options.page, per_page=pagination_options.size) + return page.items, page.total + + @staticmethod + def _filter_by_search_text(query, search_options): + if search_text := search_options.get('search_text'): + query = query.filter(ImageInfo.display_name.ilike('%' + search_text + '%')) + return query + + @staticmethod + def _get_sort_order(pagination_options): + sort = asc(text(pagination_options.sort_key)) if pagination_options.sort_order == 'asc' \ + else desc(text(pagination_options.sort_key)) + return sort diff --git a/met-api/src/met_api/resources/__init__.py b/met-api/src/met_api/resources/__init__.py index 2451f6c11..874590e80 100644 --- a/met-api/src/met_api/resources/__init__.py +++ b/met-api/src/met_api/resources/__init__.py @@ -49,6 +49,7 @@ from .widget_video import API as WIDGET_VIDEO_API from .engagement_settings import API as ENGAGEMENT_SETTINGS_API from .cac_form import API as CAC_FORM_API +from .image_info import API as IMAGE_INFO __all__ = ('API_BLUEPRINT',) @@ -79,6 +80,7 @@ API.add_namespace(ENGAGEMENT_METADATA_API) API.add_namespace(SHAPEFILE_API) API.add_namespace(TENANT_API) +API.add_namespace(IMAGE_INFO) API.add_namespace(ENGAGEMENT_MEMBERS_API, path='/engagements//members') API.add_namespace(WIDGET_DOCUMENTS_API, path='/widgets//documents') API.add_namespace(WIDGET_EVENTS_API, path='/widgets//events') diff --git a/met-api/src/met_api/resources/image_info.py b/met-api/src/met_api/resources/image_info.py new file mode 100644 index 000000000..ac3712876 --- /dev/null +++ b/met-api/src/met_api/resources/image_info.py @@ -0,0 +1,79 @@ +# Copyright © 2021 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an image uploads resource.""" + +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.models.pagination_options import PaginationOptions +from met_api.schemas.image_info import ImageInfoParameterSchema, ImageInfoSchema +from met_api.services.image_info_service import ImageInfoService +from met_api.utils.roles import Role +from met_api.utils.tenant_validator import require_role +from met_api.utils.util import allowedorigins, cors_preflight + + +API = Namespace('image_info', description='Endpoints for Image Info management') +"""Custom exception messages +""" + + +@cors_preflight('GET, POST, OPTIONS') +@API.route('/') +class ImageInfo(Resource): + """Resource for managing image info.""" + + @staticmethod + @cross_origin(origins=allowedorigins()) + @require_role([Role.CREATE_IMAGES.value]) + def get(): + """Fetch images.""" + try: + args = request.args + + pagination_options = PaginationOptions( + page=args.get('page', None, int), + size=args.get('size', None, int), + sort_key=args.get('sort_key', 'date_uploaded', str), + sort_order=args.get('sort_order', 'desc', str), + ) + + search_options = { + 'search_text': args.get('search_text', '', type=str), + } + + images = ImageInfoService().get_images_paginated(pagination_options, search_options) + return images, HTTPStatus.OK + except ValueError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + + @staticmethod + @cross_origin(origins=allowedorigins()) + @require_role([Role.CREATE_IMAGES.value]) + def post(): + """Create a new image upload.""" + try: + request_json = ImageInfoParameterSchema().load(API.payload) + image_model = ImageInfoService().create_image_info(request_json) + return ImageInfoSchema().dump(image_model), HTTPStatus.OK + except KeyError as err: + return str(err), HTTPStatus.INTERNAL_SERVER_ERROR + except 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/image_info.py b/met-api/src/met_api/schemas/image_info.py new file mode 100644 index 000000000..56dfa2bc8 --- /dev/null +++ b/met-api/src/met_api/schemas/image_info.py @@ -0,0 +1,50 @@ +"""Image Info schema class.""" +from marshmallow import EXCLUDE, Schema, fields +from met_api.services.object_storage_service import ObjectStorageService + + +class ImageInfoSchema(Schema): + """Image Info schema class.""" + + def __init__(self, *args, **kwargs): + """Initialize the Image Info schema class.""" + super().__init__(*args, **kwargs) + self.object_storage = ObjectStorageService() + + class Meta: + """Exclude unknown fields in the deserialized output.""" + + unknown = EXCLUDE + + id = fields.Int(data_key='id') + unique_name = fields.Str(data_key='unique_name', required=True) + display_name = fields.Str(data_key='display_name', required=True) + date_uploaded = fields.DateTime(data_key='date_uploaded') + tenant_id = fields.Str(data_key='tenant_id') + url = fields.Method('get_object_store_url', dump_only=True) + + def get_object_store_url(self, obj): + """Get the image URL from object storage.""" + if obj.unique_name: + return self.object_storage.get_url(obj.unique_name) + else: + return None + + +class ImageInfoParameterSchema(Schema): + """Schema for validating fields upon image info creation.""" + + unique_name = fields.Str( + metadata={'description': 'Unique name of the file'}, + required=True, + ) + + display_name = fields.Str( + metadata={'description': 'Display name of the file'}, + required=True, + ) + + date_uploaded = fields.DateTime( + metadata={'description': 'Date when file was uploaded'}, + required=True, + ) diff --git a/met-api/src/met_api/services/image_info_service.py b/met-api/src/met_api/services/image_info_service.py new file mode 100644 index 000000000..ed4f6d770 --- /dev/null +++ b/met-api/src/met_api/services/image_info_service.py @@ -0,0 +1,40 @@ +"""Service for image management.""" +from met_api.models.pagination_options import PaginationOptions +from met_api.schemas.image_info import ImageInfoSchema +from met_api.services.object_storage_service import ObjectStorageService +from met_api.models.image_info import ImageInfo as ImageInfoModel + + +class ImageInfoService: + """Image Info management service.""" + + def __init__(self): + """Initialize.""" + self.object_storage = ObjectStorageService() + + @staticmethod + def get_images_paginated(pagination_options: PaginationOptions, search_options=None): + """Get images paginated.""" + items, total = ImageInfoModel.get_images_paginated( + pagination_options, + search_options, + ) + + images = ImageInfoSchema(many=True).dump(items) + + return { + 'items': images, + 'total': total + } + + @staticmethod + def create_image_info(request_json: dict): + """Create an Image Info upload.""" + new_image = ImageInfoModel( + unique_name=request_json.get('unique_name', None), + display_name=request_json.get('display_name', None), + date_uploaded=request_json.get('date_uploaded', None), + ) + new_image.save() + new_image.commit() + return new_image.find_by_id(new_image.id) diff --git a/met-api/src/met_api/utils/roles.py b/met-api/src/met_api/utils/roles.py index 957d798c4..ff6297838 100644 --- a/met-api/src/met_api/utils/roles.py +++ b/met-api/src/met_api/utils/roles.py @@ -62,3 +62,4 @@ class Role(Enum): EXPORT_ALL_TO_CSV = 'export_all_to_csv' EXPORT_INTERNAL_COMMENT_SHEET = 'export_internal_comment_sheet' EXPORT_PROPONENT_COMMENT_SHEET = 'export_proponent_comment_sheet' + CREATE_IMAGES = 'create_images' diff --git a/met-api/tests/unit/api/test_images.py b/met-api/tests/unit/api/test_images.py new file mode 100644 index 000000000..f613a58d5 --- /dev/null +++ b/met-api/tests/unit/api/test_images.py @@ -0,0 +1,118 @@ +# 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 to verify the Images API end-point. + +Test-Suite to ensure that the /images endpoint is working as expected. +""" +import copy +import json +from http import HTTPStatus + +import pytest +from faker import Faker + +from met_api.models.tenant import Tenant as TenantModel +from met_api.utils.constants import TENANT_ID_HEADER +from met_api.utils.enums import ContentType +from tests.utilities.factory_scenarios import TestImageInfo, TestJwtClaims, TestTenantInfo +from tests.utilities.factory_utils import factory_auth_header, factory_tenant_model + + +fake = Faker() + + +def test_add_image(client, jwt, session): # pylint:disable=unused-argument + """Assert that an image can be POSTed.""" + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.met_admin_role) + rv = client.post('/api/image_info/', data=json.dumps(TestImageInfo.image_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK.value + + +@pytest.mark.parametrize('role', [TestJwtClaims.no_role, TestJwtClaims.public_user_role]) +def test_add_images_invalid_authorization(client, jwt, session, role): # pylint:disable=unused-argument + """Assert that an image can not be POSTed without authorization.""" + headers = factory_auth_header(jwt=jwt, claims=role) + rv = client.post('/api/image_info/', data=json.dumps(TestImageInfo.image_1), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.UNAUTHORIZED.value + + +@pytest.mark.parametrize('image_data', [ + TestImageInfo.image_missing_unique_name, + TestImageInfo.image_missing_display_name, + TestImageInfo.image_missing_date_uploaded_name]) +def test_add_images_invalid_data(client, jwt, session, image_data): # pylint:disable=unused-argument + """Assert that an image can not be POSTed with incorrect data.""" + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.met_admin_role) + rv = client.post('/api/image_info/', data=json.dumps(image_data), + headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.INTERNAL_SERVER_ERROR.value + + +def test_get_images(client, jwt, session): # pylint:disable=unused-argument + """Assert that all images can be fetched.""" + headers = factory_auth_header(jwt=jwt, claims=TestJwtClaims.met_admin_role) + rv = client.get('/api/image_info/', headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.OK.value + + +@pytest.mark.parametrize('role', [TestJwtClaims.no_role, TestJwtClaims.public_user_role]) +def test_get_images_invalid_authorization(client, jwt, session, role): # pylint:disable=unused-argument + """Assert that all images can not be fetched without proper authorization.""" + headers = factory_auth_header(jwt=jwt, claims=role) + rv = client.get('/api/image_info/', headers=headers, content_type=ContentType.JSON.value) + assert rv.status_code == HTTPStatus.UNAUTHORIZED.value + + +def test_cannot_get_images_with_different_tenant_ids(client, jwt, session): # pylint:disable=unused-argument + """Assert that a user from tenant 1 cannot see images from tenant 2.""" + tenant_1 = TestTenantInfo.tenant1 # Create tenant 1 + factory_tenant_model(tenant_1) + tenant_1_short_name = tenant_1.value['short_name'] + tenant_1 = TenantModel.find_by_short_name(tenant_1_short_name) + assert tenant_1 is not None + + tenant_2 = TestTenantInfo.tenant2 # Create tenant 2 + factory_tenant_model(tenant_2) + tenant_2_short_name = tenant_2.value['short_name'] + tenant_2 = TenantModel.find_by_short_name(tenant_2_short_name) + assert tenant_2 is not None + + user_1 = copy.deepcopy(TestJwtClaims.met_admin_role.value) # Create a user for tenant 1 + user_1['tenant_id'] = tenant_1.id + + user_2 = copy.deepcopy(TestJwtClaims.met_admin_role.value) # Create a user for tenant 2 + user_2['tenant_id'] = tenant_2.id + + session.commit() + + headers = factory_auth_header(jwt=jwt, claims=user_1) + headers[TENANT_ID_HEADER] = tenant_1_short_name + rv = client.post('/api/image_info/', data=json.dumps(TestImageInfo.image_1), + headers=headers, content_type=ContentType.JSON.value) + response_tenant_id = rv.json.get('tenant_id') + user_tenant_id = user_1.get('tenant_id') + assert int(response_tenant_id) == int(user_tenant_id) # Create image for tenant 1 + + headers = factory_auth_header(jwt=jwt, claims=user_1) + headers[TENANT_ID_HEADER] = tenant_1_short_name + rv = client.get('/api/image_info/', headers=headers, content_type=ContentType.JSON.value) + assert rv.json.get('total') == 1 # Assert user 1 can see image + + headers = factory_auth_header(jwt=jwt, claims=user_2) + headers[TENANT_ID_HEADER] = tenant_2_short_name + rv = client.get('/api/image_info/', headers=headers, content_type=ContentType.JSON.value) + assert rv.json.get('total') == 0 # Assert user from different tenant cannot see image diff --git a/met-api/tests/utilities/factory_scenarios.py b/met-api/tests/utilities/factory_scenarios.py index d4adbba02..05b18a05d 100644 --- a/met-api/tests/utilities/factory_scenarios.py +++ b/met-api/tests/utilities/factory_scenarios.py @@ -295,7 +295,8 @@ class TestJwtClaims(dict, Enum): 'toggle_user_status', 'export_to_csv', 'update_user_group', - 'create_tenant' + 'create_tenant', + 'create_images' ] } } @@ -650,3 +651,28 @@ class TestSubscribeInfo(Enum): } ] } + + +class TestImageInfo(dict, Enum): + """Test data for image info.""" + + image_1 = { + 'unique_name': fake.word(), + 'display_name': fake.word(), + 'date_uploaded': (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + } + + image_missing_unique_name = { + 'display_name': fake.word(), + 'date_uploaded': (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + } + + image_missing_display_name = { + 'unique_name': fake.word(), + 'date_uploaded': (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + } + + image_missing_date_uploaded_name = { + 'unique_name': fake.word(), + 'display_name': (datetime.today() - timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S.%f')[:-3], + } diff --git a/met-web/src/apiManager/endpoints/index.ts b/met-web/src/apiManager/endpoints/index.ts index 366337c76..2c6479317 100644 --- a/met-web/src/apiManager/endpoints/index.ts +++ b/met-web/src/apiManager/endpoints/index.ts @@ -154,6 +154,10 @@ const Endpoints = { CREATE: `${AppConfig.apiUrl}/engagements/engagement_id/cacform/widget_id`, GET_SHEET: `${AppConfig.apiUrl}/engagements/engagement_id/cacform/sheet`, }, + Images: { + GET: `${AppConfig.apiUrl}/image_info/`, + CREATE: `${AppConfig.apiUrl}/image_info/`, + }, }; export default Endpoints; diff --git a/met-web/src/components/image/listing/ImageContext.tsx b/met-web/src/components/image/listing/ImageContext.tsx new file mode 100644 index 000000000..cc542a830 --- /dev/null +++ b/met-web/src/components/image/listing/ImageContext.tsx @@ -0,0 +1,150 @@ +import React, { createContext, useEffect, useState } from 'react'; +import { ImageInfo } from 'models/image'; +import { saveObject } from 'services/objectStorageService'; +import { getImages, postImage } from 'services/imageService'; +import { useLocation } from 'react-router-dom'; +import { createDefaultPageInfo, PageInfo, PaginationOptions } from 'components/common/Table/types'; +import { updateURLWithPagination } from 'components/common/Table/utils'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { useAppDispatch } from 'hooks'; + +export interface ImageListingContext { + images: ImageInfo[]; + handleUploadImage: (_files: File[]) => void; + searchText: string; + setSearchText: (value: string) => void; + paginationOptions: PaginationOptions; + setPaginationOptions: (value: PaginationOptions) => void; + pageInfo: PageInfo; + setPageInfo: (value: PageInfo) => void; + tableLoading: boolean; + imageToDisplay: ImageInfo | undefined; +} + +export const ImageContext = createContext({ + images: [], + handleUploadImage: (_files: File[]) => { + /* empty default method */ + }, + searchText: '', + setSearchText: () => { + /* empty default method */ + }, + paginationOptions: { + page: 1, + size: 10, + sort_key: 'date_uploaded', + sort_order: 'desc', + }, + setPaginationOptions: () => { + /* empty default method */ + }, + pageInfo: createDefaultPageInfo(), + setPageInfo: () => { + /* empty default method */ + }, + tableLoading: false, + imageToDisplay: undefined, +}); + +export const ImageProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const pageFromURL = searchParams.get('page'); + const sizeFromURL = searchParams.get('size'); + const [paginationOptions, setPaginationOptions] = useState>({ + page: Number(pageFromURL) || 1, + size: Number(sizeFromURL) || 10, + sort_key: 'date_uploaded', + sort_order: 'desc', + }); + + const [imageToDisplay, setImageToDisplay] = useState(); + const [searchText, setSearchText] = useState(''); + const [tableLoading, setTableLoading] = useState(true); + const [pageInfo, setPageInfo] = useState(createDefaultPageInfo()); + const [images, setImages] = useState>([]); + const dispatch = useAppDispatch(); + const { page, size, sort_key, sort_order } = paginationOptions; + + const handleUploadImage = async (files: File[]) => { + if (files.length > 0) { + const [uniquefilename, fileName]: string[] = (await handleSaveImage(files[0])) || []; + createImage(uniquefilename, fileName); + } + }; + + const handleSaveImage = async (file: File) => { + if (!file) { + return; + } + try { + const savedDocumentDetails = await saveObject(file, { filename: file.name }); + return [savedDocumentDetails?.uniquefilename, savedDocumentDetails?.filename]; + } catch (error) { + console.log(error); + throw new Error('Error occurred during image upload'); + } + }; + + const fetchImages = async () => { + try { + setTableLoading(true); + const response = await getImages({ + page, + size, + sort_key, + sort_order, + search_text: searchText, + }); + setImages(response.items); + setPageInfo({ + total: response.total, + }); + setTableLoading(false); + } catch (err) { + dispatch(openNotification({ severity: 'error', text: 'Error occurred while fetching images' })); + setTableLoading(false); + } + }; + + const createImage = async (unqiueFilename: string, fileName: string) => { + setImageToDisplay(undefined); + const date_uploaded = new Date(); + try { + const image: ImageInfo = await postImage({ + unique_name: unqiueFilename, + display_name: fileName, + date_uploaded, + }); + setPaginationOptions({ page: 1, size: 10 }); + setImageToDisplay(image); + } catch (err) { + dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating image' })); + } + }; + + useEffect(() => { + updateURLWithPagination(paginationOptions); + fetchImages(); + }, [paginationOptions]); + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/image/listing/ImageListing.tsx b/met-web/src/components/image/listing/ImageListing.tsx new file mode 100644 index 000000000..5818f5b0c --- /dev/null +++ b/met-web/src/components/image/listing/ImageListing.tsx @@ -0,0 +1,195 @@ +import React, { useContext } from 'react'; +import Grid from '@mui/material/Grid'; +import { ImageContext } from './ImageContext'; +import { HeaderTitle, MetPageGridContainer, MetParagraph, PrimaryButton } from 'components/common'; +import ImageUpload from '../../imageUpload'; +import { IconButton, Stack, TextField } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import MetTable from 'components/common/Table'; +import { HeadCell, PaginationOptions } from 'components/common/Table/types'; +import { ImageInfo } from 'models/image'; +import ContentCopyIcon from '@mui/icons-material/ContentCopy'; +import { Else, If, Then } from 'react-if'; +import { openNotification } from 'services/notificationService/notificationSlice'; +import { useAppDispatch } from 'hooks'; + +const ImageListing = () => { + const { + images, + handleUploadImage, + paginationOptions, + searchText, + setSearchText, + tableLoading, + pageInfo, + setPaginationOptions, + imageToDisplay, + } = useContext(ImageContext); + + const dispatch = useAppDispatch(); + + const copyToClipBoard = (text: string) => { + navigator.clipboard.writeText(text); + dispatch(openNotification({ severity: 'success', text: 'URL copied to clipboard' })); + }; + + const headCells: HeadCell[] = [ + { + key: 'display_name', + label: '', + disablePadding: true, + allowSort: true, + numeric: false, + renderCell: (row: ImageInfo) => { + return ( + + ); + }, + }, + { + key: 'display_name', + label: 'File Name', + disablePadding: true, + allowSort: true, + numeric: false, + }, + { + key: 'date_uploaded', + label: 'Date Uploaded', + disablePadding: true, + allowSort: true, + numeric: false, + renderCell: (row: ImageInfo) => { + const date = new Date(row.date_uploaded); + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Months are 0-indexed, pad with leading zero + const day = date.getDate().toString().padStart(2, '0'); // Pad with leading zero + return ( + + {`${year}-${month}-${day}`} + + ); + }, + }, + { + key: 'url', + label: 'Image URL', + disablePadding: true, + allowSort: true, + numeric: false, + renderCell: (row: ImageInfo) => ( + + {row.url} + + copyToClipBoard(row.url)}> + + + + + ), + }, + ]; + + return ( + + + Image URL Generator + + + + + + + + + + {imageToDisplay?.display_name} has been successfully uploaded + + + + + + {imageToDisplay?.url} + copyToClipBoard(imageToDisplay?.url ?? '')}> + + + + + + + + + + + + Uploaded Files + + + + setSearchText(e.target.value)} + size="small" + /> + setPaginationOptions({ page: 1, size: 10 })}> + + + + + + ) => + setPaginationOptions(paginationOptions) + } + paginationOptions={paginationOptions} + loading={tableLoading} + pageInfo={pageInfo} + /> + + + ); +}; + +export default ImageListing; diff --git a/met-web/src/components/image/listing/index.tsx b/met-web/src/components/image/listing/index.tsx new file mode 100644 index 000000000..ad848feaf --- /dev/null +++ b/met-web/src/components/image/listing/index.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { ImageProvider } from './ImageContext'; +import ImageListing from './ImageListing'; + +const Images = () => { + return ( + + + + ); +}; + +export default Images; diff --git a/met-web/src/components/imageUpload/Uploader.tsx b/met-web/src/components/imageUpload/Uploader.tsx index ea7534f56..7ba0dc6d1 100644 --- a/met-web/src/components/imageUpload/Uploader.tsx +++ b/met-web/src/components/imageUpload/Uploader.tsx @@ -1,137 +1,142 @@ -import React, { useEffect, useContext } from 'react'; -import { Grid, Stack, Typography } from '@mui/material'; -import Dropzone, { Accept } from 'react-dropzone'; -import { PrimaryButton, SecondaryButton } from 'components/common'; -import { ImageUploadContext } from './imageUploadContext'; - -interface UploaderProps { - margin?: number; - helpText?: string; - height?: string; - accept?: Accept; -} -const Uploader = ({ - margin = 2, - helpText = 'Drag and drop some files here, or click to select files', - height = '10em', - accept = {}, -}: UploaderProps) => { - const { - handleAddFile, - addedImageFileUrl, - setAddedImageFileUrl, - setAddedImageFileName, - existingImageUrl, - setExistingImageURL, - setCropModalOpen, - imgAfterCrop, - setImgAfterCrop, - } = useContext(ImageUploadContext); - - useEffect(() => { - return () => { - if (addedImageFileUrl) { - URL.revokeObjectURL(addedImageFileUrl); - } - }; - }, []); - - const existingImage = imgAfterCrop || addedImageFileUrl || existingImageUrl; - - if (existingImage) { - return ( - - - { - URL.revokeObjectURL(addedImageFileUrl); - setExistingImageURL(''); - setAddedImageFileUrl(''); - setAddedImageFileName(''); - setImgAfterCrop(''); - }} - /> - - - - { - setAddedImageFileUrl(''); - setAddedImageFileName(''); - setExistingImageURL(''); - setImgAfterCrop(''); - handleAddFile([]); - URL.revokeObjectURL(addedImageFileUrl); - }} - size="small" - > - Remove - - { - setCropModalOpen(true); - }} - size="small" - > - Crop - - - - - ); - } - return ( - { - if (acceptedFiles.length === 0) return; - const createdObjectURL = URL.createObjectURL(acceptedFiles[0]); - handleAddFile(acceptedFiles); - setAddedImageFileUrl(createdObjectURL); - setAddedImageFileName(acceptedFiles[0].name); - }} - accept={accept} - > - {({ getRootProps, getInputProps }) => ( -
- - - {helpText} - -
- )} -
- ); -}; - -export default Uploader; +import React, { useEffect, useContext } from 'react'; +import { Grid, Stack, Typography } from '@mui/material'; +import Dropzone, { Accept } from 'react-dropzone'; +import { PrimaryButton, SecondaryButton } from 'components/common'; +import { ImageUploadContext } from './imageUploadContext'; +import { When } from 'react-if'; + +interface UploaderProps { + margin?: number; + helpText?: string; + height?: string; + accept?: Accept; + canCrop?: boolean; +} +const Uploader = ({ + margin = 2, + helpText = 'Drag and drop some files here, or click to select files', + height = '10em', + accept = {}, + canCrop = true, +}: UploaderProps) => { + const { + handleAddFile, + addedImageFileUrl, + setAddedImageFileUrl, + setAddedImageFileName, + existingImageUrl, + setExistingImageURL, + setCropModalOpen, + imgAfterCrop, + setImgAfterCrop, + } = useContext(ImageUploadContext); + + useEffect(() => { + return () => { + if (addedImageFileUrl) { + URL.revokeObjectURL(addedImageFileUrl); + } + }; + }, []); + + const existingImage = imgAfterCrop || addedImageFileUrl || existingImageUrl; + + if (existingImage) { + return ( + + + { + URL.revokeObjectURL(addedImageFileUrl); + setExistingImageURL(''); + setAddedImageFileUrl(''); + setAddedImageFileName(''); + setImgAfterCrop(''); + }} + /> + + + + { + setAddedImageFileUrl(''); + setAddedImageFileName(''); + setExistingImageURL(''); + setImgAfterCrop(''); + handleAddFile([]); + URL.revokeObjectURL(addedImageFileUrl); + }} + size="small" + > + Remove + + + { + setCropModalOpen(true); + }} + size="small" + > + Crop + + + + + + ); + } + return ( + { + if (acceptedFiles.length === 0) return; + const createdObjectURL = URL.createObjectURL(acceptedFiles[0]); + handleAddFile(acceptedFiles); + setAddedImageFileUrl(createdObjectURL); + setAddedImageFileName(acceptedFiles[0].name); + }} + accept={accept} + > + {({ getRootProps, getInputProps }) => ( +
+ + + {helpText} + +
+ )} +
+ ); +}; + +export default Uploader; diff --git a/met-web/src/components/imageUpload/cropImage.tsx b/met-web/src/components/imageUpload/cropImage.tsx index 1558a96e4..3307dea21 100644 --- a/met-web/src/components/imageUpload/cropImage.tsx +++ b/met-web/src/components/imageUpload/cropImage.tsx @@ -1,86 +1,86 @@ -import { Area } from 'react-easy-crop'; -export const createImage = (url: string) => - new Promise((resolve, reject) => { - const image = new Image(); - image.addEventListener('load', () => resolve(image)); - image.addEventListener('error', (error) => reject(error)); - image.setAttribute('crossOrigin', 'anonymous'); - image.src = url; - }); - -export function getRadianAngle(degreeValue: number) { - return (degreeValue * Math.PI) / 180; -} - -/** - * Returns the new bounding area of a rotated rectangle. - */ -export function rotateSize(width: number, height: number, rotation: number) { - const rotRad = getRadianAngle(rotation); - - return { - width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), - height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), - }; -} - -/** - * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop - */ -export default async function getCroppedImg( - imageSrc: string, - pixelCrop: Area, - rotation = 0, - flip = { horizontal: false, vertical: false }, -) { - const image = await createImage(imageSrc); - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - - if (!ctx) { - return null; - } - - const rotRad = getRadianAngle(rotation); - - // calculate bounding box of the rotated image - const { width: bBoxWidth, height: bBoxHeight } = rotateSize(image.width, image.height, rotation); - - // set canvas size to match the bounding box - canvas.width = bBoxWidth; - canvas.height = bBoxHeight; - - // translate canvas context to a central location to allow rotating and flipping around the center - ctx.translate(bBoxWidth / 2, bBoxHeight / 2); - ctx.rotate(rotRad); - ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); - ctx.translate(-image.width / 2, -image.height / 2); - - // draw rotated image - ctx.drawImage(image, 0, 0); - - // croppedAreaPixels values are bounding box relative - // extract the cropped image using these values - const data = ctx.getImageData(pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height); - - // set canvas width to final desired crop size - this will clear existing context - canvas.width = pixelCrop.width; - canvas.height = pixelCrop.height; - - // paste generated rotate image at the top left corner - ctx.putImageData(data, 0, 0); - - // As Base64 string - // return canvas.toDataURL('image/jpeg'); - - // As a blob - return new Promise((resolve, reject) => { - canvas.toBlob((file) => { - if (!file) { - reject('Bad value for file'); - return; - } - resolve(file); - }, 'image/jpeg'); - }); -} +import { Area } from 'react-easy-crop'; +export const createImage = (url: string) => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', (error) => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export function getRadianAngle(degreeValue: number) { + return (degreeValue * Math.PI) / 180; +} + +/** + * Returns the new bounding area of a rotated rectangle. + */ +export function rotateSize(width: number, height: number, rotation: number) { + const rotRad = getRadianAngle(rotation); + + return { + width: Math.abs(Math.cos(rotRad) * width) + Math.abs(Math.sin(rotRad) * height), + height: Math.abs(Math.sin(rotRad) * width) + Math.abs(Math.cos(rotRad) * height), + }; +} + +/** + * This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop + */ +export default async function getCroppedImg( + imageSrc: string, + pixelCrop: Area, + rotation = 0, + flip = { horizontal: false, vertical: false }, +) { + const image = await createImage(imageSrc); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + if (!ctx) { + return null; + } + + const rotRad = getRadianAngle(rotation); + + // calculate bounding box of the rotated image + const { width: bBoxWidth, height: bBoxHeight } = rotateSize(image.width, image.height, rotation); + + // set canvas size to match the bounding box + canvas.width = bBoxWidth; + canvas.height = bBoxHeight; + + // translate canvas context to a central location to allow rotating and flipping around the center + ctx.translate(bBoxWidth / 2, bBoxHeight / 2); + ctx.rotate(rotRad); + ctx.scale(flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1); + ctx.translate(-image.width / 2, -image.height / 2); + + // draw rotated image + ctx.drawImage(image, 0, 0); + + // croppedAreaPixels values are bounding box relative + // extract the cropped image using these values + const data = ctx.getImageData(pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height); + + // set canvas width to final desired crop size - this will clear existing context + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + // paste generated rotate image at the top left corner + ctx.putImageData(data, 0, 0); + + // As Base64 string + // return canvas.toDataURL('image/jpeg'); + + // As a blob + return new Promise((resolve, reject) => { + canvas.toBlob((file) => { + if (!file) { + reject('Bad value for file'); + return; + } + resolve(file); + }, 'image/jpeg'); + }); +} diff --git a/met-web/src/components/imageUpload/cropModal.tsx b/met-web/src/components/imageUpload/cropModal.tsx index 44a914d79..c925802e5 100644 --- a/met-web/src/components/imageUpload/cropModal.tsx +++ b/met-web/src/components/imageUpload/cropModal.tsx @@ -1,116 +1,116 @@ -import React, { useContext, useState } from 'react'; -import Modal from '@mui/material/Modal'; -import { Container, Grid, Paper } from '@mui/material'; -import { MetDescription, modalStyle, PrimaryButton } from 'components/common'; -import Cropper, { Area } from 'react-easy-crop'; -import { ImageUploadContext } from './imageUploadContext'; -import { Box } from '@mui/system'; -import getCroppedImg from './cropImage'; -import { blobToFile } from 'utils'; - -export const CropModal = () => { - const { - existingImageUrl, - addedImageFileUrl, - setImgAfterCrop, - cropModalOpen, - setCropModalOpen, - handleAddFile, - savedImageName, - addedImageFileName, - cropAspectRatio, - } = useContext(ImageUploadContext); - - const currentImageUrl = addedImageFileUrl || `${existingImageUrl}?dummy-variable`; - - const [crop, setCrop] = useState({ x: 0, y: 0 }); - const [zoom, setZoom] = useState(1); - const [croppedArea, setCroppedArea] = useState(null); - - const handleCropDone = async (imgCroppedArea: Area | null) => { - if (!imgCroppedArea) { - return; - } - const croppedImage = await getCroppedImg(currentImageUrl, imgCroppedArea); - if (!croppedImage) { - return; - } - setImgAfterCrop(URL.createObjectURL(croppedImage)); - const imageFile = blobToFile(croppedImage, addedImageFileName || savedImageName); - handleAddFile([imageFile]); - setCropModalOpen(false); - }; - - return ( - { - setCropModalOpen(false); - }} - keepMounted={false} - > - - - - { - setCroppedArea(croppedAreaPixels); - }} - style={{ - containerStyle: { - backgroundColor: '#fff', - }, - }} - /> - - - - - - 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. - - - - { - handleCropDone(croppedArea); - }} - > - Save - - - - - - - - ); -}; +import React, { useContext, useState } from 'react'; +import Modal from '@mui/material/Modal'; +import { Container, Grid, Paper } from '@mui/material'; +import { MetDescription, modalStyle, PrimaryButton } from 'components/common'; +import Cropper, { Area } from 'react-easy-crop'; +import { ImageUploadContext } from './imageUploadContext'; +import { Box } from '@mui/system'; +import getCroppedImg from './cropImage'; +import { blobToFile } from 'utils'; + +export const CropModal = () => { + const { + existingImageUrl, + addedImageFileUrl, + setImgAfterCrop, + cropModalOpen, + setCropModalOpen, + handleAddFile, + savedImageName, + addedImageFileName, + cropAspectRatio, + } = useContext(ImageUploadContext); + + const currentImageUrl = addedImageFileUrl || `${existingImageUrl}?dummy-variable`; + + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedArea, setCroppedArea] = useState(null); + + const handleCropDone = async (imgCroppedArea: Area | null) => { + if (!imgCroppedArea) { + return; + } + const croppedImage = await getCroppedImg(currentImageUrl, imgCroppedArea); + if (!croppedImage) { + return; + } + setImgAfterCrop(URL.createObjectURL(croppedImage)); + const imageFile = blobToFile(croppedImage, addedImageFileName || savedImageName); + handleAddFile([imageFile]); + setCropModalOpen(false); + }; + + return ( + { + setCropModalOpen(false); + }} + keepMounted={false} + > + + + + { + setCroppedArea(croppedAreaPixels); + }} + style={{ + containerStyle: { + backgroundColor: '#fff', + }, + }} + /> + + + + + + 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. + + + + { + handleCropDone(croppedArea); + }} + > + Save + + + + + + + + ); +}; diff --git a/met-web/src/components/imageUpload/imageUploadContext.tsx b/met-web/src/components/imageUpload/imageUploadContext.tsx index 79fe5b324..9e6556ab8 100644 --- a/met-web/src/components/imageUpload/imageUploadContext.tsx +++ b/met-web/src/components/imageUpload/imageUploadContext.tsx @@ -1,92 +1,92 @@ -import React, { createContext, useState } from 'react'; - -export interface ImageUploadContextState { - cropModalOpen: boolean; - setCropModalOpen: React.Dispatch>; - handleAddFile: (_files: File[]) => void; - savedImageUrl: string; - savedImageName: string; - addedImageFileUrl: string; - setAddedImageFileUrl: React.Dispatch>; - addedImageFileName: string; - setAddedImageFileName: React.Dispatch>; - existingImageUrl: string; - setExistingImageURL: React.Dispatch>; - imgAfterCrop: string; - setImgAfterCrop: React.Dispatch>; - cropAspectRatio: number; -} - -export const ImageUploadContext = createContext({ - cropModalOpen: false, - setCropModalOpen: () => { - throw new Error('setCropModalOpen not implemented'); - }, - handleAddFile: () => { - throw new Error('handleAddFile not implemented'); - }, - savedImageUrl: '', - savedImageName: '', - addedImageFileUrl: '', - setAddedImageFileUrl: () => { - throw new Error('setAddedImageFileUrl not implemented'); - }, - addedImageFileName: '', - setAddedImageFileName: () => { - throw new Error('setAddedImageFileName not implemented'); - }, - existingImageUrl: '', - setExistingImageURL: () => { - throw new Error('setExistingImageURL not implemented'); - }, - imgAfterCrop: '', - setImgAfterCrop: () => { - throw new Error('setExistingImageURL not implemented'); - }, - cropAspectRatio: 1, -}); - -interface ImageUploadContextProviderProps { - handleAddFile: (_files: File[]) => void; - children: React.ReactNode; - savedImageUrl: string; - savedImageName: string; - cropAspectRatio: number; -} -export const ImageUploadContextProvider = ({ - children, - handleAddFile, - savedImageUrl, - savedImageName, - cropAspectRatio, -}: ImageUploadContextProviderProps) => { - const [cropModalOpen, setCropModalOpen] = useState(false); - const [addedImageFileUrl, setAddedImageFileUrl] = useState(''); - const [addedImageFileName, setAddedImageFileName] = useState(''); - - const [existingImageUrl, setExistingImageURL] = useState(savedImageUrl); - const [imgAfterCrop, setImgAfterCrop] = useState(''); - - return ( - - {children} - - ); -}; +import React, { createContext, useState } from 'react'; + +export interface ImageUploadContextState { + cropModalOpen: boolean; + setCropModalOpen: React.Dispatch>; + handleAddFile: (_files: File[]) => void; + savedImageUrl: string; + savedImageName: string; + addedImageFileUrl: string; + setAddedImageFileUrl: React.Dispatch>; + addedImageFileName: string; + setAddedImageFileName: React.Dispatch>; + existingImageUrl: string; + setExistingImageURL: React.Dispatch>; + imgAfterCrop: string; + setImgAfterCrop: React.Dispatch>; + cropAspectRatio: number; +} + +export const ImageUploadContext = createContext({ + cropModalOpen: false, + setCropModalOpen: () => { + throw new Error('setCropModalOpen not implemented'); + }, + handleAddFile: () => { + throw new Error('handleAddFile not implemented'); + }, + savedImageUrl: '', + savedImageName: '', + addedImageFileUrl: '', + setAddedImageFileUrl: () => { + throw new Error('setAddedImageFileUrl not implemented'); + }, + addedImageFileName: '', + setAddedImageFileName: () => { + throw new Error('setAddedImageFileName not implemented'); + }, + existingImageUrl: '', + setExistingImageURL: () => { + throw new Error('setExistingImageURL not implemented'); + }, + imgAfterCrop: '', + setImgAfterCrop: () => { + throw new Error('setExistingImageURL not implemented'); + }, + cropAspectRatio: 1, +}); + +interface ImageUploadContextProviderProps { + handleAddFile: (_files: File[]) => void; + children: React.ReactNode; + savedImageUrl: string; + savedImageName: string; + cropAspectRatio: number; +} +export const ImageUploadContextProvider = ({ + children, + handleAddFile, + savedImageUrl, + savedImageName, + cropAspectRatio, +}: ImageUploadContextProviderProps) => { + const [cropModalOpen, setCropModalOpen] = useState(false); + const [addedImageFileUrl, setAddedImageFileUrl] = useState(''); + const [addedImageFileName, setAddedImageFileName] = useState(''); + + const [existingImageUrl, setExistingImageURL] = useState(savedImageUrl); + const [imgAfterCrop, setImgAfterCrop] = useState(''); + + return ( + + {children} + + ); +}; diff --git a/met-web/src/components/imageUpload/index.tsx b/met-web/src/components/imageUpload/index.tsx index 7b1e509fd..49f85c7da 100644 --- a/met-web/src/components/imageUpload/index.tsx +++ b/met-web/src/components/imageUpload/index.tsx @@ -1,44 +1,46 @@ -import React from 'react'; -import { CropModal } from './cropModal'; -import { ImageUploadContextProvider } from './imageUploadContext'; -import Uploader from './Uploader'; -import { Accept } from 'react-dropzone'; - -interface UploaderProps { - margin?: number; - handleAddFile: (_files: File[]) => void; - savedImageUrl?: string; - savedImageName?: string; - helpText?: string; - height?: string; - cropAspectRatio?: number; - accept?: Accept; -} -export const ImageUpload = ({ - margin = 2, - 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.', - height = '10em', - cropAspectRatio = 1, - accept = { - 'image/jpeg': [], - 'image/png': [], - 'image/webp': [], - }, -}: UploaderProps) => { - return ( - - - - - ); -}; - -export default ImageUpload; +import React from 'react'; +import { CropModal } from './cropModal'; +import { ImageUploadContextProvider } from './imageUploadContext'; +import Uploader from './Uploader'; +import { Accept } from 'react-dropzone'; + +interface UploaderProps { + margin?: number; + handleAddFile: (_files: File[]) => void; + savedImageUrl?: string; + savedImageName?: string; + helpText?: string; + height?: string; + cropAspectRatio?: number; + accept?: Accept; + canCrop?: boolean; +} +export const ImageUpload = ({ + margin = 2, + 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.', + height = '10em', + cropAspectRatio = 1, + accept = { + 'image/jpeg': [], + 'image/png': [], + 'image/webp': [], + }, + canCrop = true, +}: UploaderProps) => { + return ( + + + + + ); +}; + +export default ImageUpload; diff --git a/met-web/src/components/layout/SideNav/SideNavElements.tsx b/met-web/src/components/layout/SideNav/SideNavElements.tsx index 7c7368cfe..dcefbf5a4 100644 --- a/met-web/src/components/layout/SideNav/SideNavElements.tsx +++ b/met-web/src/components/layout/SideNav/SideNavElements.tsx @@ -38,4 +38,11 @@ export const Routes: Route[] = [ authenticated: true, allowedRoles: [USER_ROLES.VIEW_FEEDBACKS], }, + { + name: 'Images', + path: '/images', + base: 'images', + authenticated: true, + allowedRoles: [USER_ROLES.CREATE_IMAGES], + }, ]; diff --git a/met-web/src/models/image.ts b/met-web/src/models/image.ts new file mode 100644 index 000000000..f398a3c7d --- /dev/null +++ b/met-web/src/models/image.ts @@ -0,0 +1,7 @@ +export interface ImageInfo { + id: number; + display_name: string; + unique_name: string; + date_uploaded: string; + url: string; +} diff --git a/met-web/src/routes/AuthenticatedRoutes.tsx b/met-web/src/routes/AuthenticatedRoutes.tsx index be1740c52..e9b547018 100644 --- a/met-web/src/routes/AuthenticatedRoutes.tsx +++ b/met-web/src/routes/AuthenticatedRoutes.tsx @@ -24,6 +24,7 @@ import UserProfile from 'components/userManagement/userDetails'; import ScrollToTop from 'components/scrollToTop'; import ReportSettings from 'components/survey/report'; import FormioListener from 'components/FormioListener'; +import Images from 'components/image/listing'; const AuthenticatedRoutes = () => { return ( @@ -70,6 +71,9 @@ const AuthenticatedRoutes = () => { }> } /> + }> + } /> + } /> } /> diff --git a/met-web/src/services/imageService/index.tsx b/met-web/src/services/imageService/index.tsx new file mode 100644 index 000000000..88b9168fd --- /dev/null +++ b/met-web/src/services/imageService/index.tsx @@ -0,0 +1,36 @@ +import http from 'apiManager/httpRequestHandler'; +import { Page } from 'services/type'; +import { ImageInfo } from 'models/image'; +import Endpoints from 'apiManager/endpoints'; + +interface GetImageParams { + page?: number; + size?: number; + sort_key?: string; + sort_order?: 'asc' | 'desc'; + search_text?: string; +} + +interface PostImageParams { + unique_name: string; + display_name: string; + date_uploaded: Date; +} + +export const getImages = async (params: GetImageParams = {}): Promise> => { + const responseData = await http.GetRequest>(Endpoints.Images.GET, params); + return ( + responseData.data ?? { + items: [], + total: 0, + } + ); +}; + +export const postImage = async (data: PostImageParams): Promise => { + const response = await http.PostRequest(Endpoints.Images.CREATE, data); + if (response.data) { + return response.data; + } + return Promise.reject('Failed to create image'); +}; diff --git a/met-web/src/services/userService/constants.ts b/met-web/src/services/userService/constants.ts index 31b2b658a..866c71a57 100644 --- a/met-web/src/services/userService/constants.ts +++ b/met-web/src/services/userService/constants.ts @@ -39,4 +39,5 @@ export const USER_ROLES = { EXPORT_ALL_TO_CSV: 'export_all_to_csv', EXPORT_INTERNAL_COMMENT_SHEET: 'export_internal_comment_sheet', EXPORT_PROPONENT_COMMENT_SHEET: 'export_proponent_comment_sheet', + CREATE_IMAGES: 'create_images', }; diff --git a/met-web/tests/unit/components/sidenav.test.tsx b/met-web/tests/unit/components/sidenav.test.tsx index ead3672f1..7a0b8b5a8 100644 --- a/met-web/tests/unit/components/sidenav.test.tsx +++ b/met-web/tests/unit/components/sidenav.test.tsx @@ -25,6 +25,7 @@ jest.mock('react-redux', () => ({ USER_ROLES.VIEW_SURVEYS, USER_ROLES.VIEW_USERS, USER_ROLES.VIEW_FEEDBACKS, + USER_ROLES.CREATE_IMAGES, ]; }), }));